limn-review 0.1.143 → 0.1.145

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 (35) hide show
  1. package/dist/ai/budget.js +26 -1
  2. package/dist/api/routes/ai-budget.js +16 -7
  3. package/dist/config.js +3 -0
  4. package/dist/db/client.js +43 -14
  5. package/dist/review/claude-review/auto-post.js +3 -11
  6. package/dist/review/claude-review/chat-explain.js +6 -3
  7. package/dist/review/claude-review/manager.js +1 -1
  8. package/dist/review/claude-review/ticket.js +5 -2
  9. package/dist/review/diagrams/fingerprint.js +4 -1
  10. package/dist/review/diagrams/persist.js +27 -0
  11. package/dist/review/diagrams/routes.js +6 -2
  12. package/dist/review/diagrams/trigger.js +136 -39
  13. package/dist/review/ticket-review/manager.js +18 -0
  14. package/dist/review/ticket-review/persist.js +34 -5
  15. package/dist/review/ticket-review/post-item.js +23 -6
  16. package/dist/review/ticket-review/prompts.js +6 -4
  17. package/dist/review/ticket-review/reconcile.js +11 -5
  18. package/dist/review/ticket-review/routes.js +7 -2
  19. package/dist/review/ticket-review/schema.js +2 -2
  20. package/dist/review/ticket-review/sweep-waits.js +20 -0
  21. package/dist/review/ticket-review/sweep.js +53 -7
  22. package/dist/shared/ai-budget.js +21 -2
  23. package/dist/shared/index.js +1 -0
  24. package/dist/shared/prose.js +89 -0
  25. package/dist/shared/sequence-diagrams.js +5 -0
  26. package/dist/shared/types.js +14 -0
  27. package/package.json +1 -1
  28. package/public/assets/{AiFixTab-B3eAgc7u.js → AiFixTab-B3Tk1CSK.js} +1 -1
  29. package/public/assets/{ClaudeReviewTab-C7GoZdiF.js → ClaudeReviewTab-Pq-RnFxz.js} +1 -1
  30. package/public/assets/{PrBotBehaviourTab-ChRG19sQ.js → PrBotBehaviourTab-2y4itLnt.js} +1 -1
  31. package/public/assets/index-CTwtlPvo.css +1 -0
  32. package/public/assets/index-DbYNXOny.js +82 -0
  33. package/public/index.html +2 -2
  34. package/public/assets/index-BvK15F-9.js +0 -81
  35. package/public/assets/index-CY_rIeEn.css +0 -1
package/dist/ai/budget.js CHANGED
@@ -45,7 +45,7 @@
45
45
  //
46
46
  // One budget per ACCOUNT (= per machine), never per workspace.
47
47
  import { and, eq, gte, inArray } from 'drizzle-orm';
48
- import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, rollingSpend, } from '../shared/index.js';
48
+ import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiProviderWorthShowing, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, rollingSpend, } from '../shared/index.js';
49
49
  import { db, schema } from '../db/client.js';
50
50
  import { LOCAL_ACCOUNT_ID } from '../auth/account.js';
51
51
  import { getActiveProvider, getProvider } from './provider.js';
@@ -321,6 +321,31 @@ export async function aiBudgetSnapshot(accountId, opts = {}) {
321
321
  }),
322
322
  };
323
323
  }
324
+ /** Every provider id the budget knows, the default first. */
325
+ const PROVIDER_IDS = ['claude', 'codex'];
326
+ /**
327
+ * The OTHER providers' figures for the header's popover (every provider but the default): each one
328
+ * that holds a credential, or that Limn ran on in its current week (`aiProviderWorthShowing`), with
329
+ * its own decision and usage view — its own windows, its own ledger rows. Never throws: a provider
330
+ * whose read fails is left out.
331
+ */
332
+ export async function aiBudgetOthers(accountId, opts = {}) {
333
+ const defaultId = getActiveProvider().id;
334
+ const out = [];
335
+ for (const id of PROVIDER_IDS) {
336
+ if (id === defaultId)
337
+ continue;
338
+ try {
339
+ const snap = await aiBudgetSnapshot(accountId, { ...opts, provider: id });
340
+ if (aiProviderWorthShowing(snap.usage))
341
+ out.push({ provider: id, status: snap.status, usage: snap.usage });
342
+ }
343
+ catch {
344
+ /* left out */
345
+ }
346
+ }
347
+ return out;
348
+ }
324
349
  /** The budget's status for an account. Never throws: a failed read is "off" (never blocks). */
