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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # What a {PrWatch::Outcome} does to the plan folder (M4): the side
6
+ # effects the watch itself refuses to have.
7
+ #
8
+ # A person's verdict arrives on GitHub, not in a ledger, so this is where
9
+ # it is written down: the pull request's row in `pull-requests.md`, the
10
+ # folder's state, and for a rejection the comments, mailed to the agents
11
+ # who fix it. Each step is asked of the folder as it stands, so applying
12
+ # the same outcome twice changes nothing the second time; that is what
13
+ # lets the watch ask GitHub again every 15 seconds without remembering
14
+ # what it already did.
15
+ class PrOutcomes
16
+ # What changed, for the log.
17
+ #
18
+ # @!attribute [r] plan
19
+ # @return [String] ordinal
20
+ # @!attribute [r] pr
21
+ # @return [Integer]
22
+ # @!attribute [r] to
23
+ # @return [Symbol, nil] the state the folder moved to, nil when it stayed
24
+ # @!attribute [r] note
25
+ # @return [String]
26
+ Applied = Data.define(:plan, :pr, :to, :note)
27
+
28
+ # Who signs a mail carrying a person's review comments.
29
+ SENDER = "github"
30
+
31
+ # @param tree_dir [String] the `.plans` directory, reread after each rename
32
+ # @param agents [Agents::Registry] who handles 🔴, and so gets the comments
33
+ # @param clock [#call] the time a merge is recorded at
34
+ def initialize(tree_dir:, agents:, clock: -> { Clock::System.now })
35
+ @tree_dir = tree_dir
36
+ @agents = agents
37
+ @clock = clock
38
+ end
39
+
40
+ # @param outcome [PrWatch::Outcome]
41
+ # @return [Applied, nil] nil when the folder already said so
42
+ def apply(outcome)
43
+ case outcome.kind
44
+ when :rejected then reject(outcome)
45
+ when :merged then merged(outcome)
46
+ when :closed then closed(outcome)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # `review-rejected` with comments: 🔴, and the comments to the builders.
53
+ # A plan already at 🔴 has been told; one at 🟢 passes through 👀, the
54
+ # only state the machine rejects from.
55
+ def reject(outcome)
56
+ subject = find(outcome.plan) or return nil
57
+ return nil if subject.status.key == :rejected
58
+
59
+ moved = walk(subject, :rejected) or return nil
60
+ # A person overruled the approval: the board must stop showing it
61
+ # as finished.
62
+ Plans::PlanState.for(moved.feature.path).unfinish!
63
+ mailed = mail(moved, outcome)
64
+ Applied.new(plan: outcome.plan,
65
+ pr: outcome.pr,
66
+ to: :rejected,
67
+ note: "PR ##{outcome.pr} labelled #{Plans::ReviewLabel::REJECTED.name}; mailed #{mailed.join(", ")}")
68
+ end
69
+
70
+ # Merged on GitHub: the row says so, `state.json` says when (the
71
+ # watch learns of it on the poll after, which is as close as a
72
+ # local clock gets), and once no pull request is left open the plan is
73
+ # ✅, by the person who merged it.
74
+ def merged(outcome)
75
+ subject = find(outcome.plan) or return nil
76
+ record(subject, outcome.pr, "merged")
77
+ Plans::PlanState.for(subject.feature.path).finish!("merged", at: @clock.call)
78
+ subject = find(outcome.plan)
79
+ return applied(outcome, nil, "merged") if subject.pull_requests.any?(&:open?)
80
+
81
+ to = walk(subject, :approved) ? :approved : nil
82
+ applied(outcome, to, "merged")
83
+ end
84
+
85
+ # Closed unmerged: the row says so, and a plan with nothing merged and
86
+ # nothing open is ❌, with `discarded.md` saying who closed what.
87
+ def closed(outcome)
88
+ subject = find(outcome.plan) or return nil
89
+ record(subject, outcome.pr, "closed")
90
+ subject = find(outcome.plan)
91
+ prs = subject.pull_requests
92
+ return applied(outcome, nil, "closed unmerged") if prs.any? { it.open? || it.merged? }
93
+
94
+ return applied(outcome, nil, "closed unmerged") if subject.status.key == :discarded
95
+
96
+ note = File.join(subject.feature.path, "discarded.md")
97
+ written = !File.exist?(note) && File.write(note, <<~MARKDOWN)
98
+ # Discarded
99
+
100
+ Every pull request was closed on GitHub without merging: #{prs.map { "##{it.number}" }.join(", ")}.
101
+ MARKDOWN
102
+ to = walk(subject, :discarded) ? :discarded : nil
103
+ # ❌ requires the note, so it is written before the move; a refused
104
+ # move takes it back, or the folder would carry a discarded note
105
+ # while claiming some other state.
106
+ File.delete(note) if written && to.nil?
107
+ applied(outcome, to, "closed unmerged")
108
+ end
109
+
110
+ # @return [Applied]
111
+ def applied(outcome, to, what) = Applied.new(plan: outcome.plan, pr: outcome.pr, to:, note: "PR ##{outcome.pr} #{what}")
112
+
113
+ # Rewrite one row's state in `pull-requests.md`, worded as
114
+ # {Plans::PullRequests::STATES} words it, leaving every other row and
115
+ # every line around the table alone.
116
+ def record(subject, number, word)
117
+ pair = Plans::PullRequests::STATES.find { |pattern, _| pattern.match?(word) } or
118
+ raise(Error, "no pull request state matches #{word.inspect}; Plans::PullRequests::STATES must word it")
119
+ state = pair.last
120
+ rows = subject.pull_requests.map do |pr|
121
+ mine = pr.number.to_s.delete("#").to_i == number
122
+ { number: pr.number, title: pr.title, url: pr.url, state: mine ? state : pr.state }
123
+ end
124
+ Plans::PullRequests.upsert(File.join(subject.feature.path, Plans::PullRequests::FILENAME), rows)
125
+ end
126
+
127
+ # Move to +to+, through 👀 when the plan is still at 🟢: a person who
128
+ # ruled on GitHub before hansolo did has reviewed it all the same.
129
+ #
130
+ # @return [Plans::Subject, nil] the plan after the move, nil when the
131
+ # machine refused it
132
+ def walk(subject, to)
133
+ subject.machine.promote!(:in_review) if subject.status.key == :ready_for_review && !subject.machine.may?(to)
134
+ subject = find(subject.feature.ordinal.to_s)
135
+ return nil unless subject.machine.may?(to)
136
+
137
+ subject.machine.promote!(to)
138
+ find(subject.feature.ordinal.to_s)
139
+ rescue Plans::StateMachine::Refused
140
+ nil
141
+ end
142
+
143
+ # @return [Array<String>] who was mailed
144
+ def mail(subject, outcome)
145
+ body = ["PR ##{outcome.pr} was labelled #{Plans::ReviewLabel::REJECTED.name} on GitHub.", *comments(outcome)].join("\n\n")
146
+ status = Plans::STATUS_BY_KEY.fetch(:rejected)
147
+ @agents.all.select { it.handles?(status) }.map(&:name).each do |name|
148
+ Plans::Mailbox.new(dir: subject.feature.path).append(from: SENDER, to: name, body:)
149
+ end
150
+ end
151
+
152
+ def comments(outcome)
153
+ return ["No comment was left; read the review on GitHub."] if outcome.comments.empty?
154
+
155
+ outcome.comments.map { |c| "#{c[:author]} (#{c[:url]}):\n\n#{c[:body].strip}" }
156
+ end
157
+
158
+ def find(ordinal) = Plans::Tree.new(dir: @tree_dir).find(ordinal)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # Human mode (M4): while a person reviews and merges, the engine watches
6
+ # each plan's open pull request and says what happened to it.
7
+ #
8
+ # Paced by elapsed time rather than by ticks, so a faster tick loop does
9
+ # not become a faster stream of `gh` calls. It only reads; the dispatcher
10
+ # fires the events and sends the mail.
11
+ class PrWatch
12
+ # What one pull request turned out to be.
13
+ # :rejected — carries `review-rejected`; `comments` are what to fix
14
+ # :merged, :closed — no longer open, merged or not
15
+ # :waiting — still open, waiting on a person
16
+ Outcome = Data.define(:plan, :pr, :kind, :comments) do
17
+ def initialize(comments: [], **rest) = super
18
+ end
19
+
20
+ # @param github [Vcs::GitHub]
21
+ # @param clock [#call] monotonic seconds
22
+ # @param interval [Numeric] seconds between polls
23
+ def initialize(github:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, interval: 15)
24
+ @github = github
25
+ @clock = clock
26
+ @interval = interval
27
+ @last = nil
28
+ end
29
+
30
+ # @return [Boolean] whether a poll is due; the first one waits a full interval
31
+ def due?
32
+ now = @clock.call
33
+ @last ||= now
34
+ now - @last >= @interval
35
+ end
36
+
37
+ # One `gh pr list` for every watched plan, and one `gh pr view` for each
38
+ # watched pull request that list left out.
39
+ #
40
+ # @param subjects [Array<Plans::Subject>]
41
+ # @return [Array<Outcome>] one per open pull request a plan records
42
+ def poll(subjects)
43
+ @last = @clock.call
44
+ watched = subjects.flat_map { |s| s.pull_requests.select(&:open?).map { |pr| [s, pr.number.to_s.delete("#").to_i] } }
45
+ return [] if watched.empty?
46
+
47
+ by_number = @github.pulls(state: "all").to_h { |row| [row[:number], row] }
48
+ watched.filter_map do |subject, number|
49
+ row = by_number[number] || lookup(number) or next
50
+ outcome(subject, number, row)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # `gh pr list` returns only the newest pull requests, up to its limit,
57
+ # so a plan whose pull request is older than that would drop out of
58
+ # every poll and its merge or rejection would never be applied. Asked
59
+ # by number instead. One that cannot be read is skipped this poll and
60
+ # asked again on the next, rather than stopping the watch for all.
61
+ #
62
+ # @return [Hash, nil] a row shaped like {Vcs::GitHub#pulls}
63
+ def lookup(number)
64
+ @github.pull_request(number.to_s)
65
+ rescue Error
66
+ nil
67
+ end
68
+
69
+ def outcome(subject, number, row)
70
+ plan = subject.feature.ordinal.to_s
71
+ if row[:open]
72
+ return Outcome.new(plan:, pr: number, kind: :waiting) unless rejected?(row)
73
+
74
+ Outcome.new(plan:, pr: number, kind: :rejected, comments: @github.review_comments(number:))
75
+ else
76
+ Outcome.new(plan:, pr: number, kind: row[:state].to_s.start_with?("Merged") ? :merged : :closed)
77
+ end
78
+ end
79
+
80
+ def rejected?(row) = Array(row[:labels]).include?(Plans::ReviewLabel::REJECTED.name)
81
+ end
82
+ end
83
+ end
@@ -1,123 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "tmpdir"
5
+
3
6
  module OpenLanes
