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

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 (321) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +7 -2
  3. package/board/client/board-look.ts +31 -1
  4. package/board/client/board-panel-parts.tsx +150 -0
  5. package/board/client/board-panel-stages.ts +99 -0
  6. package/board/client/board-panel-styles.ts +223 -0
  7. package/board/client/board-panel.tsx +77 -1275
  8. package/board/client/engine-life.ts +48 -0
  9. package/board/client/final-review-stack.tsx +144 -0
  10. package/board/client/heartbeat.tsx +61 -0
  11. package/board/client/helper-lines.tsx +69 -0
  12. package/board/client/live-feed.tsx +86 -0
  13. package/board/client/live-line.ts +73 -0
  14. package/board/client/model-line.tsx +178 -0
  15. package/board/client/needs-you-section.tsx +133 -0
  16. package/board/client/one-line.tsx +193 -0
  17. package/board/client/repo-wait-banner.tsx +69 -0
  18. package/board/client/run-footer.tsx +68 -0
  19. package/board/client/run-header.tsx +189 -0
  20. package/board/client/run-picker.tsx +98 -0
  21. package/board/client/status-bar.tsx +131 -0
  22. package/board/client/step-dots.tsx +127 -0
  23. package/board/client/stuck-actions.tsx +269 -0
  24. package/board/client/ticket-card-view.tsx +238 -0
  25. package/board/client/ticket-detail.tsx +87 -0
  26. package/board/client/ticket-stack.tsx +119 -0
  27. package/board/client/ticket-wait.ts +188 -0
  28. package/board/client/timeline-rows.tsx +41 -14
  29. package/board/client/usage-line.tsx +51 -0
  30. package/board/client/wait-line.tsx +39 -0
  31. package/board/index.client.tsx +1 -1
  32. package/board/index.server.ts +7 -1
  33. package/board/package.json +1 -1
  34. package/board/server/board-panel-state-helpers.ts +67 -0
  35. package/board/server/board-server-check.ts +294 -0
  36. package/board/server/board-server-engine.ts +129 -0
  37. package/board/server/board-server-events.ts +134 -0
  38. package/board/server/board-server-read.ts +190 -0
  39. package/board/server/board-server-resume.ts +164 -0
  40. package/board/server/board-server-start.ts +174 -0
  41. package/board/server/board-server-store.ts +281 -0
  42. package/board/server/board-server-types.ts +114 -0
  43. package/board/server/board-server-util.ts +4 -0
  44. package/board/server/board-server.ts +52 -943
  45. package/board/server/board-vocabulary-engine.ts +144 -0
  46. package/board/server/board-vocabulary-review.ts +115 -0
  47. package/board/server/board-vocabulary-run.ts +145 -0
  48. package/board/server/board-vocabulary-shapes.ts +153 -0
  49. package/board/server/board-vocabulary.ts +20 -375
  50. package/board/server/current-step-panel-helpers.ts +108 -0
  51. package/board/server/describe-record.ts +403 -0
  52. package/board/server/engine-launch.ts +20 -3
  53. package/board/server/engine-restart-helpers.ts +105 -0
  54. package/board/server/engine-watch.ts +45 -5
  55. package/board/server/journal-rebuild-helpers.ts +169 -0
  56. package/board/server/live-file.ts +130 -0
  57. package/board/server/luca-version.ts +1 -1
  58. package/board/server/make-rows.ts +14 -629
  59. package/board/server/record-texts.ts +350 -0
  60. package/board/server/reduce-board-agent-start.ts +224 -0
  61. package/board/server/reduce-board-agents.ts +160 -0
  62. package/board/server/reduce-board-counters.ts +138 -0
  63. package/board/server/reduce-board-create.ts +148 -0
  64. package/board/server/reduce-board-final-review.ts +174 -0
  65. package/board/server/reduce-board-needs-you.ts +283 -0
  66. package/board/server/reduce-board-reasons.ts +59 -0
  67. package/board/server/reduce-board-run.ts +255 -0
  68. package/board/server/reduce-board-settle.ts +88 -0
  69. package/board/server/reduce-board-steps.ts +226 -0
  70. package/board/server/reduce-board-text.ts +139 -0
  71. package/board/server/reduce-board-ticket-checks.ts +168 -0
  72. package/board/server/reduce-board-ticket-join.ts +144 -0
  73. package/board/server/reduce-board-ticket-start.ts +132 -0
  74. package/board/server/reduce-board-update.ts +117 -0
  75. package/board/server/reduce-board-usage.ts +247 -0
  76. package/board/server/reduce-board.ts +100 -1917
  77. package/board/server/reply-post.ts +407 -0
  78. package/board/server/run-registry.ts +8 -4
  79. package/board/server/ticket-stages-panel-helpers.ts +296 -0
  80. package/board/shared/board-rows.ts +9 -0
  81. package/board/shared/board-rpc.ts +42 -0
  82. package/board/shared/board-state-counts.ts +77 -0
  83. package/board/shared/board-state-live.ts +99 -0
  84. package/board/shared/board-state-review.ts +59 -0
  85. package/board/shared/board-state-run.ts +236 -0
  86. package/board/shared/board-state-ticket.ts +257 -0
  87. package/board/shared/board-state-usage.ts +107 -0
  88. package/board/shared/board-state.ts +97 -567
  89. package/board/shared/reply-actions.ts +207 -0
  90. package/engine/agents/agent-launcher.ts +27 -4
  91. package/engine/agents/claude-launcher-helpers.ts +233 -0
  92. package/engine/agents/claude-launcher.ts +28 -669
  93. package/engine/agents/claude-messages.ts +212 -0
  94. package/engine/agents/claude-options.ts +43 -8
  95. package/engine/agents/common-instructions.ts +138 -0
  96. package/engine/agents/input-channel.ts +36 -0
  97. package/engine/agents/launch-paths.ts +29 -0
  98. package/engine/agents/launch-session.ts +227 -0
  99. package/engine/agents/learner-instructions.ts +31 -0
  100. package/engine/agents/open-session.ts +210 -0
  101. package/engine/agents/prompt-sections.ts +95 -0
  102. package/engine/agents/rejoin-prompts.ts +135 -0
  103. package/engine/agents/reviewer-instructions.ts +99 -0
  104. package/engine/agents/role-instructions.ts +31 -244
  105. package/engine/agents/role-prompts.ts +39 -109
  106. package/engine/agents/role-results.ts +106 -7
  107. package/engine/agents/scripted-launcher.ts +21 -6
  108. package/engine/agents/session-pool.ts +124 -0
  109. package/engine/agents/turn-progress.ts +283 -0
  110. package/engine/agents/writer-instructions.ts +151 -0
  111. package/engine/cli/computer-adapters-real.ts +15 -3
  112. package/engine/cli/computer-adapters.ts +2 -0
  113. package/engine/cli/computer-checks.ts +87 -4
  114. package/engine/cli/config-reload-text.ts +39 -0
  115. package/engine/cli/decision-model-setup.ts +70 -0
  116. package/engine/cli/doctor-command.ts +1 -0
  117. package/engine/cli/doctor.ts +21 -3
  118. package/engine/cli/going-runs.ts +192 -36
  119. package/engine/cli/init-command.ts +1 -0
  120. package/engine/cli/init.ts +23 -1
  121. package/engine/cli/live-runs.ts +73 -0
  122. package/engine/cli/luca-run.ts +42 -7
  123. package/engine/cli/luca-skills.ts +183 -0
  124. package/engine/cli/luca.ts +8 -1
  125. package/engine/cli/models-command.ts +119 -0
  126. package/engine/cli/read-run.ts +81 -0
  127. package/engine/cli/run-args.ts +33 -6
  128. package/engine/cli/run-demo.ts +143 -0
  129. package/engine/cli/run-end.ts +184 -0
  130. package/engine/cli/run-modes-helpers.ts +81 -0
  131. package/engine/cli/run-modes.ts +62 -439
  132. package/engine/cli/setup.ts +5 -48
  133. package/engine/cli/unfinished-runs.ts +122 -0
  134. package/engine/cli/upgrade-command.ts +212 -30
  135. package/engine/cli/upgrade-finish.ts +149 -0
  136. package/engine/cli/upgrade.ts +112 -71
  137. package/engine/config/config-fields.ts +127 -0
  138. package/engine/config/config-reload.ts +250 -0
  139. package/engine/config/engine-config-schema.ts +235 -0
  140. package/engine/config/engine-config.ts +72 -142
  141. package/engine/config/test-commands.ts +102 -0
  142. package/engine/core/agent-guards-helpers.ts +84 -0
  143. package/engine/core/agent-turn.ts +395 -0
  144. package/engine/core/already-done.ts +44 -1
  145. package/engine/core/build-action.ts +214 -0
  146. package/engine/core/build-context.ts +135 -0
  147. package/engine/core/build-launch.ts +259 -0
  148. package/engine/core/commit-in.ts +282 -0
  149. package/engine/core/decide-build-early.ts +108 -0
  150. package/engine/core/decide-build-ending.ts +145 -0
  151. package/engine/core/decide-build-helpers.ts +87 -0
  152. package/engine/core/decide-build-tickets.ts +207 -0
  153. package/engine/core/decide-build-view.ts +103 -0
  154. package/engine/core/decide-build.ts +101 -1368
  155. package/engine/core/decide-final-fix.ts +203 -0
  156. package/engine/core/decide-final-review.ts +55 -310
  157. package/engine/core/decide-red-green.ts +398 -0
  158. package/engine/core/decide-rejoin.ts +259 -0
  159. package/engine/core/decide-repo-wait.ts +129 -0
  160. package/engine/core/decide-run-budget.ts +86 -12
  161. package/engine/core/decide-stuck-helpers.ts +127 -0
  162. package/engine/core/decide-stuck.ts +60 -8
  163. package/engine/core/decide-ticket-review.ts +95 -0
  164. package/engine/core/decide-ticket.ts +267 -0
  165. package/engine/core/decide-waits.ts +47 -0
  166. package/engine/core/decide.ts +74 -23
  167. package/engine/core/engine-chores.ts +163 -0
  168. package/engine/core/environment-rerun.ts +83 -0
  169. package/engine/core/evidence-text.ts +84 -0
  170. package/engine/core/execute-action.ts +303 -0
  171. package/engine/core/execute-already-done-check.ts +133 -0
  172. package/engine/core/execute-build.ts +68 -944
  173. package/engine/core/execute-changeset.ts +5 -7
  174. package/engine/core/execute-final-review.ts +125 -13
  175. package/engine/core/execute-gates.ts +182 -0
  176. package/engine/core/execute-intake.ts +130 -0
  177. package/engine/core/execute-join.ts +276 -0
  178. package/engine/core/execute-memory.ts +12 -0
  179. package/engine/core/execute-pull-request.ts +84 -0
  180. package/engine/core/execute-red-check.ts +219 -0
  181. package/engine/core/execute-repo-wait.ts +129 -0
  182. package/engine/core/execute-stuck.ts +25 -10
  183. package/engine/core/execute-waits.ts +158 -0
  184. package/engine/core/execute-with-jev.ts +118 -0
  185. package/engine/core/execute.ts +124 -831
  186. package/engine/core/final-failure-cap.ts +38 -0
  187. package/engine/core/final-review-action.ts +125 -0
  188. package/engine/core/final-review-text.ts +63 -17
  189. package/engine/core/final-stuck-text.ts +86 -0
  190. package/engine/core/fix-loop-text.ts +49 -2
  191. package/engine/core/left-out.ts +120 -0
  192. package/engine/core/live-file.ts +372 -0
  193. package/engine/core/loop-caps.ts +25 -2
  194. package/engine/core/not-in-run.ts +7 -3
  195. package/engine/core/pull-request-text.ts +255 -6
  196. package/engine/core/repo-wait-text.ts +81 -0
  197. package/engine/core/review-text.ts +55 -1
  198. package/engine/core/run-crash-recovery-helpers.ts +16 -0
  199. package/engine/core/run-session-close-helpers.ts +96 -0
  200. package/engine/core/run-start.ts +148 -0
  201. package/engine/core/run-stuck-text.ts +86 -0
  202. package/engine/core/schedule-rules.ts +193 -0
  203. package/engine/core/stuck-detail-text.ts +89 -0
  204. package/engine/core/stuck-format.ts +22 -0
  205. package/engine/core/stuck-text.ts +29 -332
  206. package/engine/core/ticket-stuck-text.ts +192 -0
  207. package/engine/core/worktree-changes.ts +106 -0
  208. package/engine/gates/already-done-check.ts +120 -0
  209. package/engine/gates/feature-map.ts +96 -0
  210. package/engine/gates/gate-runner.ts +108 -108
  211. package/engine/gates/gate-schemas.ts +61 -2
  212. package/engine/gates/leftover-scan.ts +154 -31
  213. package/engine/gates/new-tests.ts +45 -0
  214. package/engine/gates/own-files-check.ts +98 -0
  215. package/engine/gates/prepare-check.ts +60 -0
  216. package/engine/gates/prepare-slots.ts +99 -0
  217. package/engine/gates/red-check.ts +184 -16
  218. package/engine/gates/shell-check.ts +64 -0
  219. package/engine/gates/slot-waits.ts +55 -0
  220. package/engine/gates/slow-test-runner.ts +59 -0
  221. package/engine/gates/test-checks.ts +161 -0
  222. package/engine/gates/test-runner.ts +164 -5
  223. package/engine/gates/test-slots.ts +48 -0
  224. package/engine/git/git-adapter.ts +55 -396
  225. package/engine/git/git-commits.ts +132 -0
  226. package/engine/git/git-join.ts +139 -0
  227. package/engine/git/git-read.ts +123 -0
  228. package/engine/git/git-run.ts +32 -0
  229. package/engine/git/git-worktrees.ts +143 -0
  230. package/engine/git/outside-links.ts +55 -0
  231. package/engine/guards/after-turn-check.ts +31 -5
  232. package/engine/guards/guard-hook.ts +60 -12
  233. package/engine/guards/guard-roles.ts +68 -0
  234. package/engine/guards/helper-rules.ts +92 -0
  235. package/engine/guards/permission-rules.ts +161 -0
  236. package/engine/guards/role-rules-helpers.ts +48 -0
  237. package/engine/guards/role-rules.ts +34 -606
  238. package/engine/guards/shell-rules.ts +344 -0
  239. package/engine/guards/worktree-git.ts +208 -0
  240. package/engine/guards/worktree-snapshot.ts +412 -0
  241. package/engine/guards/worktree-state.ts +28 -825
  242. package/engine/guards/worktree-undo.ts +260 -0
  243. package/engine/guards/write-rules.ts +149 -0
  244. package/engine/index.ts +30 -9
  245. package/engine/intake/criteria-checks.ts +114 -0
  246. package/engine/intake/intake-checks.ts +164 -30
  247. package/engine/intake/intake-schemas.ts +37 -2
  248. package/engine/jev/clef-client.ts +259 -0
  249. package/engine/jev/decision-model-credentials.ts +212 -0
  250. package/engine/jev/jev-jobs.ts +144 -60
  251. package/engine/jev/jev-schemas.ts +86 -5
  252. package/engine/jev/jev-shadow-helpers.ts +218 -0
  253. package/engine/jev/jev-shadow.ts +109 -64
  254. package/engine/jev/model-pick.ts +275 -0
  255. package/engine/journal/agent-entries.ts +167 -0
  256. package/engine/journal/agent-message-entries.ts +72 -0
  257. package/engine/journal/agent-turn-replay.ts +203 -0
  258. package/engine/journal/already-done-entries.ts +55 -0
  259. package/engine/journal/commit-entries.ts +56 -0
  260. package/engine/journal/cross-ticket-replay.ts +222 -0
  261. package/engine/journal/decision-model-entries.ts +86 -0
  262. package/engine/journal/entry-fields.ts +10 -0
  263. package/engine/journal/final-review-entries.ts +128 -0
  264. package/engine/journal/final-review-replay.ts +324 -0
  265. package/engine/journal/final-review-round.ts +320 -0
  266. package/engine/journal/final-review-state.ts +180 -0
  267. package/engine/journal/gate-entries.ts +81 -0
  268. package/engine/journal/intake-entries.ts +87 -0
  269. package/engine/journal/join-entries.ts +126 -0
  270. package/engine/journal/journal-entry-schema.ts +218 -0
  271. package/engine/journal/journal-kinds.ts +97 -0
  272. package/engine/journal/journal-record-schema.ts +218 -0
  273. package/engine/journal/journal-record.ts +43 -1400
  274. package/engine/journal/journal.ts +6 -1
  275. package/engine/journal/limit-entries.ts +90 -0
  276. package/engine/journal/memory-entries.ts +124 -0
  277. package/engine/journal/memory-replay.ts +157 -0
  278. package/engine/journal/plan-replay.ts +139 -0
  279. package/engine/journal/pull-request-entries.ts +41 -0
  280. package/engine/journal/replay.ts +157 -2144
  281. package/engine/journal/reply-replay.ts +144 -0
  282. package/engine/journal/repo-wait-entries.ts +53 -0
  283. package/engine/journal/run-entries.ts +172 -0
  284. package/engine/journal/run-state.ts +264 -0
  285. package/engine/journal/shared-failure-replay.ts +133 -0
  286. package/engine/journal/stuck-entries.ts +193 -0
  287. package/engine/journal/ticket-progress.ts +392 -0
  288. package/engine/journal/ticket-replay.ts +411 -0
  289. package/engine/journal/ticket-result-replay.ts +364 -0
  290. package/engine/journal/ticket-retry-replay.ts +142 -0
  291. package/engine/journal/worktree-entries.ts +122 -0
  292. package/engine/models/model-score.ts +512 -0
  293. package/engine/shell/run-command.ts +187 -18
  294. package/engine/testing/agent-turn-fixtures.ts +287 -0
  295. package/engine/testing/already-done-fixtures.ts +97 -0
  296. package/engine/testing/build-fixtures.ts +68 -973
  297. package/engine/testing/checkouts-fixtures.ts +48 -0
  298. package/engine/testing/final-review-fixtures.ts +62 -1
  299. package/engine/testing/final-review-steps.ts +143 -0
  300. package/engine/testing/limit-fixtures.ts +85 -0
  301. package/engine/testing/live-file-fixtures.ts +57 -0
  302. package/engine/testing/practice-repo.ts +10 -4
  303. package/engine/testing/practice-run.ts +1 -1
  304. package/engine/testing/reply-fixtures.ts +88 -0
  305. package/engine/testing/run-budget-fixtures.ts +165 -0
  306. package/engine/testing/run-fixtures.ts +219 -0
  307. package/engine/testing/sdk-play.ts +177 -0
  308. package/engine/testing/setup-repo.ts +194 -0
  309. package/engine/testing/step-record-fixtures.ts +55 -0
  310. package/engine/testing/ticket-step-fixtures.ts +272 -0
  311. package/engine/testing/wait-scenarios.ts +186 -0
  312. package/engine/tracker/gh-retry.ts +217 -0
  313. package/engine/tracker/github-tracker.ts +310 -90
  314. package/engine/tracker/in-memory-tracker.ts +8 -4
  315. package/engine/tracker/tracker.ts +131 -6
  316. package/package.json +2 -1
  317. package/skills/luca-retro/SKILL.md +74 -0
  318. package/skills/luca-retro/scripts/retro-summary.ts +1356 -0
  319. package/skills/luca-unstick/SKILL.md +153 -0
  320. package/skills/luca-unstick/scripts/stuck-summary.ts +1063 -0
  321. package/engine/jev/jev-client.ts +0 -173
