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
@@ -28,82 +28,21 @@ module OpenLanes
28
28
  # @param options [Hash]
29
29
  # @return [void]
30
30
  def call(**options)
31
- github = Vcs.github
32
31
  tree = tree_for(options)
33
- changes = OpenLanes::Lifecycle.resync_prs(tree:,
34
- github:,
35
- adopt: options.fetch(:adopt, true),
36
- root: File.dirname(tree.dir))
37
- .call(commit: !dry_run?(options))
32
+ changes = unwrap(OpenLanes::Lifecycle.retitle_prs(tree:,
33
+ github: Vcs.github,
34
+ adopt: options.fetch(:adopt, true),
35
+ root: File.dirname(tree.dir),
36
+ commit: !dry_run?(options)))
38
37
 
39
38
  if changes.empty?
40
39
  success("Every pull request title already carries a prefix.") unless quiet?(options)
41
40
  return
42
41
  end
43
42
 
44
- changes.each do |change|
45
- puts "#{change.number}\t#{change.new_title || "-"}\t#{change.reason}"
46
- end
47
-
48
- return if quiet?(options)
49
-
50
- report_pr_changes(changes, options)
51
- rescue OpenLanes::Error => e
52
- refuse(e.message, 69)
53
- end
54
-
55
- private
56
-
57
- # @param changes [Array]
58
- # @param options [Hash]
59
- # @return [void]
60
- def report_pr_changes(changes, options)
61
- applicable, flagged = changes.partition(&:applicable?)
62
- assumed = applicable.select(&:assumed?)
63
-
64
- applicable.each do |c|
65
- note = if c.adopted?
66
- paint(" (new plan)", :magenta)
67
- elsif c.assumed?
68
- paint(" (assumed)", :yellow)
69
- end
70
- say("##{c.number} #{c.new_title}#{note}")
71
- end
72
-
73
- report_adoptions(applicable.select(&:adopted?), options)
74
-
75
- flagged.each { |c| say("##{c.number} #{paint("SKIPPED", :red)} — #{c.reason}", bullet: "!") }
76
-
77
- if dry_run?(options)
78
- dry_run_footer(applicable.size, "retitle#{"s" unless applicable.size == 1}")
79
- else
80
- success("Retitled #{applicable.size} pull request#{"s" unless applicable.size == 1}.")
81
- end
82
-
83
- return if assumed.empty?
84
-
85
- warn("#{assumed.size} title#{"s" unless assumed.size == 1} would get " \
86
- "[#{OpenLanes::NO_PLAN_PREFIX}] because no plan resolved.\n\n" \
87
- "That is an assertion about intent, and it is yours to make: check each one\n" \
88
- "before committing. A pull request that writes a plan's spec belongs to that\n" \
89
- "plan however its branch was named.")
90
- end
91
-
92
- # Creating folders is a bigger act than editing a title, so it is
93
- # reported separately rather than buried in the retitle list.
94
- #
95
- # @param adopted [Array]
96
- # @param options [Hash]
97
- # @return [void]
98
- def report_adoptions(adopted, options)
99
- return if adopted.empty?
100
-
101
- verb = dry_run?(options) ? "Would create" : "Created"
102
- info("#{verb} #{adopted.size} plan folder#{"s" unless adopted.size == 1} for pull " \
103
- "requests that resolved to no plan:\n\n" \
104
- "#{adopted.map { |c| " #{c.ordinal} ##{c.number} #{c.title}" }.join("\n")}\n\n" \
105
- "Each holds its pull request and nothing else. Run `openlanes run`\n" \
106
- "to have the specification written from what the pull request actually did.")
43
+ report = Presentation.pr_changes(changes, dry_run: dry_run?(options))
44
+ puts report.lines
45
+ report.report unless quiet?(options)
107
46
  end
108
47
  end
