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
@@ -20,11 +20,55 @@ module OpenLanes
20
20
  # carries itself from worktree creation through to a merged pull request with
21
21
  # nobody having to remember it.
22
22
  class Worktree
23
- # `bin/setup-worktree`, which copies in the ignored-but-required files a new
24
- # checkout does not get. Shelling out rather than reimplementing the rules
25
- # here keeps one answer to "what does a worktree need", and keeps that answer
26
- # usable from a shell on a machine that has never run `bundle install`.
27
- SEEDER = File.expand_path("../../../bin/setup-worktree", __dir__)
23
+ # `setup-worktree`, which copies in the ignored-but-required files a new
24
+ # checkout does not get. It is the user's own script, not this gem's:
25
+ # shelling out rather than reimplementing the rules here keeps one answer
26
+ # to "what does a worktree need", usable from a shell on a machine that
27
+ # has never run `bundle install`.
28
+ SEEDER_NAME = "setup-worktree"
29
+
30
+ # Names the seeder outright, ahead of every place {.seeder} searches.
31
+ SEEDER_ENV = "OPENLANES_SETUP_WORKTREE"
32
+
33
+ # Searched after `PATH`, where the user's own tooling keeps it.
34
+ SEEDER_HOMES = %w[~/.claude/bin ~/.agents/bin].freeze
35
+
36
+ class << self
37
+ # The seeder to run, or nil when none is installed.
38
+ #
39
+ # It used to be `bin/setup-worktree` beside this file, which the gem
40
+ # never shipped, so every installed run seeded nothing and said so
41
+ # once per agent.
42
+ #
43
+ # @param env [Hash] the environment, for `PATH` and {SEEDER_ENV}
44
+ # @return [String, nil] an executable path
45
+ def seeder(env = ENV) = seeder_candidates(env).find { File.file?(it) && File.executable?(it) }
46
+
47
+ # Every place {.seeder} looks, in order.
48
+ #
49
+ # @param env [Hash]
50
+ # @return [Array<String>]
51
+ def seeder_candidates(env = ENV)
52
+ on_path = env.fetch("PATH", "").split(File::PATH_SEPARATOR).reject(&:empty?).map { File.join(it, SEEDER_NAME) }
53
+ homes = SEEDER_HOMES.map { File.join(File.expand_path(it), SEEDER_NAME) }
54
+ [env[SEEDER_ENV].to_s, *on_path, *homes].reject(&:empty?).uniq
55
+ end
56
+
57
+ # @return [String] where {.seeder} looks, for a person
58
+ def seeder_search = "$#{SEEDER_ENV}, #{SEEDER_NAME} on PATH, #{SEEDER_HOMES.join(", ")}"
59
+
60
+ # The main checkout of the repository +path+ is in, even when +path+
61
+ # is itself a worktree. Worktrees live beside the main checkout, so a
62
+ # command run from inside one and rooted there looks for
63
+ # `<worktree>.worktrees` and finds nothing.
64
+ #
65
+ # @param path [String] any checkout of the repository
66
+ # @return [String] the main checkout; +path+ when git cannot say
67
+ def main_root(path)
68
+ common = `git -C #{path.shellescape} rev-parse --path-format=absolute --git-common-dir 2>/dev/null`.strip
69
+ common.empty? ? path : File.dirname(common)
70
+ end
71
+ end
28
72
 
29
73
  # One plan's isolated checkout.
30
74
  #
@@ -50,9 +94,11 @@ module OpenLanes
50
94
  # @param root [String] the main repository
51
95
  # @param dir [String, nil] where worktrees go; defaults to a sibling
52
96
  # @param user [String] branch namespace
53
- def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
97
+ # @param seeder [String, nil] the seeder to run; nil runs none
98
+ def initialize(root:, dir: nil, user: ENV["USER"] || "agent", seeder: self.class.seeder)
54
99
  @root = File.expand_path(root)
55
100
  @user = user
101
+ @seeder = seeder
56
102
  @dir = File.expand_path(dir || default_dir)
57
103
  end
58
104
 
@@ -103,6 +149,18 @@ module OpenLanes
103
149
  Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