325
350
  export async function aiBudgetStatus(accountId, opts = {}) {
326
351
  try {
@@ -1,7 +1,7 @@
1
1
  import { aiBudgetSettingsView, validateAiBudgetBody, } from '../../shared/index.js';
2
2
  import { config } from '../../config.js';
3
3
  import { accountIdOf } from '../plugins/auth.js';
4
- import { aiBudgetSnapshot, installAiBudgetObserver, writeAiBudgetSettings } from '../../ai/budget.js';
4
+ import { aiBudgetOthers, aiBudgetSnapshot, installAiBudgetObserver, writeAiBudgetSettings } from '../../ai/budget.js';
5
5
  // THE CLAUDE SPEND BUDGET (ai/budget.ts; types in packages/shared ai-budget.ts).
6
6
  //
7
7
  // GET /api/ai/budget[?provider=claude|codex] → AiBudgetResponse (the named provider's, else the
@@ -10,7 +10,9 @@ import { aiBudgetSnapshot, installAiBudgetObserver, writeAiBudgetSettings } from
10
10
  // (`status`) and the header's usage view (`usage`). Serves the CACHED usage
11
11
  // reading and, on the login route, starts the usage probe IN THE BACKGROUND
12
12
  // (at most every 5 minutes, 20s timeout, no model call) — it never waits.
13
- // The header polls it every ~2 minutes while the tab is visible.
13
+ // The header polls it every ~2 minutes while the tab is visible. The plain
14
+ // read (no `?provider=`) adds `others`: every OTHER provider that holds a
15
+ // credential or that Limn ran on this week, each with its own status + usage.
14
16
  // PUT /api/ai/budget AiBudgetBody (any subset; validated) → AiBudgetResponse.
15
17
  //
16
18
  // Registered only when `config.aiEnabled`. Registering it also installs the message observer every
@@ -20,12 +22,19 @@ export async function aiBudgetRoutes(app) {
20
22
  return;
21
23
  installAiBudgetObserver();
22
24
  const respond = async (accountId, provider) => {
23
- const { settings, isDefault, status, usage } = await aiBudgetSnapshot(accountId, {
24
- probe: 'always',
25
- ...(provider ? { provider } : {}),
26
- });
25
+ const [{ settings, isDefault, status, usage }, others] = await Promise.all([
26
+ aiBudgetSnapshot(accountId, { probe: 'always', ...(provider ? { provider } : {}) }),
27
+ // The header's popover shows EVERY provider in use: the others ride the plain read only.
28
+ provider ? Promise.resolve(null) : aiBudgetOthers(accountId, { probe: 'always' }),
29
+ ]);
27
30
  // A subscription carries no US$ on the wire: the API key's weekly figure is sent only on that route.
28
- return { settings: aiBudgetSettingsView(settings, status.route), isDefault, status, usage };
31
+ return {
32
+ settings: aiBudgetSettingsView(settings, status.route),
33
+ isDefault,
34
+ status,
35
+ usage,
36
+ ...(others ? { others } : {}),
37
+ };
29
38
  };
30
39
  // `?provider=claude|codex`: that provider's reading (the per-run picker's over-budget confirm, for a
31
40
  // run on the provider that is NOT the default). Absent or anything else ⇒ the default provider's.
package/dist/config.js CHANGED
@@ -378,6 +378,9 @@ export const config = {
378
378
  // AUTOMATIC ticket reviews (trigger 'auto' / 'cascade') per workspace per UTC day. Not shared with
379
379
  // the PR review's own auto cap. Manual runs never count. Default = shared TICKET_REVIEW_DAILY_CAP.
380
380
  ticketReviewDailyCap: intFromEnv('TICKET_REVIEW_DAILY_CAP', 20),
381
+ // Automatic re-checks of ONE ticket that only follow pushes, per UTC day (review/ticket-review/sweep.ts).
382
+ // A PR joining, leaving or merging, or the story changing, is not limited by it.
383
+ ticketReviewPushRechecksPerDay: intFromEnv('TICKET_REVIEW_PUSH_RECHECKS_PER_DAY', 3),
381
384
  // ---- CI review (one run per (PR, head, failing-check set) — review/ci-review/) ----
382
385
  // Its OWN spend cap and turn cap: a CI run reads log excerpts and the files they point at, nothing
383
386
  // more. Env only — no route sets it.
package/dist/db/client.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // The app talks to `db` through the async surface only (`await builder` / `.execute()` /
4
4
  // `.returning().execute()` / runTransaction). Postgres support is gone; the query layer keeps
5
5
  // that style because nothing gains from rewriting it.
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
7
  import { chmodSync, mkdirSync } from 'node:fs';
7
8
  import { dirname } from 'node:path';
8
9
  import Database from 'better-sqlite3';
@@ -40,23 +41,51 @@ export async function closeDb() {
40
41
  }
41
42
  // The one transaction entry. better-sqlite3 rejects async transaction callbacks ("Transaction
42
43
  // function cannot return a promise"), so this wraps a manual BEGIN/COMMIT around the awaited
43
- // (synchronously-resolving) drizzle ops — no other macrotask interleaves between BEGIN and
44
- // COMMIT because the callback only awaits in-process sqlite work (no real I/O).
44
+ // (synchronously-resolving) drizzle ops. The callback only awaits in-process sqlite work, so no
45
+ // MACROtask interleaves — but MICROtasks do: two runTransaction calls started by separate async
46
+ // chains in the same tick both reached BEGIN, and the second threw "cannot start a transaction
47
+ // within a transaction" (two ticket runs started in the same second, 2026-10-28). So callers queue
48
+ // on ONE in-process lock, in call order.
49
+ // ⚠ A NESTED call (runTransaction inside a runTransaction callback) would wait on itself for ever;
50
+ // it throws instead, as the bare BEGIN always did. Detected by the holder's token in async context,
51
+ // so work the callback merely STARTED (fire-and-forget) that runs a transaction after the commit
52
+ // waits its turn rather than throwing.
53
+ let txTail = Promise.resolve();
54
+ let txHolder = null;
55
+ const txContext = new AsyncLocalStorage();
45
56
  export async function runTransaction(fn) {
46
- sqliteConn.exec('BEGIN');
57
+ const outer = txContext.getStore();
58
+ if (outer != null && outer === txHolder) {
59
+ throw new Error('runTransaction: a nested transaction is not supported');
60
+ }
61
+ const prev = txTail;
62
+ let release;
63
+ txTail = new Promise((resolve) => (release = resolve));
64
+ await prev;
65
+ const token = Symbol('tx');
66
+ txHolder = token;
47
67
  try {
48
- const result = await fn(db);
49
- sqliteConn.exec('COMMIT');
50
- return result;
68
+ return await txContext.run(token, async () => {
69
+ sqliteConn.exec('BEGIN');
70
+ try {
71
+ const result = await fn(db);
72
+ sqliteConn.exec('COMMIT');
73
+ return result;
74
+ }
75
+ catch (err) {
76
+ try {
77
+ sqliteConn.exec('ROLLBACK');
78
+ }
79
+ catch {
80
+ /* rollback best-effort */
81
+ }
82
+ throw err;
83
+ }
84
+ });
51
85
  }
52
- catch (err) {
53
- try {
54
- sqliteConn.exec('ROLLBACK');
55
- }
56
- catch {
57
- /* rollback best-effort */
58
- }
59
- throw err;
86
+ finally {
87
+ txHolder = null;
88
+ release();
60
89
  }
61
90
  }
62
91
  //# sourceMappingURL=client.js.map
@@ -58,6 +58,7 @@
58
58
  // ⚠ Manual runs are never auto-posted: a review you start yourself keeps the Post button.
59
59
  // ⚠ Never throws: auto-posting failing must never touch the review.
60
60
  import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
61
+ import { clipAtSentence as clipAtSentenceShared } from '../../shared/index.js';
61
62
  import {} from '../../shared/index.js';
62
63
  import { readWorkspaceAutoPostForPr } from './auto-settings.js';
63
64
  import { similarTitles } from './settled-by-reply.js';
@@ -226,18 +227,9 @@ export const LIFT_BLOCK_MESSAGE = liftBlockMessage('Claude');
226
227
  // last sentence end that fits, never mid-sentence (GitHub allows 65,536 characters in a review body).
227
228
  const LEAD_MAX_CHARS = 3_000;
228
229
  /** Cut `text` to ≤ `max` characters at the last sentence end that fits; failing that, at the last space
229
- * (with "…"). Text that fits is returned whole. */
230
+ * (with "…"). Text that fits is returned whole. The fold is shared (`clipAtSentence`, shared prose.ts). */
230
231
  export function clipAtSentence(text, max = LEAD_MAX_CHARS) {
231
- if (text.length <= max)
232
- return text;
233
- const head = text.slice(0, max);
234
- let cut = -1;
235
- for (const m of head.matchAll(/[.!?](?=\s)/g))
236
- cut = m.index;
237
- if (cut > 0)
238
- return head.slice(0, cut + 1);
239
- const space = head.slice(0, max - 1).lastIndexOf(' ');
240
- return `${head.slice(0, space > 0 ? space : max - 1)}…`;
232
+ return clipAtSentenceShared(text, max);
241
233
  }
242
234
  /** The review body's lead: the summary's first plain paragraph (whole, unless it passes
243
235
  * LEAD_MAX_CHARS), else a count. `by` names the provider that ran the review (default Claude — its
@@ -1,4 +1,4 @@
1
- import { CLAUDE_REVIEW_CHAT_MAX_PINS, } from '../../shared/index.js';
1
+ import { CLAUDE_REVIEW_CHAT_MAX_PINS, criterionTextOnly, } from '../../shared/index.js';
2
2
  import { loadZod } from '../../ai/runtime.js';
3
3
  // Per-field caps on what the model sends back (it is stored and rendered as plain text).
4
4
  const CARD_FIELD_CHARS = 4_000;
@@ -69,8 +69,11 @@ export function findingPinLabel(f) {
69
69
  return `${SEVERITY_WORD[f.severity] ?? 'Finding'} · ${oneLine(f.title, LABEL_TITLE_CHARS)}`;
70
70
  }
71
71
  export function storyPinLabel(item, ticketKey) {
72
- const head = ticketKey ? `${ticketKey} ${item.ref}` : item.ref;
73
- return `${head} · ${STORY_STATUS_WORD[item.status]} · ${oneLine(item.title, LABEL_TITLE_CHARS)}`;
72
+ // A missing item's 'M1'… ref is internal (the reader never sees it): it is named by its title only.
73
+ const missing = item.status === 'missing';
74
+ const head = [ticketKey, missing ? null : item.ref].filter((x) => x != null && x !== '').join(' ');
75
+ const title = oneLine(missing ? item.title : criterionTextOnly(item.title, item.ref), LABEL_TITLE_CHARS);
76
+ return `${head !== '' ? `${head} · ` : ''}${STORY_STATUS_WORD[item.status]} · ${title}`;
74
77
  }
75
78
  const clip = (t, max) => {
76
79
  const s = t.replace(/\s+$/, '');
@@ -540,7 +540,7 @@ function runEnded(item, end) {
540
540
  // trigger never throws and only queues, so it can neither block nor fail this review.
541
541
  if (succeeded) {
542
542
  void import('../diagrams/trigger.js')
543
- .then((m) => m.maybeDrawDiagramsAfterReview(ctx, { accountId: item.accountId, prId, reviewTrigger: item.trigger }))
543
+ .then(async (m) => m.logTriggerLimits(ctx, prId, await m.maybeDrawDiagramsAfterReview(ctx, { accountId: item.accountId, prId, reviewTrigger: item.trigger })))
544
544
  .catch((err) => ctx.log.warn(`sequence diagrams pr ${prId}: ${err instanceof Error ? err.message : String(err)}`));
545
545
  }
546
546
  // AUTO FIX: a SUCCEEDED auto run may start a review-seeded fix on the reader's OWN PR
@@ -1,4 +1,4 @@
1
- import { CLAUDE_REVIEW_TICKET_MAX_CRITERIA, storedList, ticketRef } from '../../shared/index.js';
1
+ import { CLAUDE_REVIEW_TICKET_MAX_CRITERIA, criterionTextForMatch, storedList, ticketRef } from '../../shared/index.js';
2
2
  export const TICKET_SUMMARY_CHARS = 1_000;
3
3
  export const TICKET_EXPLANATION_CHARS = 1_000;
4
4
  export const TICKET_GAP_TITLE_CHARS = 200;
@@ -143,6 +143,9 @@ export function storyMatchKey(f) {
143
143
  if (!f.story)
144
144
  return null;
145
145
  const kind = /^M\d+$/.test(f.story.ref) ? 'missing' : 'criterion';
146
- return `${kind}\u0000${f.title.replace(/\s+/g, ' ').trim().toLowerCase()}`;
146
+ // A criterion's leading "AC2: " is not part of its identity: older runs stored it in the title,
147
+ // newer ones drop it (reconcile.ts), and the two must stay one item (its posted state carries over).
148
+ const text = kind === 'criterion' ? criterionTextForMatch(f.title) : f.title;
149
+ return `${kind}\u0000${text.replace(/\s+/g, ' ').trim().toLowerCase()}`;
147
150
  }
148
151
  //# sourceMappingURL=ticket.js.map
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { repeatRunLimit } from '../../shared/index.js';
2
3
  import { codeLocFor, isNonCodeFile } from '../../db/code-loc.js';
3
4
  // "IS THIS DIAGRAM STILL TRUE?" and "SHOULD A FINISHED REVIEW DRAW ONE?" — PURE, clock-free.
4
5
  //
@@ -51,8 +52,10 @@ export function diagramSkipReason(a) {
51
52
  return 'drawn';
52
53
  if (a.inFlight)
53
54
  return 'in_flight';
54
- if (a.usedToday >= a.dailyCap)
55
+ if (a.usedToday >= (a.redraw != null ? repeatRunLimit(a.dailyCap) : a.dailyCap))
55
56
  return 'daily_cap';
57
+ if (a.redraw != null && a.redraw.subjectDrawsToday >= a.redraw.subjectCap)
58
+ return 'redraw_limit';
56
59
  return null;
57
60
  }
58
61
  //# sourceMappingURL=fingerprint.js.map
@@ -250,6 +250,33 @@ export async function countReviewDiagramsSince(ctx, accountId, dayStartMs) {
250
250
  .execute());
251
251
  return rows.length;
252
252
  }
253
+ /**
254
+ * One subject's draw history for the trigger's REDRAW rule (trigger.ts): how many review-started rows
255
+ * of it count against the cap today, and the members of its newest drawn row (any trigger, any status,
256
+ * no server refusal) — null members when that row has none (a PR diagram); `drawnBefore` false when
257
+ * the subject was never drawn.
258
+ */
259
+ export async function subjectDrawHistory(ctx, accountId, kind, subject, dayStartMs) {
260
+ const d = t(ctx);
261
+ const today = (await ctx.db
262
+ .select({ id: d.id })
263
+ .from(d)
264
+ .where(and(eq(d.accountId, accountId), eq(d.kind, kind), eq(d.subject, subject), eq(d.trigger, 'review'), or(isNull(d.refusal), notInArray(d.refusal, [...FREE_REFUSALS])), gte(d.createdAt, new Date(dayStartMs))))
265
+ .execute());
266
+ const last = (await ctx.db
267
+ .select({ members: d.members })
268
+ .from(d)
269
+ .where(and(eq(d.accountId, accountId), eq(d.kind, kind), eq(d.subject, subject), isNull(d.refusal)))
270
+ .orderBy(desc(d.createdAt), desc(d.id))
271
+ .limit(1)
272
+ .execute());
273
+ const row = last[0];
274
+ return {
275
+ drawsToday: today.length,
276
+ drawnBefore: row != null,
277
+ lastMemberIds: row?.members != null ? row.members.map((m) => m.prId) : null,
278
+ };
279
+ }
253
280
  // ---- the wire ----
254
281
  export function toDiagramView(row, current) {
255
282
  const iso = (v) => v == null ? null : (v instanceof Date ? v : new Date(v)).toISOString();
@@ -4,7 +4,7 @@ import { AGENTIC_AI_ENABLED } from '../claude-review/manager.js';
4
4
  import { prDiagramFingerprint, storedCodeAnswer, storyDiagramFingerprint } from './fingerprint.js';
5
5
  import { liveStoryMembers, startDiagram } from './manager.js';
6
6
  import { getDiagramPrContext, latestDiagram, latestSucceededDiagram, toDiagramView, } from './persist.js';
7
- import { MAX_TICKETS, toDiagramMembers } from './trigger.js';
7
+ import { MAX_TICKETS, diagramAutoSkip, toDiagramMembers } from './trigger.js';
8
8
  import { providerName } from '../../ai/prompt-tools.js';
9
9
  // THE DESIGN-DIAGRAM API (CORE, free, LOCAL ONLY; paths keep "sequence-diagrams") — docs/API.md § Design diagrams. Registered by
10
10
  // `registerAgenticRoutes` (review/agentic.ts), so absent in cloud and under LIMN_AI_DISABLED; the
@@ -43,13 +43,17 @@ function featureOff(reply) {
43
43
  /** What to draw and, beside it, the newest run when that is another one. `liveFp` null ⇒ not current. */
44
44
  export async function slotFor(ctx, accountId, kind, subject, liveFp) {
45
45
  const latest = await latestDiagram(ctx, accountId, kind, subject);
46
+ // Why the last finished review did not draw it at the live inputs (a daily or redraw limit).
47
+ const skipped = diagramAutoSkip(accountId, kind, subject, liveFp);
48
+ const autoSkipped = skipped != null ? { autoSkipped: skipped } : {};
46
49
  if (latest == null)
47
- return { diagram: null, latest: null };
50
+ return { diagram: null, latest: null, ...autoSkipped };
48
51
  const good = latest.status === 'succeeded' ? latest : await latestSucceededDiagram(ctx, accountId, kind, subject);
49
52
  const view = (r) => toDiagramView(r, liveFp != null && r.fingerprint === liveFp);
50
53
  return {
51
54
  diagram: good != null ? view(good) : null,
52
55
  latest: good != null && good.id === latest.id ? null : view(latest),
56
+ ...autoSkipped,
53
57
  };
54
58
  }
55
59
  async function storySlot(ctx, accountId, ident, ticketKey) {
@@ -1,9 +1,9 @@
1
- import { parseTicketIdent } from '../../shared/index.js';
1
+ import { SEQUENCE_DIAGRAM_SUBJECT_DAILY_DRAWS, parseTicketIdent, } from '../../shared/index.js';
2
2
  import { config } from '../../config.js';
3
3
  import { getTicketSource } from '../../tracker/ticket-source.js';
4
4
  import { automaticRunAllowed } from '../budget-gate.js';
5
5
  import { diagramSkipReason, prDiagramFingerprint, storedCodeAnswer, storyDiagramFingerprint, } from './fingerprint.js';
6
- import { countReviewDiagramsSince, diagramExistsAt, diagramsSwitchedOnForPr, getDiagramPrContext, getPrFileStats, markDiagramRefused, insertQueuedDiagram, } from './persist.js';
6
+ import { countReviewDiagramsSince, diagramExistsAt, diagramsSwitchedOnForPr, getDiagramPrContext, getPrFileStats, markDiagramRefused, insertQueuedDiagram, subjectDrawHistory, } from './persist.js';
7
7
  // "A REVIEW JUST FINISHED — DRAW ITS DIAGRAMS?" The one hook between Claude Review and this process.
8
8
  // claude-review/manager.ts calls it fire-and-forget after EVERY succeeded run, MANUAL OR AUTO, and
9
9
  // nothing it does can reach the review: it never throws (every error is logged and swallowed) and it
@@ -18,6 +18,15 @@ import { countReviewDiagramsSince, diagramExistsAt, diagramsSwitchedOnForPr, get
18
18
  // at most MAX_TICKETS. A ticket with MORE THAN ONE open or merged PR queues ONE
19
19
  // 'story' diagram under the same rules, its fingerprint over every member's head and
20
20
  // state — so a push to ANY member redraws it after that member's next review.
21
+ // ⚠ A REDRAW IS LIMITED (2026-10-28: one story drawn 11 times and one PR 9 times spent the cap of 30
22
+ // by 14:52, and every later review drew nothing, silently). A subject drawn before —
23
+ // a story with the SAME pull requests — is drawn at most SEQUENCE_DIAGRAM_SUBJECT_DAILY_DRAWS
24
+ // (2) times a UTC day, its first draw included, and never takes the cap's last slots
25
+ // (shared `repeatRunLimit`). A first draw, or a story whose PRs changed, is held only
26
+ // by the cap. A skip for a limit is LOGGED (`logTriggerLimits`) and kept in memory
27
+ // for the slot (`diagramAutoSkip` → `autoSkipped`), so the pane can say why.
28
+ // After a TICKET REVIEW succeeds (`maybeDrawStoryDiagramAfterTicketReview`), its story is asked again
29
+ // under the same rules: a PR's ticket link can land after that PR's own review ended.
21
30
  //
22
31
  // ⚠ THE PER-WORKSPACE SWITCH (`workspaces.auto_diagrams_enabled`, migration 0126, ON by default):
23
32
  // switched off for the PR's workspace ⇒ 'switched_off' and nothing is queued or stored. It gates
@@ -36,6 +45,7 @@ const defaultDeps = {
36
45
  switchedOn: diagramsSwitchedOnForPr,
37
46
  exists: diagramExistsAt,
38
47
  usedToday: countReviewDiagramsSince,
48
+ history: subjectDrawHistory,
39
49
  held: async (accountId, kind, subject) => (await manager()).diagramHeld(accountId, kind, subject),
40
50
  ticketsForPr: async (accountId, prId) => (await getTicketSource().ticketsForPr(accountId, prId)).map((t) => ({ ident: t.ident, key: t.ticket.key ?? null })),
41
51
  storyMembers: async (ctx, accountId, ident) => (await manager()).liveStoryMembers(ctx, accountId, ident),
@@ -46,6 +56,7 @@ const defaultDeps = {
46
56
  await markDiagramRefused(ctx, a.accountId, id, 'no_code');
47
57
  },
48
58
  dailyCap: config.sequenceDiagramDailyCap,
59
+ subjectCap: SEQUENCE_DIAGRAM_SUBJECT_DAILY_DRAWS,
49
60
  // The ONE agentic switch (config.aiEnabled); the routes and the manager check it too.
50
61
  enabled: config.aiEnabled,
51
62
  };
@@ -67,6 +78,85 @@ function fold(codes) {
67
78
  return 'unknown';
68
79
  return 'no';
69
80
  }
81
+ // ---- why a design was not redrawn (show the wait) ----
82
+ // `${accountId}|${kind}|${subject}` → the skip and the fingerprint it was skipped at. The slot reads it
83
+ // only while that fingerprint is still the live one (routes.ts `slotFor`), so a later draw or a new
84
+ // head clears it by itself. In memory: a restart forgets it, and the next review asks again.
85
+ const autoSkips = new Map();
86
+ const skipKey = (accountId, kind, subject) => `${accountId}|${kind}|${subject}`;
87
+ function noteSkip(accountId, kind, subject, fp, outcome) {
88
+ const k = skipKey(accountId, kind, subject);
89
+ if (outcome === 'daily_cap')
90
+ autoSkips.set(k, { skip: 'daily_limit', fingerprint: fp });
91
+ else if (outcome === 'redraw_limit')
92
+ autoSkips.set(k, { skip: 'redraw_limit', fingerprint: fp });
93
+ else if (outcome === 'queued' || outcome === 'drawn')
94
+ autoSkips.delete(k);
95
+ }
96
+ /** Why the last finished review did not draw this subject at `liveFp`; null when it did, or nothing is known. */
97
+ export function diagramAutoSkip(accountId, kind, subject, liveFp) {
98
+ const s = autoSkips.get(skipKey(accountId, kind, subject));
99
+ return s != null && liveFp != null && s.fingerprint === liveFp ? s.skip : null;
100
+ }
101
+ export function _resetDiagramSkipsForTest() {
102
+ autoSkips.clear();
103
+ }
104
+ /** The redraw rule's input for one subject: null for a FIRST draw (never drawn, or a story whose
105
+ * pull requests changed since its last design). */
106
+ async function redrawOf(ctx, deps, accountId, kind, subject, dayStartMs, memberIds) {
107
+ const h = await deps.history(ctx, accountId, kind, subject, dayStartMs);
108
+ if (!h.drawnBefore)
109
+ return null;
110
+ if (memberIds != null) {
111
+ const before = new Set(h.lastMemberIds ?? []);
112
+ const same = h.lastMemberIds != null && before.size === memberIds.length && memberIds.every((id) => before.has(id));
113
+ if (!same)
114
+ return null;
115
+ }
116
+ return { subjectDrawsToday: h.drawsToday, subjectCap: deps.subjectCap };
117
+ }
118
+ /** One ticket's story design, after a review of one of its PRs or after its ticket review. */
119
+ async function drawStory(ctx, deps, accountId, t, used, dayStartMs) {
120
+ const members = await deps.storyMembers(ctx, accountId, t.ident);
121
+ const stats = await deps.fileStats(ctx, accountId, members.map((m) => m.prId));
122
+ const code = fold(members.map((m) => (stats.has(m.prId) ? storedCodeAnswer(stats.get(m.prId)) : 'unknown')));
123
+ const fp = storyDiagramFingerprint(t.ident, members);
124
+ const drawn = members.length >= 2 && (await deps.exists(ctx, accountId, 'story', t.ident, fp));
125
+ const storyArgs = {
126
+ accountId,
127
+ kind: 'story',
128
+ subject: t.ident,
129
+ prId: null,
130
+ ticketKey: t.key,
131
+ fingerprint: fp,
132
+ headSha: null,
133
+ members: toDiagramMembers(members),
134
+ trigger: 'review',
135
+ };
136
+ const redraw = members.length >= 2 && !drawn
137
+ ? await redrawOf(ctx, deps, accountId, 'story', t.ident, dayStartMs, members.map((m) => m.prId))
138
+ : null;
139
+ const skip = diagramSkipReason({
140
+ code,
141
+ drawnAtFingerprint: drawn,
142
+ inFlight: await deps.held(accountId, 'story', t.ident),
143
+ usedToday: used.n,
144
+ dailyCap: deps.dailyCap,
145
+ memberCount: members.length,
146
+ redraw,
147
+ });
148
+ if (skip != null) {
149
+ noteSkip(accountId, 'story', t.ident, fp, skip);
150
+ if (skip === 'no_code' && !drawn)
151
+ await deps.refuseNoCode(ctx, storyArgs);
152
+ return skip;
153
+ }
154
+ const r = await deps.enqueue(ctx, storyArgs);
155
+ noteSkip(accountId, 'story', t.ident, fp, r.outcome);
156
+ if (r.outcome === 'queued')
157
+ used.n += 1;
158
+ return r.outcome;
159
+ }
70
160
  /** Never throws. Manual and auto reviews both draw; `reviewTrigger` decides only whether the Claude
71
161
  * spend budget is asked (auto only). */
72
162
  export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, nowMs = Date.now()) {
@@ -91,7 +181,8 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
91
181
  result.pr = 'no_head';
92
182
  return result;
93
183
  }
94
- let used = await deps.usedToday(ctx, accountId, utcDayStartMs(nowMs));
184
+ const dayStartMs = utcDayStartMs(nowMs);
185
+ const used = { n: await deps.usedToday(ctx, accountId, dayStartMs) };
95
186
  // ---- the PR's own diagram ----
96
187
  const prSubject = String(prId);
97
188
  const prFp = prDiagramFingerprint(prId, pr.headSha);
@@ -111,19 +202,22 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
111
202
  code: storedCodeAnswer(pr),
112
203
  drawnAtFingerprint: prDrawn,
113
204
  inFlight: await deps.held(accountId, 'pr', prSubject),
114
- usedToday: used,
205
+ usedToday: used.n,
115
206
  dailyCap: deps.dailyCap,
207
+ redraw: prDrawn ? null : await redrawOf(ctx, deps, accountId, 'pr', prSubject, dayStartMs, null),
116
208
  });
117
209
  if (prSkip != null) {
118
210
  result.pr = prSkip;
211
+ noteSkip(accountId, 'pr', prSubject, prFp, prSkip);
119
212
  if (prSkip === 'no_code' && !prDrawn)
120
213
  await deps.refuseNoCode(ctx, prArgs);
121
214
  }
122
215
  else {
123
216
  const r = await deps.enqueue(ctx, prArgs);
124
217
  result.pr = r.outcome;
218
+ noteSkip(accountId, 'pr', prSubject, prFp, r.outcome);
125
219
  if (r.outcome === 'queued')
126
- used += 1;
220
+ used.n += 1;
127
221
  }
128
222
  // ---- the story diagrams of its tickets ----
129
223
  const tickets = (await deps.ticketsForPr(accountId, prId).catch(() => []))
@@ -133,40 +227,7 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
133
227
  })
134
228
  .slice(0, MAX_TICKETS);
135
229
  for (const t of tickets) {
136
- const members = await deps.storyMembers(ctx, accountId, t.ident);
137
- const stats = await deps.fileStats(ctx, accountId, members.map((m) => m.prId));
138
- const code = fold(members.map((m) => (stats.has(m.prId) ? storedCodeAnswer(stats.get(m.prId)) : 'unknown')));
139
- const fp = storyDiagramFingerprint(t.ident, members);
140
- const drawn = members.length >= 2 && (await deps.exists(ctx, accountId, 'story', t.ident, fp));
141
- const storyArgs = {
142
- accountId,
143
- kind: 'story',
144
- subject: t.ident,
145
- prId: null,
146
- ticketKey: t.key,
147
- fingerprint: fp,
148
- headSha: null,
149
- members: toDiagramMembers(members),
150
- trigger: 'review',
151
- };
152
- const skip = diagramSkipReason({
153
- code,
154
- drawnAtFingerprint: drawn,
155
- inFlight: await deps.held(accountId, 'story', t.ident),
156
- usedToday: used,
157
- dailyCap: deps.dailyCap,
158
- memberCount: members.length,
159
- });
160
- if (skip != null) {
161
- result.stories.push({ ident: t.ident, outcome: skip });
162
- if (skip === 'no_code' && !drawn)
163
- await deps.refuseNoCode(ctx, storyArgs);
164
- continue;
165
- }
166
- const r = await deps.enqueue(ctx, storyArgs);
167
- result.stories.push({ ident: t.ident, outcome: r.outcome });
168
- if (r.outcome === 'queued')
169
- used += 1;
230
+ result.stories.push({ ident: t.ident, outcome: await drawStory(ctx, deps, accountId, t, used, dayStartMs) });
170
231
  }
171
232
  return result;
172
233
  }
@@ -175,4 +236,40 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
175
236
  return result;
176
237
  }
177
238
  }
239
+ /**
240
+ * Never throws. A TICKET REVIEW of a tracker ticket just succeeded: draw its story design under the
241
+ * same rules. It closes a race the PR trigger cannot: a PR's ticket link is read when the PR is
242
+ * received, and can land AFTER that PR's review ended — then the PR review saw a one-PR ticket and
243
+ * drew no story. A ticket review runs once the link exists (a PR joining is what re-runs it).
244
+ * `viaPrId` is any member, for the workspace switch.
245
+ */
246
+ export async function maybeDrawStoryDiagramAfterTicketReview(ctx, a, deps = defaultDeps, nowMs = Date.now()) {
247
+ if (!deps.enabled)
248
+ return 'disabled';
249
+ const p = parseTicketIdent(a.ident);
250
+ if (p == null || p.kind === 'manual')
251
+ return 'disabled';
252
+ try {
253
+ if (!(await deps.switchedOn(ctx, a.accountId, a.viaPrId)))
254
+ return 'switched_off';
255
+ if (a.reviewTrigger === 'auto' && !(await automaticRunAllowed(ctx, a.accountId, 'diagrams')))
256
+ return 'over_budget';
257
+ const dayStartMs = utcDayStartMs(nowMs);
258
+ const used = { n: await deps.usedToday(ctx, a.accountId, dayStartMs) };
259
+ return await drawStory(ctx, deps, a.accountId, { ident: a.ident, key: a.ticketKey }, used, dayStartMs);
260
+ }
261
+ catch (err) {
262
+ ctx.log.warn(`story design after the ticket review of ${a.ident}: ${err instanceof Error ? err.message : String(err)}`);
263
+ return 'disabled';
264
+ }
265
+ }
266
+ /** One log line when a finished review drew nothing because of a limit (silent otherwise). */
267
+ export function logTriggerLimits(ctx, prId, r) {
268
+ const limited = [
269
+ r.pr === 'daily_cap' || r.pr === 'redraw_limit' ? `pr ${prId}: ${r.pr}` : null,
270
+ ...r.stories.filter((s) => s.outcome === 'daily_cap' || s.outcome === 'redraw_limit').map((s) => `${s.ident}: ${s.outcome}`),
271
+ ].filter((x) => x != null);
272
+ if (limited.length > 0)
273
+ ctx.log.info(`sequence diagrams not drawn (${limited.join(', ')})`);
274
+ }
178
275
  //# sourceMappingURL=trigger.js.map
@@ -181,6 +181,24 @@ function launch(job) {
181
181
  progress({ phase: 'preparing' });
182
182
  void runPipeline(job, controller, progress)
183
183
  .then(() => {
184
+ // THE STORY DESIGN (review/diagrams/trigger.ts): a SUCCEEDED run of a ticket with 2+ PRs asks
185
+ // for its story design under the trigger's own rules — a PR's ticket link can land after that
186
+ // PR's review ended, so the PR review alone can miss it. Fire-and-forget; it only queues.
187
+ void getTicketReviewById(ctx, job.accountId, runId)
188
+ .then(async (r) => {
189
+ const via = r?.members[0]?.prId;
190
+ if (r?.status !== 'succeeded' || r.members.length < 2 || via == null)
191
+ return;
192
+ const m = await import('../diagrams/trigger.js');
193
+ await m.maybeDrawStoryDiagramAfterTicketReview(ctx, {
194
+ accountId: job.accountId,
195
+ ident: job.ident,
196
+ ticketKey: r.ticketKey,
197
+ viaPrId: via,
198
+ reviewTrigger: isAuto(job) ? 'auto' : 'manual',
199
+ });
200
+ })
201
+ .catch((err) => ctx.log.warn(`story design after ticket review ${runId}: ${err instanceof Error ? err.message : String(err)}`));
184
202
  // AUTO-POSTING: an AUTOMATIC run that succeeded may post its story gaps on their owner PRs
185
203
  // (./auto-post.ts decides, reads the run's status itself, never throws, never retries).
186
204
  if (!isAuto(job))