limn-review 0.1.184 → 0.1.185

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 (52) hide show
  1. package/dist/ai/credentials.js +1 -1
  2. package/dist/api/plugins/rate-limit.js +12 -24
  3. package/dist/api/routes/insights.js +33 -0
  4. package/dist/api/routes/me.js +3 -5
  5. package/dist/db/ai-reviews-seen.js +176 -0
  6. package/dist/db/bot-dormancy.js +1 -1
  7. package/dist/db/branch-queries.js +1 -1
  8. package/dist/db/claude-review-seen.js +36 -0
  9. package/dist/db/daily-brief.js +1 -8
  10. package/dist/db/migrations/0167_ai_reviews_seen.sql +4 -0
  11. package/dist/db/migrations/0168_ai_review_seen_per_run.sql +12 -0
  12. package/dist/db/migrations/meta/_journal.json +14 -0
  13. package/dist/db/my-turn-dismissals.js +14 -48
  14. package/dist/db/pending-first-landing.js +5 -4
  15. package/dist/db/pending-tabs.js +46 -11
  16. package/dist/db/queries.js +195 -258
  17. package/dist/db/schema.sqlite.js +10 -1
  18. package/dist/db/triage.js +2 -3
  19. package/dist/db/work-plan.js +29 -384
  20. package/dist/platform/context.js +0 -10
  21. package/dist/platform/db.js +1 -1
  22. package/dist/platform/index.js +1 -6
  23. package/dist/platform/insights/sprint-report.js +3 -0
  24. package/dist/platform/retention.js +1 -1
  25. package/dist/platform/slack/pending-blocks.js +5 -9
  26. package/dist/platform/slack/tones.js +1 -2
  27. package/dist/platform/work-plan/schema.sqlite.js +3 -0
  28. package/dist/review/claude-review/auto-resolve.js +36 -15
  29. package/dist/review/claude-review/auto-settings.js +10 -2
  30. package/dist/review/claude-review/manager.js +13 -0
  31. package/dist/review/claude-review/persist.js +2 -1
  32. package/dist/review/claude-review/replies-check.js +9 -4
  33. package/dist/review/claude-review/routes.js +25 -0
  34. package/dist/review/llm.js +1 -1
  35. package/dist/shared/my-turn-settings.js +9 -21
  36. package/dist/shared/pending-rules.js +27 -30
  37. package/dist/shared/types.js +1 -0
  38. package/dist/status.js +10 -10
  39. package/package.json +1 -1
  40. package/public/assets/{AiFixTab-1Th64RMo.js → AiFixTab-Bgm-Ja8b.js} +1 -1
  41. package/public/assets/ClaudeReviewTab-Di50zOZT.js +20 -0
  42. package/public/assets/{PrBotBehaviourTab-DCXj-QHC.js → PrBotBehaviourTab-GP2FO_VM.js} +1 -1
  43. package/public/assets/index-C5bt1sKw.js +90 -0
  44. package/public/assets/index-CICgf03S.css +1 -0
  45. package/public/index.html +5 -2
  46. package/public/theme-boot.js +17 -0
  47. package/dist/platform/work-plan/hash.js +0 -99
  48. package/dist/platform/work-plan/prompt.js +0 -209
  49. package/dist/platform/work-plan/routes.js +0 -350
  50. package/public/assets/ClaudeReviewTab-BRGdbkXT.js +0 -22
  51. package/public/assets/index-CXSOCXMH.js +0 -89
  52. package/public/assets/index-DG7ITfra.css +0 -1
@@ -2,7 +2,7 @@
2
2
  // Fix, the ticket / CI reviews, diagrams, sprint paths, the docs build and chat, resolve with
3
3
  // Claude, every chat) AND the one-shot completions (review/llm.ts `cheapComplete`, which the
4
4
  // sprint briefs/themes, story labels, docs ranking, sprint Ask Claude and every platform feature —
5
- // reports, synthesis, work-plan wording, pointers, bot themes, the Slack
5
+ // reports, synthesis, pointers, bot themes, the Slack
6
6
  // report — all go through).
7
7
  //
8
8
  // 1. THE LOCAL CLAUDE CODE LOGIN (default) — a `claude` session on disk or
@@ -332,30 +332,8 @@ function tierFor(method, path) {
332
332
  return [TIERS.ai, TIERS.aiHourly];
333
333
  return [TIERS.search, TIERS.read];
334
334
  }
335
- // ---- The work plan (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
336
- // ONE endpoint, two verbs, two costs — the synthesis shape exactly, and tiered by copying it:
337
- // POST /api/pro/work-plan the generate path — one Haiku call behind a payload-hash $0 cache,
338
- // plus the evidence fold below. The catch-all's `generates` branch
339
- // would land it on `ai` anyway; spelled here so the family's two
340
- // tiers are DECIDED together (this file's failure mode is a tier
341
- // nobody re-examined).
342
- // GET /api/pro/work-plan the free cached read — but it re-runs the whole `getWorkPlan`
343
- // evidence fold to recompute the payload hash for the `stale` probe,
344
- // and that fold reaches getWorkspaceInsights (the stalled-review /
345
- // untouched-thread / reviewer-load / merge / update_branch cards).
346
- // That is the synthesis / bot-behaviour shape of cost — this
347
- // process's event loop and this database, not GitHub quota and not
348
- // Anthropic — so it takes the same 60/min `search` bucket, NOT the
349
- // catch-all's 600/min GET→read branch. The narration now decorates
350
- // the Pending board, so this GET fires whenever that board is
351
- // mounted. It must NEVER grow a generation leg.
352
- if (path === '/api/pro/work-plan') {
353
- if (mutating)
354
- return [TIERS.ai, TIERS.aiHourly];
355
- return [TIERS.search, TIERS.read];
356
- }
357
335
  // ---- Chronology pointers (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
