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
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # Rewrites a question an agent wrote as prose into D3's shape (D9): a
6
+ # short context paragraph and lettered options, exactly one recommended.
7
+ # The page draws a button per option and a text box under them, so a
8
+ # person can answer with one click instead of composing a reply to a
9
+ # paragraph.
10
+ #
11
+ # One call to a small model on OpenRouter, through `inquirex-llm`'s
12
+ # `OpenRouterAdapter`, which asks for a JSON object and checks that both
13
+ # fields came back. What the model returns is not trusted to be D3: it is
14
+ # cleaned, written out with {Plans::Choices.write}, and read back with
15
+ # {Plans::Choices.parse}, and anything that does not survive the trip is
16
+ # {Invalid}.
17
+ #
18
+ # It neither reads nor writes `blocked.md`; {Engine::Conversions} does,
19
+ # so this stays a function of text, and a seam: the suite sets
20
+ # {Live.choice_converter} to nil, and examples about it hand it an
21
+ # adapter double, so nothing in the tests reaches OpenRouter.
22
+ class ChoiceConverter
23
+ # Small and quick, and listed by OpenRouter and by inquirex-llm's own
24
+ # catalogue (`Models::OPENROUTER_ALIASES[:claude_haiku]`).
25
+ # `questions.model` in `.openlanes.yml` replaces it.
26
+ DEFAULT_MODEL = "anthropic/claude-haiku-5.5"
27
+
28
+ # What the model reads with the question.
29
+ PROMPT = <<~PROMPT
30
+ A software agent stopped and wrote the question below for a person. The person answers it in a
31
+ web page that shows one button per option and a free-text box under them. Rewrite the question as
32
+ multiple choice:
33
+
34
+ - `context`: one short paragraph with what the person needs to know to choose. Keep every fact,
35
+ number and name that matters; drop repetition. Do not repeat the heading.
36
+ - `options`: an array of 2 to 5 objects `{"label": String, "recommended": Boolean}`, each a distinct
37
+ answer the question already implies, written as one short line in the agent's own terms.
38
+ Exactly one has `"recommended": true`: the one the question itself prefers, or, if it prefers
39
+ none, the safest one.
40
+
41
+ Never change what is being asked, never add facts the question does not state, never answer it,
42
+ and never add an option for "something else" or "type something instead": the page has a text box.
43
+ PROMPT
44
+
45
+ # What the model answered could not be made into a D3 question.
46
+ class Invalid < Error; end
47
+
48
+ # @!attribute [r] body
49
+ # @return [String] the question's new body, D3-shaped Markdown
50
+ # @!attribute [r] count
51
+ # @return [Integer] how many options it offers
52
+ # @!attribute [r] model
53
+ # @return [String] the model that wrote it
54
+ Result = Data.define(:body, :count, :model)
55
+
56
+ # A converter when OPENROUTER_API_KEY is set, nil otherwise.
57
+ #
58
+ # @param env [#[]]
59
+ # @param config [OpenLanes::Config]
60
+ # @return [OpenLanes::Live::ChoiceConverter, nil]
61
+ def self.from_env(env = ENV, config: Config.current)
62
+ key = env["OPENROUTER_API_KEY"].to_s.strip
63
+ key.empty? ? nil : new(api_key: key, model: config.questions_model || DEFAULT_MODEL)
64
+ end
65
+
66
+ # The model's answer, cleaned and written in D3's shape.
67
+ #
68
+ # @param output [Hash] `context` and `options`, as the adapter parsed them
69
+ # @return [String] the new body
70
+ # @raise [Invalid] when it cannot be one
71
+ def self.render(output)
72
+ output = output.to_h.transform_keys(&:to_sym)
73
+ context = output[:context].to_s.strip
74
+ raise Invalid, "no context" if context.empty?
75
+ # A heading inside the body would end the question there, and a
76
+ # `Bn` or `An` line would start another one.
77
+ if [Plans::BlockedFile::HEADING, Plans::OPEN_BLOCK, Plans::ANSWER_BLOCK].any? { context.match?(it) }
78
+ raise Invalid, "the context holds a heading"
79
+ end
80
+
81
+ options = Array(output[:options]).map { option(it) }
82
+ .reject { |label, _| label.match?(Plans::Choices::SOMETHING_ELSE) }
83
+ raise Invalid, "#{options.size} option#{"s" unless options.size == 1}, not 2 or more" if options.size < 2
84
+
85
+ recommended = options.each_index.select { options[it].last }
86
+ raise Invalid, "#{recommended.size} options recommended, not one" unless recommended.size == 1
87
+
88
+ body = Plans::Choices.write(context:, labels: options.map(&:first), recommended: recommended.first)
89
+ back = Plans::Choices.parse(body)
90
+ raise Invalid, "the rewrite does not read back as #{options.size} choices" unless back&.options&.size == options.size
91
+
92
+ body
93
+ end
94
+
95
+ # One option as a label on one line and whether it is recommended; a
96
+ # recommendation the model wrote into the label is taken out of it, since
97
+ # the flag already says so.
98
+ #
99
+ # @param raw [Hash, Object]
100
+ # @return [Array(String, Boolean)]
101
+ # @raise [Invalid]
102
+ def self.option(raw)
103
+ raise Invalid, "an option is not an object" unless raw.is_a?(Hash)
104
+
105
+ raw = raw.transform_keys(&:to_sym)
106
+ label = raw[:label].to_s.gsub(/\s+/, " ").sub(Plans::Choices::RECOMMENDED, " ").strip
107
+ raise Invalid, "an option has no label" if label.empty?
108
+
109
+ [label, [true, "true"].include?(raw[:recommended])]
110
+ end
111
+ private_class_method :option
112
+
113
+ # @param api_key [String, nil] OpenRouter's; only read to build the default adapter
114
+ # @param model [String] an OpenRouter model id
115
+ # @param adapter [#call, nil] answers `call(node, answers)` with a Hash
116
+ # holding `context` and `options`, as `Inquirex::LLM::OpenRouterAdapter`
117
+ # does; built on first use when nil, so a process that never converts
118
+ # never loads inquirex
119
+ def initialize(api_key: nil, model: DEFAULT_MODEL, adapter: nil)
120
+ @api_key = api_key
121
+ @model = model
122
+ @adapter = adapter
123
+ @mutex = Mutex.new
124
+ end
125
+
126
+ # @return [String]
127
+ attr_reader :model
128
+
129
+ # Blocks for as long as the request takes (the adapter gives up after
130
+ # 30 seconds without an answer), so the engine calls it on a thread.
131
+ #
132
+ # @param heading [String] the question's heading, for context; it is kept
133
+ # @param body [String] the question as the agent wrote it
134
+ # @return [OpenLanes::Live::ChoiceConverter::Result]
135
+ # @raise [Invalid] when the answer is not a D3 question
136
+ # @raise [StandardError] whatever the adapter raises: no network, a 4xx, bad JSON
137
+ def call(heading:, body:)
138
+ output = adapter.call(node, { heading: heading.to_s, question: body.to_s })
139
+ written = self.class.render(output)
140
+ Result.new(body: written, count: Plans::Choices.parse(written).options.size, model:)
141
+ end
142
+
143
+ private
144
+
145
+ def adapter
146
+ @mutex.synchronize do
147
+ @adapter ||= begin
148
+ require "inquirex-llm"
149
+ Inquirex::LLM::OpenRouterAdapter.new(api_key: @api_key, model:)
150
+ end
151
+ end
152
+ end
153
+
154
+ def node
155
+ @node ||= begin
156
+ require "inquirex-llm"
157
+ Inquirex::LLM::Node.new(id: :choices,
158
+ verb: :extract,
159
+ prompt: PROMPT,
160
+ schema: Inquirex::LLM::Schema.new(context: :string, options: :array),
161
+ from_steps: %i[heading question])
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # A second opinion on {Plans::Choices}: does this message ask its reader
8
+ # to choose one of the options it lists? Asked of Jev, TypeSafe's System
9
+ # One model, through `inquirex-llm`'s `JevAdapter`, as one boolean.
10
+ #
11
+ # The parser cannot tell "Done so far: 1. … 2. …" from a numbered choice;
12
+ # Jev can. Its answer only ever narrows: `false` takes the buttons away and
13
+ # leaves the text box, `true` keeps them, and `nil` (no answer, an error, a
14
+ # timeout, an answer too unsure to count) leaves the parser's verdict as it
15
+ # was. A missing key therefore costs nothing but the second opinion.
16
+ #
17
+ # The snapshot is rebuilt every time the board moves, so every answer is
18
+ # kept, in this process, by the SHA256 of the text: one request per
19
+ # distinct question, ever. A request still in flight when {#call} gives up
20
+ # waiting goes on in its thread, and the next snapshot finds its answer.
21
+ #
22
+ # A seam, like {Vcs::GitHub} and {Linear::API}: the suite sets
23
+ # {Live.choice_judge} to nil, and examples about the judge hand it an
24
+ # adapter double, so nothing in the tests reaches TypeSafe.
25
+ class ChoiceJudge
26
+ # What Jev reads with the message. Its boolean question is whether
27
+ # `asks_to_choose` is true according to the message.
28
+ PROMPT = <<~PROMPT
29
+ The message below was written by a software agent to a person, who answers it in a web page.
30
+ `asks_to_choose` is true when the message asks the person to pick one of the options it lists
31
+ (lettered or numbered alternatives, or a plain yes/no question), and false when the list is
32
+ something else: steps already done, findings, a plan, or a question that wants a written answer.
33
+ PROMPT
34
+
35
+ # How long a snapshot waits for an answer it does not have yet.
36
+ TIMEOUT = 3.0
37
+
38
+ # Below this Jev is guessing, and the parser's verdict stands.
39
+ MIN_CONFIDENCE = 0.6
40
+
41
+ # The schema field Jev answers.
42
+ FIELD = :asks_to_choose
43
+
44
+ # A judge when TYPESAFE_API_KEY is set, nil otherwise.
45
+ #
46
+ # @param env [#[]]
47
+ # @return [OpenLanes::Live::ChoiceJudge, nil]
48
+ def self.from_env(env = ENV)
49
+ key = env["TYPESAFE_API_KEY"].to_s.strip
50
+ key.empty? ? nil : new(api_key: key)
51
+ end
52
+
53
+ # @param api_key [String, nil] TypeSafe's; only read to build the default adapter
54
+ # @param adapter [#call, nil] answers `call(node, answers)` with a Hash
55
+ # holding {FIELD}, as `Inquirex::LLM::JevAdapter` does; built on first
56
+ # use when nil, so a process that never judges never loads inquirex
57
+ # @param timeout [Float] seconds {#call} waits for an answer it lacks
58
+ def initialize(api_key: nil, adapter: nil, timeout: TIMEOUT)
59
+ @api_key = api_key
60
+ @adapter = adapter
61
+ @timeout = timeout
62
+ @answers = {}
63
+ @mutex = Mutex.new
64
+ end
65
+
66
+ # @param text [String] the question, as the person would read it
67
+ # @return [Boolean, nil] whether it asks to choose; nil when Jev has not
68
+ # said, or could not
69
+ def call(text)
70
+ key = Digest::SHA256.hexdigest(text.to_s)
71
+ asking = @mutex.synchronize { @answers[key] ||= ask(text.to_s) }
72
+ asking.join(@timeout) ? asking.value : nil
73
+ end
74
+
75
+ private
76
+
77
+ # @return [Thread] whose value is true, false or nil
78
+ def ask(text)
79
+ Thread.new do
80
+ Thread.current.report_on_exception = false
81
+ adapter.call(node, { message: text })[FIELD]
82
+ rescue StandardError => e
83
+ UI.log("choice judge: no verdict (#{e.class})")
84
+ nil
85
+ end
86
+ end
87
+
88
+ def adapter
89
+ @mutex.synchronize do
90
+ @adapter ||= begin
91
+ require "inquirex-llm"
92
+ Inquirex::LLM::JevAdapter.new(api_key: @api_key, min_confidence: MIN_CONFIDENCE)
93
+ end
94
+ end
95
+ end
96
+
97
+ def node
98
+ @node ||= begin
99
+ require "inquirex-llm"
100
+ Inquirex::LLM::Node.new(id: :choice,
101
+ verb: :extract,
102
+ prompt: PROMPT,
103
+ schema: Inquirex::LLM::Schema.new(FIELD => :boolean),
104
+ from_steps: [:message])
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -39,9 +39,17 @@ module OpenLanes
39
39
  # Extend every claim this node holds. One that lapsed and was taken by
