@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
@@ -1,100 +1,77 @@
1
- import type { BoardRecord } from './board-vocabulary'
2
-
3
- import {
4
- ALL_STEPS_DONE,
5
- DEFAULT_RUN_BUDGET_TOKENS,
6
- LENS_NAMES,
7
- LOOP_CAP,
8
- NO_MEMORY,
9
- USAGE_LABEL,
10
- usageLevel,
11
- windowRank,
12
- windowWords,
13
- type BoardState,
14
- type EngineEnded,
15
- type FinalReview,
16
- type FindingCounts,
17
- type LensCard,
18
- type NeedsYou,
19
- type PlanUsed,
20
- type RunInfo,
21
- type RunStatus,
22
- type TicketCard,
23
- type Usage,
24
- } from '../shared/board-state'
25
-
26
1
  /**
27
2
  * The board's reducer: pure functions that turn journal records into board
28
3
  * state. The same records in the same order always give the same state, so
29
4
  * a replayed journal rebuilds the board exactly.
30
- */
31
-
32
- /** How many "tried" lines a ticket or the final review keeps. */
33
- const TRIED_KEPT = 6
34
-
35
- /** Longest single "tried" line or stuck detail kept in state. */
36
- const LINE_MAX = 240
37
- const DETAIL_MAX = 2000
38
-
39
- const STUCK_REASONS: Record<string, string> = {
40
- agent_failed: 'An agent failed on its last try.',
41
- red_check_failed: 'The red check failed.',
42
- nothing_new_to_test:
43
- 'The test-writer says the ticket changes no behavior, so there is nothing new to test. Label it refactor and retry it.',
44
- leftovers_found:
45
- 'The leftover scan found files that must not be committed.',
46
- gates_failed: 'The checks failed.',
47
- bad_test: 'The implementer sent back a bad test.',
48
- changes_requested: 'The ticket review asked for changes.',
49
- join_failed: "The ticket couldn't join the run branch.",
50
- join_gates_failed: 'The checks failed after the ticket joined.',
51
- install_failed: 'Installing the dependencies failed.',
52
- prepare_failed: "The repo's prepare command failed.",
53
- setup_change_needed:
54
- 'An agent needs a test setup file changed; only you may change one.',
55
- crashed: 'The engine crashed in the same step again and again.',
56
- }
57
-
58
- /**
59
- * A commit's short sha, as git and GitHub show it.
60
- *
61
- * @example
62
- * shortSha({ sha: '3559c25f5a1b' }) // '3559c25'
63
- */
64
- export const shortSha = ({ sha }: { sha: string }): string => sha.slice(0, 7)
65
-
66
- /**
67
- * An already-done ticket's activity (#484): calm, with the commits that
68
- * did its work.
69
5
  *
70
- * @example
71
- * alreadyDoneText({ shas: ['3559c25f5a1b'] }) // 'already done · 3559c25'
72
- */
73
- export const alreadyDoneText = ({ shas }: { shas: string[] }): string =>
74
- ['already done', ...shas.map((sha) => shortSha({ sha }))].join(' · ')
75
-
76
- /** Why a retry of an edited ticket was refused, in words. */
77
- export const RETRY_REFUSED_TEXT =
78
- "Its new text or labels aren't ready to build."
79
-
80
- /** Cuts `text` to `max` characters, ending in "…" when it was longer. */
81
- export const clip = ({ text, max }: { text: string; max: number }): string =>
82
- text.length > max ? `${text.slice(0, max - 1)}…` : text
83
-
84
- const firstLine = ({ text }: { text: string }): string =>
85
- clip({ text: text.split('\n')[0] ?? '', max: LINE_MAX })
86
-
87
- /** How an agent turn failed, in words: `agent_failed`'s `failure`. */
88
- const FAILURES: Record<string, string> = {
89
- agent: 'failed',
90
- result: 'gave no usable result',
91
- guard: "broke its role's rules (the changes were undone)",
92
- engine: 'could not run (an engine-side failure; a fresh agent starts)',
93
- }
94
-
95
- /** An `agent_failed` failure kind in words. */
96
- export const failureText = ({ failure }: { failure: string }): string =>
97
- FAILURES[failure] ?? `failed (${failure})`
6
+ * This module applies a record and passes it on by kind; the handlers are in
7
+ * modules by responsibility, each with the record kinds it handles:
8
+ * - `reduce-board-run.ts`: the run's own records (start, intake, snapshot,
9
+ * run branch, pull request, stop).
10
+ * - `reduce-board-ticket-start.ts`, `reduce-board-ticket-checks.ts` and
11
+ * `reduce-board-ticket-join.ts`: a ticket from its start, through its
12
+ * checks, to its way onto the run branch.
13
+ * - `reduce-board-agent-start.ts` and `reduce-board-agents.ts`: an agent's
14
+ * turn.
15
+ * - `reduce-board-steps.ts`: the steps in flight.
16
+ * - `reduce-board-needs-you.ts`: stuck items, skips, retries and replies.
17
+ * - `reduce-board-final-review.ts`: the final review and its lenses.
18
+ * - `reduce-board-usage.ts`: tokens, plan usage and limit waits.
19
+ * - `reduce-board-counters.ts`: the run-wide counters.
20
+ * - `reduce-board-settle.ts`: what depends on the whole board.
21
+ * The pieces they share are `reduce-board-text.ts` (words),
22
+ * `reduce-board-reasons.ts` (stuck reasons), `reduce-board-update.ts` (small
23
+ * state updates) and `reduce-board-create.ts` (starting shapes).
24
+ */
25
+ import type { BoardKind, BoardRecord } from './board-vocabulary'
26
+ import { AGENT_KINDS, applyAgentKind } from './reduce-board-agents'
27
+ import { COUNTER_KINDS, applyCounterKind } from './reduce-board-counters'
28
+ import {
29
+ FINAL_REVIEW_KINDS,
30
+ applyFinalReviewKind,
31
+ } from './reduce-board-final-review'
32
+ import { NEEDS_YOU_KINDS, applyNeedsYouKind } from './reduce-board-needs-you'
33
+ import { RUN_KINDS, applyRunKind } from './reduce-board-run'
34
+ import { settle } from './reduce-board-settle'
35
+ import { STEP_KINDS, applyStepKind, withoutSteps } from './reduce-board-steps'
36
+ import {
37
+ TICKET_CHECKS_KINDS,
38
+ applyTicketChecksKind,
39
+ } from './reduce-board-ticket-checks'
40
+ import {
41
+ TICKET_JOIN_KINDS,
42
+ applyTicketJoinKind,
43
+ } from './reduce-board-ticket-join'
44
+ import {
45
+ TICKET_START_KINDS,
46
+ applyTicketStartKind,
47
+ } from './reduce-board-ticket-start'
48
+ import { USAGE_KINDS, applyUsageKind } from './reduce-board-usage'
49
+
50
+ import type { BoardState, EngineEnded } from '../shared/board-state'
51
+
52
+ export { startKind } from './reduce-board-agent-start'
53
+ export { createBoardState } from './reduce-board-create'
54
+ export {
55
+ RETRY_REFUSED_TEXT,
56
+ finalReasonText,
57
+ reasonText,
58
+ runReasonText,
59
+ } from './reduce-board-reasons'
60
+ export { unfinishedBlockers } from './reduce-board-settle'
61
+ export { stepText } from './reduce-board-steps'
62
+ export {
63
+ alreadyDoneText,
64
+ clip,
65
+ countFindings,
66
+ failureText,
67
+ numbersText,
68
+ rebasedText,
69
+ reviewCountsText,
70
+ roleWords,
71
+ shortSha,
72
+ testsSentBackText,
73
+ } from './reduce-board-text'
74
+ export { LEARNER } from './reduce-board-update'
98
75
 
99
76
  /** Record kinds that don't mean the engine moved on after a stop. */