109
48
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module CLI
5
+ # `openlanes review PR LABEL` — move a pull request to its next review label.
6
+ #
7
+ # The one door through which a review label is set. Agents may not run
8
+ # `gh pr edit`, so a verdict reaches GitHub only by passing the CI rule in
9
+ # {Vcs::Review}, and this command is where a person, or hansolo, asks.
10
+ class Review < Base
11
+ desc "Replace a pull request's review label, refusing a passing verdict while CI is red"
12
+
13
+ argument :pull_request, required: true, desc: "Pull request number or URL, e.g. 12, #12"
14
+ argument :label,
15
+ required: true,
16
+ values: Plans::ReviewLabel.names,
17
+ desc: "One of: #{Plans::ReviewLabel.names.join(", ")}"
18
+
19
+ dry_run_option "Compute the transition and write nothing to GitHub"
20
+
21
+ example [
22
+ "12 #{Plans::ReviewLabel::INITIAL.name} # label PR #12",
23
+ "12 #{Plans::ReviewLabel::REJECTED.name} -n # what that would do"
24
+ ]
25
+
26
+ # @param pull_request [String]
27
+ # @param label [String]
28
+ # @param options [Hash]
29
+ # @return [void]
30
+ def call(pull_request:, label:, **options)
31
+ quiet?(options)
32
+ begin
33
+ # Checked here so a typo is a usage error, not a `gh` failure.
34
+ Plans::ReviewLabel.fetch(label)
35
+ rescue OpenLanes::Error => e
36
+ refuse(e.message, 64)
37
+ end
38
+
39
+ result = begin
40
+ Vcs.review.call(pr: pull_request, label:, dry_run: dry_run?(options))
41
+ rescue OpenLanes::Error => e
42
+ refuse(e.message, 69)
43
+ end
44
+ report(result)
45
+ exit 1 if result.refused?
46
+ end
47
+
48
+ private
49
+
50
+ # The deliverable on STDOUT, one tab-separated line, and the reading of
51
+ # it on STDERR.
52
+ #
53
+ # @param result [OpenLanes::Vcs::Review::Result]
54
+ # @return [void]
55
+ def report(result)
56
+ from = result.from.empty? ? "(none)" : result.from.join(", ")
57
+ puts "#{result.pr}\t#{from}\t#{result.to}\t#{result.ci}\t#{result.refused? ? "refused" : "ok"}"
58
+
59
+ if result.refused?
60
+ error("PR ##{result.pr} stays #{from}: #{result.reason}.\n" \
61
+ "Only #{Plans::ReviewLabel::REJECTED.name} and #{Plans::ReviewLabel::VULNERABILITY.name} " \
62
+ "are accepted until CI is green.")
63
+ return
64
+ end
65
+
66
+ say("PR ##{result.pr}: #{from} → #{paint(result.to, :green)} (CI #{result.ci})")
67
+ warn(result.reason) if result.reason
68
+ dry_run_footer(1, "label change") if result.dry_run?
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tmpdir"
4
-
5
3
  module OpenLanes
6
4
  module CLI
7
5
  # `openlanes run` — drive the specialist agents until the tree settles.
@@ -16,12 +14,9 @@ module OpenLanes
16
14
  desc: "Seconds before one agent is abandoned; only tightens an agent's own clock " \
17
15
  "(default: the agent's own, else 900)"
18
16
  option :agent, desc: "Only run this one agent"
19
- # `--prompt` shorter than this that names an existing file is read.
20
- PROMPT_PATH_LIMIT = 80
21
-
22
17
  option :prompt,
23
18
  desc: "Extra instructions appended to the agent's prompt (only with --agent); " \
24
- "a path to an existing text file under #{PROMPT_PATH_LIMIT} characters is read instead"
19
+ "a path to an existing text file under #{Engine::RunWorkflow::PROMPT_PATH_LIMIT} characters is read instead"
25
20
  option :skip,
26
21
  desc: "Never assign this agent; its plans wait, the rest of the pipeline runs. " \
27
22
  "Comma separated for several"
@@ -53,7 +48,7 @@ module OpenLanes
53
48
  default: true,