4
7
  module Engine
5
- # One line of `run --log FILE`, laid out as fixed-width columns.
6
- #
7
- # The log file is the only record of a headless run, and it is shared: two
8
- # `openlanes run` invocations pointed at the same `--log` both append to
9
- # it, interleaved, and so does every round within one run. That is what the
10
- # process id column is for. Without it a reader cannot tell one run's agents
11
- # from another's, and "started 003.00" twice looks like a bug rather than two
12
- # machines doing the same work.
13
- #
14
- # Fixed columns are what make the file readable by eye and by `awk` alike. A
15
- # value wider than its column is cut rather than allowed to push the columns
16
- # out, and a value that is missing still occupies its width, so a round header
17
- # with no plan or agent of its own lines up with the lines underneath it.
8
+ # What is left of the run's own column-format log now that every event
9
+ # goes through {Log}: the clock and the status cell the terminal's lines
10
+ # are drawn with, and where a spawned server's raw output is kept.
18
11
  #
19
- # @example
20
- # ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
21
- # agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
22
-
23
- # #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
12
+ # The fixed-column file it used to write is gone. Two files saying the
13
+ # same thing in two formats is the duplication this codebase keeps
14
+ # paying for; `log/development.log` is now the one record of a run.
24
15
  module ProgressLog
