@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,1063 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Prints what is stuck in a Luca run, and why, from its journal, for the
4
+ * `/luca-unstick` skill:
5
+ *
6
+ * bun stuck-summary.ts [run id] [#ticket] [--repo <path>] [--runs-dir <dir>] [--registry <file>]
7
+ *
8
+ * With no run id, it lists the runs of the repo (`--repo`, else the git repo
9
+ * of the current folder), newest first, and sums up the newest one that
10
+ * needs you. With `#ticket`, it sums up only that ticket (and the run).
11
+ *
12
+ * It only reads: the journal (`<runs folder>/<run id>/journal.jsonl`), the
13
+ * board's run registry (never its tokens), the engine's log, and `ps`. It
14
+ * changes nothing.
15
+ *
16
+ * It imports nothing but Bun's and Node's own modules: it runs from
17
+ * `~/.claude/skills/luca-unstick/`, where no packages are installed. So it
18
+ * reads records loosely, not with the engine's Zod schemas.
19
+ * `stuck-summary.test.ts` checks it against journals built with the
20
+ * engine's own fixtures, and every kind in `KINDS_READ` against the
21
+ * journal's kinds, so the two can't drift apart unseen.
22
+ */
23
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { basename, dirname, join, resolve } from 'node:path'
26
+
27
+ /** Every journal record kind this script reads. */
28
+ export const KINDS_READ = [
29
+ 'run_started',
30
+ 'engine_resumed',
31
+ 'intake_read',
32
+ 'spec_snapshot',
33
+ 'ticket_snapshot',
34
+ 'intake_refused',
35
+ 'nothing_to_do',
36
+ 'run_branch_created',
37
+ 'ticket_worktree_created',
38
+ 'baseline_prepared',
39
+ 'agent_finished',
40
+ 'agent_failed',
41
+ 'red_check',
42
+ 'already_done_checked',
43
+ 'leftover_scan',
44
+ 'gates_run',
45
+ 'ticket_rebased',
46
+ 'tests_sent_back',
47
+ 'ticket_stuck',
48
+ 'run_stuck',
49
+ 'final_review_stuck',
50
+ 'stuck_reported',
51
+ 'comment_read',
52
+ 'reply_received',
53
+ 'reply_ignored',
54
+ 'ticket_retried',
55
+ 'ticket_skipped',
56
+ 'final_review_retried',
57
+ 'final_review_shipped',
58
+ 'run_stopped',
59
+ 'pull_request_opened',
60
+ 'limit_wait_started',
61
+ 'limit_wait_ended',
62
+ 'step_started',
63
+ 'step_ended',
64
+ ] as const
65
+
66
+ type KindRead = (typeof KINDS_READ)[number]
67
+
68
+ /** One journal line, read loosely. */
69
+ export type LooseRecord = {
70
+ seq: number
71
+ time: string
72
+ kind: string
73
+ ticket: number | null
74
+ content: Record<string, unknown>
75
+ }
76
+
77
+ /** Longest text quoted from a record, such as a stuck detail. */
78
+ const DETAIL_MAX = 2000
79
+
80
+ /** Longest command output quoted: its end, where the error usually is. */
81
+ const OUTPUT_MAX = 1500
82
+
83
+ /** Engine log lines shown for a crash. */
84
+ const LOG_LINES = 25
85
+
86
+ const isObject = (value: unknown): value is Record<string, unknown> =>
87
+ typeof value === 'object' && value !== null && !Array.isArray(value)
88
+
89
+ const text = (value: unknown): string =>
90
+ typeof value === 'string' ? value : ''
91
+
92
+ const numberOr = (value: unknown): number | null =>
93
+ typeof value === 'number' ? value : null
94
+
95
+ const listOf = (value: unknown): unknown[] =>
96
+ Array.isArray(value) ? value : []
97
+
98
+ const objectOf = (value: unknown): Record<string, unknown> =>
99
+ isObject(value) ? value : {}
100
+
101
+ /** The first `max` characters, marked when cut. */
102
+ const clip = (value: string, max = DETAIL_MAX): string =>
103
+ value.length > max ? `${value.slice(0, max)} ... (clipped)` : value
104
+
105
+ /** The last `max` characters, marked when cut. */
106
+ const clipEnd = (value: string, max = OUTPUT_MAX): string =>
107
+ value.length > max ? `(clipped) ... ${value.slice(-max)}` : value
108
+
109
+ /** A journal record's seq as `seq 123`, never `#123`, which GitHub links. */
110
+ const seqText = (seq: number): string => `seq ${seq}`
111
+
112
+ /** Each line of each entry, after `by`. */
113
+ const nest = (entries: string[], by: string): string[] =>
114
+ entries.flatMap((entry) => entry.split('\n')).map((line) => `${by}${line}`)
115
+
116
+ const indent = (value: string, by = ' '): string =>
117
+ value
118
+ .split('\n')
119
+ .map((line) => `${by}${line}`)
120
+ .join('\n')
121
+
122
+ /** One journal line as a loose record, or `null` when it isn't one. */
123
+ const toRecord = (line: string): LooseRecord | null => {
124
+ let json: unknown
125
+ try {
126
+ json = JSON.parse(line)
127
+ } catch {
128
+ return null
129
+ }
130
+ if (!isObject(json)) return null
131
+ const seq = numberOr(json.seq)
132
+ if (seq === null || typeof json.kind !== 'string') return null
133
+ return {
134
+ seq,
135
+ time: text(json.time),
136
+ kind: json.kind,
137
+ ticket: numberOr(json.ticket),
138
+ content: objectOf(json.content),
139
+ }
140
+ }
141
+
142
+ /**
143
+ * A run's records, oldest first. Lines that aren't records (such as a
144
+ * half-written last line) are skipped. No journal gives none.
145
+ */
146
+ export const readRecords = ({ file }: { file: string }): LooseRecord[] =>
147
+ existsSync(file)
148
+ ? readFileSync(file, 'utf8')
149
+ .split('\n')
150
+ .filter((line) => line.trim() !== '')
151
+ .map(toRecord)
152
+ .filter((record): record is LooseRecord => record !== null)
153
+ : []
154
+
155
+ const ofKind = (records: LooseRecord[], kind: KindRead): LooseRecord[] =>
156
+ records.filter((record) => record.kind === kind)
157
+
158
+ const lastOf = (
159
+ records: LooseRecord[],
160
+ kind: KindRead,
161
+ where: (record: LooseRecord) => boolean = () => true
162
+ ): LooseRecord | undefined => ofKind(records, kind).filter(where).at(-1)
163
+
164
+ /** Something that waits on the spec owner's reply. */
165
+ export type StuckItem = {
166
+ /** The stuck ticket, or `null` for the final review or the whole run. */
167
+ ticket: number | null
168
+ what: 'ticket' | 'final_review' | 'run'
169
+ /** The record that made it stuck, such as `ticket_stuck`. */
170
+ kind: string
171
+ reason: string
172
+ detail: string
173
+ seq: number
174
+ /** A `retry` the engine refused (the edited ticket isn't ready). */
175
+ refused: string[]
176
+ }
177
+
178
+ /**
179
+ * What waits on the owner now: stuck tickets not yet retried or skipped,
180
+ * a stuck final review not yet retried or shipped, and the run stuck on its
181
+ * budget until a bare `retry`. Pure.
182
+ */
183
+ export const openStuck = ({
184
+ records,
185
+ }: {
186
+ records: LooseRecord[]
187
+ }): StuckItem[] => {
188
+ const tickets = new Map<number, StuckItem>()
189
+ let final: StuckItem | null = null
190
+ let run: StuckItem | null = null
191
+ for (const record of records) {
192
+ const { kind, ticket, content, seq } = record
193
+ const item = (what: StuckItem['what']): StuckItem => ({
194
+ ticket,
195
+ what,
196
+ kind,
197
+ reason: text(content.reason),
198
+ detail: text(content.detail),
199
+ seq,
200
+ refused: [],
201
+ })
202
+ if (kind === 'ticket_stuck' && ticket !== null) {
203
+ tickets.set(ticket, item('ticket'))
204
+ } else if (kind === 'ticket_retried' && ticket !== null) {
205
+ const stuck = tickets.get(ticket)
206
+ if (content.mode === 'refused' && stuck !== undefined) {
207
+ stuck.refused = listOf(content.problems).map(text)
208
+ } else {
209
+ tickets.delete(ticket)
210
+ }
211
+ } else if (kind === 'ticket_skipped' && ticket !== null) {
212
+ tickets.delete(ticket)
213
+ } else if (kind === 'final_review_stuck') {
214
+ final = item('final_review')
215
+ } else if (
216
+ kind === 'final_review_retried' ||
217
+ kind === 'final_review_shipped'
218
+ ) {
219
+ final = null
220
+ } else if (kind === 'run_stuck') {
221
+ run = item('run')
222
+ } else if (
223
+ kind === 'reply_received' &&
224
+ content.word === 'retry' &&
225
+ content.ticket === null &&
226
+ run !== null
227
+ ) {
228
+ run = null
229
+ }
230
+ }
231
+ return [
232
+ ...(run === null ? [] : [run]),
233
+ ...[...tickets.values()],
234
+ ...(final === null ? [] : [final]),
235
+ ]
236
+ }
237
+
238
+ /** How the run stands, from its last records. Pure. */
239
+ export const runState = ({
240
+ records,
241
+ }: {
242
+ records: LooseRecord[]
243
+ }): { over: boolean; line: string } => {
244
+ const pr = lastOf(records, 'pull_request_opened')
245
+ if (pr !== undefined) {
246
+ return { over: true, line: `It opened its PR: ${text(pr.content.url)}` }
247
+ }
248
+ const refused = lastOf(records, 'intake_refused')
249
+ if (refused !== undefined) {
250
+ const problems = listOf(refused.content.problems)
251
+ .map(objectOf)
252
+ .map(
253
+ (problem) =>
254
+ `${problem.ticket === null ? 'the run' : `#${String(problem.ticket)}`} misses ${listOf(problem.missing).map(text).join(', ')}`
255
+ )
256
+ return {
257
+ over: true,
258
+ line: `Intake refused it: ${problems.join('; ') || 'see the spec issue'}.`,
259
+ }
260
+ }
261
+ if (lastOf(records, 'nothing_to_do') !== undefined) {
262
+ return { over: true, line: 'It ended with nothing to do.' }
263
+ }
264
+ const stopped = lastOf(records, 'run_stopped')
265
+ const resumed = lastOf(records, 'engine_resumed')
266
+ if (stopped !== undefined) {
267
+ const reason = text(stopped.content.reason)
268
+ if (stopped.content.billing === true) {
269
+ return {
270
+ over: true,
271
+ line: `It stopped for good on a billing sign: ${reason}`,
272
+ }
273
+ }
274
+ if (stopped.content.crashed === true) {
275
+ return {
276
+ over: true,
277
+ line: `It stopped for good: the same step crashed again and again. ${reason}`,
278
+ }
279
+ }
280
+ if (resumed === undefined || resumed.seq < stopped.seq) {
281
+ return {
282
+ over: false,
283
+ line: `The launcher stopped it: ${reason}`,
284
+ }
285
+ }
286
+ }
287
+ const stop = lastOf(
288
+ records,
289
+ 'reply_received',
290
+ ({ content }) => content.word === 'stop'
291
+ )
292
+ if (stop !== undefined) {
293
+ return {
294
+ over: true,
295
+ line: `The owner replied stop (${text(stop.content.author)}), so it ends without a PR.`,
296
+ }
297
+ }
298
+ const wait = lastOf(records, 'limit_wait_started')
299
+ const waited = lastOf(records, 'limit_wait_ended')
300
+ if (wait !== undefined && (waited === undefined || waited.seq < wait.seq)) {
301
+ return {
302
+ over: false,
303
+ line: `It waits for a plan limit until ${text(wait.content.until)}. That is not stuck: it goes on by itself.`,
304
+ }
305
+ }
306
+ return { over: false, line: 'It is not over.' }
307
+ }
308
+
309
+ /** The steps a crash cut off: started, never ended. Pure. */
310
+ export const cutOffSteps = ({
311
+ records,
312
+ }: {
313
+ records: LooseRecord[]
314
+ }): { key: string; step: string; seq: number }[] => {
315
+ const open = new Map<string, { key: string; step: string; seq: number }>()
316
+ for (const { kind, content, seq } of records) {
317
+ const key = text(content.key)
318
+ if (kind === 'step_started') {
319
+ open.set(key, { key, step: text(content.step), seq })
320
+ } else if (kind === 'step_ended') {
321
+ open.delete(key)
322
+ }
323
+ }
324
+ return [...open.values()]
325
+ }
326
+
327
+ /** `owner/repo` from a GitHub issue URL, or `null`. Pure. */
328
+ export const repoOfUrl = (url: string): string | null =>
329
+ /^https:\/\/github\.com\/([^/]+\/[^/]+)\//.exec(url)?.[1] ?? null
330
+
331
+ /** A failed check's lines: its name, command, how it ended, its output's end. */
332
+ const checkLines = (check: Record<string, unknown>): string[] => {
333
+ const exit = numberOr(check.exit_code)
334
+ return [
335
+ `${text(check.name) || 'check'} \`${text(check.command)}\` ${exit === null ? 'timed out (no exit code)' : `exit ${exit}`}`,
336
+ indent(clipEnd(text(check.output)).trim() || '(no output)'),
337
+ ]
338
+ }
339
+
340
+ /** A reviewer's blocking findings (blockers and should-fixes), one per line. */
341
+ const findingLines = (result: Record<string, unknown>): string[] =>
342
+ listOf(result.findings)
343
+ .map(objectOf)
344
+ .filter((finding) => finding.severity !== 'nit')
345
+ .map(
346
+ (finding) =>
347
+ `- ${text(finding.severity)} (${text(finding.kind)})${text(finding.file) === '' ? '' : ` ${text(finding.file)}`}: ${clip(text(finding.title), 300)}`
348
+ )
349
+
350
+ /** What the journal says about the stuck final review. Pure. */
351
+ const finalReviewLines = ({
352
+ records,
353
+ }: {
354
+ records: LooseRecord[]
355
+ }): string[] => {
356
+ const lines: string[] = []
357
+ const run_level = records.filter((record) => record.ticket === null)
358
+ const lenses = new Map<string, LooseRecord>()
359
+ for (const record of ofKind(run_level, 'agent_finished')) {
360
+ const role = text(record.content.role)
361
+ if (role.endsWith('-lens')) lenses.set(role, record)
362
+ }
363
+ for (const [role, record] of lenses) {
364
+ const findings = findingLines(objectOf(record.content.result))
365
+ if (findings.length > 0) {
366
+ lines.push(
367
+ `${role}'s open findings (${seqText(record.seq)}):`,
368
+ ...findings
369
+ )
370
+ }
371
+ }
372
+ const failed = lastOf(run_level, 'agent_failed')
373
+ if (failed !== undefined) {
374
+ lines.push(
375
+ `Last failed try (${seqText(failed.seq)}): ${text(failed.content.role)} (${text(failed.content.failure) || 'agent'})`,
376
+ indent(clip(text(failed.content.error), 800))
377
+ )
378
+ }
379
+ const gates = lastOf(run_level, 'gates_run')
380
+ if (gates !== undefined && gates.content.ok === false) {
381
+ lines.push(
382
+ `Last gates on the run branch failed (${seqText(gates.seq)}):`,
383
+ ...listOf(gates.content.checks)
384
+ .map(objectOf)
385
+ .filter((check) => check.ok === false)
386
+ .flatMap(checkLines)
387
+ )
388
+ }
389
+ return lines
390
+ }
391
+
392
+ /** What the journal says about one stuck ticket. Pure. */
393
+ const ticketLines = ({
394
+ records,
395
+ item,
396
+ }: {
397
+ records: LooseRecord[]
398
+ item: StuckItem
399
+ }): string[] => {
400
+ const n = item.ticket
401
+ const mine = records.filter((record) => record.ticket === n)
402
+ const lines: string[] = []
403
+ const worktree = lastOf(mine, 'ticket_worktree_created')
404
+ if (worktree !== undefined) {
405
+ lines.push(
406
+ `Worktree: ${text(worktree.content.path)} (branch ${text(worktree.content.branch)})`
407
+ )
408
+ }
409
+ const agent = lastOf(mine, 'agent_finished')
410
+ if (agent !== undefined) {
411
+ const role = text(agent.content.role)
412
+ const result = objectOf(agent.content.result)
413
+ const outcome = text(result.outcome) || text(result.verdict)
414
+ lines.push(
415
+ `Last agent result (${seqText(agent.seq)}): ${role} ${outcome}`
416
+ )
417
+ const bad = objectOf(result.bad_test)
418
+ if (text(bad.file) !== '') {
419
+ lines.push(
420
+ ` bad_test: ${text(bad.file)} > ${text(bad.name)}`,
421
+ indent(`reason: ${clip(text(bad.reason))}`)
422
+ )
423
+ }
424
+ lines.push(...nest(findingLines(result), ' '))
425
+ const setup = objectOf(result.setup_change)
426
+ if (text(setup.file) !== '') {
427
+ lines.push(
428
+ ` setup_change: ${text(setup.file)}: ${clip(text(setup.reason))}`
429
+ )
430
+ }
431
+ const done_by = listOf(result.done_by).map(objectOf)
432
+ if (done_by.length > 0) {
433
+ lines.push(
434
+ ` done_by: ${done_by.map((commit) => `${text(commit.sha).slice(0, 9)} ${text(commit.title)}`).join('; ')}`
435
+ )
436
+ }
437
+ if (text(result.summary) !== '') {
438
+ lines.push(indent(`summary: ${clip(text(result.summary), 600)}`))
439
+ }
440
+ }
441
+ const failed = lastOf(mine, 'agent_failed')
442
+ if (failed !== undefined) {
443
+ lines.push(
444
+ `Last failed try (${seqText(failed.seq)}): ${text(failed.content.role)} (${text(failed.content.failure) || 'agent'})`,
445
+ indent(clip(text(failed.content.error), 800))
446
+ )
447
+ }
448
+ const prepared = lastOf(mine, 'baseline_prepared')
449
+ const prepare_check = objectOf(prepared?.content.check)
450
+ if (prepared !== undefined && prepare_check.ok === false) {
451
+ lines.push(
452
+ `Prepare failed (${seqText(prepared.seq)}):`,
453
+ ...checkLines(prepare_check)
454
+ )
455
+ }
456
+ const red = lastOf(mine, 'red_check')
457
+ if (red !== undefined && red.content.ok === false) {
458
+ lines.push(
459
+ `Last red check failed (${seqText(red.seq)}):`,
460
+ ...listOf(red.content.problems).map(
461
+ (p) => ` - ${clip(text(p), 600)}`
462
+ )
463
+ )
464
+ }
465
+ const evidence = lastOf(mine, 'already_done_checked')
466
+ if (evidence !== undefined && evidence.content.ok === false) {
467
+ lines.push(
468
+ `already_done evidence did not check out (${seqText(evidence.seq)}):`,
469
+ ...listOf(evidence.content.problems).map(
470
+ (p) => ` - ${clip(text(p), 600)}`
471
+ )
472
+ )
473
+ }
474
+ const gates = lastOf(mine, 'gates_run')
475
+ if (gates !== undefined && gates.content.ok === false) {
476
+ lines.push(
477
+ `Last gates failed (${seqText(gates.seq)}, on the ${text(gates.content.target) === 'run_branch' ? 'run branch' : 'ticket worktree'}):`,
478
+ ...listOf(gates.content.checks)
479
+ .map(objectOf)
480
+ .filter((check) => check.ok === false)
481
+ .flatMap(checkLines)
482
+ )
483
+ }
484
+ const scan = lastOf(mine, 'leftover_scan')
485
+ const hits = listOf(scan?.content.hits).map(objectOf)
486
+ if (hits.length > 0) {
487
+ lines.push(
488
+ 'Leftover scan hits:',
489
+ ...hits.map((hit) => ` - ${text(hit.path)}: ${text(hit.reason)}`)
490
+ )
491
+ }
492
+ const rebased = lastOf(mine, 'ticket_rebased')
493
+ if (rebased !== undefined) {
494
+ lines.push(
495
+ `Rebased onto the run branch (${seqText(rebased.seq)},${text(rebased.content.cause)}): clashed tests ${listOf(rebased.content.tests).map(text).join(', ') || 'none'}; clashed code ${listOf(rebased.content.code).map(text).join(', ') || 'none'}`
496
+ )
497
+ }
498
+ const sent = lastOf(mine, 'tests_sent_back')
499
+ if (sent !== undefined) {
500
+ const bad = objectOf(sent.content.bad_test)
501
+ lines.push(
502
+ `tests_sent_back round ${String(sent.content.round)} (${seqText(sent.seq)}): ${text(bad.file)} > ${text(bad.name)}`,
503
+ ` joined since: ${
504
+ listOf(sent.content.joined)
505
+ .map(objectOf)
506
+ .map((t) => `#${String(t.ticket)} ${text(t.title)}`)
507
+ .join('; ') || 'none'
508
+ }`
509
+ )
510
+ }
511
+ return lines
512
+ }
513
+
514
+ /** The replies and answers since something got stuck. Pure. */
515
+ const replyLines = ({
516
+ records,
517
+ since,
518
+ }: {
519
+ records: LooseRecord[]
520
+ since: number
521
+ }): string[] =>
522
+ records
523
+ .filter((record) => record.seq > since)
524
+ .flatMap((record): string[] => {
525
+ const { kind, content, seq } = record
526
+ if (kind === 'stuck_reported') {
527
+ return [
528
+ `${seqText(seq)} stuck_reported: comment ${String(content.comment_id)}`,
529
+ ]
530
+ }
531
+ if (kind === 'comment_read') {
532
+ return [
533
+ `${seqText(seq)} comment_read by ${text(content.author)}: ${clip(text(content.body).replace(/\s+/g, ' '), 200)}`,
534
+ ]
535
+ }
536
+ if (kind === 'reply_received') {
537
+ return [
538
+ `${seqText(seq)} reply_received: ${text(content.word)}${content.ticket === null ? '' : ` #${String(content.ticket)}`} from ${text(content.author)}`,
539
+ ]
540
+ }
541
+ if (kind === 'reply_ignored') {
542
+ return [
543
+ `${seqText(seq)} reply_ignored: ${text(content.reason)}`,
544
+ ]
545
+ }
546
+ if (kind === 'ticket_retried') {
547
+ return [
548
+ `${seqText(seq)} ticket_retried #${String(record.ticket)}: ${text(content.mode)}${listOf(content.problems).length === 0 ? '' : ` (${listOf(content.problems).map(text).join('; ')})`}`,
549
+ ]
550
+ }
551
+ if (
552
+ kind === 'ticket_skipped' ||
553
+ kind === 'final_review_retried' ||
554
+ kind === 'final_review_shipped'
555
+ ) {
556
+ return [
557
+ `${seqText(seq)} ${kind}${record.ticket === null ? '' : ` #${record.ticket}`}`,
558
+ ]
559
+ }
560
+ return []
561
+ })
562
+
563
+ /** The board's registry entry for a run, without its token. */
564
+ export type RegistryEntry = {
565
+ spec: number | null
566
+ repo: string | null
567
+ log_path: string | null
568
+ ended: { ok: boolean; message: string } | null
569
+ restarts: number | null
570
+ }
571
+
572
+ /** A run's entry in the board's run registry, or `null`. */
573
+ export const registryEntry = ({
574
+ registry,
575
+ run_id,
576
+ }: {
577
+ registry: string
578
+ run_id: string
579
+ }): RegistryEntry | null => {
580
+ if (!existsSync(registry)) return null
581
+ let json: unknown
582
+ try {
583
+ json = JSON.parse(readFileSync(registry, 'utf8'))
584
+ } catch {
585
+ return null
586
+ }
587
+ const entry = listOf(objectOf(json).runs)
588
+ .map(objectOf)
589
+ .find((run) => run.run_id === run_id)
590
+ if (entry === undefined) return null
591
+ const ended = objectOf(entry.ended)
592
+ return {
593
+ spec: numberOr(entry.spec),
594
+ repo: text(entry.repo) || null,
595
+ log_path: text(entry.log_path) || null,
596
+ ended: isObject(entry.ended)
597
+ ? { ok: ended.ok === true, message: text(ended.message) }
598
+ : null,
599
+ restarts: numberOr(entry.restarts),
600
+ }
601
+ }
602
+
603
+ /** Whether a run's engine is running now, from `ps`; `null` when unknown. */
604
+ export const engineRunning = ({
605
+ run_id,
606
+ command_lines,
607
+ }: {
608
+ run_id: string
609
+ command_lines: string[] | null
610
+ }): boolean | null => {
611
+ if (command_lines === null) return null
612
+ return command_lines.some((line) => {
613
+ const tokens = line.split(/\s+/)
614
+ return tokens.some(
615
+ (token, index) =>
616
+ ((token === '--run-id' || token === '--resume') &&
617
+ tokens[index + 1] === run_id) ||
618
+ token === `--run-id=${run_id}` ||
619
+ token === `--resume=${run_id}`
620
+ )
621
+ })
622
+ }
623
+
624
+ /** Every process's command line, or `null` when `ps` fails. */
625
+ const commandLines = (): string[] | null => {
626
+ const ps = Bun.spawnSync(['ps', '-axww', '-o', 'command='])
627
+ return ps.exitCode === 0 ? ps.stdout.toString().split('\n') : null
628
+ }
629
+
630
+ /** The last lines of the engine's log, or `null` when it can't be read. */
631
+ const logTail = (path: string | null): string | null => {
632
+ if (path === null || !existsSync(path)) return null
633
+ const lines = readFileSync(path, 'utf8').trimEnd().split('\n')
634
+ return lines.slice(-LOG_LINES).join('\n')
635
+ }
636
+
637
+ /**
638
+ * The summary of one run, as plain text: the run, how its engine stands,
639
+ * then each stuck item with what the journal says about it and the replies
640
+ * since. `ticket` limits it to that ticket. Pure.
641
+ */
642
+ export const formatSummary = ({
643
+ run_id,
644
+ records,
645
+ ticket = null,
646
+ registry = null,
647
+ running = null,
648
+ log_tail = null,
649
+ }: {
650
+ run_id: string
651
+ records: LooseRecord[]
652
+ ticket?: number | null
653
+ registry?: RegistryEntry | null
654
+ running?: boolean | null
655
+ log_tail?: string | null
656
+ }): string => {
657
+ if (records.length === 0) return `Run ${run_id}: no journal records.`
658
+ const started = objectOf(lastOf(records, 'run_started')?.content)
659
+ // The snapshot once intake passed, else the spec as intake read it.
660
+ const spec = objectOf(
661
+ objectOf(
662
+ (lastOf(records, 'spec_snapshot') ?? lastOf(records, 'intake_read'))
663
+ ?.content
664
+ ).spec
665
+ )
666
+ const resumed = lastOf(records, 'engine_resumed')
667
+ const branch = lastOf(records, 'run_branch_created')
668
+ const state = runState({ records })
669
+ const stuck = openStuck({ records }).filter(
670
+ (item) =>
671
+ ticket === null || item.ticket === ticket || item.what !== 'ticket'
672
+ )
673
+ const cut = cutOffSteps({ records })
674
+ const lines: string[] = [
675
+ `Run ${run_id}: spec #${String(started.spec_number ?? spec.number ?? '?')}${text(spec.title) === '' ? '' : ` "${text(spec.title)}"`}`,
676
+ `Repo: ${text(started.repo) || registry?.repo || '?'}${repoOfUrl(text(spec.url)) === null ? '' : ` (${repoOfUrl(text(spec.url))})`}`,
677
+ `Spec owner: ${text(spec.author) || '? (read it with gh issue view)'}`,
678
+ `Luca: started on ${text(started.luca_version) || '?'}${resumed === undefined ? '' : `, last resumed on ${text(resumed.content.luca_version)}`}`,
679
+ ]
680
+ if (branch !== undefined) {
681
+ lines.push(
682
+ `Run branch: ${text(branch.content.branch)} at ${text(branch.content.path)}`
683
+ )
684
+ }
685
+ const last = records.at(-1)
686
+ if (last !== undefined) {
687
+ lines.push(
688
+ `Last record: ${seqText(last.seq)} ${last.kind} at ${last.time}`
689
+ )
690
+ }
691
+ lines.push(`State: ${state.line}`)
692
+ lines.push(
693
+ `Engine: ${running === null ? 'unknown (ps failed)' : running ? 'running now, so it will read a reply within about a minute' : 'not running, so nothing reads replies until the run is resumed'}`
694
+ )
695
+ if (registry !== null) {
696
+ lines.push(
697
+ `Board: started by the board${registry.ended === null ? '' : `; it ended: ${registry.ended.message}`}${registry.restarts === null ? '' : `; automatic restarts: ${registry.restarts}`}`
698
+ )
699
+ if (registry.log_path !== null) {
700
+ lines.push(`Engine log: ${registry.log_path}`)
701
+ }
702
+ } else {
703
+ lines.push(
704
+ 'Board: not in the board registry (started from a terminal?)'
705
+ )
706
+ }
707
+ if (running === false && !state.over && cut.length > 0) {
708
+ lines.push(
709
+ `Cut off (started, never ended): ${cut.map((step) => `${step.step} on ${step.key} (${seqText(step.seq)})`).join('; ')}`
710
+ )
711
+ }
712
+ if (log_tail !== null && registry?.ended?.ok === false && !state.over) {
713
+ lines.push('Engine log, last lines:', indent(log_tail))
714
+ }
715
+ if (stuck.length === 0) {
716
+ lines.push(
717
+ ticket === null
718
+ ? 'Stuck now: nothing waits on a reply.'
719
+ : `Stuck now: #${ticket} doesn't wait on a reply.`
720
+ )
721
+ return lines.join('\n')
722
+ }
723
+ lines.push('Stuck now:')
724
+ for (const item of stuck) {
725
+ const title =
726
+ item.ticket === null
727
+ ? ''
728
+ : text(
729
+ lastOf(
730
+ records,
731
+ 'ticket_snapshot',
732
+ ({ content }) => content.number === item.ticket
733
+ )?.content.title
734
+ )
735
+ const head =
736
+ item.what === 'ticket'
737
+ ? `Ticket #${String(item.ticket)}${title === '' ? '' : ` "${title}"`}`
738
+ : item.what === 'final_review'
739
+ ? 'The final review'
740
+ : 'The whole run'
741
+ lines.push(
742
+ '',
743
+ `${head}: ${item.reason} (${item.kind} ${seqText(item.seq)})`,
744
+ indent(`detail: ${clip(item.detail)}`)
745
+ )
746
+ if (item.refused.length > 0) {
747
+ lines.push(` A retry was refused: ${item.refused.join('; ')}`)
748
+ }
749
+ if (item.what === 'ticket') {
750
+ lines.push(...nest(ticketLines({ records, item }), ' '))
751
+ } else if (item.what === 'final_review') {
752
+ lines.push(...nest(finalReviewLines({ records }), ' '))
753
+ }
754
+ const replies = replyLines({ records, since: item.seq })
755
+ lines.push(
756
+ replies.length === 0
757
+ ? ' Replies since: none yet'
758
+ : ' Since then:',
759
+ ...nest(replies, ' ')
760
+ )
761
+ }
762
+ return lines.join('\n')
763
+ }
764
+
765
+ /** One run of a repo, for picking the one that needs you. */
766
+ export type RunChoice = {
767
+ run_id: string
768
+ started: string
769
+ spec: number | null
770
+ over: boolean
771
+ stuck: number
772
+ }
773
+
774
+ /**
775
+ * The runs of `repo` in `runs_dir`, newest first, with how many things
776
+ * wait on a reply in each. A run's repo is its `run_started` repo; it
777
+ * matches any of `repos`, symlinks followed.
778
+ */
779
+ export const repoRuns = ({
780
+ runs_dir,
781
+ repos,
782
+ }: {
783
+ runs_dir: string
784
+ repos: string[]
785
+ }): RunChoice[] => {
786
+ if (!existsSync(runs_dir)) return []
787
+ const want = new Set(repos.map(realPath))
788
+ return readdirSync(runs_dir)
789
+ .map((run_id) => ({
790
+ run_id,
791
+ records: readRecords({
792
+ file: join(runs_dir, run_id, 'journal.jsonl'),
793
+ }),
794
+ }))
795
+ .filter(({ records }) => {
796
+ const started = records.find((r) => r.kind === 'run_started')
797
+ return (
798
+ started !== undefined &&
799
+ want.has(realPath(text(started.content.repo) || '/'))
800
+ )
801
+ })
802
+ .map(({ run_id, records }) => ({
803
+ run_id,
804
+ started: records[0]?.time ?? '',
805
+ spec: numberOr(
806
+ records.find((r) => r.kind === 'run_started')?.content
807
+ .spec_number
808
+ ),
809
+ over: runState({ records }).over,
810
+ stuck: openStuck({ records }).length,
811
+ }))
812
+ .toSorted((a, b) => b.started.localeCompare(a.started))
813
+ }
814
+
815
+ /** The flags, read. */
816
+ export type SummaryArgs = {
817
+ run_id: string | null
818
+ ticket: number | null
819
+ repo: string | null
820
+ runs_dir: string
821
+ registry: string
822
+ /** `--watch <seq>`: wait for the engine to take a reply posted after it. */
823
+ watch: number | null
824
+ /** How long `--watch` waits, in seconds. */
825
+ timeout_s: number
826
+ }
827
+
828
+ /** How long `--watch` waits by default: the engine reads replies every minute. */
829
+ const WATCH_TIMEOUT_S = 180
830
+
831
+ /** How often `--watch` reads the journal again. */
832
+ const WATCH_POLL_MS = 5000
833
+
834
+ /** Reads the flags; `env` gives the default folders. Pure. */
835
+ export const parseArgs = ({
836
+ argv,
837
+ env,
838
+ home,
839
+ }: {
840
+ argv: string[]
841
+ env: Record<string, string | undefined>
842
+ home: string
843
+ }): SummaryArgs => {
844
+ const args: SummaryArgs = {
845
+ run_id: null,
846
+ ticket: null,
847
+ repo: null,
848
+ runs_dir:
849
+ env.LUCA_RUNS_DIR ?? join(home, '.local', 'state', 'luca', 'runs'),
850
+ registry: join(
851
+ env.LUCA_BOARD_STATE_DIR || join(home, '.local/state/luca/board'),
852
+ 'runs.json'
853
+ ),
854
+ watch: null,
855
+ timeout_s: WATCH_TIMEOUT_S,
856
+ }
857
+ for (let i = 0; i < argv.length; i += 1) {
858
+ const arg = argv[i] ?? ''
859
+ const value = argv[i + 1]
860
+ if (arg === '--repo' && value !== undefined) {
861
+ args.repo = value
862
+ i += 1
863
+ } else if (arg === '--runs-dir' && value !== undefined) {
864
+ args.runs_dir = value
865
+ i += 1
866
+ } else if (arg === '--registry' && value !== undefined) {
867
+ args.registry = value
868
+ i += 1
869
+ } else if (arg === '--watch' && value !== undefined) {
870
+ args.watch = Number(value)
871
+ i += 1
872
+ } else if (arg === '--timeout-s' && value !== undefined) {
873
+ args.timeout_s = Number(value)
874
+ i += 1
875
+ } else if (/^#?\d+$/.test(arg)) {
876
+ args.ticket = Number(arg.replace('#', ''))
877
+ } else if (arg !== '' && !arg.startsWith('-')) {
878
+ args.run_id = arg
879
+ }
880
+ }
881
+ return args
882
+ }
883
+
884
+ /**
885
+ * How the engine took a reply posted after record `after`: `done` once it
886
+ * took it and acted (a retry, a skip, a ship, a stop, or an answer that it
887
+ * can't use), `taken` once it only took it, `waiting` before that. `lines`
888
+ * are the records that tell. Pure.
889
+ */
890
+ export const replyOutcome = ({
891
+ records,
892
+ after,
893
+ }: {
894
+ records: LooseRecord[]
895
+ after: number
896
+ }): { status: 'waiting' | 'taken' | 'done'; lines: string[] } => {
897
+ const since = records.filter((record) => record.seq > after)
898
+ const lines = replyLines({ records: since, since: after })
899
+ const acted = since.find((record) =>
900
+ [
901
+ 'reply_ignored',
902
+ 'ticket_retried',
903
+ 'ticket_skipped',
904
+ 'final_review_retried',
905
+ 'final_review_shipped',
906
+ ].includes(record.kind)
907
+ )
908
+ const taken = since.find((record) => record.kind === 'reply_received')
909
+ const run_level =
910
+ taken !== undefined &&
911
+ (taken.content.word === 'stop' ||
912
+ (taken.content.word === 'retry' && taken.content.ticket === null))
913
+ return {
914
+ status:
915
+ acted !== undefined || run_level
916
+ ? 'done'
917
+ : taken !== undefined
918
+ ? 'taken'
919
+ : 'waiting',
920
+ lines,
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Reads the journal every few seconds until the engine took the reply and
926
+ * acted, or `timeout_s` passed. Returns the exit code: 0 when done.
927
+ */
928
+ const watchReply = async ({
929
+ file,
930
+ after,
931
+ timeout_s,
932
+ }: {
933
+ file: string
934
+ after: number
935
+ timeout_s: number
936
+ }): Promise<number> => {
937
+ const until = Date.now() + timeout_s * 1000
938
+ for (;;) {
939
+ const outcome = replyOutcome({ records: readRecords({ file }), after })
940
+ if (outcome.status === 'done' || Date.now() >= until) {
941
+ console.log(
942
+ outcome.status === 'done'
943
+ ? 'The engine took the reply:'
944
+ : outcome.status === 'taken'
945
+ ? `The engine took the reply, but hasn't acted on it yet after ${timeout_s} s:`
946
+ : `The engine hasn't taken a reply after ${timeout_s} s.`
947
+ )
948
+ for (const line of outcome.lines) console.log(` ${line}`)
949
+ return outcome.status === 'done' ? 0 : 1
950
+ }
951
+ await Bun.sleep(WATCH_POLL_MS)
952
+ }
953
+ }
954
+
955
+ /**
956
+ * A path with its symlinks followed (`/tmp` is `/private/tmp` on macOS), or
957
+ * just made absolute when it doesn't exist.
958
+ */
959
+ const realPath = (path: string): string => {
960
+ try {
961
+ return realpathSync(path)
962
+ } catch {
963
+ return resolve(path)
964
+ }
965
+ }
966
+
967
+ /**
968
+ * The repos a run started from `cwd` may name, real paths, main first: the
969
+ * main checkout (a run's repo is the main checkout), then the git top folder
970
+ * (a worktree's own, or a subfolder's repo). Outside git, `cwd` itself. A
971
+ * bare repo or a submodule has no `<checkout>/.git` common dir, so it adds
972
+ * no main checkout, only its top folder.
973
+ *
974
+ * Its twin is `currentRepos` in `luca-retro/scripts/retro-summary.ts`: each skill folder is installed
975
+ * to `~/.claude/skills/` on its own, so they can't share a file. Keep the two
976
+ * the same; a test in each pins it.
977
+ *
978
+ * @example
979
+ * currentRepos({ cwd: '/code/app-worktree' }) // ['/code/app', '/code/app-worktree']
980
+ */
981
+ export const currentRepos = ({ cwd }: { cwd: string }): string[] => {
982
+ const git = (...args: string[]) => {
983
+ const out = Bun.spawnSync(['git', '-C', cwd, ...args])
984
+ const said = out.stdout.toString().trim()
985
+ return out.exitCode === 0 && said !== '' ? said : null
986
+ }
987
+ const top = git('rev-parse', '--show-toplevel')
988
+ const common = git(
989
+ 'rev-parse',
990
+ '--path-format=absolute',
991
+ '--git-common-dir'
992
+ )
993
+ const main =
994
+ common !== null && basename(common) === '.git' ? dirname(common) : null
995
+ return [main, top ?? cwd]
996
+ .filter((repo) => repo !== null)
997
+ .map(realPath)
998
+ .filter((repo, index, all) => all.indexOf(repo) === index)
999
+ }
1000
+
1001
+ const main = async () => {
1002
+ const args = parseArgs({
1003
+ argv: Bun.argv.slice(2),
1004
+ env: process.env,
1005
+ home: homedir(),
1006
+ })
1007
+ if (args.watch !== null) {
1008
+ if (args.run_id === null || Number.isNaN(args.watch)) {
1009
+ console.error('Usage: stuck-summary.ts <run id> --watch <seq>')
1010
+ process.exit(2)
1011
+ }
1012
+ process.exit(
1013
+ await watchReply({
1014
+ file: join(args.runs_dir, args.run_id, 'journal.jsonl'),
1015
+ after: args.watch,
1016
+ timeout_s: args.timeout_s,
1017
+ })
1018
+ )
1019
+ }
1020
+ let run_id = args.run_id
1021
+ if (run_id === null) {
1022
+ const repos =
1023
+ args.repo === null
1024
+ ? currentRepos({ cwd: process.cwd() })
1025
+ : [args.repo]
1026
+ const runs = repoRuns({ runs_dir: args.runs_dir, repos })
1027
+ if (runs.length === 0) {
1028
+ console.log(
1029
+ `No Luca runs of ${repos.join(' or ')} in ${args.runs_dir}.`
1030
+ )
1031
+ return
1032
+ }
1033
+ console.log(`Runs of ${repos[0]}, newest first:`)
1034
+ for (const run of runs.slice(0, 10)) {
1035
+ console.log(
1036
+ `- ${run.run_id}: spec #${run.spec ?? '?'}, started ${run.started}, ${run.over ? 'over' : 'not over'}, ${run.stuck} waiting on a reply`
1037
+ )
1038
+ }
1039
+ const pick =
1040
+ runs.find((run) => run.stuck > 0 && !run.over) ??
1041
+ runs.find((run) => !run.over) ??
1042
+ runs[0]
1043
+ if (pick === undefined) return
1044
+ run_id = pick.run_id
1045
+ console.log(`\nThe one that needs you most: ${run_id}\n`)
1046
+ }
1047
+ const records = readRecords({
1048
+ file: join(args.runs_dir, run_id, 'journal.jsonl'),
1049
+ })
1050
+ const registry = registryEntry({ registry: args.registry, run_id })
1051
+ console.log(
1052
+ formatSummary({
1053
+ run_id,
1054
+ records,
1055
+ ticket: args.ticket,
1056
+ registry,
1057
+ running: engineRunning({ run_id, command_lines: commandLines() }),
1058
+ log_tail: logTail(registry?.log_path ?? null),
1059
+ })
1060
+ )
1061
+ }
1062
+
1063
+ if (import.meta.main) await main()