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
@@ -45,7 +45,7 @@ module OpenLanes
45
45
 
46
46
  # The writes {#multi} accepts. Reads inside a transaction answer
47
47
  # nothing until it commits, so none are offered.
48
- WRITES = %i[set del expire hset hdel xadd xack].freeze
48
+ WRITES = %i[set del expire hset hdel xadd xack xtrim].freeze
49
49
 
50
50
  # Records the writes of one {#multi} block, to be applied together.
51
51
  class Transaction
@@ -144,6 +144,15 @@ module OpenLanes
144
144
  # @return [Integer] entries in the stream
145
145
  def xlen(key) = call("XLEN", key)
146
146
 
147
+ # Drops every entry older than +minid+, exactly rather than with `~`,
148
+ # so a message an hour old is gone when the hour is up, not when Redis
149
+ # next finds a whole node to free.
150
+ #
151
+ # @param key [String]
152
+ # @param minid [String] `<ms>-<seq>`, the oldest id kept
153
+ # @return [Integer] how many were removed
154
+ def xtrim(key, minid:) = call(*xtrim_command(key, minid:))
155
+
147
156
  # Creates the group, and the stream with it. Asking twice is not an
148
157
  # error: every engine start asks.
149
158
  #
@@ -169,6 +178,10 @@ module OpenLanes
169
178
  # @return [Array<Entry>]
170
179
  def xreadgroup(key, group, consumer, id: ">", count: 100)
171
180
  entries(call("XREADGROUP", "GROUP", group, consumer, "COUNT", count, "STREAMS", key, id)&.fetch(key, nil))
181
+ rescue RedisClient::CommandError => e
182
+ raise unless e.message.start_with?("NOGROUP")
183
+
184
+ raise GroupMissing, e.message.lines.first.to_s.strip
172
185
  end
173
186
 
174
187
  # @return [Integer] how many were acknowledged
@@ -215,6 +228,24 @@ module OpenLanes
215
228
  found.uniq.sort
216
229
  end
217
230
 
231
+ # ---- pub/sub ----------------------------------------------------------
232
+
233
+ # @param channel [String]
234
+ # @param message [String]
235
+ # @return [Integer] how many subscriptions received it
236
+ def publish(channel, message) = call("PUBLISH", channel, message)
237
+
238
+ # A connection of its own, subscribed to +channels+ and +patterns+.
239
+ #
240
+ # @return [OpenLanes::Live::Subscriber]
241
+ def subscriber(channels: [], patterns: []) = Subscriber.new(url:, channels:, patterns:)
242
+
243
+ # The database in the URL. Pub/sub ignores databases, so whoever names
244
+ # a channel puts this in it (see {Keys#chat_room}).
245
+ #
246
+ # @return [Integer]
247
+ def db = @client.config.db.to_i
248
+
218
249
  # ---- transactions -----------------------------------------------------
219
250
 
220
251
  # The writes the block makes, applied atomically.
@@ -250,10 +281,14 @@ module OpenLanes
250
281
  case name
251
282
  when :set then set_command(*args, **kwargs)
252
283
  when :xadd then xadd_command(*args, **kwargs)
284
+ when :xtrim then xtrim_command(*args, **kwargs)
253
285
  else [name.to_s.upcase, *args]
254
286
  end
255
287
  end
256
288
 
289
+ # @return [Array]
290
+ def xtrim_command(key, minid:) = ["XTRIM", key, "MINID", minid]
291
+
257
292
  # @return [Array]
258
293
  def set_command(key, value, ex: nil, nx: false)