25
- # Wall clock only. A log line answers "when", and the date is the file's.
16
+ # Wall clock only. A terminal line answers "when", and the date is the day's.
26
17
  TIME_FORMAT = "%H:%M:%S"
27
18
 
28
- # Width of {TIME_FORMAT}'s output.
29
- TIME_WIDTH = 8
30
-
31
- # "003.00".
32
- PLAN_WIDTH = 6
33
-
34
- # Derived from the widest state as {Status#to_s} renders it, emoji
35
- # included, so a state added to {STATUSES} with a longer label cannot
36
- # silently knock the columns after it out of line — then trimmed by five
37
- # cells: the emoji already names the state, so the longest labels can
38
- # afford to lose their tails to keep the line short.
39
- STATUS_WIDTH = Plans::STATUSES.map { |status| UI.display_width(status.to_s) }.max - 5
40
-
41
- # The longest specialist name in `agents/` is `palpatine-planner` at 17,
42
- # and the names are hyphenated words rather than a bounded vocabulary, so
43
- # this leaves room for one more without a reflow.
44
- AGENT_WIDTH = 20
45
-
46
- # Enough for a 32-bit process id, right justified.
47
- PID_WIDTH = 7
48
-
49
- # "01". Rounds cap at two digits; a run that reaches a third has bigger
50
- # problems than this column.
51
- ROUND_WIDTH = 2
19
+ # Cells given to the emoji, and the gap after it. Many terminal fonts draw
20
+ # a colour emoji wider than its two cells, and with one space it overlapped
21
+ # the label's first letter ("🔴Changes").
22
+ EMOJI_WIDTH = 2
23
+ EMOJI_GAP = " "
52
24
 