40
40
  # another node in the meantime is dropped from {#held}, not fought for.
41
41
  #
42
+ # One that is simply gone is taken back: a Redis that restarted empty
43
+ # (W32) has lost every claim, and reading that as "another machine
44
+ # took it" stopped this machine's own agents for plans nobody else
45
+ # wanted. `SET NX` still loses to a node that got there first.
46
+ #
42
47
  # @return [Array<String>] the ordinals lost
43
48
  def renew
44
- lost = held.reject { |ordinal| @store.compare_and_expire(@keys.claim(ordinal), @keys.node, @ttl) }
49
+ lost = held.reject do |ordinal|
50
+ key = @keys.claim(ordinal)
51
+ @store.compare_and_expire(key, @keys.node, @ttl) || @store.set(key, @keys.node, ex: @ttl, nx: true)
52
+ end
45
53
  @mutex.synchronize { @held -= lost }
46
54
  lost
47
55
  end
@@ -42,25 +42,28 @@ module OpenLanes
42
42
 
43
43
  # Every kind the page may send.
44
44
  KINDS = %w[answer hold_blocked release_hold resume discard message hold wrap_up compact stop stop_all
45
- set_model switch_model add_plan create_plans].freeze
45
+ set_model switch_model add_plan create_plans respond pause_discuss resume_anyway
46
+ stop_plan restart_plan set_concurrency].freeze
46
47
 
