openlanes 3.0.0 → 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 (329) 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 +62 -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 +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  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 +26 -2
  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 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  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 +94 -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 +1229 -116
  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 +107 -0
  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 +38 -105
  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 +236 -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 +39 -11
  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 +665 -0
  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 +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # What the engine says once Redis is back: how long it was gone, how
9
+ # that was measured, and which copy of the run state won.
10
+ #
11
+ # The duration prefers this node's last heartbeat in Redis, because it
12
+ # is the last moment Redis provably had the engine's word. A heartbeat
13
+ # far older than the first failure is not that: it is what an RDB
14
+ # rollback restores, and measuring from it would overstate the outage by
15
+ # however long the snapshot was stale. Then the engine's own monotonic
16
+ # window is the answer, and `measured_by` says so.
17
+ class OutageReport
18
+ # Seconds a heartbeat may precede the first failure and still count.
19
+ # The publisher beats every tick, so a live one is a few seconds old.
20
+ FRESH = 15
21
+
22
+ # @param store [#hget]
23
+ # @param keys [OpenLanes::Live::Keys]
24
+ # @param node [OpenLanes::Live::Node]
25
+ # @param window [OpenLanes::Live::Outage::Window]
26
+ # @param reconciled_from [Symbol, nil] `:redis`, `:file` or nil, from
27
+ # {RunRecord#reconcile}
28
+ def initialize(store:, keys:, node:, window:, reconciled_from:)
29
+ @store = store
30
+ @keys = keys
31
+ @node = node
32
+ @window = window
33
+ @reconciled_from = reconciled_from
34
+ end
35
+
36
+ # The `run.outage` wire shape (plan 022, shared with the page).
37
+ #
38
+ # @return [Hash]
39
+ def to_h
40
+ { started_at: iso(started_at),
41
+ ended_at: iso(@window.ended_at),
42
+ seconds: seconds,
43
+ measured_by: measured_by,
44
+ reconciled_from: @reconciled_from&.to_s }
45
+ end
46
+
47
+ # @return [String] one log line
48
+ def line
49
+ from = @reconciled_from ? "run state from #{@reconciled_from}" : "no run state to reconcile"
50
+ "Redis is back after #{seconds}s (by #{measured_by}), #{from}; resuming"
51
+ end
52
+
53
+ # @return [Integer]
54
+ def seconds
55
+ (heartbeat ? @window.ended_at - heartbeat : @window.seconds).round
56
+ end
57
+
58
+ # @return [String] `"heartbeat"` or `"clock"`
59
+ def measured_by = heartbeat ? "heartbeat" : "clock"
60
+
61
+ private
62
+
63
+ # @return [Time]
64
+ def started_at = heartbeat || @window.started_at
65
+
66
+ # This node's heartbeat, when it is from just before the first failure.
67
+ #
68
+ # @return [Time, nil]
69
+ def heartbeat
70
+ return @heartbeat if defined?(@heartbeat)
71
+
72
+ @heartbeat = read_heartbeat
73
+ end
74
+
75
+ # @return [Time, nil]
76
+ def read_heartbeat
77
+ json = @store.hget(@keys.nodes, @node.name) or return nil
78
+ at = Time.iso8601(JSON.parse(json)["heartbeat_at"].to_s)
79
+ at if at <= @window.started_at && @window.started_at - at <= FRESH
80
+ rescue JSON::ParserError, ArgumentError, Unavailable
81
+ nil
82
+ end
83
+
84
+ # @param time [Time]
85
+ # @return [String]
86
+ def iso(time) = time.utc.iso8601
87
+ end
88
+ end
89
+ end
@@ -60,6 +60,12 @@ module OpenLanes
60
60
  @on_lost = block
61
61
  end
62
62
 
63
+ # {OutageReport#to_h} of the outage this run last recovered from,
64
+ # published as `run.outage` from the next snapshot on.
65
+ #
66
+ # @return [Hash, nil]
67
+ attr_accessor :outage
68
+
63
69
  # @return [OpenLanes::Live::Meter]
64
70
  attr_reader :meter
65
71
 
