@alecsibilia/luca 14.0.0-alpha.21 → 14.0.0-alpha.23

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 (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/board/package.json +1 -1
  3. package/board/server/luca-version.ts +1 -1
  4. package/engine/agents/agent-launcher.ts +7 -2
  5. package/engine/agents/claude-launcher-helpers.ts +6 -1
  6. package/engine/agents/claude-launcher.ts +3 -12
  7. package/engine/agents/claude-options.ts +10 -9
  8. package/engine/agents/launch-session.ts +5 -9
  9. package/engine/agents/role-effort.ts +55 -0
  10. package/engine/agents/scripted-launcher.ts +17 -3
  11. package/engine/config/config-fields.ts +3 -2
  12. package/engine/config/engine-config-schema.ts +42 -4
  13. package/engine/config/engine-config.ts +2 -0
  14. package/engine/core/agent-turn.ts +34 -11
  15. package/engine/core/decide-final-fix.ts +11 -4
  16. package/engine/core/decide-final-review.ts +3 -1
  17. package/engine/core/execute-final-review.ts +11 -2
  18. package/engine/core/execute-memory.ts +7 -0
  19. package/engine/core/final-failure-cap.ts +8 -4
  20. package/engine/core/final-review-text.ts +21 -3
  21. package/engine/core/pull-request-text.ts +11 -7
  22. package/engine/core/stuck-format.ts +14 -2
  23. package/engine/guards/helper-rules.ts +6 -0
  24. package/engine/index.ts +1 -1
  25. package/engine/jev/jev-jobs.ts +57 -10
  26. package/engine/jev/jev-schemas.ts +3 -1
  27. package/engine/journal/agent-entries.ts +5 -1
  28. package/engine/journal/final-review-replay.ts +3 -0
  29. package/engine/journal/final-review-round.ts +66 -4
  30. package/engine/journal/final-review-state.ts +4 -1
  31. package/engine/journal/replay.ts +1 -1
  32. package/engine/models/model-score-facts.ts +273 -0
  33. package/engine/models/model-score-rules.ts +35 -0
  34. package/engine/models/model-score.ts +169 -246
  35. package/engine/testing/effort-pick-fixtures.ts +139 -0
  36. package/engine/testing/model-score-fixtures.ts +300 -0
  37. package/package.json +1 -1
@@ -15,6 +15,7 @@ import {
15
15
  } from './execute-build'
16
16
  import { closeSessions, openSessionsIn } from './session-close'
17
17
 
18
+ import { roleEffort } from '../agents/role-effort'
18
19
  import { hasSlowTests } from '../config/engine-config'
19
20
  import type { EngineCommit } from '../git/git-adapter'
20
21
  import { LOCKED_PICK } from '../jev/model-pick'
@@ -137,6 +138,7 @@ export const executeFinalReviewAction = async ({
137
138
  case 'launch_lens': {
138
139
  const { lens, role, prompt } = action
139
140
  const round = state.final_review.round
141
+ const effort = roleEffort({ role, config })
140
142
  journal.append({
141
143
  kind: 'lens_started',
142
144
  ticket: null,
@@ -152,6 +154,7 @@ export const executeFinalReviewAction = async ({
152
154
  prompt,
153
155
  follow_up_of: null,
154
156
  ...LOCKED_PICK,
157
+ effort,
155
158
  },
156
159
  })
157
160
  const result = await runTurn({
@@ -171,6 +174,7 @@ export const executeFinalReviewAction = async ({
171
174
  // Lenses are reviewers: no agent messages.
172
175
  messaging: null,
173
176
  model: LOCKED_PICK.model,
177
+ effort,
174
178
  }),
175
179
  })
176
180
  if (result === null || !('verdict' in result.result)) return
@@ -206,7 +210,9 @@ export const executeFinalReviewAction = async ({
206
210
  return
207
211
  case 'launch_final_fixer': {
208
212
  const { role, prompt, may_edit_tests } = action
209
- // The last fixes before the PR are never routed: always Opus.
213
+ // The last fixes before the PR are never routed: always Opus,
214
+ // and at the reviewers' effort, so the last code is the cleanest.
215
+ const effort = roleEffort({ role, config, final_fix: true })
210
216
  journal.append({
211
217
  kind: 'agent_started',
212
218
  ticket: null,
@@ -216,6 +222,7 @@ export const executeFinalReviewAction = async ({
216
222
  prompt,
217
223
  follow_up_of: null,
218
224
  ...LOCKED_PICK,
225
+ effort,
219
226
  },
220
227
  })
221
228
  await runTurn({
@@ -235,13 +242,14 @@ export const executeFinalReviewAction = async ({
235
242
  // Every ticket is over, so nobody is left to talk to.
236
243
  messaging: null,
237
244
  model: LOCKED_PICK.model,
245
+ effort,
238
246
  }),
239
247
  })
240
248
  return
241
249
  }
242
250
  case 'follow_up_final_fixer': {
243
251
  const { role, session_id, message } = action
244
- const { model, model_reason } = followUpModel({
252
+ const { model, model_reason, effort } = followUpModel({
245
253
  records: journal.read(),
246
254
  session_id,
247
255
  })
@@ -255,6 +263,7 @@ export const executeFinalReviewAction = async ({
255
263
  follow_up_of: session_id,
256
264
  model,
257
265
  model_reason,
266
+ effort,
258
267
  },
259
268
  })
260
269
  await runTurn({
@@ -1,6 +1,7 @@
1
1
  import type { MemoryAction, MemorySaveStep } from './decide-memory'
2
2
  import { buildContext, need, runTurn, type BuildDeps } from './execute-build'
3
3
 
4
+ import { roleEffort } from '../agents/role-effort'
4
5
  import { launchPick } from '../jev/model-pick'
5
6
  import type { Journal } from '../journal/journal'
6
7
  import type { JournalRecord } from '../journal/journal-record'
@@ -416,6 +417,10 @@ export const executeMemoryAction = async ({
416
417
  role: 'learner',
417
418
  step,
418
419
  })
420
+ const effort = roleEffort({
421
+ role: 'learner',
422
+ config: built.config,
423
+ })
419
424
  journal.append({
420
425
  kind: 'agent_started',
421
426
  ticket: null,
@@ -426,6 +431,7 @@ export const executeMemoryAction = async ({
426
431
  follow_up_of: null,
427
432
  model,
428
433
  model_reason,
434
+ effort,
429
435
  },
430
436
  })
431
437
  await runTurn({
@@ -445,6 +451,7 @@ export const executeMemoryAction = async ({
445
451
  // The learner writes nothing and talks to nobody.
446
452
  messaging: null,
447
453
  model,
454
+ effort,
448
455
  }),
449
456
  })
450
457
  return
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The cap on a final review role's failed tries (`failureCap`): the stuck step
3
- * once a failed turn used up the role's tries, or its engine failures in a row.
3
+ * once a failed turn used up the role's tries in a row, or its engine
4
+ * failures in a row. A finished turn of the role gives its tries back.
4
5
  */
5
6
  import { stuck, type FinalReviewAction } from './final-review-action'
6
7
  import { MAX_ENGINE_FAILURES, MAX_FIX_ROUNDS } from './loop-caps'
@@ -8,8 +9,11 @@ import { MAX_ENGINE_FAILURES, MAX_FIX_ROUNDS } from './loop-caps'
8
9
  import type { FinalReviewState, ReplayedAgentFailure } from '../journal/replay'
9
10
 
10
11
  /**
11
- * The stuck step when a failed turn used up its role's tries (or engine
12
- * failures in a row), else `null`: the role gets another go.
12
+ * The stuck step when a failed turn used up its role's tries in a row (or
13
+ * engine failures in a row), else `null`: the role gets another go.
14
+ *
15
+ * @example
16
+ * failureCap({ review, failed }) // null while the role has tries left
13
17
  */
14
18
  export const failureCap = ({
15
19
  review,
@@ -32,7 +36,7 @@ export const failureCap = ({
32
36
  return tries >= MAX_FIX_ROUNDS
33
37
  ? stuck({
34
38
  reason: 'agent_failed',
35
- detail: `The ${role} failed ${tries} tries in the final review; the last one: ${error}`,
39
+ detail: `The ${role} failed ${tries} tries in a row in the final review; the last one: ${error}`,
36
40
  })
37
41
  : null
38
42
  }
@@ -190,13 +190,27 @@ const findingsSection = ({
190
190
  FIX_RULES,
191
191
  ].join('\n\n')
192
192
 
193
+ /** The round's failed slow tests, beside the implementer's code findings (#563). */
194
+ const slowTestsSection = ({
195
+ slow_tests,
196
+ }: {
197
+ slow_tests: ReplayedGates
198
+ }): string =>
199
+ [
200
+ '## The slow tests failed too',
201
+ 'Once every lens finished, the engine ran the slow tests on the run branch, and these failed. Fix the code so they pass too: the gates run them again after you answer.',
202
+ failedChecks({ gates: slow_tests }),
203
+ ].join('\n\n')
204
+
193
205
  /**
194
206
  * The prompt a fresh final review fixer starts with: its task, the spec,
195
207
  * every ticket, and its findings (the test findings for the test-writer,
196
208
  * the code findings for the implementer, or the findings the other fixer
197
- * answered `other_role`, #616). An implementer launched only to
198
- * fix failed gates (the round had only test findings) gets the gates'
199
- * output instead. The engine journals it word for word.
209
+ * answered `other_role`, #616). The implementer that starts a round whose
210
+ * slow tests failed (#563) gets their output beside its code findings. An
211
+ * implementer launched only to fix failed gates (the round had only test
212
+ * findings) gets the gates' output instead. The engine journals it word
213
+ * for word.
200
214
  *
201
215
  * @example
202
216
  * const prompt = finalFixerPrompt({ role: 'implementer', snapshot, fix, gates: null })
@@ -206,6 +220,7 @@ export const finalFixerPrompt = ({
206
220
  snapshot,
207
221
  fix,
208
222
  gates,
223
+ slow_tests,
209
224
  run_notes,
210
225
  sections,
211
226
  }: {
@@ -214,6 +229,8 @@ export const finalFixerPrompt = ({
214
229
  fix: FinalReviewFix
215
230
  /** Failed gates to fix, for an implementer with no findings left. */
216
231
  gates: ReplayedGates | null
232
+ /** The round's failed slow tests, for the implementer that starts it. */
233
+ slow_tests?: ReplayedGates | null
217
234
  /** Notes earlier agents in the run left, oldest first. */
218
235
  run_notes?: PromptRunNote[]
219
236
  /** More sections after the task, such as a follow-up's message. */
@@ -239,6 +256,7 @@ export const finalFixerPrompt = ({
239
256
  FIXER_TASKS[role],
240
257
  ...workSections({ snapshot }),
241
258
  task,
259
+ ...(slow_tests?.ok === false ? [slowTestsSection({ slow_tests })] : []),
242
260
  ...(sections ?? []),
243
261
  ...runNotesSection({ run_notes: run_notes ?? [] }),
244
262
  ].join('\n\n')
@@ -15,6 +15,7 @@ import { reasonLine } from './stuck-text'
15
15
  import type { AgentRole, MergeDanger } from '../agents/role-results'
16
16
  import {
17
17
  EMPTY_FINAL_REVIEW,
18
+ finalPullRequestFindings,
18
19
  type FinalReviewState,
19
20
  type ReplayedSnapshot,
20
21
  type TicketProgress,
@@ -114,7 +115,9 @@ const outOfTurnsSection = ({
114
115
  * agents ran out of turns (#543), the reviews' nits,
115
116
  * the findings declined through "won't fix", and the memories the learner
116
117
  * added or updated (#370). A final review shipped
117
- * while stuck puts its open findings at the very top. Right after the
118
+ * while stuck puts its open findings at the very top; a round it never
119
+ * settled counts the findings, nits, and rulings of the lenses that
120
+ * finished it (see `finalPullRequestFindings`). Right after the
118
121
  * "Built by" line come the Summary picture and the Merge Danger (from the
119
122
  * final review's integration lens, or "Not available" without them) and
120
123
  * the Evidence that each ticket's new tests failed first and pass now
@@ -137,6 +140,8 @@ export const pullRequestText = ({
137
140
  memory_saves?: MemorySave[]
138
141
  }): { title: string; body: string } => {
139
142
  const review = final_review ?? EMPTY_FINAL_REVIEW
143
+ // A round a stuck lens never settled keeps its finished lenses' findings.
144
+ const findings = finalPullRequestFindings({ review })
140
145
  const { spec } = snapshot
141
146
  const title = (number: number) => snapshot.tickets[number]?.title ?? ''
142
147
  const isSkipped = (number: number) =>
@@ -175,7 +180,7 @@ export const pullRequestText = ({
175
180
  `- #${number} ${id}${fileText(file)}: ${title}`
176
181
  )
177
182
  ),
178
- ...review.nits.map(
183
+ ...findings.nits.map(
179
184
  ({ lens, id, title, file }) =>
180
185
  `- final review, ${lens} lens: ${id}${fileText(file)}: ${title}`
181
186
  ),
@@ -206,23 +211,22 @@ export const pullRequestText = ({
206
211
  declinedLine({ where: `#${number} `, ...entry })
207
212
  )
208
213
  ),
209
- ...review.declined.map((entry) =>
214
+ ...findings.declined.map((entry) =>
210
215
  declinedLine({
211
216
  where: `final review, ${entry.lens} lens: `,
212
217
  ...entry,
213
218
  })
214
219
  ),
215
220
  ]
216
- const stillOpen = review.fix?.findings ?? []
217
221
  const shipped =
218
222
  review.shipped && review.stuck !== null
219
223
  ? shippedFindingsSection({
220
- findings: stillOpen,
224
+ findings: findings.open,
221
225
  stuck: review.stuck,
222
226
  edits: review.shipped_edits,
223
227
  })
224
- : review.fresh_started && stillOpen.length > 0
225
- ? freshFindingsSection({ findings: stillOpen })
228
+ : review.fresh_started && findings.open.length > 0
229
+ ? freshFindingsSection({ findings: findings.open })
226
230
  : ''
227
231
  const body = [
228
232
  shipped,
@@ -18,5 +18,17 @@ export const fenced = (text: string): string => {
18
18
  return `${fence}\n${clipped}\n${fence}`
19
19
  }
20
20
 
21
- export const plural = (count: number, word: string): string =>
22
- `${count} ${word}${count === 1 ? '' : 's'}`
21
+ /**
22
+ * `count` and `word`, with `s` past one; a word ending in a consonant and
23
+ * `y` ends in `ies` instead.
24
+ *
25
+ * @example
26
+ * plural(2, 'fix round') // '2 fix rounds'
27
+ * plural(3, 'failed try') // '3 failed tries'
28
+ */
29
+ export const plural = (count: number, word: string): string => {
30
+ if (count === 1) return `${count} ${word}`
31
+ return /[^aeiou]y$/.test(word)
32
+ ? `${count} ${word.slice(0, -1)}ies`
33
+ : `${count} ${word}s`
34
+ }
@@ -6,6 +6,8 @@ import { z } from 'zod'
6
6
 
7
7
  import { ALLOW, deny, READ_TOOLS, type ToolDecision } from './guard-roles'
8
8
 
9
+ import type { EffortLevel } from '../config/engine-config'
10
+
9
11
  /** The SDK tool that starts a helper. */
10
12
  export const HELPER_TOOL = 'Agent'
11
13
 
@@ -15,6 +17,9 @@ export const HELPER_TYPE = 'searcher'
15
17
  /** Helpers always run on the newest Haiku, by its short name. */
16
18
  export const HELPER_MODEL = 'haiku'
17
19
 
20
+ /** Helpers always run at low effort (#659): a search needs little thought. */
21
+ export const HELPER_EFFORT: EffortLevel = 'low'
22
+
18
23
  /** The only tools a helper has: it reads and searches, nothing else. */
19
24
  export const HELPER_TOOLS = READ_TOOLS
20
25
 
@@ -26,6 +31,7 @@ export const SEARCHER = {
26
31
  description:
27
32
  'A read-only helper that searches the repo: finds files, symbols and callers, and reports what it found. It cannot edit files or run commands.',
28
33
  model: HELPER_MODEL,
34
+ effort: HELPER_EFFORT,
29
35
  tools: [...HELPER_TOOLS],
30
36
  prompt: 'You are a read-only searcher. You may only read and search files in the current worktree with Read, Grep and Glob. Never try to change a file or run a command. Answer with what you found: file paths, line numbers and short excerpts, as briefly as the question allows.',
31
37
  }
package/engine/index.ts CHANGED
@@ -15,7 +15,6 @@ export {
15
15
  type AgentQuerySession,
16
16
  } from './agents/claude-launcher'
17
17
  export {
18
- AGENT_EFFORT,
19
18
  agentEnv,
20
19
  agentOptions,
21
20
  BANNED_ENV,
@@ -32,6 +31,7 @@ export {
32
31
  LUCA_SERVER,
33
32
  sendMessageDescription,
34
33
  } from './agents/message-tool'
34
+ export { REVIEW_EFFORT, ROLE_EFFORT, roleEffort } from './agents/role-effort'
35
35
  export { roleInstructions } from './agents/role-instructions'
36
36
  export {
37
37
  rolePrompt,
@@ -10,8 +10,13 @@ import {
10
10
  } from './jev-schemas'
11
11
  import { offeredModels, type RoutedRole } from './model-pick'
12
12
 
13
+ import { ROLE_EFFORT, roleEffort } from '../agents/role-effort'
13
14
  import type { Finding } from '../agents/role-results'
14
- import { DEFAULT_MODEL, modelsOf } from '../config/engine-config'
15
+ import {
16
+ DEFAULT_MODEL,
17
+ modelsOf,
18
+ type EffortLevel,
19
+ } from '../config/engine-config'
15
20
  import type { EngineAction } from '../core/decide'
16
21
  import type { TicketSnapshot } from '../intake/intake-schemas'
17
22
  import type { JournalRecord } from '../journal/journal-record'
@@ -210,6 +215,51 @@ const agentModelAsk = ({
210
215
  }
211
216
  }
212
217
 
218
+ /** The efforts an `agent_effort` ask offers, lowest first. */
219
+ const OFFERED_EFFORTS: readonly EffortLevel[] = ['medium', 'high', 'xhigh']
220
+
221
+ /**
222
+ * Which effort a fresh test-writer or implementer on a ticket should use
223
+ * (#661), with the same facts as its `agent_model` ask. Shadow mode only:
224
+ * the agent always runs at its role's effort.
225
+ */
226
+ const agentEffortAsk = ({
227
+ state,
228
+ ticket,
229
+ role,
230
+ attempt,
231
+ }: {
232
+ state: RunState
233
+ ticket: number
234
+ role: RoutedRole
235
+ attempt: number
236
+ }): JevAsk => ({
237
+ job: 'agent_effort',
238
+ ticket,
239
+ role,
240
+ request: {
241
+ state: {
242
+ ...ticketState({ state, ticket }),
243
+ role,
244
+ try: String(attempt),
245
+ },
246
+ questions: {
247
+ effort: choice({
248
+ instructions:
249
+ 'Which effort should this agent use? Pick the lowest that will get it right.',
250
+ options: OFFERED_EFFORTS,
251
+ }),
252
+ },
253
+ },
254
+ fixed: {
255
+ effort:
256
+ state.config === null
257
+ ? ROLE_EFFORT[role]
258
+ : roleEffort({ role, config: state.config }),
259
+ },
260
+ acted: false,
261
+ })
262
+
213
263
  const agentSkillsAsk = ({
214
264
  state,
215
265
  ticket,
@@ -250,8 +300,9 @@ const isRoutedTicketRole = (role: string): role is RoutedRole =>
250
300
 
251
301
  /**
252
302
  * The asks to make before the engine carries out `action`: ticket order
253
- * before a ticket starts, skills before each agent, and the model before
254
- * each fresh test-writer, implementer, and learner.
303
+ * before a ticket starts, skills before each agent, the model before each
304
+ * fresh test-writer, implementer, and learner, and the effort before each
305
+ * fresh test-writer and implementer.
255
306
  */
256
307
  export const jevAsksBefore = ({
257
308
  action,
@@ -265,16 +316,12 @@ export const jevAsksBefore = ({
265
316
  return createTicketWorktreeAsks({ state, ticket: action.ticket })
266
317
  case 'launch_agent': {
267
318
  const { ticket, role } = action
268
- const failed = state.tickets[ticket]?.failed_tries[role] ?? 0
319
+ const attempt = (state.tickets[ticket]?.failed_tries[role] ?? 0) + 1
269
320
  return [
270
321
  ...(isRoutedTicketRole(role)
271
322
  ? [
272
- agentModelAsk({
273
- state,
274
- ticket,
275
- role,
276
- attempt: failed + 1,
277
- }),
323
+ agentModelAsk({ state, ticket, role, attempt }),
324
+ agentEffortAsk({ state, ticket, role, attempt }),
278
325
  ]
279
326
  : []),
280
327
  agentSkillsAsk({ state, ticket, role }),
@@ -100,11 +100,13 @@ export type JevAnswer = z.infer<typeof JevAnswerSchema>
100
100
  /**
101
101
  * The jobs the engine asks Jev about. `ticket_model` (one model per ticket)
102
102
  * is no longer asked; `agent_model` (one per agent) replaced it in #602. It
103
- * stays so old journals read.
103
+ * stays so old journals read. `agent_effort` is only ever asked in shadow
104
+ * mode (#661).
104
105
  */
105
106
  export const JevJobSchema = z.enum([
106
107
  'ticket_model',
107
108
  'agent_model',
109
+ 'agent_effort',
108
110
  'agent_skills',
109
111
  'ticket_order',
110
112
  'failure_kind',
@@ -8,6 +8,7 @@ import {
8
8
  FileModeSchema,
9
9
  RoleResultSchema,
10
10
  } from '../agents/role-results'
11
+ import { EffortLevelSchema } from '../config/engine-config'
11
12
  import { GateCheckSchema } from '../gates/gate-schemas'
12
13
  import { ModelReasonSchema } from '../jev/model-pick'
13
14
 
@@ -23,7 +24,9 @@ import { ModelReasonSchema } from '../jev/model-pick'
23
24
  * follow-up's is its session's); `null` in journals from before #600. The
24
25
  * exact version is in the turn's `agent_session`. `model_reason` is why it
25
26
  * launched on that model (#603; a follow-up's is its session's); `null` in
26
- * journals from before it.
27
+ * journals from before it. `effort` is the effort it ran at (#659; a
28
+ * follow-up's is its session's); `null` in journals from before it, when
29
+ * every agent ran at `high`.
27
30
  */
28
31
  export const AgentStartedEntrySchema = z.object({
29
32
  ...ENTRY_FIELDS,
@@ -34,6 +37,7 @@ export const AgentStartedEntrySchema = z.object({
34
37
  follow_up_of: z.string().nullable().default(null),
35
38
  model: z.string().nullable().default(null),
36
39
  model_reason: ModelReasonSchema.nullable().default(null),
40
+ effort: EffortLevelSchema.nullable().default(null),
37
41
  }),
38
42
  })
39
43
 
@@ -143,7 +143,10 @@ export const finalReviewAfter = ({
143
143
  return finalResultChange({
144
144
  review: {
145
145
  ...review,
146
+ // A finished turn gives the role its tries back: the
147
+ // cap is on failed tries in a row.
146
148
  agent_failures: omit(review.agent_failures, role),
149
+ failed_tries: omit(review.failed_tries, role),
147
150
  engine_failures: omit(review.engine_failures, role),
148
151
  sessions: sessionsAfter({
149
152
  sessions: review.sessions,
@@ -13,7 +13,11 @@ import {
13
13
  handoffRole,
14
14
  type FinishedContent,
15
15
  } from './agent-turn-replay'
16
- import type { FinalFinding, FinalReviewState } from './final-review-state'
16
+ import type {
17
+ FinalDeclined,
18
+ FinalFinding,
19
+ FinalReviewState,
20
+ } from './final-review-state'
17
21
  import { setupChangeOf } from './ticket-result-replay'
18
22
 
19
23
  import {
@@ -112,6 +116,21 @@ const lensResultOf = ({
112
116
  }
113
117
  }
114
118
 
119
+ /**
120
+ * The blocking findings of each due lens that finished this round, with
121
+ * its lens.
122
+ */
123
+ const roundBlocking = ({
124
+ review,
125
+ }: {
126
+ review: FinalReviewState
127
+ }): FinalFinding[] =>
128
+ review.lenses_due.flatMap((lens) =>
129
+ (review.results[lens]?.findings ?? [])
130
+ .filter(isBlocking)
131
+ .map((entry) => ({ ...entry, lens }))
132
+ )
133
+
115
134
  /**
116
135
  * Every due lens finished this round, and its slow tests ran if they were
117
136
  * due (#563): the round can settle.
@@ -161,9 +180,7 @@ export const settleRound = ({
161
180
  result.findings.filter((entry) => !isBlocking(entry))
162
181
  )
163
182
  )
164
- const blocking = due.flatMap(({ lens, result }) =>
165
- tagged(lens, result.findings.filter(isBlocking))
166
- )
183
+ const blocking = roundBlocking({ review })
167
184
  const settled: FinalReviewState = {
168
185
  ...review,
169
186
  declined: [...review.declined, ...declined],
@@ -199,6 +216,51 @@ export const settleRound = ({
199
216
  }
200
217
  }
201
218
 
219
+ /** The final review's findings as its PR lists them. */
220
+ export type PullRequestFindings = {
221
+ /** The blocking findings still open. */
222
+ open: FinalFinding[]
223
+ nits: FinalFinding[]
224
+ declined: FinalDeclined[]
225
+ }
226
+
227
+ /**
228
+ * The final review's findings as its PR lists them: the blocking ones still
229
+ * open, the nits, and the "won't fix" answers a lens let go. A round that
230
+ * never settled (a lens failed on every try, or a step crashed, and the
231
+ * owner replied `ship`) is settled over the lenses that finished it, as
232
+ * `settleRound` would: their blocking findings, nits, and rulings count,
233
+ * and a finding one of them cleared is no longer open. The open fix round's
234
+ * findings stay open only for the lenses that never finished. A settled
235
+ * round's findings are as they stand.
236
+ *
237
+ * @example
238
+ * finalPullRequestFindings({ review }).open
239
+ * // [{ id: 'simplification-F1', lens: 'simplification', severity: 'should_fix', ... }]
240
+ */
241
+ export const finalPullRequestFindings = ({
242
+ review,
243
+ }: {
244
+ review: FinalReviewState
245
+ }): PullRequestFindings => {
246
+ const earlier = review.fix?.findings ?? []
247
+ const finished = review.lenses_due.filter(
248
+ (lens) => review.results[lens] !== undefined
249
+ )
250
+ if (roundDone({ review }) || finished.length === 0) {
251
+ return { open: earlier, nits: review.nits, declined: review.declined }
252
+ }
253
+ const settled = settleRound({ review: { ...review, lenses_due: finished } })
254
+ return {
255
+ open: [
256
+ ...earlier.filter(({ lens }) => !finished.includes(lens)),
257
+ ...(settled.fix?.findings ?? []),
258
+ ],
259
+ nits: settled.nits,
260
+ declined: settled.declined,
261
+ }
262
+ }
263
+
202
264
  /**
203
265
  * Where a finished agent's result goes in the final review: a lens's
204
266
  * result this round (settling the round once every due lens finished), or
@@ -72,7 +72,10 @@ export type FinalReviewState = {
72
72
  sessions: Partial<Record<AgentRole, string>>
73
73
  /** Each role's failed turn, if no agent of that role finished after it. */
74
74
  agent_failures: Partial<Record<AgentRole, ReplayedAgentFailure>>
75
- /** Failed tries (agent, result, guard) per role, over the whole final review. */
75
+ /**
76
+ * Failed tries (agent, result, guard) per role, in a row: a finished
77
+ * turn of the role starts its count again.
78
+ */
76
79
  failed_tries: Partial<Record<AgentRole, number>>
77
80
  /** Engine failures in a row, per role. */
78
81
  engine_failures: Partial<Record<AgentRole, number>>
@@ -71,7 +71,7 @@ export { EMPTY_FINAL_REVIEW } from './final-review-state'
71
71
  export type { PlanState } from './plan-replay'
72
72
  export type { LearnerState, MemoryState } from './memory-replay'
73
73
  export { EMPTY_MEMORY } from './memory-replay'
74
- export { namespacedId } from './final-review-round'
74
+ export { finalPullRequestFindings, namespacedId } from './final-review-round'
75
75
  export { isFinalKey } from './final-review-replay'
76
76
  export { handoffRole } from './agent-turn-replay'
77
77