@alecsibilia/luca 14.0.0-alpha.22 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.23
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.22
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.22",
3
+ "version": "14.0.0-alpha.23",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,2 +1,2 @@
1
1
  /** The Luca version of this board, stamped when the package was packed. */
2
- export const LUCA_VERSION: string = "14.0.0-alpha.22"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.23"
@@ -33,15 +33,19 @@ export const MODEL_NAMES = ModelNameSchema.options
33
33
  /** The model reviewers run on, and every agent the decision model doesn't route (#603). */
34
34
  export const CLAUDE_MODEL = DEFAULT_MODEL
35
35
 
36
- /** How many model turns one agent gets before its try ends. */
36
+ /**
37
+ * How many model turns one agent gets before its try ends. The integration
38
+ * lens reads how every ticket fits together on the whole branch, so it gets
39
+ * more than the other lenses.
40
+ */
37
41
  export const MAX_TURNS: Record<AgentRole, number> = {
38
42
  'test-writer': 80,
39
- implementer: 120,
43
+ implementer: 150,
40
44
  'ticket-reviewer': 60,
41
45
  'architecture-lens': 60,
42
46
  'simplification-lens': 60,
43
47
  'security-lens': 60,
44
- 'integration-lens': 60,
48
+ 'integration-lens': 100,
45
49
  'rules-lens': 60,
46
50
  learner: 40,
47
51
  }
@@ -29,10 +29,11 @@ import type { AgentRole } from '../agents/role-results'
29
29
  import { handoffRole, type ReplayedAgentFailure } from '../journal/replay'
30
30
 
31
31
  /**
32
- * A fresh fixer of this role, told its findings (or the failed gates). With
33
- * `follow_up`, it stands in for a follow-up whose session is gone, and its
34
- * prompt carries the message. A fixer taking a turn a crash cut off is told
35
- * of the crash.
32
+ * A fresh fixer of this role, told its findings (or the failed gates). The
33
+ * implementer that starts a round whose slow tests failed is told of them
34
+ * too (#563). With `follow_up`, it stands in for a follow-up whose session
35
+ * is gone, and its prompt carries the message. A fixer taking a turn a
36
+ * crash cut off is told of the crash.
36
37
  */
37
38
  const launchFixer = ({
38
39
  role,
@@ -49,6 +50,11 @@ const launchFixer = ({
49
50
  // An implementer with no code findings left is here for failed gates.
50
51
  const gates =
51
52
  role === 'implementer' && fix.code_answered ? review.gates : null
53
+ // The implementer with code findings also fixes the round's slow tests.
54
+ const slow_tests =
55
+ role === 'implementer' && !fix.code_answered && review.slow_due
56
+ ? review.slow_tests
57
+ : null
52
58
  const sections = [
53
59
  ...(follow_up === undefined
54
60
  ? []
@@ -63,6 +69,7 @@ const launchFixer = ({
63
69
  snapshot,
64
70
  fix,
65
71
  gates,
72
+ slow_tests,
66
73
  run_notes,
67
74
  sections,
68
75
  }),
@@ -69,7 +69,9 @@ const lensStep = ({
69
69
  * earlier findings; its blocking findings get one fix round (past the round
70
70
  * cap, too) and one re-review, and what is still open then tops the PR
71
71
  * instead of getting the review stuck.
72
- * Failed tries are capped per role like a ticket's. A stuck final review
72
+ * Failed tries are capped per role, in a row: a finished turn of the role
73
+ * gives its tries back, so one failed try in an early round doesn't count
74
+ * against it in a later one. A stuck final review
73
75
  * waits for a reply; `ship` opens the PR anyway. A final review step cut
74
76
  * off by a crash `MAX_CRASHES` times in a row is stuck too.
75
77
  *
@@ -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
+ }
@@ -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
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alecsibilia/luca",
3
3
  "description": "Luca: turns a spec on GitHub Issues into a reviewed pull request, run from Paseo with Claude Code",
4
- "version": "14.0.0-alpha.22",
4
+ "version": "14.0.0-alpha.23",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Alec Sibilia <sibilia.alec@gmail.com>",
7
7
  "homepage": "https://github.com/asibilia/luca-framework#readme",