100
77
  const QUIET_KINDS = new Set([
@@ -104,6 +81,7 @@ const QUIET_KINDS = new Set([
104
81
  'jev_asked',
105
82
  'jev_answered',
106
83
  'jev_failed',
84
+ 'decision_model_off',
107
85
  'agent_message',
108
86
  'agent_message_delivered',
109
87
  'shared_git_changed',
@@ -111,639 +89,6 @@ const QUIET_KINDS = new Set([
111
89
  'step_ended',
112
90
  ])
113
91
 
114
- /** The learner's role (#370): its records have no ticket. */
115
- export const LEARNER = 'learner'
116
-
117
- const withLearner = ({
118
- state,
119
- learner,
120
- }: {
121
- state: BoardState
122
- learner: BoardState['memory']['learner']
123
- }): BoardState => ({ ...state, memory: { ...state.memory, learner } })
124
-
125
- /** A stuck reason code in words. */
126
- export const reasonText = ({ reason }: { reason: string }): string =>
127
- STUCK_REASONS[reason] ?? `${reason.replaceAll('_', ' ')}.`
128
-
129
- /** The final review's stuck reasons that read differently from a ticket's. */
130
- const FINAL_STUCK_REASONS: Record<string, string> = {
131
- changes_requested: `The lenses still ask for changes after ${LOOP_CAP} fix rounds.`,
132
- gates_failed: "The checks still fail on the final review's fixes.",
133
- bad_test:
134
- "The implementer sent back a bad test while fixing the final review's findings.",
135
- }
136
-
137
- /** A final review's stuck reason code in words. */
138
- export const finalReasonText = ({ reason }: { reason: string }): string =>
139
- FINAL_STUCK_REASONS[reason] ?? reasonText({ reason })
140
-
141
- /**
142
- * The run's version note after a resume on Luca `resumed_on`: both versions
143
- * when it differs from the one the run started on, else the note so far (a
144
- * run that once changed version keeps saying so). Nothing when the journal
145
- * doesn't say what the run started on.
146
- */
147
- const versionNote = ({
148
- run,
149
- resumed_on,
150
- }: {
151
- run: RunInfo
152
- resumed_on: string
153
- }): string | null =>
154
- run.started_on === null || run.started_on === resumed_on
155
- ? run.version_note
156
- : `This run started on Luca ${run.started_on} and resumed on Luca ${resumed_on}. It keeps going.`
157
-
158
- const noFindings = (): FindingCounts => ({ blocker: 0, should_fix: 0, nit: 0 })
159
-
160
- const freshLenses = (): LensCard[] =>
161
- LENS_NAMES.map((name) => ({
162
- name,
163
- state: 'waiting',
164
- findings: noFindings(),
165
- }))
166
-
167
- /**
168
- * A new run's board, before any record.
169
- *
170
- * @example
171
- * const state = createBoardState({
172
- * run_id: 'luca-20260923-123042-ab12',
173
- * spec_number: 10,
174
- * demo: false,
175
- * started_at: '2026-09-23T12:30:42.000Z',
176
- * log_path: '/tmp/luca-20260923-123042-ab12.log',
177
- * })
178
- */
179
- export const createBoardState = ({
180
- run_id,
181
- spec_number,
182
- demo,
183
- started_at,
184
- log_path,
185
- }: {
186
- run_id: string
187
- spec_number: number | null
188
- demo: boolean
189
- started_at: string
190
- log_path: string | null
191
- }): BoardState => ({
192
- run: {
193
- run_id,
194
- spec_number,
195
- spec_title: null,
196
- demo,
197
- branch: null,
198
- phase: 'starting',
199
- status: 'starting',
200
- pr_url: null,
201
- pr_number: null,
202
- refusal: [],
203
- started_at,
204
- last_time: null,
205
- engine_ended: null,
206
- stopped: null,
207
- log_path,
208
- started_on: null,
209
- version_note: null,
210
- },
211
- usage: null,
212
- usage_label: USAGE_LABEL,
213
- run_tokens: 0,
214
- run_budget_tokens: DEFAULT_RUN_BUDGET_TOKENS,
215
- run_budget_each: DEFAULT_RUN_BUDGET_TOKENS,
216
- limit_wait: null,
217
- usage_line_wait: null,
218
- run_plan_used: [],
219
- needs_you: [],
220
- tickets: [],
221
- final_review: {
222
- state: 'waiting',
223
- active: false,
224
- round: 0,
225
- fix_round: 0,
226
- lenses: freshLenses(),
227
- tried: [],
228
- },
229
- jev: { asked: 0, answered: 0, failed: 0 },
230
- messages: { sent: 0, refused: 0, delivered: 0 },
231
- memory: NO_MEMORY,
232
- shared_git_changed: 0,
233
- run_steps: [],
234
- current_step: null,
235
- event_count: 0,
236
- latest: null,
237
- })
238
-
239
- const newTicket = ({
240
- number,
241
- title,
242
- refactor,
243
- blockers,
244
- }: {
245
- number: number
246
- title: string
247
- refactor: boolean
248
- blockers: number[]
249
- }): TicketCard => ({
250
- number,
251
- title,
252
- refactor,
253
- blockers,
254
- started: false,
255
- stage: 'building',
256
- step: -1,
257
- activity: 'queued',
258
- role: null,
259
- current_step: null,
260
- fix_round: 0,
261
- review_round: 0,
262
- review_fix_round: 0,
263
- open_check: null,
264
- failed_turn: null,
265
- tests: null,
266
- baseline_tests: null,
267
- findings: null,
268
- tokens: 0,
269
- agent_tokens: {},
270
- tried: [],
271
- plan_used: [],
272
- })
273
-
274
- const withTried = ({
275
- tried,
276
- line,
277
- }: {
278
- tried: string[]
279
- line: string
280
- }): string[] => [...tried, firstLine({ text: line })].slice(-TRIED_KEPT)
281
-
282
- const updateTicket = ({
283
- state,
284
- number,
285
- update,
286
- }: {
287
- state: BoardState
288
- number: number | null
289
- update: (ticket: TicketCard) => TicketCard
290
- }): BoardState => {
291
- if (number === null) return state
292
- return {
293
- ...state,
294
- tickets: state.tickets.map((ticket) =>
295
- ticket.number === number ? update(ticket) : ticket
296
- ),
297
- }
298
- }
299
-
300
- const updateFinal = ({
301
- state,
302
- update,
303
- }: {
304
- state: BoardState
305
- update: (review: FinalReview) => FinalReview
306
- }): BoardState => ({ ...state, final_review: update(state.final_review) })
307
-
308
- const updateLens = ({
309
- state,
310
- lens,
311
- update,
312
- }: {
313
- state: BoardState
314
- lens: string
315
- update: (card: LensCard) => LensCard
316
- }): BoardState =>
317
- updateFinal({
318
- state,
319
- update: (review) => ({
320
- ...review,
321
- lenses: review.lenses.map((card) =>
322
- card.name === lens ? update(card) : card
323
- ),
324
- }),
325
- })
326
-
327
- const addNeedsYou = ({
328
- state,
329
- item,
330
- }: {
331
- state: BoardState
332
- item: NeedsYou
333
- }): BoardState => ({
334
- ...state,
335
- needs_you: [
336
- ...state.needs_you.filter((entry) => entry.key !== item.key),
337
- item,
338
- ],
339
- })
340
-
341
- const resolveNeedsYou = ({
342
- state,
343
- key,
344
- }: {
345
- state: BoardState
346
- key: string | null
347
- }): BoardState => ({
348
- ...state,
349
- needs_you:
350
- key === null
351
- ? []
352
- : state.needs_you.filter((entry) => entry.key !== key),
353
- })
354
-
355
- /** The key of a ticket's "Needs you" item. */
356
- export const ticketKey = ({ ticket }: { ticket: number }): string =>
357
- `ticket-${ticket}`
358
-
359
- /** The key of the final review's "Needs you" item. */
360
- export const FINAL_KEY = 'final'
361
-
362
- /** The key of the "Needs you" item for the whole run, stuck on its budget. */
363
- export const RUN_KEY = 'run'
364
-
365
- /** Why the whole run is stuck (`run_stuck`'s reason), in words. */
366
- const RUN_STUCK_REASONS: Record<string, string> = {
367
- run_budget:
368
- 'The run used up its run budget of tokens. `retry` adds one more full budget.',
369
- }
370
-
371
- /** A run stuck reason code in words. */
372
- export const runReasonText = ({ reason }: { reason: string }): string =>
373
- RUN_STUCK_REASONS[reason] ?? `${reason.replaceAll('_', ' ')}.`
374
-
375
- /**
376
- * A role in words: a final review lens's role (`security-lens`) reads
377
- * "security lens"; any other role as it is.
378
- *
379
- * @example
380
- * roleWords({ role: 'security-lens' }) // 'security lens'
381
- */
382
- export const roleWords = ({ role }: { role: string }): string =>
383
- role.endsWith('-lens') ? `${role.slice(0, -'-lens'.length)} lens` : role
384
-
385
- /** A step's action type in words, when it reads better than its name. */
386
- const STEP_WORDS: Record<string, string> = {
387
- install_dependencies: 'installing packages',
388
- run_baseline_tests: 'running the baseline tests',
389
- run_red_check: 'running the red check',
390
- run_gates: 'running the checks',
391
- run_final_gates: 'running the checks',
392
- join_run_branch: 'joining the run branch',
393
- push_run_branch: 'pushing the run branch',
394
- push_final_fixes: 'pushing the run branch',
395
- write_changeset: 'writing the changeset',
396
- open_pull_request: 'opening the pull request',
397
- mark_already_done: 'noting the work is already done',
398
- close_ticket: 'closing the ticket',
399
- }
400
-
401
- /**
402
- * A `step_started`'s step in words: an agent's turn (`<type>:<role>`)
403
- * waits for its role; any other step as `STEP_WORDS` has it, else its
404
- * action type.
405
- *
406
- * @example
407
- * stepText({ step: 'run_gates' }) // 'running the checks'
408
- * stepText({ step: 'follow_up_agent:implementer' }) // 'waiting for the implementer'
409
- */
410
- export const stepText = ({ step }: { step: string }): string => {
411
- const colon = step.indexOf(':')
412
- if (colon >= 0) {
413
- return `waiting for the ${roleWords({ role: step.slice(colon + 1) })}`
414
- }
415
- return STEP_WORDS[step] ?? step.replaceAll('_', ' ')
416
- }
417
-
418
- /** The run's running steps, and the newest of them as its current step. */
419
- const withRunSteps = ({
420
- state,
421
- run_steps,
422
- }: {
423
- state: BoardState
424
- run_steps: BoardState['run_steps']
425
- }): BoardState => {
426
- const newest = run_steps.at(-1)
427
- return {
428
- ...state,
429
- run_steps,
430
- current_step: newest
431
- ? { text: newest.text, since: newest.since }
432
- : null,
433
- }
434
- }
435
-
436
- /** No step running anywhere: the engine ended, or the run stopped. */
437
- const withoutSteps = ({ state }: { state: BoardState }): BoardState =>
438
- withRunSteps({
439
- state: {
440
- ...state,
441
- tickets: state.tickets.map((card) =>
442
- card.current_step === null
443
- ? card
444
- : { ...card, current_step: null }
445
- ),
446
- },
447
- run_steps: [],
448
- })
449
-
450
- /**
451
- * A step began: it is its ticket's current step, or one of the run's.
452
- * A new step on the same key (a crash's redo) replaces the one before it.
453
- */
454
- const stepStarted = ({
455
- state,
456
- record,
457
- }: {
458
- state: BoardState
459
- record: Extract<BoardRecord, { kind: 'step_started' }>
460
- }): BoardState => {
461
- // A replayed journal of an engine that ended has no step running.
462
- if (state.run.engine_ended !== null) return state
463
- const current_step = {
464
- text: stepText({ step: record.content.step }),
465
- since: record.time,
466
- }
467
- if (record.ticket !== null) {
468
- return updateTicket({
469
- state,
470
- number: record.ticket,
471
- update: (card) => ({ ...card, current_step }),
472
- })
473
- }
474
- const { key } = record.content
475
- return withRunSteps({
476
- state,
477
- run_steps: [
478
- ...state.run_steps.filter((step) => step.key !== key),
479
- { key, ...current_step },
480
- ],
481
- })
482
- }
483
-
484
- const stepEnded = ({
485
- state,
486
- record,
487
- }: {
488
- state: BoardState
489
- record: Extract<BoardRecord, { kind: 'step_ended' }>
490
- }): BoardState => {
491
- if (record.ticket !== null) {
492
- return updateTicket({
493
- state,
494
- number: record.ticket,
495
- update: (card) => ({ ...card, current_step: null }),
496
- })
497
- }
498
- return withRunSteps({
499
- state,
500
- run_steps: state.run_steps.filter(
501
- (step) => step.key !== record.content.key
502
- ),
503
- })
504
- }
505
-
506
- /** The final review with one more "tried" line. */
507
- const finalTried = ({
508
- state,
509
- line,
510
- }: {
511
- state: BoardState
512
- line: string
513
- }): BoardState =>
514
- updateFinal({
515
- state,
516
- update: (review) => ({
517
- ...review,
518
- tried: withTried({ tried: review.tried, line }),
519
- }),
520
- })
521
-
522
- const testCounts = ({
523
- cases,
524
- }: {
525
- cases: { status: string }[]
526
- }): { failing: number; total: number } => ({
527
- failing: cases.filter((entry) => entry.status === 'failed').length,
528
- total: cases.length,
529
- })
530
-
531
- /** A ticket review's findings counted by severity. */
532
- export const countFindings = ({
533
- findings,
534
- }: {
535
- findings: { severity: 'blocker' | 'should_fix' | 'nit' }[]
536
- }): FindingCounts => ({
537
- blocker: findings.filter((finding) => finding.severity === 'blocker')
538
- .length,
539
- should_fix: findings.filter((finding) => finding.severity === 'should_fix')
540
- .length,
541
- nit: findings.filter((finding) => finding.severity === 'nit').length,
542
- })
543
-
544
- type SessionContent = Extract<BoardRecord, { kind: 'agent_session' }>['content']
545
-
546
- /** Every token a session read or wrote, cache reads included. */
547
- const sessionTokens = ({ session }: SessionContent): number =>
548
- session.usage.input_tokens +
549
- session.usage.output_tokens +
550
- session.usage.cache_read_input_tokens +
551
- session.usage.cache_creation_input_tokens
552
-
553
- /**
554
- * A session's exact tokens: input, output, and cache-creation tokens over
555
- * its tokens per model, subagents included. An older journal's session,
556
- * with none, counts its main loop's `usage`. Cache reads are left out.
557
- */
558
- const countedTokens = ({ session }: SessionContent): number => {
559
- const models = Object.values(session.model_usage)
560
- return (models.length === 0 ? [session.usage] : models).reduce(
561
- (total, each) =>
562
- total +
563
- each.input_tokens +
564
- each.output_tokens +
565
- each.cache_creation_input_tokens,
566
- 0
567
- )
568
- }
569
-
570
- /** A 0-to-1 utilization as a whole percent from 0 to 100. */
571
- const percentOf = ({ utilization }: { utilization: number }): number =>
572
- Math.min(100, Math.max(0, Math.round(utilization * 100)))
573
-
574
- /** Epoch seconds as an ISO time. */
575
- const isoOf = ({ seconds }: { seconds: number }): string =>
576
- new Date(seconds * 1000).toISOString()
577
-
578
- type Reading = SessionContent['session']['rate_limit_events'][number]
579
-
580
- /**
581
- * One reading's windows as `{ type, utilization, resetsAt }`: its top-level
582
- * `rateLimitType` form, then each of its `unifiedWindows`.
583
- */
584
- const windowsOf = ({
585
- reading,
586
- }: {
587
- reading: Reading
588
- }): { type: string; utilization: number; resetsAt: number | undefined }[] => {
589
- const { rateLimitType, utilization, resetsAt, unifiedWindows } = reading
590
- const top =
591
- rateLimitType !== undefined && utilization !== undefined
592
- ? [{ type: rateLimitType, utilization, resetsAt }]
593
- : []
594
- const unified = Object.entries(unifiedWindows ?? {}).flatMap(
595
- ([type, window]) =>
596
- window.utilization === undefined
597
- ? []
598
- : [
599
- {
600
- type,
601
- utilization: window.utilization,
602
- resetsAt: window.resetsAt,
603
- },
604
- ]
605
- )
606
- return [...top, ...unified]
607
- }
608
-
609
- /**
610
- * The plan usage after a session's rate-limit readings. The latest
611
- * `five_hour` reading sets the five-hour window (and when it resets); the
612
- * latest reading of any `seven_day*` type sets the weekly one, and inside
613
- * one reading the highest `seven_day*` window wins, since the tightest
614
- * weekly cap is the one that binds. A window with no reading keeps what it
615
- * had. `null` while no window has a reading.
616
- */
617
- const usageAfter = ({
618
- usage,
619
- session,
620
- time,
621
- }: {
622
- usage: Usage | null
623
- session: SessionContent['session']
624
- time: string
625
- }): Usage | null => {
626
- let five_hour = usage?.five_hour_percent ?? null
627
- let weekly = usage?.weekly_percent ?? null
628
- let resets_at = usage?.resets_at ?? null
629
- let read = false
630
- for (const reading of session.rate_limit_events) {
631
- const windows = windowsOf({ reading })
632
- const fiveHour = windows.filter(({ type }) => type === 'five_hour')
633
- const latest = fiveHour.at(-1)
634
- if (latest) {
635
- five_hour = percentOf({ utilization: latest.utilization })
636
- resets_at =
637
- latest.resetsAt === undefined
638
- ? resets_at
639
- : isoOf({ seconds: latest.resetsAt })
640
- read = true
641
- }
642
- const weeklies = windows
643
- .filter(({ type }) => type.startsWith('seven_day'))
644
- .map(({ utilization }) => percentOf({ utilization }))
645
- if (weeklies.length > 0) {
646
- weekly = Math.max(...weeklies)
647
- read = true
648
- }
649
- }
650
- if (!read) return usage
651
- return {
652
- five_hour_percent: five_hour,
653
- weekly_percent: weekly,
654
- five_hour_level:
655
- five_hour === null ? null : usageLevel({ percent: five_hour }),
656
- weekly_level: weekly === null ? null : usageLevel({ percent: weekly }),
657
- resets_at,
658
- read_at: time,
659
- }
660
- }
661
-
662
- /** The roles that fix a ticket review's findings. */
663
- const REVIEW_FIXERS = new Set(['test-writer', 'implementer'])
664
-
665
- const plural = ({ count, word }: { count: number; word: string }) =>
666
- `${count} ${word}${count === 1 ? '' : 's'}`
667
-
668
- /**
669
- * A ticket review's finding counts in words, such as "1 blocker, 2
670
- * should-fix, 2 nits". Nits are left out unless `nits` is set; `null` when
671
- * nothing is left to say.
672
- *
673
- * @example
674
- * reviewCountsText({ findings: { blocker: 2, should_fix: 1, nit: 1 }, nits: false })
675
- * // '2 blockers, 1 should-fix'
676
- */
677
- export const reviewCountsText = ({
678
- findings,
679
- nits,
680
- }: {
681
- findings: FindingCounts
682
- nits: boolean
683
- }): string | null => {
684
- const parts = [
685
- findings.blocker > 0
686
- ? plural({ count: findings.blocker, word: 'blocker' })
687
- : null,
688
- findings.should_fix > 0 ? `${findings.should_fix} should-fix` : null,
689
- nits && findings.nit > 0
690
- ? plural({ count: findings.nit, word: 'nit' })
691
- : null,
692
- ].filter((part) => part !== null)
693
- return parts.length > 0 ? parts.join(', ') : null
694
- }
695
-
696
- /**
697
- * A usage record's windows as whole percents in words: five-hour first,
698
- * then the weekly ones, then the rest, each group in the record's order.
699
- */
700
- const planUsedOf = ({
701
- windows,
702
- }: {
703
- windows: Record<string, { used: number }>
704
- }): PlanUsed[] =>
705
- Object.entries(windows)
706
- .map(([window, { used }], index) => ({ window, used, index }))
707
- .toSorted(
708
- (left, right) =>
709
- windowRank({ window: left.window }) -
710
- windowRank({ window: right.window }) ||
711
- left.index - right.index
712
- )
713
- .map(({ window, used }) => ({
714
- window: windowWords({ window }),
715
- percent: Math.round(used),
716
- }))
717
-
718
- /**
719
- * What an `agent_started` is, from the ticket's card before it:
720
- * - `retry`: a new try after the role's (or any) turn failed;
721
- * - `review_fix`: a test-writer (always fresh) or the implementer (a
722
- * follow-up, or fresh if its session was lost) sent a ticket review's
723
- * findings, in a review fix round;
724
- * - `fix`: a follow-up answering a failed red check or gates, a new round;
725
- * - `resent`: the same fix sent again (its turn never ended, such as after
726
- * a crash);
727
- * - `fresh`: anything else.
728
- */
729
- export const startKind = ({
730
- card,
731
- role,
732
- follow_up_of,
733
- }: {
734
- card: TicketCard | undefined
735
- role: string
736
- follow_up_of: string | null
737
- }): 'retry' | 'review_fix' | 'fix' | 'resent' | 'fresh' => {
738
- if (card === undefined) return 'fresh'
739
- if (card.failed_turn !== null) return 'retry'
740
- if (card.open_check === 'review' && REVIEW_FIXERS.has(role)) {
741
- return card.role === null ? 'review_fix' : 'resent'
742
- }
743
- if (follow_up_of === null || card.open_check === null) return 'fresh'
744
- return card.role === null ? 'fix' : 'resent'
745
- }
746
-
747
92
  /**
748
93
  * Applies one journal record to the board. Pure: returns a new state.
749
94
  *
@@ -783,1211 +128,49 @@ export const applyEnded = ({
783
128
  }),
784
129
  })
785
130
 
786
- const applyKind = ({
787
- state,
788
- record,
789
- }: {
790
- state: BoardState
131
+ /**
132
+ * Whether `record` is one of `kinds`, narrowing it to those kinds. A type
133
+ * guard, so it takes its arguments by position.
134
+ */
135
+ const isOneOf = <Kinds extends readonly BoardKind[]>(
136
+ kinds: Kinds,
791
137
  record: BoardRecord
792
- }): BoardState => {
793
- const { ticket } = record
794
- switch (record.kind) {
795
- case 'run_started': {
796
- const budget =
797
- record.content.config?.run_budget_tokens ??
798
- DEFAULT_RUN_BUDGET_TOKENS
799
- return {
800
- ...state,
801
- run_budget_tokens: budget,
802
- run_budget_each: budget,
803
- run: {
804
- ...state.run,
805
- spec_number: record.content.spec_number,
806
- phase: 'intake',
807
- started_on: record.content.luca_version,
808
- },
809
- }
810
- }
811
- case 'engine_resumed':
812
- return {
813
- ...state,
814
- run: {
815
- ...state.run,
816
- version_note: versionNote({
817
- run: state.run,
818
- resumed_on: record.content.luca_version,
819
- }),
820
- },
821
- }
822
- case 'intake_read':
823
- return {
824
- ...state,
825
- run: {
826
- ...state.run,
827
- spec_title:
828
- state.run.spec_title ?? record.content.spec.title,
829
- phase: 'intake',
830
- },
831
- }
832
- case 'intake_refused':
833
- return {
834
- ...state,
835
- run: {
836
- ...state.run,
837
- phase: 'refused',
838
- refusal: record.content.problems.map(
839
- (problem) =>
840
- `${problem.ticket === null ? 'The run' : `#${problem.ticket}`}: missing ${problem.missing.join(', ')}`
841
- ),
842
- },
843
- }
844
- case 'nothing_to_do':
845
- return { ...state, run: { ...state.run, phase: 'nothing_to_do' } }
846
- case 'spec_snapshot':
847
- return {
848
- ...state,
849
- run: {
850
- ...state.run,
851
- spec_number: record.content.spec.number,
852
- spec_title: record.content.spec.title,
853
- phase: 'building',
854
- },
855
- }
856
- case 'ticket_snapshot': {
857
- const { number, title, labels, blockers } = record.content
858
- const refactor = labels.includes('refactor')
859
- const known = state.tickets.some((card) => card.number === number)
860
- if (known) {
861
- return updateTicket({
862
- state,
863
- number,
864
- update: (card) => ({ ...card, title, refactor, blockers }),
865
- })
866
- }
867
- return {
868
- ...state,
869
- tickets: [
870
- ...state.tickets,
871
- newTicket({ number, title, refactor, blockers }),
872
- ],
873
- }
874
- }
875
- case 'run_branch_created':
876
- return {
877
- ...state,
878
- run: { ...state.run, branch: record.content.branch },
879
- }
880
- case 'ticket_worktree_created':
881
- return updateTicket({
882
- state,
883
- number: ticket,
884
- update: (card) => ({
885
- ...card,
886
- started: true,
887
- stage: 'building',
888
- activity: 'starting',
889
- }),
890
- })
891
- case 'dependencies_installed': {
892
- const { check } = record.content
893
- if (check === null || check.ok || ticket === null) return state
894
- return updateTicket({
895
- state,
896
- number: ticket,
897
- update: (card) => ({
898
- ...card,
899
- activity: 'install failed',
900
- tried: withTried({
901
- tried: card.tried,
902
- line: `The install failed: \`${check.command}\``,
903
- }),
904
- }),
905
- })
906
- }
907
- case 'baseline_tests': {
908
- const tests = testCounts({ cases: record.content.cases })
909
- return updateTicket({
910
- state,
911
- number: ticket,
912
- update: (card) => ({
913
- ...card,
914
- started: true,
915
- activity: 'baseline tests',
916
- tests,
917
- baseline_tests: tests,
918
- }),
919
- })
920
- }
921
- case 'baseline_reused': {
922
- // Another ticket's baseline from the same run-branch commit (#404).
923
- const { from_ticket } = record.content
924
- const tests =
925
- state.tickets.find((card) => card.number === from_ticket)
926
- ?.baseline_tests ?? null
927
- return updateTicket({
928
- state,
929
- number: ticket,
930
- update: (card) => ({
931
- ...card,
932
- started: true,
933
- activity: `baseline tests (reused from #${from_ticket})`,
934
- tests,
935
- baseline_tests: tests,
936
- }),
937
- })
938
- }
939
- case 'agent_started':
940
- // The learner (#370) has no ticket and is no final review agent.
941
- if (record.content.role === LEARNER) {
942
- return withLearner({ state, learner: 'learning' })
943
- }
944
- return agentStarted({ state, record })
945
- case 'agent_finished':
946
- if (record.content.role === LEARNER) {
947
- return withLearner({ state, learner: 'learned' })
948
- }
949
- return agentFinished({ state, record })
950
- case 'agent_failed': {
951
- const { role, error, failure } = record.content
952
- // A failed learner gets a fresh one; its row says so.
953
- if (role === LEARNER) return state
954
- // A final review agent (a lens or a fixer) has no ticket.
955
- if (ticket === null) {
956
- return finalTried({
957
- state,
958
- line: `The ${roleWords({ role })} ${failureText({ failure })}: ${error}`,
959
- })
960
- }
961
- return updateTicket({
962
- state,
963
- number: ticket,
964
- update: (card) => ({
965
- ...card,
966
- role: null,
967
- failed_turn: role,
968
- activity: `${role} ${failure === 'engine' ? 'could not run' : 'failed'}`,
969
- tried: withTried({
970
- tried: card.tried,
971
- line: `The ${role} ${failureText({ failure })}: ${error}`,
972
- }),
973
- }),
974
- })
975
- }
976
- case 'agent_session': {
977
- const tokens = sessionTokens(record.content)
978
- const { role } = record.content
979
- const withTokens = updateTicket({
980
- state,
981
- number: ticket,
982
- update: (card) => ({
983
- ...card,
984
- tokens: card.tokens + tokens,
985
- agent_tokens: {
986
- ...card.agent_tokens,
987
- [role]: (card.agent_tokens[role] ?? 0) + tokens,
988
- },
989
- }),
990
- })
991
- return {
992
- ...withTokens,
993
- run_tokens: state.run_tokens + countedTokens(record.content),
994
- usage: usageAfter({
995
- usage: state.usage,
996
- session: record.content.session,
997
- time: record.time,
998
- }),
999
- }
1000
- }
1001
- case 'run_stopped': {
1002
- const { reason, role, billing, crashed } = record.content
1003
- const stopped = updateTicket({
1004
- state,
1005
- number: ticket,
1006
- update: (card) => ({
1007
- ...card,
1008
- role: null,
1009
- activity: 'run stopped',
1010
- tried: withTried({
1011
- tried: card.tried,
1012
- line: `${billing ? 'Stopped for billing' : crashed ? 'Stopped after crashes' : 'The run stopped'}: ${reason}`,
1013
- }),
1014
- }),
1015
- })
1016
- return {
1017
- ...stopped,
1018
- run: {
1019
- ...stopped.run,
1020
- stopped: {
1021
- reason,
1022
- role,
1023
- ticket,
1024
- billing,
1025
- crashed,
1026
- since: record.time,
1027
- },
1028
- },
1029
- }
1030
- }
1031
- case 'worktree_reset':
1032
- return updateTicket({
1033
- state,
1034
- number: ticket,
1035
- update: (card) => ({
1036
- ...card,
1037
- step: 0,
1038
- fix_round: 0,
1039
- open_check: null,
1040
- activity: 'starting over',
1041
- tried: withTried({
1042
- tried: card.tried,
1043
- line: 'Started over from the tests after a bad test',
1044
- }),
1045
- }),
1046
- })
1047
- case 'red_check':
1048
- return updateTicket({
1049
- state,
1050
- number: ticket,
1051
- update: (card) => ({
1052
- ...card,
1053
- step: record.content.ok ? 2 : 1,
1054
- open_check: record.content.ok ? null : 'red_check',
1055
- fix_round: record.content.ok ? 0 : card.fix_round,
1056
- activity: record.content.ok
1057
- ? 'red check passed'
1058
- : 'red check failed',
1059
- tests: record.content.tests
1060
- ? testCounts({ cases: record.content.tests.cases })
1061
- : card.tests,
1062
- tried: record.content.ok
1063
- ? card.tried
1064
- : withTried({
1065
- tried: card.tried,
1066
- line: `The red check failed: ${record.content.problems[0] ?? 'no detail'}`,
1067
- }),
1068
- }),
1069
- })
1070
- case 'leftover_scan':
1071
- if (record.content.hits.length === 0) return state
1072
- if (ticket === null) {
1073
- return finalTried({
1074
- state,
1075
- line: `The leftover scan found ${record.content.hits.map((hit) => hit.path).join(', ')} in the final review's fixes`,
1076
- })
1077
- }
1078
- return updateTicket({
1079
- state,
1080
- number: ticket,
1081
- update: (card) => ({
1082
- ...card,
1083
- activity: 'leftovers found',
1084
- tried: withTried({
1085
- tried: card.tried,
1086
- line: `The leftover scan found ${record.content.hits.map((hit) => hit.path).join(', ')}`,
1087
- }),
1088
- }),
1089
- })
1090
- case 'commit_made':
1091
- return updateTicket({
1092
- state,
1093
- number: ticket,
1094
- update: (card) => {
1095
- switch (record.content.stage) {
1096
- case 'green':
1097
- return {
1098
- ...card,
1099
- step: 4,
1100
- activity: 'code committed',
1101
- }
1102
- case 'fix':
1103
- return {
1104
- ...card,
1105
- activity: 'review fixes committed',
1106
- }
1107
- default:
1108
- return { ...card, activity: 'tests committed' }
1109
- }
1110
- },
1111
- })
1112
- case 'gates_run':
1113
- return gatesRun({ state, record })
1114
- case 'ticket_joined':
1115
- return updateTicket({
1116
- state,
1117
- number: ticket,
1118
- update: (card) =>
1119
- record.content.ok
1120
- ? {
1121
- ...card,
1122
- stage: 'done',
1123
- step: ALL_STEPS_DONE,
1124
- role: null,
1125
- activity: 'joined',
1126
- }
1127
- : {
1128
- ...card,
1129
- activity: 'join failed',
1130
- tried: withTried({
1131
- tried: card.tried,
1132
- line: `Joining the run branch failed: ${record.content.error ?? 'no detail'}`,
1133
- }),
1134
- },
1135
- })
1136
- case 'ticket_rebased':
1137
- return ticketRebased({ state, record })
1138
- case 'run_branch_pushed':
1139
- return updateTicket({
1140
- state,
1141
- number: ticket,
1142
- update: (card) =>
1143
- card.stage === 'done'
1144
- ? { ...card, activity: 'pushed' }
1145
- : card,
1146
- })
1147
- case 'ticket_stuck': {
1148
- if (ticket === null) return state
1149
- const card = state.tickets.find((entry) => entry.number === ticket)
1150
- const stuck = updateTicket({
1151
- state,
1152
- number: ticket,
1153
- update: (entry) => ({
1154
- ...entry,
1155
- stage: 'stuck',
1156
- role: null,
1157
- activity: 'stuck',
1158
- }),
1159
- })
1160
- return addNeedsYou({
1161
- state: stuck,
1162
- item: {
1163
- key: ticketKey({ ticket }),
1164
- ticket,
1165
- subject: `Ticket #${ticket} is stuck${card ? `: ${card.title}` : ''}`,
1166
- reason: reasonText({ reason: record.content.reason }),
1167
- detail: clip({
1168
- text: record.content.detail,
1169
- max: DETAIL_MAX,
1170
- }),
1171
- tried: card?.tried ?? [],
1172
- replies: [`retry #${ticket}`, `skip #${ticket}`, 'stop'],
1173
- since: record.time,
1174
- },
1175
- })
1176
- }
1177
- case 'run_stuck':
1178
- return addNeedsYou({
1179
- state,
1180
- item: {
1181
- key: RUN_KEY,
1182
- ticket: null,
1183
- subject: 'The run is stuck',
1184
- reason: runReasonText({ reason: record.content.reason }),
1185
- detail: clip({
1186
- text: record.content.detail,
1187
- max: DETAIL_MAX,
1188
- }),
1189
- tried: [],
1190
- replies: ['retry', 'stop'],
1191
- since: record.time,
1192
- },
1193
- })
1194
- case 'pull_request_opened':
1195
- return {
1196
- ...state,
1197
- run: {
1198
- ...state.run,
1199
- phase: 'done',
1200
- pr_url: record.content.url,
1201
- pr_number: record.content.number,
1202
- },
1203
- }
1204
- // Removing the worktrees at the end changes nothing on the board.
1205
- case 'worktrees_removed':
1206
- return state
1207
- case 'jev_asked':
1208
- return {
1209
- ...state,
1210
- jev: { ...state.jev, asked: state.jev.asked + 1 },
1211
- }
1212
- case 'jev_answered':
1213
- return {
1214
- ...state,
1215
- jev: { ...state.jev, answered: state.jev.answered + 1 },
1216
- }
1217
- case 'jev_failed':
1218
- return {
1219
- ...state,
1220
- jev: { ...state.jev, failed: state.jev.failed + 1 },
1221
- }
1222
- case 'agent_message': {
1223
- const refused = record.content.status === 'refused'
1224
- return {
1225
- ...state,
1226
- messages: {
1227
- ...state.messages,
1228
- sent: state.messages.sent + (refused ? 0 : 1),
1229
- refused: state.messages.refused + (refused ? 1 : 0),
1230
- },
1231
- }
1232
- }
1233
- case 'agent_message_delivered':
1234
- return {
1235
- ...state,
1236
- messages: {
1237
- ...state.messages,
1238
- delivered:
1239
- state.messages.delivered + record.content.ids.length,
1240
- },
1241
- }
1242
- // Another process's change to the shared `.git`: only counted.
1243
- case 'shared_git_changed':
1244
- return {
1245
- ...state,
1246
- shared_git_changed: state.shared_git_changed + 1,
1247
- }
1248
- case 'limit_wait_started': {
1249
- const { resets_at, until, rate_limit_type } = record.content
1250
- return {
1251
- ...state,
1252
- limit_wait: {
1253
- resets_at: resets_at ?? until,
1254
- window:
1255
- rate_limit_type === null
1256
- ? null
1257
- : windowWords({ window: rate_limit_type }),
1258
- since: record.time,
1259
- },
1260
- }
1261
- }
1262
- case 'limit_wait_ended':
1263
- return { ...state, limit_wait: null }
1264
- case 'usage_line_wait_started': {
1265
- const { window, line, percent, resets_at } = record.content
1266
- return {
1267
- ...state,
1268
- usage_line_wait: {
1269
- window: windowWords({ window }),
1270
- line,
1271
- percent,
1272
- resets_at,
1273
- since: record.time,
1274
- },
1275
- }
1276
- }
1277
- case 'usage_line_wait_ended':
1278
- return { ...state, usage_line_wait: null }
1279
- case 'usage_recorded': {
1280
- const plan_used = planUsedOf({ windows: record.content.windows })
1281
- if (record.content.scope === 'run') {
1282
- return { ...state, run_plan_used: plan_used }
1283
- }
1284
- return updateTicket({
1285
- state,
1286
- number: record.content.ticket ?? ticket,
1287
- update: (card) => ({ ...card, plan_used }),
1288
- })
1289
- }
1290
- case 'reply_received':
1291
- return replyReceived({ state, record })
1292
- case 'ticket_skipped':
1293
- return skipTicket({
1294
- state,
1295
- ticket,
1296
- because: record.content.because,
1297
- })
1298
- // Done, not stuck (#484): its work was already on the base branch.
1299
- case 'ticket_already_done':
1300
- return updateTicket({
1301
- state,
1302
- number: ticket,
1303
- update: (card) => ({
1304
- ...card,
1305
- stage: 'done',
1306
- step: ALL_STEPS_DONE,
1307
- role: null,
1308
- open_check: null,
1309
- activity: alreadyDoneText({ shas: record.content.shas }),
1310
- }),
1311
- })
1312
- // Its closing changes nothing the board shows.
1313
- case 'ticket_closed':
1314
- return state
1315
- case 'ticket_retried':
1316
- return ticketRetried({ state, record })
1317
- // An ignored reply is only a chat row; the engine answered it.
1318
- case 'reply_ignored':
1319
- return state
1320
- case 'memory_recalled': {
1321
- const { vaults, memories } = record.content
1322
- const { memory } = state
1323
- return {
1324
- ...state,
1325
- memory: {
1326
- ...memory,
1327
- searches: memory.searches + 1,
1328
- search_errors:
1329
- memory.search_errors +
1330
- vaults.filter(({ ok }) => !ok).length,
1331
- shown: memory.shown + memories.length,
1332
- },
1333
- }
1334
- }
1335
- case 'memories_saved': {
1336
- const count = (outcome: string) =>
1337
- record.content.saves.filter((save) => save.outcome === outcome)
1338
- .length
1339
- const { memory } = state
1340
- return {
1341
- ...state,
1342
- memory: {
1343
- ...memory,
1344
- added: memory.added + count('added'),
1345
- updated: memory.updated + count('updated'),
1346
- refused: memory.refused + count('refused'),
1347
- failed: memory.failed + count('failed'),
1348
- },
1349
- }
1350
- }
1351
- case 'learning_skipped':
1352
- return withLearner({ state, learner: 'skipped' })
1353
- // The listing on the spec issue is only a chat row.
1354
- case 'memories_reported':
1355
- return state
1356
- case 'step_started':
1357
- return stepStarted({ state, record })
1358
- case 'step_ended':
1359
- return stepEnded({ state, record })
1360
- case 'final_review_started':
1361
- return {
1362
- ...updateFinal({
1363
- state,
1364
- update: (review) => ({
1365
- ...review,
1366
- state: 'reviewing',
1367
- round: review.round + 1,
1368
- lenses: review.lenses.map((lens) =>
1369
- lens.state === 'clean'
1370
- ? lens
1371
- : { ...lens, state: 'waiting' }
1372
- ),
1373
- }),
1374
- }),
1375
- run: { ...state.run, phase: 'final_review' },
1376
- }
1377
- case 'lens_started':
1378
- return updateLens({
1379
- state,
1380
- lens: record.content.lens,
1381
- update: (lens) => ({ ...lens, state: 'reviewing' }),
1382
- })
1383
- case 'lens_finished': {
1384
- const { findings } = record.content
1385
- const counts = {
1386
- blocker: findings.blocker,
1387
- should_fix: findings.should_fix,
1388
- nit: findings.nit,
1389
- }
1390
- return updateLens({
1391
- state,
1392
- lens: record.content.lens,
1393
- update: (lens) => ({
1394
- ...lens,
1395
- findings: counts,
1396
- state:
1397
- counts.blocker + counts.should_fix > 0
1398
- ? 'fixing'
1399
- : 'clean',
1400
- }),
1401
- })
1402
- }
1403
- case 'final_review_fixing':
1404
- return updateFinal({
1405
- state,
1406
- update: (review) => ({
1407
- ...review,
1408
- state: 'fixing',
1409
- fix_round: record.content.round,
1410
- tried: withTried({
1411
- tried: review.tried,
1412
- line: `Fix round ${record.content.round}/${LOOP_CAP} on the lenses' findings`,
1413
- }),
1414
- }),
1415
- })
1416
- case 'final_review_stuck':
1417
- return addNeedsYou({
1418
- state: updateFinal({
1419
- state,
1420
- update: (review) => ({ ...review, state: 'stuck' }),
1421
- }),
1422
- item: {
1423
- key: FINAL_KEY,
1424
- ticket: null,
1425
- subject: 'The final review is stuck',
1426
- reason: finalReasonText({ reason: record.content.reason }),
1427
- detail: clip({
1428
- text: record.content.detail,
1429
- max: DETAIL_MAX,
1430
- }),
1431
- tried: state.final_review.tried,
1432
- replies: ['retry', 'stop', 'ship'],
1433
- since: record.time,
1434
- },
1435
- })
1436
- // A `ship` reply to the stuck final review, as the engine journals
1437
- // it: the same as the reply itself.
1438
- case 'final_review_shipped':
1439
- return resolveNeedsYou({
1440
- state: updateFinal({
1441
- state,
1442
- update: (review) => ({ ...review, state: 'passed' }),
1443
- }),
1444
- key: FINAL_KEY,
1445
- })
1446
- case 'final_review_passed':
1447
- return resolveNeedsYou({
1448
- state: updateFinal({
1449
- state,
1450
- update: (review) => ({
1451
- ...review,
1452
- state: 'passed',
1453
- lenses: review.lenses.map((lens) => ({
1454
- ...lens,
1455
- state: 'clean',
1456
- })),
1457
- }),
1458
- }),
1459
- key: FINAL_KEY,
1460
- })
1461
- }
1462
- }
1463
-
1464
- const OPEN_CHECK_TEXT = {
1465
- red_check: 'red check',
1466
- gates: 'checks',
1467
- review: 'ticket review',
1468
- } as const
138
+ ): record is Extract<BoardRecord, { kind: Kinds[number] }> =>
139
+ kinds.includes(record.kind)
1469
140
 
1470
141
  /**
1471
- * A fixer's start in a review fix round. The round is the review's round (a
1472
- * round follows each review that asked for changes); only the round's first
1473
- * fixer adds its "tried" line. The card stays in Reviewing at its step.
142
+ * Passes a record to the module that handles its kind. Every kind belongs to
143
+ * one module's `_KINDS`; a kind that belongs to none fails the type check.
1474
144
  */
1475
- const reviewFixStarted = ({
1476
- card,
1477
- role,
1478
- }: {
1479
- card: TicketCard
1480
- role: string
1481
- }): TicketCard => {
1482
- const round = Math.max(card.review_round, 1)
1483
- const counts = card.findings
1484
- ? reviewCountsText({ findings: card.findings, nits: false })
1485
- : null
1486
- return {
1487
- ...card,
1488
- started: true,
1489
- role,
1490
- stage: 'reviewing',
1491
- review_fix_round: round,
1492
- activity: `fixing the review's findings (${round}/${LOOP_CAP})`,
1493
- tried:
1494
- card.review_fix_round < round
1495
- ? withTried({
1496
- tried: card.tried,
1497
- line: `Review fix round ${round}/${LOOP_CAP}${counts ? `: ${counts}` : ''}`,
1498
- })
1499
- : card.tried,
1500
- }
1501
- }
1502
-
1503
- const agentStarted = ({
1504
- state,
1505
- record,
1506
- }: {
1507
- state: BoardState
1508
- record: Extract<BoardRecord, { kind: 'agent_started' }>
1509
- }): BoardState => {
1510
- const { role, follow_up_of } = record.content
1511
- return updateTicket({
1512
- state,
1513
- number: record.ticket,
1514
- update: (card) => {
1515
- const kind = startKind({ card, role, follow_up_of })
1516
- if (
1517
- kind === 'review_fix' ||
1518
- (kind === 'resent' && card.open_check === 'review')
1519
- ) {
1520
- return reviewFixStarted({ card, role })
1521
- }
1522
- const fix_round =
1523
- kind === 'fix' ? card.fix_round + 1 : card.fix_round
1524
- const started = { ...card, started: true, role, fix_round }
1525
- const tried =
1526
- kind === 'fix' && card.open_check !== null
1527
- ? withTried({
1528
- tried: card.tried,
1529
- line: `Fix round ${fix_round}/${LOOP_CAP} after the ${OPEN_CHECK_TEXT[card.open_check]}`,
1530
- })
1531
- : card.tried
1532
- const fixing = kind === 'fix' || kind === 'resent'
1533
- if (role === 'test-writer') {
1534
- // During a ticket review (a retried review fixer) the card
1535
- // stays in Reviewing at its step.
1536
- const reviewing = card.stage === 'reviewing'
1537
- return {
1538
- ...started,
1539
- tried,
1540
- stage: reviewing ? 'reviewing' : 'building',
1541
- step: reviewing ? card.step : 0,
1542
- activity: fixing
1543
- ? `fixing tests (${fix_round}/${LOOP_CAP})`
1544
- : kind === 'retry'
1545
- ? 'writing tests again'
1546
- : 'writing tests',
1547
- }
1548
- }
1549
- if (role === 'implementer') {
1550
- return {
1551
- ...started,
1552
- tried,
1553
- stage:
1554
- card.stage === 'reviewing' ? 'reviewing' : 'building',
1555
- step: 2,
1556
- activity: fixing
1557
- ? `fixing (${fix_round}/${LOOP_CAP})`
1558
- : kind === 'retry'
1559
- ? 'coding again'
1560
- : 'coding',
1561
- }
1562
- }
1563
- if (role === 'ticket-reviewer') {
1564
- const retry = kind === 'retry'
1565
- return {
1566
- ...started,
1567
- stage: 'reviewing',
1568
- step: 4,
1569
- open_check: retry ? card.open_check : null,
1570
- review_round: retry
1571
- ? Math.max(card.review_round, 1)
1572
- : card.review_round + 1,
1573
- activity: retry
1574
- ? 'reviewing again'
1575
- : card.review_fix_round > 0
1576
- ? 're-reviewing'
1577
- : 'reviewing',
1578
- }
1579
- }
1580
- return { ...started, activity: role }
1581
- },
1582
- })
1583
- }
1584
-
1585
- const agentFinished = ({
1586
- state,
1587
- record,
1588
- }: {
1589
- state: BoardState
1590
- record: Extract<BoardRecord, { kind: 'agent_finished' }>
1591
- }): BoardState => {
1592
- const { role, result } = record.content
1593
- return updateTicket({
1594
- state,
1595
- number: record.ticket,
1596
- update: (card) => {
1597
- const wontFix = result.finding_responses.filter(
1598
- ({ response }) => response === 'wont_fix'
1599
- )
1600
- const done = {
1601
- ...card,
1602
- role: null,
1603
- failed_turn: null,
1604
- tried: wontFix.reduce(
1605
- (tried, { finding_id, reason }) =>
1606
- withTried({
1607
- tried,
1608
- line: `Won't fix ${finding_id}: ${reason}`,
1609
- }),
1610
- card.tried
1611
- ),
1612
- }
1613
- if (role === 'test-writer') {
1614
- if (card.open_check === 'review') {
1615
- return { ...done, activity: 'test findings answered' }
1616
- }
1617
- if (result.outcome === 'already_done') {
1618
- return { ...done, activity: 'already done' }
1619
- }
1620
- return result.outcome === 'nothing_new_to_test'
1621
- ? { ...done, step: 2, activity: 'nothing new to test' }
1622
- : { ...done, step: 1, activity: 'red check' }
1623
- }
1624
- if (role === 'implementer') {
1625
- return result.outcome === 'bad_test'
1626
- ? { ...done, activity: 'bad test' }
1627
- : { ...done, step: 3, activity: 'checks' }
1628
- }
1629
- if (role === 'ticket-reviewer') {
1630
- const findings = result.findings
1631
- ? countFindings({ findings: result.findings })
1632
- : card.findings
1633
- const ruled = result.rulings.reduce(
1634
- (tried, { finding_id, ruling, reason }) =>
1635
- withTried({
1636
- tried,
1637
- line:
1638
- ruling === 'accepted'
1639
- ? `Declined ${finding_id} accepted: ${reason}`
1640
- : `${finding_id} still stands: ${reason}`,
1641
- }),
1642
- done.tried
1643
- )
1644
- return result.verdict === 'approve'
1645
- ? {
1646
- ...done,
1647
- step: ALL_STEPS_DONE,
1648
- findings,
1649
- open_check: null,
1650
- tried: ruled,
1651
- activity: 'approved',
1652
- }
1653
- : {
1654
- ...done,
1655
- findings,
1656
- open_check: 'review',
1657
- tried: ruled,
1658
- activity: 'changes requested',
1659
- }
1660
- }
1661
- return done
1662
- },
1663
- })
1664
- }
1665
-
1666
- const gatesRun = ({
145
+ const applyKind = ({
1667
146
  state,
1668
147
  record,
1669
148
  }: {
1670
149
  state: BoardState
1671
- record: Extract<BoardRecord, { kind: 'gates_run' }>
150
+ record: BoardRecord
1672
151
  }): BoardState => {
1673
- const { ok, target, checks } = record.content
1674
- const failed = checks
1675
- .filter((check) => !check.ok)
1676
- .map((check) => check.name)
1677
- .join(', ')
1678
- const afterJoin = target === 'run_branch'
1679
- // The final review's fixes are checked on the run branch, with no ticket.
1680
- if (record.ticket === null) {
1681
- return ok
1682
- ? state
1683
- : finalTried({
1684
- state,
1685
- line: `The checks failed on the final review's fixes: ${failed || 'no detail'}`,
1686
- })
152
+ if (isOneOf(RUN_KINDS, record)) return applyRunKind({ state, record })
153
+ if (isOneOf(TICKET_START_KINDS, record)) {
154
+ return applyTicketStartKind({ state, record })
1687
155
  }
1688
- return updateTicket({
1689
- state,
1690
- number: record.ticket,
1691
- update: (card) => {
1692
- if (ok) {
1693
- return afterJoin
1694
- ? { ...card, activity: 'checks passed after joining' }
1695
- : {
1696
- ...card,
1697
- step: 4,
1698
- open_check: null,
1699
- fix_round: 0,
1700
- activity: 'checks passed',
1701
- }
1702
- }
1703
- return {
1704
- ...card,
1705
- step: afterJoin ? card.step : 3,
1706
- open_check: afterJoin ? card.open_check : 'gates',
1707
- activity: afterJoin
1708
- ? 'checks failed after joining'
1709
- : 'checks failed',
1710
- tried: withTried({
1711
- tried: card.tried,
1712
- line: `${afterJoin ? 'After joining, the' : 'The'} checks failed: ${failed || 'no detail'}`,
1713
- }),
1714
- }
1715
- },
1716
- })
1717
- }
1718
-
1719
- /** What happened when a ticket was sent back onto the run branch, in words. */
1720
- export const rebasedText = ({
1721
- content,
1722
- }: {
1723
- content: Extract<BoardRecord, { kind: 'ticket_rebased' }>['content']
1724
- }): string => {
1725
- if (content.cause === 'join_gates') {
1726
- return 'The checks failed after joining; fixing on top of the run branch'
156
+ if (isOneOf(AGENT_KINDS, record)) return applyAgentKind({ state, record })
157
+ if (isOneOf(TICKET_CHECKS_KINDS, record)) {
158
+ return applyTicketChecksKind({ state, record })
1727
159
  }
1728
- const files = [...content.tests, ...content.code]
1729
- return files.length === 0
1730
- ? 'Clashed with the run branch; fixing on top of it'
1731
- : `Clashed with the run branch in ${files.join(', ')}`
1732
- }
1733
-
1734
- /**
1735
- * A ticket sent back onto the run branch builds again: its tests first if
1736
- * test files clashed, else its code; then the checks and a fresh review,
1737
- * whose fix rounds count from zero again.
1738
- */
1739
- const ticketRebased = ({
1740
- state,
1741
- record,
1742
- }: {
1743
- state: BoardState
1744
- record: Extract<BoardRecord, { kind: 'ticket_rebased' }>
1745
- }): BoardState =>
1746
- updateTicket({
1747
- state,
1748
- number: record.ticket,
1749
- update: (card) => ({
1750
- ...card,
1751
- stage: 'building',
1752
- step: record.content.tests.length > 0 ? 0 : 2,
1753
- role: null,
1754
- fix_round: 0,
1755
- // The engine starts the ticket's review loop over on top of the
1756
- // run branch: a fresh reviewer, fresh review fix rounds.
1757
- review_fix_round: 0,
1758
- findings: null,
1759
- open_check: null,
1760
- failed_turn: null,
1761
- activity: 'fixing on the run branch',
1762
- tried: withTried({
1763
- tried: card.tried,
1764
- line: rebasedText({ content: record.content }),
1765
- }),
1766
- }),
1767
- })
1768
-
1769
- const skipTicket = ({
1770
- state,
1771
- ticket,
1772
- because,
1773
- }: {
1774
- state: BoardState
1775
- ticket: number | null
1776
- because: number | null
1777
- }): BoardState => {
1778
- if (ticket === null) return state
1779
- return resolveNeedsYou({
1780
- state: updateTicket({
1781
- state,
1782
- number: ticket,
1783
- update: (card) => ({
1784
- ...card,
1785
- stage: 'skipped',
1786
- role: null,
1787
- activity:
1788
- because === null
1789
- ? 'skipped'
1790
- : `skipped: waits on skipped #${because}`,
1791
- }),
1792
- }),
1793
- key: ticketKey({ ticket }),
1794
- })
1795
- }
1796
-
1797
- /**
1798
- * How the engine took a `retry`: `resume` changes nothing more than the
1799
- * reply did; `restart` builds the edited ticket from scratch (its new
1800
- * snapshot came just before); `refused` leaves it stuck, back in Needs you.
1801
- */
1802
- const ticketRetried = ({
1803
- state,
1804
- record,
1805
- }: {
1806
- state: BoardState
1807
- record: Extract<BoardRecord, { kind: 'ticket_retried' }>
1808
- }): BoardState => {
1809
- const { ticket } = record
1810
- if (ticket === null) return state
1811
- switch (record.content.mode) {
1812
- case 'resume':
1813
- return state
1814
- case 'restart':
1815
- return updateTicket({
1816
- state,
1817
- number: ticket,
1818
- update: (card) => ({
1819
- ...card,
1820
- stage: 'building',
1821
- step: 0,
1822
- role: null,
1823
- fix_round: 0,
1824
- review_fix_round: 0,
1825
- findings: null,
1826
- open_check: null,
1827
- failed_turn: null,
1828
- activity: 'starting over from the edited ticket',
1829
- tried: withTried({
1830
- tried: card.tried,
1831
- line: 'Started over from the edited ticket',
1832
- }),
1833
- }),
1834
- })
1835
- case 'refused': {
1836
- const card = state.tickets.find((entry) => entry.number === ticket)
1837
- return addNeedsYou({
1838
- state: updateTicket({
1839
- state,
1840
- number: ticket,
1841
- update: (entry) => ({
1842
- ...entry,
1843
- stage: 'stuck',
1844
- role: null,
1845
- activity: 'stuck',
1846
- }),
1847
- }),
1848
- item: {
1849
- key: ticketKey({ ticket }),
1850
- ticket,
1851
- subject: `Retry of #${ticket} was refused${card ? `: ${card.title}` : ''}`,
1852
- reason: RETRY_REFUSED_TEXT,
1853
- detail: clip({
1854
- text: record.content.problems.join(' '),
1855
- max: DETAIL_MAX,
1856
- }),
1857
- tried: card?.tried ?? [],
1858
- replies: [`retry #${ticket}`, `skip #${ticket}`, 'stop'],
1859
- since: record.time,
1860
- },
1861
- })
1862
- }
160
+ if (isOneOf(TICKET_JOIN_KINDS, record)) {
161
+ return applyTicketJoinKind({ state, record })
1863
162
  }
1864
- }
1865
-
1866
- const replyReceived = ({
1867
- state,
1868
- record,
1869
- }: {
1870
- state: BoardState
1871
- record: Extract<BoardRecord, { kind: 'reply_received' }>
1872
- }): BoardState => {
1873
- const { word } = record.content
1874
- const ticket = record.content.ticket ?? record.ticket
1875
- switch (word) {
1876
- case 'retry':
1877
- if (ticket !== null) {
1878
- return resolveNeedsYou({
1879
- state: updateTicket({
1880
- state,
1881
- number: ticket,
1882
- update: (card) => ({
1883
- ...card,
1884
- stage: 'building',
1885
- activity: 'retrying',
1886
- }),
1887
- }),
1888
- key: ticketKey({ ticket }),
1889
- })
1890
- }
1891
- // A bare `retry` while the run is stuck on its budget adds one
1892
- // more full budget; the final review is left as it is.
1893
- if (state.needs_you.some((item) => item.key === RUN_KEY)) {
1894
- return resolveNeedsYou({
1895
- state: {
1896
- ...state,
1897
- run_budget_tokens:
1898
- state.run_budget_tokens + state.run_budget_each,
1899
- },
1900
- key: RUN_KEY,
1901
- })
1902
- }
1903
- return resolveNeedsYou({
1904
- state: updateFinal({
1905
- state,
1906
- update: (review) => ({ ...review, state: 'reviewing' }),
1907
- }),
1908
- key: FINAL_KEY,
1909
- })
1910
- case 'skip':
1911
- return skipTicket({ state, ticket, because: null })
1912
- case 'ship':
1913
- return resolveNeedsYou({
1914
- state: updateFinal({
1915
- state,
1916
- update: (review) => ({ ...review, state: 'passed' }),
1917
- }),
1918
- key: FINAL_KEY,
1919
- })
1920
- case 'stop':
1921
- return resolveNeedsYou({ state, key: null })
163
+ if (isOneOf(NEEDS_YOU_KINDS, record)) {
164
+ return applyNeedsYouKind({ state, record })
1922
165
  }
1923
- }
1924
-
1925
- /** A ticket's blockers inside this run that aren't done yet. */
1926
- export const unfinishedBlockers = ({
1927
- state,
1928
- ticket,
1929
- }: {
1930
- state: BoardState
1931
- ticket: TicketCard
1932
- }): number[] =>
1933
- ticket.blockers.filter((number) => {
1934
- const blocker = state.tickets.find((card) => card.number === number)
1935
- return blocker !== undefined && blocker.stage !== 'done'
1936
- })
1937
-
1938
- const isActive = ({ state }: { state: BoardState }): boolean =>
1939
- state.tickets.some(
1940
- (ticket) =>
1941
- ticket.started &&
1942
- (ticket.stage === 'building' || ticket.stage === 'reviewing')
1943
- ) ||
1944
- state.final_review.state === 'reviewing' ||
1945
- state.final_review.state === 'fixing'
1946
-
1947
- const runStatus = ({ state }: { state: BoardState }): RunStatus => {
1948
- const { phase, engine_ended, stopped } = state.run
1949
- if (phase === 'done' || phase === 'refused' || phase === 'nothing_to_do')
1950
- return phase
1951
- if (stopped) return 'stopped'
1952
- if (engine_ended && !engine_ended.ok) return 'ended_with_error'
1953
- if (state.limit_wait) return 'limit_wait'
1954
- if (state.usage_line_wait) return 'usage_line_wait'
1955
- // Nothing new starts while the run is stuck on its budget.
1956
- if (state.needs_you.some((item) => item.key === RUN_KEY)) return 'stuck'
1957
- if (state.needs_you.length > 0 && !isActive({ state })) return 'stuck'
1958
- return phase
1959
- }
1960
-
1961
- /**
1962
- * Recomputes what depends on the whole board: which unstarted tickets are
1963
- * blocked, whether the final review is active, and the run's status.
1964
- */
1965
- const settle = ({ state }: { state: BoardState }): BoardState => {
1966
- const tickets = state.tickets.map((ticket): TicketCard => {
1967
- if (ticket.started) return ticket
1968
- if (ticket.stage !== 'blocked' && ticket.stage !== 'building')
1969
- return ticket
1970
- const waiting = unfinishedBlockers({ state, ticket })
1971
- return waiting.length > 0
1972
- ? {
1973
- ...ticket,
1974
- stage: 'blocked',
1975
- activity: `waits on ${waiting.map((number) => `#${number}`).join(', ')}`,
1976
- }
1977
- : { ...ticket, stage: 'building', activity: 'queued' }
1978
- })
1979
- const settledTickets = { ...state, tickets }
1980
- const active =
1981
- tickets.length > 0 &&
1982
- tickets.every(
1983
- (ticket) => ticket.stage === 'done' || ticket.stage === 'skipped'
1984
- )
1985
- const withReview = {
1986
- ...settledTickets,
1987
- final_review: { ...state.final_review, active },
166
+ if (isOneOf(STEP_KINDS, record)) return applyStepKind({ state, record })
167
+ if (isOneOf(FINAL_REVIEW_KINDS, record)) {
168
+ return applyFinalReviewKind({ state, record })
1988
169
  }
1989
- return {
1990
- ...withReview,
1991
- run: { ...withReview.run, status: runStatus({ state: withReview }) },
170
+ if (isOneOf(USAGE_KINDS, record)) return applyUsageKind({ state, record })
171
+ if (isOneOf(COUNTER_KINDS, record)) {
172
+ return applyCounterKind({ state, record })
1992
173
  }
174
+ record satisfies never
175
+ return state
1993
176
  }