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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "open3"
3
5
  require "tempfile"
4
6
  require "tty/command"
5
7
 
@@ -34,13 +36,23 @@ module OpenLanes
34
36
  # @!attribute [r] updated
35
37
  # @return [Boolean] whether the push went to a pull request the plan
36
38
  # already had open, rather than opening a new one
37
- Publication = Data.define(:ordinal, :branch, :title, :url, :refusal, :updated) do
38
- def initialize(updated: false, **rest) = super
39
+ # @!attribute [r] label_error
40
+ # @return [String, nil] why `ready-for-review` could not be applied; the
41
+ # pull request stays open regardless (open question 4)
42
+ Publication = Data.define(:ordinal, :branch, :title, :url, :refusal, :updated, :label_error) do
43
+ def initialize(updated: false, label_error: nil, **rest) = super
39
44
 
40
45
  # @return [Boolean]
41
46
  def published? = !url.nil?
47
+
48
+ # @return [Boolean] whether it was refused for want of `## How to test`,
49
+ # which an agent can write, unlike a rejected push
50
+ def untestable? = refusal == UNTESTABLE
42
51
  end
43
52
 
53
+ # Why a pull request nobody could test is not opened.
54
+ UNTESTABLE = "publish refused — plan.md has no `#{Lifecycle::Completion::HOW_TO_TEST}` section".freeze
55
+
44
56
  # A..Z, which is more parts than any feature should be split into.
45
57
  LETTERS = ("A".."Z").to_a.freeze
46
58
 
@@ -88,20 +100,94 @@ module OpenLanes
88
100
  return refuse(subject, checkout, title, "nothing to publish — the worktree is unchanged")
89
101
  end
90
102
 
103
+ untestable = open.nil? && missing_how_to_test(subject)
104
+ return refuse(subject, checkout, title, untestable) if untestable
105
+
91
106
  return Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: nil) if @dry_run
92
107
 
93
108
  mismatch = open && head_mismatch(open, checkout)
94
109
  return refuse(subject, checkout, title, mismatch) if mismatch
95
110
 
111
+ carry_plan(checkout, subject)
96
112
  commit(checkout, title)
97
113
  push(checkout)
98
114
  url = open ? open.url : create_pull_request(checkout, subject, title)
115
+ label_error = url.nil? ? nil : label_for_review(url, resubmitted: !open.nil?)
99
116
 
100
- Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil, updated: !open.nil?)
117
+ Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil, updated: !open.nil?, label_error:)
101
118
  rescue TTY::Command::ExitError => e
102
119
  refuse(subject, checkout, title, git_complaint(e))
103
120
  end
104
121
 
122
+ # Why a push from this checkout would not reach the pull request, as
123
+ # {#publish} asks before pushing. `pull-requests.md` does not record a
124
+ # head branch, and an adopted or hand-opened pull request can come from
125
+ # any branch, while the checkout's branch is derived from the plan.
126
+ # Verification asks it too, before chewie runs and before its commit:
127
+ # otherwise it verifies one branch's code, pushes screenshots to it,
128
+ # and labels a pull request built from another.
129
+ #
130
+ # @param pull [OpenLanes::Plans::PullRequest] an open pull request
131
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
132
+ # @param refused [String] how the sentence starts
133
+ # @return [String, nil] nil when the pull request is built from the checkout's branch
134
+ def branch_mismatch(pull, checkout, refused: "refused") = head_mismatch(pull, checkout, refused:)
135
+
136
+ # Paths named in a {#commit_plan} refusal before the rest are counted.
137
+ STRAY_SHOWN = 10
138
+
139
+ # Commit the plan's folder, and nothing else, to the checkout's branch and
140
+ # push it. Verification uses it to land chewie's screenshots on the pull
141
+ # request before the engine links them by SHA.
142
+ #
143
+ # It refuses a checkout with changes outside the plan folder: chewie
144
+ # writes `verification/**` and nothing more, so a stray code edit is
145
+ # something it should not have made, and committing it here would slip
146
+ # unreviewed code into a pull request that already passed review. Nor is
147
+ # it a resubmit, so the review labels are left exactly as they are.
148
+ #
149
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
150
+ # @param plan [OpenLanes::Plans::Subject] the plan in the main checkout
151
+ # @param message [String] the commit subject
152
+ # @return [String, nil] the new commit's SHA, nil on a dry run
153
+ # @raise [OpenLanes::Error] on a stray change, a pull request built from
154
+ # another branch, or nothing to commit
155
+ def commit_plan(checkout:, plan:, message:)
156
+ folder = plan_parent(plan)
157
+ raise Error, "the plans live outside the repository, so the branch has no place for them" if folder.nil?
158
+
159
+ stray = changed_paths(checkout).reject { |path, _| inside_plan?(path, folder, plan) }
160
+ raise Error, stray_complaint(plan, stray) if stray.any?
161
+ return nil if @dry_run
162
+
163
+ open = plan.pull_requests.select(&:open?).last
164
+ mismatch = open && branch_mismatch(open, checkout, refused: "refused to commit #{plan.feature.ordinal}")
165
+ raise Error, mismatch if mismatch
166
+
167
+ carry_plan(checkout, plan)
168
+ run(checkout, "git", "add", "-A", "--", folder)
169
+ raise Error, "nothing to commit for #{plan.feature.ordinal}" if run!(checkout, "git", "diff", "--cached", "--quiet").success?
170
+
171
+ run(checkout, "git", "commit", "-m", message)
172
+ sha = run(checkout, "git", "rev-parse", "HEAD").out.strip
173
+ pushed!(checkout, sha)
174
+ sha
175
+ rescue TTY::Command::ExitError => e
176
+ raise Error, git_complaint(e)
177
+ end
178
+
179
+ # A commit that stayed on this machine is said as one: "not committed"
180
+ # would send someone looking for a change that is right there in the
181
+ # checkout, waiting for the next push to carry it.
182
+ #
183
+ # @return [void]
184
+ # @raise [OpenLanes::Error] when the push fails
185
+ def pushed!(checkout, sha)
186
+ push(checkout)
187
+ rescue TTY::Command::ExitError => e
188
+ raise Error, "committed #{sha[0, 7]} locally, but the push failed: #{git_complaint(e)}"
189
+ end
190
+
105
191
  # git and gh put the useful sentence in stderr, several lines below the
