limn-review 0.1.180 → 0.1.181

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 (30) hide show
  1. package/dist/api/plugins/rate-limit.js +5 -0
  2. package/dist/api/routes/me.js +18 -1
  3. package/dist/db/migrations/0164_teammate_block_lift.sql +7 -0
  4. package/dist/db/migrations/meta/_journal.json +7 -0
  5. package/dist/db/my-turn-pushed-since.js +74 -0
  6. package/dist/db/schema.sqlite.js +5 -0
  7. package/dist/db/teammate-reviews-import.js +18 -0
  8. package/dist/db/teammate-reviews.js +14 -0
  9. package/dist/review/claude-review/auto-post.js +98 -8
  10. package/dist/review/claude-review/auto-settings.js +32 -2
  11. package/dist/review/claude-review/manager.js +21 -3
  12. package/dist/review/claude-review/persist.js +39 -3
  13. package/dist/review/claude-review/post-meta.js +100 -1
  14. package/dist/review/claude-review/routes.js +9 -2
  15. package/dist/review/claude-review/team-view.js +550 -31
  16. package/dist/review/claude-review/teammate-anchor.js +80 -9
  17. package/dist/review/claude-review/teammate-lift.js +231 -0
  18. package/dist/review/post-seam.js +2 -0
  19. package/dist/shared/limn-review-meta.js +48 -1
  20. package/dist/shared/pending-rules.js +7 -0
  21. package/dist/shared/types.js +2 -0
  22. package/package.json +1 -1
  23. package/public/assets/{AiFixTab-C_4GZ1f7.js → AiFixTab-uoyOe44d.js} +1 -1
  24. package/public/assets/ClaudeReviewTab-NiqDVtMN.js +20 -0
  25. package/public/assets/{PrBotBehaviourTab-BS6_D2CB.js → PrBotBehaviourTab-Dl_TppS2.js} +1 -1
  26. package/public/assets/{index-CPNM_C-u.js → index-B-MIQUya.js} +57 -57
  27. package/public/assets/index-DG7ITfra.css +1 -0
  28. package/public/index.html +2 -2
  29. package/public/assets/ClaudeReviewTab-kbXqq0_E.js +0 -20
  30. package/public/assets/index-KI5WT9Ev.css +0 -1
@@ -177,6 +177,11 @@ function tierFor(method, path) {
177
177
  // EXACT `===` for the same near-miss.
178
178
  if (path === '/api/me/my-turn-settings')
179
179
  return [TIERS.read];
180
+ // GET /api/my-turn/pushed-since/:prId — the expanded "N commits since you …" list on a Pushed
181
+ // since card. FOLLOWING THE TOKEN: two DB reads over synced `commits`/`reviews`/comments, no
182
+ // GitHub call, no model. Recorded, EXACT pattern, so a future live variant is not inherited.
183
+ if (/^\/api\/my-turn\/pushed-since\/\d+$/.test(path))
184
+ return [TIERS.read];
180
185
  // PUT /api/me/feature-settings — Settings → Features (which sidebar destinations show). One
181
186
  // validated UPDATE plus DB-only reads; no GitHub, no model. Recorded, EXACT `===`.
182
187
  if (path === '/api/me/feature-settings')
@@ -15,7 +15,8 @@ import { codexDetailCached } from '../../ai/codex/detect.js';
15
15
  import { usageLockedUntilIso } from '../../ai/usage-lockout.js';
16
16
  import { aiEffortSettingStatus } from '../../ai/effort.js';
17
17
  import { aiProviderSettingStatus, ALL_RUN_KINDS, CODEX_NOT_AVAILABLE_MESSAGE, CODEX_RUNNABLE, getActiveProvider, } from '../../ai/provider.js';
18
- import { getMyTurn } from '../../db/queries.js';
18
+ import { getMyTurn, resolveWorkspaceScope } from '../../db/queries.js';
19
+ import { getPushedSinceCommits } from '../../db/my-turn-pushed-since.js';
19
20
  import { clearDailyBriefCountsFor } from '../../db/daily-brief.js';
20
21
  import { dismissMyTurn, restoreMyTurn } from '../../db/my-turn-dismissals.js';
21
22
  import { featureFacts } from '../../db/feature-gates.js';
@@ -425,6 +426,22 @@ export async function meRoutes(app) {
425
426
  // The account-wide inbox. Unscoped on purpose (see `getMyTurn`): the browser-notification
426
427
  // watcher reads exactly this call — and without `onePerPr`, which is the board's rule.
427
428
  app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
429
+ // EVERY commit a "Pushed since" card counts — its expanded "N commits since you …" list. The
430
+ // SAME fold as the count (db/my-turn-pushed-since.ts). DB only; the SPA fetches it only when the
431
+ // reader expands the card. Scoped by `?workspace=` (absent/unknown ⇒ the default); a PR outside
432
+ // the account or the workspace answers 404, so the route is no existence oracle.
433
+ app.get('/api/my-turn/pushed-since/:prId', async (req, reply) => {
434
+ const raw = req.params.prId ?? '';
435
+ if (!/^[1-9][0-9]{0,15}$/.test(raw)) {
436
+ return reply.code(400).send({ error: 'BadRequest', message: 'Unknown pull request.' });
437
+ }
438
+ const accountId = accountIdOf(req);
439
+ const scope = await resolveWorkspaceScope(accountId, req.query.workspace);
440
+ const body = await getPushedSinceCommits(accountId, Number(raw), scope.workspaceId, scope.repoIds);
441
+ if (body == null)
442
+ return reply.code(404).send({ error: 'NotFound', message: 'Not found' });
443
+ return body;
444
+ });
428
445
  // DISMISS / BRING BACK one My Turn subject — a pull request (`pr`) or, for a red default branch,
429
446
  // a repository (`repo`). ⚠ NOT the "Done" routes 0060 deleted: a dismissal lasts only until
430
447
  // something newer happens on the subject, and a subject that leaves your plate discharges it
@@ -0,0 +1,7 @@
1
+ -- LIFTING A TEAMMATE'S LIMN BLOCK (docs/CLAUDE-REVIEW.md § Lifting a teammate's block). On an imported
2
+ -- teammate review (`claude_reviews.kind = 'teammate'`), the reader's Limn's ONE attempt to dismiss that
3
+ -- teammate's standing Request changes: JSON `TeammateLiftRecord` — claimed (NULL → 'dismissing') by a
4
+ -- compare-and-set BEFORE the GitHub write, then settled 'dismissed' or 'failed' with GitHub's words.
5
+ -- Never retried: a row that is not NULL is never claimed again. The teammate-review import never writes
6
+ -- it. NULL on every run of the reader's own. Additive and nullable: no backfill.
7
+ ALTER TABLE `claude_reviews` ADD `teammate_lift` text;
@@ -1149,6 +1149,13 @@
1149
1149
  "when": 1792224069016,
1150
1150
  "tag": "0163_pr_cross_repository",
1151
1151
  "breakpoints": true
1152
+ },
1153
+ {
1154
+ "idx": 164,
1155
+ "version": "6",
1156
+ "when": 1792224071016,
1157
+ "tag": "0164_teammate_block_lift",
1158
+ "breakpoints": true
1152
1159
  }
