limn-review 0.1.182 → 0.1.184

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.
@@ -177,6 +177,8 @@ export async function eraseAccountData(accountId) {
177
177
  await tx.delete(schema.teamClaimLines).where(eq(schema.teamClaimLines.accountId, accountId)).execute();
178
178
  // The teammate lines the AI Review tab names (migration 0159): GitHub logins. No FK.
179
179
  await tx.delete(schema.teamClaimSeen).where(eq(schema.teamClaimSeen.accountId, accountId)).execute();
180
+ // The skipped lines (migration 0166): heads and providers. No FK.
181
+ await tx.delete(schema.teamClaimSkipLines).where(eq(schema.teamClaimSkipLines.accountId, accountId)).execute();
180
182
  // The worktree ledger (migration 0146): which PR / ticket a checkout on disk was for. No FK. The
181
183
  // directories themselves become unledgered orphans the worktree sweep collects by age.
182
184
  await tx.delete(schema.worktreeLedger).where(eq(schema.worktreeLedger.accountId, accountId)).execute();
@@ -370,6 +372,8 @@ export function accountScopedTables() {
370
372
  { name: 'teamClaimLines', col: schema.teamClaimLines.accountId, table: schema.teamClaimLines },
371
373
  // The teammate lines (migration 0159), erased explicitly above.
372
374
  { name: 'teamClaimSeen', col: schema.teamClaimSeen.accountId, table: schema.teamClaimSeen },
375
+ // The skipped lines (migration 0166), erased explicitly above.
376
+ { name: 'teamClaimSkipLines', col: schema.teamClaimSkipLines.accountId, table: schema.teamClaimSkipLines },
373
377
  // The worktree ledger (migration 0146), erased explicitly above.
374
378
  { name: 'worktreeLedger', col: schema.worktreeLedger.accountId, table: schema.worktreeLedger },
375
379
  { name: 'workspaceSonar', col: schema.workspaceSonar.accountId, table: schema.workspaceSonar },
@@ -0,0 +1,20 @@
1
+ -- AN AUTO REVIEW THIS LIMN SKIPPED, SAID ON THE PR (docs/CLAUDE-REVIEW.md § Team claims → The skipped
2
+ -- line). When the reader's Limn does not start an auto review it would otherwise run (the AI budget is
3
+ -- used, the workspace's daily limit is reached, the provider is over its usage limit, AI is not signed
4
+ -- in), it posts ONE green `limn/review-<provider>` line on the head. One row per (account, PR, head,
5
+ -- provider, reason), claimed BEFORE the GitHub write: at most once, never retried. Additive.
6
+ CREATE TABLE `team_claim_skip_lines` (
7
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
8
+ `account_id` integer NOT NULL,
9
+ `pr_id` integer NOT NULL,
10
+ `head_sha` text NOT NULL,
11
+ `provider` text NOT NULL,
12
+ `reason` text NOT NULL,
13
+ `state` text NOT NULL,
14
+ `description` text,
15
+ `last_error` text,
16
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
17
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL
18
+ );
19
+ --> statement-breakpoint
20
+ CREATE UNIQUE INDEX `team_claim_skip_lines_key` ON `team_claim_skip_lines` (`account_id`,`pr_id`,`head_sha`,`provider`,`reason`);
@@ -1163,6 +1163,13 @@
1163
1163
  "when": 1792224073016,
1164
1164
  "tag": "0165_withdraw_approval_on_push",
1165
1165
  "breakpoints": true
1166
+ },
1167
+ {
1168
+ "idx": 166,
1169
+ "version": "6",
1170
+ "when": 1792224075016,
1171
+ "tag": "0166_team_claim_skip_lines",
1172
+ "breakpoints": true
1166
1173
  }
1167
1174
  ]
1168
1175
  }
@@ -3980,6 +3980,28 @@ export const teamClaimSeen = sqliteTable('team_claim_seen', {
3980
3980
  }, (t) => ({
3981
3981
  prHeadProviderUx: uniqueIndex('team_claim_seen_account_pr_head_provider').on(t.accountId, t.prId, t.headSha, t.provider),
3982
3982
  }));