@@ -77,11 +83,12 @@ module OpenLanes
77
83
  @meter.record(board.rows, now:)
78
84
  snapshot = build(board, now)
79
85
  json = JSON.generate(snapshot)
80
- @store.multi do |tx|
86
+ EventBus.for(@store).transaction do |tx|
81
87
  tx.set(@keys.snapshot, json, ex: Keys::SNAPSHOT_TTL)
82
- tx.xadd(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
88
+ tx.append(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
83
89
  tx.hset(@keys.nodes, @node.name, JSON.generate(@node.wire(heartbeat_at: now)))
84
90
  end
91
+ nudge(snapshot)
85
92
  lost = @claims&.renew || []
86
93
  unless lost.empty?
87
94
  UI.log("claims lost to another machine: #{lost.join(", ")}; stopping their agents")
@@ -153,9 +160,42 @@ module OpenLanes
153
160
  claims: @claims,
154
161
  run_state: @run_state,
155
162
  meter: @meter,
163
+ # The URL the engine publishes through, not one read back from the
164
+ # environment: the page's status bar says which Redis the claims and
165
+ # run state really live in, and only the store that wrote them knows.
166
+ redis_url: @store.url,
167
+ outage: @outage,
156
168
  log:).to_h
157
169
  end
158
170
 
171
+ # Fields that move with the clock alone: the sequence number, the
172
+ # snapshot's time, this node's heartbeat, and how long each plan has
173
+ # been worked on. A car's `elapsed` is left in on purpose: while an
174
+ # agent runs, its seconds counter is what the page is there to show.
175
+ #
176
+ # @param snapshot [Hash]
177
+ # @return [Hash] what the page would see change
178
+ def content(snapshot)
179
+ snapshot.except(:seq, :at)
180
+ .merge(node: snapshot[:node].except(:heartbeat_at),
181
+ plans: Array(snapshot[:plans]).map { it.except(:elapsed) })
182
+ end
183
+
184
+ # Nudges the open pages ({News}) when this tick's snapshot says
185
+ # something the last one did not. The snapshot is still written every
186
+ # tick, because its expiry is how a node is known to be alive; only
187
+ # the nudge waits for news, so an engine with nothing to do is quiet.
188
+ #
189
+ # @param snapshot [Hash]
190
+ # @return [void]
191
+ def nudge(snapshot)
192
+ now = content(snapshot)
193
+ return if now == @published
194
+
195
+ News.publish(store: @store, keys: @keys, kind: "board")
196
+ @published = now
197
+ end
198
+
159
199
  # One more than the last published, even across an engine restart:
160
200
  # the API server reads a change of `seq` as news, and a restart that
161
201
  # counted from one again would look like the past.
@@ -50,7 +50,7 @@ module OpenLanes
50
50
  @state_file = dispatcher.state_file
51
51
  redis = read_redis
52
52
  file = @state_file&.live
53
- newer = [redis, file].compact.max_by { it["seq"].to_i }
53
+ newer = newest(redis, file)
54
54
  # A copy that is missing is behind, unless both are: then there is
55
55
  # nothing to keep until a person changes something.
56
56
  @in_redis = redis ? redis["seq"].to_i : (newer && -1) || 0
@@ -62,6 +62,37 @@ module OpenLanes
62
62
  ordinal, agent = key.split("/", 2)
63
63
  dispatcher.prefer_model(ordinal, agent, model)
64
64
  end
65
+ restore_concurrency(dispatcher)
66
+ end
67
+
68
+ # A `/concurrency` set before the restart holds after it (plan 020.00).
69
+ # One this run cannot take (a shared checkout runs one agent) is said
70
+ # once and dropped, rather than stopping the engine from starting.
71
+ #
72
+ # @param dispatcher [OpenLanes::Engine::Dispatcher]
73
+ # @return [void]
74
+ def restore_concurrency(dispatcher)
75
+ limit = @run_state.concurrency or return
76
+ dispatcher.concurrency = limit
77
+ rescue OpenLanes::Error => e
78
+ UI.log("concurrency #{limit} from before the restart not kept: #{e.message}")
79
+ end
80
+
81
+ # After Redis comes back: which copy is the newer, by the same `seq`
82
+ # rule as {#restore}. An RDB rollback leaves Redis behind or empty and
83
+ # the file wins. The engine's own state kept going through the outage,
84
+ # so nothing is restored from either; both marks are reset to what is
85
+ # really there, so the next {#flush} rewrites whichever copy is behind.
86
+ #
87
+ # @return [Symbol, nil] `:redis` or `:file`, or nil with neither copy
88
+ def reconcile
89
+ @warned = false
90
+ redis = read_redis
91
+ file = @state_file&.live
92
+ @in_redis = redis ? redis["seq"].to_i : -1
93
+ @in_file = file ? file["seq"].to_i : -1
94
+ newer = newest(redis, file) or return nil
95
+ newer.equal?(redis) ? :redis : :file
65
96
  end
66
97
 
67
98
  # Bring both copies up to the state's `seq`. The file is written by the
@@ -98,6 +129,13 @@ module OpenLanes
98
129
 
99
130
  private
100
131
 
132
+ # The copy with the higher `seq`; Redis on a tie, being listed first.
133
+ #
134
+ # @param redis [Hash, nil]
135
+ # @param file [Hash, nil]
136
+ # @return [Hash, nil]
137
+ def newest(redis, file) = [redis, file].compact.max_by { it["seq"].to_i }
138
+
101
139
  # @return [void]
102
140
  def mirror
103
141
  return unless @state_file && @in_file != @run_state.seq
@@ -9,7 +9,8 @@ module OpenLanes
9
9
  # Held in the engine, and kept by {RunRecord} in Redis and in
10
10
  # `.plans/openlanes-state.json` (W6), so an engine that restarts still
11
11
  # knows them. Every change moves {#seq} on by one, which is how the
12
- # record tells which of its two copies is newer. A model choice also
12
+ # record tells which of its two copies is newer. So does the concurrency
13
+ # a person set with `/concurrency` (plan 020.00). A model choice also
13
14
  # lives in the executor, which is what applies it; this copy is what the
14
15
  # page is shown and what a restart hands back to the executor.
15
16
  class RunState
@@ -18,9 +19,24 @@ module OpenLanes
18
19
  @held_questions = []
19
20
  @held_cars = []
20
21
  @models = {}
22
+ @concurrency = nil
21
23
  @seq = 0
22
24
  end
23
25
 
26
+ # @return [Integer, nil] the limit `/concurrency` set; nil when nobody did
27
+ def concurrency = @mutex.synchronize { @concurrency }
28
+
29
+ # @param limit [Integer]
30
+ # @return [void]
31
+ def concurrency=(limit)
32
+ @mutex.synchronize do
33
+ next if @concurrency == limit
34
+
35
+ @concurrency = limit
36
+ @seq += 1
37
+ end
38
+ end
39
+
24
40
  # @param id [String] `"<ordinal>/B<n>"`
25
41
  # @param held [Boolean]
26
42
  # @return [void]
@@ -67,7 +83,7 @@ module OpenLanes
67
83
  def to_h
68
84
  @mutex.synchronize do
69
85
  { "seq" => @seq, "held_questions" => @held_questions.dup, "held_cars" => @held_cars.dup,
70
- "models" => @models.dup }
86
+ "models" => @models.dup }.merge(@concurrency ? { "concurrency" => @concurrency } : {})
71
87
  end