53
- # " 907s". Anything longer than four digits of seconds is an agent nobody
54
- # is still waiting on.
55
- SECONDS_WIDTH = 6
56
-
57
- # Between columns, inside the brackets.
58
- SEPARATOR = " | "
59
-
60
- # Cells from the opening bracket to the closing one, separators included.
61
- # The message starts one space after this on every line, whatever fields
62
- # that line happens to be missing, so a reader indenting a wrapped message
63
- # has a number to indent by.
64
- COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
65
- SECONDS_WIDTH].freeze
66
-
67
- # @see COLUMN_WIDTHS
68
- LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
25
+ # Hex digits of the checkout's path in a default file's name.
26
+ CHECKOUT_DIGITS = 6
69
27
 
70
28
  class << self
71
- # Render one log line: bracketed fixed-width columns, then the message.
72
- #
73
- # No colour. The line goes to a file, where an escape sequence is neither
74
- # readable nor the width it claims to be.
29
+ # Where a spawned process's raw STDOUT and STDERR go: the temp
30
+ # directory, under the checkout's directory name and a short digest
31
+ # of its full path. The name alone collided: `~/a/minesweeper` and
32
+ # `~/b/minesweeper` both appended to `openlanes-minesweeper.log`, and
33
+ # two runs' lines read as one.
75
34
  #