358
- // The work plan's shape and costs exactly: POST is one Haiku call behind a payload-hash $0 cache
336
+ // The synthesis shape and costs exactly: POST is one Haiku call behind a payload-hash $0 cache
359
337
  // (the `ai` pair); the free GET re-runs the whole Chronology fold (`getFlowCourts` — the lane
360
338
  // resolver plus chunked action scans over every merge in the window) plus the exemplar text
361
339
  // reads to recompute the hash for its `stale` probe, so it takes `search`, never the
@@ -581,6 +559,11 @@ function tierFor(method, path) {
581
559
  // `hitsGithub` alternation further down is never reached by a `/claude-review` path).
582
560
  if (mutating && /^\/api\/prs\/\d+\/claude-review\/team-claim\/clear$/.test(path))
583
561
  return [TIERS.githubWrite];
562
+ // SEEN (db/queries.ts `markClaudeReviewSeen`): the AI Review tab showed a finished run — one
563
+ // UPDATE of one column; no GitHub, no model. `read`, EXACT, so it never lands on the AI bucket
564
+ // the `/claude-review` family's generic POST rule below would give it.
565
+ if (mutating && /^\/api\/prs\/\d+\/claude-review\/seen$/.test(path))
566
+ return [TIERS.read];
584
567
  // AI Fix PLAN MODE (coding/ai-fix/routes.ts, LOCAL ONLY). Spelled out so the family cannot drift
585
568
  // with the /api/pro/ catch-all below, which would also have parked the DB-only discard on `ai`:
586
569
  // POST /api/pro/prs/:id/ai-fix/plan starts a planning agent pass → ai + ai_hourly
@@ -723,7 +706,7 @@ function tierFor(method, path) {
723
706
  // GET /api/attention — the Pending board. RE-TIERED off the 600/min blanket `read` when it
724
707
  // absorbed the ranked head: one request now runs `getWorkspaceInsights` (including the
725
708
  // maintained-repo resolution, which the merge-card emitter makes effectively unconditional)
726
- // AND `rankWorkPlan` on top of it — the approvals fold, the untouched-thread group-by and a
709
+ // AND `rankPendingTabs` (`scoreCards`) on top of it — the approvals fold, the untouched-thread group-by and a
727
710
  // narrow PR select. Same cost shape as the daily brief above, same 60/min bucket.
728
711
  // ⚠ "This route is DB-only" is exactly the sentence this file exists to distrust: the fold
729
712
  // moved into it, so the tier moves with it. The board mounts on navigation and refetches on a
@@ -746,6 +729,11 @@ function tierFor(method, path) {
746
729
  // GitHub, it reads.)
747
730
  if (mutating && path === '/api/attention/liveness')
748
731
  return [TIERS.prDetail, TIERS.read];
732
+ // POST /api/attention/ai-reviews/seen — "AI reviews" was looked at (db/ai-reviews-seen.ts).
733
+ // FOLLOWING THE TOKEN: one UPDATE of one column; no GitHub, no model. `read`, decided rather than
734
+ // inherited; EXACT `===`.
735
+ if (mutating && path === '/api/attention/ai-reviews/seen')
736
+ return [TIERS.read];
749
737
  // GET /api/flow-findings — the Chronology tab (deterministic, no model and no GitHub). DECIDED,
750
738
  // not inherited: "this
751
739
  // route is DB-only" is the sentence this file exists to distrust, and one call here runs the
@@ -5,11 +5,23 @@ import { getMergedPrsInWindow } from '../../db/merged-prs.js';
5
5
  import { getStories } from '../../db/stories.js';
6
6
  import { getReportingWindow, reportingWindowInfo } from '../../db/reporting-window.js';
7
7
  import { buildPendingBoard } from '../../db/pending-tabs.js';
8
+ import { markAiReviewsSeen } from '../../db/ai-reviews-seen.js';
8
9
  import { PR_LIVENESS_MAX_IDS, sweepPrLiveness } from '../../sync/pr-liveness-sweep.js';
9
10
  import { accountIdOf } from '../plugins/auth.js';
10
11
  // The liveness body. `maxItems` is a coarse guard only — the real cap is applied in the handler
11
12
  // AFTER de-duplication (a board legitimately renders several cards for one PR), and it 400s
12
13
  // rather than truncating.
14
+ const aiReviewsSeenSchema = {
15
+ querystring: {
16
+ type: 'object',
17
+ properties: { workspace: { type: 'string' } },
18
+ },
19
+ body: {
20
+ type: ['object', 'null'],
21
+ additionalProperties: false,
22
+ properties: { through: { type: 'string', maxLength: 40 } },
23
+ },
24
+ };
13
25
  const livenessSchema = {
14
26
  querystring: {
15
27
  type: 'object',
@@ -228,6 +240,27 @@ export async function insightsRoutes(app) {
228
240
  }
229
241
  return { workspaceId: scope.workspaceId, ...swept };
230
242
  });
243
+ // POST /api/attention/ai-reviews/seen — THE READER LOOKED AT "AI REVIEWS" (db/ai-reviews-seen.ts).
244
+ // Moves the workspace's stamp to `through` (the newest review time the reader's list showed; absent
245
+ // = now), never back. Sent by the SPA when the tab is ON SCREEN — never on the board's mount for
246
+ // another tab. DB only (one UPDATE): no GitHub, no model. `?workspace=` resolves like every scoped
247
+ // route (absent / unknown ⇒ the Default) and is echoed.
248
+ app.post('/api/attention/ai-reviews/seen', { schema: aiReviewsSeenSchema }, async (req, reply) => {
249
+ const q = req.query;
250
+ const body = (req.body ?? {});
251
+ let through = null;
252
+ if (body.through != null) {
253
+ const ms = Date.parse(body.through);
254
+ if (!Number.isFinite(ms)) {
255
+ return reply.code(400).send({ error: 'BadThrough', message: '`through` must be an ISO date.' });
256
+ }
257
+ through = new Date(ms);
258
+ }
259
+ const accountId = accountIdOf(req);
260
+ const scope = await resolveWorkspaceScope(accountId, q.workspace);
261
+ const at = await markAiReviewsSeen(accountId, scope.workspaceId, through);
262
+ return { workspaceId: scope.workspaceId, seenAt: (at ?? new Date()).toISOString() };
263
+ });
231
264
  // Heavier per-repo analytics for the drill-down chart panel — loaded on demand.
232
265
  // Ownership-scoped: a repo not owned by the account 404s.
233
266
  app.get('/api/insights/:repoId/analytics', async (req, reply) => {
@@ -131,10 +131,9 @@ const myTurnSettingsSchema = {
131
131
  additionalProperties: false,
132
132
  properties: Object.fromEntries(MY_TURN_TOGGLES.map((k) => [k, { type: 'boolean' }])),
133
133
  },
134
- trunkScope: { type: 'string', maxLength: 32 },
135
134
  order: {
136
135
  type: 'array',
137
- maxItems: 15,
136
+ maxItems: MY_TURN_TOGGLES.length,
138
137
  items: { type: 'string', maxLength: 32 },
139
138
  },
140
139
  weights: {
@@ -442,8 +441,7 @@ export async function meRoutes(app) {
442
441
  return reply.code(404).send({ error: 'NotFound', message: 'Not found' });
443
442
  return body;
444
443
  });
445
- // DISMISS / BRING BACK one My Turn subject — a pull request (`pr`) or, for a red default branch,
446
- // a repository (`repo`). ⚠ NOT the "Done" routes 0060 deleted: a dismissal lasts only until
444
+ // DISMISS / BRING BACK one My Turn subject — a pull request (`pr`), whatever its card type. ⚠ NOT the "Done" routes 0060 deleted: a dismissal lasts only until
447
445
  // something newer happens on the subject, and a subject that leaves your plate discharges it
448
446
  // (db/my-turn-dismissals.ts). DB-only, both modes, free.
449
447
  //
@@ -481,7 +479,7 @@ export async function meRoutes(app) {
481
479
  /** `:kind/:id` → a target, or null for anything else (a non-integer id, an unknown kind). */
482
480
  export function parseDismissTarget(params) {
483
481
  const p = (params ?? {});
484
- if (p.kind !== 'pr' && p.kind !== 'repo')
482
+ if (p.kind !== 'pr')
485
483
  return null;
486
484
  const raw = typeof p.id === 'string' ? p.id : '';
487
485
  if (!/^[1-9][0-9]{0,15}$/.test(raw))
@@ -0,0 +1,176 @@
1
+ // ── PENDING → "AI REVIEWS": THE NEWEST AI REVIEW ON EACH PR (docs/BACKEND.md § The AI reviews list) ──
2
+ //
3
+ // The tab's one list: a stable REFERENCE, never an inbox. One `ai_review` card per PR of the
4
+ // workspace, open or not — the NEWEST of:
5
+ // • the reader's OWN Limn — a finished full review (`kind IS NULL`, `succeeded`, not a router
6
+ // skip). A Claude + Codex pair is ONE review: listed once, on its first half, with the pair's
7
+ // one verdict and both halves' findings (a merged-away repeat never counts);
8
+ // • a TEAMMATE's Limn — a review it posted to GitHub, imported as `kind = 'teammate'`.
9
+ // Newest first, at most `AI_REVIEWS_LIST_CAP` rows.
10
+ //
11
+ // ⚠ LOOKING NEVER REMOVES A ROW. The workspace's `ai_reviews_seen_at` stamp only says which rows are
12
+ // newer than the reader's last look: the SPA tags them "New" (against the stamp its visit started
13
+ // with) and the tab's badge counts them (`PendingTab.newTotal`). It is moved ONLY by the reader
14
+ // looking (`markAiReviewsSeen`, the SPA's POST while the tab is on screen), never by a read.
15
+ //
16
+ // ⚠ LIVE BOARD ONLY and COUNTED ONLY IN ITS TAB: `buildPendingBoard` appends these after the fold,
17
+ // so the daily brief, Slack, the My Turn counts and the notifications never see one.
18
+ //
19
+ // DB only — no GitHub call.
20
+ import { AI_REVIEWS_LIST_CAP } from '../shared/index.js';
21
+ import { and, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
22
+ import { db, schema } from './client.js';
23
+ import { TEAMMATE_KIND } from './teammate-reviews.js';
24
+ const { claudeReviews: cr, claudeReviewFindings: crf, pullRequests, repos, workspaces } = schema;
25
+ /** The stamp, or null when the reader never looked in this workspace. Account-scoped. */
26
+ export async function getAiReviewsSeenAt(accountId, workspaceId) {
27
+ const rows = await db
28
+ .select({ at: workspaces.aiReviewsSeenAt })
29
+ .from(workspaces)
30
+ .where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId)))
31
+ .limit(1)
32
+ .execute();
33
+ return rows[0]?.at ?? null;
34
+ }
35
+ /** How many listed rows are newer than the stamp (null = never looked: all of them). Pure. */
36
+ export function countNewAiReviews(cards, seenAt) {
37
+ if (seenAt == null)
38
+ return cards.length;
39
+ const ms = seenAt.getTime();
40
+ return cards.filter((c) => Date.parse(c.at) > ms).length;
41
+ }
42
+ /**
43
+ * THE ONE WRITER. The reader looked: stamp `through` (the newest review time their list showed),
44
+ * else `now`. Clamped to `now`, and NEVER BACK — an older `through` (a second tab, a stale list)
45
+ * leaves the stamp where it is. Scoped to the account's own workspace; returns the stamp after the
46
+ * call, or null when the workspace is not the account's.
47
+ */
48
+ export async function markAiReviewsSeen(accountId, workspaceId, through, now = Date.now()) {
49
+ const wanted = Math.floor(Math.min(through?.getTime() ?? now, now) / 1000);
50
+ const rows = await db
51
+ .update(workspaces)
52
+ .set({
53
+ aiReviewsSeenAt: sql `max(coalesce(${workspaces.aiReviewsSeenAt}, 0), ${wanted})`,
54
+ })
55
+ .where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId)))
56
+ .returning({ at: workspaces.aiReviewsSeenAt })
57
+ .execute();
58
+ return rows[0]?.at ?? null;
59
+ }
60
+ /**
61
+ * The `ai_review` cards for one workspace scope: the newest AI review on each PR, newest first, at
62
+ * most `AI_REVIEWS_LIST_CAP` — plus the teammates they name (for `users[]`). DB only.
63
+ */
64
+ export async function buildAiReviewCards(accountId, repoIds) {
65
+ if (repoIds.length === 0)
66
+ return { cards: [], userIds: [] };
67
+ // Own runs by FINISH time, teammates' reviews by POST time (else import time) — the two clocks
68
+ // `listTeammateReviews` and the run panel already show.
69
+ const clock = sql `case when ${cr.kind} = ${TEAMMATE_KIND} then coalesce(${cr.postedAt}, ${cr.createdAt}) else coalesce(${cr.finishedAt}, ${cr.createdAt}) end`;
70
+ const all = await db
71
+ .select({
72
+ id: cr.id,
73
+ prId: cr.prId,
74
+ kind: cr.kind,
75
+ verdict: cr.verdict,
76
+ trigger: cr.trigger,
77
+ pairId: cr.pairId,
78
+ pairRole: cr.pairRole,
79
+ pairConsolidation: cr.pairConsolidation,
80
+ finishedAt: cr.finishedAt,
81
+ postedAt: cr.postedAt,
82
+ createdAt: cr.createdAt,
83
+ sourceUserId: cr.sourceUserId,
84
+ repoId: pullRequests.repoId,
85
+ prNumber: pullRequests.number,
86
+ prTitle: pullRequests.title,
87
+ owner: repos.owner,
88
+ name: repos.name,
89
+ })
90
+ .from(cr)
91
+ .innerJoin(pullRequests, eq(pullRequests.id, cr.prId))
92
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
93
+ .where(and(eq(cr.accountId, accountId), eq(repos.accountId, accountId), inArray(pullRequests.repoId, [...repoIds]), or(and(isNull(cr.kind), eq(cr.status, 'succeeded'),
94
+ // A router SKIP is not a review: nothing was read.
95
+ sql `coalesce(${cr.reviewMode}, '') <> 'skip'`,
96
+ // A pair is ONE review, listed on its first half.
97
+ sql `coalesce(${cr.pairRole}, 'first') = 'first'`), and(eq(cr.kind, TEAMMATE_KIND),
98
+ // Never unnamed: a teammate's review with no poster would read as the reader's own.
99
+ isNotNull(cr.sourceUserId)))))
100
+ .orderBy(sql `${clock} desc`, sql `${cr.id} desc`)
101
+ .execute();
102
+ // ONE ROW PER PR — its newest review — and at most the cap.
103
+ const onPr = new Set();
104
+ const rows = [];
105
+ for (const r of all) {
106
+ if (onPr.has(r.prId))
107
+ continue;
108
+ onPr.add(r.prId);
109
+ rows.push(r);
110
+ if (rows.length >= AI_REVIEWS_LIST_CAP)
111
+ break;
112
+ }
113
+ if (rows.length === 0)
114
+ return { cards: [], userIds: [] };
115
+ // A pair's second half: its findings count, and its finish time when it ended later.
116
+ const pairIds = [...new Set(rows.map((r) => r.pairId).filter((id) => id != null))];
117
+ const secondOf = new Map();
118
+ if (pairIds.length > 0) {
119
+ for (const h of await db
120
+ .select({ id: cr.id, pairId: cr.pairId, finishedAt: cr.finishedAt })
121
+ .from(cr)
122
+ .where(and(eq(cr.accountId, accountId), inArray(cr.pairId, pairIds), eq(cr.pairRole, 'second'), eq(cr.status, 'succeeded')))
123
+ .execute()) {
124
+ if (h.pairId != null)
125
+ secondOf.set(h.pairId, { id: h.id, finishedAt: h.finishedAt });
126
+ }
127
+ }
128
+ const runIdsOf = (r) => {
129
+ const second = r.pairId != null ? secondOf.get(r.pairId) : undefined;
130
+ return second != null ? [r.id, second.id] : [r.id];
131
+ };
132
+ // ONE grouped count over every listed run: findings that ask for something.
133
+ const findingCount = new Map();
134
+ for (const f of await db
135
+ .select({ reviewId: crf.reviewId, n: sql `count(*)` })
136
+ .from(crf)
137
+ .where(and(inArray(crf.reviewId, rows.flatMap(runIdsOf)), isNull(crf.mergedInto), sql `${crf.severity} <> 'praise'`))
138
+ .groupBy(crf.reviewId)
139
+ .execute()) {
140
+ findingCount.set(f.reviewId, Number(f.n));
141
+ }
142
+ const userIds = new Set();
143
+ const cards = rows.map((r) => {
144
+ const teammate = r.kind === TEAMMATE_KIND;
145
+ const second = r.pairId != null ? secondOf.get(r.pairId) : undefined;
146
+ const ownAt = Math.max((r.finishedAt ?? r.createdAt).getTime(), second?.finishedAt?.getTime() ?? 0);
147
+ const at = teammate ? (r.postedAt ?? r.createdAt).getTime() : ownAt;
148
+ const verdict = !teammate && r.pairId != null ? (r.pairConsolidation?.verdict ?? r.verdict) : r.verdict;
149
+ if (teammate && r.sourceUserId != null)
150
+ userIds.add(r.sourceUserId);
151
+ return {
152
+ id: `ai_review:${r.id}`,
153
+ kind: 'ai_review',
154
+ severity: 'info',
155
+ reviewId: r.id,
156
+ prId: r.prId,
157
+ repoId: r.repoId,
158
+ repoFullName: `${r.owner}/${r.name}`,
159
+ prNumber: r.prNumber,
160
+ prTitle: r.prTitle,
161
+ githubUrl: `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}`,
162
+ ranByUserId: teammate ? r.sourceUserId : null,
163
+ auto: !teammate && r.trigger === 'auto',
164
+ verdict,
165
+ findingCount: runIdsOf(r).reduce((n, id) => n + (findingCount.get(id) ?? 0), 0),
166
+ at: new Date(at).toISOString(),
167
+ };
168
+ });
169
+ cards.sort(compareAiReviews);
170
+ return { cards, userIds: [...userIds] };
171
+ }
172
+ /** Newest first; ties to the higher review id — a total order, so two polls list alike. */
173
+ export function compareAiReviews(a, b) {
174
+ return Date.parse(b.at) - Date.parse(a.at) || b.reviewId - a.reviewId;
175
+ }
176
+ //# sourceMappingURL=ai-reviews-seen.js.map
@@ -32,7 +32,7 @@
32
32
  // any observed work at all keeps a reviewer visible.
33
33
  //
34
34
  // The predicate is CORE so no second surface can grow its own rule: the benchmark reaches this
35
- // through the OPTIONAL `ProHostQueries.dormantBotUserIds` seam (apiVersion stays 21 — the `getWorkPlan` /
35
+ // through the OPTIONAL `ProHostQueries.dormantBotUserIds` seam (apiVersion stays 21 — the `registerAccountErasure?` /
36
36
  // `workspaceHumanSeatCount` precedent; an older host simply hides nothing).
37
37
  //
38
38
  // ⚠ IT DELIBERATELY IMPORTS NOTHING FROM `queries.ts` — a cycle with the app's largest module
@@ -14,7 +14,7 @@ export function trunkHeadKey(repoId, sha) {
14
14
  }
15
15
  /**
16
16
  * THE ONE READER OF A DEFAULT-BRANCH HEAD'S FAILING CHECKS — `/api/branch-status` and the Pending
17
- * board's red-trunk cards (ci_failing's trunk arm, the promoted `trunk_red` my_turn card) both read
17
+ * board's red-trunk card (ci_failing's trunk arm) reads
18
18
  * through it, so the two surfaces cannot name different checks for one red head.
19
19
  *
20
20
  * The head's own `branch_commits` row holds them (written by the branch snapshot in the same
@@ -0,0 +1,36 @@
1
+ // MY TURN → "NEW AI REVIEWS BY YOUR LIMN": THE ONE SEEN WRITER (docs/BACKEND.md § My Turn).
2
+ //
3
+ // The reader had a finished run of their own Limn on screen in the PR's AI Review tab
4
+ // (`POST /api/prs/:id/claude-review/seen`, sent by the tab once per run while `seenAt` is null). The
5
+ // run — and, for a Claude + Codex pair, every half — is stamped only where `seen_at` is still NULL,
6
+ // so a repeat is a no-op and the stamp never moves. A stamped run is no longer a `claude_review` My
7
+ // Turn card (`getUnseenOwnAiReviews`). ⚠ This module opens NO database at import (it takes an
8
+ // executor): the review routes import it.
9
+ import { and, eq, isNull } from 'drizzle-orm';
10
+ import * as schema from './schema.sqlite.js';
11
+ const cr = schema.claudeReviews;
12
+ /**
13
+ * Stamp one finished own run (and its pair) seen. Account- and PR-scoped. Returns how many rows it
14
+ * stamped: 0 for a run that is not the account's, not this PR's, not a full review, not finished, or
15
+ * already seen — the route answers 404 only for the first three (`null`).
16
+ */
17
+ export async function markClaudeReviewSeen(exec, accountId, prId, reviewId, now = new Date()) {
18
+ const run = (await exec
19
+ .select({ id: cr.id, pairId: cr.pairId, status: cr.status })
20
+ .from(cr)
21
+ .where(and(eq(cr.id, reviewId), eq(cr.accountId, accountId), eq(cr.prId, prId), isNull(cr.kind)))
22
+ .limit(1)
23
+ .execute())[0];
24
+ if (run == null)
25
+ return null;
26
+ if (run.status !== 'succeeded')
27
+ return 0;
28
+ const stamped = await exec
29
+ .update(cr)
30
+ .set({ seenAt: now })
31
+ .where(and(eq(cr.accountId, accountId), eq(cr.prId, prId), isNull(cr.seenAt), run.pairId != null ? eq(cr.pairId, run.pairId) : eq(cr.id, run.id)))
32
+ .returning({ id: cr.id })
33
+ .execute();
34
+ return stamped.length;
35
+ }
36
+ //# sourceMappingURL=claude-review-seen.js.map
@@ -239,11 +239,7 @@ async function computeBriefCounts(accountId, workspaceId) {
239
239
  cached(anomalyCache, `${accountId}:${scope.workspaceId}`, ANOMALY_TTL_MS, () => briefBotAnomalies(accountId, scope)),
240
240
  trunkRedRepos(accountId, scope),
241
241
  ]);
242
- // ⚠ A RED TRUNK THE READER PROMOTED INTO MY TURN IS COUNTED THERE, NOT HERE — it is a my_turn
243
- // card (and in `myTurn` below), so listing it on this line too would count one red branch twice.
244
- // Filtered after the fold resolves, then capped.
245
- const promotedTrunks = new Set(insights.myTurnTrunkRepoIds ?? []);
246
- const trunkRed = allRedTrunks.filter((t) => !promotedTrunks.has(t.repoId)).slice(0, TRUNK_CAP);
242
+ const trunkRed = allRedTrunks.slice(0, TRUNK_CAP);
247
243
  let myTurn = 0;
248
244
  // The `MyTurnCard.personal` subset of the same cards — the figure the NOTIFICATION surfaces
249
245
  // display, where the board keeps displaying `myTurn`. Counted off the cards for exactly the
@@ -287,9 +283,6 @@ async function computeBriefCounts(accountId, workspaceId) {
287
283
  for (const c of insights.cards) {
288
284
  if (c.kind === 'ci_failing')
289
285
  ciFailing += 1;
290
- // A finished Claude review is listed in its own tab, not My turn, so no My turn line counts it.
291
- else if (c.kind === 'my_turn' && c.reason === 'claude_review')
292
- continue;
293
286
  else if (c.kind === 'my_turn') {
294
287
  myTurn += 1;
295
288
  if (c.personal)
@@ -0,0 +1,4 @@
1
+ -- PENDING → "AI REVIEWS" SINCE THE READER LAST LOOKED (docs/BACKEND.md § The AI reviews list): when the
2
+ -- reader last looked at the tab in this workspace. NULL = never (the list covers the last 7 days).
3
+ -- Additive and nullable; no backfill.
4
+ ALTER TABLE `workspaces` ADD `ai_reviews_seen_at` integer;
@@ -0,0 +1,12 @@
1
+ -- MY TURN → "NEW AI REVIEWS BY YOUR LIMN" (docs/BACKEND.md § My Turn): when the reader first had a run
2
+ -- on screen in the PR's AI Review tab after it finished. NULL = not seen — the newest such run on an
3
+ -- open PR is a `claude_review` My Turn card until it is seen or dismissed.
4
+ ALTER TABLE `claude_reviews` ADD `seen_at` integer;
5
+ --> statement-breakpoint
6
+ -- Every run that already finished is treated as seen: the card's meaning changed (it used to be
7
+ -- "un-posted findings"), and an upgrade must not drop every PR's latest review onto My Turn at once.
8
+ UPDATE `claude_reviews` SET `seen_at` = coalesce(`finished_at`, `created_at`) WHERE `status` = 'succeeded';
9
+ --> statement-breakpoint
10
+ -- The red default branch is no longer a My Turn type: its repository-subject dismissals have no item
11
+ -- to hide. (The `repo_id` column stays, dormant.)
12
+ DELETE FROM `my_turn_dismissals` WHERE `pr_id` IS NULL;
@@ -1170,6 +1170,20 @@
1170
1170
  "when": 1792224075016,
1171
1171
  "tag": "0166_team_claim_skip_lines",
1172
1172
  "breakpoints": true
1173
+ },
1174
+ {
1175
+ "idx": 167,
1176
+ "version": "6",
1177
+ "when": 1792224077016,
1178
+ "tag": "0167_ai_reviews_seen",
1179
+ "breakpoints": true
1180
+ },
1181
+ {
1182
+ "idx": 168,
1183
+ "version": "6",
1184
+ "when": 1792224079016,
1185
+ "tag": "0168_ai_review_seen_per_run",
1186
+ "breakpoints": true
1173
1187
  }
1174
1188
  ]
1175
1189
  }
