limn-review 0.1.142 → 0.1.144

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 (31) hide show
  1. package/dist/config.js +3 -0
  2. package/dist/db/client.js +43 -14
  3. package/dist/review/claude-review/auto-post.js +13 -3
  4. package/dist/review/claude-review/chat-explain.js +6 -3
  5. package/dist/review/claude-review/manager.js +1 -1
  6. package/dist/review/claude-review/ticket.js +5 -2
  7. package/dist/review/diagrams/fingerprint.js +4 -1
  8. package/dist/review/diagrams/persist.js +27 -0
  9. package/dist/review/diagrams/routes.js +6 -2
  10. package/dist/review/diagrams/trigger.js +136 -39
  11. package/dist/review/ticket-review/manager.js +18 -0
  12. package/dist/review/ticket-review/persist.js +34 -5
  13. package/dist/review/ticket-review/post-item.js +23 -6
  14. package/dist/review/ticket-review/prompts.js +6 -4
  15. package/dist/review/ticket-review/reconcile.js +11 -5
  16. package/dist/review/ticket-review/routes.js +7 -2
  17. package/dist/review/ticket-review/schema.js +2 -2
  18. package/dist/review/ticket-review/sweep-waits.js +20 -0
  19. package/dist/review/ticket-review/sweep.js +53 -7
  20. package/dist/shared/index.js +1 -0
  21. package/dist/shared/prose.js +89 -0
  22. package/dist/shared/sequence-diagrams.js +5 -0
  23. package/dist/shared/types.js +14 -0
  24. package/package.json +1 -1
  25. package/public/assets/{AiFixTab-B3eAgc7u.js → AiFixTab-C3QTCWh3.js} +1 -1
  26. package/public/assets/{ClaudeReviewTab-C7GoZdiF.js → ClaudeReviewTab-ITKwJAxq.js} +1 -1
  27. package/public/assets/{PrBotBehaviourTab-ChRG19sQ.js → PrBotBehaviourTab-ChfF8zOq.js} +1 -1
  28. package/public/assets/index-BS5YZ0nk.css +1 -0
  29. package/public/assets/{index-BvK15F-9.js → index-CFlzr-u4.js} +48 -47
  30. package/public/index.html +2 -2
  31. package/public/assets/index-CY_rIeEn.css +0 -1
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';
@@ -222,14 +223,23 @@ export function liftBlockMessage(by = 'Claude') {
222
223
  return `${by}'s blocking findings are fixed.`;
223
224
  }
224
225
  export const LIFT_BLOCK_MESSAGE = liftBlockMessage('Claude');
225
- /** The review body's lead: the summary's first plain sentence, else a count. `by` names the provider
226
- * that ran the review (default Claude — its text is unchanged). */
226
+ // The lead's ceiling. A summary's first paragraph is normally far shorter; past this it is cut at the
227
+ // last sentence end that fits, never mid-sentence (GitHub allows 65,536 characters in a review body).
228
+ const LEAD_MAX_CHARS = 3_000;
229
+ /** Cut `text` to ≤ `max` characters at the last sentence end that fits; failing that, at the last space
230
+ * (with "…"). Text that fits is returned whole. The fold is shared (`clipAtSentence`, shared prose.ts). */
231
+ export function clipAtSentence(text, max = LEAD_MAX_CHARS) {
232
+ return clipAtSentenceShared(text, max);
233
+ }
234
+ /** The review body's lead: the summary's first plain paragraph (whole, unless it passes
235
+ * LEAD_MAX_CHARS), else a count. `by` names the provider that ran the review (default Claude — its
236
+ * text is unchanged). */
227
237
  export function autoReviewBodyLead(summary, commentCount, by = 'Claude') {
228
238
  for (const raw of (summary ?? '').split('\n')) {
229
239
  const line = raw.trim();
230
240
  if (line === '' || /^([-*+]|\d+[.)])\s/.test(line) || line.startsWith('#'))
231
241
  continue;
232
- return line.length > 400 ? `${line.slice(0, 399)}…` : line;
242
+ return clipAtSentence(line);
233
243
  }