54
49
  desc: "With --isolation worktree, a finished branch is pushed and its pull request " \
55
50
  "opened as soon as it lands, titled [NNN.MM](X). --no-git-push turns that off and leaves it uncommitted."
56
- option :log, desc: "Append progress to this file (default: a per-project file under the system temp dir)"
51
+ log_options
57
52
  option :redis_url,
58
53
  desc: "Redis for the live board, its commands and plan claims shared with other machines " \
59
54
  "(default: .openlanes.yml redis_url, else $REDIS_URL, else #{Live::DEFAULT_URL})"
@@ -67,7 +62,16 @@ module OpenLanes
67
62
  desc: "Open the control room in the browser; --no-open only prints its URL"
68
63
  option :port,
69
64
  default: OpenLanes::Web::DEFAULT_PORT.to_s,
70
- desc: "Port the control room listens on, on 127.0.0.1"
65
+ desc: "Port the control room listens on"
66
+ listen_options
67
+ option :ignore_bombs,
68
+ type: :boolean,
69
+ default: false,
70
+ desc: "Work on while a 💣 stands: a plan others wait on, blocked too long, halts nothing this run"
71
+ option :stay,
72
+ type: :boolean,
73
+ default: false,
74
+ desc: "Stay up past fixed points with --no-web, for a control room served apart by `openlanes web`"
71
75
  option :until_settled,
72
76
  type: :boolean,
73
77
  default: false,
@@ -82,463 +86,27 @@ module OpenLanes
82
86
  "--rounds 3 # …capping every agent at three rounds per plan",
83
87
  "--plan 005,006,007 # only the plans a batch step just created",
84
88
  "--no-web # headless: no control room, no browser; ends when the tree settles",
85
- "--until-settled # with the control room, but ending when the tree settles"
89
+ "--no-web --stay # headless, but up for a page `openlanes web` serves on its own",
90
+ "--until-settled # with the control room, but ending when the tree settles",
91
+ "--ignore-bombs # keep working while a 💣 stands",
92
+ "-I --admin-credentials admin:s3cret # the control room on every interface, behind a login"
86
93
  ]
87
94
 
88
- # Option precedence, quietest to loudest: the built-in default, then
89
- # `.openlanes.yml` (`jobs`, and the `commands.run` section), then the flag
90
- # actually typed. {Base.default_params} has already folded the file in.
91
- #
92
95
  # @param options [Hash]
93
96
  # @return [void]
94
97
  def call(**options)