3983
+ // 0166. AN AUTO REVIEW THIS LIMN SKIPPED, SAID ON THE PR (docs/CLAUDE-REVIEW.md § Team claims → The
3984
+ // skipped line): one row per (account, PR, head, provider, reason) — the claim, written BEFORE the
3985
+ // GitHub write, so the line "Cleared by alex: auto review skipped · AI budget used" goes out at most
3986
+ // ONCE and a failed write is never retried. `reason` 'budget' | 'daily_cap' | 'usage_limit' |
3987
+ // 'not_ready'; `state` 'posting' (claimed) → 'posted' | 'failed' (GitHub refused; `last_error`) |
3988
+ // 'not_needed' (another line already sits on the head). No FK on pr_id: BOTH delete paths delete its
3989
+ // rows (db/team-claim-lines-delete.ts), the erasure deletes everything.
3990
+ export const teamClaimSkipLines = sqliteTable('team_claim_skip_lines', {
3991
+ id: integer('id').primaryKey({ autoIncrement: true }),
3992
+ accountId: integer('account_id').notNull(),
3993
+ prId: integer('pr_id').notNull(),
3994
+ headSha: text('head_sha').notNull(),
3995
+ provider: text('provider').notNull(),
3996
+ reason: text('reason').notNull(),
3997
+ state: text('state', { enum: ['posting', 'posted', 'failed', 'not_needed'] }).notNull(),
3998
+ description: text('description'),
3999
+ lastError: text('last_error'),
4000
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
4001
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
4002
+ }, (t) => ({
4003
+ keyUx: uniqueIndex('team_claim_skip_lines_key').on(t.accountId, t.prId, t.headSha, t.provider, t.reason),
4004
+ }));
3983
4005
  // 0139. THIS DATABASE'S OWN FACTS (key → value) — never an account's, so not account-scoped and not
3984
4006
  // erased with one. `instance_id`: a random id written once (db/instance-id.ts) that names this
3985
4007
  // database file wherever it is moved; the reference-docs janitor's ownership marker.
@@ -17,5 +17,8 @@ export async function deleteSettledClaimLinesForPrs(tx, prIds) {
17
17
  // The teammate lines the AI Review tab names (migration 0159): nothing on GitHub to release, so
18
18
  // every row of these PRs goes.
19
19
  await tx.delete(schema.teamClaimSeen).where(inArray(schema.teamClaimSeen.prId, prIds)).execute();
20
+ // The skipped lines this machine posted (migration 0166): a settled success on GitHub, nothing to
21
+ // release, so every row of these PRs goes.
22
+ await tx.delete(schema.teamClaimSkipLines).where(inArray(schema.teamClaimSkipLines.prId, prIds)).execute();
20
23
  }
21
24
  //# sourceMappingURL=team-claim-lines-delete.js.map
@@ -1,6 +1,7 @@
1
1
  // TEAMMATES' POSTED REVIEWS — THE READ SIDE (docs/CLAUDE-REVIEW.md § Teammates' reviews). The import
2
2
  // is db/teammate-reviews-import.ts. ⚠ This module opens NO database at import (every reader takes an
3
3
  // executor): review modules and their fake-context tests import it.
4
+ import { createHash } from 'node:crypto';
4
5
  import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm';
5
6
  import * as schema from './schema.sqlite.js';
6
7
  const { users, reviewComments, commits, pullRequests, repos } = schema;
@@ -67,6 +68,9 @@ export async function listTeammateReviews(exec, accountId, prId) {
67
68
  kind: crf.postedCommentKind,
68
69
  githubCommentId: crf.githubCommentId,
69
70
  nodeId: crf.sourceCommentNodeId,
71
+ postedAt: crf.postedAt,
72
+ side: crf.side,
73
+ reminder: crf.reminder,
70
74
  })
71
75
  .from(crf)
72
76
  .where(inArray(crf.reviewId, rows.map((r) => r.id)))
@@ -114,6 +118,13 @@ export async function listTeammateReviews(exec, accountId, prId) {
114
118
  threadId: kind === 'inline' && f.nodeId != null ? (threadOf.get(f.nodeId) ?? null) : null,
115
119
  ...providersOf(f.nodeId),
116
120
  githubCommentId: f.githubCommentId ?? null,
121
+ postedAt: f.postedAt != null ? f.postedAt.toISOString() : null,
122
+ side: f.side === 'LEFT' ? 'LEFT' : 'RIGHT',
123
+ diffAnchorId: createHash('sha256').update(f.path, 'utf8').digest('hex'),
124
+ // The reader's reminder (stored beside the finding, never its own post); wire shape only.
125
+ reminder: f.reminder == null
126
+ ? null
127
+ : { githubCommentId: f.reminder.githubCommentId, postedCommentKind: f.reminder.postedCommentKind, postedAt: f.reminder.postedAt },
117
128
  };
