limn-review 0.1.140 → 0.1.142

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 (38) hide show
  1. package/dist/coding/ai-fix/manager.js +4 -2
  2. package/dist/db/erase-account.js +4 -0
  3. package/dist/db/migrations/0137_review_pair_consolidation.sql +8 -0
  4. package/dist/db/migrations/0138_team_claim_lines.sql +55 -0
  5. package/dist/db/migrations/meta/_journal.json +14 -0
  6. package/dist/db/queries.js +34 -4
  7. package/dist/db/retention.js +4 -0
  8. package/dist/db/schema.sqlite.js +59 -0
  9. package/dist/db/team-claim-lines-delete.js +18 -0
  10. package/dist/platform/slack/signals.js +27 -3
  11. package/dist/review/agent-context.js +1 -0
  12. package/dist/review/agentic.js +5 -0
  13. package/dist/review/claude-review/auto-post.js +40 -31
  14. package/dist/review/claude-review/auto-verdict.js +17 -0
  15. package/dist/review/claude-review/auto.js +5 -0
  16. package/dist/review/claude-review/chat.js +76 -14
  17. package/dist/review/claude-review/finding-facts.js +27 -0
  18. package/dist/review/claude-review/manager.js +78 -12
  19. package/dist/review/claude-review/manual-reply.js +3 -2
  20. package/dist/review/claude-review/pair-consolidate.js +302 -0
  21. package/dist/review/claude-review/pair-view.js +248 -0
  22. package/dist/review/claude-review/pair.js +7 -3
  23. package/dist/review/claude-review/persist.js +139 -36
  24. package/dist/review/claude-review/routes.js +70 -128
  25. package/dist/review/claude-review/team-claim-ledger.js +135 -0
  26. package/dist/review/claude-review/team-claim-release.js +274 -0
  27. package/dist/review/claude-review/team-claim.js +200 -34
  28. package/dist/review/llm.js +1 -1
  29. package/dist/shared/ai-provider-words.js +28 -1
  30. package/package.json +1 -1
  31. package/public/assets/AiFixTab-B3eAgc7u.js +3 -0
  32. package/public/assets/ClaudeReviewTab-C7GoZdiF.js +20 -0
  33. package/public/assets/{PrBotBehaviourTab-Bwu839bx.js → PrBotBehaviourTab-ChRG19sQ.js} +1 -1
  34. package/public/assets/{index-BoS_VHWj.js → index-BvK15F-9.js} +46 -46
  35. package/public/assets/{index-B23U_yh7.css → index-CY_rIeEn.css} +1 -1
  36. package/public/index.html +2 -2
  37. package/public/assets/AiFixTab-D8HrsNZV.js +0 -3
  38. 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,
