@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
@@ -0,0 +1,273 @@
1
+ import {
2
+ EFFORT_BEFORE_659,
3
+ FINDING_WEIGHTS,
4
+ SCORED_ROLES,
5
+ type ScoredRole,
6
+ } from './model-score-rules'
7
+
8
+ import type { FindingSeverity } from '../agents/role-results'
9
+ import { modelsOf } from '../config/config-fields'
10
+ import {
11
+ EffortLevelSchema,
12
+ ModelNameSchema,
13
+ ModelsConfigSchema,
14
+ type EffortLevel,
15
+ type ModelName,
16
+ } from '../config/engine-config'
17
+ import type { JournalRecord } from '../journal/journal-record'
18
+ import { countedTokens } from '../limits/plan-usage'
19
+
20
+ /**
21
+ * What one run's journal says for the model score report (#605): each
22
+ * ticket's models, efforts, Clef's picks and review results, the learner's
23
+ * agents, and the final review's findings. Pure: it only reads records.
24
+ */
25
+
26
+ /** Clef's first model pick for a role on a ticket. */
27
+ export type Pick = {
28
+ model: ModelName | null
29
+ confidence: number | null
30
+ /** Asked in shadow mode: `acted: false`, or an older `ticket_model` ask. */
31
+ shadow: boolean
32
+ }
33
+
34
+ /** What one run's journal says about one ticket. */
35
+ export type TicketFacts = {
36
+ /** The model of each role's first agent. */
37
+ models: Partial<Record<ScoredRole, ModelName>>
38
+ /** The effort of each role's first agent, an older one's `high`. */
39
+ efforts: Partial<Record<ScoredRole, EffortLevel>>
40
+ /** The first ticket review's effort, `high` when none is journaled. */
41
+ review_effort: EffortLevel
42
+ picks: Partial<Record<ScoredRole, Pick>>
43
+ /**
44
+ * Clef's first `agent_effort` answer for each role at or above the run's
45
+ * confidence bar.
46
+ */
47
+ effort_picks: Partial<Record<ScoredRole, EffortLevel>>
48
+ reviews: number
49
+ /** The first ticket review's weighted findings. */
50
+ findings: number
51
+ fix_rounds: number
52
+ joined: boolean
53
+ lines: number | null
54
+ stuck: boolean
55
+ tokens: number | null
56
+ }
57
+
58
+ /** A fresh learner agent's model and effort. */
59
+ export type LearnerRun = { model: ModelName; effort: EffortLevel }
60
+
61
+ /** What one run's journal says. */
62
+ export type RunFacts = {
63
+ tickets: TicketFacts[]
64
+ routed: boolean
65
+ learner: LearnerRun[]
66
+ /** The first final review round's weighted findings, or `null` with none. */
67
+ final_findings: number | null
68
+ /** The joined tickets' lines, or `null` when one has none. */
69
+ lines: number | null
70
+ }
71
+
72
+ /**
73
+ * The model family a short name or full model id (as older `ticket_model`
74
+ * asks named them) belongs to, or `null` for none.
75
+ *
76
+ * @example
77
+ * familyOf('sonnet') // 'sonnet'
78
+ */
79
+ const familyOf = (name: unknown): ModelName | null => {
80
+ if (typeof name !== 'string') return null
81
+ const lower = name.toLowerCase()
82
+ return (
83
+ ModelNameSchema.options.find((model) => lower.includes(model)) ?? null
84
+ )
85
+ }
86
+
87
+ const isScored = (role: string | null): role is ScoredRole =>
88
+ SCORED_ROLES.some((scored) => scored === role)
89
+
90
+ export const sum = (values: number[]): number =>
91
+ values.reduce((total, value) => total + value, 0)
92
+
93
+ const weigh = (counts: Record<FindingSeverity, number>): number =>
94
+ counts.blocker * FINDING_WEIGHTS.blocker +
95
+ counts.should_fix * FINDING_WEIGHTS.should_fix +
96
+ counts.nit * FINDING_WEIGHTS.nit
97
+
98
+ const newTicket = (): TicketFacts => ({
99
+ models: {},
100
+ efforts: {},
101
+ review_effort: EFFORT_BEFORE_659,
102
+ picks: {},
103
+ effort_picks: {},
104
+ reviews: 0,
105
+ findings: 0,
106
+ fix_rounds: 0,
107
+ joined: false,
108
+ lines: null,
109
+ stuck: false,
110
+ tokens: null,
111
+ })
112
+
113
+ /**
114
+ * What one run's records say, ticket by ticket. An agent with no model ran
115
+ * Opus (before #600), and one with no effort ran at `high` (before #659).
116
+ */
117
+ export const factsOf = (records: JournalRecord[]): RunFacts => {
118
+ const tickets = new Map<number, TicketFacts>()
119
+ const ticketOf = (number: number): TicketFacts => {
120
+ const found = tickets.get(number) ?? newTicket()
121
+ tickets.set(number, found)
122
+ return found
123
+ }
124
+ const asks = new Map<number, { role: string | null; shadow: boolean }>()
125
+ const effort_asks = new Map<number, string | null>()
126
+ const reviewed = new Set<number>()
127
+ const learner: LearnerRun[] = []
128
+ let bar = ModelsConfigSchema.parse({}).confidence_bar
129
+ let routed = false
130
+ let final_rounds = 0
131
+ let final_findings = 0
132
+ for (const record of records) {
133
+ switch (record.kind) {
134
+ case 'run_started':
135
+ bar = modelsOf({ config: record.content.config }).confidence_bar
136
+ break
137
+ case 'jev_asked':
138
+ if (record.content.job === 'agent_effort') {
139
+ effort_asks.set(record.seq, record.role)
140
+ }
141
+ if (
142
+ record.content.job === 'agent_model' ||
143
+ record.content.job === 'ticket_model'
144
+ ) {
145
+ asks.set(record.seq, {
146
+ role: record.role,
147
+ shadow:
148
+ record.content.job === 'ticket_model' ||
149
+ !record.content.acted,
150
+ })
151
+ }
152
+ break
153
+ case 'jev_answered': {
154
+ const effort_role = effort_asks.get(record.content.asked_seq)
155
+ if (effort_role !== undefined && record.ticket !== null) {
156
+ const answer = record.content.answers.effort
157
+ const level = EffortLevelSchema.safeParse(answer?.value)
158
+ const confidence = answer?.confidence ?? null
159
+ if (
160
+ isScored(effort_role) &&
161
+ level.success &&
162
+ confidence !== null &&
163
+ confidence >= bar
164
+ ) {
165
+ ticketOf(record.ticket).effort_picks[effort_role] ??=
166
+ level.data
167
+ }
168
+ break
169
+ }
170
+ const ask = asks.get(record.content.asked_seq)
171
+ if (ask === undefined || record.ticket === null) break
172
+ const answer = record.content.answers.model
173
+ const pick: Pick = {
174
+ model: familyOf(answer?.value),
175
+ confidence: answer?.confidence ?? null,
176
+ shadow: ask.shadow,
177
+ }
178
+ const roles =
179
+ record.content.job === 'ticket_model'
180
+ ? SCORED_ROLES
181
+ : SCORED_ROLES.filter((role) => role === ask.role)
182
+ const facts = ticketOf(record.ticket)
183
+ for (const role of roles) facts.picks[role] ??= pick
184
+ break
185
+ }
186
+ case 'agent_started': {
187
+ const { role, model, effort, follow_up_of } = record.content
188
+ const family = familyOf(model) ?? 'opus'
189
+ const level = effort ?? EFFORT_BEFORE_659
190
+ if (follow_up_of !== null) break
191
+ if (role === 'learner') {
192
+ learner.push({ model: family, effort: level })
193
+ }
194
+ if (isScored(role) || role === 'learner') {
195
+ routed ||= family !== 'opus'
196
+ }
197
+ if (record.ticket === null) break
198
+ if (isScored(role)) {
199
+ const facts = ticketOf(record.ticket)
200
+ facts.models[role] ??= family
201
+ facts.efforts[role] ??= level
202
+ }
203
+ if (
204
+ role === 'ticket-reviewer' &&
205
+ !reviewed.has(record.ticket)
206
+ ) {
207
+ reviewed.add(record.ticket)
208
+ ticketOf(record.ticket).review_effort = level
209
+ }
210
+ break
211
+ }
212
+ case 'agent_finished': {
213
+ const { content } = record
214
+ if (
215
+ content.role !== 'ticket-reviewer' ||
216
+ record.ticket === null
217
+ ) {
218
+ break
219
+ }
220
+ const facts = ticketOf(record.ticket)
221
+ if (facts.reviews === 0) {
222
+ facts.findings = sum(
223
+ content.result.findings.map(
224
+ ({ severity }) => FINDING_WEIGHTS[severity]
225
+ )
226
+ )
227
+ }
228
+ facts.reviews += 1
229
+ if (content.result.verdict === 'changes_requested') {
230
+ facts.fix_rounds += 1
231
+ }
232
+ break
233
+ }
234
+ case 'ticket_joined':
235
+ if (record.ticket !== null && record.content.ok) {
236
+ const facts = ticketOf(record.ticket)
237
+ facts.joined = true
238
+ facts.lines = record.content.lines_changed
239
+ }
240
+ break
241
+ case 'ticket_stuck':
242
+ if (record.ticket !== null) ticketOf(record.ticket).stuck = true
243
+ break
244
+ case 'usage_recorded': {
245
+ const { scope, ticket, tokens } = record.content
246
+ if (scope === 'ticket' && ticket !== null) {
247
+ ticketOf(ticket).tokens = countedTokens({ tokens })
248
+ }
249
+ break
250
+ }
251
+ case 'final_review_started':
252
+ final_rounds += 1
253
+ break
254
+ case 'lens_finished':
255
+ if (final_rounds === 1) {
256
+ final_findings += weigh(record.content.findings)
257
+ }
258
+ break
259
+ default:
260
+ break
261
+ }
262
+ }
263
+ const joined = [...tickets.values()].filter(({ joined }) => joined)
264
+ return {
265
+ tickets: [...tickets.values()],
266
+ routed,
267
+ learner,
268
+ final_findings: final_rounds > 0 ? final_findings : null,
269
+ lines: joined.some(({ lines }) => lines === null)
270
+ ? null
271
+ : sum(joined.map(({ lines }) => lines ?? 0)),
272
+ }
273
+ }
@@ -0,0 +1,35 @@
1
+ import type { FindingSeverity } from '../agents/role-results'
2
+ import type { EffortLevel } from '../config/engine-config'
3
+
4
+ /**
5
+ * The model score report's constants (#605, #662): the finding weights, the
6
+ * verdict edges, the scored roles, and the effort an older journal's agent
7
+ * ran at. The one place they live.
8
+ */
9
+
10
+ /** How much each ticket review finding weighs. */
11
+ export const FINDING_WEIGHTS: Record<FindingSeverity, number> = {
12
+ blocker: 3,
13
+ should_fix: 1,
14
+ nit: 0,
15
+ }
16
+
17
+ /** The fewest tickets on each side before a verdict is given. */
18
+ export const MIN_TICKETS = 10
19
+
20
+ /** How far above the baseline a rate may be before it is "worse". */
21
+ export const WORSE_BY = 0.2
22
+
23
+ /** Rounding slack when a rate is compared with the baseline's. */
24
+ export const EPSILON = 1e-9
25
+
26
+ /** The roles scored by the ticket review's findings. */
27
+ export const SCORED_ROLES = ['test-writer', 'implementer'] as const
28
+
29
+ export type ScoredRole = (typeof SCORED_ROLES)[number]
30
+
31
+ /**
32
+ * The effort an agent with no journaled effort ran at: every role ran at
33
+ * `high` before #659.
34
+ */
35
+ export const EFFORT_BEFORE_659: EffortLevel = 'high'