47
48
  # Kinds that act on one plan, and so must name it.
48
49
  PLAN_KINDS = %w[answer hold_blocked release_hold resume discard message hold wrap_up compact stop
49
- set_model switch_model].freeze
50
+ set_model switch_model respond pause_discuss resume_anyway stop_plan restart_plan].freeze
50
51
 
51
52
  # Kinds that act on one agent of that plan, and so must name it too.
52
53
  AGENT_KINDS = %w[hold wrap_up compact stop set_model switch_model].freeze
53
54
 
54
55
  # Payload fields each kind cannot do without.
55
56
  PAYLOAD = {
56
- "answer" => %w[question text],
57
- "hold_blocked" => %w[question],
58
- "release_hold" => %w[question],
59
- "message" => %w[text],
60
- "set_model" => %w[model],
61
- "switch_model" => %w[model],
62
- "add_plan" => %w[ordinal],
63
- "create_plans" => %w[idea]
57
+ "answer" => %w[question text],
58
+ "hold_blocked" => %w[question],
59
+ "release_hold" => %w[question],
60
+ "message" => %w[text],
61
+ "set_model" => %w[model],
62
+ "switch_model" => %w[model],
63
+ "add_plan" => %w[ordinal],
64
+ "create_plans" => %w[idea],
65
+ "respond" => %w[text],
66
+ "set_concurrency" => %w[limit]
64
67
  }.freeze