72
88
  end
73
89
 
@@ -80,6 +96,7 @@ module OpenLanes
80
96
  @held_questions = Array(data["held_questions"]).map(&:to_s)
81
97
  @held_cars = Array(data["held_cars"]).map(&:to_s)
82
98
  @models = (data["models"].is_a?(Hash) ? data["models"] : {}).to_h { |k, v| [k.to_s, v.to_s] }
99
+ @concurrency = data["concurrency"].is_a?(Integer) && data["concurrency"].positive? ? data["concurrency"] : nil
83
100
  @seq = data["seq"].to_i
84
101
  end
85
102
  self
@@ -19,13 +19,16 @@ module OpenLanes
19
19
  # @param node [OpenLanes::Live::Node]
20
20
  # @param repo [String]
21
21
  # @param clock [Proc] wall time
22
+ # @param outage [OpenLanes::Live::Outage] what pauses the run while
23
+ # Redis is gone; a spec hands in one on its own clock
22
24
  def initialize(store:, root:, tree:, agents:, project: nil, node: Node.current(root:), repo: Repo.for(root),
23
- clock: -> { Time.now })
25
+ clock: -> { Time.now }, outage: Outage.new(store:, limit: Config.current.outage_limit, **Config.current.redis_backoff))
24
26
  @store = store