118
129
  });
119
130
  const at = r.postedAt ?? r.createdAt;
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // THE RULE, in order (the first that fails decides):
7
7
  // 0. the workspace holding the PR's repo has auto-posting ON and its `ciFailures` kind on
8
- // (default on, inside auto-posting, which is itself OFF until switched on). Off ⇒ nothing is
8
+ // (default on, inside auto-posting, itself ON by default since 2026-11-04). Off ⇒ nothing is
9
9
  // recorded at all;
10
10
  // 1. the run is an AUTO run that SUCCEEDED (a CI check you started yourself is never posted) and
11
11
  // has never been claimed;
@@ -201,7 +201,7 @@ async function withdrawOnPush(ctx, { accountId, prId, headSha }, deps) {
201
201
  .execute());
202
202
  const pr = rows[0];
203
203
  // Auto review AND auto-posting on (the approval could only exist there), and the switch not off.
204
- if (!pr || pr.reviewOn !== true || pr.postOn !== true || pr.withdrawOn === false)
204
+ if (!pr || pr.reviewOn !== true || pr.postOn === false || pr.withdrawOn === false)
205
205
  return { kind: 'off' };
206
206
  if (pr.state !== 'open')
207
207
  return { kind: 'none' };
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // THE RULE, in order (the first that fails decides):
5
5
  // 0. the workspace holding the PR's repo has auto-posting ON (`workspaces.auto_post_enabled`,
6
- // migration 0087 / pg 0074 — OFF for every workspace until switched on). Off ⇒ nothing is
6
+ // migration 0087 — ON by default since 2026-11-04: only a stored false is off). Off ⇒ nothing is
7
7
  // recorded at all;
8
8
  // 1. WHICH PRs (`autoPostEligibility`, pure): open (never merged or closed), not a draft, not
9
9
  // bot-authored (the workspace's bot union), and under scope 'mine' the PR is the reader's own,
@@ -35,12 +35,14 @@ import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
35
35
  // by the wire shape and the sweeper's roster alike. Bounded 1..500 at the route.
36
36
  //
37
37
  // AUTO-POSTING rides the same route as two more columns (migration 0087 / pg 0074):
38
- // `auto_post_enabled` (NULL/false = OFF — for every workspace, existing and new, until someone
39
- // switches it on: it writes to GitHub AS THE READER) and `auto_post_settings`, OVERRIDES ONLY
38
+ // `auto_post_enabled` (⚠ ON BY DEFAULT since 2026-11-04 — NULL reads ON, only a stored false is
39
+ // off, in every reader and in the writer; it writes to GitHub AS THE READER, but only for AUTO runs,
40
+ // so it does nothing until auto review is switched on) and `auto_post_settings`, OVERRIDES ONLY
40
41
  // ({ scope?, kinds? }; NULL = the product defaults), resolved through `resolveAutoPostSettings`.
41
42
  // Stored independently of `auto_review_enabled`: it only ever acts on AUTO runs, so it does nothing
42
- // while auto review is off, but switching auto review off and on keeps it. The `autoVerdict` and
43
- // `autoResolve` switches (both OFF by default) ride the same overrides JSON (no column), and so
43
+ // while auto review is off, but switching auto review off and on keeps it. The `autoVerdict` (ON by
44
+ // default since 2026-11-04: absent = on, a stored false = off) and `autoResolve` (OFF by default)
45
+ // switches ride the same overrides JSON (no column), and so
44
46
  // does `approveImpact` (which blast-radius levels an auto verdict may APPROVE; all by default).
45
47
  // So do the two TEAMMATE-LIFT opt-ins, `teamLiftConsent` and `liftTeammateBlocks` (both OFF by
46
48
  // default; docs/CLAUDE-REVIEW.md § Lifting a teammate's block).
