@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,407 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { z } from 'zod'
4
+
5
+ import type { CommandResult, RunCommand } from './board-server'
6
+
7
+ import type { ReplyPostInput, ReplyPostOutput } from '../shared/board-rpc'
8
+ import type { BoardState } from '../shared/board-state'
9
+ import {
10
+ allowedReplies,
11
+ replyParts,
12
+ repoOfIssueUrl,
13
+ type PostedReply,
14
+ } from '../shared/reply-actions'
15
+
16
+ /**
17
+ * `reply.post` (#503): a reply button on stuck work posts its word on the
18
+ * spec issue with `gh issue comment`, so the owner doesn't have to go to
19
+ * GitHub. It never trusts the button: it checks again, from the run's
20
+ * board, that the item is stuck and the word is one the engine takes for
21
+ * it, and that the signed-in `gh` user owns the spec (the engine counts
22
+ * only the spec owner's replies). It posts once per stuck item. `gh` runs
23
+ * with an argument list, never through a shell, and needs no engine token,
24
+ * so runs the board didn't start can be answered too.
25
+ */
26
+
27
+ /** How long one `gh` call may take. */
28
+ export const GH_TIMEOUT_MS = 30_000
29
+
30
+ const GH_HINT =
31
+ 'Install the GitHub CLI (gh), or set LUCA_GH to its absolute path for the Paseo daemon.'
32
+
33
+ /**
34
+ * Finds `gh`: `LUCA_GH`, then the daemon's `PATH`, then the usual install
35
+ * folders. Only an absolute path that exists counts, since Paseo's daemon
36
+ * may run with a short `PATH`.
37
+ *
38
+ * @example
39
+ * resolveGh({ env: process.env, home_dir: homedir(), file_exists })
40
+ * // '/opt/homebrew/bin/gh', or null
41
+ */
42
+ export const resolveGh = ({
43
+ env,
44
+ home_dir,
45
+ file_exists,
46
+ }: {
47
+ env: Record<string, string | undefined>
48
+ home_dir: string
49
+ file_exists: ({ path }: { path: string }) => boolean
50
+ }): string | null =>
51
+ [
52
+ env.LUCA_GH ?? '',
53
+ ...(env.PATH ?? '').split(':').map((dir) => join(dir, 'gh')),
54
+ '/opt/homebrew/bin/gh',
55
+ '/usr/local/bin/gh',
56
+ join(home_dir, '.local/bin/gh'),
57
+ ].find((path) => path.startsWith('/') && file_exists({ path })) ?? null
58
+
59
+ /** A run as `reply.post` sees it. */
60
+ export type ReplyRun = {
61
+ state: BoardState
62
+ /** The run's repo folder; `null` when the board doesn't know it. */
63
+ repo_dir: string | null
64
+ /** The next journal seq the board wants: a later `reply_received` is new. */
65
+ next_seq: number
66
+ }
67
+
68
+ /** A posted reply, with the run it is for and the seq it waits past. */
69
+ type PostedEntry = PostedReply & {
70
+ run_id: string
71
+ after_seq: number
72
+ /** True while `gh` is posting it. */
73
+ sending: boolean
74
+ }
75
+
76
+ const IssueViewSchema = z.object({
77
+ author: z.object({ login: z.string() }),
78
+ url: z.string(),
79
+ })
80
+
81
+ const firstLine = ({ text }: { text: string }): string =>
82
+ text.trim().split('\n')[0]?.slice(0, 300) ?? ''
83
+
84
+ const ghProblem = ({ result }: { result: CommandResult }): string =>
85
+ result.exit_code === null
86
+ ? `it didn't finish in ${GH_TIMEOUT_MS / 1000} s`
87
+ : firstLine({ text: result.stderr || result.stdout }) ||
88
+ `it exited with ${result.exit_code}`
89
+
90
+ const refuse = (message: string): ReplyPostOutput => ({
91
+ ok: false,
92
+ status: 'refused',
93
+ message,
94
+ posted: null,
95
+ })
96
+
97
+ const fail = (message: string): ReplyPostOutput => ({
98
+ ok: false,
99
+ status: 'failed',
100
+ message,
101
+ posted: null,
102
+ })
103
+
104
+ const toPosted = ({ entry }: { entry: PostedEntry }): PostedReply => ({
105
+ key: entry.key,
106
+ since: entry.since,
107
+ reply: entry.reply,
108
+ posted_at: entry.posted_at,
109
+ comment_url: entry.comment_url,
110
+ taken_at: entry.taken_at,
111
+ })
112
+
113
+ /**
114
+ * The reply poster, with `gh` run through `run_command`. It keeps what it
115
+ * posted in memory (lost on a plugin restart), marks a reply taken when the
116
+ * engine journals `reply_received` for it, and lists a run's posted replies
117
+ * for `board.read`.
118
+ *
119
+ * @example
120
+ * const poster = createReplyPoster({ run_command, env, home_dir, file_exists, now, log })
121
+ * const output = await poster.postReply({ run, input: { run_id, key: 'ticket-13', reply: 'retry #13' } })
122
+ */
123
+ export const createReplyPoster = ({
124
+ run_command,
125
+ env,
126
+ home_dir,
127
+ file_exists,
128
+ now,
129
+ log,
130
+ }: {
131
+ run_command: RunCommand
132
+ env: Record<string, string | undefined>
133
+ home_dir: string
134
+ file_exists: ({ path }: { path: string }) => boolean
135
+ now: () => Date
136
+ log: (message: string) => void
137
+ }) => {
138
+ const posted = new Map<string, PostedEntry>()
139
+
140
+ const ghEnv = (): Record<string, string> => {
141
+ const gh_env: Record<string, string> = {}
142
+ for (const [key, value] of Object.entries(env)) {
143
+ if (value !== undefined) gh_env[key] = value
144
+ }
145
+ gh_env.GH_PROMPT_DISABLED = '1'
146
+ return gh_env
147
+ }
148
+
149
+ /**
150
+ * Checks the owner and posts the comment. A refusal or failure says why;
151
+ * `comment_url` is what `gh` printed.
152
+ */
153
+ const send = async ({
154
+ run,
155
+ spec_number,
156
+ reply,
157
+ }: {
158
+ run: ReplyRun
159
+ spec_number: number
160
+ reply: string
161
+ }): Promise<
162
+ | { ok: true; comment_url: string | null }
163
+ | { ok: false; output: ReplyPostOutput }
164
+ > => {
165
+ const gh = resolveGh({ env, home_dir, file_exists })
166
+ if (gh === null) {
167
+ return { ok: false, output: fail(`Couldn't find gh. ${GH_HINT}`) }
168
+ }
169
+ const cwd = run.repo_dir ?? home_dir
170
+ const runGh = (args: string[]) =>
171
+ run_command({
172
+ command: gh,
173
+ args,
174
+ cwd,
175
+ env: ghEnv(),
176
+ timeout_ms: GH_TIMEOUT_MS,
177
+ })
178
+
179
+ let owner = run.state.run.spec_author
180
+ let repo = run.state.run.spec_repo
181
+ if (owner === null || repo === null) {
182
+ if (repo === null && run.repo_dir === null) {
183
+ return {
184
+ ok: false,
185
+ output: fail(
186
+ `The board doesn't know spec #${spec_number}'s repo, so it can't post there.`
187
+ ),
188
+ }
189
+ }
190
+ const view = await runGh([
191
+ 'issue',
192
+ 'view',
193
+ String(spec_number),
194
+ ...(repo === null ? [] : ['--repo', repo]),
195
+ '--json',
196
+ 'author,url',
197
+ ])
198
+ const parsed =
199
+ view.exit_code === 0
200
+ ? IssueViewSchema.safeParse(safeJson({ text: view.stdout }))
201
+ : null
202
+ if (!parsed?.success) {
203
+ return {
204
+ ok: false,
205
+ output: fail(
206
+ `Couldn't read spec #${spec_number}'s owner with gh issue view: ${parsed ? 'it gave an answer the board could not read' : ghProblem({ result: view })}.`
207
+ ),
208
+ }
209
+ }
210
+ owner ??= parsed.data.author.login
211
+ repo ??= repoOfIssueUrl({ url: parsed.data.url })
212
+ }
213
+ if (repo === null) {
214
+ return {
215
+ ok: false,
216
+ output: fail(
217
+ `The board couldn't tell spec #${spec_number}'s repo, so it can't post there.`
218
+ ),
219
+ }
220
+ }
221
+
222
+ const user = await runGh(['api', 'user', '--jq', '.login'])
223
+ const login = user.stdout.trim()
224
+ if (user.exit_code !== 0 || login === '') {
225
+ return {
226
+ ok: false,
227
+ output: fail(
228
+ `Couldn't tell who is signed in to gh: ${ghProblem({ result: user })}. Run gh auth login.`
229
+ ),
230
+ }
231
+ }
232
+ if (login.toLowerCase() !== owner.toLowerCase()) {
233
+ return {
234
+ ok: false,
235
+ output: refuse(
236
+ `You're signed in to gh as ${login}, but spec #${spec_number} is ${owner}'s. The engine only counts ${owner}'s replies, so the board didn't post \`${reply}\`. Switch gh to ${owner} (gh auth switch), or reply as ${owner} on GitHub.`
237
+ ),
238
+ }
239
+ }
240
+
241
+ const comment = await runGh([
242
+ 'issue',
243
+ 'comment',
244
+ String(spec_number),
245
+ '--repo',
246
+ repo,
247
+ '--body',
248
+ reply,
249
+ ])
250
+ if (comment.exit_code !== 0) {
251
+ return {
252
+ ok: false,
253
+ output: fail(
254
+ `gh couldn't post \`${reply}\` on spec #${spec_number}: ${ghProblem({ result: comment })}.`
255
+ ),
256
+ }
257
+ }
258
+ const comment_url =
259
+ comment.stdout
260
+ .split('\n')
261
+ .map((line) => line.trim())
262
+ .find((line) => line.startsWith('https://')) ?? null
263
+ return { ok: true, comment_url }
264
+ }
265
+
266
+ /**
267
+ * Posts `input.reply` on the run's spec issue, if the run's board has
268
+ * `input.key` stuck and the word is one the engine takes for it, and
269
+ * the `gh` user owns the spec. The same reply for the same stuck item
270
+ * is posted once (`already_posted` after that, and while it is in
271
+ * flight); a different word for it is refused. A failed post can be
272
+ * tried again. Never throws.
273
+ */
274
+ const postReply = async ({
275
+ run,
276
+ input,
277
+ }: {
278
+ run: ReplyRun | null
279
+ input: ReplyPostInput
280
+ }): Promise<ReplyPostOutput> => {
281
+ const { run_id, key, reply } = input
282
+ if (run === null) {
283
+ return refuse(`The board doesn't know run ${run_id}.`)
284
+ }
285
+ const { state } = run
286
+ if (state.run.demo) {
287
+ return refuse(
288
+ `Run ${run_id} is a demo: it has no spec issue to reply on.`
289
+ )
290
+ }
291
+ const spec_number = state.run.spec_number
292
+ if (spec_number === null) {
293
+ return refuse(`Run ${run_id} doesn't name its spec issue yet.`)
294
+ }
295
+ const item = state.needs_you.find((entry) => entry.key === key)
296
+ if (!item) {
297
+ return refuse(
298
+ `Nothing in run ${run_id} waits for a reply as "${key}" now, so the board didn't post \`${reply}\`.`
299
+ )
300
+ }
301
+ const allowed = allowedReplies(item)
302
+ if (!allowed.includes(reply)) {
303
+ return refuse(
304
+ `The engine doesn't take \`${reply}\` here. For "${item.subject}" it takes ${allowed.map((word) => `\`${word}\``).join(', ')}.`
305
+ )
306
+ }
307
+
308
+ const id = `${run_id}\n${key}\n${item.since}`
309
+ const existing = posted.get(id)
310
+ if (existing) {
311
+ if (existing.reply !== reply) {
312
+ return refuse(
313
+ `\`${existing.reply}\` was already posted for this. The engine takes the first reply, so the board won't post \`${reply}\` too.`
314
+ )
315
+ }
316
+ return {
317
+ ok: true,
318
+ status: 'already_posted',
319
+ message: existing.sending
320
+ ? `\`${reply}\` is being posted now.`
321
+ : `\`${reply}\` was already posted on spec #${spec_number}.`,
322
+ posted: existing.sending ? null : toPosted({ entry: existing }),
323
+ }
324
+ }
325
+ // Kept before the first await, so a second tap sees it.
326
+ const entry: PostedEntry = {
327
+ key,
328
+ since: item.since,
329
+ reply,
330
+ posted_at: now().toISOString(),
331
+ comment_url: null,
332
+ taken_at: null,
333
+ run_id,
334
+ after_seq: run.next_seq,
335
+ sending: true,
336
+ }
337
+ posted.set(id, entry)
338
+ try {
339
+ const sent = await send({ run, spec_number, reply })
340
+ if (!sent.ok) {
341
+ posted.delete(id)
342
+ log(`[${run_id}] reply \`${reply}\`: ${sent.output.message}`)
343
+ return sent.output
344
+ }
345
+ entry.sending = false
346
+ entry.comment_url = sent.comment_url
347
+ log(
348
+ `[${run_id}] posted \`${reply}\` on spec #${spec_number}${sent.comment_url ? `: ${sent.comment_url}` : ''}`
349
+ )
350
+ return {
351
+ ok: true,
352
+ status: 'posted',
353
+ message: `Posted \`${reply}\` on spec #${spec_number}. The engine reads it within about a minute.`,
354
+ posted: toPosted({ entry }),
355
+ }
356
+ } catch (error) {
357
+ posted.delete(id)
358
+ return fail(
359
+ `Couldn't post \`${reply}\`: ${error instanceof Error ? error.message : String(error)}`
360
+ )
361
+ }
362
+ }
363
+
364
+ /**
365
+ * The engine took a reply (`reply_received` at `seq`): marks the posted
366
+ * replies it matches as taken.
367
+ */
368
+ const noteTaken = ({
369
+ run_id,
370
+ seq,
371
+ time,
372
+ word,
373
+ ticket,
374
+ }: {
375
+ run_id: string
376
+ seq: number
377
+ time: string
378
+ word: string
379
+ ticket: number | null
380
+ }) => {
381
+ for (const entry of posted.values()) {
382
+ if (entry.run_id !== run_id || entry.sending) continue
383
+ if (entry.taken_at !== null || seq < entry.after_seq) continue
384
+ const parts = replyParts({ reply: entry.reply })
385
+ if (parts?.word === word && parts.ticket === ticket) {
386
+ entry.taken_at = time
387
+ }
388
+ }
389
+ }
390
+
391
+ /** The replies posted for a run, oldest first. */
392
+ const postedOf = ({ run_id }: { run_id: string }): PostedReply[] =>
393
+ [...posted.values()]
394
+ .filter((entry) => entry.run_id === run_id && !entry.sending)
395
+ .map((entry) => toPosted({ entry }))
396
+
397
+ return { postReply, noteTaken, postedOf }
398
+ }
399
+
400
+ /** Parses JSON, or `null` when it isn't. */
401
+ const safeJson = ({ text }: { text: string }): unknown => {
402
+ try {
403
+ return JSON.parse(text)
404
+ } catch {
405
+ return null
406
+ }
407
+ }
@@ -28,7 +28,10 @@ export const RunEntrySchema = z.object({
28
28
  log_path: z.string(),
29
29
  /** How the run's engine ended, once it did; `null` while it may run. */
30
30
  ended: EngineEndedSchema.nullable().default(null),
31
- /** How many times the plugin restarted the run by itself (#375). */
31
+ /**
32
+ * How many times the plugin restarted the run by itself (#375); back to
33
+ * 0 when the owner resumes it (#493).
34
+ */
32
35
  restarts: z.number().int().min(0).default(0),
33
36
  })
34
37
 
@@ -136,15 +139,16 @@ export const createRunRegistry = ({
136
139
  write()
137
140
  },
138
141
  /**
139
- * Changes a known run's `ended` or `restarts` and writes the file.
140
- * Returns the changed entry, or `null` for an unknown run.
142
+ * Changes a known run's `ended`, `restarts`, or `token` (a resume
143
+ * gets a new one) and writes the file. Returns the changed entry, or
144
+ * `null` for an unknown run.
141
145
  */
142
146
  update: ({
143
147
  run_id,
144
148
  change,
145
149
  }: {
146
150
  run_id: string
147
- change: Partial<Pick<RunEntry, 'ended' | 'restarts'>>
151
+ change: Partial<Pick<RunEntry, 'ended' | 'restarts' | 'token'>>
148
152
  }): RunEntry | null => {
149
153
  reread()
150
154
  const known = entries.find((entry) => entry.run_id === run_id)