104
150
  end
105
151
 
152
+ # Bring a plan's checkout up to date with the default branch before an
153
+ # agent runs in it, and say what happened in the progress log.
154
+ #
155
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
156
+ # @param hold [String, nil] see {Refresh#call}
157
+ # @return [OpenLanes::Vcs::Refresh::Outcome]
158
+ def refresh(checkout, hold: nil)
159
+ outcome = Refresh.new(path: checkout.path).call(hold:)
160
+ UI.log(outcome.summary, plan: checkout.ordinal.to_s) unless outcome.kind == :current
161
+ outcome
162
+ end
163
+
106
164
  # Copy in the files git ignores and a new checkout therefore lacks: `.env`
107
165
  # and friends, and credential keys.
108
166
  #
@@ -118,14 +176,16 @@ module OpenLanes
118
176
  # @param path [String] the worktree to seed
119
177
  # @return [Boolean] whether the seeder ran and succeeded
120
178
  def seed(path)
121
- unless File.executable?(SEEDER)
122
- UI.log("worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys")
179
+ unless @seeder && File.executable?(@seeder)
180
+ UI.log("worktree: no #{SEEDER_NAME} found (searched #{self.class.seeder_search}), " \
181
+ "so #{path} has no .env or credential keys",
182
+ level: :warn)
123
183
  return false
124
184
  end
125
185
 
126
- return true if system(SEEDER, "--quiet", path, out: File::NULL, err: File::NULL)
186
+ return true if system(@seeder, "--quiet", path, out: File::NULL, err: File::NULL)
127
187
 
128
- UI.log("worktree: could not seed #{path}; its suite may fail on a missing key")
188
+ UI.log("worktree: could not seed #{path}; its suite may fail on a missing key", level: :warn)
129
189
  false
130
190
  end
131
191
 
@@ -164,7 +224,82 @@ module OpenLanes
164
224
  #
165
225
  # @return [Array<String>] paths removed
166
226
  def prune