1153
1160
  ]
1154
1161
  }
@@ -0,0 +1,74 @@
1
+ import { and, eq, inArray } from 'drizzle-orm';
2
+ import { PUSHED_SINCE_LIST_MAX, } from '../shared/index.js';
3
+ import { db, schema } from './client.js';
4
+ import { lastActionClocks } from './queries.js';
5
+ import { getAccountUserId } from '../auth/account.js';
6
+ const { commits, pullRequests } = schema;
7
+ // ── "N COMMITS SINCE YOU …" — THE EXPANDED LIST ON A "PUSHED SINCE" MY TURN CARD ───────────────
8
+ //
9
+ // The card carries the count (`NewPrBall.humanCommitsAfter`) and the newest `PUSHED_COMMITS_SHOWN`.
10
+ // When the reader opens the card's disclosure, this lists EVERY commit that count names. It runs
11
+ // the SAME fold (`lastActionClocks`) on the one PR, so the list and the count cannot disagree:
12
+ // human commits by someone other than you, strictly after your last review (any state), review
13
+ // comment, PR comment or push. NOT "since you last looked" — viewing is not acting (`pr_views` is
14
+ // never read here, as in the fold). DB only; ⚠ fetched only on expand, never on the board's mount.
15
+ /** null ⇔ the PR is not this account's, or not in the workspace's repos (→ 404). */
16
+ export async function getPushedSinceCommits(accountId, prId, workspaceId, repoIds, listMax = PUSHED_SINCE_LIST_MAX) {
17
+ if (repoIds.length === 0)
18
+ return null;
19
+ const pr = await db
20
+ .select({ id: pullRequests.id })
21
+ .from(pullRequests)
22
+ .where(and(eq(pullRequests.id, prId), eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, [...repoIds])))
23
+ .limit(1)
24
+ .execute();
25
+ if (pr.length === 0)
26
+ return null;
27
+ const empty = {
28
+ workspaceId,
29
+ prId,
30
+ yourLastAction: null,
31
+ anchorAt: null,
32
+ total: 0,
33
+ commits: [],
34
+ };
35
+ const viewerId = await getAccountUserId(accountId);
36
+ if (viewerId == null)
37
+ return empty;
38
+ const c = (await lastActionClocks(viewerId, [prId])).get(prId);
39
+ if (!c || c.mineLast == null)
40
+ return empty;
41
+ const counted = [...c.humanCommitIdsAfterMine].sort((a, b) => b.at.getTime() - a.at.getTime() || b.id - a.id);
42
+ const shownIds = counted.slice(0, Math.max(0, listMax)).map((x) => x.id);
43
+ let list = [];
44
+ if (shownIds.length > 0) {
45
+ const rows = await db
46
+ .select({
47
+ id: commits.id,
48
+ sha: commits.sha,
49
+ headline: commits.messageHeadline,
50
+ authorId: commits.authorId,
51
+ at: commits.committedAt,
52
+ })
53
+ .from(commits)
54
+ .where(and(eq(commits.prId, prId), inArray(commits.id, shownIds)))
55
+ .execute();
56
+ list = rows
57
+ .sort((a, b) => b.at.getTime() - a.at.getTime() || b.id - a.id)
58
+ .map((r) => ({
59
+ sha: r.sha,
60
+ headline: r.headline ?? null,
61
+ authorId: r.authorId,
62
+ at: r.at.toISOString(),
63
+ }));
64
+ }
65
+ return {
66
+ workspaceId,
67
+ prId,
68
+ yourLastAction: c.mineLastAction,
69
+ anchorAt: c.mineLast.toISOString(),
70
+ total: c.humanCommitsAfterMine,
71
+ commits: list,
72
+ };
73
+ }
74
+ //# sourceMappingURL=my-turn-pushed-since.js.map
@@ -1235,6 +1235,11 @@ export const claudeReviews = sqliteTable('claude_reviews', {
1235
1235
  sourceUserId: integer('source_user_id').references(() => users.id),
1236
1236
  sourceReviewNodeId: text('source_review_node_id'),
1237
1237
  teammate: text('teammate', { mode: 'json' }).$type(),
1238
+ // On a TEAMMATE review only (migration 0164; review/claude-review/teammate-lift.ts): the reader's
1239
+ // Limn's ONE attempt to dismiss this teammate's standing Request changes. Claimed NULL →
1240
+ // 'dismissing' BEFORE the GitHub write, settled 'dismissed' / 'failed'; never retried. The import
1241
+ // never writes it.
1242
+ teammateLift: text('teammate_lift', { mode: 'json' }).$type(),
1238
1243
  }, (t) => ({
1239
1244
  prIdx: index('cr_pr_idx').on(t.prId),
1240
1245
  prShaIdx: index('cr_pr_sha_idx').on(t.prId, t.headSha),
@@ -47,6 +47,21 @@ const STATE_VERDICT = {
47
47
  changes_requested: 'REQUEST_CHANGES',
48
48
  commented: 'COMMENT',
49
49
  };
50
+ /** A pair's finding comments → who raised each, from the lead's tag ("Codex", "Claude + Codex"). */
51
+ function pairProviders(parsed) {
52
+ const out = {};
53
+ for (const { c, p } of parsed) {
54
+ const t = (p.tag ?? '').toLowerCase();
55
+ const who = [];
56
+ if (/\bclaude\b/.test(t))
57
+ who.push('claude');
58
+ if (/\bcodex\b/.test(t))
59
+ who.push('codex');
60
+ if (who.length > 0)
61
+ out[c.nodeId] = who;
62
+ }
63
+ return Object.keys(out).length > 0 ? { findingProviders: out } : {};
64
+ }
50
65
  /**
51
66
  * Import every review a teammate's Limn posted on this PR (see the header). Returns what it wrote.
52
67
  * Safe to call after every walk of the PR; one transaction.
@@ -184,6 +199,9 @@ export async function importTeammateReviews(accountId, prId) {
184
199
  ...(parsed.some((x) => x.p.severity == null)
185
200
  ? { unlabelled: parsed.filter((x) => x.p.severity == null).map((x) => x.c.nodeId) }
186
201
  : {}),
202
+ ...(meta.judged != null && meta.judged.length > 0 ? { judged: meta.judged } : {}),
203
+ ...(meta.liftConsent === true ? { liftConsent: true } : {}),
204
+ ...(meta.pair ? pairProviders(parsed) : {}),
187
205
  };
188
206
  const first = meta.runs[0];
189
207
  const values = {
@@ -37,6 +37,7 @@ export async function listTeammateReviews(exec, accountId, prId) {
37
37
  postedAt: cr.postedAt,
38
38
  createdAt: cr.createdAt,
39
39
  teammate: cr.teammate,
40
+ teammateLift: cr.teammateLift,
40
41
  userId: users.id,
41
42
  login: users.githubLogin,
42
43
  displayName: users.displayName,
@@ -85,6 +86,14 @@ export async function listTeammateReviews(exec, accountId, prId) {
85
86
  const rec = (r.teammate ?? null);
86
87
  const unlabelled = new Set(rec?.unlabelled ?? []);
87
88
  const prUrl = `https://github.com/${r.owner}/${r.name}/pull/${r.number}`;
89
+ // Who raised each finding: a pair comment's lead tag, else the single run's provider.
90
+ const single = rec != null && !rec.pair && rec.runs.length === 1 ? rec.runs[0]?.provider ?? null : null;
91
+ const providersOf = (nodeId) => {
92
+ const tagged = nodeId != null ? rec?.findingProviders?.[nodeId] : undefined;
93
+ if (tagged != null && tagged.length > 0)
94
+ return { providers: tagged };
95
+ return single != null ? { providers: [single] } : {};
96
+ };
88
97
  const mine = findings
89
98
  .filter((f) => f.reviewId === r.id)
90
99
  .map((f) => {
@@ -103,6 +112,8 @@ export async function listTeammateReviews(exec, accountId, prId) {
103
112
  ? `${prUrl}#discussion_r${f.githubCommentId}`
104
113
  : `${prUrl}#issuecomment-${f.githubCommentId}`,
105
114
  threadId: kind === 'inline' && f.nodeId != null ? (threadOf.get(f.nodeId) ?? null) : null,
115
+ ...providersOf(f.nodeId),
116
+ githubCommentId: f.githubCommentId ?? null,
106
117
  };
107
118
  });
108
119
  const at = r.postedAt ?? r.createdAt;
@@ -120,6 +131,9 @@ export async function listTeammateReviews(exec, accountId, prId) {
120
131
  runs: rec?.runs ?? [],
121
132
  limnVersion: rec?.limnVersion ?? null,
122
133
  findings: mine,
134
+ ...(rec?.judged != null && rec.judged.length > 0 ? { judged: rec.judged } : {}),
135
+ // The reader's Limn's one attempt to lift this block (migration 0164).
136
+ ...(r.teammateLift != null ? { lift: r.teammateLift } : {}),
123
137
  };
124
138
  });
125
139
  }
@@ -78,8 +78,9 @@ import { resolveFindingBody } from './follow-up.js';
78
78
  import { autoVerdictFor, findingsNotApproved, pairVerdict, reasonMayPost, safeReasonText, withReason, } from './auto-verdict.js';
79
79
  import { aiRunProviderName, autoPostFooter, findingSourceLabel, limnSeverityLabel, PAIR_AUTO_POST_FOOTER, } from '../../shared/index.js';
80
80
  import { pairRaisedByLead, pairReviewBody } from './pair.js';
81
- import { limnMetaForReview } from './post-meta.js';
81
+ import { limnMetaForReview, loadJudgedForPost } from './post-meta.js';
82
82
  import { TEAMMATE_KIND, ownRunsOnly } from '../../db/teammate-reviews.js';
83
+ import { claimTeammateLift, liveLiftCheck, loadTeammateLiftCandidates, settleTeammateLift, teammateLiftMessage, } from './teammate-lift.js';
83
84
  // A pair's body names both when it falls back to a count ("3 comments from Claude and Codex.").
84
85
  const PAIR_BODY_BY = 'Claude and Codex';
85
86
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -171,6 +172,17 @@ export function selectFindingsToPost(findings, kinds, earlier) {
171
172
  }
172
173
  return { toPost, alreadyPosted };
173
174
  }
175
+ /**
176
+ * May this post carry `lift=1` (the poster's consent to a teammate's Limn lifting its block)? Only
177
+ * when every BLOCKER the verdict counts is one of the post's own comments. A blocker already on GitHub
178
+ * from an earlier post is left out of this one, yet still makes it a REQUEST_CHANGES: a teammate's
179
+ * Limn reads the block's points from its comments alone (teammate-lift.ts `pointsBehindBlock`), so
180
+ * it could lift a block whose real reason it never checked.
181
+ */
182
+ export function blockersAllInPost(findings, toPost) {
183
+ const posted = new Set(toPost.map((f) => f.id));
184
+ return findings.every((f) => f.severity !== 'blocker' || f.story != null || isReaderIgnoredFinding(f) || posted.has(f.id));
185
+ }
174
186
  // THE VERDICT RULE (./auto-verdict.ts), re-exported under its old name.
175
187
  export { autoVerdictFor } from './auto-verdict.js';
176
188
  /** May an automatic APPROVE go out at this impact level? ⚠ 'unknown' is judged as HIGH. */
@@ -324,7 +336,7 @@ export const defaultAutoPostDeps = {
324
336
  for (let page = 1; page <= 10; page += 1) {
325
337
  const rows = await ghRestGet(`/repos/${owner}/${name}/pulls/${prNumber}/reviews?per_page=100&page=${page}`);
326
338
  for (const r of rows)
327
- out.push({ id: r.id, login: r.user?.login ?? null, state: r.state });
339
+ out.push({ id: r.id, login: r.user?.login ?? null, state: r.state, body: r.body ?? null });
328
340
  if (rows.length < 100)
329
341
  return out;
330
342
  }
@@ -714,10 +726,35 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
714
726
  // (Read for a REQUEST_CHANGES too: the gate must tell Limn's own standing block from a person's.)
715
727
  const limnBlockIds = !facts.authorIsMe ? await loadLimnAutoBlockIds(ctx, accountId, prId, reviewId) : new Set();
716
728
  const mayLift = noBlocker && unjudgedBlockers.length === 0 && limnBlockIds.size > 0;
729
+ // LIFTING A TEAMMATE'S LIMN BLOCK (./teammate-lift.ts): the DB pre-check first (both opt-ins, every
730
+ // point behind the block settled), so an ordinary run costs no extra GitHub read.
731
+ let readerLogin = null;
732
+ let teammateCandidates = [];
733
+ if (ws.settings.liftTeammateBlocks && !facts.authorIsMe && noBlocker && unjudgedBlockers.length === 0) {
734
+ try {
735
+ readerLogin = await deps.accountLogin(accountId);
736
+ teammateCandidates = await loadTeammateLiftCandidates(ctx, {
737
+ accountId,
738
+ prId,
739
+ reviewId,
740
+ headSha: review.headSha,
741
+ leftovers: rules.approveLeftovers,
742
+ readerLogin,
743
+ });
744
+ }
745
+ catch (err) {
746
+ ctx.log.warn(`auto post review ${reviewId}: could not read teammates' blocks: ${errText(err)}`);
747
+ }
748
+ }
749
+ const mayLiftTeammate = teammateCandidates.length > 0;
717
750
  // A COMMENT-only run with a reason still to tell GitHub goes on (to the reason-only review).
718
751
  // (Never while a review request of the reader is open: any review answers it.)
719
752
  const commentReasonToPost = wanted === 'COMMENT' && reasonMayPost(verdict) && !reasonAlreadyAtHead && facts.openRequestOfMe !== true;
720
- if (picked.toPost.length === 0 && (wanted == null || wanted === 'COMMENT') && !mayLift && !commentReasonToPost) {
753
+ if (picked.toPost.length === 0 &&
754
+ (wanted == null || wanted === 'COMMENT') &&
755
+ !mayLift &&
756
+ !mayLiftTeammate &&
757
+ !commentReasonToPost) {
721
758
  return skip(...nothingReason());
722
759
  }
723
760
  const pctx = await getReviewPostContext(ctx, reviewId, accountId);
@@ -747,13 +784,17 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
747
784
  }
748
785
  const impactHeld = impact != null && !impactAllowsApprove(impact.level, impact.allowed);
749
786
  let standing = { state: 'unreadable', reviewId: null };
750
- if (!facts.authorIsMe && ((wantsVerdict && !impactHeld) || mayLift)) {
787
+ // A teammate block still standing LIVE, with Limn's marker and the consent in its live body.
788
+ let teammateLifts = [];
789
+ if (!facts.authorIsMe && ((wantsVerdict && !impactHeld) || mayLift || mayLiftTeammate)) {
751
790
  try {
752
791
  const [login, reviews] = await Promise.all([
753
792
  deps.accountLogin(accountId),
754
793
  deps.liveReviews(pctx.owner, pctx.name, pctx.prNumber),
755
794
  ]);
795
+ readerLogin = login;
756
796
  standing = ownStandingReview(reviews, login, limnBlockIds);
797
+ teammateLifts = teammateCandidates.filter((c) => liveLiftCheck(reviews, c, login) == null);
757
798
  }
758
799
  catch (err) {
759
800
  ctx.log.warn(`auto post review ${reviewId}: could not read the PR's reviews: ${errText(err)}`);
@@ -793,12 +834,18 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
793
834
  // Limn's own automatic request for changes still stands, this run has no blocker and none behind
794
835
  // the block is left unjudged: lift it — superseded by the APPROVE this run sends, else dismissed.
795
836
  const liftId = noBlocker && !blockersRemain && standing.state === 'LIMN_AUTO_CHANGES_REQUESTED' ? standing.reviewId : null;
796
- if (picked.toPost.length === 0 && (verdict?.submitted ?? 'COMMENT') === 'COMMENT' && liftId == null && !reasonOnly) {
837
+ if (picked.toPost.length === 0 &&
838
+ (verdict?.submitted ?? 'COMMENT') === 'COMMENT' &&
839
+ liftId == null &&
840
+ !reasonOnly &&
841
+ teammateLifts.length === 0) {
797
842
  return skip(...nothingReason());
798
843
  }
799
844
  const event = verdict?.submitted ?? 'COMMENT';
800
- // Nothing but the lift to do: no review is sent at all.
801
- const sendsReview = picked.toPost.length > 0 || event !== 'COMMENT' || reasonOnly;
845
+ // Nothing but the lift of Limn's own block to do: no review is sent at all. Lifting a TEAMMATE'S
846
+ // block always sends the reader's own review (APPROVE when the gate allows it, else COMMENT): it
847
+ // carries the judgements (`j=`) the teammate's Limn reads.
848
+ const sendsReview = picked.toPost.length > 0 || event !== 'COMMENT' || reasonOnly || teammateLifts.length > 0;
802
849
  const record = {
803
850
  status: 'posting',
804
851
  at: nowIso(),
@@ -834,6 +881,8 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
834
881
  first = null;
835
882
  }
836
883
  const pairFirst = first;
884
+ // The earlier Limn findings on GitHub this review judged, for the marker's `j=` (DB only, never throws).
885
+ const judged = await loadJudgedForPost(ctx, accountId, prId, pairFirst != null ? [pairFirst, review] : [review]);
837
886
  // THE PAIR'S ONE REVIEW (pair-consolidate.ts): its summary, and who raised each kept finding. A pair
838
887
  // from before the record reads as the two summaries joined and each half's own provider.
839
888
  const consolidation = pairFirst != null ? await readPairConsolidation(ctx, pairFirst.id, prId).catch(() => null) : null;
@@ -861,6 +910,8 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
861
910
  return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
862
911
  }), withReasonLine(reason, footer));
863
912
  };
913
+ // The poster's consent rides only a post whose comments hold every blocker behind it.
914
+ const liftConsent = ws.settings.teamLiftConsent && blockersAllInPost(allFindings, picked.toPost);
864
915
  const submit = (ev) => ctx.review.postReview({
865
916
  owner: pctx.owner,
866
917
  name: pctx.name,
@@ -879,8 +930,10 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
879
930
  model: r.model || null,
880
931
  effort: r.effort ?? null,
881
932
  })),
933
+ judged,
934
+ liftConsent,
882
935
  }
883
- : limnMetaForReview(review),
936
+ : { ...limnMetaForReview(review), judged, liftConsent },
884
937
  // Questions ride the same review: the seam anchors each inline, else falls back. In a pair each
885
938
  // comment carries its own provider's footer and names who raised it.
886
939
  includedFindings: picked.toPost.map((f) => toPostFinding(f, byOf.get(f.id) ?? aiRunProviderName(review), tagOf(f))),
@@ -969,6 +1022,43 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
969
1022
  }
970
1023
  }