@@ -168,6 +168,8 @@ export async function eraseAccountData(accountId) {
168
168
  // hold third-party analysis of the account's code. No FK on the snapshots, so this is the whole
169
169
  // guarantee; workspace_sonar cascades from workspaces too, and is explicit anyway.
170
170
  await tx.delete(schema.sonarSnapshots).where(eq(schema.sonarSnapshots.accountId, accountId)).execute();
171
+ // The team-claim ledger (migration 0138): GitHub logins and heads. No FK, so this is the whole guarantee.
172
+ await tx.delete(schema.teamClaimLines).where(eq(schema.teamClaimLines.accountId, accountId)).execute();
171
173
  await tx.delete(schema.workspaceSonar).where(eq(schema.workspaceSonar.accountId, accountId)).execute();
172
174
  // Sequence diagrams (migration 0115 / pg 0102): no FKs, so this is the whole guarantee (story rows
173
175
  // carry no pr_id and are reached ONLY here).
@@ -330,6 +332,8 @@ export function accountScopedTables() {
330
332
  { name: 'workspaceTrackers', col: schema.workspaceTrackers.accountId, table: schema.workspaceTrackers },
331
333
  // SonarCloud (migration 0114 / pg 0101), erased explicitly above; `workspaceSonar` holds a credential.
332
334
  { name: 'sonarSnapshots', col: schema.sonarSnapshots.accountId, table: schema.sonarSnapshots },
335
+ // The team-claim ledger (migration 0138), erased explicitly above.
336
+ { name: 'teamClaimLines', col: schema.teamClaimLines.accountId, table: schema.teamClaimLines },
333
337
  { name: 'workspaceSonar', col: schema.workspaceSonar.accountId, table: schema.workspaceSonar },
334
338
  // Sequence diagrams (migration 0115 / pg 0102), erased explicitly above.
335
339
  { name: 'sequenceDiagrams', col: schema.sequenceDiagrams.accountId, table: schema.sequenceDiagrams },
@@ -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;
@@ -0,0 +1,55 @@
1
+ -- THE TEAM-CLAIM LEDGER (docs/CLAUDE-REVIEW.md § Team claims → Releasing stale lines). One row per
2
+ -- GitHub status line (`limn/review-claude` | `limn/review-codex`) Limn holds or released on a PR
3
+ -- head, written BEFORE the pending line is posted, so a restart, a dropped run or a failed final write
4
+ -- never leaves this machine's line pending: the reconciler releases every open row whose run is not
5
+ -- running. `whose = 'teammate'` records a teammate's expired line released once.
6
+ CREATE TABLE `team_claim_lines` (
7
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
8
+ `account_id` integer NOT NULL,
9
+ `pr_id` integer NOT NULL,
10
+ `repo_id` integer,
11
+ `owner` text,
12
+ `name` text,
13
+ `head_sha` text NOT NULL,
14
+ `provider` text NOT NULL,
15
+ `review_id` integer,
16
+ `whose` text DEFAULT 'mine' NOT NULL,
17
+ `state` text NOT NULL,
18
+ `login` text,
19
+ `final_description` text,
20
+ `final_target_url` text,
21
+ `release_attempts` integer DEFAULT 0 NOT NULL,
22
+ `last_error` text,
23
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
24
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL
25
+ );
26
+ --> statement-breakpoint
27
+ CREATE INDEX `team_claim_lines_state` ON `team_claim_lines` (`state`);
28
+ --> statement-breakpoint
29
+ CREATE INDEX `team_claim_lines_account_pr_head` ON `team_claim_lines` (`account_id`,`pr_id`,`head_sha`);
30
+ --> statement-breakpoint
31
+ -- Lines older Limn versions left PENDING (a failed run's "didn't finish · free to claim", or a run that
32
+ -- died mid-way): the newest auto run's record per (PR, head, context), when it is still pending, from
33
+ -- the last 14 days. The reconciler replaces each with the success release line at boot — only where the
34
+ -- head's newest line is still this account's pending one (it reads GitHub first).
35
+ INSERT INTO `team_claim_lines` (`account_id`, `pr_id`, `repo_id`, `owner`, `name`, `head_sha`, `provider`, `review_id`, `whose`, `state`)
36
+ SELECT cr.`account_id`, cr.`pr_id`, pr.`repo_id`, r.`owner`, r.`name`, cr.`head_sha`,
37
+ CASE json_extract(cr.`team_claim`, '$.context') WHEN 'limn/review-codex' THEN 'codex' ELSE 'claude' END,
38
+ cr.`id`, 'mine', 'pending'
39
+ FROM `claude_reviews` cr
40
+ JOIN `pull_requests` pr ON pr.`id` = cr.`pr_id`
41
+ JOIN `repos` r ON r.`id` = pr.`repo_id`
42
+ WHERE cr.`team_claim` IS NOT NULL
43
+ AND json_valid(cr.`team_claim`)
44
+ AND json_extract(cr.`team_claim`, '$.state') = 'marked'
45
+ AND json_extract(cr.`team_claim`, '$.lastLine') IN ('reviewing', 'unfinished')
46
+ AND cr.`created_at` >= unixepoch() - 14 * 86400
47
+ AND cr.`id` = (
48
+ SELECT MAX(c2.`id`) FROM `claude_reviews` c2
49
+ WHERE c2.`account_id` = cr.`account_id`
50
+ AND c2.`pr_id` = cr.`pr_id`
51
+ AND c2.`head_sha` = cr.`head_sha`
52
+ AND c2.`team_claim` IS NOT NULL
53
+ AND json_valid(c2.`team_claim`)
54
+ AND json_extract(c2.`team_claim`, '$.context') = json_extract(cr.`team_claim`, '$.context')
55
+ );
@@ -960,6 +960,20 @@
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
970
+ },
971
+ {
972
+ "idx": 138,
973
+ "version": "6",
974
+ "when": 1792137625016,
975
+ "tag": "0138_team_claim_lines",
976
+ "breakpoints": true
963
977
  }
964
978
  ]