167
- list.select { |path| clean?(path) }.each { |path| remove(path) }
227
+ list.select { |path| clean?(path) }.select { |path| remove(path) }
228
+ end
229
+
230
+ # @param path [String] a checkout
231
+ # @return [String] the branch it has checked out; empty when detached
232
+ def branch_of(path) = `git -C #{path.shellescape} branch --show-current 2>/dev/null`.strip
233
+
234
+ # Every checkout of one plan: the one at {#path_for}, and any other
235
+ # whose folder starts with the same ordinal. A folder-name parse that
236
+ # once leaked the status into the slug (`018.00-018.00━━🟡━━❯x`) gave a
237
+ # plan three checkouts, and only the ordinal finds all of them.
238
+ #
239
+ # @param feature [OpenLanes::Plans::Feature]
240
+ # @return [Array<String>] absolute paths, as git lists them
241
+ def checkouts_of(feature)
242
+ prefix = "#{feature.ordinal}-"
243
+ list.select { File.basename(it).start_with?(prefix) }
244
+ end
245
+
246
+ # Remove a checkout whose work is on the default branch, and its local
247
+ # branch with it.
248
+ #
249
+ # A merged pull request ends a plan's worktree; left behind, they piled
250
+ # up to forty-eight in one repository, each looking like work in
251
+ # progress. Removal cannot be undone for anything that never left the
252
+ # machine, so every condition below has to hold first, and a refusal
253
+ # says which one failed and leaves the checkout exactly as it was.
254
+ #
255
+ # "On the default branch" is a tree comparison, never `git log
256
+ # main..branch`: a squash merge rewrites every hash, so that log lists
257
+ # every commit of a branch that landed whole. A stacked pull request
258
+ # merged into its parent's branch reports MERGED too, which is why the
259
+ # base is checked.
260
+ #
261
+ # @param path [String] a checkout from {#list}
262
+ # @param merge_commit [String] the commit the pull request merged as
263
+ # @param base [String] the branch the pull request merged into
264
+ # @param default_branch [String]
265
+ # @param held [#call, nil] path -> who holds a lock in it, or nil
266
+ # @param dry_run [Boolean] check everything, remove nothing
267
+ # @return [Retirement]
268
+ def retire(path, merge_commit:, base:, default_branch: Refresh.default_branch(root), held: nil, dry_run: false)
269
+ branch = branch_of(path)
270
+ sha = `git -C #{path.shellescape} rev-parse HEAD 2>/dev/null`.strip
271
+ refusal = retire_refusal(path, sha, merge_commit, base, default_branch, held)
272
+ return Retirement.new(path:, branch:, sha:, removed: false, reason: refusal) if refusal
273
+ return Retirement.new(path:, branch:, sha:, removed: false, reason: "would be removed: landed in #{merge_commit[0, 8]}") if dry_run
274
+ return Retirement.new(path:, branch:, sha:, removed: false, reason: "git refused to remove it") unless remove(path)
275
+
276
+ # -D, not -d: a squash-merged branch looks unmerged to -d. The tree
277
+ # comparison above is the check that makes this safe.
278
+ system("git", "-C", root, "branch", "-D", branch, out: File::NULL, err: File::NULL) unless branch.empty?
279
+ Retirement.new(path:, branch:, sha:, removed: true, reason: "landed in #{merge_commit[0, 8]}")
280
+ end
281
+
282
+ # What {#retire} did with one checkout.
283
+ #
284
+ # @!attribute [r] path
285
+ # @return [String]
286
+ # @!attribute [r] branch
287
+ # @return [String] empty for a detached checkout
288
+ # @!attribute [r] sha
289
+ # @return [String] the branch's last commit, so it can be restored
290
+ # @!attribute [r] removed
291
+ # @return [Boolean]
292
+ # @!attribute [r] reason
293
+ # @return [String] why it was removed, or why not
294
+ Retirement = Data.define(:path, :branch, :sha, :removed, :reason) do
295
+ # @return [Boolean]
296
+ def removed? = removed
297
+
298
+ # @return [String] one line for the progress log
299
+ def to_s
300
+ name = File.basename(path)
301
+ removed? ? "worktree #{name} removed (#{branch} was #{sha[0, 8]}): #{reason}" : "worktree #{name} kept: #{reason}"
302
+ end
168
303
  end
169
304
 
170
305
  # Where one plan's checkout lives, whether or not it has been made.
@@ -207,10 +342,14 @@ module OpenLanes
207
342
  true
208
343
  end
209
344
 
345
+ # Never `--force`: git refuses a checkout with uncommitted or untracked
346
+ # work, and that refusal is the last guard against deleting it.
347
+ # Ignored files, the seeded `.env` among them, do not count.
348
+ #
210
349
  # @param path [String]
211
- # @return [void]
350
+ # @return [Boolean] whether git removed it
212
351
  def remove(path)
213
- system("git", "-C", root, "worktree", "remove", "--force", path, out: File::NULL, err: File::NULL)
352
+ system("git", "-C", root, "worktree", "remove", path, out: File::NULL, err: File::NULL)
214
353
  end
215
354
 
216
355
  private
@@ -232,12 +371,69 @@ module OpenLanes
232
371
  # @return [Boolean]
233
372
  def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
234
373
 