25
27
  @node = node
26
28
  @keys = Keys.new(repo:, node: node.name)
27
29
  @run_state = RunState.new
28
30
  @record = RunRecord.new(store:, keys: @keys, run_state: @run_state)
31
+ @outage = outage
29
32
  @claims = Claims.new(store:, keys: @keys)
30
33
  @publisher = Publisher.new(store:,
31
34
  keys: @keys,
@@ -69,20 +72,45 @@ module OpenLanes
69
72
  # @return [OpenLanes::Live::RunRecord]
70
73
  attr_reader :record
71
74
 
75
+ # @return [OpenLanes::Live::Outage]
76
+ attr_reader :outage
77
+
72
78
  # @return [Proc] the runner's `on_board:`
73
79
  def on_board = @publisher.to_proc
74
80
 
75
81
  # Wire the run in: the run state a previous engine left is taken back
76
- # on the first tick, commands are applied at the top of each tick, and
77
- # the log is heard from now on.
82
+ # on the first tick, commands are applied at the top of each tick, mail
83
+ # published to Redis is drained into the plans' `state.json`, and the
84
+ # log is heard from now on.
78
85
  #
79
86
  # @param dispatcher [OpenLanes::Engine::Dispatcher]
80
87
  # @return [void]
81
88
  def attach(dispatcher)
82
89
  @publisher.listen
83
90
  @publisher.on_lost { |ordinals| dispatcher.fence(ordinals) }
91
+ # First, so a tick that finds Redis gone pauses before anything else
92
+ # on it asks Redis for something (W32).
93
+ dispatcher.before_tick(&@outage)
84
94
  dispatcher.before_tick(&@record)
85
95
  dispatcher.before_tick(&@commands)
96
+ dispatcher.mail = Mail.new(store: @store, keys: @keys)
97
+ @outage.on_resume { recover(it) }
98
+ end
99
+
100
+ # Redis answers again: say how long it was gone and which copy of the
101
+ # run state won, and publish that as `run.outage`.
102
+ #
103
+ # @param window [OpenLanes::Live::Outage::Window]
104
+ # @return [OpenLanes::Live::OutageReport]
105
+ def recover(window)
106
+ report = OutageReport.new(store: @store,
107
+ keys: @keys,
108
+ node: @node,
109
+ window:,
110
+ reconciled_from: @record.reconcile)
111
+ UI.log(report.line)
112
+ @publisher.outage = report.to_h
113
+ report
86
114
  end
87
115
 
88
116
  # The run is over: the run state is written down, claims go back, and
@@ -101,7 +129,13 @@ module OpenLanes
101
129
  end
102
130
 
103
131
  # @return [String] where, with the password masked, and as whom
104
- def describe = "#{Live.mask(@store.url)} as #{@node.label} (#{@node.name})"
132
+ def describe = described.join(" ")
133
+
134
+ # The same, in the two parts a narrow box may break it between: the
135
+ # node id is long, and wraps mid-word when left to the box.
136
+ #
137
+ # @return [Array(String, String)] `"redis://… as kig@host"` and `"(<node id>)"`
138
+ def described = ["#{Live.mask(@store.url)} as #{@node.label}", "(#{@node.name})"]
105
139
  end
106
140
  end
107
141
  end
@@ -13,17 +13,6 @@ module OpenLanes
13
13
  # to the page's fixture, level by level, so a field renamed on one side
14
14
  # fails the other's suite.
15
15
  class Snapshot
16
- # How an agent marks a question as answerable with Yes or No (W30), on
17
- # its own line under the question's heading. The agents' prompt shows
18
- # it, and {#questions} looks for it; neither spells it twice.
19
- YES_NO_MARK = "answers: yes, no"
20
-
21
- # {YES_NO_MARK}, read forgivingly: brackets, case and spacing vary.
22
- YES_NO = /answers:\s*\[?\s*yes\s*,\s*no\s*\]?/i
23
-
24
- # Any Markdown heading, which ends the body of the question above it.
25
- HEADING = /^[ \t]{0,3}\#{1,6}[ \t]/
26
-
27
16
  # Plan states finished with, kept off "Choose an existing plan".
28
17
  FINISHED = %i[approved deployed discarded].freeze
29
18
 
@@ -42,9 +31,16 @@ module OpenLanes
42
31
  # @param meter [OpenLanes::Live::Meter]
43
32
  # @param log [Array<Hash>] `LogLine`s, newest last
44
33
  # @param adapter [OpenLanes::Adapters::Base] whose models the page offers
34
+ # @param judge [#call, nil] a second opinion on whether a question asks
35
+ # to choose ({ChoiceJudge}); nil leaves the parser's verdict alone
36
+ # @param redis_url [String, nil] the store the engine itself is using,
37
+ # unredacted: the snapshot redacts it ({Live.redact})
38
+ # @param outage [Hash, nil] {OutageReport#to_h} of the outage this run
39
+ # last recovered from; nil when there was none
45
40
  def initialize(board:, tree:, agents:, node:, repo:, project:, seq:, description: nil, at: Time.now,
46
41
  started_at: nil, claims: nil, run_state: RunState.new, meter: Meter.new, log: [],
47
- adapter: Adapters.for(Adapters::DEFAULT))
42
+ adapter: Adapters.for(Adapters::DEFAULT), judge: Live.choice_judge, redis_url: nil,
43
+ outage: nil)
48
44
  @board = board