106
192
  # command they echo. Reporting only the first line reports "it failed".
107
193
  #
@@ -132,22 +218,140 @@ module OpenLanes
132
218
  # @return [String]
133
219
  def auto_letter(subject) = LETTERS.fetch(subject.pull_requests.size, LETTERS.last)
134
220
 
221
+ # Left out of the copy: the half-written temporaries an atomic write
222
+ # renames into place (`state.json.<pid>.<thread>.tmp`).
223
+ CARRY_EXCLUDED = ["*.tmp"].freeze
224
+
225
+ # Copy the plan's folder from the main checkout into the worktree, so
226
+ # the pull request carries its spec, plan and state next to the code.
227
+ #
228
+ # The main checkout stays the only writer. The engine reads, signs and
229
+ # renames the plan there and nowhere else; the worktree gets a snapshot,
230
+ # refreshed on every publish. Every copy of the same plan number is
231
+ # removed first, so a folder renamed since the last publish (⭐ to 🟢,
232
+ # say) does not leave its old name behind in the branch, and a file
233
+ # deleted in the main checkout is deleted in the branch too.
234
+ #
235
+ # Nothing is copied when the worktree is the main checkout itself, which
236
+ # would delete the source, or when the plans live outside the
237
+ # repository, where the branch has no place for them.
238
+ #
239
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
240
+ # @param subject [OpenLanes::Plans::Subject] the plan in the main checkout
241
+ # @return [String, nil] the copy's path, nil when nothing was copied
242
+ def carry_plan(checkout, subject)
243
+ root = real(@root)
244
+ source = real(subject.feature.path)
245
+ return nil if real(checkout.path) == root || !source.start_with?("#{root}/")
246
+
247
+ parent = File.join(checkout.path, File.dirname(source.delete_prefix("#{root}/")))
248
+ FileUtils.mkdir_p(parent)
249
+ Dir.children(parent).each do |name|
250
+ next unless Plans::Ordinal.from_dirname(name) == subject.feature.ordinal
251
+
252
+ FileUtils.rm_rf(File.join(parent, name))
253
+ end
254
+ copy_tree(source, File.join(parent, File.basename(source)))
255
+ end
256
+
135
257
  private
136
258
 