76
- # @param message [String] the free-form tail, the only variable-width part
77
- # @param plan [String, nil] the plan's ordinal, e.g. "003.00"
78
- # @param status [String, nil] emoji and label, e.g. "⭐️ Planned"
79
- # @param agent [String, nil] the specialist's name, e.g. "yoda-writer"
80
- # @param seconds [Numeric, nil] how long this agent has been alive
81
- # @param round [String, nil] which pass over the tree, e.g. "01"
82
- # @param pid [Integer, nil] which run wrote the line
83
- # @param at [Time] injectable, so a caller can render a fixed clock
84
- # @return [String] one line, with no trailing newline
85
- def render(message, plan: nil, status: nil, agent: nil, seconds: nil,
86
- round: nil, pid: Process.pid, at: Time.now)
87
- columns = [
88
- left(at.strftime(TIME_FORMAT), TIME_WIDTH),
89
- left(plan, PLAN_WIDTH),
90
- left(status, STATUS_WIDTH),
91
- left(agent, AGENT_WIDTH),
92
- right(round, ROUND_WIDTH),
93
- right(pid, PID_WIDTH),
94
- right(duration(seconds), SECONDS_WIDTH)
95
- ]
96
-
97
- text = message.to_s
98
- prefix = "[#{columns.join(SEPARATOR)}]"
99
- text.empty? ? prefix : "#{prefix} #{text}"
35
+ # @param root [String] the checkout
36
+ # @param kind [String, nil] which process writes it, e.g. "web"
37
+ # @return [String] e.g. "/tmp/openlanes-web-minesweeper-3f9a1c.log"
38
+ def default_path(root, kind: nil)
39
+ root = File.expand_path(root)
40
+ digest = Digest::SHA256.hexdigest(root)[0, CHECKOUT_DIGITS]
41
+ File.join(Dir.tmpdir, "#{["openlanes", kind, File.basename(root), digest].compact.join("-")}.log")
100
42
  end
101
43
 
102
- # @param seconds [Numeric, nil]
103
- # @return [String] whole seconds with a trailing "s", or blank for nil
104
- def duration(seconds) = seconds.nil? ? "" : "#{seconds.round}s"
105
-
106
- # @param value [Object, nil]
107
- # @param width [Integer] terminal cells
108
- # @return [String] padded on the right to exactly +width+ cells
109
- def left(value, width) = UI.fit(value, width)
110
-
111
- # {UI.fit} pads on the right, which is the wrong end for a number. Fit
112
- # first so an over-long value is still truncated to the column, then move
113
- # the padding to the front.
44
+ # The emoji fitted to {EMOJI_WIDTH} and followed by {EMOJI_GAP}, then the
45
+ # label. A value with no label (or none at all) passes through.
114
46
  #
115
- # @param value [Object, nil]
116
- # @param width [Integer] terminal cells
117
- # @return [String] padded on the left to exactly +width+ cells
118
- def right(value, width)
119
- text = UI.fit(value, width).rstrip
120
- UI.fit("", width - UI.display_width(text)) + text
47
+ # @param status [String, nil] emoji and label, e.g. "⭐ Planned"
48
+ # @return [String, nil]
49
+ def status_cell(status)
50
+ emoji, label = status.to_s.split(" ", 2)
51
+ return status if label.nil?
52
+
53
+ "#{UI.fit(emoji, EMOJI_WIDTH)}#{EMOJI_GAP}#{label}"
121
54
  end
122
55
  end