@@ -103,7 +105,8 @@ approveLeftovers) {
103
105
  approveImpact[k] = si[k];
104
106
  }
105
107
  return {
106
- enabled: enabled === true,
108
+ // ON unless explicitly switched off (2026-11-04).
109
+ enabled: enabled !== false,
107
110
  scope: isScope(stored?.scope) ? stored.scope : AUTO_POST_DEFAULT_SCOPE,
108
111
  kinds,
109
112
  autoVerdict: typeof stored?.autoVerdict === 'boolean' ? stored.autoVerdict : AUTO_POST_DEFAULT_AUTO_VERDICT,
@@ -218,6 +221,7 @@ export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
218
221
  const rows = (await ctx.db
219
222
  .select({
220
223
  workspaceId: w.id,
224
+ autoReviewOn: w.autoReviewEnabled,
221
225
  enabled: w.autoPostEnabled,
222
226
  stored: w.autoPostSettings,
223
227
  ...verdictRuleCols(w),
@@ -234,6 +238,7 @@ export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
234
238
  workspaceId: r.workspaceId,
235
239
  settings: resolveAutoPostSettings(r.enabled, r.stored, r.approveLeftovers),
236
240
  pairStrictness: resolvePairStrictness(r.pairStrictness),
241
+ autoReviewOn: r.autoReviewOn === true,
237
242
  }
238
243
  : null;
239
244
  }
@@ -450,7 +455,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
450
455
  set.approveLeftovers = leftoversStored;
451
456
  }
452
457
  if (autoPost !== undefined) {
453
- if (postAfter.enabled !== (existing.autoPostEnabled === true))
458
+ if (postAfter.enabled !== (existing.autoPostEnabled !== false))
454
459
  set.autoPostEnabled = postAfter.enabled;
455
460
  if (JSON.stringify(postOverrides) !== JSON.stringify(existing.autoPostSettings ?? null)) {
456
461
  set.autoPostSettings = postOverrides;
@@ -524,7 +529,7 @@ function settingChanges(set, existing, next) {
524
529
  ]);
525
530
  }
526
531
  if ('autoPostEnabled' in set)
527
- out.push(['auto_post', `Auto-posting ${onOff(next.autoPostEnabled === true)}`]);
532
+ out.push(['auto_post', `Auto-posting ${onOff(next.autoPostEnabled !== false)}`]);
528
533
  else if ('autoPostSettings' in set)
529
534
  out.push(['auto_post', 'Auto-posting settings changed']);
530
535
  return out;
@@ -188,9 +188,26 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
188
188
  // row that would use up its one automatic review. The PRs still qualify on a later tick.
189
189
  if (!agenticRunReady(ctx, 'review')) {
190
190
  result.stopped = 'ai_not_ready';
191
+ // THE SKIPPED LINE (team-claim-skip.ts): the tick says so on each due head, once. A press does not
192
+ // (the SPA shows its own line, and the next tick posts).
193
+ if (!press) {
194
+ sweeping = true;
195
+ try {
196
+ await noteSkippedWhileHeld(ctx, nowMs, candidatesOf, 'not_ready');
197
+ }
198
+ catch (err) {
199
+ ctx.log.warn(`auto review skipped lines: ${err instanceof Error ? err.message : String(err)}`);
200
+ }
201
+ finally {
202
+ sweeping = false;
203
+ }
204
+ }
191
205
  return result;
192
206
  }
193
207
  sweeping = true;
208
+ // Due heads this pass did not start, and why (the daily cap; the spend budget) — said on the PR once
209
+ // each, at the end of the pass (team-claim-skip.ts).
210
+ const skipped = [];
194
211
  try {
195
212
  const dayStartMs = utcDayStartMs(nowMs);
196
213
  const liveHeads = new Set();
@@ -224,8 +241,11 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
224
241
  result.overBudget = true;
225
242
  }
226
243
  for (const ws of roster) {
227
- if (budgetOk.get(ws.accountId) === false)
244
+ if (budgetOk.get(ws.accountId) === false) {
245
+ // Over the spend budget: nothing is queued; the due heads are said on the PR (read-only pass).
246
+ skipped.push(...(await dueHeadsHeld(ctx, nowMs, candidatesOf, ws, 'budget')));
228
247
  continue;
248
+ }
229
249
  if (autoLaneRoom() === 0) {
230
250
  result.stopped = 'lane_full';
231
251
  break;
@@ -273,7 +293,9 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
273
293
  continue;
274
294
  if (budget <= 0) {
275
295
  result.capped = true;
276
- break;
296
+ // Said on the PR (team-claim-skip.ts); the rest of the list is noted the same way.
297
+ skipped.push({ accountId: ws.accountId, prId, headSha: null, reason: 'daily_cap' });
298
+ continue;
277
299
  }
278
300
  const k = `${ws.accountId}:${prId}`;
279
301
  livePrs.add(k);
@@ -523,6 +545,9 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
523
545
  continue;
524
546
  if (budget <= 0) {
525
547
  result.capped = true;
548
+ // A MOVED HEAD is a head never reviewed: said on the PR. A same-head comment burst is not.
549
+ if (commentsAtMs == null)
550
+ skipped.push({ accountId: ws.accountId, prId, headSha, reason: 'daily_cap' });
526
551
  continue;
527
552
  }
528
553
  const r = enqueueAutoReview(ctx, ws.accountId, prId, ws.workspaceId, commentsAtMs ?? null, { pressed });
@@ -563,6 +588,7 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
563
588
  }
564
589
  if (result.queued > 0)
565
590
  ctx.log.info(`auto claude review: queued ${result.queued} PR(s)`);
591
+ await postSkippedHeads(ctx, skipped);
566
592
  return result;
567
593
  }
568
594
  catch (err) {
@@ -573,6 +599,96 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
573
599
  sweeping = false;
574
600
  }
575
601
  }