65
68
 
66
69
  # Ids are ULIDs; this only keeps an absurd one out of a hash field.
@@ -31,26 +31,49 @@ module OpenLanes
31
31
 
32
32
  # Command kind to the method that applies it.
33
33
  HANDLERS = {
34
- "answer" => :answer,
35
- "hold_blocked" => :hold_blocked,
36
- "release_hold" => :release_hold,
37
- "resume" => :resume,
38
- "discard" => :discard,
39
- "message" => :message,
40
- "hold" => :signal,
41
- "wrap_up" => :signal,
42
- "compact" => :compact,
43
- "stop" => :signal,
44
- "stop_all" => :stop_all,
45
- "set_model" => :set_model,
46
- "switch_model" => :switch_model,
47
- "add_plan" => :add_plan,
48
- "create_plans" => :create_plans
34
+ "answer" => :answer,
35
+ "hold_blocked" => :hold_blocked,
36
+ "release_hold" => :release_hold,
37
+ "resume" => :resume,
38
+ "discard" => :discard,
39
+ "message" => :message,
40
+ "hold" => :signal,
41
+ "wrap_up" => :signal,
42
+ "compact" => :compact,
43
+ "stop" => :signal,
44
+ "stop_all" => :stop_all,
45
+ "set_model" => :set_model,
46
+ "switch_model" => :switch_model,
47
+ "add_plan" => :add_plan,
48
+ "create_plans" => :create_plans,
49
+ "respond" => :respond,
50
+ "pause_discuss" => :pause_discuss,
51
+ "resume_anyway" => :resume_anyway,
52
+ "stop_plan" => :stop_plan,
53
+ "restart_plan" => :restart_plan,
54
+ "set_concurrency" => :set_concurrency
49
55
  }.freeze