965
979
  }
@@ -18,6 +18,7 @@ const REASON_PRIORITY = [
18
18
  ];
19
19
  import { db, schema } from './client.js';
20
20
  import { dropRepoRelations } from './related-repos.js';
21
+ import { deleteSettledClaimLinesForPrs } from './team-claim-lines-delete.js';
21
22
  import { runTransaction } from './client.js';
22
23
  import { enrichMyTurn } from '../feed/my-turn.js';
23
24
  import { resolvePrTickets } from '../pr/detail-enricher.js';
@@ -7873,6 +7874,9 @@ export async function deleteRepo(id, accountId) {
7873
7874
  await tx.delete(schema.trackerTickets).where(inArray(schema.trackerTickets.prId, prIds)).execute();
7874
7875
  // SonarCloud snapshots (migration 0114 / pg 0101). No FKs, so nothing would cascade.
7875
7876
  await tx.delete(schema.sonarSnapshots).where(inArray(schema.sonarSnapshots.prId, prIds)).execute();
7877
+ // The team-claim ledger (migration 0138). No FKs, so nothing would cascade. This machine's OPEN
7878
+ // rows stay: their lines still need releasing on GitHub (db/team-claim-lines-delete.ts).
7879
+ await deleteSettledClaimLinesForPrs(tx, prIds);
7876
7880
  // Sequence diagrams of these PRs, and the story diagrams naming any of them (migration 0115 /
7877
7881
  // pg 0102). No FKs, so nothing would cascade. Before the PRs go: it reads their accounts.
7878
7882
  await pruneSequenceDiagramsForPrs(tx, prIds);
@@ -8038,6 +8042,7 @@ repoIds) {
8038
8042
  reviewHead: claudeReviews.headSha,
8039
8043
  prHead: pullRequests.headSha,
8040
8044
  trigger: claudeReviews.trigger,
8045
+ pairId: claudeReviews.pairId,
8041
8046
  })
8042
8047
  .from(claudeReviews)
8043
8048
  .innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