95
- if options[:prompt] && !options[:agent]
96
- refuse("--prompt only works with --agent: it speaks to one agent, and without " \
97
- "that restriction every agent in the round would hear it.",
98
- 64)
99
- end
100
-
101
- tree = tree_for(options)
102
- root = options[:root] || File.dirname(tree.dir)
103
- agents = OpenLanes::Agents.registry
104
- agents = filtered(agents, options[:agent]) if options[:agent]
105
- agents = skipped(agents, options[:skip], options[:agent]) if options[:skip]
106
- plans = options[:plan] ? scoped(tree, options[:plan]) : nil
107
- assignable!(agents, tree, plans, options[:agent]) if options[:agent]
108
-
109
- isolation = options.fetch(:isolation, "worktree").to_sym
110
- jobs = (options[:jobs] || UI.default_jobs).to_i
111
- timeout = options[:timeout]&.to_i
112
-
113
- if isolation == :worktree && !OpenLanes::Vcs.worktree(root:).repository?
114
- refuse("#{root} is not a git repository, so plans cannot be isolated.\n\n" \
115
- "Run with --isolation shared to work in one tree, serially.",
116
- 66)
117
- end
118
-
119
- # Set before the loop starts, not after: `UI.animate?` (and therefore
120
- # whether a round prints anything at all) is read the whole time the
121
- # loop runs, not just when `report` prints its closing summary.
122
- quiet?(options)
123
- UI.log_path = options[:log] ||
124
- File.join(Dir.tmpdir, "openlanes-#{File.basename(root)}.log")
125
- info("Progress: #{UI.log_path}") unless quiet?(options)
126
- credentials_warning if !dry_run?(options) && !quiet?(options)
127
- spec_warning(tree, plans) unless quiet?(options)
128
-
129
- # The state file is what a harness that dies leaves for the next one.
130
- # It sits in .plans/ so a reboot keeps it, and it is ignored by name
131
- # so it never rides along in a commit. Editing somebody's .gitignore
132
- # is announced, the once it happens. The runner drops the file on a
133
- # dry run, which is why it can be built unconditionally.
134
- state = Engine.state_file(tree)
135
- if !dry_run?(options) && Engine::StateFile.ensure_ignored!(root) && !quiet?(options)
136
- info("Added #{Engine::StateFile::IGNORE.join(" and ")} to .gitignore: the run keeps its state there.")
137
- end
138
-
139
- # Control files from a previous run would otherwise be read as orders
140
- # to this one. Progress goes to STDERR as lines; the web view (plan 004)
141
- # is what will draw the board and take kill, extend and stop.
142
- Execution::Control.reset!
143
-
144
- live = live_for(root:, tree:, agents:, options:)
145
- web = web_for(live, root:, tree:, options:)
146
- runner = Engine.runner(
147
- tree:,
148
- agents:,
149
- isolation:,
150
- jobs:,
151
- plans:,
152
- state:,
153
- worktree: (OpenLanes::Vcs.worktree(root:) if isolation == :worktree),
154
- rounds: options[:rounds]&.to_i,
155
- executor: Execution.executor(root:,
156
- timeout:,
157
- dry_run: dry_run?(options),
158
- instructions: instructions_from(options[:prompt]),
159
- model: options[:model],
160
- max_tokens: options[:max_tokens]&.to_i,
161
- lean: !options[:user_config]),
162
- dry_run: dry_run?(options),
163
- publisher: publisher_for(root, isolation, options),
164
- on_board: live&.on_board,
165
- claims: live&.claims,
166
- **staying(options, web)
167
- )
168
-
169
- started = UI.monotonic
170
- attempts = begin
171
- Execution.on_interrupt(signals:) { runner.call { |dispatcher| live&.attach(dispatcher) } }
172
- ensure
173
- live&.close
174
- web&.stop
175
- end
176
- finish(runner, attempts, options, seconds: UI.monotonic - started)
98
+ tree = tree_for(options)
99
+ listen = listen_for(options)
100
+ options = options.merge(quiet: quiet?(options), listen:, credentials: credentials_for(options, listen))
101
+ exit(unwrap(workflow(tree, options).call))
177
102
  end
178
103
 
179
104
  private
180
105
 