49
45
  @tree = tree
50
46
  @agents = agents
@@ -60,6 +56,9 @@ module OpenLanes
60
56
  @meter = meter
61
57
  @log = log
62
58
  @adapter = adapter
59
+ @judge = judge
60
+ @redis_url = redis_url
61
+ @outage = outage
63
62
  end
64
63
 
65
64
  # @return [Hash]
@@ -76,8 +75,10 @@ module OpenLanes
76
75
  stations: Stations.all(@agents).map(&:wire),
77
76
  plans: mine,
78
77
  backlog: backlog(subjects, mine.map { it[:ordinal] }),
78
+ bombs: bombs(subjects),
79
79
  models: @adapter.models.map(&:wire),
80
80
  autocompact: Adapters::AUTOCOMPACT / 100.0,
81
+ redis_url: @redis_url && Live.redact(@redis_url),
81
82
  log: @log }
82
83
  end
83
84
 
@@ -93,7 +94,9 @@ module OpenLanes
93
94
  up: @board.up.to_i,
94
95
  down: @board.down.to_i,
95
96
  cost: plans.sum { it[:cost] }.round(4),
96
- agents_working: @board.running.to_i }
97
+ agents_working: @board.running.to_i,
98
+ jobs: @board.jobs.to_i,
99
+ outage: @outage }
97
100
  end
98
101
 
99
102
  # @return [String]
@@ -121,12 +124,15 @@ module OpenLanes
121
124
  ordinal = subject.feature.ordinal.to_s
122
125
  state = Plans::PlanState.for(subject.feature.path)
123
126
  stages = state.stages
127
+ finished = state.finished
124
128
  rows = @board.rows.select { it.ordinal == ordinal }
125
129
  spent = @meter.for(ordinal, method(:price))
126
130
  started = @meter.started_at(ordinal)
