limn-review 0.1.125 → 0.1.127

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 (78) hide show
  1. package/dist/api/plugins/rate-limit.js +10 -0
  2. package/dist/coding/ai-fix/auto-fix.js +6 -0
  3. package/dist/coding/ai-fix/manager.js +8 -1
  4. package/dist/coding/ai-fix/review-seed.js +17 -13
  5. package/dist/config.js +11 -0
  6. package/dist/db/ci-review-prune.js +22 -0
  7. package/dist/db/erase-account.js +23 -0
  8. package/dist/db/migrations/0082_ci_reviews.sql +73 -0
  9. package/dist/db/migrations/0083_workspace_auto_fix.sql +9 -0
  10. package/dist/db/migrations/0084_auto_fix_off_by_default.sql +7 -0
  11. package/dist/db/migrations/0085_auto_review_daily_cap.sql +6 -0
  12. package/dist/db/migrations/0086_ticket_review_pr_cards.sql +24 -0
  13. package/dist/db/migrations/0087_auto_post.sql +21 -0
  14. package/dist/db/migrations/meta/_journal.json +42 -0
  15. package/dist/db/migrations-pg/0069_ci_reviews.sql +65 -0
  16. package/dist/db/migrations-pg/0070_workspace_auto_fix.sql +3 -0
  17. package/dist/db/migrations-pg/0071_auto_fix_off_by_default.sql +5 -0
  18. package/dist/db/migrations-pg/0072_auto_review_daily_cap.sql +3 -0
  19. package/dist/db/migrations-pg/0073_ticket_review_pr_cards.sql +17 -0
  20. package/dist/db/migrations-pg/0074_auto_post.sql +14 -0
  21. package/dist/db/migrations-pg/meta/_journal.json +42 -0
  22. package/dist/db/my-turn-dismissals.js +8 -11
  23. package/dist/db/queries.js +56 -47
  24. package/dist/db/retention.js +3 -0
  25. package/dist/db/schema.pg.js +129 -1
  26. package/dist/db/schema.sqlite.js +176 -0
  27. package/dist/db/ticket-review-prune.js +4 -1
  28. package/dist/review/agentic.js +15 -2
  29. package/dist/review/ci-review/agent.js +143 -0
  30. package/dist/review/ci-review/currency.js +143 -0
  31. package/dist/review/ci-review/manager.js +363 -0
  32. package/dist/review/ci-review/persist.js +486 -0
  33. package/dist/review/ci-review/prepare.js +49 -0
  34. package/dist/review/ci-review/prompts.js +103 -0
  35. package/dist/review/ci-review/reconcile.js +78 -0
  36. package/dist/review/ci-review/routes.js +197 -0
  37. package/dist/review/ci-review/schema.js +44 -0
  38. package/dist/review/ci-review/sweep.js +172 -0
  39. package/dist/review/claude-review/auto-post.js +405 -0
  40. package/dist/review/claude-review/auto-settings.js +231 -25
  41. package/dist/review/claude-review/auto.js +45 -57
  42. package/dist/review/claude-review/ci-failures.js +0 -42
  43. package/dist/review/claude-review/manager.js +45 -70
  44. package/dist/review/claude-review/persist.js +178 -10
  45. package/dist/review/claude-review/prompts.js +42 -19
  46. package/dist/review/claude-review/routes.js +9 -0
  47. package/dist/review/claude-review/settled-by-reply.js +146 -0
  48. package/dist/review/claude-review/specialists.js +1 -1
  49. package/dist/review/clone-hygiene.js +1 -1
  50. package/dist/review/clone-manager.js +72 -1
  51. package/dist/review/path-guard.js +1 -1
  52. package/dist/review/post-review.js +25 -4
  53. package/dist/review/post-seam.js +22 -13
  54. package/dist/review/schema.js +9 -29
  55. package/dist/review/submit-map.js +5 -3
  56. package/dist/review/ticket-review/auto-post.js +291 -0
  57. package/dist/review/ticket-review/cards.js +271 -0
  58. package/dist/review/ticket-review/fingerprint.js +2 -0
  59. package/dist/review/ticket-review/manager.js +167 -26
  60. package/dist/review/ticket-review/persist.js +44 -2
  61. package/dist/review/ticket-review/post-item.js +187 -0
  62. package/dist/review/ticket-review/prepare.js +18 -7
  63. package/dist/review/ticket-review/prepass.js +259 -0
  64. package/dist/review/ticket-review/prompts.js +72 -11
  65. package/dist/review/ticket-review/routes.js +30 -146
  66. package/dist/review/ticket-review/schema.js +43 -0
  67. package/dist/review/ticket-review/sweep.js +56 -65
  68. package/dist/shared/types.js +42 -2
  69. package/package.json +1 -1
  70. package/public/assets/{AiFixTab-B1OazE4O.js → AiFixTab-D-Xph0Bw.js} +2 -2
  71. package/public/assets/ClaudeReviewTab-BwIpVwEa.js +16 -0
  72. package/public/assets/{PrBotBehaviourTab-h7Nujxom.js → PrBotBehaviourTab-DYNOt_5y.js} +1 -1
  73. package/public/assets/index-gwxOLty-.css +1 -0
  74. package/public/assets/index-rdFHnTfI.js +77 -0
  75. package/public/index.html +2 -2
  76. package/public/assets/ClaudeReviewTab-7GZdYcOv.js +0 -8
  77. package/public/assets/index-DTRruvsb.css +0 -1
  78. package/public/assets/index-q7WCDbAA.js +0 -77