@@ -5,8 +5,7 @@
5
5
  //
6
6
  // ── 1. A DISMISSAL SETS ONE ITEM DOWN UNTIL SOMETHING NEW HAPPENS ────────────────────────────
7
7
  //
8
- // The reader can take a subject — a pull request, or a repository for a red default branch — off
9
- // their plate. ⚠ This is NOT the "Done" button migration 0060 deleted, and the difference is the
8
+ // The reader can take a subject — a pull request — off their plate. ⚠ This is NOT the "Done" button migration 0060 deleted, and the difference is the
10
9
  // whole design. That one stored "I dealt with this" and never expired, so a PR whose ball came back
11
10
  // stayed hidden behind it for weeks. This one stores a TIMESTAMP and lets the fold decide:
12
11
  //
@@ -39,10 +38,10 @@
39
38
  // ⚠ IT IS A BOARD RULE, NOT A NOTIFICATION RULE. `getWorkspaceInsights` asks for it; the
40
39
  // account-wide `GET /api/my-turn` does not, because the browser-notification watcher diffs item
41
40
  // ids — a deduplicated list would flip the winner whenever the top job cleared and announce the
42
- // runner-up as "new". Red default branches are repo-grained and never collide with a PR.
41
+ // runner-up as "new".
43
42
  import { and, eq, inArray } from 'drizzle-orm';