234
244
  if (commentCount === 0)
235
245
  return `Reviewed by ${by}.`;
@@ -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))
@@ -550,11 +550,21 @@ export async function getTicketStateInputs(ctx, accountId, idents) {
550
550
  latestRowsByIdent(ctx, accountId, idents, ['queued', 'running']),
551
551
  latestRowsByIdent(ctx, accountId, idents, null),
552
552
  ]);
553
- const memberRows = await readMembers(ctx, accountId, [...succeeded.values()].map((r) => r.id));
553
+ // A newer attempt than the latest success (it failed, was refused or was cancelled) reads its own
554
+ // members too.
555
+ const newerAttempts = [...any.values()].filter((a) => a.id > (succeeded.get(a.ticketIdent)?.id ?? 0));
556
+ const memberRows = await readMembers(ctx, accountId, [
557
+ ...[...succeeded.values()].map((r) => r.id),
558
+ ...newerAttempts.map((r) => r.id),
559
+ ]);
560
+ const membersOf = (runId) => memberRows
561
+ .filter((m) => m.ticketReviewId === runId)
562
+ .map((m) => ({ prId: m.prId, headSha: m.headSha, state: m.prState }));
554
563
  for (const ident of new Set(idents)) {
555
564
  const s = succeeded.get(ident);
556
565
  const run = live.get(ident);
557
566
  const a = any.get(ident);
567
+ const newer = a != null && a.id > (s?.id ?? 0);
558
568
  out.set(ident, {
559
569
  latest: s != null
560
570
  ? {
@@ -564,13 +574,19 @@ export async function getTicketStateInputs(ctx, accountId, idents) {
564
574
  alignment: asAlignment(s.alignment),
565
575
  assessment: s.assessment ?? null,
566
576
  completedAt: asDate(s.completedAt),
567
- members: memberRows
568
- .filter((m) => m.ticketReviewId === s.id)
569
- .map((m) => ({ prId: m.prId, headSha: m.headSha, state: m.prState })),
577
+ members: membersOf(s.id),
570
578
  }
571
579
  : null,
572
580
  runningRunId: run?.id ?? null,
573
- latestAttempt: a != null ? { id: a.id, status: a.status, fingerprint: a.fingerprint, refused: a.refused } : null,
581
+ latestAttempt: a != null
582
+ ? {
583
+ id: a.id,
584
+ status: a.status,
585
+ fingerprint: a.fingerprint,
586
+ refused: a.refused,
587
+ ...(newer ? { ticketHash: a.ticketHash, members: membersOf(a.id) } : {}),
588
+ }
589
+ : null,
574
590
  });
575
591
  }
576
592
  return out;
@@ -619,6 +635,19 @@ export async function getTicketItemPostContext(ctx, accountId, runId, itemId) {
619
635
  const item = run.items.find((i) => i.id === itemId);
620
636
  return item != null ? { run, item } : null;
621
637
  }
638
+ /**
639
+ * The ticket's 'cascade' runs (automatic re-checks) since `dayStartMs` — the per-ticket limit on
640
+ * re-checks that only follow pushes (sweep.ts). Every workspace counts: it is the ticket's own budget.
641
+ */
642
+ export async function countCascadeTicketReviewsSince(ctx, accountId, ident, dayStartMs) {
643
+ const { tr } = tables(ctx);
644
+ const rows = (await ctx.db
645
+ .select({ id: tr.id })
646
+ .from(tr)
647
+ .where(and(eq(tr.accountId, accountId), eq(tr.ticketIdent, ident), eq(tr.trigger, 'cascade'), gte(tr.createdAt, new Date(dayStartMs))))
648
+ .execute());
649
+ return rows.length;
650
+ }
622
651
  /**
623
652
  * Automatic ticket reviews (trigger 'auto' or 'cascade') a workspace started since `dayStartMs` —
624
653
  * the TICKET_REVIEW_DAILY_CAP counter. Manual runs never count.