131
+ mailbox = Plans.mailbox(subject.feature.path)
127
132
  { ordinal:,
128
133
  title: subject.feature.title,
129
134
  slug: subject.feature.slug,
135
+ summary: Plans::Spec.summary(subject.read(Plans::Spec::FILENAME).to_s),
130
136
  status: status(subject.status),
131
137
  lane: Plans::Spec.for(subject).lane.to_s,
132
138
  station: Stations.key_for(subject.status.key, before: stages.last&.fetch("from", nil)&.to_sym).to_s,
@@ -136,15 +142,20 @@ module OpenLanes
136
142
  started_at: started && iso(started),
137
143
  elapsed: started ? (@at - started).round : 0,
138
144
  cars: rows.map { car(it, subject, stages) },
139
- pr: pull_request(subject),
145
+ pr: pull_request(subject, finished),
140
146
  questions: questions(subject, state),
141
147
  interrupted: interrupted(stages, rows),
142
- claimed_by: claimed_by(ordinal) }
148
+ claimed_by: claimed_by(ordinal),
149
+ unacked: mailbox.unacked,
150
+ mail: mailbox.unacked_pairs,
151
+ last_known: last_known(state),
152
+ locked: Plans::Lock.read(subject.feature.path)&.to_json_hash,
153
+ finished: finished && { kind: finished.kind, at: iso(finished.at) } }
143
154
  end
144
155
 
145
156
  # @param status [OpenLanes::Plans::Status]
146
157
  # @return [Hash] `Status`
147
- def status(status) = { key: status.key.to_s, emoji: status.emoji, label: status.label }
158
+ def status(status) = { key: status.key.to_s, emoji: status.emoji, label: status.label, note: status.note.to_s.delete("`") }
148
159
 
149
160
  # @param subjects [Array<OpenLanes::Plans::Subject>]
150
161
  # @param taken [Array<String>] ordinals already on the board
@@ -158,6 +169,24 @@ module OpenLanes
158
169
  end
159
170
  end
160
171
 
172
+ # Every 💣 in the tree that is not another machine's, on the board or
173
+ # not: a bomb halts the whole run, so the page shows it whatever this
174
+ # node is working on (plan 009).
175
+ #
176
+ # @param subjects [Array<OpenLanes::Plans::Subject>]
177
+ # @return [Array<Hash>] `Bomb`
178
+ def bombs(subjects)
179
+ aging = Lifecycle::Aging.new(tree: @tree, now: @at)
180
+ subjects.select { it.status.key == :bomb && !foreign?(it.feature.ordinal.to_s) }.map do |s|
181
+ resume = Plans::PlanState.for(s.feature.path).resume_until
182
+ { plan: s.feature.ordinal.to_s,
183
+ slug: s.feature.slug,
184
+ blocked_md: s.read("blocked.md").to_s,
185
+ blocked_since: iso(aging.started_at(s)),
186
+ resume_until: resume && iso(resume) }
187
+ end
188
+ end
189
+
161
190
  # ---- cars -------------------------------------------------------------
162
191
 
163
192
  # @param row [OpenLanes::Engine::Board::Row]
@@ -183,6 +212,7 @@ module OpenLanes
183
212
  elapsed: row.elapsed.to_i,
184
213
  remaining: row.remaining,
185
214
  held: @run_state.car_held?(row.key),
215
+ subagents: Array(row.running),
186
216
  next_model: next_model(row) }
187
217
  end
188
218
 
@@ -229,16 +259,24 @@ module OpenLanes
229
259
  # ---- pull request, interruption, claim --------------------------------
230
260
 
231
261
  # @param subject [OpenLanes::Plans::Subject]
262
+ # @param finished [OpenLanes::Plans::PlanState::Finished, nil]
232
263
  # @return [Hash, nil] `PullRequest`
233
- def pull_request(subject)
264
+ def pull_request(subject, finished)
234
265
  pr = subject.pull_requests.find(&:open?) || subject.pull_requests.last or return nil
235
- { number: pr.number.to_s[/\d+/].to_i, url: pr.url, state: pr_state(pr, subject), ci: nil, merged_at: nil }
266
+ merged_at = finished.at if pr.merged? && finished&.merged?
267
+ { number: pr.number.to_s[/\d+/].to_i, url: pr.url, state: pr_state(pr, subject, finished), ci: nil,
268
+ merged_at: merged_at && iso(merged_at) }
236
269
  end