44
43
  import { db, schema } from './client.js';
45
- const { myTurnDismissals, pullRequests, repos } = schema;
44
+ const { myTurnDismissals, pullRequests } = schema;
46
45
  export const subjectKey = (t) => `${t.kind}:${t.id}`;
47
46
  /** Every dismissal the account holds. One indexed select plus a join for the PR's repo/state. */
48
47
  export async function readMyTurnDismissals(accountId) {
@@ -68,14 +67,6 @@ export async function readMyTurnDismissals(accountId) {
68
67
  closed: r.prState !== 'open',
69
68
  });
70
69
  }
71
- else if (r.repoId != null) {
72
- out.push({
73
- target: { kind: 'repo', id: r.repoId },
74
- dismissedAtMs: r.dismissedAt.getTime(),
75
- repoId: r.repoId,
76
- closed: false,
77
- });
78
- }
79
70
  }
80
71
  return out;
81
72
  }
@@ -97,38 +88,19 @@ export function dismissalInstant(nowMs) {
97
88
  */
98
89
  export async function dismissMyTurn(accountId, target, nowMs = Date.now()) {
99
90
  const at = dismissalInstant(nowMs);
100
- if (target.kind === 'pr') {
101
- const owned = await db
102
- .select({ id: pullRequests.id })
103
- .from(pullRequests)
104
- .where(and(eq(pullRequests.id, target.id), eq(pullRequests.accountId, accountId)))
105
- .limit(1)
106
- .execute();
107
- if (owned[0] == null)
108
- return null;
109
- await db
110
- .insert(myTurnDismissals)
111
- .values({ accountId, prId: target.id, repoId: null, dismissedAt: at })
112
- .onConflictDoUpdate({
113
- target: [myTurnDismissals.accountId, myTurnDismissals.prId],
114
- set: { dismissedAt: at },
115
- })
116
- .execute();
117
- return at;
118
- }
119
91
  const owned = await db
120
- .select({ id: repos.id })
121
- .from(repos)
122
- .where(and(eq(repos.id, target.id), eq(repos.accountId, accountId)))
92
+ .select({ id: pullRequests.id })
93
+ .from(pullRequests)
94
+ .where(and(eq(pullRequests.id, target.id), eq(pullRequests.accountId, accountId)))
123
95
  .limit(1)
124
96
  .execute();
125
97
  if (owned[0] == null)
126
98
  return null;
127
99
  await db
128
100
  .insert(myTurnDismissals)
129
- .values({ accountId, prId: null, repoId: target.id, dismissedAt: at })
101
+ .values({ accountId, prId: target.id, repoId: null, dismissedAt: at })
130
102
  .onConflictDoUpdate({
131
- target: [myTurnDismissals.accountId, myTurnDismissals.repoId],
103
+ target: [myTurnDismissals.accountId, myTurnDismissals.prId],
132
104
  set: { dismissedAt: at },
133
105
  })
134
106
  .execute();
@@ -136,30 +108,22 @@ export async function dismissMyTurn(accountId, target, nowMs = Date.now()) {
136
108
  }
137
109
  /** Bring a subject back. True when a dismissal existed. Scoped by account on the row itself. */
138
110
  export async function restoreMyTurn(accountId, target) {
139
- const col = target.kind === 'pr' ? myTurnDismissals.prId : myTurnDismissals.repoId;
140
111
  const deleted = await db
141
112
  .delete(myTurnDismissals)
142
- .where(and(eq(myTurnDismissals.accountId, accountId), eq(col, target.id)))
113
+ .where(and(eq(myTurnDismissals.accountId, accountId), eq(myTurnDismissals.prId, target.id)))
143
114
  .returning({ id: myTurnDismissals.id })
144
115
  .execute();
145
116
  return deleted.length > 0;
146
117
  }
147
118
  /** Drop the rows the fold found nothing left for — see `MyTurnDismissalFilter.dischargeable`. */
148
119
  export async function dischargeMyTurnDismissals(accountId, targets) {
149
- const prIds = targets.filter((t) => t.kind === 'pr').map((t) => t.id);
150
- const repoIds = targets.filter((t) => t.kind === 'repo').map((t) => t.id);
120
+ const prIds = targets.map((t) => t.id);
151
121
  if (prIds.length > 0) {
152
122
  await db
153
123
  .delete(myTurnDismissals)
154
124
  .where(and(eq(myTurnDismissals.accountId, accountId), inArray(myTurnDismissals.prId, prIds)))
155
125
  .execute();
156
126
  }
157
- if (repoIds.length > 0) {
158
- await db
159
- .delete(myTurnDismissals)
160
- .where(and(eq(myTurnDismissals.accountId, accountId), inArray(myTurnDismissals.repoId, repoIds)))
161
- .execute();
162
- }
163
127
  }
164
128
  /**
165
129
  * The per-fold filter. `keep` is called once per candidate item, in any order; it records which
@@ -271,6 +235,7 @@ export function onePerPr(mt, order) {
271
235
  approvedPrs: pr('pr_approved'),
272
236
  ownReady: pr('own_ready'),
273
237
  yourPrs: pr('your_pr'),
238
+ approvedMerged: pr('ai_approved_merged'),
274
239
  watchedRepoPrs: pr('watched_repo_pr'),
275
240
  };
276
241
  const thread = (reason) => ({
@@ -309,6 +274,8 @@ export function onePerPr(mt, order) {
309
274
  offer(S.ownReady.of(i));
310
275
  for (const i of mt.yourPrs)
311
276
  offer(S.yourPrs.of(i));
277
+ for (const i of mt.approvedMerged)
278
+ offer(S.approvedMerged.of(i));
312
279
  for (const i of mt.watchedRepoPrs)
313
280
  offer(S.watchedRepoPrs.of(i));
314
281
  for (const i of mt.threadsAwaiting)
@@ -333,8 +300,7 @@ export function onePerPr(mt, order) {
333
300
  ownThreads: mt.ownThreads.filter((i) => winnerIs(ownThreads.of(i))),
334
301
  // Exempt (header, rule 2): every Claude review keeps its own card.
335
302
  claudeReviewsToAction: mt.claudeReviewsToAction,
336
- // Repo-grained: a branch is not a pull request, and never shares a key with one.
337
- redTrunks: mt.redTrunks,
303
+ approvedMerged: mt.approvedMerged.filter((i) => winnerIs(S.approvedMerged.of(i))),
338
304
  };
339
305
  }
340
306
  //# sourceMappingURL=my-turn-dismissals.js.map
@@ -24,7 +24,7 @@
24
24
  // is built until the reader presses Add.
25
25
  //
26
26
  // ⚠ LIVE STATE, like the board: nothing here may enter a payload hash.
27
- import { PENDING_OWN_PRS_SHOWN, PENDING_TABS, PENDING_SUMMARY_DAYS, } from '../shared/index.js';
27
+ import { PENDING_OWN_PRS_SHOWN, pendingTabIsReference, PENDING_SUMMARY_DAYS, } from '../shared/index.js';
28
28
  import { and, count, desc, eq, inArray, isNotNull, min, sql } from 'drizzle-orm';
29
29
  import { db, schema } from './client.js';
30
30
  import { ownRunsOnly } from './teammate-reviews.js';
@@ -40,10 +40,11 @@ export function summaryWindowOpen(firstOnboardingAt, now) {
40
40
  return now - firstOnboardingAt.getTime() < PENDING_SUMMARY_DAYS * DAY_MS;
41
41
  }
42
42
  /** Is every tab empty? The tabs' totals are the board's populations (review load is not counted).
43
- * ⚠ "Waiting on others" (`byWait`) is not asked: it is information about the reader's own PRs, not
44
- * something to do — and the empty board lists those same PRs, with what blocks each. */
43
+ * ⚠ The read-only tabs are not asked (`pendingTabIsReference`): "Waiting on others" is information
44
+ * about the reader's own PRs — and the empty board lists those same PRs, with what blocks each —
45
+ * and "AI reviews" is a reference list of finished reviews. */
45
46
  export function boardIsEmpty(tabs) {
46
- return tabs.every((t) => t.total === 0 || PENDING_TABS.find((d) => d.key === t.key)?.byWait === true);
47
+ return tabs.every((t) => t.total === 0 || pendingTabIsReference(t.key));
47
48
  }
48
49
  /** May the suggestion show at all? Pure — the PR pick is separate. */
49
50
  export function reviewSuggestionAllowed(f) {