50
56
 
57
+ # How long "Resume anyway" lifts a 💣's halt.
58
+ RESUME_FOR = 24 * 3600
59
+
51
60
  # Plans a person may not add to a run: there is nothing left to do.
52
61
  FINISHED = Snapshot::FINISHED
53
62
 
63
+ # The copilot turn that answers a slash command: `"<id>.1"`, the turn
64
+ # the page's "reading" waits for. {Web::Demo} writes the same one, so
65
+ # the demo's copilot ends its wait as the engine's does.
66
+ #
67
+ # @param reply [OpenLanes::Live::Reply]
68
+ # @return [OpenLanes::Live::Copilot::Turn]
69
+ def self.echo_turn(reply)
70
+ Copilot::Turn.new(id: "#{reply.id}.1",
71
+ from: "copilot",
72
+ kind: "text",
73
+ text: [reply.status, reply.reason].compact.join(": "),
74
+ at: reply.at)
75
+ end
76
+
54
77
  # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
55
78
  # @param keys [OpenLanes::Live::Keys]
56
79
  # @param node [OpenLanes::Live::Node]
@@ -106,9 +129,19 @@ module OpenLanes
106
129
  @ready = true
107
130
  end
108
131
 
132
+ # A group that went with a Redis restarted empty is made again, and
133
+ # the read tried once more; that it vanished once is no reason to stop
134
+ # the engine, as it did (#59).
135
+ #
109
136
  # @param id [String] "0" for this consumer's pending, ">" for new
110
137
  # @return [Array<OpenLanes::Live::Store::Entry>]
111
- def read(id) = @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
138
+ def read(id)
139
+ @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
140
+ rescue GroupMissing
141
+ @ready = false
142
+ prepare
143
+ @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
144
+ end
112
145
 
113
146
  # Answer the commands whose work finished since the last tick. A
114
147
  # command still at work is neither answered nor acknowledged, so an
@@ -135,7 +168,7 @@ module OpenLanes
135
168
  # the page is part of the run's story, beside what the agents did.
136
169
  UI.log("from the page: #{command.kind.tr("_", " ")}", **{ plan: command.plan, agent: command.agent }.compact)
137
170
  status, reason = apply(command, dispatcher)
138
- return reply(entry, command.id, status, reason) unless status == :pending
171
+ return reply(entry, command.id, status, reason, echo: command.payload["source"] == "copilot") unless status == :pending
139
172
 
140
173
  @pending[entry.id] = [entry, command.id, reason]
141
174
  nil
@@ -157,25 +190,36 @@ module OpenLanes
157
190
  ["refused", "#{command.kind} failed: #{e.message}"]
158
191
  end
159
192
 
193
+ # A command the copilot sent (a slash command, plan 020.00) is also
194
+ # answered in the conversation, as turn `"<id>.1"`: that is the turn the
195
+ # page's "reading" waits for, and the conversation keeps the outcome.
196
+ # It is written in the same `MULTI`, so a redelivery, which
197
+ # {#answer_again} handles, can never write it twice.
198
+ #
199
+ # @param echo [Boolean] whether to answer in the copilot stream too
160
200
  # @return [OpenLanes::Live::Reply]
161
- def reply(entry, id, status, reason)
201
+ def reply(entry, id, status, reason, echo: false)
162
202
  reply = Reply.new(id:, status:, reason:, at: @clock.call)
163
203
  json = reply.to_json
164
- @store.multi do |tx|
204
+ turn = Commands.echo_turn(reply) if echo
205
+ EventBus.for(@store).transaction do |tx|
165
206
  tx.hset(@keys.done, id, json)