237
270
 
271
+ # hansolo's approval leaves the plan at 👀 with its pull request open,
272
+ # which the status key alone reads as "open": `finished` is what says
273
+ # it was approved, so the page's pill turns green.
274
+ #
238
275
  # @return [String]
239
- def pr_state(pr, subject)
276
+ def pr_state(pr, subject, finished)
240
277
  return "merged" if pr.merged?
241
278
  return "closed" unless pr.open?
279
+ return "approved" if finished&.kind == "approved"
242
280
 
243
281
  { rejected: "rejected", approved: "approved" }.fetch(subject.status.key, "open")
244
282
  end
@@ -255,6 +293,18 @@ module OpenLanes
255
293
  { agent: last["agent"], at: last["finished_at"] || last["started_at"], note: last["note"].to_s }
256
294
  end
257
295
 
296
+ # Where each interrupted agent stood, as the next round will read it.
297
+ # Read-only: an unacked count or a resume record never changes a round.
298
+ #
299
+ # @param state [OpenLanes::Plans::PlanState]
300
+ # @return [Array<Hash>] `LastKnown`, one per agent
301
+ def last_known(state)
302
+ state.last_known_states.map do |agent, entry|
303
+ { agent:, source: entry.source, at: entry.at, round: entry.round, status: entry.status,
304
+ next_step: entry.next_step, note: entry.note }
305
+ end
306
+ end
307
+
258
308
  # @param ordinal [String]
259
309
  # @return [String, nil] the node id, without its checkout
260
310
  def claimed_by(ordinal) = @claims&.held_by(ordinal)&.then { it.split(":").first }
@@ -265,17 +315,25 @@ module OpenLanes
265
315
 
266
316
  # ---- questions --------------------------------------------------------
267
317
 
268
- # The questions `blocked.md` still asks, each with its body, whether it
269
- # is a yes/no question, and who asked.
318
+ # The questions `blocked.md` still asks, each with its context, the
319
+ # options it offers (D3), and who asked; then the harness's own
320
+ # requests for more time or rounds (E1), and R4's quality question on
321
+ # the plan that asked it.
270
322
  #
271
323
  # @param subject [OpenLanes::Plans::Subject]
272
324
  # @param state [OpenLanes::Plans::PlanState]
273
325
  # @return [Array<Hash>] `Question`
274
326
  def questions(subject, state)
327
+ blocked(subject, state) + Engine::Extensions.questions(subject, state) +
328
+ Engine::QualityQuestion.questions(subject, state)
329
+ end
330
+
331
+ # @return [Array<Hash>] `Question`, from `blocked.md`
332
+ def blocked(subject, state)
275
333
  open = Lifecycle::Unblocker.questions(subject).reject(&:answered)
276
334
  return [] if open.empty?
277
335
 
278
- bodies = bodies(subject.read("blocked.md").to_s)
336
+ bodies = Plans::BlockedFile.bodies(subject.read("blocked.md").to_s)
279
337
  asker = state.signatures.rfind { it["status"] == "Blocked" }
280
338
  asked_at = asker&.fetch("at", nil) || iso(File.mtime(File.join(subject.feature.path, "blocked.md")))
281
339
  open.map { |q| question(subject, q, bodies.fetch(q.number, ""), asker, asked_at) }
@@ -284,34 +342,37 @@ module OpenLanes
284
342
  # @return [Hash]
285
343
  def question(subject, open, body, asker, asked_at)
286
344
  id = "#{subject.feature.ordinal}/B#{open.number}"