259
294
  ["SET", key, value, *("NX" if nx), *(["EX", ex] if ex)]
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis-client"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # One Redis pub/sub connection, subscribed once to the channels and
8
+ # patterns it was made with (plan 006's chat). A subscribed connection
9
+ # can do nothing else, so it is its own connection, never the {Store}'s.
10
+ #
11
+ # Made by {Store#subscriber}; {MemorySubscriber} is the suite's.
12
+ class Subscriber
13
+ # Seconds the shortest wait lasts. redis-client reads a timeout of 0 as
14
+ # "wait for ever", which is the one thing a polling loop must not do.
15
+ SHORTEST = 0.01
16
+
17
+ # @param url [String]
18
+ # @param channels [Array<String>]
19
+ # @param patterns [Array<String>]
20
+ # @param timeout [Float] seconds to connect and to wait for each confirmation
21
+ # @raise [OpenLanes::Live::Unavailable]
22
+ def initialize(url:, channels: [], patterns: [], timeout: Store::TIMEOUT)
23
+ @url = url
24
+ guarded do
25
+ @pubsub = RedisClient.config(url:, timeout:).new_client.pubsub
26
+ @pubsub.call("SUBSCRIBE", *channels) unless channels.empty?
27
+ @pubsub.call("PSUBSCRIBE", *patterns) unless patterns.empty?
28
+ confirm(channels.size + patterns.size, timeout)
29
+ end
30
+ rescue Unavailable
31
+ # The caller never gets this object, so nobody else can close the
32
+ # connection, and a chat retrying a NOPERM every step would pile up
33
+ # one open socket per try until the GC got round to them.
34
+ close
35
+ raise
36
+ end
37
+
38
+ # The next message, waiting up to +timeout+ seconds for one.
39
+ #
40
+ # @param timeout [Float]
41
+ # @return [Array(String, String), nil] its channel and its payload
42
+ # @raise [OpenLanes::Live::Unavailable]
43
+ def next_message(timeout)
44
+ loop do
45
+ event = read([timeout, SHORTEST].max) or return nil
46
+ case event.first
47
+ when "message" then return [event[1], event[2]]
48
+ when "pmessage" then return [event[2], event[3]]
49
+ end
50
+ end
51
+ end
52
+
53
+ # @return [void]
54
+ def close = @pubsub&.close
55
+
56
+ private
57
+
58
+ # Waits until Redis has confirmed every subscription, so a message
59
+ # published the moment this returns is one this connection receives.
60
+ #
61
+ # @return [void]
62
+ def confirm(expected, timeout)
63
+ confirmed = 0
64
+ while confirmed < expected
65
+ event = read(timeout) or raise Unavailable, "Redis at #{Live.mask(@url)} did not confirm a subscription"
66
+ confirmed += 1 if %w[subscribe psubscribe].include?(event.first)
67
+ end
68
+ end
69
+
70
+ # The next event on the connection. redis-client hands a refusal back
71
+ # as an error object rather than raising it, so a `NOPERM` from an ACL
72
+ # that withholds `PSUBSCRIBE` would otherwise reach `event.first` as a
73
+ # NoMethodError and kill the hub's thread without a word.
74
+ #
75
+ # @param timeout [Float]
76
+ # @return [Array, nil]
77
+ # @raise [OpenLanes::Live::Unavailable]
78
+ def read(timeout)
79
+ event = guarded { @pubsub.next_event(timeout) }
80
+ guarded { raise event } if event.is_a?(RedisClient::Error)
81
+ event
82
+ end
83
+
84
+ # @return [Object] the block's value
85
+ # @raise [OpenLanes::Live::Unavailable]
86
+ def guarded
87
+ yield
88
+ rescue RedisClient::Error => e
89
+ raise Unavailable, "Redis at #{Live.mask(@url)} is unavailable: #{e.message.sub(/\s*\(redis:.*\)\z/, "")}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -17,6 +17,11 @@ module OpenLanes
17
17
  # Redis could not be reached, or stopped answering.
18
18
  class Unavailable < Error; end
19
19
 
20
+ # A stream's consumer group is not there: a Redis that restarted empty
21
+ # (W32) took it with everything else. Whoever reads the group creates it
22
+ # again; nothing is wrong with the connection.
23
+ class GroupMissing < Error; end
24
+
20
25
  module_function
21
26
 
22
27
  # W16's order: `--redis-url`, then `.openlanes.yml`'s `redis_url:`, then
@@ -36,6 +41,16 @@ module OpenLanes
36
41
  # @return [String]
37
42
  def mask(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)([^:@/]*):[^@/]+@}i, '\1\2:***@')
38
43
 