166
- tx.xadd(@keys.replies, { json: }, maxlen: Keys::REPLIES_LENGTH)
207
+ tx.append(@keys.replies, { json: }, maxlen: Keys::REPLIES_LENGTH)
208
+ tx.append(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH) if turn
167
209
  tx.xack(@keys.commands, Keys::GROUP, entry.id)
168
210
  @record&.write(tx)
169
211
  end
212
+ News.publish(store: @store, keys: @keys, kind: "reply")
170
213
  reply
171
214
  end
172
215
 
173
216
  # @return [OpenLanes::Live::Reply] the first reply, sent again
174
217
  def answer_again(entry, first)
175
- @store.multi do |tx|
176
- tx.xadd(@keys.replies, { json: first }, maxlen: Keys::REPLIES_LENGTH)
218
+ EventBus.for(@store).transaction do |tx|
219
+ tx.append(@keys.replies, { json: first }, maxlen: Keys::REPLIES_LENGTH)
177
220
  tx.xack(@keys.commands, Keys::GROUP, entry.id)
178
221
  end
222
+ News.publish(store: @store, keys: @keys, kind: "reply")
179
223
  data = JSON.parse(first)
180
224
  Reply.new(id: data["id"], status: data["status"], reason: data["reason"], at: Time.iso8601(data["at"]))
181
225
  end
@@ -217,17 +261,25 @@ module OpenLanes
217
261
 
218
262
  # `## A<n>` under the question, signed, for `lando-broker` to fold in
219
263
  # (W10). The dispatcher hands the plan to the broker once every
220
- # question has an answer, and the plan leaves ⭕️ when the file is
264
+ # question has an answer, and the plan leaves ⭕ when the file is
221
265
  # drained. The answer carries the command's id in a comment, so a
222
266
  # command applied again after a crash finds it and writes nothing.
223
267
  #
224
268
  # @return [String, nil]
225
269
  def answer(command, dispatcher)
270
+ return extension(command, dispatcher) if command["question"].to_s.match?(/\AX\d+\z/i)
271
+ return quality(command, dispatcher) if command["question"].to_s.casecmp?(Engine::QualityQuestion::NAME)
272
+
226
273
  subject, question = question!(command, dispatcher)
227
- path = File.join(subject.feature.path, "blocked.md")
228
- return nil if File.read(path).include?(marker(command))
274
+ return nil if File.read(File.join(subject.feature.path, "blocked.md")).include?(marker(command))
229
275
  return "B#{question.number} already has an answer waiting" if question.answered
230
276
 
277
+ write_answer(subject, question, command, dispatcher)
278
+ end
279
+
280
+ # @return [nil]
281
+ def write_answer(subject, question, command, dispatcher)
282
+ path = File.join(subject.feature.path, "blocked.md")
231
283
  File.open(path, "a") do |file|
232
284
  file.write("\n## A#{question.number}\n\n#{command["text"]}\n\n" \
233
285
  "Answered by #{@node.user} on #{@clock.call.strftime("%Y-%m-%d")}\n#{marker(command)}\n")
@@ -237,6 +289,83 @@ module OpenLanes
237
289
  nil
238
290
  end
239
291
 