287
- yes_no = open.heading.match?(YES_NO) || body.match?(YES_NO)
345
+ choices = choices(open.heading, body)
288
346
  { id:,
289
347
  number: open.number,
290
- heading: open.heading.sub(YES_NO, "").strip,
291
- body: body.lines.grep_v(/\A\s*#{YES_NO.source}\s*\z/i).join.strip,
348
+ kind: "block",
349
+ heading: open.heading.sub(Plans::Choices::YES_NO, "").strip,
350
+ body: choices ? choices.context : body.strip,
292
351
  agent: asker&.fetch("agent", nil),
293
352
  owner: subject.status.key == :product_blocked ? "product" : "technical",
294
- answers: yes_no ? %w[yes no] : nil,
353
+ options: choices&.to_a,
295
354
  held: @run_state.question_held?(id),
296
355
  asked_at: }
297
356
  end
298
357
 
299
- # @param text [String] the whole of blocked.md
300
- # @return [Hash{Integer => String}] each open question's number to the
301
- # lines under its heading, up to the next heading
302
- def bodies(text)
303
- found = {}
304
- current = nil
305
- text.each_line do |line|
306
- if (number = line[Plans::OPEN_BLOCK, 1])
307
- current = found[number.to_i] = +""
308
- elsif line.match?(HEADING) || line.match?(Plans::ANSWER_BLOCK)
309
- current = nil
310
- elsif current
311
- current << line
312
- end
313
- end
314
- found
358
+ # The options a question offers, or nil for one that wants words.
359
+ #
360
+ # A question its agent marked yes/no (W30) is taken at its word. Any
361
+ # other the parser found options in goes past the judge, which may say
362
+ # the list was not a choice after all; it is never asked about a
363
+ # question the parser found nothing in, since there would be no buttons
364
+ # to show.
365
+ #
366
+ # @param heading [String]
367
+ # @param body [String]
368
+ # @return [OpenLanes::Plans::Choices, nil]
369
+ def choices(heading, body)
370
+ return Plans::Choices.yes_no(body) if heading.match?(Plans::Choices::YES_NO)
371
+
372
+ found = Plans::Choices.parse(body) or return nil
373
+ return found if body.match?(Plans::Choices::YES_NO)
374
+
375
+ @judge&.call(body) == false ? nil : found
315
376
  end
316
377
 
317
378
  # @param time [Time]
@@ -25,17 +25,20 @@ module OpenLanes
25
25
  def wire = { key: key.to_s, label:, agents: }
26
26
  end
27
27
 
28
- # Key, label and the states folded into it, in spine order. ⭐️ Planned
28
+ # Key, label and the states folded into it, in spine order. ⭐ Planned
29
29
  # sits with 🟡 Building and 🟢 Ready for Review with 👀 In Review,
30
30
  # because on the board each pair is one stop with the same agents.
31
+ # 🔴 Rejected sits at Building too: luke and rey rework it there, and
32
+ # placed by the spine alone it showed them at Building UI, a stop whose
33
+ # agent list leaves luke out.
31
34
  GROUPS = [
32
35
  [:new, "New", %i[new]],
33
36
  [:researched, "Researched", %i[researched retroactive]],
34
37
  [:ready_for_planning, "Planning", %i[ready_for_planning]],
35
- [:building, "Building", %i[planned building]],
38
+ [:building, "Building", %i[planned building rejected]],
36
39
  [:building_ui, "Building UI", %i[building_ui]],
37
- [:in_review, "Review", %i[ready_for_review in_review]],
38
- [:pr, "Pull request", %i[approved deployed]]
40
+ [:in_review, "Review", %i[ready_for_review in_review verifying]],
41
+ [:pr, "Pull request", %i[completed approved deployed]]
39
42
  ].freeze
40
43
 
41
44
  module_function
@@ -48,10 +51,11 @@ module OpenLanes
48
51
  end
49
52
  end
50
53
 
51
- # The stop a plan in +state+ is shown at. A state off the spine (🔴,
52
- # 💩, 😱) is shown where the spine takes it next; one with no way
53
- # forward (⭕️, 🅱️) where the plan was when it stopped, which only
54
- # its history knows.
54
+ # The stop a plan in +state+ is shown at. A state a {GROUPS} entry
55
+ # names is shown at that stop, which is how 🔴 Rejected lands at
56
+ # Building. Any other state off the spine (💩, 😱) is shown where the
57
+ # spine takes it next; one with no way forward (⭕, 🛑) where the
58
+ # plan was when it stopped, which only its history knows.
55
59
  #
56
60
  # @param state [Symbol]
57
61
  # @param before [Symbol, nil] the state the plan was worked in last