44
+ # The address with all of its userinfo replaced, user name included, for
45
+ # a page anyone on the network can open. {.mask} keeps the user name
46
+ # because a log read by the person who ran the engine is better for it; a
47
+ # page shown on a shared screen is not.
48
+ #
49
+ # @param url [String]
50
+ # @return [String] `redis://:***@host:6379/0`, or the URL as it was when
51
+ # it carries no userinfo
52
+ def redact(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)[^@/]*@}i, '\1:***@')
53
+
39
54
  # @param url [String]
40
55
  # @return [OpenLanes::Live::Store]
41
56
  def store(url) = Store.new(url:)
@@ -54,9 +69,64 @@ module OpenLanes
54
69
  store
55
70
  end
56
71
 
72
+ # The process's {ChoiceJudge}: one, so its answers are shared by every
73
+ # snapshot built here; nil without TYPESAFE_API_KEY, and nil in the suite.
74
+ #
75
+ # @return [OpenLanes::Live::ChoiceJudge, nil]
76
+ def choice_judge
77
+ return @choice_judge if defined?(@choice_judge)
78
+
79
+ @choice_judge = ChoiceJudge.from_env
80
+ end
81
+
82
+ # @param judge [#call, nil] answers `call(text)` with true, false or nil
83
+ # @return [void]
84
+ def choice_judge=(judge)
85
+ @choice_judge = judge
86
+ end
87
+
88
+ # The process's {ChoiceConverter}: nil without OPENROUTER_API_KEY, and
89
+ # nil in the suite.
90
+ #
91
+ # @return [OpenLanes::Live::ChoiceConverter, nil]
92
+ def choice_converter
93
+ return @choice_converter if defined?(@choice_converter)
94
+
95
+ @choice_converter = ChoiceConverter.from_env
96
+ end
97
+
98
+ # @param converter [#call, nil] answers `call(heading:, body:)` with a
99
+ # {ChoiceConverter::Result}
100
+ # @return [void]
101
+ def choice_converter=(converter)
102
+ @choice_converter = converter
103
+ end
104
+
105
+ # The process's {Interviewer} (M6): nil without OPENROUTER_API_KEY, and
106
+ # nil in the suite.
107
+ #
108
+ # @return [OpenLanes::Live::Interviewer, nil]
109
+ def interviewer
110
+ return @interviewer if defined?(@interviewer)
111
+
112
+ @interviewer = Interviewer.from_env
113
+ end
114
+
115
+ # @param interviewer [#call, nil] answers `call(idea:)` with
116
+ # {Interviewer::Question}s
117
+ # @return [void]
118
+ def interviewer=(interviewer)
119
+ @interviewer = interviewer
120
+ end
121
+
57
122
  # @return [OpenLanes::Live::Session]
58
123
  def session(**) = Session.new(**)
59
124
 
125
+ # The conversation with the run's copilot (W17), on +store+ under +keys+.
126
+ #
127
+ # @return [OpenLanes::Live::Copilot]
128
+ def copilot(**) = Copilot.new(**)
129
+
60
130
  # This checkout's keys, for the API server beside its engine.
61
131
  #
62
132
  # @param root [String]
@@ -90,7 +160,7 @@ module OpenLanes
90
160
  # @raise [OpenLanes::Live::Command::Invalid] for the API server's 422
91
161
  def enqueue(store:, keys:, input:)
92
162
  command = Command.parse(input)
93
- store.xadd(keys.commands, { json: JSON.generate(command.wire) })
163
+ EventBus.for(store).append(keys.commands, { json: JSON.generate(command.wire) })
94
164
  Reply.new(id: command.id, status: "accepted")
95
165
  end
