openlanes 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module OpenLanes
8
+ module Vcs
9
+ # The date a plan reached the state that sends it into an inactive
10
+ # subfolder, for the `[YYYY-MM-DD]` its archived name carries (N3).
11
+ #
12
+ # A state change is a folder rename, so the date of the folder's latest
13
+ # rename is the date it got there. Git knows it, through a file inside the
14
+ # folder: `--follow --diff-filter=R` returns the rename itself, where
15
+ # `git log -1 -- <folder>` returns whichever edit came last, and a spec
16
+ # touched up a month after the merge would date the archive by the edit.
17
+ #
18
+ # Git does not always know. The folder may never have been committed, the
19
+ # clone may be shallow, or the tree may not be a repository at all; then
20
+ # `state.json`'s `updated_at`, which the harness writes on every state
21
+ # change, and failing that, today.
22
+ module RenameDate
23
+ # Files tried first; a folder holding none of them (a ❌ plan may hold
24
+ # only `discarded.md`) is followed through whichever file it does hold.
25
+ PROBES = %w[spec.md plan.md pull-requests.md].freeze
26
+
27
+ class << self
28
+ # @param dir [String] a plan folder
29
+ # @param today [Date] the last resort
30
+ # @return [Date]
31
+ def for(dir, today: Date.today) = from_git(dir) || from_state(dir) || today
32
+
33
+ private
34
+
35
+ # @param dir [String]
36
+ # @return [Date, nil]
37
+ def from_git(dir)
38
+ probe = probe_in(dir) or return nil
39
+
40
+ out, status = Open3.capture2("git",
41
+ "-C",
42
+ dir,
43
+ "log",
44
+ "--follow",
45
+ "--diff-filter=R",
46
+ "--format=%cI",
47
+ "-1",
48
+ "--",
49
+ probe,
50
+ err: File::NULL)
51
+ status.success? ? parse(out.strip) : nil
52
+ rescue SystemCallError
53
+ nil
54
+ end
55
+
56
+ # @param dir [String]
57
+ # @return [String, nil] a file name inside +dir+
58
+ def probe_in(dir)
59
+ PROBES.find { File.file?(File.join(dir, it)) } ||
60
+ Dir.children(dir).sort.find { File.file?(File.join(dir, it)) && it != Plans::Lock::FILENAME }
61
+ end
62
+
63
+ # @param dir [String]
64
+ # @return [Date, nil]
65
+ def from_state(dir)
66
+ parse(JSON.parse(File.read(File.join(dir, Plans::PlanState::FILENAME)))["updated_at"])
67
+ rescue SystemCallError, JSON::ParserError, TypeError
68
+ nil
69
+ end
70
+
71
+ # @param text [String, nil] an ISO-8601 timestamp
72
+ # @return [Date, nil]
73
+ def parse(text)
74
+ return nil if text.to_s.empty?
75
+
76
+ Date.iso8601(text.to_s[0, 10])
77
+ rescue Date::Error
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Vcs
5
+ # Moves a pull request from one review label to the next (M3, R3).
6
+ #
7
+ # The only way a review label is set: `gh pr edit` stays forbidden to
8
+ # agents (L3), so the CI rule below cannot be skipped by labelling by hand
9
+ # from inside a round.
10
+ class Review
11
+ # What happened, or under a dry run what would have.
12
+ Result = Data.define(:pr, :from, :to, :ci, :refused, :reason, :dry_run) do
13
+ alias_method :refused?, :refused
14
+ alias_method :dry_run?, :dry_run
15
+ end
16
+
17
+ # @param github [GitHub]
18
+ def initialize(github: GitHub.new)
19
+ @github = github
20
+ end
21
+
22
+ # @param pr [String, Integer] "12", "#12" or a pull request URL
23
+ # @param label [String] a {Plans::ReviewLabel} name
24
+ # @param dry_run [Boolean] compute the result, write nothing
25
+ # @return [Result]
26
+ # @raise [OpenLanes::Error] on an unknown label or a reference naming no
27
+ # pull request, before any `gh` call
28
+ def call(pr:, label:, dry_run: false) # rubocop:disable Naming/MethodParameterName -- `pr` is the contract with CLI::Review
29
+ to = Plans::ReviewLabel.fetch(label)
30
+ number = GitHub.number_of(pr)
31
+ repo = GitHub.repo_of(pr)
32
+
33
+ from = @github.labels(number:, repo:) & Plans::ReviewLabel.known
34
+ ci = CiGate.verdict(@github.checks(number:, repo:))
35
+ reason = refusal(to, ci)
36
+ result = Result.new(pr: number,
37
+ from:,
38
+ to: to.name,
39
+ ci:,
40
+ refused: !reason.nil?,
41
+ reason: reason || (ci == :none ? NO_CHECKS : nil),
42
+ dry_run:)
43
+ return result if result.refused? || dry_run
44
+
45
+ @github.swap_label(number:, repo:, add: to, remove: from & Plans::ReviewLabel.displaced_by(to))
46
+ result
47
+ end
48
+
49
+ # Zero checks, or only skipped ones, counts as green (open question 2),
50
+ # said out loud.
51
+ NO_CHECKS = "no CI checks ran; treated as green"
52
+
53
+ private
54
+
55
+ # @return [String, nil] why `to` may not be set now
56
+ def refusal(label, verdict)
57
+ return unless label.passing?
58
+
59
+ case verdict
60
+ when :red then "CI is red"
61
+ when :pending then "CI pending"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -56,6 +56,18 @@ module OpenLanes
56
56
 
57
57
  # @return [String] where {.seeder} looks, for a person
58
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
59
71
  end
60
72
 
61
73
  # One plan's isolated checkout.
@@ -137,6 +149,18 @@ module OpenLanes
137
149
  Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
138
150
  end
139
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
+
140
164
  # Copy in the files git ignores and a new checkout therefore lacks: `.env`
141
165
  # and friends, and credential keys.
142
166
  #
@@ -154,13 +178,14 @@ module OpenLanes
154
178
  def seed(path)
155
179
  unless @seeder && File.executable?(@seeder)
156
180
  UI.log("worktree: no #{SEEDER_NAME} found (searched #{self.class.seeder_search}), " \
157
- "so #{path} has no .env or credential keys")
181
+ "so #{path} has no .env or credential keys",
182
+ level: :warn)
158
183
  return false
159
184
  end
160
185
 
161
186
  return true if system(@seeder, "--quiet", path, out: File::NULL, err: File::NULL)
162
187
 
163
- 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)
164
189
  false
165
190
  end
166
191
 
@@ -199,7 +224,82 @@ module OpenLanes
199
224
  #
200
225
  # @return [Array<String>] paths removed
201
226
  def prune
202
- 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
203
303
  end
204
304
 
205
305
  # Where one plan's checkout lives, whether or not it has been made.
@@ -242,10 +342,14 @@ module OpenLanes
242
342
  true
243
343
  end
244
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
+ #
245
349
  # @param path [String]
246
- # @return [void]
350
+ # @return [Boolean] whether git removed it
247
351
  def remove(path)
248
- 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)
249
353
  end
250
354
 
251
355
  private
@@ -267,12 +371,69 @@ module OpenLanes
267
371
  # @return [Boolean]
268
372
  def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
269
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
+ #
270
431
  # @param branch [String]
271
432
  # @param path [String]
272
433
  # @return [void]
273
434
  def add(branch, path)
274
435
  exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
275
- 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)]
276
437
 
277
438
  return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
278
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.1"
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