602
+ /** At most this many PRs are said on GitHub per tick (each once per head and reason, recorded). */
603
+ export const AUTO_SKIP_LINES_PER_TICK = 25;
604
+ /**
605
+ * The heads this workspace's tick WOULD have started — first reviews, and moved heads whose start rule
606
+ * says go — without starting anything or touching a clock. Read-only: the spend budget and AI
607
+ * readiness hold the whole workspace, so these are said on the PR instead. Not a held PR (a teammate's
608
+ * claim, already visible), not one waiting in the lane, not a retry or a same-head comment burst.
609
+ */
610
+ async function dueHeadsHeld(ctx, nowMs, candidatesOf, ws, reason) {
611
+ const waiting = autoPendingPrIds();
612
+ const res = await candidatesOf(ws.accountId, ws.workspaceId, {
613
+ openedSinceMs: ws.enabledAtMs,
614
+ dayStartMs: utcDayStartMs(nowMs),
615
+ limit: ws.dailyCap + waiting.size,
616
+ });
617
+ if (!res)
618
+ return [];
619
+ const retrying = new Set((res.retry ?? []).map((r) => r.prId));
620
+ const held = async (prId, headSha) => {
621
+ const h = teamClaimHeldHead(ws.accountId, prId, nowMs);
622
+ if (h == null)
623
+ return false;
624
+ return (headSha ?? (await syncedHeadOf(ctx, ws.accountId, prId))) === h;
625
+ };
626
+ const out = [];
627
+ for (const prId of res.prIds) {
628
+ if (waiting.has(prId) || (await held(prId, null)))
629
+ continue;
630
+ out.push({ accountId: ws.accountId, prId, headSha: null, reason });
631
+ }
632
+ for (const cand of res.reReview ?? []) {
633
+ const { prId, headSha, commentsAtMs, lastRunAtMs } = cand;
634
+ if (commentsAtMs != null || waiting.has(prId) || retrying.has(prId))
635
+ continue;
636
+ const seen = headSeen.get(`${ws.accountId}:${prId}`);
637
+ const due = autoReviewDue({
638
+ nowMs,
639
+ settle: {
640
+ quietSinceMs: seen && seen.key === `${headSha}|` ? seen.firstSeenMs : nowMs,
641
+ burstStartMs: seen ? seen.burstStartMs : nowMs,
642
+ reason: 'head',
643
+ },
644
+ lastRunAtMs: lastRunAtMs ?? null,
645
+ });
646
+ if (!due.due || (await held(prId, headSha)))
647
+ continue;
648
+ out.push({ accountId: ws.accountId, prId, headSha, reason });
649
+ }
650
+ return out;
651
+ }
652
+ /** AI not ready: every switched-on workspace's due heads, said on the PR. */
653
+ async function noteSkippedWhileHeld(ctx, nowMs, candidatesOf, reason) {
654
+ const heads = [];
655
+ for (const ws of await listAutoReviewWorkspaces(ctx)) {
656
+ heads.push(...(await dueHeadsHeld(ctx, nowMs, candidatesOf, ws, reason)));
657
+ if (heads.length >= AUTO_SKIP_LINES_PER_TICK)
658
+ break;
659
+ }
660
+ await postSkippedHeads(ctx, heads);
661
+ }
662
+ /** Post the skipped lines (each provider's context, once per head and reason). Never throws. */
663
+ async function postSkippedHeads(ctx, heads) {
664
+ if (heads.length === 0)
665
+ return;
666
+ try {
667
+ const m = await import('./team-claim-skip.js');
668
+ let notReady = null;
669
+ for (const h of heads.slice(0, AUTO_SKIP_LINES_PER_TICK)) {
670
+ let skips;
671
+ if (h.reason === 'not_ready') {
672
+ if (!notReady) {
673
+ const [{ activeProviderId }, { activeRuntimeStatus }] = await Promise.all([
674
+ import('../../ai/provider-setting.js'),
675
+ import('../../ai/active-runtime.js'),
676
+ ]);
677
+ notReady = { provider: activeProviderId(), notSetUp: activeRuntimeStatus().runtime !== 'ready' };
678
+ }
679
+ skips = [{ provider: notReady.provider, reason: 'not_ready', notSetUp: notReady.notSetUp }];
680
+ }
681
+ else {
682
+ const providers = await m.autoProvidersForPr(ctx, h.accountId, h.prId);
683
+ skips = providers.map((provider) => ({ provider, reason: h.reason }));
684
+ }
685
+ await m.postAutoSkipLines(ctx, { accountId: h.accountId, prId: h.prId, headSha: h.headSha }, skips);
686
+ }
687
+ }
688
+ catch (err) {
689
+ ctx.log.warn(`auto review skipped lines: ${err instanceof Error ? err.message : String(err)}`);
690
+ }
691
+ }
576
692
  function isSettleWait(r) {
577
693
  return r === 'comments' || r === 'commits' || r === 'ci';
578
694
  }