259
+ # @param plan [OpenLanes::Plans::Subject]
260
+ # @return [String, nil] the plans directory relative to the repository,
261
+ # nil when the plans live outside it
262
+ def plan_parent(plan)
263
+ root = real(@root)
264
+ source = real(plan.feature.path)
265
+ source.start_with?("#{root}/") ? File.dirname(source.delete_prefix("#{root}/")) : nil
266
+ end
267
+
268
+ # Every path git sees changed, untracked files included. `-z` keeps an
269
+ # emoji folder name unquoted; a rename's second entry is its old path.
270
+ #
271
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
272
+ # @return [Hash{String => Boolean}] each path, and whether git does not
273
+ # track it
274
+ def changed_paths(checkout)
275
+ entries = run(checkout, "git", "status", "--porcelain", "-z", "--untracked-files=all").out.split("\0")
276
+ paths = {}
277
+ until entries.empty?
278
+ entry = entries.shift
279
+ paths[entry[3..]] = entry.start_with?("??")
280
+ paths[entries.shift] = false if entry.start_with?("R", "C")
281
+ end
282
+ paths.reject { |path, _| path.nil? }
283
+ end
284
+
285
+ # Running the app in the checkout leaves files behind that a project
286
+ # may never have ignored (logs, `tmp/`, a SQLite file, `coverage/`),
287
+ # and each one refuses every verification commit until it is. So the
288
+ # refusal names them, apart from edits to tracked files, and says what
289
+ # to do about them; "changes outside its plan folder" alone reads as a
290
+ # stray code edit and sends someone looking for one.
291
+ #
292
+ # @param plan [OpenLanes::Plans::Subject]
293
+ # @param stray [Hash{String => Boolean}] see {#changed_paths}
294
+ # @return [String]
295
+ def stray_complaint(plan, stray)
296
+ untracked, edited = stray.partition { |_, new| new }.map { |pairs| pairs.map(&:first) }
297
+ parts = []
298
+ parts << "edited #{shown(edited)}" if edited.any?
299
+ parts << "untracked #{shown(untracked)} (left by the app or its tests? add them to .gitignore)" if untracked.any?
300
+ "refused to commit #{plan.feature.ordinal}: changes outside its plan folder: #{parts.join("; ")}"
301
+ end
302
+
303
+ # @param paths [Array<String>]
304
+ # @return [String]
305
+ def shown(paths)
306
+ more = paths.size - STRAY_SHOWN
307
+ "#{paths.first(STRAY_SHOWN).join(", ")}#{" and #{more} more" if more.positive?}"
308
+ end
309
+
310
+ # @return [Boolean] whether `path` lies in any copy of this plan's folder
311
+ def inside_plan?(path, folder, plan)
312
+ return false unless path.start_with?("#{folder}/")
313
+
314
+ Plans::Ordinal.from_dirname(path.delete_prefix("#{folder}/").split("/").first) == plan.feature.ordinal
315
+ end
316
+
317
+ # @param path [String]
318
+ # @return [String] symlinks resolved, so /var and /private/var compare equal
319
+ def real(path)
320
+ File.realpath(path)
321
+ rescue Errno::ENOENT
322
+ File.expand_path(path)
323
+ end
324
+
325
+ # @param source [String]
326
+ # @param target [String]
327
+ # @return [String] target
328
+ def copy_tree(source, target)
329
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: source).each do |relative|
330
+ next if relative == "." || CARRY_EXCLUDED.any? { File.fnmatch?(it, File.basename(relative)) }
331
+
332
+ from = File.join(source, relative)
333
+ to = File.join(target, relative)
334
+ FileUtils.mkdir_p(File.directory?(from) ? to : File.dirname(to))
335
+ FileUtils.cp(from, to) if File.file?(from)
336
+ end
337
+ FileUtils.mkdir_p(target)
338
+ target
339
+ end
340
+
137
341
  # Why the open pull request cannot receive this checkout's push, if it
138
342
  # cannot.
139
343
  #
140
344
  # @param open [OpenLanes::Plans::PullRequest]
141
345
  # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
142
346
  # @return [String, nil] nil when the pull request is built from the checkout's branch
143
- def head_mismatch(open, checkout)
347
+ def head_mismatch(open, checkout, refused: "publish refused")
144
348
  head = @github.pull_request(open.number || open.url).fetch(:branch)
145
349
  return if head == checkout.branch
146
350
 
147
- "publish refused — ##{open.number} is built from #{head.empty? ? "an unknown branch" : head}, " \
351
+ "#{refused} — ##{open.number} is built from #{head.empty? ? "an unknown branch" : head}, " \
148
352
  "not #{checkout.branch}, so a push would not reach it"
149
353
  rescue Error => e
150
- "publish refused — could not tell which branch ##{open.number} is built from: #{e.message}"
354
+ "#{refused} — could not tell which branch ##{open.number} is built from: #{e.message}"
151
355
  end
152
356
 
153
357
  # @return [Hash]
