openlanes 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # The conversation with the run's copilot, c3po-operator (W17): one Redis
9
+ # stream per node ({Keys#copilot}) holding every turn, the person's and
10
+ # the copilot's, in the order they were said. The page reads it once with
11
+ # {#history} and then follows it on the event stream it already has
12
+ # (`event: copilot`), so the copilot needs no transport of its own.
13
+ #
14
+ # Who answers is the responder's business, not this class's: the API
15
+ # server hands it {Operator}, which runs c3po-operator, and `--demo` a
16
+ # canned line. Whatever answers writes {Turn}s, and {Turn.check} refuses
17
+ # a card that lacks what the page needs to draw it, whoever wrote it.
18
+ class Copilot
19
+ # A message the person may not send as written.
20
+ class Invalid < Error; end
21
+
22
+ # A message nobody is there to answer.
23
+ class Unanswered < Error; end
24
+
25
+ # Who spoke a turn.
26
+ SPEAKERS = %w[you copilot].freeze
27
+
28
+ # What a turn is: words, a red card asking to confirm a command that
29
+ # cannot be undone, or a quiet card holding a drafted answer.
30
+ KINDS = %w[text confirm draft].freeze
31
+
32
+ # The fields each card cannot be drawn without.
33
+ FIELDS = {
34
+ "text" => [],
35
+ "confirm" => %w[command confirm done],
36
+ "draft" => %w[question draft]
37
+ }.freeze
38
+
39
+ # Characters one message may carry. A longer one is a paste gone
40
+ # wrong, and the agent would pay for every token of it.
41
+ LIMIT = 4000
42
+
43
+ # Tokens and model before the copilot has said anything that counts them.
44
+ NO_USAGE = { model: nil, up: 0, down: 0 }.freeze
45
+
46
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
47
+ # @param keys [OpenLanes::Live::Keys]
48
+ # @param responder [#call, nil] the person's {Turn} in, the copilot's
49
+ # {Turn}s out; nil for a reader, which {#say} refuses to write through
50
+ def initialize(store:, keys:, responder: nil)
51
+ @store = store
52
+ @keys = keys
53
+ @responder = responder
54
+ end
55
+
56
+ # Record the person's message, and nothing more. Answering is
57
+ # {#answer}'s, run apart from the request that brought the message: a
58
+ # real responder is a `claude -p` call, and a POST held open for the
59
+ # whole of it would keep the page from showing the message, or that
60
+ # the copilot is reading, until the answer was already written.
61
+ #
62
+ # A slash command ({Slash}, plan 020.00) is not the copilot's to
63
+ # answer: it is queued for this node's engine under the message's id,
64
+ # and the engine's reply comes back into the conversation as turn
65
+ # `"<id>.1"`. A slash message that is not a command is answered here,
66
+ # at once, with the usage. Neither reaches the responder, so a server
67
+ # with no copilot still takes them, and {Turn#slash?} tells the caller
68
+ # not to {#answer} them.
69
+ #
70
+ # @param input [String, Hash] `CopilotMessage`: `{id, text}`
71
+ # @return [OpenLanes::Live::Copilot::Turn] the person's turn
72
+ # @raise [OpenLanes::Live::Copilot::Invalid] for the API server's 422
73
+ # @raise [OpenLanes::Live::Copilot::Unanswered] when nobody would
74
+ # answer: no copilot for plain text, no live engine for a command
75
+ def say(input)
76
+ turn = message(input)
77
+ slash = Slash.parse(turn.text, id: turn.id)
78
+ # Refused before it is stored: a message kept where nothing will ever
79
+ # answer it reads, on the page, as a copilot that is merely slow.
80
+ raise Unanswered, "the copilot is not running in this server" unless slash || @responder
81
+ if slash.is_a?(Command) && !engine?
82
+ raise Unanswered, "no engine is running to apply #{turn.text.split.first}"
83
+ end
84
+
85
+ append(turn)
86
+ case slash
87
+ when Command then EventBus.for(@store).append(@keys.commands, { json: JSON.generate(slash.wire) })
88
+ when Slash::Help then append(Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: slash.text))
89
+ end
90
+ turn
91
+ end
92
+
93
+ # Store whatever the responder answers to +turn+. A responder that
94
+ # fails is answered for, under the reply id the page waits for, so the
95
+ # failure shows in the conversation rather than as a page reading
96
+ # forever.
97
+ #
98
+ # @param turn [OpenLanes::Live::Copilot::Turn] the person's, from {#say}
99
+ # @return [Array<OpenLanes::Live::Copilot::Turn>] what was stored
100
+ def answer(turn)
101
+ replies = begin
102
+ @responder.call(turn)
103
+ rescue StandardError => e
104
+ [Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: "c3po-operator could not answer: #{e.message}")]
105
+ end
106
+ replies.each { append(it) }
107
+ end
108
+
109
+ # The conversation as the page draws it on load: the newest turns,
110
+ # oldest first, and the copilot's latest model and tokens. The usage is
111
+ # looked for in the whole retained stream when the newest turns carry
112
+ # none, so a run of unmetered turns does not reset the header to zero.
113
+ #
114
+ # @param count [Integer]
115
+ # @return [Hash] `CopilotLog`: `{turns:, usage:}`
116
+ def history(count: 100)
117
+ turns = newest_turns(count)
118
+ usage = latest_usage(turns)
119
+ usage ||= latest_usage(newest_turns(Keys::COPILOT_LENGTH)) if turns.size == count
120
+ { turns:, usage: usage || NO_USAGE }
121
+ end
122
+
123
+ # Turns written after +after+, oldest first, with their stream ids.
124
+ #
125
+ # @param after [String] a stream id
126
+ # @return [Array<Array(String, Hash)>]
127
+ def since(after)
128
+ @store.xread(@keys.copilot, id: after).map { |entry| [entry.id, JSON.parse(entry.fields["json"])] }
129
+ end
130
+
131
+ # @return [String] the newest turn's stream id, `0-0` when there is none
132
+ def newest = @store.xrevrange(@keys.copilot, count: 1).first&.id || "0-0"
133
+
134
+ private
135
+
136
+ # @return [OpenLanes::Live::Copilot::Turn]
137
+ def message(input)
138
+ data = input.is_a?(String) ? JSON.parse(input) : input
139
+ raise Invalid, "a message is a JSON object" unless data.is_a?(Hash)
140
+
141
+ id = data["id"].to_s
142
+ text = data["text"].to_s.strip
143
+ raise Invalid, "id must be 1 to 64 letters, digits, - or _" unless id.match?(Command::ID)
144
+ raise Invalid, "a message needs text" if text.empty?
145
+ raise Invalid, "a message is at most #{LIMIT} characters" if text.length > LIMIT
146
+
147
+ Turn.new(id:, from: "you", kind: "text", text:)
148
+ rescue JSON::ParserError => e
149
+ raise Invalid, "not a message: #{e.message.lines.first.to_s.strip}"
150
+ end
151
+
152
+ # @return [Array<Hash>] up to +count+ turns, oldest first
153
+ def newest_turns(count)
154
+ @store.xrevrange(@keys.copilot, count:).reverse.map { JSON.parse(it.fields["json"]) }
155
+ end
156
+
157
+ # Whether this node's engine is there to read {Keys#commands}: its
158
+ # snapshot key lives only while it publishes (`EX 30`), so a command
159
+ # queued without one would sit unanswered and the page would read on.
160
+ #
161
+ # @return [Boolean]
162
+ def engine? = !@store.get(@keys.snapshot).nil?
163
+
164
+ # @return [Hash, nil]
165
+ def latest_usage(turns) = turns.reverse_each.map { it["usage"] }.find(&:itself)
166
+
167
+ # Written here, in the API server rather than by the Dispatcher, so the
168
+ # open streams are nudged here too: a turn has no other way to reach a
169
+ # stream that waits on {Keys#news}.
170
+ #
171
+ # @return [void]
172
+ def append(turn)
173
+ EventBus.for(@store).append(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH)
174
+ News.publish(store: @store, keys: @keys, kind: "copilot")
175
+ end
176
+ end
177
+
178
+ class Copilot
179
+ # One thing said in the conversation: `CopilotTurn` in `types.ts`.
180
+ #
181
+ # @!attribute [r] id
182
+ # @return [String] the person's message id; a reply's is that id, a
183
+ # dot and its place among the replies, so a reply names what it answers
184
+ # @!attribute [r] from
185
+ # @return [String] one of {SPEAKERS}
186
+ # @!attribute [r] kind
187
+ # @return [String] one of {KINDS}
188
+ # @!attribute [r] text
189
+ # @return [String]
190
+ # @!attribute [r] at
191
+ # @return [Time]
192
+ # @!attribute [r] usage
193
+ # @return [Hash, nil] `{model, up, down}`, the copilot's running total
194
+ # after this turn; nil on the person's turns and on answers no model
195
+ # wrote
196
+ # @!attribute [r] fields
197
+ # @return [Hash{String => Object}] what a card needs, per {FIELDS}
198
+ Turn = Data.define(:id, :from, :kind, :text, :at, :usage, :fields) do
199
+ def initialize(at: Time.now, usage: nil, fields: {}, **rest)
200
+ super
201
+ Turn.check(self)
202
+ end
203
+
204
+ # @return [Hash] as the wire carries it
205
+ def wire = { id:, from:, kind:, text:, at: at.utc.iso8601, usage: }.merge(fields.transform_keys(&:to_sym))
206
+
207
+ # @return [Boolean] whether this is the person's slash message, which
208
+ # the engine or {Slash} answers and the copilot never does
209
+ def slash? = from == "you" && text.start_with?("/")
210
+
211
+ # @return [String]
212
+ def to_json(*) = JSON.generate(wire)
213
+ end
214
+
215
+ class Turn
216
+ # The keys every turn has; anything else belongs to {FIELDS}.
217
+ BASE = %w[id from kind text at usage].freeze
218
+
219
+ # @param data [Hash] a turn as the wire carries it
220
+ # @return [OpenLanes::Live::Copilot::Turn]
221
+ # @raise [OpenLanes::Live::Copilot::Invalid]
222
+ def self.parse(data)
223
+ data = data.transform_keys(&:to_s)
224
+ new(id: data["id"].to_s,
225
+ from: data["from"].to_s,
226
+ kind: data["kind"].to_s,
227
+ text: data["text"].to_s,
228
+ at: data["at"] ? Time.iso8601(data["at"]) : Time.now,
229
+ usage: data["usage"],
230
+ fields: data.except(*BASE))
231
+ rescue ArgumentError => e
232
+ raise Invalid, "not a turn: #{e.message}"
233
+ end
234
+
235
+ # @param turn [OpenLanes::Live::Copilot::Turn]
236
+ # @return [void]
237
+ # @raise [OpenLanes::Live::Copilot::Invalid] naming what is wrong
238
+ def self.check(turn)
239
+ raise Invalid, "unknown speaker #{turn.from.inspect}" unless SPEAKERS.include?(turn.from)
240
+ raise Invalid, "unknown kind #{turn.kind.inspect}; known: #{KINDS.join(", ")}" unless KINDS.include?(turn.kind)
241
+ raise Invalid, "only the copilot draws cards" if turn.from == "you" && turn.kind != "text"
242
+
243
+ fields = turn.fields.transform_keys(&:to_s)
244
+ check_names(turn.kind, fields.keys)
245
+ check_text(turn.kind, fields)
246
+ check_command(fields["command"]) if turn.kind == "confirm"
247
+ end
248
+
249
+ # A card holds exactly its own fields: a missing one cannot be drawn,
250
+ # and an extra one could shadow a base key such as `id` on the wire.
251
+ #
252
+ # @return [void]
253
+ def self.check_names(kind, names)
254
+ wanted = FIELDS.fetch(kind)
255
+ missing = wanted - names
256
+ raise Invalid, "a #{kind} card needs #{missing.join(" and ")}" unless missing.empty?
257
+
258
+ extra = names - wanted
259
+ raise Invalid, "a #{kind} card has no #{extra.join(" or ")}" unless extra.empty?
260
+ end
261
+
262
+ # Every field but a command is drawn as text, so it has to be some:
263
+ # a blank one would draw an unnamed button or an empty draft.
264
+ #
265
+ # @return [void]
266
+ def self.check_text(kind, fields)
267
+ wrong = (FIELDS.fetch(kind) - %w[command]).reject { fields[it].is_a?(String) && !fields[it].strip.empty? }
268
+ raise Invalid, "a #{kind} card's #{wrong.join(" and ")} must be text" unless wrong.empty?
269
+ end
270
+
271
+ # A confirm card is sent through the same gate as the page's own
272
+ # buttons (W13), so it has to be a command that gate would take. It
273
+ # carries no id: the page mints one when the person presses, and an id
274
+ # supplied here would replace it and could repeat one already done.
275
+ #
276
+ # @return [void]
277
+ def self.check_command(command)
278
+ raise Invalid, "a confirm card's command is a JSON object" unless command.is_a?(Hash)
279
+
280
+ command = command.transform_keys(&:to_s)
281
+ raise Invalid, "a confirm card's command carries no id; the page mints one" if command.key?("id")
282
+
283
+ Command.parse(command.merge("id" => "card"))
284
+ rescue Command::Invalid => e
285
+ raise Invalid, "a confirm card's command is not one: #{e.message}"
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # What every bus answers, delegated to the store behind it.
7
+ class Base
8
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
9
+ attr_reader :store
10
+
11
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
12
+ def initialize(store)
13
+ @store = store
14
+ freeze
15
+ end
16
+
17
+ # @param channel [String]
18
+ # @param payload [String]
19
+ # @return [Integer] how many subscriptions received it
20
+ def publish(channel, payload) = store.publish(channel, payload)
21
+
22
+ # A subscription of its own; the caller closes it.
23
+ #
24
+ # @param channels [Array<String>]
25
+ # @param patterns [Array<String>]
26
+ # @return [#next_message, #close]
27
+ def subscribe(channels: [], patterns: []) = store.subscriber(channels:, patterns:)
28
+
29
+ # A durable append, in stream order.
30
+ #
31
+ # @param stream [String]
32
+ # @param fields [Hash]
33
+ # @param maxlen [Integer, nil] approximate trim
34
+ # @return [String] the entry id
35
+ def append(stream, fields, maxlen: nil) = store.xadd(stream, fields, maxlen:)
36
+
37
+ # The appends and writes the block makes, applied atomically.
38
+ #
39
+ # @yieldparam tx [OpenLanes::Live::EventBus::Transaction]
40
+ # @return [void]
41
+ def transaction
42
+ store.multi { |raw| yield Transaction.new(raw) }
43
+ end
44
+
45
+ # The database pub/sub channel names carry (see {Keys#news}).
46
+ #
47
+ # @return [Integer]
48
+ def db = store.db
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # The bus for the suite and `--demo`, over {MemoryStore}: one process,
7
+ # one keyspace, the same contract.
8
+ class MemoryBus < Base
9
+ # @param store [OpenLanes::Live::MemoryStore]
10
+ def initialize(store = MemoryStore.new) = super
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # The production bus, over {Store}: Redis pub/sub and streams.
7
+ class RedisBus < Base; end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # One {Base#transaction} block's writes: {#append} for the streams, and
7
+ # the store's other writes ({Store::WRITES}) beside them, so a site that
8
+ # sets a key and appends an entry together keeps doing both atomically.
9
+ class Transaction
10
+ # @param raw [OpenLanes::Live::Store::Transaction]
11
+ def initialize(raw)
12
+ @raw = raw
13
+ end
14
+
15
+ # @param stream [String]
16
+ # @param fields [Hash]
17
+ # @param maxlen [Integer, nil]
18
+ # @return [nil]
19
+ def append(stream, fields, maxlen: nil) = @raw.xadd(stream, fields, maxlen:)
20
+
21
+ (Store::WRITES - %i[xadd]).each do |name|
22
+ define_method(name) { |*args, **kwargs| @raw.public_send(name, *args, **kwargs) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # The port every publish, subscribe and stream append goes through.
6
+ #
7
+ # Engine, Live and Web used to call `store.publish`, `store.subscriber`
8
+ # and `store.xadd` directly, on whichever of {Store} and {MemoryStore}
9
+ # they were handed, so the two shared a duck type nobody declared. A bus
10
+ # names that surface, and the stores become its backing: {RedisBus} over
11
+ # {Store}, {MemoryBus} over {MemoryStore}. Key layout, stream ids and the
12
+ # wire format are the store's, unchanged, because web cursors rely on them.
13
+ module EventBus
14
+ module_function
15
+
16
+ # The bus over +store+, or +store+ itself when it already is one.
17
+ #
18
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore, Base]
19
+ # @return [Base]
20
+ def for(store)
21
+ case store
22
+ when Base then store
23
+ when MemoryStore then MemoryBus.new(store)
24
+ else RedisBus.new(store)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # Grills a new idea before its spec.md is written (M6): the questions the
8
+ # agents downstream would otherwise block to ask, each as a short context
9
+ # paragraph and lettered options with exactly one recommended. The page
10
+ # asks them one at a time, and the answers reach {Commands#create_plans}
11
+ # as `payload.decisions`, which writes them into the spec.
12
+ #
13
+ # One call to a small model on OpenRouter, as {ChoiceConverter} makes,
14
+ # and every question is checked the same way: written with
15
+ # {Plans::Choices.write} and read back with {Plans::Choices.parse}. A
16
+ # question that does not survive the trip is dropped, not the interview:
17
+ # four good questions are worth asking without the fifth.
18
+ #
19
+ # A seam: the suite sets {Live.interviewer} to nil, and examples about it
20
+ # hand it an adapter double, so nothing in the tests reaches OpenRouter.
21
+ class Interviewer
22
+ # The most questions one idea is asked. A person is interviewed while
23
+ # they wait, so the list stays short (M0).
24
+ LIMIT = 5
25
+
26
+ # Seconds a person waits for the questions before the plan is created
27
+ # without them. The HTTP client alone allows 10 to connect and 30 to
28
+ # read, and a Puma thread and the dialog are held for all of it.
29
+ TIMEOUT = 20
30
+
31
+ # What the model reads with the idea.
32
+ PROMPT = <<~PROMPT.freeze
33
+ A person described a software feature they want built. Agents will turn it into a spec, a plan and
34
+ code without being able to ask anything, so interview the person now. Return `questions`: an array
35
+ of at most #{LIMIT} objects, the decisions that most change what gets built and that the idea leaves open.
36
+ Ask nothing the idea already answers. Each object is:
37
+
38
+ - `context`: one short paragraph saying what must be decided and why it matters.
39
+ - `options`: an array of 2 to 5 objects `{"label": String, "recommended": Boolean}`, each a distinct
40
+ answer on one short line. Exactly one has `"recommended": true`: the one you would choose.
41
+
42
+ Never add an option for "something else" or "type something instead": the page has a text box.
43
+ An idea that leaves nothing open gets an empty array.
44
+ PROMPT
45
+
46
+ # One question, ready for the page.
47
+ #
48
+ # @!attribute [r] context
49
+ # @return [String]
50
+ # @!attribute [r] options
51
+ # @return [Array<String>] labels, in order
52
+ # @!attribute [r] recommended
53
+ # @return [Integer] the index of the recommended option
54
+ Question = Data.define(:context, :options, :recommended) do
55
+ # @return [Hash] as `POST /api/interview` sends it
56
+ def wire = { context:, options:, recommended: }
57
+ end
58
+
59
+ # An interviewer when OPENROUTER_API_KEY is set, nil otherwise.
60
+ #
61
+ # @param env [#[]]
62
+ # @param config [OpenLanes::Config]
63
+ # @return [OpenLanes::Live::Interviewer, nil]
64
+ def self.from_env(env = ENV, config: Config.current)
65
+ key = env["OPENROUTER_API_KEY"].to_s.strip
66
+ key.empty? ? nil : new(api_key: key, model: config.questions_model || ChoiceConverter::DEFAULT_MODEL)
67
+ end
68
+
69
+ # The model's questions, each cleaned the way {ChoiceConverter.render}
70
+ # cleans one, keeping those that survive.
71
+ #
72
+ # @param output [Hash] `questions`, as the adapter parsed it
73
+ # @return [Array<OpenLanes::Live::Interviewer::Question>] at most {LIMIT}
74
+ def self.render(output)
75
+ output = output.to_h.transform_keys(&:to_sym)
76
+ Array(output[:questions]).filter_map { question(it) }.first(LIMIT)
77
+ end
78
+
79
+ # @param raw [Object]
80
+ # @return [OpenLanes::Live::Interviewer::Question, nil]
81
+ def self.question(raw)
82
+ return unless raw.is_a?(Hash)
83
+
84
+ body = ChoiceConverter.render(raw)
85
+ choices = Plans::Choices.parse(body)
86
+ Question.new(context: choices.context,
87
+ options: choices.options.map(&:label),
88
+ recommended: choices.options.index(&:recommended))
89
+ rescue ChoiceConverter::Invalid
90
+ nil
91
+ end
92
+ private_class_method :question
93
+
94
+ # @param api_key [String, nil] OpenRouter's; only read to build the default adapter
95
+ # @param model [String] an OpenRouter model id
96
+ # @param adapter [#call, nil] answers `call(node, answers)` with a Hash
97
+ # holding `questions`; built on first use when nil
98
+ # @param timeout [Numeric] seconds before the interview is given up
99
+ def initialize(api_key: nil, model: ChoiceConverter::DEFAULT_MODEL, adapter: nil, timeout: TIMEOUT)
100
+ @api_key = api_key
101
+ @model = model
102
+ @adapter = adapter
103
+ @timeout = timeout
104
+ @mutex = Mutex.new
105
+ end
106
+
107
+ # @return [String]
108
+ attr_reader :model
109
+
110
+ # Blocks for at most {TIMEOUT} seconds.
111
+ #
112
+ # @param idea [String] Markdown, as the idea dialog sends it
113
+ # @return [Array<OpenLanes::Live::Interviewer::Question>]
114
+ # @raise [StandardError] whatever the adapter raises: no network, a 4xx,
115
+ # bad JSON, or Timeout::Error past the limit
116
+ def call(idea:)
117
+ output = Timeout.timeout(@timeout, Timeout::Error, "no questions within #{@timeout}s") do
118
+ adapter.call(node, { idea: idea.to_s })
119
+ end
120
+ self.class.render(output)
121
+ end
122
+
123
+ private
124
+
125
+ def adapter
126
+ @mutex.synchronize do
127
+ @adapter ||= begin
128
+ require "inquirex-llm"
129
+ Inquirex::LLM::OpenRouterAdapter.new(api_key: @api_key, model:)
130
+ end
131
+ end
132
+ end
133
+
134
+ def node
135
+ @node ||= begin
136
+ require "inquirex-llm"
137
+ Inquirex::LLM::Node.new(id: :interview,
138
+ verb: :extract,
139
+ prompt: PROMPT,
140
+ schema: Inquirex::LLM::Schema.new(questions: :array),
141
+ from_steps: %i[idea])
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end