@@ -5,7 +5,8 @@ import { getTicketSource } from '../../tracker/ticket-source.js';
5
5
  import { agenticRunReady } from './ai-ready.js';
6
6
  import { automaticRunAllowed } from '../budget-gate.js';
7
7
  import { detectWake, FORCE_RELEASE_MS, runsToInterrupt, WATCHDOG_TICK_MS } from './run-watchdog.js';
8
- import { automaticRunProviders, isLockedOut, LOCKOUT_MAX_MS, noteProviderFailure, restoreUsageLockouts } from '../../ai/usage-lockout.js';
8
+ import { automaticRunProviders, isLockedOut, LOCKOUT_MAX_MS, noteProviderFailure, restoreUsageLockouts, usageLockout, } from '../../ai/usage-lockout.js';
9
+ import { postAutoSkipLinesLater } from './team-claim-skip.js';
9
10
  import { decideReviewMode } from './routing.js';
10
11
  import { offeredSpecialists } from './specialists.js';
11
12
  import { buildUserPrompt, peerRef, pickReviewNonce, skipSummary, sonarHasContent, systemPromptForMode, untrustedTexts, } from './prompts.js';
@@ -339,11 +340,25 @@ async function startAutoItem(a) {
339
340
  let launched = false;
340
341
  // A claim posted before the row could be written is released ("free to claim") below.
341
342
  let teamClaim = null;
343
+ // THE SKIPPED LINE (team-claim-skip.ts): an item dropped here for the budget, a usage limit or AI not
344
+ // ready says so on the PR head, once — only a first review or a moved head (a retry's or a same-head
345
+ // comment burst's head already carries this Limn's own line). Posted in the background.
346
+ const sayOnPr = a.retryOfReviewId == null && a.commentsAtMs == null;
347
+ const sayNotReady = () => {
348
+ if (!sayOnPr)
349
+ return;
350
+ postAutoSkipLinesLater(ctx, { accountId, prId }, async () => {
351
+ const { activeRuntimeStatus } = await import('../../ai/active-runtime.js');
352
+ return [{ provider: activeProviderId(), reason: 'not_ready', notSetUp: activeRuntimeStatus().runtime !== 'ready' }];
353
+ });
354
+ };
342
355
  try {
343
356
  // AI went away since the sweep (runtime removed, signed out): drop the item BEFORE its row
344
357
  // exists, or the PR's one automatic review is spent on a failure (ai-ready.ts).
345
- if (!agenticRunReady(ctx, 'review'))
358
+ if (!agenticRunReady(ctx, 'review')) {
359
+ sayNotReady();
346
360
  return;
361
+ }
347
362
  // An AUTOMATIC run goes to the DEFAULT provider (Settings → AI), read now — unless the workspace
348
363
  // asks for Claude + Codex (`auto_review_providers = 'both'`): then a PAIR when both can review,
349
364
  // else the one that can. ⚠ A provider LOCKED OUT by its usage limit (ai/usage-lockout.ts) never
@@ -368,6 +383,18 @@ async function startAutoItem(a) {
368
383
  providers = pick(true);
369
384
  if (providers.length === 0) {
370
385
  ctx.log.info(`auto claude review pr ${prId}: skipped — no provider outside its usage limit`);
386
+ // Each provider that would have run: over its usage limit (with its end), else not signed in.
387
+ if (sayOnPr) {
388
+ const wanted = wantsPair ? [...PAIR_ORDER] : [activeProviderId()];
389
+ const skips = wanted.flatMap((p) => {
390
+ if (isLockedOut(p, nowMs))
391
+ return [{ provider: p, reason: 'usage_limit', untilMs: usageLockout(p, nowMs)?.untilMs ?? null }];
392
+ // A pair half that is simply not signed in is not a skip of a review it would run.
393
+ return wantsPair ? [] : [{ provider: p, reason: 'not_ready' }];
394
+ });
395
+ if (skips.length > 0)
396
+ postAutoSkipLinesLater(ctx, { accountId, prId }, skips);
397
+ }
371
398
  return;
372
399
  }
373
400
  let provider = providers[0];
@@ -377,8 +404,11 @@ async function startAutoItem(a) {
377
404
  // THE CLAUDE SPEND BUDGET (ai/budget.ts): over it, drop the item before its row exists — the
378
405
  // sweeper re-derives it once the budget is under its limit again.
379
406
  // A READER'S PRESS is not asked again: they chose it (the SPA's over-budget confirm ran).
380
- if (!a.pressed && !(await automaticRunAllowed(ctx, accountId, 'auto review', provider)))
407
+ if (!a.pressed && !(await automaticRunAllowed(ctx, accountId, 'auto review', provider))) {
408
+ if (sayOnPr)
409
+ postAutoSkipLinesLater(ctx, { accountId, prId }, providers.map((p) => ({ provider: p, reason: 'budget' })));
381
410
  return;
411
+ }
382
412
  const prCtx = await getReviewPrContext(ctx, prId, accountId);
383
413
  if (!prCtx?.headSha)
384
414
  return;
@@ -586,6 +586,22 @@ export function registerClaudeReviewRoutes(app, ctx) {
586
586
  const accountId = ctx.accountIdOf(req);
587
587
  const fctx = await getFindingPostContext(ctx, findingId, accountId);
588
588
  if (!fctx) {
589
+ // A TEAMMATE'S finding takes ONE action here, "Post reminder" (teammate-reminder.ts): posted as
590
+ // the reader, linking the teammate's comment. A plain Post of it stays 404 (it is theirs).
591
+ if (req.body?.reminder === true) {
592
+ const { postTeammateReminder } = await import('./teammate-reminder.js');
593
+ const out = await postTeammateReminder(ctx, accountId, findingId);
594
+ if (out.kind === 'posted')
595
+ return out.result;
596
+ if (out.kind === 'conflict') {
597
+ reply.status(409);
598
+ return { error: out.code, message: out.message };
599
+ }
600
+ if (out.kind === 'github_error') {
601
+ reply.status(502);
602
+ return { error: 'GitHubError', message: out.message };
603
+ }
604
+ }
589
605
  reply.status(404);
590
606
  return { error: 'NotFound', message: `Finding ${findingId} not found` };
591
607
  }