971
1024
  }
1025
+ // A TEAMMATE'S BLOCK, after the reader's review landed at the PR's current head: claim it on the
1026
+ // teammate's row (compare-and-set, once ever), dismiss, record what GitHub said. Never retried.
1027
+ if (teammateLifts.length > 0 && landed && !reviewFailed) {
1028
+ const lifted = [];
1029
+ for (const c of teammateLifts) {
1030
+ const message = teammateLiftMessage(readerLogin ?? 'you', c.points, pctx.reviewHeadSha);
1031
+ const base = { byReviewId: reviewId, headSha: pctx.reviewHeadSha, points: c.points, message };
1032
+ let claimed = false;
1033
+ try {
1034
+ claimed = await claimTeammateLift(ctx, accountId, c.teammateReviewId, base);
1035
+ }
1036
+ catch (err) {
1037
+ ctx.log.warn(`auto post review ${reviewId}: could not claim the teammate lift: ${errText(err)}`);
1038
+ }
1039
+ // Another run tried this block already: never twice.
1040
+ if (!claimed)
1041
+ continue;
1042
+ let res;
1043
+ try {
1044
+ res = deps.dismissReview
1045
+ ? await deps.dismissReview(accountId, pctx.owner, pctx.name, pctx.prNumber, c.githubReviewId, message)
1046
+ : { ok: false, message: 'Dismissing is not available.' };
1047
+ }
1048
+ catch (err) {
1049
+ res = { ok: false, message: errText(err) };
1050
+ }
1051
+ const error = res.ok ? null : res.message.slice(0, 300);
1052
+ const status = res.ok ? 'dismissed' : 'failed';
1053
+ await settleTeammateLift(ctx, accountId, c.teammateReviewId, { ...base, status, at: nowIso(), error }).catch((err) => ctx.log.warn(`auto post review ${reviewId}: could not record the teammate lift: ${errText(err)}`));
1054
+ if (res.ok) {
1055
+ await stampReviewDismissed(ctx, prId, c.githubReviewId).catch((err) => ctx.log.warn(`auto post review ${reviewId}: could not stamp the dismissal: ${errText(err)}`));
1056
+ }
1057
+ lifted.push({ teammateReviewId: c.teammateReviewId, login: c.login, status, error });
1058
+ }
1059
+ if (lifted.length > 0)
1060
+ record.liftedTeammateBlocks = lifted;
1061
+ }
972
1062
  // A run whose only write was the lift: posted when it took, failed (GitHub's words) when not.