@@ -468,6 +468,16 @@ function tierFor(method, path) {
468
468
  return [TIERS.githubWrite];
469
469
  if (path.startsWith('/api/ticket-reviews'))
470
470
  return [TIERS.read];
471
+ // CI REVIEW (review/ci-review/routes.ts), matched EXPLICITLY by exact path for the same reason:
472
+ // POST /api/ci-reviews starts an agent run (reads the head's checks + logs) → ai + ai_hourly
473
+ // POST /api/ci-reviews/states batched DB read of the SYNCED head + failing names → read
474
+ // GET …/:id, …/:id/stream, /api/prs/:id/ci-review → read
475
+ if (path === '/api/ci-reviews' && mutating)
476
+ return [TIERS.ai, TIERS.aiHourly];
477
+ if (path === '/api/ci-reviews/states')
478
+ return [TIERS.read];
479
+ if (path.startsWith('/api/ci-reviews'))
480
+ return [TIERS.read];
471
481
  if (/^\/api\/claude-reviews\/[^/]+\/chat$/.test(path)) {
472
482
  return mutating ? [TIERS.ai, TIERS.aiHourly] : [TIERS.read];
473
483
  }
@@ -6,6 +6,9 @@
6
6
  // 0. the PR's author IS the account's own GitHub user (pull_requests.author_id → users.github_login
7
7
  // vs accounts.github_login, case-insensitive). Anyone else's PR: nothing happens and nothing is
8
8
  // recorded — fixing someone else's branch is never automatic;
9
+ // ½. `off` — auto AI Fix is switched off for the workspace holding the PR's repo
10
+ // (`workspaces.auto_fix_enabled`, migration 0083 / pg 0070, Settings →
11
+ // Auto review). ON by default; a repo with no membership row reads ON;
9
12
  // 1. `nothing_to_fix` — the review's seed is empty (the same refusal as the button's NothingToFix);
10
13
  // 2. `head_moved` — the PR's synced head is no longer the reviewed one (the next review decides);
11
14
  // 3. `fix_in_progress` — a fix for this PR (auto or manual) is queued or running;
@@ -33,6 +36,7 @@ import { AUTO_FIX_DAILY_CAP, CLAUDE_REVIEW_MODELS, DEFAULT_AI_FIX_MODEL, } from
33
36
  import { isFixRunning, loadReviewSeed, startReviewFix } from './manager.js';
34
37
  import { parseChangeReport, parseReviewItems } from './persist.js';
35
38
  import { getFixPrContext } from './pr-context.js';
39
+ import { readWorkspaceAutoFixForPr } from '../../review/claude-review/auto-settings.js';
36
40
  // At most AUTO_FIX_DAILY_CAP (shared) AUTO fixes per PR in any rolling AUTO_FIX_WINDOW_MS.
37
41
  export { AUTO_FIX_DAILY_CAP };
38
42
  export const AUTO_FIX_WINDOW_MS = 24 * 60 * 60 * 1000;
@@ -119,6 +123,8 @@ export async function maybeStartAutoFix(ctx, input, deps = defaultDeps, nowMs =
119
123
  try {
120
124
  if (!(await prAuthorIsAccount(ctx, accountId, prId)))
121
125
  return { status: 'not_own' };
126
+ if (!(await readWorkspaceAutoFixForPr(ctx, accountId, prId)))
127
+ return skip('off');
122
128
  const loaded = await deps.loadReviewSeed(ctx, { accountId, prId, reviewId });
123
129
  if (!loaded)
124
130
  return skip('not_started');
@@ -4,6 +4,7 @@ import { getClaudeReviewById } from '../../review/claude-review/persist.js';
4
4
  import { pickReviewNonce } from '../../review/claude-review/prompts.js';
5
5
  import { buildReviewSeed } from './review-seed.js';
6
6
  import { getOwnedTicketItemsForPr } from '../../review/ticket-review/persist.js';
7
+ import { getFixableCiItemsForPr } from '../../review/ci-review/persist.js';
7
8
  import { buildFixSystemPrompt, buildFixUserPrompt } from './prompts.js';
8
9
  import { insertQueuedFix, markFixCancelled, markFixFailed, markFixRunning, reconcileOrphanedFixes, saveFixSuccess, } from './persist.js';
9
10
  // In-memory job manager for AI Fix's agentic fixer (CORE, free, local-only — it left the plugin
@@ -102,7 +103,13 @@ export async function loadReviewSeed(ctx, input) {
102
103
  return [];
103
104
  })
104
105
  : [];
105
- return { review, seed: buildReviewSeed(review, { nonce: pickReviewNonce, ticketItems }) };
106
+ // The CI review's fixable items at the PR's CURRENT head (manual and auto fixes alike). A failed
107
+ // read costs the CI items only.
108
+ const ciItems = await getFixableCiItemsForPr(ctx, input.accountId, input.prId).catch((err) => {
109
+ ctx.log.warn({ err }, 'ai-fix: loading CI review items failed');
110
+ return [];
111
+ });
112
+ return { review, seed: buildReviewSeed(review, { nonce: pickReviewNonce, ticketItems, ciItems }) };
106
113
  }
107
114
  catch (err) {
108
115
  ctx.log.warn({ err }, 'ai-fix: loading the review seed failed');
@@ -15,7 +15,9 @@ import { isThreadToFix } from '../../shared/index.js';
15
15
  // addressed — unless this run re-raised it as a finding (then F covers it).
16
16
  // T<n> another reviewer's thread the review judged still needs a fix (shared
17
17
  // `isThreadToFix`: valid / partly valid AND not / partly addressed).
18
- // C<n> a CI failure the review judged fixable in this PR (`ciFailures`, when present).
18
+ // C<n> a CI failure the CI REVIEW (review/ci-review/) explained and judged fixable in this PR —
19
+ // from its latest succeeded run AT THE PR'S CURRENT HEAD (`ciItems`, the caller's
20
+ // `getFixableCiItemsForPr`), never from the code review's own (legacy) `ciFailures`.
19
21
  // S<t>-AC<n> an acceptance criterion of ticket t judged not met or partly met, and
20
22
  // S<t>-M<n> something ticket t asked for that is missing — BOTH from the TICKET review
21
23
  // (review/ticket-review/), never from the PR review, and ONLY the items whose owner is
@@ -72,21 +74,21 @@ function mkItem(ref, kind, title, over = {}) {
72
74
  ...over,
73
75
  };
74
76
  }
75
- // ---- CI failures (the review's `ciFailures`, from the Claude Review CI diagnosis) ----
76
- // Only a DIAGNOSED failure the review judged fixable in this PR (`fixableInPr === true`). Anything
77
- // else — infrastructure, flaky, unclear, not checked — is not the fixer's. Older rows carry none.
78
- export function readFixableCiFailures(review) {
79
- const raw = review.ciFailures;
80
- if (!Array.isArray(raw))
81
- return [];
82
- return raw.filter((f) => f != null && f.status === 'diagnosed' && f.fixableInPr === true);
77
+ // ---- CI failures (the CI review's items) ----
78
+ // Only a DIAGNOSED failure judged fixable in this PR (`fixableInPr === true`). Anything else —
79
+ // infrastructure, flaky, unclear, not checked — is not the fixer's. The code review's own legacy
80
+ // `ciFailures` are NEVER read: the CI review replaced them, and they describe an older head.
81
+ export function fixableCiItems(items) {
82
+ return items.filter((f) => f != null && f.status === 'diagnosed' && f.fixableInPr === true);
83
83
  }
84
84
  /**
85
85
  * Collect every fixable item of a review, refs assigned. Pure — the review is the stored run.
86
86
  */
87
87
  export function collectReviewItems(review,
88
88
  // The ticket review's items THIS PR owns. A MANUAL fix only; absent/[] ⇒ none (an auto fix).
89
- ticketItems = []) {
89
+ ticketItems = [],
90
+ // The CI review's items at the PR's current head (both manual and auto fixes); absent/[] ⇒ none.
91
+ ciItems = []) {
90
92
  const out = [];
91
93
  // F — the review's findings. A RE-RAISE (`priorFindingId` set) left out only because the same
92
94
  // comment is already on this commit (follow-up.ts `isAlreadyOnThisCommit`) is NOT a reader's
@@ -161,13 +163,15 @@ ticketItems = []) {
161
163
  });
162
164
  });