292
+ # An answer to the harness's request for more time or rounds (E1).
293
+ # The page sends the chosen option, "b) Extend Time by 10 minutes";
294
+ # only its letter counts, so there is nothing to parse but that.
295
+ #
296
+ # @return [String]
297
+ def extension(command, dispatcher)
298
+ subject = subject!(command, dispatcher)
299
+ number = command["question"][1..].to_i
300
+ letter = command["text"].to_s.strip[/\A([a-c])\)?(?:\s|\z)/i, 1] or
301
+ raise Refusal, "X#{number} takes a), b) or c), not #{command["text"].to_s.strip[0, 40].inspect}"
302
+ settled = dispatcher.answer_extension(subject, number, letter.downcase, by: @node.user) or
303
+ raise Refusal, "#{command.plan} has no open request X#{number}"
304
+ settled["grant"] == "live" ? "#{settled["agent"]} has #{settled["seconds"] / 60} more minutes" : "#{settled["agent"]} gets one more round"
305
+ end
306
+
307
+ # R4's answer: a) high or b) mvp, written into `.openlanes.yml` (U3).
308
+ # Only the letter counts, as for {#extension}.
309
+ #
310
+ # @return [String]
311
+ def quality(command, dispatcher)
312
+ subject = subject!(command, dispatcher)
313
+ name = Engine::QualityQuestion::NAME
314
+ letter = command["text"].to_s.strip[/\A([ab])\)?(?:\s|\z)/i, 1] or
315
+ raise Refusal, "#{name} takes a) or b), not #{command["text"].to_s.strip[0, 40].inspect}"
316
+ chosen = Engine::QualityQuestion.answer!(subject.feature.path, letter, by: @node.user, root: File.dirname(dispatcher.runner.tree.dir)) or
317
+ raise Refusal, "#{command.plan} has no open question #{name}"
318
+ dispatcher.wake!
319
+ "desired_codebase_quality: #{chosen} in #{Config::FILE}"
320
+ end
321
+
322
+ # ---- bombs ------------------------------------------------------------
323
+
324
+ # The 💣 notice's answer box (plan 009): the first open question with
325
+ # no answer yet gets this one, in exactly the form {#answer} writes,
326
+ # so the drain that clears a ⭕ clears the 💣 too.
327
+ #
328
+ # @return [String, nil]
329
+ def respond(command, dispatcher)
330
+ subject = bomb!(command, dispatcher)
331
+ return nil if File.read(File.join(subject.feature.path, "blocked.md")).include?(marker(command))
332
+
333
+ question = Lifecycle::Unblocker.questions(subject).reject(&:answered).first or
334
+ raise Refusal, "every question #{command.plan} asks already has an answer waiting"
335
+ write_answer(subject, question, command, dispatcher)
336
+ "answered B#{question.number}"
337
+ end
338
+
339
+ # Pause and Discuss (N10): every agent on this machine holds, and the
340
+ # page opens the copilot on the plan. No file changes.
341
+ #
342
+ # @return [String]
343
+ def pause_discuss(command, dispatcher)
344
+ bomb!(command, dispatcher)
345
+ held = dispatcher.running.map(&:key).select { dispatcher.signal(it, Execution::Control::HOLD) }
346
+ held.each { @run_state.hold_car(it, held: true) }
347
+ "held #{held.size} agent#{"s" unless held.size == 1}; discuss #{command.plan} in the copilot"
348
+ end
349
+
350
+ # Resume anyway for {RESUME_FOR}: the halt this 💣 causes lapses on its
351
+ # own, with no config edit to undo.
352
+ #
353
+ # @return [String]
354
+ def resume_anyway(command, dispatcher)
355
+ subject = bomb!(command, dispatcher)
356
+ till = Plans::PlanState.for(subject.feature.path).resume_anyway!(till: @clock.call + RESUME_FOR)
357
+ dispatcher.wake!
358
+ "#{command.plan} halts nothing until #{till.utc.iso8601}"
359
+ end
360
+
361
+ # @return [OpenLanes::Plans::Subject] the plan, refused unless it is 💣
362
+ def bomb!(command, dispatcher)
363
+ subject = subject!(command, dispatcher)
364
+ raise Refusal, "#{command.plan} is #{subject.status}, not a 💣" unless subject.status.key == :bomb
365
+
366
+ subject
367
+ end
368
+
240
369
  # @return [nil]
241
370
  def hold_blocked(command, dispatcher) = hold_question(command, dispatcher, held: true)
242
371
 
@@ -417,7 +546,7 @@ module OpenLanes
417
546
  nil
418
547
  end
419
548
 
420
- # The idea becomes a ⚪️ plan whose spec.md opens with it, then joins the run.
549
+ # The idea becomes a ⚪ plan whose spec.md opens with it, then joins the run.
421
550
  #
422
551
  # @return [String]
423
552
  def create_plans(command, dispatcher)
@@ -432,10 +561,10 @@ module OpenLanes
432
561
  ->(problem) { raise Refusal, "could not create the plan: #{problem}" }
433
562
  )
434
563
  feature = Plans::Feature.parse(path)