@@ -8049,16 +8054,41 @@ repoIds) {
8049
8054
  .execute();
8050
8055
  const seen = new Set(); // most-recent succeeded run per PR only
8051
8056
  const candidates = [];
8057
+ // A CLAUDE + CODEX PAIR is ONE review (review/claude-review/pair-view.ts): its succeeded halves
8058
+ // are read together — posted when either was posted as a review, its findings both halves' (a
8059
+ // merged-away duplicate never counts), its verdict the pair's one verdict.
8060
+ const halvesOf = new Map();
8061
+ for (const r of rows) {
8062
+ if (r.pairId == null)
8063
+ continue;
8064
+ const list = halvesOf.get(r.pairId) ?? [];
8065
+ list.push(r);
8066
+ halvesOf.set(r.pairId, list);
8067
+ }
8052
8068
  for (const r of rows) {
8053
8069
  if (seen.has(r.prId))
8054
8070
  continue;
8055
8071
  seen.add(r.prId);
8056
- if (r.postedAt != null)
8072
+ const halves = r.pairId != null ? (halvesOf.get(r.pairId) ?? [r]) : [r];
8073
+ if (halves.some((h) => h.postedAt != null))
8057
8074
  continue; // the run itself was posted → actioned, no findings needed
8058
8075
  candidates.push(r);
8059
8076
  }
8060
8077
  if (candidates.length === 0)
8061
8078
  return [];
8079
+ const runIdsOf = (c) => c.pairId != null ? (halvesOf.get(c.pairId) ?? [c]).map((h) => h.reviewId) : [c.reviewId];
8080
+ // The pairs' one verdicts (on each pair's FIRST row), in ONE read.
8081
+ const pairIds = [...new Set(candidates.map((c) => c.pairId).filter((id) => id != null))];
8082
+ const pairVerdicts = new Map();
8083
+ if (pairIds.length > 0) {
8084
+ const recs = await db
8085
+ .select({ id: claudeReviews.id, rec: claudeReviews.pairConsolidation })
8086
+ .from(claudeReviews)
8087
+ .where(inArray(claudeReviews.id, pairIds))
8088
+ .execute();
8089
+ for (const p of recs)
8090
+ pairVerdicts.set(p.id, p.rec?.verdict ?? null);
8091
+ }
8062
8092
  // ONE grouped read over the candidate runs — never one query per run, and never a per-run
8063
8093
  // EXISTS in the select above, which would run the sub-query for every historical run of every
8064
8094
  // open PR rather than the handful that got this far.
@@ -8070,7 +8100,7 @@ repoIds) {
8070
8100
  postedAt: claudeReviewFindings.postedAt,
8071
8101
  })
8072
8102
  .from(claudeReviewFindings)
8073
- .where(inArray(claudeReviewFindings.reviewId, candidates.map((c) => c.reviewId)))
8103
+ .where(and(inArray(claudeReviewFindings.reviewId, candidates.flatMap(runIdsOf)), isNull(claudeReviewFindings.mergedInto)))
8074
8104
  .execute();
8075
8105
  const findingsByRun = new Map();
8076
8106
  for (const f of findingRows) {
@@ -8080,7 +8110,7 @@ repoIds) {
8080
8110
  }
8081
8111
  const out = [];
8082
8112
  for (const r of candidates) {
8083
- const findings = findingsByRun.get(r.reviewId) ?? [];
8113
+ const findings = runIdsOf(r).flatMap((id) => findingsByRun.get(id) ?? []);
8084
8114
  // STATE ONE: nothing from this run has reached GitHub. It was never worked, so it stays —
8085
8115
  // including a run with no findings at all, whose summary and verdict are still yours to post
8086
8116
  // or discard. ⚠ This branch is what keeps a fresh run visible: `included` defaults to false,
@@ -8101,7 +8131,7 @@ repoIds) {
8101
8131
  repoFullName: `${r.owner}/${r.name}`,
8102
8132
  prNumber: r.prNumber,
8103
8133
  prTitle: r.prTitle,
8104
- verdict: r.verdict,
8134
+ verdict: r.pairId != null ? (pairVerdicts.get(r.pairId) ?? r.verdict) : r.verdict,
8105
8135
  finishedAt: iso(r.finishedAt),
8106
8136
  headStale: r.reviewHead !== r.prHead,
8107
8137
  githubUrl: `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}`,
@@ -4,6 +4,7 @@ import { config } from '../config.js';
4
4
  import { pruneTicketReviewsForPrs } from './ticket-review-prune.js';
5
5
  import { pruneCiReviewsForPrs } from './ci-review-prune.js';
6
6
  import { pruneSequenceDiagramsForPrs } from './sequence-diagram-prune.js';
7
+ import { deleteSettledClaimLinesForPrs } from './team-claim-lines-delete.js';
7
8
  // Time-based retention sweep. Per-account server data (events, PRs + their whole subtree)
8
9
  // accumulates forever otherwise; this prunes anything past the retention window so the DB
9
10
  // stays bounded (the pre-launch, tight-budget scalability lever). Runs periodically from
@@ -100,6 +101,9 @@ async function deletePrSubtree(tx, prIds) {
100
101
  await tx.delete(schema.trackerTickets).where(inArray(schema.trackerTickets.prId, prIds)).execute();
101
102
  // SonarCloud snapshots (migration 0114 / pg 0101). No FKs — explicit, pr-keyed.
102
103
  await tx.delete(schema.sonarSnapshots).where(inArray(schema.sonarSnapshots.prId, prIds)).execute();
104
+ // The team-claim ledger (migration 0138). No FKs — explicit, pr-keyed. This machine's OPEN rows
105
+ // stay: their lines still need releasing on GitHub (db/team-claim-lines-delete.ts).
106
+ await deleteSettledClaimLinesForPrs(tx, prIds);
103
107
  // Sequence diagrams of these PRs, and the story diagrams naming any of them (migration 0115 / pg
104
108
  // 0102). No FKs. Before the PRs go: it reads their accounts.
105
109
  await pruneSequenceDiagramsForPrs(tx, prIds);
@@ -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
@@ -3378,4 +3390,51 @@ export const storyDocsAlignments = sqliteTable('story_docs_alignments', {
3378
3390
  foreignColumns: [workspaces.id, workspaces.accountId],
3379
3391
  }).onDelete('cascade'),
3380
3392
  }));