163
165
  // C — CI failures fixable in this PR.
164
- readFixableCiFailures(review).forEach((f, i) => {
166
+ fixableCiItems(ciItems).forEach((f, i) => {
165
167
  const ref = `C${i + 1}`;
166
- const first = f.relatedFiles?.[0] ?? null;
168
+ const first = f.path ? { path: f.path, line: f.line } : (f.relatedFiles?.[0] ?? null);
167
169
  const body = [
168
170
  `Check: ${f.checkName}${f.step ? ` (step: ${f.step})` : ''}`,
169
171
  f.cause ? `Cause: ${f.cause}` : '',
170
172
  f.explanation ? `Detail: ${f.explanation}` : '',
173
+ f.path ? `Where: ${where(f.path, f.line)}` : '',
174
+ f.suggestion ? `Suggested change: ${f.suggestion}` : '',
171
175
  f.relatedFiles?.length
172
176
  ? `Related files: ${f.relatedFiles.map((r) => where(r.path, r.line)).join(', ')}`
173
177
  : '',
@@ -226,7 +230,7 @@ function fencedTexts(items, summary) {
226
230
  */
227
231
  export function buildReviewSeed(review, opts) {
228
232
  const budget = opts.budgetChars ?? REVIEW_SEED_CHAR_BUDGET;
229
- const all = collectReviewItems(review, opts.ticketItems ?? []);
233
+ const all = collectReviewItems(review, opts.ticketItems ?? [], opts.ciItems ?? []);
230
234
  if (all.length === 0)
231
235
  return { items: [], sentRefs: [], text: '' };
232
236
  const summary = clip(review.userBody?.trim() || review.summary?.trim() || '', SUMMARY_MAX);
package/dist/config.js CHANGED
@@ -536,9 +536,20 @@ export const config = {
536
536
  // every member's worktree.
537
537
  ticketReviewBudgetUsd: floatFromEnv('TICKET_REVIEW_BUDGET_USD', 4),
538
538
  ticketReviewMaxTurns: intFromEnv('TICKET_REVIEW_MAX_TURNS', 40),
539
+ // The per-card spend cap of the ticket review's CARD PRE-PASS (Sonnet 5, diff-only, one tool —
540
+ // review/ticket-review/prepass.ts). Its spend is added to the ticket run's cost. Env only.
541
+ ticketCardBudgetUsd: floatFromEnv('TICKET_CARD_BUDGET_USD', 0.4),
539
542
  // AUTOMATIC ticket reviews (trigger 'auto' / 'cascade') per workspace per UTC day. Not shared with
540
543
  // the PR review's own auto cap. Manual runs never count. Default = shared TICKET_REVIEW_DAILY_CAP.
541
544
  ticketReviewDailyCap: intFromEnv('TICKET_REVIEW_DAILY_CAP', 20),
545
+ // ---- CI review (one run per (PR, head, failing-check set) — review/ci-review/) ----
546
+ // Its OWN spend cap and turn cap: a CI run reads log excerpts and the files they point at, nothing
547
+ // more. Env only — no route sets it.
548
+ ciReviewBudgetUsd: floatFromEnv('CI_REVIEW_BUDGET_USD', 2),
549
+ ciReviewMaxTurns: intFromEnv('CI_REVIEW_MAX_TURNS', 25),
550
+ // AUTOMATIC CI reviews per workspace per UTC day. Not shared with the PR or ticket review caps.
551
+ // Manual runs never count. Default = shared CI_REVIEW_DAILY_CAP.
552
+ ciReviewDailyCap: intFromEnv('CI_REVIEW_DAILY_CAP', 20),
542
553
  // ---- Diff-size cap ----
543
554
  // A very large inlined diff is the dominant cost on a big PR (it's the cached
544
555
  // prefix re-read every turn). The diff shown IN THE PROMPT is truncated at a
@@ -0,0 +1,22 @@
1
+ import { inArray } from 'drizzle-orm';
2
+ import { schema } from './client.js';
3
+ // THE CI-REVIEW HALF OF BOTH PR DELETE PATHS (deleteRepo in queries.ts, retention's
4
+ // deletePrSubtree). A CI review belongs to exactly one PR, so deleting the PR deletes its runs and
5
+ // their items. Children before parents, explicitly — the composite FKs cascade, but these paths run
6
+ // parent-last by hand on both dialects. Runs inside the caller's transaction.
7
+ export async function pruneCiReviewsForPrs(tx, prIds) {
8
+ if (prIds.length === 0)
9
+ return;
10
+ const runs = schema.ciReviews;
11
+ const rows = await tx
12
+ .select({ id: runs.id })
13
+ .from(runs)
14
+ .where(inArray(runs.prId, [...prIds]))
15
+ .execute();
16
+ const runIds = rows.map((r) => r.id);
17
+ if (runIds.length > 0) {
18
+ await tx.delete(schema.ciReviewItems).where(inArray(schema.ciReviewItems.ciReviewId, runIds)).execute();
19
+ }
20
+ await tx.delete(runs).where(inArray(runs.prId, [...prIds])).execute();
21
+ }
22
+ //# sourceMappingURL=ci-review-prune.js.map
@@ -128,6 +128,19 @@ export async function eraseAccountData(accountId) {
128
128
  .delete(schema.ticketReviews)
129
129
  .where(eq(schema.ticketReviews.accountId, accountId))
130
130
  .execute();
131
+ // Ticket review contribution cards (migration 0086 / pg 0073): model text describing the user's
132
+ // PRs. The repo loop has normally taken them with their PRs; explicit, like every agentic table.
133
+ await tx
134
+ .delete(schema.ticketReviewPrCards)
135
+ .where(eq(schema.ticketReviewPrCards.accountId, accountId))
136
+ .execute();
137
+ // CI reviews (migration 0082 / pg 0069), items first. The repo loop has normally taken them with
138
+ // their PRs; explicit, like every agentic table.
139
+ await tx
140
+ .delete(schema.ciReviewItems)
141
+ .where(eq(schema.ciReviewItems.accountId, accountId))
142
+ .execute();
143
+ await tx.delete(schema.ciReviews).where(eq(schema.ciReviews.accountId, accountId)).execute();
131
144
  // AI Fix runs (patches of the user's code + the prompts they were built from). Core since
132
145
  // migration 0074 / pg 0061 — the plugin's erasure hook used to own them.
133
146
  await tx.delete(schema.aiFixes).where(eq(schema.aiFixes.accountId, accountId)).execute();
@@ -211,6 +224,16 @@ export function accountScopedTables() {
211
224
  col: schema.ticketReviewItems.accountId,
212
225
  table: schema.ticketReviewItems,
213
226
  },
227
+ // Contribution cards (migration 0086 / pg 0073) — one per (PR, head); erased explicitly above.
228
+ {
229
+ name: 'ticketReviewPrCards',
230
+ col: schema.ticketReviewPrCards.accountId,
231
+ table: schema.ticketReviewPrCards,
232
+ },
233
+ // CI reviews (migration 0082 / pg 0069) — the runs and their per-check items. Both carry their
234
+ // own accountId; both are erased explicitly above.
235
+ { name: 'ciReviews', col: schema.ciReviews.accountId, table: schema.ciReviews },
236
+ { name: 'ciReviewItems', col: schema.ciReviewItems.accountId, table: schema.ciReviewItems },
214
237
  { name: 'aiUsage', col: aiUsage.accountId, table: aiUsage },
215
238
  // Adopted from the plugin (migration 0074 / pg 0061) with AI Fix's fixer. No FKs, so nothing
216
239
  // cascades: the explicit delete above is the whole guarantee. (`reviewLearnings`, review
@@ -0,0 +1,73 @@
1
+ -- CI REVIEW (local-only feature, like the rest of Claude Review): a separate Claude run that explains
2
+ -- why checks FAILED on a PR's head, split out of the PR review's own run. Two tables:
3
+ -- ci_reviews one row per run (history kept). Keyed by (pr, head, sorted failing check names):
4
+ -- `failing_key` is what the run read live, `trigger_key` the synced set that
5
+ -- started it.
6
+ -- ci_review_items one row per failing check: Claude's cause, or the server's not-checked reason.
7
+ -- Run ids arrive in request paths, so the item table's tenancy is STRUCTURAL: a composite FK against
8
+ -- ci_reviews(id, account_id), whose parent key `ci_reviews_id_account` is created here; the run's PR
9
+ -- against pull_requests(id, account_id). Both delete paths (deleteRepo, retention's deletePrSubtree)
10
+ -- and eraseAccountData delete these rows explicitly. `claude_reviews.ci_failures` becomes LEGACY
11
+ -- (read-only); nothing here touches it. The Postgres twin is migrations-pg/0069_ci_reviews.sql.
12
+ CREATE TABLE IF NOT EXISTS `ci_reviews` (
13
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
14
+ `account_id` integer NOT NULL,
15
+ `workspace_id` integer NOT NULL,
16
+ `pr_id` integer NOT NULL,
17
+ `repo_id` integer NOT NULL,
18
+ `head_sha` text NOT NULL,
19
+ `failing_key` text,
20
+ `trigger_key` text,
21
+ `failing_checks` text,
22
+ `trigger` text DEFAULT 'manual' NOT NULL,
23
+ `status` text NOT NULL,
24
+ `model` text NOT NULL,
25
+ `cost_usd` real,
26
+ `input_tokens` integer,
27
+ `output_tokens` integer,
28
+ `num_turns` integer,
29
+ `error` text,
30
+ `refused` text,
31
+ `ci_state` text,
32
+ `summary` text,
33
+ `started_at` integer,
34
+ `completed_at` integer,
35
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
36
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
37
+ CONSTRAINT `cir_pr_account_fk` FOREIGN KEY (`pr_id`,`account_id`) REFERENCES `pull_requests`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
38
+ );
39
+ --> statement-breakpoint
40
+ CREATE INDEX IF NOT EXISTS `cir_account_pr_created_idx` ON `ci_reviews` (`account_id`,`pr_id`,`created_at`);
41
+ --> statement-breakpoint
42
+ CREATE INDEX IF NOT EXISTS `cir_account_ws_created_idx` ON `ci_reviews` (`account_id`,`workspace_id`,`created_at`);
43
+ --> statement-breakpoint
44
+ CREATE UNIQUE INDEX IF NOT EXISTS `ci_reviews_id_account` ON `ci_reviews` (`id`,`account_id`);
45
+ --> statement-breakpoint
46
+ CREATE TABLE IF NOT EXISTS `ci_review_items` (
47
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
48
+ `ci_review_id` integer NOT NULL,
49
+ `account_id` integer NOT NULL,
50
+ `ref` text,
51
+ `check_name` text NOT NULL,
52
+ `job_id` integer,
53
+ `step` text,
54
+ `url` text,
55
+ `sent` integer DEFAULT false NOT NULL,
56
+ `carried` integer DEFAULT false NOT NULL,
57
+ `status` text NOT NULL,
58
+ `not_checked_reason` text,
59
+ `cause` text,
60
+ `explanation` text,
61
+ `category` text,
62
+ `fixable_in_pr` integer,
63
+ `path` text,
64
+ `line` integer,
65
+ `suggestion` text,
66
+ `related_files` text,
67
+ `assessed_at_head` text NOT NULL,
68
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
69
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
70
+ CONSTRAINT `ciri_review_account_fk` FOREIGN KEY (`ci_review_id`,`account_id`) REFERENCES `ci_reviews`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
71
+ );
72
+ --> statement-breakpoint
73
+ CREATE INDEX IF NOT EXISTS `ciri_review_idx` ON `ci_review_items` (`ci_review_id`);
@@ -0,0 +1,9 @@
1
+ -- AUTO AI FIX, PER WORKSPACE — `workspaces.auto_fix_enabled`. After a succeeded AUTO Claude review
2
+ -- of the reader's OWN pull request, Limn may prepare one review-seeded AI Fix (never pushed;
3
+ -- coding/ai-fix/auto-fix.ts `maybeStartAutoFix`). This is that step's switch, edited in Settings
4
+ -- beside auto review and written by `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
5
+ -- ⚠ DEFAULT TRUE (1) and NOT NULL: auto fix ran unconditionally before this column existed, so
6
+ -- every existing workspace keeps today's behaviour until someone switches it off. It only matters
7
+ -- while auto review is on, because it only runs after an auto review. The Postgres twin is
8
+ -- migrations-pg/0070_workspace_auto_fix.sql.
9
+ ALTER TABLE `workspaces` ADD `auto_fix_enabled` integer DEFAULT 1 NOT NULL;
@@ -0,0 +1,7 @@
1
+ -- AUTO AI FIX IS OFF BY DEFAULT. 0083 added `workspaces.auto_fix_enabled` DEFAULT 1 so nothing
2
+ -- changed on upgrade; the product decision since is OFF until someone switches it on. Every
3
+ -- existing workspace is switched off once here (the switch shipped one release earlier, so no one
4
+ -- has had time to choose it). SQLite cannot change a column default in place, so the 0083 DDL
5
+ -- default stays 1; both workspace inserts in db/queries.ts write `autoFixEnabled: false`
6
+ -- explicitly. The Postgres twin is migrations-pg/0071_auto_fix_off_by_default.sql.
7
+ UPDATE `workspaces` SET `auto_fix_enabled` = 0;
@@ -0,0 +1,6 @@
1
+ -- AUTO REVIEW DAILY CAP, PER WORKSPACE. How many auto Claude reviews a workspace may start per UTC
2
+ -- day. OVERRIDES ONLY: NULL = the product default (20, `AUTO_REVIEW_DAILY_CAP`), resolved through
3
+ -- `resolveAutoReviewDailyCap` (review/claude-review/auto-settings.ts); the PUT route bounds it
4
+ -- 1..500. Nullable with no default, so no workspace insert has to name it. The Postgres twin is
5
+ -- migrations-pg/0072_auto_review_daily_cap.sql.
6
+ ALTER TABLE `workspaces` ADD `auto_review_daily_cap` integer;
@@ -0,0 +1,24 @@
1
+ -- TICKET REVIEW CONTRIBUTION CARDS. One row per (account, PR, head commit): a model-written,
2
+ -- verdict-free description of what that PR's head does (summary, interfaces, the story criteria it
3
+ -- moves forward, loose ends) plus the PR's changed files at that head (server-written). A ticket
4
+ -- review reads a member whose CURRENT head has a card as the card, not as its diff; the run writes a
5
+ -- card for every member it read as a diff (source 'story_check') and a cheap per-PR pre-pass writes
6
+ -- the overflow (source 'prepass', with its own cost). Re-writing the same head replaces the card.
7
+ -- Tenancy: a composite FK against pull_requests(id, account_id). Both delete paths (deleteRepo,
8
+ -- retention's deletePrSubtree, via db/ticket-review-prune.ts) and eraseAccountData delete these rows
9
+ -- explicitly. The Postgres twin is migrations-pg/0073_ticket_review_pr_cards.sql.
10
+ CREATE TABLE IF NOT EXISTS `ticket_review_pr_cards` (
11
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
12
+ `account_id` integer NOT NULL,
13
+ `pr_id` integer NOT NULL,
14
+ `head_sha` text NOT NULL,
15
+ `card` text NOT NULL,
16
+ `source` text NOT NULL,
17
+ `model` text NOT NULL,
18
+ `cost_usd` real,
19
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
20
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
21
+ CONSTRAINT `trpc_pr_account_fk` FOREIGN KEY (`pr_id`,`account_id`) REFERENCES `pull_requests`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
22
+ );
23
+ --> statement-breakpoint
24
+ CREATE UNIQUE INDEX IF NOT EXISTS `trpc_account_pr_head_ux` ON `ticket_review_pr_cards` (`account_id`,`pr_id`,`head_sha`);
@@ -0,0 +1,21 @@
1
+ -- AUTO-POSTING OF CLAUDE REVIEW OUTPUT (review/claude-review/auto-post.ts, review/ticket-review/
2
+ -- auto-post.ts). A per-workspace master switch, OFF for every workspace existing and new, plus the
3
+ -- per-run records of what was posted and the per-item "posted automatically" flag.
4
+ -- workspaces.auto_post_enabled NULL/false = off. Nullable, no default: no insert names it.
5
+ -- workspaces.auto_post_settings JSON, OVERRIDES ONLY ({ scope?, kinds? }); NULL = defaults.
6
+ -- claude_reviews.auto_post JSON ClaudeAutoPostRecord; NULL = never looked at.
7
+ -- claude_review_findings.posted_auto true = posted by auto-posting.
8
+ -- ticket_reviews.auto_post JSON TicketAutoPostRecord; NULL = never looked at.
9
+ -- ticket_review_items.posted_auto true = posted by auto-posting (carried with the posting).
10
+ -- The Postgres twin is migrations-pg/0074_auto_post.sql.
11
+ ALTER TABLE `workspaces` ADD `auto_post_enabled` integer;
12
+ --> statement-breakpoint
13
+ ALTER TABLE `workspaces` ADD `auto_post_settings` text;
14
+ --> statement-breakpoint
15
+ ALTER TABLE `claude_reviews` ADD `auto_post` text;
16
+ --> statement-breakpoint
17
+ ALTER TABLE `claude_review_findings` ADD `posted_auto` integer;
18
+ --> statement-breakpoint
19
+ ALTER TABLE `ticket_reviews` ADD `auto_post` text;
20
+ --> statement-breakpoint
21
+ ALTER TABLE `ticket_review_items` ADD `posted_auto` integer;
@@ -575,6 +575,48 @@
575
575
  "when": 1790658000000,
576
576
  "tag": "0081_review_request_requester",
577
577
  "breakpoints": true
578
+ },
579
+ {
580
+ "idx": 82,
581
+ "version": "6",
582
+ "when": 1790661600000,
583
+ "tag": "0082_ci_reviews",
584
+ "breakpoints": true
585
+ },
586
+ {
587
+ "idx": 83,
588
+ "version": "6",
589
+ "when": 1790665200000,
590
+ "tag": "0083_workspace_auto_fix",
591
+ "breakpoints": true
592
+ },
593
+ {
594
+ "idx": 84,
595
+ "version": "6",
596
+ "when": 1790751600000,
597
+ "tag": "0084_auto_fix_off_by_default",
598
+ "breakpoints": true
599
+ },
600
+ {
601
+ "idx": 85,
602
+ "version": "6",
603
+ "when": 1790838000000,
604
+ "tag": "0085_auto_review_daily_cap",
605
+ "breakpoints": true
606
+ },
607
+ {
608
+ "idx": 86,
609
+ "version": "6",
610
+ "when": 1790841600000,
611
+ "tag": "0086_ticket_review_pr_cards",
612
+ "breakpoints": true
613
+ },
614
+ {
615
+ "idx": 87,
616
+ "version": "6",
617
+ "when": 1790928000000,
618
+ "tag": "0087_auto_post",
619
+ "breakpoints": true
578
620
  }
579
621
  ]
580
622
  }
@@ -0,0 +1,65 @@
1
+ -- Postgres twin of migrations/0082_ci_reviews.sql, where the contract lives. CI review is
2
+ -- local-only, so the tables exist in Postgres for schema parity. `job_id` is BIGINT: GitHub Actions
3
+ -- job ids have passed 2^31.
4
+ CREATE TABLE IF NOT EXISTS "ci_reviews" (
5
+ "id" serial PRIMARY KEY NOT NULL,
6
+ "account_id" integer NOT NULL,
7
+ "workspace_id" integer NOT NULL,
8
+ "pr_id" integer NOT NULL,
9
+ "repo_id" integer NOT NULL,
10
+ "head_sha" text NOT NULL,
11
+ "failing_key" text,
12
+ "trigger_key" text,
13
+ "failing_checks" jsonb,
14
+ "trigger" text DEFAULT 'manual' NOT NULL,
15
+ "status" text NOT NULL,
16
+ "model" text NOT NULL,
17
+ "cost_usd" double precision,
18
+ "input_tokens" integer,
19
+ "output_tokens" integer,
20
+ "num_turns" integer,
21
+ "error" text,
22
+ "refused" text,
23
+ "ci_state" jsonb,
24
+ "summary" text,
25
+ "started_at" timestamp with time zone,
26
+ "completed_at" timestamp with time zone,
27
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
28
+ CONSTRAINT "ci_reviews_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE cascade,
29
+ CONSTRAINT "cir_pr_account_fk" FOREIGN KEY ("pr_id","account_id") REFERENCES "pull_requests"("id","account_id") ON DELETE cascade
30
+ );
31
+ --> statement-breakpoint
32
+ CREATE INDEX IF NOT EXISTS "cir_account_pr_created_idx" ON "ci_reviews" USING btree ("account_id","pr_id","created_at");
33
+ --> statement-breakpoint
34
+ CREATE INDEX IF NOT EXISTS "cir_account_ws_created_idx" ON "ci_reviews" USING btree ("account_id","workspace_id","created_at");
35
+ --> statement-breakpoint
36
+ CREATE UNIQUE INDEX IF NOT EXISTS "ci_reviews_id_account" ON "ci_reviews" USING btree ("id","account_id");
37
+ --> statement-breakpoint
38
+ CREATE TABLE IF NOT EXISTS "ci_review_items" (
39
+ "id" serial PRIMARY KEY NOT NULL,
40
+ "ci_review_id" integer NOT NULL,
41
+ "account_id" integer NOT NULL,
42
+ "ref" text,
43
+ "check_name" text NOT NULL,
44
+ "job_id" bigint,
45
+ "step" text,
46
+ "url" text,
47
+ "sent" boolean DEFAULT false NOT NULL,
48
+ "carried" boolean DEFAULT false NOT NULL,
49
+ "status" text NOT NULL,
50
+ "not_checked_reason" text,
51
+ "cause" text,
52
+ "explanation" text,
53
+ "category" text,
54
+ "fixable_in_pr" boolean,
55
+ "path" text,
56
+ "line" integer,
57
+ "suggestion" text,
58
+ "related_files" jsonb,
59
+ "assessed_at_head" text NOT NULL,
60
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
61
+ CONSTRAINT "ci_review_items_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE cascade,
62
+ CONSTRAINT "ciri_review_account_fk" FOREIGN KEY ("ci_review_id","account_id") REFERENCES "ci_reviews"("id","account_id") ON DELETE cascade
63
+ );
64
+ --> statement-breakpoint
65
+ CREATE INDEX IF NOT EXISTS "ciri_review_idx" ON "ci_review_items" USING btree ("ci_review_id");
@@ -0,0 +1,3 @@
1
+ -- Postgres twin of migrations/0083_workspace_auto_fix.sql — the per-workspace auto AI Fix switch.
2
+ -- DEFAULT true so every existing workspace keeps today's behaviour. See the sqlite original.
3
+ ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "auto_fix_enabled" boolean DEFAULT true NOT NULL;
@@ -0,0 +1,5 @@
1
+ -- Postgres twin of migrations/0084_auto_fix_off_by_default.sql: auto AI Fix is OFF by default,
2
+ -- and every existing workspace is switched off once.
3
+ ALTER TABLE "workspaces" ALTER COLUMN "auto_fix_enabled" SET DEFAULT false;
4
+ --> statement-breakpoint
5
+ UPDATE "workspaces" SET "auto_fix_enabled" = false;
@@ -0,0 +1,3 @@
1
+ -- Postgres twin of migrations/0085_auto_review_daily_cap.sql: the per-workspace auto review daily
2
+ -- cap. NULL = the default 20.
3
+ ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "auto_review_daily_cap" integer;
@@ -0,0 +1,17 @@
1
+ -- Postgres twin of migrations/0086_ticket_review_pr_cards.sql: ticket review contribution cards,
2
+ -- one row per (account, PR, head commit). The contract lives in schema.sqlite.ts.
3
+ CREATE TABLE IF NOT EXISTS "ticket_review_pr_cards" (
4
+ "id" serial PRIMARY KEY NOT NULL,
5
+ "account_id" integer NOT NULL,
6
+ "pr_id" integer NOT NULL,
7
+ "head_sha" text NOT NULL,
8
+ "card" jsonb NOT NULL,
9
+ "source" text NOT NULL,
10
+ "model" text NOT NULL,
11
+ "cost_usd" double precision,
12
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
13
+ CONSTRAINT "ticket_review_pr_cards_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE cascade,
14
+ CONSTRAINT "trpc_pr_account_fk" FOREIGN KEY ("pr_id","account_id") REFERENCES "pull_requests"("id","account_id") ON DELETE cascade
15
+ );
16
+ --> statement-breakpoint
17
+ CREATE UNIQUE INDEX IF NOT EXISTS "trpc_account_pr_head_ux" ON "ticket_review_pr_cards" USING btree ("account_id","pr_id","head_sha");
@@ -0,0 +1,14 @@
1
+ -- Postgres twin of migrations/0087_auto_post.sql: auto-posting of Claude review output. A
2
+ -- per-workspace switch (NULL/false = off) + overrides-only settings, the per-run records and the
3
+ -- per-item "posted automatically" flags. The contract lives in schema.sqlite.ts.
4
+ ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "auto_post_enabled" boolean;
5
+ --> statement-breakpoint
6
+ ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "auto_post_settings" jsonb;
7
+ --> statement-breakpoint
8
+ ALTER TABLE "claude_reviews" ADD COLUMN IF NOT EXISTS "auto_post" jsonb;
9
+ --> statement-breakpoint
10
+ ALTER TABLE "claude_review_findings" ADD COLUMN IF NOT EXISTS "posted_auto" boolean;
11
+ --> statement-breakpoint
12
+ ALTER TABLE "ticket_reviews" ADD COLUMN IF NOT EXISTS "auto_post" jsonb;
13
+ --> statement-breakpoint
14
+ ALTER TABLE "ticket_review_items" ADD COLUMN IF NOT EXISTS "posted_auto" boolean;
@@ -484,6 +484,48 @@
484
484
  "when": 1790658000000,
485
485
  "tag": "0068_review_request_requester",
486
486
  "breakpoints": true
487
+ },
488
+ {
489
+ "idx": 69,
490
+ "version": "7",
491
+ "when": 1790661600000,
492
+ "tag": "0069_ci_reviews",
493
+ "breakpoints": true
494
+ },
495
+ {
496
+ "idx": 70,
497
+ "version": "7",
498
+ "when": 1790665200000,
499
+ "tag": "0070_workspace_auto_fix",
500
+ "breakpoints": true
501
+ },
502
+ {
503
+ "idx": 71,
504
+ "version": "7",
505
+ "when": 1790751600000,
506
+ "tag": "0071_auto_fix_off_by_default",
507
+ "breakpoints": true
508
+ },
509
+ {
510
+ "idx": 72,
511
+ "version": "7",
512
+ "when": 1790838000000,
513
+ "tag": "0072_auto_review_daily_cap",
514
+ "breakpoints": true
515
+ },
516
+ {
517
+ "idx": 73,
518
+ "version": "7",
519
+ "when": 1790841600000,
520
+ "tag": "0073_ticket_review_pr_cards",
521
+ "breakpoints": true
522
+ },
523
+ {
524
+ "idx": 74,
525
+ "version": "7",
526
+ "when": 1790928000000,
527
+ "tag": "0074_auto_post",
528
+ "breakpoints": true
487
529
  }
488
530
  ]
489
531
  }
@@ -30,6 +30,12 @@
30
30
  // clock scores higher and wins `compareScored`'s age tie-break). Ties beyond that break on the
31
31
  // item's own id, so two reads of unchanged data keep the same card.
32
32
  //
33
+ // ⚠ A CLAUDE REVIEW IS EXEMPT: it never competes for the PR's one card and is never removed by
34
+ // it. A finished run, and above all an auto RE-review on a PR that already holds another job (a
35
+ // reply in your thread), is new work of its own; folding it under that job hid every re-review on
36
+ // a busy PR. Each run is its own item (its own id and `finishedAt` clock), so a newer run shows
37
+ // even after an older one's card was dismissed.
38
+ //
33
39
  // ⚠ IT IS A BOARD RULE, NOT A NOTIFICATION RULE. `getWorkspaceInsights` asks for it; the
34
40
  // account-wide `GET /api/my-turn` does not, because the browser-notification watcher diffs item
35
41
  // ids — a deduplicated list would flip the winner whenever the top job cleared and announce the
@@ -285,14 +291,6 @@ export function onePerPr(mt, order) {
285
291
  ref: `${i.threadId}`,
286
292
  }),
287
293
  };
288
- const claude = {
289
- of: (i) => ({
290
- prId: i.prId,
291
- reason: 'claude_review',
292
- clockMs: clockMsOf(i.finishedAt),
293
- ref: `${i.reviewId}`,
294
- }),
295
- };
296
294
  for (const i of mt.awaitingReview)
297
295
  offer(S.awaitingReview.of(i));
298
296
  for (const i of mt.mentions)
@@ -319,8 +317,6 @@ export function onePerPr(mt, order) {
319
317
  offer(threadReplies.of(i));
320
318
  for (const i of mt.ownThreads)
321
319
  offer(ownThreads.of(i));
322
- for (const i of mt.claudeReviewsToAction)
323
- offer(claude.of(i));
324
320
  return {
325
321
  awaitingReview: mt.awaitingReview.filter((i) => winnerIs(S.awaitingReview.of(i))),
326
322
  mentions: mt.mentions.filter((i) => winnerIs(S.mentions.of(i))),
@@ -335,7 +331,8 @@ export function onePerPr(mt, order) {
335
331
  threadsAwaiting: mt.threadsAwaiting.filter((i) => winnerIs(threadsAwaiting.of(i))),
336
332
  threadReplies: mt.threadReplies.filter((i) => winnerIs(threadReplies.of(i))),
337
333
  ownThreads: mt.ownThreads.filter((i) => winnerIs(ownThreads.of(i))),
338
- claudeReviewsToAction: mt.claudeReviewsToAction.filter((i) => winnerIs(claude.of(i))),
334
+ // Exempt (header, rule 2): every Claude review keeps its own card.
335
+ claudeReviewsToAction: mt.claudeReviewsToAction,
339
336
  // Repo-grained: a branch is not a pull request, and never shares a key with one.
340
337
  redTrunks: mt.redTrunks,
341
338
  };