@@ -170,9 +374,42 @@ module OpenLanes
170
374
  run(checkout, "git", "commit", "-m", title.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
171
375
  end
172
376
 
377
+ # The branch is the checkout's own, named by {Worktree#branch_for} and
378
+ # written only by this harness, so after the harness has rebased it
379
+ # (see {Refresh}) it may replace what `origin` holds. The lease alone
380
+ # is not enough: it trusts the tracking ref, and worktrees share
381
+ # `refs/remotes` with the main checkout, so a fetch there (an editor's
382
+ # auto-fetch, `gh pr checkout`) moves it onto somebody else's push and
383
+ # the lease would then let us overwrite it. `--force-if-includes` adds
384
+ # that the tracking ref's tip must be in this branch's own reflog,
385
+ # i.e. a commit the harness had here. It is a no-op beside an explicit
386
+ # `:<expect>`, so the lease names only the ref. A branch with no
387
+ # tracking ref yet gets a plain push, which can only fast-forward.
388
+ #
173
389
  # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
174
390
  # @return [void]
175
- def push(checkout) = run(checkout, "git", "push", "-u", "origin", checkout.branch)
391
+ def push(checkout)
392
+ # Declared first, so an agent still running in another worktree is
393
+ # not blamed for the tracking ref this push moves under it.
394
+ Execution::Boundary.sanction(checkout.path, "origin", checkout.branch)
395
+ run(checkout, "git", "push", *lease(checkout), "-u", "origin", checkout.branch)
396
+ end
397
+
398
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
399
+ # @return [Array<String>] the lease flags, or nothing
400
+ def lease(checkout)
401
+ # Read directly rather than through {#run}: it is a local lookup that
402
+ # changes nothing, and the command seam is for what leaves the machine.
403
+ seen, = Open3.capture2("git",
404
+ "-C",
405
+ checkout.path,
406
+ "rev-parse",
407
+ "--verify",
408
+ "--quiet",
409
+ "refs/remotes/origin/#{checkout.branch}",
410
+ err: File::NULL)
411
+ seen.strip.empty? ? [] : ["--force-with-lease=refs/heads/#{checkout.branch}", "--force-if-includes"]
412
+ end
176
413
 
177
414
  # @return [String, nil] the pull request URL
178
415
  def create_pull_request(checkout, subject, title)
@@ -201,9 +438,49 @@ module OpenLanes
201
438
 
202
439
  Implements `#{subject.feature.dirname}`. The specification and the plan
203
440
  for this work live in that folder.
441
+
442
+ #{Lifecycle::Completion::HOW_TO_TEST}
443
+
444
+ #{how_to_test(subject)}
204
445
  MARKDOWN
205
446
  end
206
447
 
448
+ # A pull request a person cannot test is not ready for one to review.
449
+ #
450
+ # @return [String, nil] the refusal, or nil when plan.md has the section
451
+ def missing_how_to_test(subject)
452
+ UNTESTABLE if how_to_test(subject).empty?
453
+ end
454
+
455
+ # A plan.md written before the rename says `## How to verify`; the
456
+ # harness no longer appends a duplicate to it, so its steps are read
457
+ # here under either heading rather than refused.
458
+ #
459
+ # @return [String] the section's body, without its heading
460
+ def how_to_test(subject)
461
+ headings = [Lifecycle::Completion::HOW_TO_TEST, Lifecycle::Completion::FORMER_HOW_TO_TEST]
462
+ lines = subject.read("plan.md").to_s.lines
463
+ start = lines.index { |l| headings.any? { l.strip.casecmp?(it) } } or return ""
464
+ lines[(start + 1)..].take_while { |l| !l.start_with?("## ", "# ") }.join.strip
465
+ end
466
+
467
+ # Put `ready-for-review` on the pull request. A resubmission also takes
468
+ # off whatever verdict the last review left: a `review-rejected` still
469
+ # there after the fixes are pushed reads to the watch as a fresh
470
+ # rejection, and the 🟢 plan would be sent straight back to 🔴.
471
+ #
472
+ # @param url [String]
473
+ # @param resubmitted [Boolean] whether the pull request was already open
474
+ # @return [String, nil] the error, when labelling failed
475
+ def label_for_review(url, resubmitted: false)
476
+ number = GitHub.number_of(url)
477
+ remove = resubmitted ? Plans::ReviewLabel.known : []
478
+ @github.swap_label(number:, repo: GitHub.repo_of(url), add: Plans::ReviewLabel::INITIAL, remove:)
479
+ nil
480
+ rescue Error => e
481
+ e.message
482
+ end
483
+
207
484
  # The specification's own Goal section, verbatim. A pull request body that
208
485
  # paraphrases the spec is a second copy that drifts; quoting it is not.
209
486
  #
@@ -216,6 +493,9 @@ module OpenLanes
216
493
 
217
494
  # @return [TTY::Command::Result]
218
495
  def run(checkout, *argv) = @command.run(*argv, chdir: checkout.path)
496
+
497
+ # @return [TTY::Command::Result] whatever the exit status
498
+ def run!(checkout, *argv) = @command.run!(*argv, chdir: checkout.path)
219
499
  end
220
500
  end
221
501
  end