openlanes 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -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,7 +25,7 @@ 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
31
  # 🔴 Rejected sits at Building too: luke and rey rework it there, and
@@ -37,8 +37,8 @@ module OpenLanes
37
37
  [:ready_for_planning, "Planning", %i[ready_for_planning]],
38
38
  [:building, "Building", %i[planned building rejected]],
39
39
  [:building_ui, "Building UI", %i[building_ui]],
40
- [:in_review, "Review", %i[ready_for_review in_review]],
41
- [: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]]
42
42
  ].freeze
43
43
 
44
44
  module_function
@@ -54,7 +54,7 @@ module OpenLanes
54
54
  # The stop a plan in +state+ is shown at. A state a {GROUPS} entry
55
55
  # names is shown at that stop, which is how 🔴 Rejected lands at
56
56
  # Building. Any other state off the spine (💩, 😱) is shown where the
57
- # spine takes it next; one with no way forward (⭕️, 🅱️) where the
57
+ # spine takes it next; one with no way forward (⭕, 🛑) where the
58
58
  # plan was when it stopped, which only its history knows.
59
59
  #
60
60
  # @param state [Symbol]
@@ -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
@@ -268,10 +281,14 @@ module OpenLanes
268
281
  case name
269
282
  when :set then set_command(*args, **kwargs)
270
283
  when :xadd then xadd_command(*args, **kwargs)
284
+ when :xtrim then xtrim_command(*args, **kwargs)
271
285
  else [name.to_s.upcase, *args]
272
286
  end
273
287
  end
274
288
 
289
+ # @return [Array]
290
+ def xtrim_command(key, minid:) = ["XTRIM", key, "MINID", minid]
291
+
275
292
  # @return [Array]
276
293
  def set_command(key, value, ex: nil, nx: false)
277
294
  ["SET", key, value, *("NX" if nx), *(["EX", ex] if ex)]
@@ -27,6 +27,12 @@ module OpenLanes
27
27
  @pubsub.call("PSUBSCRIBE", *patterns) unless patterns.empty?
28
28
  confirm(channels.size + patterns.size, timeout)
29
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
30
36
  end
31
37
 
32
38
  # The next message, waiting up to +timeout+ seconds for one.
@@ -36,7 +42,7 @@ module OpenLanes
36
42
  # @raise [OpenLanes::Live::Unavailable]
37
43
  def next_message(timeout)
38
44
  loop do
39
- event = guarded { @pubsub.next_event([timeout, SHORTEST].max) } or return nil
45
+ event = read([timeout, SHORTEST].max) or return nil
40
46
  case event.first
41
47
  when "message" then return [event[1], event[2]]
42
48
  when "pmessage" then return [event[2], event[3]]
@@ -56,11 +62,25 @@ module OpenLanes
56
62
  def confirm(expected, timeout)
57
63
  confirmed = 0
58
64
  while confirmed < expected
59
- event = @pubsub.next_event(timeout) or raise Unavailable, "Redis at #{Live.mask(@url)} did not confirm a subscription"
65
+ event = read(timeout) or raise Unavailable, "Redis at #{Live.mask(@url)} did not confirm a subscription"
60
66
  confirmed += 1 if %w[subscribe psubscribe].include?(event.first)
61
67
  end
62
68
  end
63
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
+
64
84
  # @return [Object] the block's value
65
85
  # @raise [OpenLanes::Live::Unavailable]
66
86
  def guarded
@@ -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,6 +69,56 @@ 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
 
@@ -95,7 +160,7 @@ module OpenLanes
95
160
  # @raise [OpenLanes::Live::Command::Invalid] for the API server's 422
96
161
  def enqueue(store:, keys:, input:)
97
162
  command = Command.parse(input)
98
- store.xadd(keys.commands, { json: JSON.generate(command.wire) })
163
+ EventBus.for(store).append(keys.commands, { json: JSON.generate(command.wire) })
99
164
  Reply.new(id: command.id, status: "accepted")
100
165
  end
101
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