3393
+ // team_claim_lines — THE TEAM-CLAIM LEDGER (migration 0138; docs/CLAUDE-REVIEW.md § Team claims →
3394
+ // Releasing stale lines). ONE row per GitHub status line Limn holds or has released on a PR head, so a
3395
+ // line this machine posted is never left pending by a restart, a dropped run or a failed final write:
3396
+ // whose 'mine' a claim this machine made. Written as 'claiming' BEFORE the pending line is posted,
3397
+ // 'pending' once it is up, 'finished' when the success line ("reviewed") is up,
3398
+ // 'released' when the release line ("didn't finish · free to claim"), a Clear, a
3399
+ // newer claim at the same head, or GitHub's refusal ended it.
3400
+ // whose 'teammate' a teammate's expired line this machine released ("Released: bob's claim
3401
+ // expired"), recorded so it is posted ONCE. Always 'released'.
3402
+ // The reconciler (review/claude-review/team-claim-release.ts) releases every 'claiming' / 'pending'
3403
+ // row whose run is not queued or running in this process. No FK on pr_id (the sonar_snapshots
3404
+ // precedent): BOTH delete paths (deleteRepo, retention's deletePrSubtree) delete its rows EXCEPT this
3405
+ // machine's OPEN ones (a pending line on GitHub still needs its release; the row carries owner/name);
3406
+ // the erasure deletes everything.
3407
+ export const teamClaimLines = sqliteTable('team_claim_lines', {
3408
+ id: integer('id').primaryKey({ autoIncrement: true }),
3409
+ accountId: integer('account_id').notNull(),
3410
+ prId: integer('pr_id').notNull(),
3411
+ repoId: integer('repo_id'),
3412
+ // The repo's owner/name when the row was written: an open line is still released after its PR or
3413
+ // repo left Limn (the delete paths keep OPEN 'mine' rows for exactly that).
3414
+ owner: text('owner'),
3415
+ name: text('name'),
3416
+ headSha: text('head_sha').notNull(),
3417
+ // 'claude' | 'codex' — the status context (`limn/review-<provider>`).
3418
+ provider: text('provider').notNull(),
3419
+ // The run the claim was for (NULL until its row is written, and on a teammate's line).
3420
+ reviewId: integer('review_id'),
3421
+ whose: text('whose', { enum: ['mine', 'teammate'] }).notNull().default('mine'),
3422
+ state: text('state', { enum: ['claiming', 'pending', 'finished', 'released'] }).notNull(),
3423
+ // The GitHub login the line names (NULL on a row backfilled from before the ledger: the
3424
+ // account's login is used).
3425
+ login: text('login'),
3426
+ // A success line that could not be written when the run finished: the reconciler posts IT
3427
+ // instead of the release line.
3428
+ finalDescription: text('final_description'),
3429
+ finalTargetUrl: text('final_target_url'),
3430
+ releaseAttempts: integer('release_attempts').notNull().default(0),
3431
+ lastError: text('last_error'),
3432
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
3433
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
3434
+ }, (t) => ({
3435
+ // The reconciler's read (open rows).
3436
+ stateIdx: index('team_claim_lines_state').on(t.state),
3437
+ // One head's rows (supersede, Clear, the tab's "Clear" offer).
3438
+ prHeadIdx: index('team_claim_lines_account_pr_head').on(t.accountId, t.prId, t.headSha),
3439
+ }));
3381
3440
  //# sourceMappingURL=schema.sqlite.js.map