181
- # What the runner is told about idling past its fixed points. With its
182
- # control room up the engine stays, since the page can answer a blocked
183
- # plan or create the next one at any moment, and a run that had ended
184
- # would leave the page talking to nobody. Headless, or with
185
- # `--until-settled`, it ends at the first fixed point.
186
- #
187
- # @param options [Hash]
188
- # @param web [OpenLanes::Web::Launcher, nil] the control room, when it came up
189
- # @return [Hash] `stay:` and `rescan:` for {Engine::Runner}, or nothing
190
- def staying(options, web)
191
- return {} if web.nil? || options[:until_settled]
192
-
193
- info("The engine stays up with its control room: Stop All Local or Ctrl-C ends it.") unless quiet?(options)
194
- { stay: true, rescan: Engine::Dispatcher::RESCAN }
195
- end
196
-
197
- # Signals that stop the run gracefully, the way the first Ctrl-C does.
198
- # TERM too: a run with its control room stays up like a service, and a
199
- # service manager stops a service that way.
200
- #
201
- # @return [Array<String>]
202
- def signals = %w[INT TERM]
203
-
204
- # The closing report, and the exit status: 1 when any attempt failed.
205
- #
206
- # @param runner [OpenLanes::Engine::Runner]
207
- # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
208
- # @param options [Hash]
209
- # @param seconds [Float]
210
- # @return [void]
211
- def finish(runner, attempts, options, seconds:)
212
- report(runner, attempts, options, seconds:)
213
- exit(failures(attempts).empty? ? 0 : 1)
214
- end
215
-
216
- # Where the browser is sent once the control room answers.
217
- #
218
- # @param web [OpenLanes::Web::Launcher]
219
- # @param _tree [OpenLanes::Plans::Tree]
220
- # @return [String]
221
- def browser_url(web, _tree) = web.url
222
-
223
- # A live board this run cannot do without. `run` goes on headless, and
224
- # {Start}, whose page is its only way in, refuses instead.
225
- #
226
- # @param message [String] why there is no board
227
- # @param options [Hash]
228
- # @return [nil]
229
- def without_live(message, options)
230
- return nil if quiet?(options)
231
-
232
- warn("#{message}\n\nThe run goes on without the live board: no page, no commands from it, and no " \
233
- "plan claims shared with other machines. Start redis-server, or pass --redis-url.")
234
- nil
235
- end
236
-
237
- # @param web [OpenLanes::Web::Launcher] one that did not answer
238
- # @param _live [OpenLanes::Live::Session]
239
- # @param options [Hash]
240
- # @return [nil]
241
- def without_web(web, _live, options)
242
- return nil if quiet?(options)
243
-
244
- warn("The control room did not answer on #{web.url}, so the run goes on without it. Its log: #{web.log}")
245
- nil
246
- end
247
-
248
- # The live board (W6, W13, W26), when this run has one: not on a dry
249
- # run, which must leave nothing behind, and not without Redis, where
250
- # the run goes on exactly as it did before there was a page. Either
251
- # way it is said once, so a page that never updates is not a mystery.
252
- #
253
- # @param root [String]
254
- # @param tree [OpenLanes::Plans::Tree]
255
- # @param agents [OpenLanes::Agents::Registry]
256
- # @param options [Hash]
257
- # @param live [Boolean] false runs without Redis at all, for the
258
- # headless flags a later command adds
259
- # @return [OpenLanes::Live::Session, nil]
260
- def live_for(root:, tree:, agents:, options:, live: true)
261
- return nil unless live
262
-
263
- if dry_run?(options)
264
- warn("Dry run: the live board is off, so nothing is published to Redis and no plan is claimed.") unless quiet?(options)
265
- return nil
266
- end
267
-
268
- config = Config.current
269
- store = Live.connect(Live.redis_url(option: options[:redis_url], config:))
270
- Live.session(store:, root:, tree:, agents:, project: config.project).tap do |session|
271
- info("Live board: #{session.describe}") unless quiet?(options)
272
- end
273
- rescue Live::Unavailable => e
274
- without_live(e.message, options)
275
- end
276
-
277
- # The control room (W1, W7): `openlanes web` as its own process beside
278
- # the engine, reading the board this run publishes. Only with a live
279
- # board to read, so a dry run, a run without Redis and `--no-web` all
280
- # stay headless. A server that does not come up is reported and left
281
- # out; the run is the agents' work, and the page is a view of it.
282
- #
283
- # @param live [OpenLanes::Live::Session, nil]
284
- # @param root [String]
285
- # @param tree [OpenLanes::Plans::Tree]
286
- # @param options [Hash]
287
- # @return [OpenLanes::Web::Launcher, nil] running, for the run to stop
288
- def web_for(live, root:, tree:, options:)
289
- return nil if live.nil? || options[:web] == false
290
-
291
- web = OpenLanes::Web.launcher(root:,
292
- plans_dir: tree.dir,
293
- port: options[:port] || OpenLanes::Web::DEFAULT_PORT,
294
- redis_url: live.store.url).start
295
- unless web.ready?
296
- web.stop
297
- return without_web(web, live, options)
298
- end
299
-
300
- info("Control room: #{web.url}") unless quiet?(options)
301
- OpenLanes::Web.open(browser_url(web, tree)) unless options[:open] == false
302
- web
303
- end
304
-
305
- # The attempts list, padded into columns.
306
- #
307
- # Tabs used to separate these, which put every column on the next
308
- # multiple of eight: one agent's name a character longer than the
309
- # next one's pushed its round and mark a whole stop to the right, so
310
- # nothing under `luke-backend` and `yoda-writer` lined up. Widths come
311
- # from the rows themselves, so the columns are as narrow as the run
312
- # allows and still square.
313
- #
314
- # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
315
- # @return [Array<String>] one line per attempt, indented by two
316
- def attempt_lines(attempts)
317
- rows = attempts.map { |a| [a.ordinal.to_s, a.agent.to_s, "[R:#{a.round}]", mark(a), a.note.to_s] }
318
- widths = rows.transpose.map { |column| column.map { |cell| UI.display_width(cell) }.max.to_i }
319
- rows.map do |cells|
320
- # The note is last and is left as it is: padding the final column
321
- # only adds trailing whitespace a copy-paste then carries.
322
- last = cells.size - 1
323
- " #{cells.each_with_index.map { |cell, i| i == last ? cell : UI.fit(cell, widths[i] + 2) }.join}"
324
- end
325
- end
326
-
327
- # @param attempt [OpenLanes::Engine::Runner::Attempt]
328
- # @return [String] what the attempt did to its plan's state
329
- def mark(attempt)
330
- return "FAIL" unless attempt.ok
331
- return "#{attempt.from} -> #{attempt.to}" if attempt.advanced?
332
-
333
- "no change"
334
- end
335
-
336
- # `--prompt` takes the instructions themselves or a file holding them.
337
- # A short argument naming an existing file is read; anything else is
338
- # the text, so a sentence that happens to match a filename in the
339
- # project is not silently swapped for that file once it is long enough
340
- # to be a sentence.
341
- #
342
- # @param prompt [String, nil]
343
- # @return [String, nil]
344
- def instructions_from(prompt)
345
- return prompt unless prompt && prompt.length < PROMPT_PATH_LIMIT && File.file?(typed_path(prompt))
346
-
347
- File.read(typed_path(prompt))
348
- end
349
-
350
- # @param agents [OpenLanes::Agents::Registry]
351
- # @param name [String]
352
- # @return [OpenLanes::Agents::Registry] holding only the agent named — the
353
- # restriction has to reach assignments, not just chaining, or a flag
354
- # like `--prompt` aimed at one agent leaks to the whole round
355
- def filtered(agents, name)
356
- agents.find(name) or
357
- refuse("No agent called #{name}.\n\nKnown: #{agents.all.map(&:name).join(", ")}", 65)
358
- agents.only(name)
359
- end
360
-
361
- # Each name is checked against the full roster, not the possibly
362
- # already-restricted one, so `--skip` misspelled is a refusal rather
363
- # than a silent no-op — the same reasoning `--plan` applies to numbers.
364
- #
365
- # @param agents [OpenLanes::Agents::Registry]
366
- # @param text [String] comma-separated agent names
367
- # @param restricted [String, nil] what `--agent` asked for, if anything
368
- # @return [OpenLanes::Agents::Registry] without the agents named
369
- def skipped(agents, text, restricted)
370
- roster = OpenLanes::Agents.registry
371
- names = text.split(",").map(&:strip).reject(&:empty?)
372
- names.each do |name|
373
- roster.find(name) or
374
- refuse("No agent called #{name}.\n\nKnown: #{roster.all.map(&:name).join(", ")}", 65)
375
- next unless name == restricted
376
-
377
- refuse("--agent #{name} and --skip #{name} contradict each other: " \
378
- "one asks for only this agent, the other for anything but.",
379
- 64)
380
- end
381
- agents.without(*names)
382
- end
383
-
384
- # A spec.md whose frontmatter does not parse routes as if it had none:
385
- # lane full, every override dropped. An eval agent's unquoted backtick
386
- # sent a quick-lane rename to research that way, and said nothing.
387
- # {Plans::Spec#problems} already knew; say it before agents are assigned.
388
- #
389
- # @param tree [OpenLanes::Plans::Tree]
390
- # @param plans [Array<OpenLanes::Plans::Ordinal>, nil] the --plan ordinals, nil for all
391
- # @return [void]
392
- def spec_warning(tree, plans)
393
- lines = tree.subjects.filter_map { |subject|
394
- ordinal = subject.feature.ordinal
395
- next if plans && !plans.include?(ordinal)
396
-
397
- problems = Plans::Spec.for(subject).problems
398
- "#{ordinal}: #{problems.join("; ")}" unless problems.empty?
399
- }
400
- return if lines.empty?
401
-
402
- warn("spec.md frontmatter ignored in part, so these plans route on defaults:\n" \
403
- "#{lines.join("\n")}")
404
- end
405
-
406
- # `--plan` names the whole point of scoping: a skill that just minted
407
- # N folders hands off exactly those, not the tree. A typo'd or already-
408
- # settled number silently running the whole tree instead is the failure
409
- # this exists to prevent, so an unknown one is refused rather than
410
- # dropped.
411
- #
412
106
  # @param tree [OpenLanes::Plans::Tree]