123
56
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # R4's question, `desired_codebase_quality`, put to a person once per
6
+ # project: on the first pull request labelled `review-ok-not-perfect`
7
+ # while `.openlanes.yml` has no key. It hangs on the plan whose pull
8
+ # request raised it, kept in that plan's `state.json` so it survives a
9
+ # restart, and every other plan waiting on the same answer stays 👀
10
+ # without asking again. The answer is written into `.openlanes.yml`,
11
+ # after which nothing asks.
12
+ module QualityQuestion
13
+ # The question's name on its plan, as `X<n>` is an extension's.
14
+ NAME = "Q1"
15
+
16
+ # R4's wording, verbatim where it fits a card.
17
+ HEADING = "Please indicate the desired quality of the codebase"
18
+
19
+ # @return [String]
20
+ BODY = "This affects the severity of items found during review that will require rework. " \
21
+ "Critical blocking issues or security issues identified during review will be addressed regardless."
22
+
23
+ # One choice, the letter the page sends back and the key it writes.
24
+ #
25
+ # @!attribute [r] letter
26
+ # @return [String]
27
+ # @!attribute [r] quality
28
+ # @return [Symbol] one of {Config::QUALITIES}
29
+ # @!attribute [r] label
30
+ # @return [String]
31
+ Choice = Data.define(:letter, :quality, :label)
32
+
33
+ # @return [Array<Choice>]
34
+ CHOICES = [
35
+ Choice.new(letter: "a",
36
+ quality: :high,
37
+ label: "High quality, but slower and more expensive. A PR review labelled \"review-ok-not-perfect\" " \
38
+ "returns to developers for fixes."),
39
+ Choice.new(letter: "b",
40
+ quality: :mvp,
41
+ label: "Good enough, but faster and cheaper, think MVP (\"review-ok-not-perfect\" is accepted, " \
42
+ "as it contains no blocking issues).")
43
+ ].freeze
44
+
45
+ module_function
46
+
47
+ # Ask, unless some plan in the same tree already has the question open.
48
+ #
49
+ # @param plan_dir [String] the asking plan's folder
50
+ # @param at [Time]
51
+ # @return [Boolean] whether this plan holds the open question
52
+ def ask!(plan_dir, at: Clock::System.now)
53
+ holder = open_in(File.dirname(plan_dir))
54
+ return holder == plan_dir if holder
55
+
56
+ Plans::PlanState.for(plan_dir).ask_quality!(at:)
57
+ true
58
+ end
59
+
60
+ # @param plans_dir [String]
61
+ # @return [String, nil] the folder of the plan whose question is open
62
+ def open_in(plans_dir)
63
+ Dir.glob(File.join(plans_dir, "*", Plans::PlanState::FILENAME)).map { File.dirname(it) }.find do |dir|
64
+ ask = Plans::PlanState.for(dir).quality_ask
65
+ ask && !ask.key?("settled_at")
66
+ end
67
+ end
68
+
69
+ # The open question on this plan, as the page's `Question`s.
70
+ #
71
+ # @param subject [OpenLanes::Plans::Subject]
72
+ # @param plan [OpenLanes::Plans::PlanState]
73
+ # @return [Array<Hash>] empty unless this plan holds it, unanswered
74
+ def questions(subject, plan)
75
+ ask = plan.quality_ask
76
+ return [] if ask.nil? || ask.key?("settled_at")
77
+
78
+ [{ id: "#{subject.feature.ordinal}/#{NAME}",
79
+ number: 1,
80
+ kind: "quality",
81
+ heading: HEADING,
82
+ body: BODY,
83
+ agent: nil,
84
+ owner: "product",
85
+ options: CHOICES.map do |choice|
86
+ { letter: choice.letter, label: choice.label, recommended: choice.quality == Config::QUALITIES.first,
87
+ answer: "#{choice.letter}) #{choice.label}" }
88
+ end,
89
+ held: false,
90
+ asked_at: ask["asked_at"] }]
91
+ end
92
+
93
+ # Settle the question and write the key, unless the file has one by now.
94
+ #
95
+ # @param plan_dir [String]
96
+ # @param letter [String] "a" or "b"
97
+ # @param by [String]
98
+ # @param root [String] the project root, where `.openlanes.yml` lives
99
+ # @param at [Time]
100
+ # @return [Symbol, nil] the quality chosen; nil when the question was not open
101
+ # @raise [ArgumentError] on a letter that is not one of {CHOICES}
102
+ def answer!(plan_dir, letter, by:, root:, at: Clock::System.now)
103
+ choice = CHOICES.find { it.letter == letter.to_s.downcase } or
104
+ raise ArgumentError, "#{letter} is not one of #{CHOICES.map { "#{it.letter})" }.join(", ")}"
105
+ Plans::PlanState.for(plan_dir).settle_quality_ask!(answer: choice.quality, by:, at:) or return nil
106
+
107
+ write_key(root, choice.quality)
108
+ choice.quality
109
+ end
110
+
111
+ # Appended rather than rewritten through {Config#to_yaml}, which
112
+ # would drop the comments a person keeps in the file.
113
+ #
114
+ # @return [void]
115
+ def write_key(root, quality)
116
+ path = File.join(root, Config::FILE)
117
+ return unless Config.load(root).quality_unset?
118
+
119
+ text = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
120
+ text += "\n" unless text.empty? || text.end_with?("\n")
121
+ File.write(path, "#{text}desired_codebase_quality: #{quality}\n")
122
+ end
123
+ end
124
+ end
125
+ end