@@ -0,0 +1,512 @@
1
+ import type { FindingSeverity } from '../agents/role-results'
2
+ import { ModelNameSchema, type ModelName } from '../config/engine-config'
3
+ import type { JournalRecord } from '../journal/journal-record'
4
+ import { countedTokens } from '../limits/plan-usage'
5
+
6
+ /**
7
+ * The model score report (#605): how good each routed role's work was on
8
+ * each model it ran on, from run journals, so the owner knows when to lower
9
+ * a model floor or raise one back. Pure: it reads records and never writes.
10
+ */
11
+
12
+ /** How much each ticket review finding weighs. */
13
+ export const FINDING_WEIGHTS: Record<FindingSeverity, number> = {
14
+ blocker: 3,
15
+ should_fix: 1,
16
+ nit: 0,
17
+ }
18
+
19
+ /** The fewest tickets on each side before a verdict is given. */
20
+ export const MIN_TICKETS = 10
21
+
22
+ /** How far above the baseline a rate may be before it is "worse". */
23
+ export const WORSE_BY = 0.2
24
+
25
+ /** Rounding slack when a rate is compared with the baseline's. */
26
+ const EPSILON = 1e-9
27
+
28
+ /** The roles scored by the ticket review's findings. */
29
+ export const SCORED_ROLES = ['test-writer', 'implementer'] as const
30
+
31
+ export type ScoredRole = (typeof SCORED_ROLES)[number]
32
+
33
+ export type Verdict = 'not enough data' | 'worse' | 'about the same'
34
+
35
+ /** The rates a model is judged by, for its tickets or its baseline's. */
36
+ export type ScoreSide = {
37
+ tickets: number
38
+ /** Weighted first-review findings per 100 changed lines; `null` with no lines. */
39
+ findings_per_100_lines: number | null
40
+ /** `null` with no tickets. */
41
+ fix_rounds_per_ticket: number | null
42
+ }
43
+
44
+ /** One routed role on one model. */
45
+ export type ModelScore = ScoreSide & {
46
+ role: ScoredRole
47
+ model: ModelName
48
+ stuck_tickets: number
49
+ /** `null` when no finished ticket has its usage journaled. */
50
+ tokens_per_finished_ticket: number | null
51
+ /** Clef's median confidence in its model pick; `null` with none. */
52
+ median_confidence: number | null
53
+ /** Its tickets whose other build role ran on a cheaper model than Opus. */
54
+ other_role_cheaper: number
55
+ /**
56
+ * The tickets where Clef picked this model in shadow mode but the role
57
+ * ran Opus. Empty for Opus.
58
+ */
59
+ baseline: ScoreSide
60
+ /** `null` for Opus, which is the baseline the others are judged by. */
61
+ verdict: Verdict | null
62
+ }
63
+
64
+ /** The final review's findings over a set of runs. */
65
+ export type FinalReviewSide = {
66
+ runs: number
67
+ findings_per_100_lines: number | null
68
+ }
69
+
70
+ export type ModelReport = {
71
+ runs: number
72
+ scores: ModelScore[]
73
+ /** Fresh learner agents per model; the learner isn't scored. */
74
+ learner: { model: ModelName; agents: number }[]
75
+ /** Runs with any agent on a cheaper model, and runs without. */
76
+ final_review: { routed: FinalReviewSide; not_routed: FinalReviewSide }
77
+ /** Joined tickets with no `lines_changed`, left out of the rates. */
78
+ without_lines_changed: number
79
+ }
80
+
81
+ /** Clef's first model pick for a role on a ticket. */
82
+ type Pick = {
83
+ model: ModelName | null
84
+ confidence: number | null
85
+ /** Asked in shadow mode: `acted: false`, or an older `ticket_model` ask. */
86
+ shadow: boolean
87
+ }
88
+
89
+ /** What one run's journal says about one ticket. */
90
+ type TicketFacts = {
91
+ /** The model of each role's first agent. */
92
+ models: Partial<Record<ScoredRole, ModelName>>
93
+ picks: Partial<Record<ScoredRole, Pick>>
94
+ reviews: number
95
+ /** The first ticket review's weighted findings. */
96
+ findings: number
97
+ fix_rounds: number
98
+ joined: boolean
99
+ lines: number | null
100
+ stuck: boolean
101
+ tokens: number | null
102
+ }
103
+
104
+ /** What one run's journal says. */
105
+ type RunFacts = {
106
+ tickets: TicketFacts[]
107
+ routed: boolean
108
+ learner: ModelName[]
109
+ /** The first final review round's weighted findings, or `null` with none. */
110
+ final_findings: number | null
111
+ /** The joined tickets' lines, or `null` when one has none. */
112
+ lines: number | null
113
+ }
114
+
115
+ /**
116
+ * The model family a short name or full model id (as older `ticket_model`
117
+ * asks named them) belongs to, or `null` for none.
118
+ *
119
+ * @example
120
+ * familyOf('sonnet') // 'sonnet'
121
+ */
122
+ const familyOf = (name: unknown): ModelName | null => {
123
+ if (typeof name !== 'string') return null
124
+ const lower = name.toLowerCase()
125
+ return (
126
+ ModelNameSchema.options.find((model) => lower.includes(model)) ?? null
127
+ )
128
+ }
129
+
130
+ const isScored = (role: string | null): role is ScoredRole =>
131
+ SCORED_ROLES.some((scored) => scored === role)
132
+
133
+ const sum = (values: number[]): number =>
134
+ values.reduce((total, value) => total + value, 0)
135
+
136
+ const median = (values: number[]): number | null => {
137
+ if (values.length === 0) return null
138
+ const sorted = values.toSorted((a, b) => a - b)
139
+ const middle = Math.floor(sorted.length / 2)
140
+ return sorted.length % 2 === 1
141
+ ? (sorted[middle] ?? null)
142
+ : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
143
+ }
144
+
145
+ const weigh = (counts: Record<FindingSeverity, number>): number =>
146
+ counts.blocker * FINDING_WEIGHTS.blocker +
147
+ counts.should_fix * FINDING_WEIGHTS.should_fix +
148
+ counts.nit * FINDING_WEIGHTS.nit
149
+
150
+ const newTicket = (): TicketFacts => ({
151
+ models: {},
152
+ picks: {},
153
+ reviews: 0,
154
+ findings: 0,
155
+ fix_rounds: 0,
156
+ joined: false,
157
+ lines: null,
158
+ stuck: false,
159
+ tokens: null,
160
+ })
161
+
162
+ /** What one run's records say, ticket by ticket. */
163
+ const factsOf = (records: JournalRecord[]): RunFacts => {
164
+ const tickets = new Map<number, TicketFacts>()
165
+ const ticketOf = (number: number): TicketFacts => {
166
+ const found = tickets.get(number) ?? newTicket()
167
+ tickets.set(number, found)
168
+ return found
169
+ }
170
+ const asks = new Map<number, { role: string | null; shadow: boolean }>()
171
+ const learner: ModelName[] = []
172
+ let routed = false
173
+ let final_rounds = 0
174
+ let final_findings = 0
175
+ for (const record of records) {
176
+ switch (record.kind) {
177
+ case 'jev_asked':
178
+ if (
179
+ record.content.job === 'agent_model' ||
180
+ record.content.job === 'ticket_model'
181
+ ) {
182
+ asks.set(record.seq, {
183
+ role: record.role,
184
+ shadow:
185
+ record.content.job === 'ticket_model' ||
186
+ !record.content.acted,
187
+ })
188
+ }
189
+ break
190
+ case 'jev_answered': {
191
+ const ask = asks.get(record.content.asked_seq)
192
+ if (ask === undefined || record.ticket === null) break
193
+ const answer = record.content.answers.model
194
+ const pick: Pick = {
195
+ model: familyOf(answer?.value),
196
+ confidence: answer?.confidence ?? null,
197
+ shadow: ask.shadow,
198
+ }
199
+ const roles =
200
+ record.content.job === 'ticket_model'
201
+ ? SCORED_ROLES
202
+ : SCORED_ROLES.filter((role) => role === ask.role)
203
+ const facts = ticketOf(record.ticket)
204
+ for (const role of roles) facts.picks[role] ??= pick
205
+ break
206
+ }
207
+ case 'agent_started': {
208
+ const { role, model, follow_up_of } = record.content
209
+ const family = familyOf(model) ?? 'opus'
210
+ if (follow_up_of !== null) break
211
+ if (role === 'learner') learner.push(family)
212
+ if (isScored(role) || role === 'learner') {
213
+ routed ||= family !== 'opus'
214
+ }
215
+ if (isScored(role) && record.ticket !== null) {
216
+ ticketOf(record.ticket).models[role] ??= family
217
+ }
218
+ break
219
+ }
220
+ case 'agent_finished': {
221
+ const { content } = record
222
+ if (
223
+ content.role !== 'ticket-reviewer' ||
224
+ record.ticket === null
225
+ ) {
226
+ break
227
+ }
228
+ const facts = ticketOf(record.ticket)
229
+ if (facts.reviews === 0) {
230
+ facts.findings = sum(
231
+ content.result.findings.map(
232
+ ({ severity }) => FINDING_WEIGHTS[severity]
233
+ )
234
+ )
235
+ }
236
+ facts.reviews += 1
237
+ if (content.result.verdict === 'changes_requested') {
238
+ facts.fix_rounds += 1
239
+ }
240
+ break
241
+ }
242
+ case 'ticket_joined':
243
+ if (record.ticket !== null && record.content.ok) {
244
+ const facts = ticketOf(record.ticket)
245
+ facts.joined = true
246
+ facts.lines = record.content.lines_changed
247
+ }
248
+ break
249
+ case 'ticket_stuck':
250
+ if (record.ticket !== null) ticketOf(record.ticket).stuck = true
251
+ break
252
+ case 'usage_recorded': {
253
+ const { scope, ticket, tokens } = record.content
254
+ if (scope === 'ticket' && ticket !== null) {
255
+ ticketOf(ticket).tokens = countedTokens({ tokens })
256
+ }
257
+ break
258
+ }
259
+ case 'final_review_started':
260
+ final_rounds += 1
261
+ break
262
+ case 'lens_finished':
263
+ if (final_rounds === 1) {
264
+ final_findings += weigh(record.content.findings)
265
+ }
266
+ break
267
+ default:
268
+ break
269
+ }
270
+ }
271
+ const joined = [...tickets.values()].filter(({ joined }) => joined)
272
+ return {
273
+ tickets: [...tickets.values()],
274
+ routed,
275
+ learner,
276
+ final_findings: final_rounds > 0 ? final_findings : null,
277
+ lines: joined.some(({ lines }) => lines === null)
278
+ ? null
279
+ : sum(joined.map(({ lines }) => lines ?? 0)),
280
+ }
281
+ }
282
+
283
+ /** Findings per 100 lines over `tickets`, those with no lines left out. */
284
+ const findingsRate = (tickets: TicketFacts[]): number | null => {
285
+ const rated = tickets.filter(({ lines }) => lines !== null)
286
+ const lines = sum(rated.map(({ lines }) => lines ?? 0))
287
+ return lines === 0
288
+ ? null
289
+ : (sum(rated.map(({ findings }) => findings)) * 100) / lines
290
+ }
291
+
292
+ const sideOf = (tickets: TicketFacts[]): ScoreSide => ({
293
+ tickets: tickets.length,
294
+ findings_per_100_lines: findingsRate(tickets),
295
+ fix_rounds_per_ticket:
296
+ tickets.length === 0
297
+ ? null
298
+ : sum(tickets.map(({ fix_rounds }) => fix_rounds)) / tickets.length,
299
+ })
300
+
301
+ /** Whether `value` is more than `WORSE_BY` above `base`. */
302
+ const above = (value: number | null, base: number | null): boolean =>
303
+ value !== null && base !== null && value > base * (1 + WORSE_BY) + EPSILON
304
+
305
+ /**
306
+ * The verdict on a cheaper model against its baseline: "not enough data"
307
+ * while either side has fewer than `MIN_TICKETS` tickets, "worse" when its
308
+ * findings rate or fix rounds per ticket is more than `WORSE_BY` above the
309
+ * baseline's, else "about the same". Pure.
310
+ */
311
+ export const verdictOf = ({
312
+ side,
313
+ baseline,
314
+ }: {
315
+ side: ScoreSide
316
+ baseline: ScoreSide
317
+ }): Verdict => {
318
+ if (side.tickets < MIN_TICKETS || baseline.tickets < MIN_TICKETS) {
319
+ return 'not enough data'
320
+ }
321
+ const worse =
322
+ above(side.findings_per_100_lines, baseline.findings_per_100_lines) ||
323
+ above(side.fix_rounds_per_ticket, baseline.fix_rounds_per_ticket)
324
+ return worse ? 'worse' : 'about the same'
325
+ }
326
+
327
+ /** One routed role on one model, over every run's tickets. */
328
+ const scoreOf = ({
329
+ tickets,
330
+ role,
331
+ model,
332
+ }: {
333
+ tickets: TicketFacts[]
334
+ role: ScoredRole
335
+ model: ModelName
336
+ }): ModelScore => {
337
+ const other = SCORED_ROLES.find((scored) => scored !== role) ?? role
338
+ const group = tickets.filter((ticket) => ticket.models[role] === model)
339
+ const finished = group.flatMap(({ joined, tokens }) =>
340
+ joined && tokens !== null ? [tokens] : []
341
+ )
342
+ const baseline =
343
+ model === 'opus'
344
+ ? []
345
+ : tickets.filter(
346
+ ({ models, picks }) =>
347
+ models[role] === 'opus' &&
348
+ picks[role]?.shadow === true &&
349
+ picks[role]?.model === model
350
+ )
351
+ const side = sideOf(group)
352
+ const baseline_side = sideOf(baseline)
353
+ return {
354
+ role,
355
+ model,
356
+ ...side,
357
+ stuck_tickets: group.filter(({ stuck }) => stuck).length,
358
+ tokens_per_finished_ticket:
359
+ finished.length === 0 ? null : sum(finished) / finished.length,
360
+ median_confidence: median(
361
+ group.flatMap(({ picks }) => {
362
+ const confidence = picks[role]?.confidence ?? null
363
+ return confidence === null ? [] : [confidence]
364
+ })
365
+ ),
366
+ other_role_cheaper: group.filter(({ models }) => {
367
+ const ran = models[other]
368
+ return ran !== undefined && ran !== 'opus'
369
+ }).length,
370
+ baseline: baseline_side,
371
+ verdict:
372
+ model === 'opus'
373
+ ? null
374
+ : verdictOf({ side, baseline: baseline_side }),
375
+ }
376
+ }
377
+
378
+ /** The final review's findings per 100 changed lines over `runs`. */
379
+ const finalReviewOf = (runs: RunFacts[]): FinalReviewSide => {
380
+ const reviewed = runs.filter(
381
+ ({ final_findings }) => final_findings !== null
382
+ )
383
+ const rated = reviewed.filter(({ lines }) => lines !== null)
384
+ const lines = sum(rated.map(({ lines }) => lines ?? 0))
385
+ return {
386
+ runs: reviewed.length,
387
+ findings_per_100_lines:
388
+ lines === 0
389
+ ? null
390
+ : (sum(rated.map(({ final_findings }) => final_findings ?? 0)) *
391
+ 100) /
392
+ lines,
393
+ }
394
+ }
395
+
396
+ /**
397
+ * The model score report from run journals, each run's records oldest
398
+ * first. A ticket counts under the model of each role's first agent on it
399
+ * (an agent from before #600, with no model, ran Opus). Pure.
400
+ *
401
+ * @example
402
+ * const report = scoreModels({ runs: [createJournal({ file }).read()] })
403
+ * console.log(formatModelReport({ report }))
404
+ */
405
+ export const scoreModels = ({
406
+ runs,
407
+ }: {
408
+ runs: JournalRecord[][]
409
+ }): ModelReport => {
410
+ const facts = runs.map(factsOf)
411
+ const tickets = facts
412
+ .flatMap((run) => run.tickets)
413
+ .filter(({ models }) => Object.keys(models).length > 0)
414
+ const scores = SCORED_ROLES.flatMap((role) =>
415
+ ModelNameSchema.options.map((model) =>
416
+ scoreOf({ tickets, role, model })
417
+ )
418
+ ).filter(({ tickets, baseline }) => tickets > 0 || baseline.tickets > 0)
419
+ const learner = facts.flatMap((run) => run.learner)
420
+ return {
421
+ runs: runs.length,
422
+ scores,
423
+ learner: ModelNameSchema.options
424
+ .map((model) => ({
425
+ model,
426
+ agents: learner.filter((ran) => ran === model).length,
427
+ }))
428
+ .filter(({ agents }) => agents > 0),
429
+ final_review: {
430
+ routed: finalReviewOf(facts.filter(({ routed }) => routed)),
431
+ not_routed: finalReviewOf(facts.filter(({ routed }) => !routed)),
432
+ },
433
+ without_lines_changed: tickets.filter(
434
+ ({ joined, lines }) => joined && lines === null
435
+ ).length,
436
+ }
437
+ }
438
+
439
+ const plural = (count: number, word: string): string =>
440
+ `${count} ${word}${count === 1 ? '' : 's'}`
441
+
442
+ const decimal = (value: number | null): string =>
443
+ value === null ? '-' : value.toFixed(2)
444
+
445
+ const sideText = (side: ScoreSide): string =>
446
+ [
447
+ plural(side.tickets, 'ticket'),
448
+ `${decimal(side.findings_per_100_lines)} findings per 100 lines`,
449
+ `${decimal(side.fix_rounds_per_ticket)} fix rounds per ticket`,
450
+ ].join(', ')
451
+
452
+ const scoreText = (score: ModelScore): string[] => {
453
+ const other = SCORED_ROLES.find((role) => role !== score.role)
454
+ const tokens = score.tokens_per_finished_ticket
455
+ return [
456
+ ` ${score.model}: ${[
457
+ sideText(score),
458
+ `${score.stuck_tickets} stuck`,
459
+ `${tokens === null ? '-' : Math.round(tokens)} tokens per finished ticket`,
460
+ `median confidence ${decimal(score.median_confidence)}`,
461
+ `${other} cheaper on ${score.other_role_cheaper}`,
462
+ ].join(', ')}`,
463
+ ...(score.verdict === null
464
+ ? []
465
+ : [
466
+ ` baseline (Clef picked ${score.model} in shadow mode, ran opus): ${sideText(score.baseline)}`,
467
+ ` verdict: ${score.verdict}`,
468
+ ]),
469
+ ]
470
+ }
471
+
472
+ const finalText = (label: string, side: FinalReviewSide): string =>
473
+ `${decimal(side.findings_per_100_lines)} ${label} (${plural(side.runs, 'run')})`
474
+
475
+ /**
476
+ * The report as `luca models` prints it: each routed role's models with
477
+ * their verdicts, the learner, the final review's line, and the tickets
478
+ * left out of the rates. Pure.
479
+ *
480
+ * @example
481
+ * console.log(formatModelReport({ report: scoreModels({ runs }) }))
482
+ */
483
+ export const formatModelReport = ({
484
+ report,
485
+ }: {
486
+ report: ModelReport
487
+ }): string => {
488
+ const weights = `blocker ${FINDING_WEIGHTS.blocker}, should-fix ${FINDING_WEIGHTS.should_fix}, nit ${FINDING_WEIGHTS.nit}`
489
+ const roles = SCORED_ROLES.flatMap((role) => {
490
+ const scores = report.scores.filter((score) => score.role === role)
491
+ return [
492
+ role,
493
+ ...(scores.length === 0
494
+ ? [' no tickets yet']
495
+ : scores.flatMap(scoreText)),
496
+ ]
497
+ })
498
+ const learner =
499
+ report.learner.length === 0
500
+ ? ''
501
+ : ` (${report.learner.map(({ model, agents }) => `${plural(agents, 'agent')} on ${model}`).join(', ')})`
502
+ const { routed, not_routed } = report.final_review
503
+ return [
504
+ `Model scores from ${plural(report.runs, 'run')}: the first ticket review's findings weighted ${weights}, per 100 changed lines.`,
505
+ '',
506
+ ...roles,
507
+ `learner: not measured by findings${learner}`,
508
+ '',
509
+ `Final review findings per 100 changed lines: ${finalText('with routed agents', routed)}, ${finalText('without', not_routed)}.`,
510
+ `${plural(report.without_lines_changed, 'joined ticket')} with no lines_changed, left out of the rates.`,
511
+ ].join('\n')
512
+ }