374
+ # The first reason {#retire} must not remove a checkout, or nil.
375
+ #
376
+ # @return [String, nil]
377
+ def retire_refusal(path, sha, merge_commit, base, default_branch, held)
378
+ return "not a worktree openlanes manages" unless managed?(path)
379
+ return "its pull request merged into #{base}, not #{default_branch}" unless base == default_branch
380
+ return "it has uncommitted changes" unless clean?(path)
381
+
382
+ holder = held&.call(path)
383
+ return "#{holder} holds a lock in it" if holder
384
+ return "merge commit #{merge_commit[0, 8]} is not in this repository, even after a fetch" unless commit?(merge_commit)
385
+
386
+ "its branch has work that is not in #{merge_commit[0, 8]}" unless landed?(sha, merge_commit)
387
+ end
388
+
389
+ # @param path [String]
390
+ # @return [Boolean] one of {#list}, and not the main checkout
391
+ def managed?(path)
392
+ here = canonical(path)
393
+ here != canonical(root) && list.any? { canonical(it) == here }
394
+ end
395
+
396
+ # Present locally, fetching once if not: the engine merged it a moment
397
+ # ago on GitHub, and nothing has fetched since.
398
+ #
399
+ # @param sha [String]
400
+ # @return [Boolean]
401
+ def commit?(sha)
402
+ known = -> { system("git", "-C", root, "cat-file", "-e", "#{sha}^{commit}", out: File::NULL, err: File::NULL) }
403
+ known.call || (system("git", "-C", root, "fetch", "--quiet", "origin", out: File::NULL, err: File::NULL) && known.call)
404
+ end
405
+
406
+ # The branch tip's tree is the merge commit's, or every file the branch
407
+ # changed reads the same in the merge commit. The second covers a
408
+ # default branch that moved on before the merge.
409
+ #
410
+ # @param sha [String] the branch tip
411
+ # @param merge_commit [String]
412
+ # @return [Boolean]
413
+ def landed?(sha, merge_commit)
414
+ tree = ->(ref) { `git -C #{root.shellescape} rev-parse #{"#{ref}^{tree}".shellescape} 2>/dev/null`.strip }
415
+ return true if tree.call(sha) == tree.call(merge_commit)
416
+
417
+ fork = `git -C #{root.shellescape} merge-base #{sha.shellescape} #{merge_commit.shellescape} 2>/dev/null`.strip
418
+ return false if fork.empty?
419
+
420
+ files = `git -C #{root.shellescape} diff --name-only #{fork.shellescape} #{sha.shellescape} 2>/dev/null`.lines(chomp: true)
421
+ files.empty? || system("git", "-C", root, "diff", "--quiet", sha, merge_commit, "--", *files, out: File::NULL, err: File::NULL)
422
+ end
423
+
424
+ # A new branch starts from the freshly fetched default branch on
425
+ # `origin`, not from whatever the main checkout has checked out: that
426
+ # is often a branch of its own, or a `main` nobody has pulled in weeks,
427
+ # and the plan's agent then builds against code that is not there.
428
+ # Without such a remote branch it falls back to the main checkout's
429
+ # HEAD, as it always did.
430
+ #
235
431
  # @param branch [String]
236
432
  # @param path [String]
237
433
  # @return [void]
238
434
  def add(branch, path)
239
435
  exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
240
- args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path]
436
+ args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path, *Refresh.fetch(root)]
241
437
 
242
438
  return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
243
439
 
data/lib/openlanes/vcs.rb CHANGED
@@ -15,5 +15,23 @@ module OpenLanes
15
15
 
16
16
  # @return [OpenLanes::Vcs::Publisher]
17
17
  def publisher(**) = Publisher.new(**)
18
+
19
+ # @param github [OpenLanes::Vcs::GitHub]
20
+ # @return [OpenLanes::Vcs::Review]
21
+ def review(github: GitHub.new) = Review.new(github:)
22
+
23
+ # @param root [String, nil] the checkout whose repository is synced; nil
24
+ # means the current directory's
25
+ # @param github [OpenLanes::Vcs::GitHub]
26
+ # @return [OpenLanes::Vcs::LabelSync]
27
+ def label_sync(root: nil, github: GitHub.new(chdir: root)) = LabelSync.new(github:)
28
+
29
+ # Run before {label_sync}, once per repository: the repository's
30
+ # {Plans::ReviewLabel::MIGRATED} label marks it done.
31
+ #
32
+ # @param root [String, nil] as for {label_sync}
33
+ # @param github [OpenLanes::Vcs::GitHub]
34
+ # @return [OpenLanes::Vcs::LabelMigration]
35
+ def label_migration(root: nil, github: GitHub.new(chdir: root)) = LabelMigration.new(github:)
18
36
  end
19
37
  end
@@ -6,5 +6,5 @@
6
6
  #
7
7
  # © 2026 Konstantin Gredeskoul
8
8
  module OpenLanes