973
1063
  const liftOnly = !sendsReview;
974
1064
  const final = {
@@ -1,5 +1,5 @@
1
1
  import { and, eq } from 'drizzle-orm';
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';
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_LIFT_TEAMMATE_BLOCKS, AUTO_POST_DEFAULT_SCOPE, AUTO_POST_DEFAULT_TEAM_LIFT_CONSENT, } from '../../shared/index.js';
3
3
  import { dropAutoReviews } from './manager.js';
4
4
  import { resolveAutoReviewProviders, storedAutoReviewProviders } from './pair.js';
5
5
  import { DEFAULT_VERDICT_RULES, resolveApproveLeftovers, resolvePairStrictness, } from './auto-verdict.js';
@@ -42,6 +42,8 @@ import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
42
42
  // while auto review is off, but switching auto review off and on keeps it. The `autoVerdict` and
43
43
  // `autoResolve` switches (both OFF by default) ride the same overrides JSON (no column), and so
44
44
  // does `approveImpact` (which blast-radius levels an auto verdict may APPROVE; all by default).
45
+ // So do the two TEAMMATE-LIFT opt-ins, `teamLiftConsent` and `liftTeammateBlocks` (both OFF by
46
+ // default; docs/CLAUDE-REVIEW.md § Lifting a teammate's block).
45
47
  //