96
166
 
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # The bodies of the questions in `blocked.md`: the lines under each
6
+ # `## Bn` heading, up to the next heading of any kind or the next `## An`;
7
+ # and the answers under `## An`, which run to the next `Bn` or `An`.
8
+ #
9
+ # The page reads a body to show it and the engine reads one to rewrite it
10
+ # into choices, so both find its edges here; two readers with two ideas
11
+ # of where a question ends would rewrite text the page never showed.
12
+ # Pure: text in, text out.
13
+ module BlockedFile
14
+ # Any Markdown heading, which ends the body of the question above it.
15
+ HEADING = /^[ \t]{0,3}\#{1,6}[ \t]/
16
+
17
+ # What sits between `A1` and the answer on its heading: a `.`, `:` or
18
+ # `)`, the close of a bold `**A1.**`, or a dash with a space after it.
19
+ # Only that, because the answer's own punctuation is part of it: `-1`
20
+ # and `--no-delete` are decisions, and `1` and `no-delete` the opposite.
21
+ SEPARATOR = /\A[ \t]*[.:)]?\**[ \t]*(?:[-–—][ \t]+)?/
22
+
23
+ # A question or an answer heading, either of which ends an answer.
24
+ BLOCK = Regexp.union(OPEN_BLOCK, ANSWER_BLOCK)
25
+
26
+ module_function
27
+
28
+ # @param text [String] the whole of blocked.md
29
+ # @return [Hash{Integer => String}] each question's number to the
30
+ # lines under its heading; a number written twice keeps the last
31
+ def bodies(text)
32
+ lines = text.to_s.lines
33
+ spans(lines).transform_values { |(from, to)| lines[from...to].join }
34
+ end
35
+
36
+ # The answers waiting under `## An` headings, by number, which is how
37
+ # the harness copies each question and its answer into the asking
38
+ # agent's journal (M3) before the broker drains them.
39
+ #
40
+ # An answer is often its heading: `## A2. Retain, do not delete`, with
41
+ # only who decided under it. So the words after the number are part of
42
+ # it. And it is a person's text, which the page writes verbatim, so a
43
+ # heading inside it (`### Database`) is part of it too; only the next
44
+ # question or answer ends it.
45
+ #
46
+ # @param text [String] the whole of blocked.md
47
+ # @return [Hash{Integer => String}] each answer's number to its heading's
48
+ # words, if any, and the lines under it
49
+ def answers(text)
50
+ lines = text.to_s.lines
51
+ spans(lines, ANSWER_BLOCK, ends: BLOCK).to_h do |number, (from, to)|
52
+ title = lines[from - 1].sub(ANSWER_BLOCK, "").sub(SEPARATOR, "").strip
53
+ [number, "#{"#{title}\n" unless title.empty?}#{lines[from...to].join}"]
54
+ end
55
+ end
56
+
57
+ # +text+ with question +number+'s body replaced by +body+, its heading
58
+ # and everything else as they were.
59
+ #
60
+ # @param text [String] the whole of blocked.md
61
+ # @param number [Integer]
62
+ # @param body [String]
63
+ # @return [String, nil] nil when the file has no such question
64
+ def replace(text, number, body)
65
+ lines = text.to_s.lines
66
+ from, to = spans(lines)[number]
67
+ return nil unless from
68
+
69
+ lines[from - 1] = "#{lines[from - 1].chomp}\n"
70
+ section = ["\n", "#{body.strip}\n"]
71
+ section << "\n" if to < lines.size
72
+ (lines[0...from] + section + lines[to..]).join
73
+ end
74
+
75
+ # +text+ with answer +number+ taken out and +note+ put at the top of its
76
+ # question's body, so the question is open again and says why.
77
+ #
78
+ # An answer the broker read and would not fold in otherwise sits under
79
+ # its question for good: the page counts the question answered and
80
+ # hides it, and the broker refuses it every time it is asked. Before
81
+ # the options, because the page reads a body's options off its end.
82
+ #
83
+ # @param text [String] the whole of blocked.md
84
+ # @param number [Integer]
85
+ # @param note [String]
86
+ # @return [String, nil] nil when the file has no such question or answer
87
+ def reject(text, number, note)
88
+ lines = text.to_s.lines
89
+ from, to = spans(lines, ANSWER_BLOCK, ends: BLOCK)[number]
90
+ return nil unless from && bodies(text).key?(number)
91
+
92
+ reopened = (lines[0...(from - 1)] + lines[to..]).join
93
+ replace(reopened, number, "#{note.strip}\n\n#{bodies(reopened)[number].strip}")
94
+ end
95
+
96
+ # @param lines [Array<String>]
97
+ # @param pattern [Regexp] {OPEN_BLOCK} for questions, {ANSWER_BLOCK}
98
+ # for answers; either kind of heading ends the other's body
99
+ # @param ends [Regexp] what else ends a body: any heading, by default
100
+ # @return [Hash{Integer => Array(Integer, Integer)}] each block's
101
+ # number to the first line under its heading and the line that ends it
102
+ def spans(lines, pattern = OPEN_BLOCK, ends: HEADING)
103
+ found = {}
104
+ open = nil
105
+ lines.each_with_index do |line, i|
106
+ number = line[pattern, 1]
107
+ if number || line.match?(ends) || line.match?(OPEN_BLOCK) || line.match?(ANSWER_BLOCK)
108
+ found[open.first] = [open.last, i] if open
109
+ open = number && [number.to_i, i + 1]
110
+ end
111
+ end
112
+ found[open.first] = [open.last, lines.size] if open
113
+ found
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # A question for a person, read as D3 shapes it: a short context paragraph,
6
+ # then lettered options `a)`, `b)`, `c)`, one of them recommended. The page
7
+ # draws a button per option, and `openlanes unblock` would offer the same
8
+ # ones, so both read them here rather than each guessing from the prose.
9
+ #
10
+ # Agents are told to write only that shape, but files written before D3,
11
+ # and agents that drift, use others. {.parse} reads them forgivingly:
12
+ #
13
+ # - lettered: `a)`, `(a)` or `a.` at the start of a line, or `a)` and
14
+ # `(a)` inline, the way agents actually run a whole choice into one
15
+ # paragraph; bullet-prefixed or bold-wrapped (`- **a)**`) alike
16
+ # - numbered: `1.`, `1)` or `Option 1:` at the start of a line
17
+ # - the W30 mark, `answers: yes, no`, which reads as `a) yes` `b) no`
18
+ #
19
+ # It is pure: text in, a value out, no file and no network. Whether the
20
+ # text really asks the reader to choose is a judgement the parser cannot
21
+ # make about a numbered list of steps already done, which is why the
22
+ # snapshot can ask {Live::ChoiceJudge} as well.
23
+ #
24
+ # @example
25
+ # choices = Plans::Choices.parse("Rebase or merge?\n\na) Rebase (Recommended)\nb) Merge")
26
+ # choices.context # => "Rebase or merge?"
27
+ # choices.options.map(&:answer) # => ["a) Rebase", "b) Merge"]
28
+ # choices.recommended.letter # => "a"
29
+ class Choices < Data.define(:context, :options)
30
+ # The text the person's choice sends back to the agent, beside how the
31
+ # option is shown. A yes/no option answers with the bare word, as W30
32
+ # promised agents ("branch on the word"); every other answers with its
33
+ # letter and label, so the agent can match either.
34
+ #
35
+ # @!attribute letter [String] "a", "b", …
36
+ # @!attribute label [String] what the button says, the marker stripped
37
+ # @!attribute recommended [Boolean]
38
+ # @!attribute answer [String] what choosing it sends
39
+ Option = Data.define(:letter, :label, :recommended, :answer) do
40
+ # @return [Hash] the page's `QuestionOption`
41
+ def to_h = { letter:, label:, recommended:, answer: }
42
+ end
43
+
44
+ # How an agent marked a yes/no question before D3 (W30), on its own line.
45
+ # Still read, and still what the prompt shows for a plain yes/no.
46
+ YES_NO_MARK = "answers: yes, no"
47
+
48
+ # {YES_NO_MARK}, read forgivingly: brackets, case and spacing vary.
49
+ YES_NO = /answers:\s*\[?\s*yes\s*,\s*no\s*\]?/i
50
+
51
+ # A letter's marker. At the start of a line (after a bullet or bold) it
52
+ # may be `a)`, `(a)` or `a.`, either case; inline only the parenthesised
53
+ # lowercase forms count, because `e.g.` and `I.` are prose.
54
+ LETTER_LINE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?(?:\(([a-z])\)|([a-z])[).])(?:\*\*)?[ \t]+/i
55
+ LETTER_INLINE = /(?<=[\s:])(?:\*\*)?(?:\(([a-z])\)|([a-z])\))(?:\*\*)?[ \t]+/
56
+
57
+ # A number's marker, line start only: `1.`, `1)`, `Option 1:`.
58
+ NUMBER_LINE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?(?:option[ \t]+(\d{1,2})[:.)]|(\d{1,2})[.)])(?:\*\*)?[ \t]+/i
59
+
60
+ # A numbered list is also how agents report steps already done, so one
61
+ # counts as a choice only after something that asks.
62
+ ASKS = /\?|\b(?:choose|chose|pick|prefer|option|which|decide)\b/i
63
+
64
+ # The recommendation, in the ways it gets written: bracketed anywhere
65
+ # (`(Recommended)`, `[recommended]`), or bare as the option's first or
66
+ # last word ("Recommended. Rebase first"). A bare "recommended" mid-label
67
+ # is prose ("the recommended layout") and stays where it is.
68
+ RECOMMENDED = /
69
+ [[:blank:]]*(?:\*\*|_)?[(\[][[:blank:]]*recommended[[:blank:]]*[)\]](?:\*\*|_)?[[:blank:]]*[.:,;]?
70
+ | \A(?:\*\*|_)?recommended(?:\*\*|_)?[[:blank:]]*[.:,;—–-][[:blank:]]*
71
+ | [[:blank:]]*[—–-][[:blank:]]*(?:\*\*|_)?recommended(?:\*\*|_)?\.?\z
72
+ /ix
73
+
74
+ # The option the page's text box already is. D3 makes it the last
75
+ # choice of every question, so an agent that writes it out gets no
76
+ # second, dead button.
77
+ SOMETHING_ELSE = /\A(?:type|write|say)\s+something\s+(?:else\s+)?instead\b|\Asomething\s+else\b|\Aother\b/i
78
+
79
+ # A label that runs into a new paragraph is not a label: the list ended
80
+ # and prose followed it, so the options are not the end of the text.
81
+ PARAGRAPH = /\n[ \t]*\n/
82
+
83
+ class << self
84
+ # @param text [String, nil] a question's body, Markdown
85
+ # @return [OpenLanes::Plans::Choices, nil] nil when the text offers
86
+ # fewer than two options, or its options are not where it ends
87
+ def parse(text)
88
+ text = text.to_s.strip
89
+ return nil if text.empty?
90
+
91
+ marked(text) || lettered(text) || numbered(text)
92
+ end
93
+
94
+ # A yes/no question about +context+.
95
+ #
96
+ # @param context [String]
97
+ # @return [OpenLanes::Plans::Choices]
98
+ def yes_no(context)
99
+ new(context: context.to_s.strip,
100
+ options: [Option.new(letter: "a", label: "yes", recommended: false, answer: "yes"),
101
+ Option.new(letter: "b", label: "no", recommended: false, answer: "no")])
102
+ end
103
+
104
+ # The D3 shape, written out: the context, a blank line, then one
105
+ # lettered line per label with the recommended one marked. {.parse}
106
+ # reads it back into the same labels, which is how a rewritten
107
+ # question proves it kept its shape.
108
+ #
109
+ # @param context [String]
110
+ # @param labels [Array<String>] one line each
111
+ # @param recommended [Integer] the index of the recommended label
112
+ # @return [String] Markdown, ending in a newline
113
+ def write(context:, labels:, recommended:)
114
+ lines = labels.each_with_index.map do |label, i|
115
+ "#{("a".ord + i).chr}) #{label}#{" (Recommended)" if i == recommended}"
116
+ end
117
+ "#{context.to_s.strip}\n\n#{lines.join("\n")}\n"
118
+ end
119
+
120
+ private
121
+
122
+ # The W30 mark, on a line of its own.
123
+ def marked(text)
124
+ line = /^[ \t]*#{YES_NO.source}[ \t]*$\n?/i
125
+ return nil unless text.match?(line)
126
+
127
+ yes_no(text.gsub(line, ""))
128
+ end
129
+
130
+ # A line's marker also matches inline from its letter on, so the
131
+ # inline match inside one (the `a)` of `- a)`) is dropped: kept, it
132
+ # would leave the bullet behind at the end of the context.
133
+ def lettered(text)
134
+ lines = scan(text, LETTER_LINE) { |m| (m[1] || m[2]).downcase.ord - "a".ord }
135
+ inline = scan(text, LETTER_INLINE) { |m| (m[1] || m[2]).ord - "a".ord }
136
+ .reject { |start, _, _| lines.any? { |from, to, _| (from...to).cover?(start) } }
137
+ run(text, (lines + inline).sort_by(&:first))
138
+ end
139
+
140
+ def numbered(text)
141
+ markers = scan(text, NUMBER_LINE) { |m| (m[1] || m[2]).to_i - 1 }
142
+ choices = run(text, markers)
143
+ choices if choices&.context&.match?(ASKS)
144
+ end
145
+
146
+ # Every marker in +text+ as `[start, finish, index]`, index 0 for `a`
147
+ # or `1`.
148
+ def scan(text, pattern)
149
+ found = []
150
+ text.scan(pattern) do
151
+ match = Regexp.last_match
152
+ found << [match.begin(0), match.end(0), yield(match)]
153
+ end
154
+ found
155
+ end
156
+
157
+ # The last run of markers that counts 0, 1, 2… up to the end of the
158
+ # text. The last, because a context may itself mention "a) or b)"
159
+ # before the real options; and to the end, because a list followed by
160
+ # more prose was something other than a choice.
161
+ def run(text, markers)
162
+ markers.each_index.reverse_each do |first|
163
+ next unless markers[first].last.zero?
164
+
165
+ picked = sequence(markers.drop(first))
166
+ next if picked.size < 2
167
+
168
+ choices = build(text, picked)
169
+ return choices if choices
170
+ end
171
+ nil
172
+ end
173
+
174
+ # From a marker for index 0, the next marker for 1, then 2, skipping
175
+ # anything out of order (an `(e)` quoted inside a label).
176
+ def sequence(markers)
177
+ markers.each_with_object([]) do |marker, picked|
178
+ picked << marker if marker.last == picked.size
179
+ end
180
+ end
181
+
182
+ def build(text, picked)
183
+ labels = picked.each_with_index.map do |(_, finish, _), i|
184
+ text[finish...(picked[i + 1]&.first || text.size)].strip
185
+ end
186
+ return nil if labels.any? { it.empty? || it.match?(PARAGRAPH) }
187
+
188
+ options = labels.each_with_index.map { |label, i| option(label, i) }
189
+ options.pop if options.last&.label&.match?(SOMETHING_ELSE)
190
+ return nil if options.size < 2
191
+
192
+ new(context: text[0...picked.first.first].strip, options: only_one_recommended(options))
193
+ end
194
+
195
+ def option(raw, index)
196
+ recommended = raw.match?(RECOMMENDED)
197
+ label = unbold(raw.sub(RECOMMENDED, " ").strip).strip
198
+ letter = ("a".ord + index).chr
199
+ Option.new(letter:, label:, recommended:, answer: "#{letter}) #{label}")
200
+ end
201
+
202
+ # A bold marker leaves its partner behind: `**a) Rebase**` becomes
203
+ # `Rebase**`. An odd count of `**` means one of them lost its pair.
204
+ def unbold(label)
205
+ label = label.sub('**', "") if label.scan("**").size.odd?
206
+ label = label[2...-2].strip if label.start_with?("**") && label.end_with?("**") && label.size > 4
207
+ label
208
+ end
209
+
210
+ # D3 says exactly one; an agent that recommends two has recommended
211
+ # the first, and the page shows one tag rather than several.
212
+ def only_one_recommended(options)
213
+ first = options.index(&:recommended)
214
+ options.each_with_index.map { |o, i| o.recommended && i != first ? o.with(recommended: false) : o }
215
+ end
216
+ end
217
+
218
+ # @return [OpenLanes::Plans::Choices::Option, nil]
219
+ def recommended = options.find(&:recommended)
220
+
221
+ # @return [Boolean] whether the options are yes and no
222
+ def yes_no? = options.map(&:label) == %w[yes no]
223
+
224
+ # @return [Array<Hash>] the page's `QuestionOption[]`
225
+ def to_a = options.map(&:to_h)
226
+ end
227
+ end
228
+ end