9
- VERSION = "3.0.0"
9
+ VERSION = "3.1.0"
10
10
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "rack/auth/basic"
5
+ require "rack/utils"
6
+
7
+ module OpenLanes
8
+ module Web
9
+ # Basic auth for every request from off this machine. The page can stop
10
+ # agents, answer their questions and message them, so a server listening
11
+ # past loopback is refused unless it is given credentials
12
+ # (`--admin-credentials`), and this asks for them.
13
+ #
14
+ # A request from loopback passes untouched: it already reached the
15
+ # machine, the browser `openlanes start` opens is on it, and so is the
16
+ # launcher's `/healthz` probe.
17
+ #
18
+ # use AdminAuth, credentials: AdminAuth::Credentials.parse("admin:s3cret")
19
+ class AdminAuth
20
+ # Where `openlanes run` hands its server the credentials. argv is
21
+ # world-readable through `ps`; a child's environment is not.
22
+ ENV_NAME = "OPENLANES_ADMIN_CREDENTIALS"
23
+
24
+ # What the browser's login prompt names.
25
+ REALM = "openlanes control room"
26
+
27
+ # Raised for credentials that are not `USER:PASSWORD`.
28
+ class Invalid < OpenLanes::Error; end
29
+
30
+ # One user and one password. The password may itself hold a colon;
31
+ # the user may not, since Basic auth splits at the first one.
32
+ Credentials = Data.define(:user, :password) do
33
+ # @param user [String]
34
+ # @param password [String]
35
+ # @return [Boolean] whether both match, compared in constant time
36
+ def match?(user, password)
37
+ # Both compared, always, so the time taken says nothing about
38
+ # which of the two was wrong.
39
+ [Rack::Utils.secure_compare(self.user, user.to_s),
40
+ Rack::Utils.secure_compare(self.password, password.to_s)].all?
41
+ end
42
+
43
+ # Never the password, so a credential that reaches a log or an
44
+ # exception message leaks nothing.
45
+ #
46
+ # @return [String]
47
+ def inspect = "#<#{self.class.name} user=#{user.inspect}>"
48
+ alias_method :to_s, :inspect
49
+ end
50
+
51
+ class << self
52
+ # @param text [String] `USER:PASSWORD`
53
+ # @return [Credentials]
54
+ # @raise [Invalid] when either half is missing
55
+ def parse(text)
56
+ user, password = text.to_s.split(":", 2)
57
+ unless user.to_s.strip.empty? || password.to_s.empty?
58
+ return Credentials.new(user: user.strip, password:)
59
+ end
60
+
61
+ raise Invalid, "takes USER:PASSWORD, both non-empty"
62
+ end
63
+
64
+ # @param address [String, nil] `REMOTE_ADDR`
65
+ # @return [Boolean] whether it is this machine, IPv4-mapped IPv6 included
66
+ def loopback?(address)
67
+ ip = IPAddr.new(address.to_s)
68
+ ip = ip.native if ip.ipv4_mapped?
69
+ ip.loopback?
70
+ rescue IPAddr::Error
71
+ false
72
+ end
73
+ end
74
+
75
+ # @param app [#call]
76
+ # @param credentials [Credentials]
77
+ def initialize(app, credentials:)
78
+ @app = app
79
+ @credentials = credentials
80
+ end
81
+
82
+ # @param env [Hash] the Rack environment
83
+ # @return [Array] the Rack response
84
+ def call(env)
85
+ return @app.call(env) if self.class.loopback?(env["REMOTE_ADDR"])
86
+
87
+ auth = Rack::Auth::Basic::Request.new(env)
88
+ return challenge unless auth.provided? && auth.basic? && @credentials.match?(*auth.credentials)
89
+
90
+ env["REMOTE_USER"] = auth.username
91
+ @app.call(env)
92
+ end
93
+
94
+ private
95
+
96
+ # @return [Array] a 401 the browser answers with its login prompt
97
+ def challenge
98
+ [401,
99
+ { "content-type" => "text/plain", "content-length" => "0", "www-authenticate" => %(Basic realm="#{REALM}") },
100
+ []]
101
+ end
102
+ end
103
+ end
104
+ end