46
48
  // THE AUTO VERDICT'S TWO RULES ride the same route as two more columns (migration 0145), OVERRIDES
47
49
  // ONLY (NULL = today's rule): `pair_verdict_strictness` ('relaxed', else strict — wire
@@ -103,6 +105,8 @@ approveLeftovers) {
103
105
  autoResolve: typeof stored?.autoResolve === 'boolean' ? stored.autoResolve : AUTO_POST_DEFAULT_AUTO_RESOLVE,
104
106
  approveImpact,
105
107
  approveLeftovers: resolveApproveLeftovers(approveLeftovers),
108
+ teamLiftConsent: typeof stored?.teamLiftConsent === 'boolean' ? stored.teamLiftConsent : AUTO_POST_DEFAULT_TEAM_LIFT_CONSENT,
109
+ liftTeammateBlocks: typeof stored?.liftTeammateBlocks === 'boolean' ? stored.liftTeammateBlocks : AUTO_POST_DEFAULT_LIFT_TEAMMATE_BLOCKS,
106
110
  };
107
111
  }
108
112
  /** The settings in force → what to store: only what differs from the product default (null = none). */
@@ -120,6 +124,12 @@ export function autoPostOverrides(s) {
120
124
  out.autoVerdict = s.autoVerdict;
121
125
  if (s.autoResolve !== undefined && s.autoResolve !== AUTO_POST_DEFAULT_AUTO_RESOLVE)
122
126
  out.autoResolve = s.autoResolve;
127
+ if (s.teamLiftConsent !== undefined && s.teamLiftConsent !== AUTO_POST_DEFAULT_TEAM_LIFT_CONSENT) {
128
+ out.teamLiftConsent = s.teamLiftConsent;
129
+ }
130
+ if (s.liftTeammateBlocks !== undefined && s.liftTeammateBlocks !== AUTO_POST_DEFAULT_LIFT_TEAMMATE_BLOCKS) {
131
+ out.liftTeammateBlocks = s.liftTeammateBlocks;
132
+ }
123
133
  if (s.approveImpact !== undefined) {
124
134
  const levels = {};
125
135
  for (const k of APPROVE_IMPACT_KEYS) {
@@ -184,6 +194,18 @@ export function autoReviewOf(row) {
184
194
  * by auto-posting before it touches GitHub. A repo with no membership row (or an unknown PR) reads
185
195
  * null, i.e. OFF: this switch WRITES as the reader, so a workspace it cannot find never says yes.
186
196
  */
197
+ /**
198
+ * The BLOCKED side's consent (`teamLiftConsent`) for the PR's workspace: does a post carry `lift=1`?
199
+ * Never throws — a failed read is no consent (docs/CLAUDE-REVIEW.md § Lifting a teammate's block).
200
+ */
201
+ export async function readTeamLiftConsent(ctx, accountId, prId) {
202
+ try {
203
+ return (await readWorkspaceAutoPostForPr(ctx, accountId, prId))?.settings.teamLiftConsent === true;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
187
209
  export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
188
210
  const { workspaces: w, workspaceRepos: wr, pullRequests: prs } = ctx.schema;
189
211
  const rows = (await ctx.db
@@ -338,6 +360,8 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
338
360
  autoResolve: autoPost?.autoResolve ?? postBefore.autoResolve,
339
361
  approveImpact: { ...postBefore.approveImpact },
340
362
  approveLeftovers: autoPost?.approveLeftovers ?? postBefore.approveLeftovers,
363
+ teamLiftConsent: autoPost?.teamLiftConsent ?? postBefore.teamLiftConsent,
364
+ liftTeammateBlocks: autoPost?.liftTeammateBlocks ?? postBefore.liftTeammateBlocks,
341
365
  };
342
366
  // The leftovers rule is its own column (migration 0145): NULL = the default.
343
367
  const leftoversStored = postAfter.approveLeftovers === 'nits_questions' ? null : postAfter.approveLeftovers;
@@ -654,6 +678,8 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
654
678
  },
655
679
  autoVerdict: { type: 'boolean' },
656
680
  autoResolve: { type: 'boolean' },
681
+ teamLiftConsent: { type: 'boolean' },
682
+ liftTeammateBlocks: { type: 'boolean' },
657
683
  approveImpact: {
658
684
  type: 'object',
659
685
  minProperties: 1,
@@ -691,7 +717,11 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
691
717
  const notBool = (o, k) => k in o && typeof o[k] !== 'boolean';
692
718
  if ((post != null &&
693
719
  typeof post === 'object' &&
694
- (notBool(post, 'enabled') || notBool(post, 'autoVerdict') || notBool(post, 'autoResolve'))) ||
720
+ (notBool(post, 'enabled') ||
721
+ notBool(post, 'autoVerdict') ||
722
+ notBool(post, 'autoResolve') ||
723
+ notBool(post, 'teamLiftConsent') ||
724
+ notBool(post, 'liftTeammateBlocks'))) ||
695
725
  (kinds != null && typeof kinds === 'object' && Object.values(kinds).some((v) => typeof v !== 'boolean')) ||
696
726
  (impact != null && typeof impact === 'object' && Object.values(impact).some((v) => typeof v !== 'boolean'))) {
697
727
  reply.status(400);
@@ -14,7 +14,7 @@ import { consolidatePair, notStartedHalf } from './pair-consolidate.js';
14
14
  import { markPairBusy } from './pair-view.js';
15
15
  import { preflightFor } from '../../ai/run-choice.js';
16
16
  import { linkReraisedFindings, reconcileFollowUp, dropAcceptedReraises, isResolvedWithReplies, selectPriorFindings, SINCE_PATCH_CHARS, } from './follow-up.js';
17
- import { loadAuthorReviewAnchor, mergeAuthorAnchor } from './teammate-anchor.js';
17
+ import { loadAuthorReviewAnchor, loadStandingBlockAnchors, mergeAuthorAnchor, mergeStandingBlocks } from './teammate-anchor.js';
18
18
  import { planThreadReview, reconcileThreads } from './threads.js';
19
19
  import { currentThreadAssessmentsForPrs, priorAssessmentsForProvider } from './thread-judgements.js';
20
20
  import { activeProviderId } from '../../ai/provider-setting.js';
@@ -876,8 +876,26 @@ async function runPipeline(item, controller, emit) {
876
876
  ctx.log.warn(`claude review pr ${item.prId}: author's review not read: ${err instanceof Error ? err.message : String(err)}`);
877
877
  }
878
878
  }
879
- const prior = authorAnchor && authorAnchor.findings.length > 0 ? mergeAuthorAnchor(authorAnchor, ownPrior, settledIds) : ownPrior;
880
- const teammateAnchor = authorAnchor && prior !== ownPrior ? authorAnchor.teammate : null;
879
+ const anchored = authorAnchor && authorAnchor.findings.length > 0 ? mergeAuthorAnchor(authorAnchor, ownPrior, settledIds) : ownPrior;
880
+ // A TEAMMATE'S STANDING REQUEST FOR CHANGES (teammate-anchor.ts `loadStandingBlockAnchors`): its
881
+ // findings join as carried, so this run checks them too. A failure costs those findings only.
882
+ let blocks = [];
883
+ if (!fresh) {
884
+ try {
885
+ blocks = await loadStandingBlockAnchors(ctx, item.prId, item.accountId, authorAnchor?.reviewId ?? null, item.headSha);
886
+ }
887
+ catch (err) {
888
+ ctx.log.warn(`claude review pr ${item.prId}: teammates' standing reviews not read: ${err instanceof Error ? err.message : String(err)}`);
889
+ }
890
+ }
891
+ const prior = mergeStandingBlocks(anchored, blocks, settledIds);
892
+ // The follow-up names a teammate when THEIR review anchors it: the author's, else (no review of the
893
+ // reader's at all) the newest standing block's.
894
+ const teammateAnchor = authorAnchor && anchored !== ownPrior
895
+ ? authorAnchor.teammate
896
+ : anchored == null && prior != null
897
+ ? (prior.teammate ?? null)
898
+ : null;
881
899
  // BACKWARD COMPATIBILITY: findings the RETIRED settle-on-sight rule took out of the chain were
882
900
  // never judged. Load them as carried items; only the ones whose thread is resolved WITH a
883
901
  // person's reply are kept (below), so they are judged once like any other.
@@ -5,7 +5,7 @@ import { storedList, stripStoredStoryLead } from '../../shared/index.js';
5
5
  import { referenceDocCitationsFromStored, reviewDocReferencesFromStored } from '../../shared/index.js';
6
6
  import { automaticRetryTarget, pressRetryTarget } from '../../db/auto-retry-at-head.js';
7
7
  import { TEAMMATE_KIND, newestTeammateReviewsForPrs, ownFindingsOnly, ownRunsOnly } from '../../db/teammate-reviews.js';
8
- import { teamReviewLead } from './team-view.js';
8
+ import { loadTeamStandingsForPrs, teamReviewLead } from './team-view.js';
9
9
  import { ticketEntriesOf } from './ticket.js';
10
10
  import { overlayFollowUpItems } from './replies-pass.js';
11
11
  import { findFindingThread } from './finding-thread.js';
@@ -85,6 +85,9 @@ export function autoPostWireOf(v) {
85
85
  : {}),
86
86
  ...(r.verdict != null && typeof r.verdict === 'object' ? { verdict: r.verdict } : {}),
87
87
  ...(r.liftedBlock != null && typeof r.liftedBlock === 'object' ? { liftedBlock: r.liftedBlock } : {}),
88
+ ...(Array.isArray(r.liftedTeammateBlocks) && r.liftedTeammateBlocks.length > 0
89
+ ? { liftedTeammateBlocks: r.liftedTeammateBlocks }
90
+ : {}),
88
91
  ...(typeof r.viaPairReviewId === 'number' ? { viaPairReviewId: r.viaPairReviewId } : {}),
89
92
  ...(typeof r.reasonPosted === 'string' && r.reasonPosted !== '' ? { reasonPosted: true } : {}),
90
93
  ...(r.reasonOnly === true ? { reasonOnly: true } : {}),
@@ -903,7 +906,24 @@ fixRunning) {
903
906
  await foldStateSummaries(ctx, out, latestByPr, repliesAfter);
904
907
  if (fixRunning)
905
908
  await foldFixStates(ctx, out, latestByPr, accountId, fixRunning);
906
- await foldTeammateLeads(ctx, out, latestByPr, prIds, accountId);
909
+ // The reader's latest run that REVIEWED, per PR (succeeded, not a skip; a pair's verdict is the
910
+ // pair's when it is the latest run) — the standings' "you reviewed last".
911
+ const ownLatest = new Map();
912
+ const stateOf = new Map(out.map((st) => [st.prId, st]));
913
+ for (const r of rows) {
914
+ if (r.kind != null || ownLatest.has(r.prId) || r.status !== 'succeeded' || r.reviewMode === 'skip')
915
+ continue;
916
+ const latest = latestByPr.get(r.prId);
917
+ const st = stateOf.get(r.prId);
918
+ const isLatest = latest != null && (latest.reviewId === r.reviewId || (latest.pairId != null && latest.pairId === r.pairId));
919
+ ownLatest.set(r.prId, {
920
+ reviewId: isLatest ? latest.reviewId : r.reviewId,
921
+ verdict: isLatest && st != null ? st.verdict : r.verdict,
922
+ at: (r.finishedAt ?? r.createdAt).getTime(),
923
+ headSha: r.headSha,
924
+ });
925
+ }
926
+ await foldTeammateLeads(ctx, out, latestByPr, prIds, accountId, ownLatest);
907
927
  return out;
908
928
  }
909
929
  /**
@@ -913,7 +933,21 @@ fixRunning) {
913
933
  * PR with no run of the reader's but a teammate review is appended, so its column is not blank.
914
934
  * Mutates `states` in place. DB-only.
915
935
  */
916
- async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId) {
936
+ async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId, ownLatest = new Map()) {
937
+ // STANDING VERDICTS (team-view.ts `teamStandings`): the strictest verdict still in force on GitHub.
938
+ // A failed read costs the field only.
939
+ let standings = new Map();
940
+ try {
941
+ standings = await loadTeamStandingsForPrs(ctx.db, accountId, prIds, ownLatest);
942
+ }
943
+ catch (err) {
944
+ ctx.log.warn(`review standings: ${err instanceof Error ? err.message : String(err)}`);
945
+ }
946
+ for (const st of states) {
947
+ const got = standings.get(st.prId);
948
+ if (got != null)
949
+ st.standing = got;
950
+ }
917
951
  const newest = await newestTeammateReviewsForPrs(ctx.db, accountId, prIds);
918
952
  if (newest.size === 0)
919
953
  return;
@@ -930,6 +964,7 @@ async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId) {
930
964
  status: state.status,
931
965
  reviewMode: row.reviewMode,
932
966
  at: (row.finishedAt ?? row.createdAt).getTime(),
967
+ lastReviewedAt: ownLatest.get(prId)?.at ?? null,
933
968
  }
934
969
  : null;
935
970
  if (teamReviewLead(own, mate.postedAt.getTime()) !== 'teammate')
@@ -960,6 +995,7 @@ async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId) {
960
995
  headMoved: false,
961
996
  currentHeadSha: mate.prHeadSha,
962
997
  teammateLead,
998
+ ...(standings.get(prId) != null ? { standing: standings.get(prId) } : {}),
963
999
  });
964
1000
  }
965
1001
  }