@@ -0,0 +1,18 @@
1
+ // The team-claim ledger's DELETE-PATH rule (migration 0138; docs/CLAUDE-REVIEW.md § Team claims →
2
+ // Releasing stale lines). When PRs leave Limn (deleteRepo, retention's deletePrSubtree), their ledger
3
+ // rows go — EXCEPT this machine's OPEN ones ('mine', 'claiming' / 'pending'): each stands for a line
4
+ // that may still be pending on GitHub, and the reconciler releases it from the row's own owner/name
5
+ // (it closes a row that has neither). The account erasure deletes every row regardless.
6
+ import { and, eq, inArray, not } from 'drizzle-orm';
7
+ import { schema } from './client.js';
8
+ /* eslint-disable @typescript-eslint/no-explicit-any */
9
+ export async function deleteSettledClaimLinesForPrs(tx, prIds) {
10
+ if (prIds.length === 0)
11
+ return;
12
+ const tl = schema.teamClaimLines;
13
+ await tx
14
+ .delete(tl)
15
+ .where(and(inArray(tl.prId, prIds), not(and(eq(tl.whose, 'mine'), inArray(tl.state, ['claiming', 'pending'])))))
16
+ .execute();
17
+ }
18
+ //# sourceMappingURL=team-claim-lines-delete.js.map
@@ -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: {
@@ -101,6 +101,11 @@ export async function startAgenticBackground(app) {
101
101
  catch (err) {
102
102
  app.log.warn({ err }, 'claude review: startup reconcile failed');
103
103
  }
104
+ // TEAM CLAIMS: a run this process lost (a restart mid-review) left its limn/review-* line pending on
105
+ // GitHub; release every such line now, in the BACKGROUND (GitHub calls; never fails or delays the boot).
106
+ void import('./claude-review/team-claim-release.js')
107
+ .then((m) => m.releaseStaleTeamClaims(ctx))
108
+ .catch((err) => app.log.warn({ err }, 'team claim release at boot failed'));
104
109
  try {
105
110
  await reconcileThreadChecksOnStartup(ctx);
106
111
  }
@@ -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
@@ -572,6 +572,11 @@ export function registerAutoReview(ctx) {
572
572
  return;
573
573
  ctx.registerScheduledJob(AUTO_REVIEW_CRON, async () => {
574
574
  await runAutoReviewSweep(ctx);
575
+ // TEAM CLAIMS: release every limn/review-* line of ours no run holds any more, and teammates'
576
+ // expired ones (team-claim-release.ts). Never throws; never blocks the sweep.
577
+ await import('./team-claim-release.js')
578
+ .then((m) => m.releaseStaleTeamClaims(ctx))
579
+ .catch((err) => ctx.log.warn(`team claim release: ${err instanceof Error ? err.message : String(err)}`));
575
580
  }, 'claude-auto-review');
576
581
  }
577
582
  //# sourceMappingURL=auto.js.map