openlanes 3.0.1 → 3.1.0

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 (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require "securerandom"
6
7
  require "socket"
7
8
  require "time"
8
9
  require "tmpdir"
@@ -22,6 +23,8 @@ module OpenLanes
22
23
  # messages the agents, through `openlanes mail send`: what a pair
23
24
  # says to each other, and the RESUME notes an interrupted
24
25
  # agent leaves for its successor
26
+ # memory the harness again: each agent's journal (M2), the lines
27
+ # it printed as it worked, with the answers to its questions
25
28
  #
26
29
  # A stage still Started whose harness process is gone is a crash, which is
27
30
  # how a restarted run finds what it has to resume.
@@ -104,38 +107,216 @@ module OpenLanes
104
107
  # @return [String, nil]
105
108
  def state = read["state"]
106
109
 
110
+ # Until when a 💣 on this plan does not halt the run: "Resume anyway
111
+ # for 24 h" from the control room. Kept here, not in `.openlanes.yml`,
112
+ # so it lapses on its own and nobody has to remember to take it out.
113
+ #
114
+ # @return [Time, nil]
115
+ def resume_until
116
+ text = read["resume_until"] or return nil
117
+ parse_time(text)
118
+ end
119
+
120
+ # @param now [Time]
121
+ # @return [Boolean] whether a halt on this plan is lifted right now
122
+ def resumed?(now: Time.now) = (resume_until || now) > now
123
+
124
+ # @param till [Time] when the halt comes back
125
+ # @return [Time] as written
126
+ def resume_anyway!(till:)
127
+ update { |data| data["resume_until"] = till.iso8601 }
128
+ till
129
+ end
130
+
131
+ # ---- finished ---------------------------------------------------------
132
+
133
+ # How a plan finished: hansolo approved it, or its pull request merged.
134
+ FINISH_KINDS = %w[approved merged].freeze
135
+
136
+ # When and how a plan finished, which the control room keeps on the
137
+ # board for the rest of the run (and a restarted engine carries the
138
+ # latest one over). Kept here because nothing else records the moment:
139
+ # the folder name says ✅ but not since when, and `pull-requests.md`
140
+ # has no times at all.
141
+ #
142
+ # @!attribute [r] kind
143
+ # @return [String] one of {FINISH_KINDS}
144
+ # @!attribute [r] at
145
+ # @return [Time]
146
+ Finished = Data.define(:kind, :at) do
147
+ # @return [Boolean]
148
+ def merged? = kind == "merged"
149
+
150
+ # @return [Hash] as `state.json` and the snapshot carry it
151
+ def to_json_h = { "kind" => kind, "at" => at.utc.iso8601 }
152
+ end
153
+
154
+ # @return [OpenLanes::Plans::PlanState::Finished, nil]
155
+ def finished
156
+ record = read["finished"] or return nil
157
+ Finished.new(kind: record["kind"], at: parse_time(record["at"]))
158
+ end
159
+
160
+ # Record that the plan finished. A merge replaces an approval; nothing
161
+ # replaces a merge, so the watch, which reports the same merged pull
162
+ # request on every poll, keeps the time of the first report and writes
163
+ # nothing after it. A second approval, after a rejection and a rebuild,
164
+ # is a new approval and takes its own time.
165
+ #
166
+ # @param kind [String] one of {FINISH_KINDS}
167
+ # @param at [Time]
168
+ # @return [OpenLanes::Plans::PlanState::Finished] the record kept
169
+ # @raise [OpenLanes::Error] for a kind outside {FINISH_KINDS}
170
+ def finish!(kind, at: Time.now)
171
+ kind = kind.to_s
172
+ raise Error, "a plan finishes #{FINISH_KINDS.join(" or ")}, not #{kind.inspect}" unless FINISH_KINDS.include?(kind)
173
+
174
+ current = finished
175
+ return current if current&.merged?
176
+
177
+ kept = Finished.new(kind:, at:)
178
+ update do |data|
179
+ existing = data["finished"]
180
+ if existing && existing["kind"] == "merged"
181
+ kept = Finished.new(kind: "merged", at: parse_time(existing["at"]))
182
+ else
183
+ data["finished"] = kept.to_json_h
184
+ end
185
+ end
186
+ kept
187
+ end
188
+
189
+ # Forget that the plan finished: an agent is working on it again, or a
190
+ # person rejected what was approved. Writes nothing when there was
191
+ # nothing to forget.
192
+ #
193
+ # @return [void]
194
+ def unfinish!
195
+ return unless read.key?("finished")
196
+
197
+ update { |data| data.delete("finished") }
198
+ end
199
+
107
200
  # ---- the harness --------------------------------------------------------
108
201
 
109
- # Record or update one agent's round. A second call for the same agent
110
- # and round updates the first, so a round is one record from dispatch
111
- # to settlement.
202
+ # Record or update one agent's round. A later call for the same agent
203
+ # and round updates the latest record of it, so a round is one record
204
+ # from dispatch to settlement.
205
+ #
206
+ # A new dispatch passes +reset+, which appends a record rather than
207
+ # finding one: rounds are numbered per state and per run, so round 1 of
208
+ # a later run shares its number with round 1 of an earlier one. Merging
209
+ # into the earlier record would carry its session, checkout and outcome
210
+ # into a round that has none of them yet, and replacing it would lose
211
+ # that round from `openlanes state show`. Appending keeps both, in
212
+ # dispatch order, which {#resumption} relies on when two rounds started
213
+ # in the same second.
112
214
  #
113
215
  # @param agent [String]
114
216
  # @param round [Integer]
217
+ # @param reset [Boolean] start a new record rather than update one
115
218
  # @param fields [Hash] status, started_at, finished_at, adapter, model,
116
- # effort, up, down, seconds, note, to, run
219
+ # effort, up, down, seconds, note, to, session, cwd; `run` only in files older releases wrote
117
220
  # @return [Hash] the stage as written
118
- def stage!(agent:, round:, **fields)
221
+ def stage!(agent:, round:, reset: false, **fields)
119
222
  stage = nil
120
223
  update do |data|
121
- stage = data["stages"].find { |s| s["agent"] == agent && s["round"] == round }
224
+ stage = data["stages"].rfind { |s| s["agent"] == agent && s["round"] == round } unless reset
122
225
  unless stage
123
226
  stage = { "agent" => agent, "round" => round }
124
227
  data["stages"] << stage
125
228
  end
126
229
  stage.merge!(fields.transform_keys(&:to_s).compact)
230
+ # A settling round notes how often the plan had moved by then, in
231
+ # the same write, so {#resumption} sees a move no stage recorded.
232
+ stage["moves"] = data["moves"].to_i if fields.key?(:to)
127
233
  end
128
234
  stage
129
235
  end
130
236
 
237
+ # Whether one agent's next round may carry on the session its last one
238
+ # ran in (M1), and if not, why not. Asked before the new round's own
239
+ # stage is written, so the answer is about the rounds before it.
240
+ #
241
+ # The latest stage of +agent+ that names a session is the candidate. It
242
+ # is resumable only when it settled leaving the plan in +state+, no
243
+ # stage started after it moved the plan anywhere else, and it ran in
244
+ # +root+: `claude` keeps its sessions per working directory, so an id
245
+ # recorded in a worktree is not found from the main checkout. The
246
+ # candidate's own `from` is not compared, because an agent that
247
+ # `starts_as` renames the folder on dispatch: its first round leaves
248
+ # from Planned and works in Building, and its second leaves from
249
+ # Building.
250
+ #
251
+ # A move no agent made, such as `resync dirs` or a person through
252
+ # the CLI, records no stage, so {#state!} counts every move in `moves`
253
+ # and a settling stage notes the count it saw: a count that has grown
254
+ # since means the plan went away and came back. A folder renamed by
255
+ # hand, outside openlanes, is not counted.
256
+ #
257
+ # A round that ends Blocked starts the next one fresh, after the
258
+ # person answers: the plan went to Blocked and back. That is the case
259
+ # where the earlier context would help most, and it is deliberately
260
+ # conservative for now; the answer reaches the agent through
261
+ # blocked.md either way.
262
+ #
263
+ # @param agent [String]
264
+ # @param state [#to_s] the state key the plan is in now
265
+ # @param root [String] the checkout the new round will run in
266
+ # @return [OpenLanes::Plans::PlanState::Resumption]
267
+ def resumption(agent:, state:, root:)
268
+ data = read
269
+ all = data["stages"]
270
+ # `started_at` has second precision, so two rounds can tie on it; the
271
+ # one dispatched later sits later in the array.
272
+ last, = all.each_with_index
273
+ .select { |s, _| s["agent"] == agent && !s["session"].to_s.empty? }
274
+ .max_by { |s, i| [started(s), i] }
275
+ return Resumption.new(session: nil, reason: "no session recorded for #{agent}") unless last
276
+
277
+ session = last["session"]
278
+ state = state.to_s
279
+ reason = if last["to"].to_s.empty?
280
+ "round #{last["round"]} (#{session}) never settled"
281
+ elsif last["to"] != state
282
+ "the plan moved to #{last["to"]} after round #{last["round"]} (#{session})"
283
+ elsif (moved = moved_since(all, last, state))
284
+ "the plan moved to #{moved} since round #{last["round"]} (#{session})"
285
+ elsif last.key?("moves") && data["moves"].to_i != last["moves"]
286
+ "the plan moved away and back since round #{last["round"]} (#{session})"
287
+ elsif File.expand_path(last["cwd"].to_s) != File.expand_path(root) || last["cwd"].to_s.empty?
288
+ "session #{session} ran in #{last["cwd"] || "an unrecorded checkout"}, not #{root}"
289
+ end
290
+ Resumption.new(session: reason ? nil : session, reason: reason || "round #{last["round"]} left it in #{state}")
291
+ end
292
+
293
+ # What {#resumption} decided.
294
+ #
295
+ # @!attribute [r] session
296
+ # @return [String, nil] the id to resume, nil to start fresh
297
+ # @!attribute [r] reason
298
+ # @return [String] why, for the log
299
+ Resumption = Data.define(:session, :reason) do
300
+ # @return [Boolean]
301
+ def resume? = !session.nil?
302
+ end
303
+
131
304
  # @param key [Symbol, String] the state the folder was just moved to
132
305
  # @return [void]
133
306
  def state!(key)
134
- update { |data| data["state"] = key.to_s }
307
+ update do |data|
308
+ data["moves"] = data["moves"].to_i + 1 unless data["state"] == key.to_s
309
+ data["state"] = key.to_s
310
+ end
135
311
  end
136
312
 
313
+ # @return [Integer] how many times {#state!} has moved the plan
314
+ def moves = read["moves"].to_i
315
+
137
316
  # Stages a dead harness left Started. Nobody is working them, and nobody
138
- # will finish them unless a run picks them up.
317
+ # will finish them unless a run picks them up. Only a stage an older
318
+ # release wrote carries `run`; newer ones leave it to the run's
319
+ # {Engine::StateFile}, so they are never reported here.
139
320
  #
140
321
  # @param alive [Proc] pid → Boolean
141
322
  # @param pid [Integer] this run, which is never its own crash
@@ -192,13 +373,385 @@ module OpenLanes
192
373
  next if message
193
374
 
194
375
  number = [data["messages"].map { |m| m["number"] }.max.to_i, after].max + 1
195
- message = { "number" => number, "at" => at.iso8601, "from" => from, "to" => to, "body" => body }
376
+ # The id, not the number, is what a drain dedupes on: a worktree's
377
+ # copy of this file numbers its messages independently of the main
378
+ # checkout's, so two different messages can both be #4.
379
+ message = { "id" => SecureRandom.uuid, "number" => number, "at" => at.iso8601, "from" => from, "to" => to,
380
+ "body" => body, "read" => "no" }
196
381
  message["command"] = command if command
197
382
  data["messages"] << message
198
383
  end
199
384
  message
200
385
  end
201
386
 
387
+ # Keep a message that arrived with its number already given, as one
388
+ # drained from Redis does (B4).
389
+ #
390
+ # A message is the same message when its id matches, or, when either
391
+ # side was written before ids existed, when sender, recipient, time and
392
+ # body all match. Never by number alone: `mail send` in a worktree
393
+ # numbers against the worktree's `state.json`, the main checkout
394
+ # numbers against its own, and both can hand out #4. Deduping on the
395
+ # number dropped the second #4 as a "duplicate" without a word.
396
+ #
397
+ # So the usual case, `mail send` having already written here before it
398
+ # published, writes nothing at all and the file's mtime stays; and a
399
+ # different message whose number is taken is kept under the next free
400
+ # one.
401
+ #
402
+ # @param message [Hash] as {#message!} writes it, string keys
403
+ # @param reserved [Array<Integer>] numbers taken outside this file,
404
+ # i.e. by mailbox.md
405
+ # @return [Hash, nil] the message as written, nil when it was here
406
+ def deliver!(message, reserved: [])
407
+ return nil if messages.any? { |m| same_message?(m, message) }
408
+
409
+ delivered = nil
410
+ update do |data|
411
+ next if data["messages"].any? { |m| same_message?(m, message) }
412
+
413
+ taken = data["messages"].map { |m| m["number"].to_i } + reserved
414
+ number = taken.include?(message["number"]) ? taken.max + 1 : message["number"]
415
+ # Unread whatever it arrived saying: only `mail ack` in this file
416
+ # says its reader read it.
417
+ delivered = message.slice("id", "number", "at", "from", "to", "body", "command")
418
+ .merge("number" => number, "read" => "no")
419
+ data["messages"] << delivered
420
+ data["messages"].sort_by! { |m| m["number"].to_i }
421
+ end
422
+ delivered
423
+ end
424
+
425
+ # Mark one message read, under the lock, so an ack and a send in the
426
+ # same second cannot lose each other. Only its recipient may ack it:
427
+ # `read` says the reader read it, not that somebody did.
428
+ #
429
+ # @param number [Integer]
430
+ # @param by [String] the agent acking, who must be the recipient
431
+ # @return [Hash, nil] the message as written, nil when there is no
432
+ # such number
433
+ # @raise [OpenLanes::Error] when +by+ is not the recipient
434
+ def ack!(number, by:)
435
+ message = nil
436
+ update do |data|
437
+ message = data["messages"].find { |m| m["number"] == number }
438
+ next unless message
439
+ raise Error, "message ##{number} is for #{message["to"]}, not #{by}" unless message["to"] == by
440
+
441
+ message["read"] = "yes"
442
+ end
443
+ message
444
+ end
445
+
446
+ # ---- conversions ------------------------------------------------------
447
+
448
+ # Questions the engine rewrote into lettered choices (D9), and the ones
449
+ # it tried and could not. `blocked.md` keeps only the rewrite, so the
450
+ # words the agent wrote live here, beside the digest that keeps a
451
+ # restarted run from asking the model about the same text again.
452
+ #
453
+ # @return [Array<Hash>] oldest first; empty for a plan never converted
454
+ def conversions = read["conversions"] || []
455
+
456
+ # @param digest [String] SHA256 of the question's body as the agent wrote it
457
+ # @return [Boolean] whether that text was converted, or failed to be
458
+ def converted?(digest) = conversions.any? { it["digest"] == digest }
459
+
460
+ # @param question [Integer] the `n` of `## Bn`
461
+ # @param digest [String] SHA256 of +original+
462
+ # @param original [String] the body before the rewrite
463
+ # @param model [String] the model asked
464
+ # @param outcome [String] "converted" or "rejected"
465
+ # @param note [String, nil] why it was rejected
466
+ # @param at [Time]
467
+ # @return [Hash] the record as written
468
+ def conversion!(question:, digest:, original:, model:, outcome:, note: nil, at: Time.now)
469
+ record = { "question" => Integer(question), "digest" => digest, "original" => original, "at" => at.iso8601,
470
+ "model" => model, "outcome" => outcome, "note" => note }.compact
471
+ update { |data| (data["conversions"] ||= []) << record }
472
+ record
473
+ end
474
+
475
+ # ---- extensions -------------------------------------------------------
476
+
477
+ # The harness asking a person for more time or another round for one
478
+ # agent (E1), and how each request was answered. Kept here rather than
479
+ # in the run's memory, so a grant survives a restart and an answer can
480
+ # come from any control room, after the run that asked is gone.
481
+ #
482
+ # @return [Array<Hash>] oldest first; empty for a plan that never asked
483
+ def extensions = read["extensions"] || []
484
+
485
+ # @param agent [String]
486
+ # @param state [String] the plan state the agent was working, a {Status} key
487
+ # @param round [Integer]
488
+ # @param limit [String] "time" or "rounds", whichever ran out
489
+ # @param at [Time]
490
+ # @return [Hash] the request as written, with its `number`
491
+ def ask_extension!(agent:, state:, round:, limit:, at: Time.now)
492
+ record = nil
493
+ update do |data|
494
+ all = (data["extensions"] ||= [])
495
+ record = { "number" => (all.map { it["number"] }.max || 0) + 1, "agent" => agent, "state" => state.to_s,
496
+ "round" => Integer(round), "limit" => limit, "asked_at" => at.iso8601 }
497
+ all << record
498
+ end
499
+ record
500
+ end
501
+
502
+ # Answer a request, or close it unanswered. Once settled it stays
503
+ # settled: a second answer, from another page or a replayed command,
504
+ # changes nothing.
505
+ #
506
+ # @param number [Integer]
507
+ # @param fields [Hash{String => Object}] `answer`, `by`, `grant`, `seconds`, `closed`
508
+ # @param at [Time]
509
+ # @return [Hash, nil] the request as settled; nil when there is no such
510
+ # request, or when it was settled already
511
+ def settle_extension!(number, at: Time.now, **fields)
512
+ settled = nil
513
+ update do |data|
514
+ found = (data["extensions"] || []).find { it["number"] == Integer(number) } or next
515
+ next if found.key?("settled_at")
516
+
517
+ settled = found.merge!(fields.transform_keys(&:to_s).compact, "settled_at" => at.iso8601)
518
+ end
519
+ settled
520
+ end
521
+
522
+ # Correct how a settled request was granted, when the grant could not
523
+ # be given as settled: time for a clock that had already stopped.
524
+ #
525
+ # @param number [Integer]
526
+ # @param fields [Hash{String => Object}]
527
+ # @return [Hash, nil] the request as it now stands
528
+ def amend_extension!(number, **fields)
529
+ amended = nil
530
+ update do |data|
531
+ amended = (data["extensions"] || []).find { it["number"] == Integer(number) }&.merge!(fields.transform_keys(&:to_s))
532
+ end
533
+ amended
534
+ end
535
+
536
+ # R4's question, when this plan is the one that asked it.
537
+ #
538
+ # @return [Hash, nil] `asked_at`, and once answered `answer`, `by`, `settled_at`
539
+ def quality_ask = read["quality_ask"]
540
+
541
+ # Record that this plan asked R4's question. Asking twice changes
542
+ # nothing, so a hand-off repeated every tick keeps its first moment.
543
+ #
544
+ # @param at [Time]
545
+ # @return [Hash] the ask as it stands
546
+ def ask_quality!(at: Time.now)
547
+ update { |data| data["quality_ask"] ||= { "asked_at" => at.iso8601 } }
548
+ quality_ask
549
+ end
550
+
551
+ # @param answer [String] "high" or "mvp"
552
+ # @param by [String]
553
+ # @param at [Time]
554
+ # @return [Hash, nil] the ask as settled; nil when none is open
555
+ def settle_quality_ask!(answer:, by:, at: Time.now)
556
+ settled = nil
557
+ update do |data|
558
+ ask = data["quality_ask"]
559
+ next if ask.nil? || ask.key?("settled_at")
560
+
561
+ settled = ask.merge!("answer" => answer.to_s, "by" => by, "settled_at" => at.iso8601)
562
+ end
563
+ settled
564
+ end
565
+
566
+ # Mark a granted round's extra seconds as given to the round that ran on them.
567
+ #
568
+ # @param number [Integer]
569
+ # @return [void]
570
+ def spend_extension!(number)
571
+ update { |data| (data["extensions"] || []).find { it["number"] == Integer(number) }&.store("spent", true) }
572
+ end
573
+
574
+ # ---- merge risk ---------------------------------------------------------
575
+
576
+ # palpatine's verdicts for this visit to 🏁 (P1, P4), keyed by pull
577
+ # request number, plus the moments the engine rebased a sibling (P5).
578
+ # A visit is told apart by {#moves}: a plan sent back to 🔴 and
579
+ # brought to 🏁 again has moved since, so its old verdicts no longer
580
+ # stand and palpatine weighs the new code.
581
+ #
582
+ # @return [Hash, nil] `moves`, `at`, `verdicts` and `rebased`, nil when
583
+ # palpatine has not ruled on this visit
584
+ def merge_risk
585
+ record = read["merge_risk"] or return nil
586
+ record["moves"] == moves ? record : nil
587
+ end
588
+
589
+ # @param verdicts [Hash{Integer, String => String}] number => `auto` or `manual`
590
+ # @param at [Time]
591
+ # @return [Hash] the record kept
592
+ def assess!(verdicts, at: Time.now)
593
+ update do |data|
594
+ data["merge_risk"] = { "moves" => data["moves"].to_i, "at" => at.iso8601,
595
+ "verdicts" => verdicts.to_h { |n, v| [n.to_s, v.to_s] }, "rebased" => {} }
596
+ end
597
+ merge_risk
598
+ end
599
+
600
+ # @param number [Integer] the sibling pull request the engine rebased
601
+ # @param at [Time]
602
+ # @return [void]
603
+ def rebased!(number, at: Time.now)
604
+ update { |data| data["merge_risk"]&.fetch("rebased")&.store(number.to_s, at.iso8601) }
605
+ end
606
+
607
+ # ---- memory -----------------------------------------------------------
608
+
609
+ # Where the journals live in the document (M2).
610
+ MEMORY = "memory"
611
+
612
+ # One agent's journal on this plan, oldest round first, each entry
613
+ # numbered as the agent refers to it: 1 up, across every round.
614
+ #
615
+ # @param agent [String]
616
+ # @return [Array<OpenLanes::Plans::Journal::Round>] empty for an agent
617
+ # that never printed an entry here
618
+ def journal(agent)
619
+ rounds = read.dig(MEMORY, agent, "rounds") || {}
620
+ first = 1
621
+ rounds.sort_by { |key, _| key.to_i }.map do |key, value|
622
+ entries = Array(value["entries"]).map { Journal::Entry.from_h(it) }
623
+ round = Journal::Round.new(number: key.to_i, round: value["round"], state: value["state"], entries:, first:)
624
+ first += entries.size
625
+ round
626
+ end
627
+ end
628
+
629
+ # Append entries to one of the agent's journal rounds, as they arrive.
630
+ #
631
+ # The first call of a dispatch passes no +number+ and is given the next
632
+ # one, under the same lock as the write, so two runs on one plan cannot
633
+ # both take round 3. Later calls of the same dispatch pass it back.
634
+ #
635
+ # An `override` must name an entry before its own: one pointing at
636
+ # itself or ahead can only be a miscount, and the prompt would draw it
637
+ # as overturning something it never saw.
638
+ #
639
+ # @param agent [String]
640
+ # @param entries [Array<OpenLanes::Plans::Journal::Entry>]
641
+ # @param number [Integer, nil] the journal round, nil to start one
642
+ # @param round [Integer, nil] the dispatcher's round, kept beside it
643
+ # @param state [#to_s, nil] the state the round started in
644
+ # @return [Array(Integer, Array<OpenLanes::Plans::Journal::Rejection>)]
645
+ # the journal round written to, and what was refused
646
+ # @raise [OpenLanes::Error] on an agent name that is not one token
647
+ def journal!(agent:, entries:, number: nil, round: nil, state: nil)
648
+ raise Error, "a journal needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
649
+
650
+ rejected = []
651
+ update do |data|
652
+ rounds = ((data[MEMORY] ||= {})[agent] ||= {})["rounds"] ||= {}
653
+ number ||= rounds.keys.map(&:to_i).max.to_i + 1
654
+ target = (rounds[number.to_s] ||= { "round" => round, "state" => state&.to_s }.compact.merge("entries" => []))
655
+ count = rounds.sum { |_, value| Array(value["entries"]).size }
656
+ entries.each do |entry|
657
+ if entry.overrides && entry.overrides > count
658
+ rejected << Journal::Rejection.new(line: Journal.line(entry.kind, entry.text, overrides: entry.overrides),
659
+ reason: "entry #{count + 1} overrides #{entry.overrides}, which is not before it")
660
+ next
661
+ end
662
+ target["entries"] << entry.to_json_h
663
+ count += 1
664
+ end
665
+ end
666
+ [number, rejected]
667
+ end
668
+
669
+ # A person's answer to one of the agent's `blocked.md` questions, kept
670
+ # in the agent's journal with the question (M3): B1 with A1. Appended to
671
+ # the agent's latest round, which is the one that asked. Written once:
672
+ # a drain that is run again finds the same ref and answer already there.
673
+ # The check is made under the lock the write takes, so two drains at
674
+ # once (two control rooms, or a hand run beside the broker) cannot both
675
+ # find it missing and both write it.
676
+ #
677
+ # @param agent [String]
678
+ # @param ref [String] `B<n>`
679
+ # @param question [String]
680
+ # @param answer [String]
681
+ # @return [OpenLanes::Plans::Journal::Entry, nil] nil when it was there
682
+ # @raise [OpenLanes::Error] on an agent name that is not one token
683
+ def answer!(agent:, ref:, question:, answer:)
684
+ raise Error, "a journal needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
685
+
686
+ entry = Journal::Entry.new(kind: "question", text: question, ref:, answer:)
687
+ written = nil
688
+ update do |data|
689
+ rounds = ((data[MEMORY] ||= {})[agent] ||= {})["rounds"] ||= {}
690
+ next if rounds.each_value.any? { Array(it["entries"]).include?(entry.to_json_h) }
691
+
692
+ latest = rounds.keys.map(&:to_i).max || 1
693
+ (rounds[latest.to_s] ||= { "entries" => [] })["entries"] << entry.to_json_h
694
+ written = entry
695
+ end
696
+ written
697
+ end
698
+
699
+ # The agent whose questions `blocked.md` holds: whoever blocked last,
700
+ # a round that settled Blocked or a Blocked signature, since a plan
701
+ # blocked by hand-run agents has signatures and no stages. Neither
702
+ # kind outranks the other: a hand-run agent that blocks after a
703
+ # harness round did is the one that asked.
704
+ #
705
+ # @return [String, nil] nil when nothing on record ever blocked
706
+ def blocked_by
707
+ data = read
708
+ blocks = data["stages"].select { it["status"] == "Blocked" }
709
+ .map { [parse_time(it["finished_at"] || it["started_at"]), it["agent"]] }
710
+ blocks += data["signatures"].select { it["status"] == "Blocked" }.map { [parse_time(it["at"]), it["agent"]] }
711
+ blocks.each_with_index.max_by { |(at, _), i| [at, i] }&.first&.last
712
+ end
713
+
714
+ # ---- last-known-state -------------------------------------------------
715
+
716
+ # Where the resume records live in the document.
717
+ LAST_KNOWN = "last-known-state"
718
+
719
+ # Keep one agent's resume record, keyed by agent, so an agent
720
+ # interrupted in rounds 1 and 3 leaves one entry, not two nobody prunes.
721
+ # A harness entry does not replace the agent's own of the same round or
722
+ # later: see {LastKnownState#supersedes?}.
723
+ #
724
+ # @param agent [String]
725
+ # @param entry [OpenLanes::Plans::LastKnownState]
726
+ # @return [OpenLanes::Plans::LastKnownState] the entry kept, which is
727
+ # the existing one when the new one may not replace it
728
+ # @raise [OpenLanes::Error] on an agent name that is not one token
729
+ def record_last_known(agent, entry)
730
+ raise Error, "a resume record needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
731
+
732
+ kept = entry
733
+ update do |data|
734
+ section = (data[LAST_KNOWN] ||= {})
735
+ existing = section[agent] && LastKnownState.from_h(section[agent])
736
+ if entry.supersedes?(existing)
737
+ section[agent] = entry.to_json_h
738
+ else
739
+ kept = existing
740
+ end
741
+ end
742
+ kept
743
+ end
744
+
745
+ # @param agent [String]
746
+ # @return [OpenLanes::Plans::LastKnownState, nil]
747
+ def last_known(agent) = last_known_states[agent]
748
+
749
+ # @return [Hash{String => OpenLanes::Plans::LastKnownState}] empty when
750
+ # no agent was ever interrupted
751
+ def last_known_states
752
+ (read[LAST_KNOWN] || {}).transform_values { |h| LastKnownState.from_h(h) }
753
+ end
754
+
202
755
  # The signatures as ledger entries, so the dispatcher reads them exactly
203
756
  # as it reads a line in plan.md. Each signature takes two line numbers,
204
757
  # the entry and its handoff, so {Ledger.handoff_after} pairs them.
@@ -266,6 +819,34 @@ module OpenLanes
266
819
  Digest::SHA256.hexdigest("#{plans}\0#{identity["ordinal"] || File.basename(dir)}")[0, 16]
267
820
  end
268
821
 
822
+ # @param stage [Hash]
823
+ # @return [Time]
824
+ def started(stage) = parse_time(stage["started_at"])
825
+
826
+ # @param all [Array<Hash>] every stage
827
+ # @param since [Hash] the stage to look after
828
+ # @param state [String]
829
+ # @return [String, nil] a state some later stage left the plan in, other
830
+ # than +state+
831
+ def moved_since(all, since, state)
832
+ after = started(since)
833
+ at = all.index { it.equal?(since) }
834
+ all.each_with_index
835
+ .select { |s, i| i > at || started(s) > after }
836
+ .map { |s, _| s["to"].to_s }
837
+ .find { !it.empty? && it != state }
838
+ end
839
+
840
+ # @param one [Hash]
841
+ # @param other [Hash]
842
+ # @return [Boolean] whether the two are one message, by id when both
843
+ # carry one, by what they say otherwise
844
+ def same_message?(one, other)
845
+ return one["id"] == other["id"] if one["id"] && other["id"]
846
+
847
+ %w[from to body].all? { one[it] == other[it] } && parse_time(one["at"]) == parse_time(other["at"])
848
+ end
849
+
269
850
  # @param text [String, nil]
270
851
  # @return [Time]
271
852
  def parse_time(text)