435
- spec = Lifecycle.brief(path:, title: feature.title, root: File.dirname(tree.dir), seed: "#{idea}\n\n#{marker(command)}")
436
- .write_scaffold!
437
- settings = idea_frontmatter(command.payload["frontmatter"])
438
- File.write(spec, "#{YAML.dump(settings)}---\n\n#{File.read(spec)}") unless settings.empty?
564
+ seed = [idea, decisions(command.payload["decisions"]), marker(command)].compact.join("\n\n")
565
+ brief = Lifecycle.brief(path:, title: feature.title, root: File.dirname(tree.dir), seed:)
566
+ brief.write_scaffold!
567
+ brief.stamp!(idea_frontmatter(command.payload["frontmatter"]))
439
568
  created(feature, dispatcher)
440
569
  end
441
570
 
@@ -462,6 +591,22 @@ module OpenLanes
462
591
  end
463
592
  end
464
593
 
594
+ # The interview's answers (M6) as a `## Decisions` section of the
595
+ # spec, so the agents downstream find them made instead of blocking to
596
+ # ask. A skipped interview sends none, and the spec is as it was.
597
+ #
598
+ # @param list [Array<Hash>, nil] `payload.decisions`: `question`, `answer`
599
+ # @return [String, nil]
600
+ def decisions(list)
601
+ rows = Array(list).filter_map do |entry|
602
+ next unless entry.is_a?(Hash)
603
+
604
+ question, answer = entry.transform_keys(&:to_s).values_at("question", "answer").map { it.to_s.gsub(/\s+/, " ").strip }
605
+ "- **#{question}** #{answer}" unless question.empty? || answer.empty?
606
+ end
607
+ "## Decisions\n\n#{rows.join("\n")}" unless rows.empty?
608
+ end
609
+
465
610
  # @return [String]
466
611
  def created(feature, dispatcher)
467
612
  claim!(feature.ordinal)
@@ -491,6 +636,48 @@ module OpenLanes
491
636
  json ? JSON.parse(json).values_at("user", "host").join("@") : "another machine"
492
637
  end
493
638
 
639
+ # ---- lanes (plan 020.00) ----------------------------------------------
640
+
641
+ # `/stop NNN`: every agent on the plan stops, and the plan leaves the board.
642
+ #
643
+ # @return [String]
644
+ def stop_plan(command, dispatcher)
645
+ ordinal = ordinal!(command)
646
+ told = dispatcher.stop_plan(ordinal)
647
+ told.each { @run_state.hold_car(it, held: false) }
648
+ "stopped #{told.size} agent#{"s" unless told.size == 1} on #{ordinal} and took it off the board"
649
+ end
650
+
651
+ # `/restart NNN`: a lane that lost its agent is dispatched again.
652
+ #
653
+ # @return [String]
654
+ def restart_plan(command, dispatcher)
655
+ ordinal = ordinal!(command)
656
+ dispatcher.restart_plan(ordinal)
657
+ "#{ordinal} restarts at the next tick"
658
+ end
659
+
660
+ # `/concurrency N`. Lowering it stops nobody; the run waits for agents
661
+ # to finish until it is under the new limit.
662
+ #
663
+ # @return [String]
664
+ def set_concurrency(command, dispatcher)
665
+ limit = command.payload["limit"]
666
+ raise Refusal, "concurrency must be a whole number of at least 1, not #{limit.inspect}" unless limit.is_a?(Integer) && limit >= 1
667
+
668
+ before = dispatcher.runner.jobs
669
+ dispatcher.concurrency = limit
670
+ @run_state.concurrency = limit
671
+ over = dispatcher.running.size - limit
672
+ wait = over.positive? ? "; the run waits for #{over} agent#{"s" unless over == 1} to finish" : ""
673
+ "concurrency #{before} → #{limit}#{wait}"
674
+ end
675
+
676
+ # @return [String] the plan's ordinal, normalised
677
+ def ordinal!(command)
678
+ (Plans::Ordinal.parse(command.plan.to_s) or raise Refusal, "#{command.plan} is not a plan number").to_s
679
+ end
680
+
494
681
  # ---- lookups ----------------------------------------------------------
495
682
 
496
683
  # @return [OpenLanes::Plans::Subject]