@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,1356 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Prints what went wrong in finished Luca runs, from their journals, for the
4
+ * `/luca-retro` skill:
5
+ *
6
+ * bun retro-summary.ts [run id ...] [--repo <path>] [--runs-dir <dir>]
7
+ *
8
+ * With no run id, it sums up the newest finished run of the repo (`--repo`,
9
+ * else the git repo of the current folder, or the main checkout of the
10
+ * worktree it is in). With several, it sums up each, then the patterns that
11
+ * repeat across them.
12
+ *
13
+ * Each summary names the journal's seq numbers (`seq 123`), so the skill can
14
+ * cite them: what got stuck and why, fix loops that hit their cap, leftover
15
+ * scan hits, agents out of turns, failed agent turns, Jev calls that failed,
16
+ * checks and tests that failed again and again, setup changes agents asked
17
+ * for, joins and rebases, the run budget, replies, the agents' assumptions,
18
+ * the findings fixers declined, slow steps, and tokens.
19
+ *
20
+ * It only reads the journal (`<runs folder>/<run id>/journal.jsonl`). It
21
+ * changes nothing.
22
+ *
23
+ * It imports nothing but Bun's and Node's own modules: it runs from
24
+ * `~/.claude/skills/luca-retro/`, where no packages are installed. So it
25
+ * reads records loosely, not with the engine's Zod schemas.
26
+ * `retro-summary.test.ts` checks it against journals built with the engine's
27
+ * own fixtures, and every kind in `KINDS_READ` against the journal's kinds,
28
+ * so the two can't drift apart unseen.
29
+ */
30
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
31
+ import { homedir } from 'node:os'
32
+ import { basename, dirname, join, resolve } from 'node:path'
33
+
34
+ /** Every journal record kind this script reads. */
35
+ export const KINDS_READ = [
36
+ 'run_started',
37
+ 'engine_resumed',
38
+ 'config_reloaded',
39
+ 'run_resumed',
40
+ 'intake_read',
41
+ 'spec_snapshot',
42
+ 'ticket_snapshot',
43
+ 'intake_refused',
44
+ 'nothing_to_do',
45
+ 'agent_finished',
46
+ 'agent_failed',
47
+ 'agent_session',
48
+ 'red_check',
49
+ 'leftover_scan',
50
+ 'gates_run',
51
+ 'ticket_joined',
52
+ 'ticket_rebased',
53
+ 'tests_sent_back',
54
+ 'join_undone',
55
+ 'ticket_stuck',
56
+ 'run_stuck',
57
+ 'final_review_stuck',
58
+ 'reply_received',
59
+ 'reply_ignored',
60
+ 'run_stopped',
61
+ 'pull_request_opened',
62
+ 'jev_asked',
63
+ 'jev_failed',
64
+ 'usage_recorded',
65
+ 'shared_git_changed',
66
+ 'step_started',
67
+ 'step_ended',
68
+ ] as const
69
+
70
+ type KindRead = (typeof KINDS_READ)[number]
71
+
72
+ /** One journal line, read loosely. */
73
+ export type LooseRecord = {
74
+ seq: number
75
+ time: string
76
+ kind: string
77
+ ticket: number | null
78
+ content: Record<string, unknown>
79
+ }
80
+
81
+ /** The most entries a list prints before "... and N more". */
82
+ const LIST_MAX = 8
83
+
84
+ /** Longest text quoted from a record, such as a stuck detail. */
85
+ const DETAIL_MAX = 300
86
+
87
+ /** How many step types the slow steps list shows. */
88
+ const SLOW_STEPS_MAX = 8
89
+
90
+ // NOTE: these small readers are copies of the ones in
91
+ // `../../luca-unstick/scripts/stuck-summary.ts`: each skill's script must
92
+ // stand alone. Keep the two in step.
93
+
94
+ const isObject = (value: unknown): value is Record<string, unknown> =>
95
+ typeof value === 'object' && value !== null && !Array.isArray(value)
96
+
97
+ const text = (value: unknown): string =>
98
+ typeof value === 'string' ? value : ''
99
+
100
+ const numberOr = (value: unknown): number | null =>
101
+ typeof value === 'number' ? value : null
102
+
103
+ const listOf = (value: unknown): unknown[] =>
104
+ Array.isArray(value) ? value : []
105
+
106
+ const objectOf = (value: unknown): Record<string, unknown> =>
107
+ isObject(value) ? value : {}
108
+
109
+ /** One line, at most `max` characters, marked when cut. */
110
+ const clip = (value: string, max = DETAIL_MAX): string => {
111
+ const line = value.replace(/\s+/g, ' ').trim()
112
+ return line.length > max ? `${line.slice(0, max)} ...` : line
113
+ }
114
+
115
+ /** One journal line as a loose record, or `null` when it isn't one. */
116
+ const toRecord = (line: string): LooseRecord | null => {
117
+ let json: unknown
118
+ try {
119
+ json = JSON.parse(line)
120
+ } catch {
121
+ return null
122
+ }
123
+ if (!isObject(json)) return null
124
+ const seq = numberOr(json.seq)
125
+ if (seq === null || typeof json.kind !== 'string') return null
126
+ return {
127
+ seq,
128
+ time: text(json.time),
129
+ kind: json.kind,
130
+ ticket: numberOr(json.ticket),
131
+ content: objectOf(json.content),
132
+ }
133
+ }
134
+
135
+ /**
136
+ * A run's records, oldest first. Lines that aren't records (such as a
137
+ * half-written last line) are skipped. No journal gives none.
138
+ */
139
+ export const readRecords = ({ file }: { file: string }): LooseRecord[] =>
140
+ existsSync(file)
141
+ ? readFileSync(file, 'utf8')
142
+ .split('\n')
143
+ .filter((line) => line.trim() !== '')
144
+ .map(toRecord)
145
+ .filter((record): record is LooseRecord => record !== null)
146
+ : []
147
+
148
+ const ofKind = (records: LooseRecord[], kind: KindRead): LooseRecord[] =>
149
+ records.filter((record) => record.kind === kind)
150
+
151
+ const lastOf = (
152
+ records: LooseRecord[],
153
+ kind: KindRead,
154
+ where: (record: LooseRecord) => boolean = () => true
155
+ ): LooseRecord | undefined => ofKind(records, kind).filter(where).at(-1)
156
+
157
+ const msBetween = (from: string, to: string): number =>
158
+ Math.max(0, Date.parse(to) - Date.parse(from)) || 0
159
+
160
+ /**
161
+ * A duration in words: `1h 02m`, `4m 05s`, `12s`.
162
+ *
163
+ * @example
164
+ * durationText(3_725_000) // '1h 02m'
165
+ */
166
+ export const durationText = (ms: number): string => {
167
+ const s = Math.round(ms / 1000)
168
+ const pad = (n: number) => String(n).padStart(2, '0')
169
+ if (s >= 3600)
170
+ return `${Math.floor(s / 3600)}h ${pad(Math.floor((s % 3600) / 60))}m`
171
+ if (s >= 60) return `${Math.floor(s / 60)}m ${pad(s % 60)}s`
172
+ return `${s}s`
173
+ }
174
+
175
+ /**
176
+ * A token count in words: `9.48M`, `820k`, `512`.
177
+ *
178
+ * @example
179
+ * tokensText(9_476_629) // '9.48M'
180
+ */
181
+ export const tokensText = (n: number): string =>
182
+ n >= 1_000_000
183
+ ? `${(n / 1_000_000).toFixed(2)}M`
184
+ : n >= 1000
185
+ ? `${Math.round(n / 1000)}k`
186
+ : String(n)
187
+
188
+ /** One group of like things: how many, and the seqs of the records. */
189
+ export type Tally = { key: string; count: number; seqs: number[] }
190
+
191
+ /** Groups `items` by key, biggest group first. Pure. */
192
+ const tally = (items: { key: string; seq: number }[]): Tally[] => {
193
+ const groups = new Map<string, Tally>()
194
+ for (const { key, seq } of items) {
195
+ const group = groups.get(key) ?? { key, count: 0, seqs: [] }
196
+ group.count += 1
197
+ group.seqs.push(seq)
198
+ groups.set(key, group)
199
+ }
200
+ return [...groups.values()].toSorted((a, b) => b.count - a.count)
201
+ }
202
+
203
+ /** A journal record's seq as `seq 123`, never `#123`, which GitHub links. */
204
+ const seqText = (seq: number): string => `seq ${seq}`
205
+
206
+ /** Seqs as `seq 1, seq 2, seq 3`, at most `max`. */
207
+ const seqsText = (seqs: number[], max = 6): string =>
208
+ seqs.length > max
209
+ ? `${seqs.slice(0, max).map(seqText).join(', ')}, +${seqs.length - max}`
210
+ : seqs.map(seqText).join(', ')
211
+
212
+ /** The first `LIST_MAX` lines, then how many more there are. */
213
+ const capped = (lines: string[], max = LIST_MAX): string[] =>
214
+ lines.length > max
215
+ ? [...lines.slice(0, max), `... and ${lines.length - max} more`]
216
+ : lines
217
+
218
+ /** Tokens as the run budget counts them: input, output, and cache writes. */
219
+ const countedTokens = (tokens: Record<string, unknown>): number =>
220
+ (numberOr(tokens.input_tokens) ?? 0) +
221
+ (numberOr(tokens.output_tokens) ?? 0) +
222
+ (numberOr(tokens.cache_creation_input_tokens) ?? 0)
223
+
224
+ /** Something that got stuck, and the owner's reply to it. */
225
+ export type StuckEvent = {
226
+ seq: number
227
+ kind: string
228
+ ticket: number | null
229
+ reason: string
230
+ detail: string
231
+ /** The cap a fix loop hit, from the detail: `3 fix rounds`, `3 rebases`. */
232
+ cap: string | null
233
+ reply: { seq: number; word: string; wait_ms: number } | null
234
+ }
235
+
236
+ /** A test or check that failed, from failing gates. */
237
+ export type GateFailures = {
238
+ runs: number
239
+ failed: number
240
+ /** By check name (`test`, `types`, `prepare (timed out)`, ...). */
241
+ by_check: Tally[]
242
+ /** By test name, from `(fail)` lines in a test check's output. */
243
+ by_test: Tally[]
244
+ }
245
+
246
+ /** One step type's times, from `step_started` to `step_ended`. */
247
+ export type StepTimes = {
248
+ step: string
249
+ count: number
250
+ total_ms: number
251
+ max_ms: number
252
+ max_seq: number
253
+ max_ticket: number | null
254
+ }
255
+
256
+ /** One agent role's sessions: turns, tokens, time. */
257
+ export type RoleUse = {
258
+ role: string
259
+ sessions: number
260
+ counted: number
261
+ turns: number
262
+ max_turns: number
263
+ duration_ms: number
264
+ }
265
+
266
+ /** An agent turn's text, such as an assumption or a declined finding. */
267
+ export type AgentNote = {
268
+ seq: number
269
+ ticket: number | null
270
+ role: string
271
+ text: string
272
+ }
273
+
274
+ /** One kind of pattern, for spotting repeats across runs. */
275
+ export type Signal = { key: string; count: number }
276
+
277
+ /** Everything `/luca-retro` reads from one run's journal. */
278
+ export type RunSummary = {
279
+ spec: { number: number | null; title: string; url: string }
280
+ repo: string
281
+ luca: string[]
282
+ started: string
283
+ ended: string
284
+ last_seq: number
285
+ over: boolean
286
+ ending: string
287
+ tickets: Map<number, string>
288
+ stuck: StuckEvent[]
289
+ leftovers: { scans: number; by_path: Tally[]; reasons: Tally[] }
290
+ out_of_turns: AgentNote[]
291
+ no_session: AgentNote[]
292
+ other_failures: AgentNote[]
293
+ jev: { asked: number; failed: Tally[]; jobs: Tally[] }
294
+ gates: GateFailures
295
+ red_checks: { runs: number; failed: number }
296
+ setup_changes: AgentNote[]
297
+ joins: {
298
+ ok: number
299
+ failed: { seq: number; ticket: number | null }[]
300
+ rebases: Tally[]
301
+ undone: { seq: number; ticket: number | null; shas: number }[]
302
+ tests_sent_back: AgentNote[]
303
+ }
304
+ replies: { words: Tally[]; ignored: Tally[] }
305
+ assumptions: {
306
+ total: number
307
+ by_role: Tally[]
308
+ by_ticket: Tally[]
309
+ notes: AgentNote[]
310
+ }
311
+ declined: { wont_fix: AgentNote[]; accepted: AgentNote[] }
312
+ steps: StepTimes[]
313
+ usage: {
314
+ run: Record<string, unknown> | null
315
+ agent_turns: number | null
316
+ by_ticket: { ticket: number; counted: number }[]
317
+ by_role: RoleUse[]
318
+ }
319
+ resumes: {
320
+ seq: number
321
+ version: string
322
+ down_ms: number
323
+ cut_off: number
324
+ }[]
325
+ config_reloaded: string[]
326
+ shared_git_changed: number
327
+ signals: Signal[]
328
+ }
329
+
330
+ /** How a run ended, from its last records; `over` when nothing more comes. */
331
+ const runEnding = ({
332
+ records,
333
+ }: {
334
+ records: LooseRecord[]
335
+ }): { over: boolean; line: string } => {
336
+ const pr = lastOf(records, 'pull_request_opened')
337
+ if (pr !== undefined) {
338
+ return {
339
+ over: true,
340
+ line: `opened PR #${String(pr.content.number)} ${text(pr.content.url)} (${seqText(pr.seq)})`,
341
+ }
342
+ }
343
+ const refused = lastOf(records, 'intake_refused')
344
+ if (refused !== undefined) {
345
+ return {
346
+ over: true,
347
+ line: `intake refused it (${seqText(refused.seq)})`,
348
+ }
349
+ }
350
+ const nothing = lastOf(records, 'nothing_to_do')
351
+ if (nothing !== undefined) {
352
+ return { over: true, line: `nothing to do (${seqText(nothing.seq)})` }
353
+ }
354
+ const stopped = lastOf(records, 'run_stopped')
355
+ if (
356
+ stopped !== undefined &&
357
+ (stopped.content.billing === true || stopped.content.crashed === true)
358
+ ) {
359
+ return {
360
+ over: true,
361
+ line: `stopped for good (${seqText(stopped.seq)}): ${clip(text(stopped.content.reason))}`,
362
+ }
363
+ }
364
+ const stop = lastOf(
365
+ records,
366
+ 'reply_received',
367
+ ({ content }) => content.word === 'stop'
368
+ )
369
+ if (stop !== undefined) {
370
+ return {
371
+ over: true,
372
+ line: `the owner replied stop (${seqText(stop.seq)}), no PR`,
373
+ }
374
+ }
375
+ return { over: false, line: 'not over yet' }
376
+ }
377
+
378
+ /** Whether a run is over: a PR, a refusal, nothing to do, a stop. Pure. */
379
+ export const isFinished = ({ records }: { records: LooseRecord[] }): boolean =>
380
+ runEnding({ records }).over
381
+
382
+ /**
383
+ * The stuck records, each with the owner's reply to it: the next
384
+ * `reply_received` for its ticket (or, for the final review or the run, the
385
+ * next one naming no ticket).
386
+ */
387
+ const stuckEvents = ({ records }: { records: LooseRecord[] }): StuckEvent[] =>
388
+ records
389
+ .filter(
390
+ ({ kind }) =>
391
+ kind === 'ticket_stuck' ||
392
+ kind === 'final_review_stuck' ||
393
+ kind === 'run_stuck'
394
+ )
395
+ .map((record) => {
396
+ const detail = text(record.content.detail)
397
+ const reply = records.find(
398
+ (later) =>
399
+ later.seq > record.seq &&
400
+ later.kind === 'reply_received' &&
401
+ numberOr(later.content.ticket) === record.ticket
402
+ )
403
+ return {
404
+ seq: record.seq,
405
+ kind: record.kind,
406
+ ticket: record.ticket,
407
+ reason: text(record.content.reason),
408
+ detail,
409
+ cap:
410
+ /after (\d+ (?:fix rounds|rebases))/.exec(detail)?.[1] ??
411
+ null,
412
+ reply:
413
+ reply === undefined
414
+ ? null
415
+ : {
416
+ seq: reply.seq,
417
+ word: text(reply.content.word),
418
+ wait_ms: msBetween(record.time, reply.time),
419
+ },
420
+ }
421
+ })
422
+
423
+ /** The tests a bun test output says failed, once each. */
424
+ const failedTests = (output: string): string[] => [
425
+ ...new Set(
426
+ [...output.matchAll(/^\(fail\) (.+?)(?: \[[\d.]+m?s\])?$/gm)].map(
427
+ (match) => match[1] ?? ''
428
+ )
429
+ ),
430
+ ]
431
+
432
+ /** Failing gates, by check and by test. Pure. */
433
+ const gateFailures = ({
434
+ records,
435
+ }: {
436
+ records: LooseRecord[]
437
+ }): GateFailures => {
438
+ const gates = ofKind(records, 'gates_run')
439
+ const checks: { key: string; seq: number }[] = []
440
+ const tests: { key: string; seq: number }[] = []
441
+ for (const gate of gates.filter(({ content }) => content.ok === false)) {
442
+ for (const check of listOf(gate.content.checks).map(objectOf)) {
443
+ if (check.ok !== false) continue
444
+ const name = text(check.name) || 'check'
445
+ checks.push({
446
+ key: check.exit_code === null ? `${name} (timed out)` : name,
447
+ seq: gate.seq,
448
+ })
449
+ for (const test of failedTests(text(check.output))) {
450
+ tests.push({ key: test, seq: gate.seq })
451
+ }
452
+ }
453
+ }
454
+ return {
455
+ runs: gates.length,
456
+ failed: gates.filter(({ content }) => content.ok === false).length,
457
+ by_check: tally(checks),
458
+ by_test: tally(tests),
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Each step type's times: from a `step_started` to the `step_ended` with the
464
+ * same key. A step a crash cut off (no end) doesn't count. Slowest in total
465
+ * first. Pure.
466
+ */
467
+ export const stepTimes = ({
468
+ records,
469
+ }: {
470
+ records: LooseRecord[]
471
+ }): StepTimes[] => {
472
+ const open = new Map<string, LooseRecord>()
473
+ const times = new Map<string, StepTimes>()
474
+ for (const record of records) {
475
+ const key = text(record.content.key)
476
+ if (record.kind === 'step_started') {
477
+ open.set(key, record)
478
+ } else if (record.kind === 'step_ended') {
479
+ const start = open.get(key)
480
+ if (start === undefined) continue
481
+ open.delete(key)
482
+ const step = text(start.content.step)
483
+ const ms = msBetween(start.time, record.time)
484
+ const entry = times.get(step) ?? {
485
+ step,
486
+ count: 0,
487
+ total_ms: 0,
488
+ max_ms: -1,
489
+ max_seq: start.seq,
490
+ max_ticket: start.ticket,
491
+ }
492
+ entry.count += 1
493
+ entry.total_ms += ms
494
+ if (ms > entry.max_ms) {
495
+ entry.max_ms = ms
496
+ entry.max_seq = start.seq
497
+ entry.max_ticket = start.ticket
498
+ }
499
+ times.set(step, entry)
500
+ }
501
+ }
502
+ return [...times.values()].toSorted((a, b) => b.total_ms - a.total_ms)
503
+ }
504
+
505
+ /** Each role's agent sessions: turns, counted tokens, and time. Pure. */
506
+ const roleUse = ({ records }: { records: LooseRecord[] }): RoleUse[] => {
507
+ const roles = new Map<string, RoleUse>()
508
+ for (const { content } of ofKind(records, 'agent_session')) {
509
+ const role = text(content.role)
510
+ const session = objectOf(content.session)
511
+ const models = Object.values(objectOf(session.model_usage)).map(
512
+ objectOf
513
+ )
514
+ const counted =
515
+ models.length === 0
516
+ ? countedTokens(objectOf(session.usage))
517
+ : models.reduce((sum, tokens) => sum + countedTokens(tokens), 0)
518
+ const turns = numberOr(session.num_turns) ?? 0
519
+ const use = roles.get(role) ?? {
520
+ role,
521
+ sessions: 0,
522
+ counted: 0,
523
+ turns: 0,
524
+ max_turns: 0,
525
+ duration_ms: 0,
526
+ }
527
+ use.sessions += 1
528
+ use.counted += counted
529
+ use.turns += turns
530
+ use.max_turns = Math.max(use.max_turns, turns)
531
+ use.duration_ms += numberOr(session.duration_ms) ?? 0
532
+ roles.set(role, use)
533
+ }
534
+ return [...roles.values()].toSorted((a, b) => b.counted - a.counted)
535
+ }
536
+
537
+ /** `#12` for a ticket, or `run` for none. */
538
+ const ticketText = (ticket: number | null): string =>
539
+ ticket === null ? 'run' : `#${ticket}`
540
+
541
+ /** The folder of a repo-relative path, or `.` for the root. */
542
+ const folderOf = (path: string): string =>
543
+ path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.'
544
+
545
+ /**
546
+ * Everything `/luca-retro` reads from one run's records, oldest first.
547
+ * Pure.
548
+ *
549
+ * @example
550
+ * const summary = summarizeJournal({ records: readRecords({ file }) })
551
+ * summary.stuck.map(({ reason }) => reason) // ['leftovers_found', 'run_budget']
552
+ */
553
+ export const summarizeJournal = ({
554
+ records,
555
+ }: {
556
+ records: LooseRecord[]
557
+ }): RunSummary => {
558
+ const started = objectOf(lastOf(records, 'run_started')?.content)
559
+ const spec = objectOf(
560
+ objectOf(
561
+ (lastOf(records, 'spec_snapshot') ?? lastOf(records, 'intake_read'))
562
+ ?.content
563
+ ).spec
564
+ )
565
+ const ending = runEnding({ records })
566
+ const tickets = new Map<number, string>()
567
+ for (const { content } of ofKind(records, 'ticket_snapshot')) {
568
+ const number = numberOr(content.number)
569
+ if (number !== null) tickets.set(number, text(content.title))
570
+ }
571
+
572
+ const note = (
573
+ record: LooseRecord,
574
+ body: string,
575
+ role = text(record.content.role)
576
+ ): AgentNote => ({
577
+ seq: record.seq,
578
+ ticket: record.ticket,
579
+ role,
580
+ text: body,
581
+ })
582
+
583
+ // Leftover scans that blocked a commit.
584
+ const scans = ofKind(records, 'leftover_scan').filter(
585
+ ({ content }) => listOf(content.hits).length > 0
586
+ )
587
+ const hits = scans.flatMap((scan) =>
588
+ listOf(scan.content.hits)
589
+ .map(objectOf)
590
+ .map((hit) => ({
591
+ seq: scan.seq,
592
+ path: text(hit.path),
593
+ reason: text(hit.reason),
594
+ }))
595
+ )
596
+
597
+ // Failed agent turns.
598
+ const failed = ofKind(records, 'agent_failed')
599
+ const error = (record: LooseRecord) => text(record.content.error)
600
+ const out_of_turns = failed
601
+ .filter((record) => error(record).includes('error_max_turns'))
602
+ .map((record) =>
603
+ note(
604
+ record,
605
+ /maximum number of turns \((\d+)\)/.exec(error(record))?.[1] ??
606
+ '?'
607
+ )
608
+ )
609
+ const no_session = failed
610
+ .filter((record) => error(record).startsWith('No open agent session'))
611
+ .map((record) => note(record, clip(error(record))))
612
+ const other_failures = failed
613
+ .filter(
614
+ (record) =>
615
+ !error(record).includes('error_max_turns') &&
616
+ !error(record).startsWith('No open agent session')
617
+ )
618
+ .map((record) =>
619
+ note(
620
+ record,
621
+ `${text(record.content.failure) || 'agent'}: ${clip(error(record))}`
622
+ )
623
+ )
624
+
625
+ // Jev calls that gave no answers.
626
+ const jev_failed = ofKind(records, 'jev_failed')
627
+
628
+ // What agents said in their results.
629
+ const finished = ofKind(records, 'agent_finished')
630
+ const results = finished.map((record) => ({
631
+ record,
632
+ result: objectOf(record.content.result),
633
+ }))
634
+ const setup_changes = results.flatMap(({ record, result }) => {
635
+ const setup = objectOf(result.setup_change)
636
+ return text(setup.file) === ''
637
+ ? []
638
+ : [note(record, `${text(setup.file)}: ${clip(text(setup.reason))}`)]
639
+ })
640
+ const assumptions = results.flatMap(({ record, result }) =>
641
+ listOf(result.assumptions).map((line) =>
642
+ note(record, clip(text(line), 200))
643
+ )
644
+ )
645
+ const wont_fix = results.flatMap(({ record, result }) =>
646
+ listOf(result.finding_responses)
647
+ .map(objectOf)
648
+ .filter(({ response }) => response === 'wont_fix')
649
+ .map((answer) =>
650
+ note(
651
+ record,
652
+ `${text(answer.finding_id)}: ${clip(text(answer.reason), 200)}`
653
+ )
654
+ )
655
+ )
656
+ const accepted = results.flatMap(({ record, result }) =>
657
+ listOf(result.rulings)
658
+ .map(objectOf)
659
+ .filter(({ ruling }) => ruling === 'accepted')
660
+ .map((ruling) =>
661
+ note(
662
+ record,
663
+ `${text(ruling.finding_id)}: ${clip(text(ruling.reason), 200)}`
664
+ )
665
+ )
666
+ )
667
+
668
+ // Joins and rebases.
669
+ const joins = ofKind(records, 'ticket_joined')
670
+ const join_failed = joins
671
+ .filter(({ content }) => content.ok === false)
672
+ .map(({ seq, ticket }) => ({ seq, ticket }))
673
+
674
+ // Usage: the run's record, and each ticket's latest.
675
+ const usage = ofKind(records, 'usage_recorded')
676
+ const run_usage = usage
677
+ .filter(({ content }) => content.scope === 'run')
678
+ .at(-1)
679
+ const by_ticket = new Map<number, number>()
680
+ for (const { content } of usage) {
681
+ const ticket = numberOr(content.ticket)
682
+ if (content.scope === 'ticket' && ticket !== null) {
683
+ by_ticket.set(ticket, countedTokens(objectOf(content.tokens)))
684
+ }
685
+ }
686
+
687
+ // Each time the engine started again, and how long it was down.
688
+ const engine_resumed = ofKind(records, 'engine_resumed')
689
+ const resumes = engine_resumed.map((record, index) => {
690
+ const before = records.filter(({ seq }) => seq < record.seq).at(-1)
691
+ const next = engine_resumed[index + 1]?.seq ?? Infinity
692
+ // The steps a crash cut off, found before this resume's first step.
693
+ const resumed = records.find(
694
+ ({ seq, kind }) =>
695
+ seq > record.seq && seq < next && kind === 'run_resumed'
696
+ )
697
+ return {
698
+ seq: record.seq,
699
+ version: text(record.content.luca_version),
700
+ down_ms:
701
+ before === undefined ? 0 : msBetween(before.time, record.time),
702
+ cut_off: listOf(resumed?.content.interrupted).length,
703
+ }
704
+ })
705
+
706
+ const stuck = stuckEvents({ records })
707
+ const gates = gateFailures({ records })
708
+ const red = ofKind(records, 'red_check')
709
+ const rebases = tally(
710
+ ofKind(records, 'ticket_rebased').map(({ seq, content }) => ({
711
+ key: text(content.cause),
712
+ seq,
713
+ }))
714
+ )
715
+
716
+ const summary: RunSummary = {
717
+ spec: {
718
+ number: numberOr(started.spec_number) ?? numberOr(spec.number),
719
+ title: text(spec.title),
720
+ url: text(spec.url),
721
+ },
722
+ repo: text(started.repo),
723
+ luca: [
724
+ text(started.luca_version) || '?',
725
+ ...ofKind(records, 'engine_resumed').map(({ content }) =>
726
+ text(content.luca_version)
727
+ ),
728
+ ].filter((version, index, all) => all.indexOf(version) === index),
729
+ started: records[0]?.time ?? '',
730
+ ended: records.at(-1)?.time ?? '',
731
+ last_seq: records.at(-1)?.seq ?? 0,
732
+ over: ending.over,
733
+ ending: ending.line,
734
+ tickets,
735
+ stuck,
736
+ leftovers: {
737
+ scans: scans.length,
738
+ by_path: tally(hits.map(({ path, seq }) => ({ key: path, seq }))),
739
+ reasons: tally(
740
+ hits.map(({ reason, seq }) => ({ key: reason, seq }))
741
+ ),
742
+ },
743
+ out_of_turns,
744
+ no_session,
745
+ other_failures,
746
+ jev: {
747
+ asked: ofKind(records, 'jev_asked').length,
748
+ failed: tally(
749
+ jev_failed.map(({ seq, content }) => ({
750
+ key: `${text(content.reason)}: ${clip(text(content.error), 120)}`,
751
+ seq,
752
+ }))
753
+ ),
754
+ jobs: tally(
755
+ jev_failed.map(({ seq, content }) => ({
756
+ key: text(content.job),
757
+ seq,
758
+ }))
759
+ ),
760
+ },
761
+ gates,
762
+ red_checks: {
763
+ runs: red.length,
764
+ failed: red.filter(({ content }) => content.ok === false).length,
765
+ },
766
+ setup_changes,
767
+ joins: {
768
+ ok: joins.length - join_failed.length,
769
+ failed: join_failed,
770
+ rebases,
771
+ undone: ofKind(records, 'join_undone').map(
772
+ ({ seq, ticket, content }) => ({
773
+ seq,
774
+ ticket,
775
+ shas: listOf(content.shas).length,
776
+ })
777
+ ),
778
+ tests_sent_back: ofKind(records, 'tests_sent_back').map(
779
+ (record) => {
780
+ const bad = objectOf(record.content.bad_test)
781
+ return note(
782
+ record,
783
+ `round ${String(record.content.round)}: ${text(bad.file)} > ${clip(text(bad.name), 160)}`,
784
+ 'implementer'
785
+ )
786
+ }
787
+ ),
788
+ },
789
+ replies: {
790
+ words: tally(
791
+ ofKind(records, 'reply_received').map(({ seq, content }) => ({
792
+ key: text(content.word),
793
+ seq,
794
+ }))
795
+ ),
796
+ ignored: tally(
797
+ ofKind(records, 'reply_ignored').map(({ seq, content }) => ({
798
+ key: text(content.reason),
799
+ seq,
800
+ }))
801
+ ),
802
+ },
803
+ assumptions: {
804
+ total: assumptions.length,
805
+ by_role: tally(
806
+ assumptions.map(({ role, seq }) => ({ key: role, seq }))
807
+ ),
808
+ by_ticket: tally(
809
+ assumptions.map(({ ticket, seq }) => ({
810
+ key: ticketText(ticket),
811
+ seq,
812
+ }))
813
+ ),
814
+ notes: assumptions,
815
+ },
816
+ declined: { wont_fix, accepted },
817
+ steps: stepTimes({ records }),
818
+ usage: {
819
+ run:
820
+ run_usage === undefined
821
+ ? null
822
+ : objectOf(run_usage.content.tokens),
823
+ agent_turns:
824
+ run_usage === undefined
825
+ ? null
826
+ : numberOr(run_usage.content.agent_turns),
827
+ by_ticket: [...by_ticket]
828
+ .map(([ticket, counted]) => ({ ticket, counted }))
829
+ .toSorted((a, b) => b.counted - a.counted),
830
+ by_role: roleUse({ records }),
831
+ },
832
+ resumes,
833
+ config_reloaded: ofKind(records, 'config_reloaded').flatMap(
834
+ ({ content }) =>
835
+ listOf(content.changes).map((change) =>
836
+ text(objectOf(change).field)
837
+ )
838
+ ),
839
+ shared_git_changed: ofKind(records, 'shared_git_changed').length,
840
+ signals: [],
841
+ }
842
+ summary.signals = signalsOf({ summary })
843
+ return summary
844
+ }
845
+
846
+ /**
847
+ * The patterns of one run, keyed so the same pattern in another run has
848
+ * the same key: stuck reasons, leftover reasons and folders, failure kinds,
849
+ * failing checks and tests, and the like. Pure.
850
+ */
851
+ const signalsOf = ({ summary }: { summary: RunSummary }): Signal[] => {
852
+ const signals = new Map<string, number>()
853
+ const add = (key: string, count = 1) => {
854
+ if (count > 0) signals.set(key, (signals.get(key) ?? 0) + count)
855
+ }
856
+ for (const event of summary.stuck) {
857
+ add(`stuck: ${event.reason}`)
858
+ if (event.cap !== null) add(`fix loop at its cap (${event.cap})`)
859
+ }
860
+ for (const { key, count } of summary.leftovers.reasons) {
861
+ add(`leftover scan: ${key}`, count)
862
+ }
863
+ for (const { key, count } of summary.leftovers.by_path) {
864
+ add(`leftover in folder: ${folderOf(key)}/`, count)
865
+ }
866
+ for (const { role, text: max } of summary.out_of_turns) {
867
+ add(`out of turns: ${role} (max ${max})`)
868
+ }
869
+ for (const { role } of summary.no_session) {
870
+ add(`No open agent session: ${role}`)
871
+ }
872
+ for (const { key, count } of summary.jev.failed) {
873
+ add(`jev_failed ${key}`, count)
874
+ }
875
+ for (const { key, count } of summary.gates.by_check) {
876
+ add(`failed check: ${key}`, count)
877
+ }
878
+ for (const { key, count } of summary.gates.by_test) {
879
+ add(`failed test: ${key}`, count)
880
+ }
881
+ add('setup change asked for', summary.setup_changes.length)
882
+ add('join failed (clash)', summary.joins.failed.length)
883
+ add('join undone', summary.joins.undone.length)
884
+ add('tests sent back after a rebase', summary.joins.tests_sent_back.length)
885
+ for (const { key, count } of summary.replies.ignored) {
886
+ add(`reply ignored: ${key}`, count)
887
+ }
888
+ add('fixer declined a finding (wont_fix)', summary.declined.wont_fix.length)
889
+ add('shared .git changed during a turn', summary.shared_git_changed)
890
+ return [...signals].map(([key, count]) => ({ key, count }))
891
+ }
892
+
893
+ /** `#12 "Its title"` for a ticket, or `run` for none. */
894
+ const titledTicket = (summary: RunSummary, ticket: number | null): string => {
895
+ const title = ticket === null ? undefined : summary.tickets.get(ticket)
896
+ return title === undefined || title === ''
897
+ ? ticketText(ticket)
898
+ : `${ticketText(ticket)} "${clip(title, 80)}"`
899
+ }
900
+
901
+ /** A list of agent notes, one per line, capped. */
902
+ const noteLines = (notes: AgentNote[]): string[] =>
903
+ capped(
904
+ notes.map(
905
+ ({ seq, ticket, role, text: body }) =>
906
+ ` ${seqText(seq)} ${ticketText(ticket)} ${role}: ${body}`
907
+ )
908
+ )
909
+
910
+ /** A tally as `key ×n (seq 1, seq 2)` lines, capped. */
911
+ const tallyLines = (groups: Tally[], max = LIST_MAX): string[] =>
912
+ capped(
913
+ groups.map(
914
+ ({ key, count, seqs }) => ` ${key} ×${count} (${seqsText(seqs)})`
915
+ ),
916
+ max
917
+ )
918
+
919
+ /**
920
+ * One run's summary as plain text, with seq numbers (`seq 123`) for evidence.
921
+ * Sections with nothing in them are left out. Pure.
922
+ */
923
+ export const formatRunSummary = ({
924
+ run_id,
925
+ summary: s,
926
+ }: {
927
+ run_id: string
928
+ summary: RunSummary
929
+ }): string => {
930
+ const gh_repo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\//.exec(
931
+ s.spec.url
932
+ )?.[1]
933
+ const lines: string[] = [
934
+ `== Run ${run_id}: spec #${s.spec.number ?? '?'}${s.spec.title === '' ? '' : ` "${s.spec.title}"`}`,
935
+ `Repo: ${s.repo || '?'}${gh_repo === undefined ? '' : ` (${gh_repo})`}`,
936
+ `Luca: ${s.luca.join(' -> ')}`,
937
+ `Time: ${s.started} to ${s.ended} (${durationText(msBetween(s.started, s.ended))}), last record ${seqText(s.last_seq)}`,
938
+ `Ended: ${s.ending}`,
939
+ `Tickets: ${s.tickets.size}`,
940
+ ]
941
+ for (const resume of s.resumes) {
942
+ lines.push(
943
+ `Engine resumed (${seqText(resume.seq)}) on ${resume.version} after ${durationText(resume.down_ms)} down${resume.cut_off === 0 ? '' : `, ${resume.cut_off} steps cut off`}`
944
+ )
945
+ }
946
+ if (s.config_reloaded.length > 0) {
947
+ lines.push(`Config reloaded on resume: ${s.config_reloaded.join(', ')}`)
948
+ }
949
+
950
+ const section = (title: string, body: string[]) => {
951
+ if (body.length > 0) lines.push('', title, ...body)
952
+ }
953
+
954
+ const waited = s.stuck.reduce(
955
+ (sum, { reply }) => sum + (reply?.wait_ms ?? 0),
956
+ 0
957
+ )
958
+ section(
959
+ `Stuck (${s.stuck.length}; the owner took ${durationText(waited)} in all to reply):`,
960
+ s.stuck.map(
961
+ (event) =>
962
+ ` ${seqText(event.seq)} ${event.kind} ${titledTicket(s, event.ticket)}: ${event.reason}${event.reply === null ? ', no reply' : ` -> ${event.reply.word} (${seqText(event.reply.seq)}, after ${durationText(event.reply.wait_ms)})`}\n ${clip(event.detail)}`
963
+ )
964
+ )
965
+ section(
966
+ 'Fix loops that hit their cap:',
967
+ s.stuck
968
+ .filter(({ cap }) => cap !== null)
969
+ .map(
970
+ ({ seq, ticket, reason, cap }) =>
971
+ ` ${seqText(seq)} ${ticketText(ticket)} ${reason} after ${cap}`
972
+ )
973
+ )
974
+ section(`Leftover scan blocked a commit (${s.leftovers.scans} scans):`, [
975
+ ...s.leftovers.reasons.map(
976
+ ({ key, count }) => ` reason: ${key} ×${count}`
977
+ ),
978
+ ...tallyLines(s.leftovers.by_path),
979
+ ])
980
+ section(
981
+ `Agents out of turns (error_max_turns, ${s.out_of_turns.length}):`,
982
+ noteLines(
983
+ s.out_of_turns.map((note) => ({
984
+ ...note,
985
+ text: `max ${note.text} turns`,
986
+ }))
987
+ )
988
+ )
989
+ section(
990
+ `Agent turns lost to "No open agent session" (${s.no_session.length}):`,
991
+ tallyLines(
992
+ tally(s.no_session.map(({ role, seq }) => ({ key: role, seq })))
993
+ )
994
+ )
995
+ section(
996
+ `Other failed agent turns (${s.other_failures.length}):`,
997
+ noteLines(s.other_failures)
998
+ )
999
+ if (s.jev.failed.length > 0) {
1000
+ section(
1001
+ `jev_failed: ${s.jev.failed.reduce((sum, { count }) => sum + count, 0)} of ${s.jev.asked} Jev calls gave no answer:`,
1002
+ [
1003
+ ...s.jev.failed.map(({ key, count }) => ` ${key} ×${count}`),
1004
+ ` by job: ${s.jev.jobs.map(({ key, count }) => `${key} ×${count}`).join(', ')}`,
1005
+ ]
1006
+ )
1007
+ }
1008
+ if (s.gates.failed > 0) {
1009
+ section(
1010
+ `Failed gates: ${s.gates.failed} of ${s.gates.runs} gates_run (red checks failed: ${s.red_checks.failed} of ${s.red_checks.runs}):`,
1011
+ [
1012
+ ...tallyLines(s.gates.by_check),
1013
+ ...(s.gates.by_test.length === 0
1014
+ ? []
1015
+ : [
1016
+ ' tests that failed most:',
1017
+ ...tallyLines(s.gates.by_test).map(
1018
+ (line) => ` ${line}`
1019
+ ),
1020
+ ]),
1021
+ ]
1022
+ )
1023
+ }
1024
+ section(
1025
+ `Setup changes agents asked for (${s.setup_changes.length}):`,
1026
+ noteLines(s.setup_changes)
1027
+ )
1028
+ const j = s.joins
1029
+ if (
1030
+ j.failed.length +
1031
+ j.undone.length +
1032
+ j.rebases.length +
1033
+ j.tests_sent_back.length >
1034
+ 0
1035
+ ) {
1036
+ section('Joins:', [
1037
+ ` ${j.ok} joined, ${j.failed.length} clashed (${seqsText(
1038
+ j.failed.map(({ seq }) => seq),
1039
+ 10
1040
+ )})`,
1041
+ ...j.rebases.map(
1042
+ ({ key, count, seqs }) =>
1043
+ ` rebased after ${key} ×${count} (${seqsText(seqs, 10)})`
1044
+ ),
1045
+ ...j.undone.map(
1046
+ ({ seq, ticket, shas }) =>
1047
+ ` ${seqText(seq)} join_undone ${ticketText(ticket)}: ${shas} commits taken off the run branch`
1048
+ ),
1049
+ ...noteLines(j.tests_sent_back).map((line) =>
1050
+ line.replace(/^ {2}/, ' tests_sent_back ')
1051
+ ),
1052
+ ])
1053
+ }
1054
+ section('Replies:', [
1055
+ ...(s.replies.words.length === 0
1056
+ ? []
1057
+ : [
1058
+ ` ${s.replies.words.map(({ key, count }) => `${key} ×${count}`).join(', ')}`,
1059
+ ]),
1060
+ ...s.replies.ignored.map(
1061
+ ({ key, count, seqs }) =>
1062
+ ` reply_ignored ${key} ×${count} (${seqsText(seqs)})`
1063
+ ),
1064
+ ])
1065
+ section(
1066
+ `Assumptions agents made (${s.assumptions.total}; ${s.assumptions.by_role.map(({ key, count }) => `${key} ${count}`).join(', ')}):`,
1067
+ s.assumptions.total === 0
1068
+ ? []
1069
+ : [
1070
+ ` by ticket: ${s.assumptions.by_ticket.map(({ key, count }) => `${key} ${count}`).join(', ')}`,
1071
+ ' the first of each ticket:',
1072
+ ...noteLines(
1073
+ s.assumptions.notes.filter(
1074
+ (note, index, all) =>
1075
+ all.findIndex(
1076
+ ({ ticket }) => ticket === note.ticket
1077
+ ) === index
1078
+ )
1079
+ ).map((line) => ` ${line}`),
1080
+ ` all of them: jq -c 'select(.kind == "agent_finished") | {seq, ticket, role: .content.role, assumptions: .content.result.assumptions}' <journal>`,
1081
+ ]
1082
+ )
1083
+ section(
1084
+ `Findings fixers declined (wont_fix, ${s.declined.wont_fix.length}) and rulings that accepted a decline (${s.declined.accepted.length}):`,
1085
+ [
1086
+ ...noteLines(s.declined.wont_fix),
1087
+ ...noteLines(s.declined.accepted).map((line) =>
1088
+ line.replace(/^ {2}/, ' accepted ')
1089
+ ),
1090
+ ]
1091
+ )
1092
+ section(
1093
+ `Slow steps (top ${SLOW_STEPS_MAX} by total time; count, total, mean, max):`,
1094
+ s.steps
1095
+ .slice(0, SLOW_STEPS_MAX)
1096
+ .map(
1097
+ (step) =>
1098
+ ` ${step.step}: ${step.count}, ${durationText(step.total_ms)}, ${durationText(step.total_ms / step.count)}, ${durationText(step.max_ms)} (${seqText(step.max_seq)} ${ticketText(step.max_ticket)})`
1099
+ )
1100
+ )
1101
+ const run = s.usage.run
1102
+ section(
1103
+ 'Tokens (counted as the run budget counts them: input, output, cache writes):',
1104
+ [
1105
+ ...(run === null
1106
+ ? []
1107
+ : [
1108
+ ` run: ${tokensText(countedTokens(run))} counted, ${tokensText(numberOr(run.cache_read_input_tokens) ?? 0)} cache reads, ${s.usage.agent_turns ?? '?'} agent turns`,
1109
+ ]),
1110
+ ...s.usage.by_role.map(
1111
+ (use) =>
1112
+ ` ${use.role}: ${use.sessions} sessions, ${tokensText(use.counted)}, ${use.turns} turns (max ${use.max_turns}), ${durationText(use.duration_ms)}`
1113
+ ),
1114
+ ...(s.usage.by_ticket.length === 0
1115
+ ? []
1116
+ : [
1117
+ ` costliest tickets: ${s.usage.by_ticket
1118
+ .slice(0, 5)
1119
+ .map(
1120
+ ({ ticket, counted }) =>
1121
+ `#${ticket} ${tokensText(counted)}`
1122
+ )
1123
+ .join(', ')}`,
1124
+ ]),
1125
+ ]
1126
+ )
1127
+ if (s.shared_git_changed > 0) {
1128
+ section('Other:', [
1129
+ ` shared_git_changed ×${s.shared_git_changed}: something changed the shared .git during an agent's turn`,
1130
+ ])
1131
+ }
1132
+ return lines.join('\n')
1133
+ }
1134
+
1135
+ /** A pattern seen in more than one run: its count in each. */
1136
+ export type Repeat = { key: string; runs: { run_id: string; count: number }[] }
1137
+
1138
+ /** The patterns that show up in two runs or more, most runs first. Pure. */
1139
+ export const repeatsAcross = ({
1140
+ runs,
1141
+ }: {
1142
+ runs: { run_id: string; summary: RunSummary }[]
1143
+ }): Repeat[] => {
1144
+ const seen = new Map<string, Repeat>()
1145
+ for (const { run_id, summary } of runs) {
1146
+ for (const { key, count } of summary.signals) {
1147
+ const repeat = seen.get(key) ?? { key, runs: [] }
1148
+ repeat.runs.push({ run_id, count })
1149
+ seen.set(key, repeat)
1150
+ }
1151
+ }
1152
+ const total = (repeat: Repeat) =>
1153
+ repeat.runs.reduce((sum, { count }) => sum + count, 0)
1154
+ return [...seen.values()]
1155
+ .filter((repeat) => repeat.runs.length > 1)
1156
+ .toSorted(
1157
+ (a, b) => b.runs.length - a.runs.length || total(b) - total(a)
1158
+ )
1159
+ }
1160
+
1161
+ /** The repeats as plain text. Pure. */
1162
+ export const formatRepeats = ({ repeats }: { repeats: Repeat[] }): string =>
1163
+ [
1164
+ '== Repeats across runs',
1165
+ ...(repeats.length === 0
1166
+ ? [' none: no pattern shows up in more than one run']
1167
+ : capped(
1168
+ repeats.map(
1169
+ ({ key, runs }) =>
1170
+ ` ${key}: ${runs.map(({ run_id, count }) => `${run_id} ×${count}`).join(', ')}`
1171
+ ),
1172
+ 20
1173
+ )),
1174
+ ].join('\n')
1175
+
1176
+ /** One run of a repo, for picking the newest finished one. */
1177
+ export type RunChoice = { run_id: string; started: string; over: boolean }
1178
+
1179
+ /**
1180
+ * The runs of any of `repos` in `runs_dir`, newest first. A run's repo is
1181
+ * its `run_started` repo.
1182
+ */
1183
+ export const repoRuns = ({
1184
+ runs_dir,
1185
+ repos,
1186
+ }: {
1187
+ runs_dir: string
1188
+ repos: string[]
1189
+ }): RunChoice[] => {
1190
+ if (!existsSync(runs_dir)) return []
1191
+ const want = new Set(repos.map(realPath))
1192
+ return readdirSync(runs_dir)
1193
+ .map((run_id) => ({
1194
+ run_id,
1195
+ records: readRecords({
1196
+ file: join(runs_dir, run_id, 'journal.jsonl'),
1197
+ }),
1198
+ }))
1199
+ .filter(({ records }) => {
1200
+ const started = records.find((r) => r.kind === 'run_started')
1201
+ return (
1202
+ started !== undefined &&
1203
+ want.has(realPath(text(started.content.repo) || '/'))
1204
+ )
1205
+ })
1206
+ .map(({ run_id, records }) => ({
1207
+ run_id,
1208
+ started: records[0]?.time ?? '',
1209
+ over: isFinished({ records }),
1210
+ }))
1211
+ .toSorted((a, b) => b.started.localeCompare(a.started))
1212
+ }
1213
+
1214
+ /**
1215
+ * The newest finished run of `runs` (newest first, as `repoRuns` gives
1216
+ * them), or `null` when none is over. Pure.
1217
+ */
1218
+ export const newestFinished = ({
1219
+ runs,
1220
+ }: {
1221
+ runs: RunChoice[]
1222
+ }): RunChoice | null => runs.find(({ over }) => over) ?? null
1223
+
1224
+ /** The flags, read. */
1225
+ export type RetroArgs = {
1226
+ run_ids: string[]
1227
+ repo: string | null
1228
+ runs_dir: string
1229
+ }
1230
+
1231
+ /** Reads the flags; `env` gives the default runs folder. Pure. */
1232
+ export const parseArgs = ({
1233
+ argv,
1234
+ env,
1235
+ home,
1236
+ }: {
1237
+ argv: string[]
1238
+ env: Record<string, string | undefined>
1239
+ home: string
1240
+ }): RetroArgs => {
1241
+ const args: RetroArgs = {
1242
+ run_ids: [],
1243
+ repo: null,
1244
+ runs_dir:
1245
+ env.LUCA_RUNS_DIR ?? join(home, '.local', 'state', 'luca', 'runs'),
1246
+ }
1247
+ for (let i = 0; i < argv.length; i += 1) {
1248
+ const arg = argv[i] ?? ''
1249
+ const value = argv[i + 1]
1250
+ if (arg === '--repo' && value !== undefined) {
1251
+ args.repo = value
1252
+ i += 1
1253
+ } else if (arg === '--runs-dir' && value !== undefined) {
1254
+ args.runs_dir = value
1255
+ i += 1
1256
+ } else if (arg !== '' && !arg.startsWith('-')) {
1257
+ args.run_ids.push(arg)
1258
+ }
1259
+ }
1260
+ return args
1261
+ }
1262
+
1263
+ /**
1264
+ * A path with its symlinks followed (`/tmp` is `/private/tmp` on macOS), or
1265
+ * just made absolute when it doesn't exist.
1266
+ */
1267
+ const realPath = (path: string): string => {
1268
+ try {
1269
+ return realpathSync(path)
1270
+ } catch {
1271
+ return resolve(path)
1272
+ }
1273
+ }
1274
+
1275
+ /**
1276
+ * The repos a run started from `cwd` may name, real paths, main first: the
1277
+ * main checkout (a run's repo is the main checkout), then the git top folder
1278
+ * (a worktree's own, or a subfolder's repo). Outside git, `cwd` itself. A
1279
+ * bare repo or a submodule has no `<checkout>/.git` common dir, so it adds
1280
+ * no main checkout, only its top folder.
1281
+ *
1282
+ * Its twin is `currentRepos` in `luca-unstick/scripts/stuck-summary.ts`: each skill folder is installed
1283
+ * to `~/.claude/skills/` on its own, so they can't share a file. Keep the two
1284
+ * the same; a test in each pins it.
1285
+ *
1286
+ * @example
1287
+ * currentRepos({ cwd: '/code/app-worktree' }) // ['/code/app', '/code/app-worktree']
1288
+ */
1289
+ export const currentRepos = ({ cwd }: { cwd: string }): string[] => {
1290
+ const git = (...args: string[]) => {
1291
+ const out = Bun.spawnSync(['git', '-C', cwd, ...args])
1292
+ const said = out.stdout.toString().trim()
1293
+ return out.exitCode === 0 && said !== '' ? said : null
1294
+ }
1295
+ const top = git('rev-parse', '--show-toplevel')
1296
+ const common = git(
1297
+ 'rev-parse',
1298
+ '--path-format=absolute',
1299
+ '--git-common-dir'
1300
+ )
1301
+ const main =
1302
+ common !== null && basename(common) === '.git' ? dirname(common) : null
1303
+ return [main, top ?? cwd]
1304
+ .filter((repo) => repo !== null)
1305
+ .map(realPath)
1306
+ .filter((repo, index, all) => all.indexOf(repo) === index)
1307
+ }
1308
+
1309
+ const main = () => {
1310
+ const args = parseArgs({
1311
+ argv: Bun.argv.slice(2),
1312
+ env: process.env,
1313
+ home: homedir(),
1314
+ })
1315
+ let run_ids = args.run_ids
1316
+ if (run_ids.length === 0) {
1317
+ const repos =
1318
+ args.repo === null
1319
+ ? currentRepos({ cwd: process.cwd() })
1320
+ : [args.repo]
1321
+ const runs = repoRuns({ runs_dir: args.runs_dir, repos })
1322
+ const pick = newestFinished({ runs })
1323
+ if (pick === null) {
1324
+ console.log(
1325
+ runs.length === 0
1326
+ ? `No Luca runs of ${repos.join(' or ')} in ${args.runs_dir}.`
1327
+ : `No finished Luca runs of ${repos.join(' or ')} in ${args.runs_dir} (${runs.length} not over yet: ${runs.map(({ run_id }) => run_id).join(', ')}).`
1328
+ )
1329
+ return
1330
+ }
1331
+ console.log(`The newest finished run of ${repos[0]}: ${pick.run_id}\n`)
1332
+ run_ids = [pick.run_id]
1333
+ }
1334
+ const runs = run_ids.map((run_id) => ({
1335
+ run_id,
1336
+ records: readRecords({
1337
+ file: join(args.runs_dir, run_id, 'journal.jsonl'),
1338
+ }),
1339
+ }))
1340
+ const summed = runs.flatMap(({ run_id, records }) => {
1341
+ if (records.length === 0) {
1342
+ console.log(
1343
+ `== Run ${run_id}: no journal at ${join(args.runs_dir, run_id, 'journal.jsonl')}\n`
1344
+ )
1345
+ return []
1346
+ }
1347
+ const summary = summarizeJournal({ records })
1348
+ console.log(`${formatRunSummary({ run_id, summary })}\n`)
1349
+ return [{ run_id, summary }]
1350
+ })
1351
+ if (summed.length > 1) {
1352
+ console.log(formatRepeats({ repeats: repeatsAcross({ runs: summed }) }))
1353
+ }
1354
+ }
1355
+
1356
+ if (import.meta.main) main()