413
- # @param text [String]
414
- # @return [Array<OpenLanes::Plans::Ordinal>]
415
- def scoped(tree, text)
416
- known = tree.subjects.map { |s| s.feature.ordinal }
417
- text.split(",").map(&:strip).reject(&:empty?).map { |token|
418
- ordinal = Plans::Ordinal.parse(token)
419
- unless ordinal && known.include?(ordinal)
420
- refuse("No plan #{token} in #{tree.dir}.\n\n" \
421
- "Known: #{known.join(", ")}",
422
- 66)
423
- end
424
- ordinal
425
- }
426
- end
427
-
428
- # `--agent` narrowing the round to nobody used to exit 0 in silence —
429
- # "0 plans addressed", green box, no hint that the agent named simply
430
- # does not handle any in-scope plan's state. Someone who typed both an
431
- # agent and a plan meant them to meet, so a pairing that cannot happen
432
- # is refused up front with the agent that would actually take each plan.
433
- #
434
- # @param agents [OpenLanes::Agents::Registry] already narrowed to the one agent
435
- # @param tree [OpenLanes::Plans::Tree]
436
- # @param plans [Array<OpenLanes::Plans::Ordinal>, nil]
437
- # @param name [String]
438
- # @return [void]
439
- def assignable!(agents, tree, plans, name)
440
- agent = agents.find(name)
441
- active = tree.subjects.select { |s|
442
- (plans.nil? || plans.include?(s.feature.ordinal)) &&
443
- !Plans::StateMachine::SETTLED.include?(s.status.key)
444
- }
445
- return if active.empty? || active.any? { |s| agent.handles?(s.status) }
446
-
447
- roster = OpenLanes::Agents.registry
448
- lines = active.map { |s|
449
- takers = OpenLanes::Agents::Routing.filter(roster.for_status(s.status), s).map(&:name)
450
- verb = takers.size == 1 ? "takes" : "take"
451
- " #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
452
- "#{" - #{takers.join(" and ")} #{verb} it" unless takers.empty?}"
453
- }
454
- handled = agent.handles.map { |k| OpenLanes::Plans::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
455
- refuse("#{name} handles #{handled.join(", ")}, and no plan in scope is there:\n\n" \
456
- "#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.",
457
- 65)
458
- end
459
-
460
- # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
461
- # @return [Array<OpenLanes::Engine::Runner::Attempt>]
462
- def failures(attempts) = attempts.reject(&:ok)
463
-
464
- # The one thing this harness does that leaves the machine — pushing a
465
- # branch and opening its pull request — so it takes one more thing to
466
- # turn on than everything else here: not being a dry run is not enough,
467
- # isolation has to actually give each plan a branch of its own too.
468
- #
469
- # `nil` is how {Runner} is told to leave a finished worktree alone; it
470
- # is what `--no-git-push` asks for, and what a shared tree gets
471
- # by construction, since there is no separate branch there to push.
472
- #
473
- # @param root [String]
474
- # @param isolation [Symbol]
475
107
  # @param options [Hash]
476
- # @return [OpenLanes::Vcs::Publisher, nil]
477
- def publisher_for(root, isolation, options)
478
- if isolation != :worktree || options[:git_push] == false
479
- unless quiet?(options)
480
- warn("No pull request will be opened (#{isolation == :worktree ? "--no-git-push" : "--isolation shared"}), " \
481
- "so a finished build stays at 🟡 Building:\n🟢 Ready for Review needs an open pull request, and hansolo-reviewer reviews one.")
482
- end
483
- return nil
484
- end
485
-
486
- Vcs.publisher(root:, dry_run: dry_run?(options))
487
- end
488
-
489
- # One line per attempt: the plan, who ran, which of that agent's rounds
490
- # it was, what moved, and the note. There is no round header any more
491
- # because the loop has no rounds of its own: each agent counts its own
492
- # per plan, so two agents on one plan can be on different numbers.
493
- #
494
- # @param runner [OpenLanes::Engine::Runner]
495
- # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
496
- # @param options [Hash]
497
- # @param seconds [Float] wall clock for the whole loop
498
- # @return [void]
499
- def report(runner, attempts, options, seconds: 0.0)
500
- # A leading blank line, because the spinners this replaces write to
501
- # STDERR and the last of them may have left the cursor mid-line:
502
- # without it the first attempt starts wherever that line stopped,
503
- # indented by however much it had drawn.
504
- puts ""
505
- puts attempt_lines(attempts)
506
-
507
- # What the run cost, on STDOUT with the attempts it belongs to, so a run
508
- # redirected to a file keeps its bill. A dry run spent nothing and gets
509
- # none of this.
510
- tally = Engine.tally(attempts:, seconds:)
511
- puts("", tally.render) unless dry_run?(options)
512
-
513
- return if quiet?(options)
514
-
515
- if Execution::Control.quit?
516
- warn("Stopped from the keyboard — q. Unfinished plans keep their state; " \
517
- "re-run to resume where they stand.")
518
- end
519
-
520
- advanced = attempts.count(&:advanced?)
521
- blocked = runner.blocked
522
- summary = ["#{attempts.size} invocation#{"s" unless attempts.size == 1}", "#{advanced} advanced"]
523
- summary << (runner.isolated? ? "#{runner.jobs} at a time, one worktree each" : "serial, shared tree")
524
- summary << "#{blocked.size} blocked" unless blocked.empty?
525
- summary << "#{failures(attempts).size} failed" unless failures(attempts).empty?
526
-
527
- if dry_run?(options)
528
- warn("Dry run — no agent was invoked.\n#{summary.join(" · ")}\n\nRe-run without --dry-run.")
529
- elsif failures(attempts).empty?
530
- success("#{summary.join(" · ")}#{"\n\nEvery plan is done or deliberately parked." if runner.settled?}")
531
- else
532
- error("#{summary.join(" · ")}\n\n#{failures(attempts).map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
533
- end
534
-
535
- return if blocked.empty?
536
-
537
- warn("#{blocked.size} plan#{"s" unless blocked.size == 1} need a human decision:\n" +
538
- blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
539
- "\n\nWrite the answers into blocked.md, then: openlanes unblock " \
540
- "#{blocked.map { |b| b.feature.ordinal }.join(",")}")
541
- end
108
+ # @return [OpenLanes::Engine::RunWorkflow]
109
+ def workflow(tree, options) = Engine.run(tree:, origin:, **options)
542
110
  end
543
111
  end
544
112
  end