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
@@ -83,15 +83,34 @@ module OpenLanes
83
83
  #
84
84
  # @param pulls [Array<Hash>] the orphans, from {Resync::Prs}
85
85
  # @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>] in pull request order
86
+ # @raise [Allocator::Refused] in a linked worktree, where no plan is
87
+ # minted, so a preview there says so rather than promising folders
86
88
  def plan(pulls)
89
+ allocator.refuse!
90
+ @taken = nil
87
91
  allocate(gather(pulls))
88
92
  end
89
93
 
90
94
  # Adopt every orphan: create its folder and record its pull request.
91
95
  #
96
+ # The slots are read and the folders made under the allocation lock, or
97
+ # a `create` running beside this one can take a slot this just handed out.
98
+ #
92
99
  # @param pulls [Array<Hash>]
93
100
  # @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>] with `path` filled in
101
+ # @raise [Allocator::Refused] in a linked worktree
94
102
  def call(pulls)
103
+ allocator.hold { adopt_all(pulls) }
104
+ end
105
+
106
+ # @return [OpenLanes::Lifecycle::Allocator]
107
+ def allocator = @allocator ||= Allocator.new(dir: tree.dir)
108
+
109
+ private
110
+
111
+ # @param pulls [Array<Hash>]
112
+ # @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>]
113
+ def adopt_all(pulls)
95
114
  adoptees = plan(pulls)
96
115
  return adoptees if adoptees.empty?
97
116
 
@@ -100,8 +119,6 @@ module OpenLanes
100
119
  created
101
120
  end
102
121
 
103
- private
104
-
105
122
  # Phase one, in parallel: ask each pull request's branch how far the
106
123
  # sequence had got when the work started. Branches are not all rebased
107
124
  # onto the same main, so this is per-branch rather than tree-wide, and it
@@ -124,7 +141,7 @@ module OpenLanes
124
141
  def major_for(pull)
125
142
  candidates = OpenLanes.plans_on_ref(root, pull[:branch].to_s)
126
143
  candidates = touched(pull) if candidates.empty?
127
- candidates = tree.subjects.map { |s| s.feature.ordinal } if candidates.empty?
144
+ candidates = tree.taken if candidates.empty?
128
145
 
129
146
  candidates.map(&:major).max.to_i
130
147
  end
@@ -133,9 +150,8 @@ module OpenLanes
133
150
  # @return [Array<OpenLanes::Plans::Ordinal>]
134
151
  def touched(pull)
135
152
  Array(pull[:files]).filter_map { |path|
136
- parts = path.to_s.split("/")
137
- index = parts.index(OpenLanes::PLANS_DIR)
138
- index && parts[index + 1] && Plans::Ordinal.from_dirname(parts[index + 1])
153
+ folder = Plans::Tree.plan_folder(path)
154
+ folder && Plans::Ordinal.from_dirname(folder)
139
155
  }.uniq
140
156
  end
141
157
 
@@ -159,9 +175,10 @@ module OpenLanes
159
175
  end
160
176
 
161
177
  # @param major [Integer]
162
- # @return [Array<Integer>] minors already used under this major
178
+ # @return [Array<Integer>] minors already used under this major, on
179
+ # this tree or on any branch: a slot taken anywhere is taken
163
180
  def minors_taken(major)
164
- tree.subjects.map { |s| s.feature.ordinal }.select { |o| o.major == major }.map(&:minor)
181
+ (@taken ||= allocator.taken).select { |o| o.major == major }.map(&:minor)
165
182
  end
166
183
 
167
184
  # Phase three, in parallel: each worker owns a number nobody else can
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module OpenLanes
7
+ module Lifecycle
8
+ # Blocked plans age (N8). A ⭕ or 🛑 that other plans are waiting for
9
+ # becomes 💣 after {BOMB_AFTER}, and the run halts on it; one nobody waits
10
+ # for is deferred after {DEFER_AFTER}, with a `delayed.md` naming what
11
+ # brings it back.
12
+ #
13
+ # This is a sweep that fires events, not an invariant that reads the
14
+ # clock. A state whose check looked at the time would rename folders as
15
+ # days passed with nobody firing anything, and asking a question about a
16
+ # folder must never fire one. So 💣 holds by its contents alone, and only
17
+ # this sweep, run by the engine before it assigns work, moves a plan in.
18
+ class Aging
19
+ # How long a plan others depend on may sit blocked before it halts the
20
+ # run. 24 hour multiples, not calendar days: the limit is the same length
21
+ # across a daylight saving change.
22
+ BOMB_AFTER = 7 * 86_400
23
+
24
+ # How long a plan nobody depends on may sit blocked before it is set
25
+ # aside.
26
+ DEFER_AFTER = 14 * 86_400
27
+
28
+ # The states that age. 💣 is not among them: it is already the loudest
29
+ # thing a block can become, and deferring it would quietly drop a plan
30
+ # others are waiting for.
31
+ AGES = %i[blocked product_blocked].freeze
32
+
33
+ # Dependents in these states wait for nothing any more: they shipped
34
+ # (008's {Plans::Dependencies::DONE}) or were dropped.
35
+ CLOSED = (Plans::Dependencies::DONE + %i[discarded]).freeze
36
+
37
+ # What one sweep did, or on a dry run would have done.
38
+ #
39
+ # @!attribute [r] bombed
40
+ # @return [Array<String>] ordinals now 💣
41
+ # @!attribute [r] deferred
42
+ # @return [Array<String>] ordinals now 💤
43
+ Result = Data.define(:bombed, :deferred) do
44
+ def initialize(bombed: [], deferred: []) = super
45
+
46
+ # @return [Boolean]
47
+ def empty? = bombed.empty? && deferred.empty?
48
+ end
49
+
50
+ # @param tree [OpenLanes::Plans::Tree]
51
+ # @param since [Date, nil] `plans.aging_since`: no plan ages from
52
+ # before local midnight on this day
53
+ # @param now [Time]
54
+ def initialize(tree:, since: nil, now: Time.now)
55
+ @tree = tree
56
+ @since = since
57
+ @now = now
58
+ end
59
+
60
+ # Bomb or defer every blocked plan among +subjects+ past its limit.
61
+ #
62
+ # @param subjects [Array<OpenLanes::Plans::Subject>, nil] the plans to
63
+ # age; every plan in the tree when nil
64
+ # @param commit [Boolean] false reports what would happen and changes
65
+ # nothing
66
+ # @return [Result]
67
+ def call(subjects: nil, commit: true)
68
+ bombed = []
69
+ deferred = []
70
+ (subjects || @tree.subjects).each do |subject|
71
+ next unless AGES.include?(subject.status.key)
72
+
73
+ age = age_of(subject)
74
+ if waited_for?(subject)
75
+ next if age < BOMB_AFTER
76
+
77
+ bombed << subject.feature.ordinal.to_s if !commit || bomb!(subject, age)
78
+ elsif age >= DEFER_AFTER
79
+ deferred << subject.feature.ordinal.to_s if !commit || defer!(subject, age)
80
+ end
81
+ end
82
+ @tree.reload if commit && !(bombed.empty? && deferred.empty?)
83
+ Result.new(bombed:, deferred:)
84
+ end
85
+
86
+ # @param subject [OpenLanes::Plans::Subject]
87
+ # @return [Float] seconds the plan has been blocked, as far as aging counts
88
+ def age_of(subject) = @now - started_at(subject)
89
+
90
+ # When the clock started: the latest `Blocked` signature, so a plan
91
+ # blocked again after an answer starts over, or `blocked.md`'s mtime
92
+ # when nobody signed, as the control room already reads it. Never
93
+ # before `aging_since`.
94
+ #
95
+ # @param subject [OpenLanes::Plans::Subject]
96
+ # @return [Time]
97
+ def started_at(subject)
98
+ signed = Plans::PlanState.for(subject.feature.path).signatures.rfind { it["status"] == "Blocked" }
99
+ start = signed ? parse(signed["at"]) : nil
100
+ start ||= File.mtime(File.join(subject.feature.path, "blocked.md"))
101
+ floor = @since && Time.local(@since.year, @since.month, @since.day)
102
+ floor && floor > start ? floor : start
103
+ end
104
+
105
+ private
106
+
107
+ # @param subject [OpenLanes::Plans::Subject]
108
+ # @return [Boolean] whether a plan still open names this one in `depends_on:`
109
+ def waited_for?(subject)
110
+ @tree.blocks_of(subject.feature.ordinal).any? do |ordinal|
111
+ dependent = @tree.find(ordinal)
112
+ dependent && !CLOSED.include?(dependent.status.key)
113
+ end
114
+ end
115
+
116
+ # @return [Boolean] whether the folder moved
117
+ def bomb!(subject, age)
118
+ subject.machine.promote!(:bomb)
119
+ UI.log("aging: 💣 after #{days(age)} blocked, with plans waiting on it", plan: subject.feature.ordinal.to_s)
120
+ true
121
+ rescue Plans::StateMachine::Refused => e
122
+ UI.log("aging: could not bomb: #{e.message}", level: :warn, plan: subject.feature.ordinal.to_s)
123
+ false
124
+ end
125
+
126
+ # `delayed.md` goes in first: 💤 refuses a folder without a trigger.
127
+ #
128
+ # @return [Boolean] whether the folder moved
129
+ def defer!(subject, age)
130
+ path = File.join(subject.feature.path, "delayed.md")
131
+ wrote = !File.exist?(path)
132
+ File.open(path, "a") { it.write(delayed(subject, age, fresh: wrote)) }
133
+ subject.machine.promote!(:deferred)
134
+ UI.log("aging: 💤 after #{days(age)} blocked, with no plan waiting on it", plan: subject.feature.ordinal.to_s)
135
+ true
136
+ rescue Plans::StateMachine::Refused => e
137
+ File.delete(path) if wrote && File.exist?(path)
138
+ UI.log("aging: could not defer: #{e.message}", level: :warn, plan: subject.feature.ordinal.to_s)
139
+ false
140
+ end
141
+
142
+ # @return [String]
143
+ def delayed(subject, age, fresh:)
144
+ heading = fresh ? "# Deferred: #{subject.feature.ordinal} #{subject.feature.slug}\n\n" : "\n"
145
+ "#{heading}Deferred by aging on #{@now.strftime("%Y-%m-%d")}: blocked #{days(age)} with no answer, " \
146
+ "and no open plan depends on it.\n\n" \
147
+ "Trigger: revisit after the block is answered. The questions are still in `blocked.md`.\n"
148
+ end
149
+
150
+ # @param seconds [Numeric]
151
+ # @return [String] "8 days"
152
+ def days(seconds)
153
+ count = (seconds / 86_400).floor
154
+ "#{count} day#{"s" unless count == 1}"
155
+ end
156
+
157
+ # @param text [String, nil]
158
+ # @return [Time, nil]
159
+ def parse(text)
160
+ Time.iso8601(text.to_s)
161
+ rescue ArgumentError
162
+ nil
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module OpenLanes
6
+ module Lifecycle
7
+ # Who may hand out a plan number, and which numbers are already gone.
8
+ #
9
+ # A number used to be the highest one in the `.plans` of whichever
10
+ # checkout ran `create`. Two checkouts on two branches each saw only
11
+ # their own folders and minted the same number: `main` stopped at 023
12
+ # while 024 to 035 lived on an unmerged branch, so `create` on `main`
13
+ # handed out 024 a second time, and the two plans collided the moment
14
+ # both branches reached `main`. So, decided 2026-10-17:
15
+ #
16
+ # - plans are minted only in the primary checkout, never in a linked
17
+ # worktree, so there is one place numbers come from;
18
+ # - a number counts as taken when it is in that checkout's `.plans`
19
+ # (archive and friends included, N3) or in `.plans` on any local
20
+ # branch or `origin/*` remote-tracking branch, read with `ls-tree`
21
+ # and no fetch;
22
+ # - the numbers are read and the folder made under an exclusive lock in
23
+ # the shared git directory, so two `create`s at once cannot both take
24
+ # the number they both just saw was free.
25
+ #
26
+ # Decided 2026-10-20, on top of that: the primary checkout must also be on
27
+ # the default branch (origin's HEAD, else `main`). A plan minted on a
28
+ # feature branch there is a plan `main` does not know about until that
29
+ # branch merges, which is the same split by another road. The rule covers
30
+ # new folders, their opening `spec.md` and `depends_on:`; every other file
31
+ # in a plan folder is still written from worktrees, as agents do.
32
+ #
33
+ # Outside a git repository there are no branches and no worktrees, and
34
+ # the checkout's own `.plans` is the whole truth, as before.
35
+ #
36
+ # {Plans::Ordinal} stays arithmetic over the list this returns; the git
37
+ # lives here and nowhere else.
38
+ class Allocator
39
+ # The lock file, in `git rev-parse --git-common-dir`: the one directory
40
+ # every worktree of a repository shares.
41
+ LOCK_FILE = "openlanes-ordinal.lock"
42
+
43
+ # Where branches that may carry plans live. Other remotes are someone
44
+ # else's forks; their numbers were never handed out here.
45
+ REFS = %w[refs/heads refs/remotes/origin].freeze
46
+
47
+ # The branch plans are minted on when origin names no default.
48
+ DEFAULT_BRANCH = "main"
49
+
50
+ # Raised when a plan would be minted in a linked worktree, or in the
51
+ # primary checkout off its default branch.
52
+ class Refused < OpenLanes::Error; end
53
+
54
+ # The repository around a `.plans` directory, as git reports it.
55
+ #
56
+ # @!attribute [r] git_dir
57
+ # @return [String] this checkout's git directory, absolute
58
+ # @!attribute [r] common_dir
59
+ # @return [String] the directory every worktree shares, absolute
60
+ # @!attribute [r] toplevel
61
+ # @return [String] this checkout's root
62
+ # @!attribute [r] plans
63
+ # @return [String] the `.plans` directory, relative to +toplevel+
64
+ # @!attribute [r] branch
65
+ # @return [String, nil] the checked-out branch; nil on a detached HEAD
66
+ # @!attribute [r] default_branch
67
+ # @return [String] origin's HEAD, else {DEFAULT_BRANCH}
68
+ Repository = Data.define(:git_dir, :common_dir, :toplevel, :plans, :branch, :default_branch) do
69
+ # A linked worktree has a git directory of its own under the common
70
+ # one; the primary checkout's git directory is the common one.
71
+ #
72
+ # @return [Boolean]
73
+ def worktree? = git_dir != common_dir
74
+
75
+ # @return [String] the primary checkout, which owns the common directory
76
+ def primary = File.basename(common_dir) == ".git" ? File.dirname(common_dir) : common_dir
77
+
78
+ # @return [String]
79
+ def lock_path = File.join(common_dir, LOCK_FILE)
80
+
81
+ # @return [Boolean]
82
+ def on_default_branch? = branch == default_branch
83
+ end
84
+
85
+ # @param dir [String] the `.plans` directory; need not exist yet
86
+ def initialize(dir:)
87
+ @dir = File.expand_path(dir)
88
+ end
89
+
90
+ # @return [String]
91
+ attr_reader :dir
92
+
93
+ # @return [Repository, nil] nil outside a git repository
94
+ def repository = defined?(@repository) ? @repository : (@repository = discover)
95
+
96
+ # @return [String, nil] why a plan may not be minted here, or nil when it may
97
+ def refusal
98
+ repo = repository or return
99
+ if repo.worktree?
100
+ return "plans are created only in the primary checkout, #{repo.primary}; this is a worktree of it"
101
+ end
102
+ return if repo.on_default_branch?
103
+
104
+ "plans are created only on #{repo.default_branch} in the primary checkout; " \
105
+ "#{repo.primary} is on #{repo.branch || "a detached HEAD"}"
106
+ end
107
+
108
+ # @return [void]
109
+ # @raise [Refused] in a linked worktree, or off the default branch
110
+ def refuse!
111
+ message = refusal
112
+ raise Refused, message if message
113
+ end
114
+
115
+ # Run the block holding the allocation lock: read {#taken} and create
116
+ # the folder inside it, or another `create` can read the same numbers
117
+ # in between.
118
+ #
119
+ # The lock is `flock`, so it dies with the process: a `create` killed
120
+ # mid-way leaves nothing for anyone to break.
121
+ #
122
+ # @yieldreturn [Object]
123
+ # @return [Object] the block's value
124
+ # @raise [Refused] in a linked worktree, or off the default branch
125
+ def hold
126
+ refuse!
127
+ repo = repository or return yield
128
+
129
+ File.open(repo.lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
130
+ lock.flock(File::LOCK_EX)
131
+ yield
132
+ end
133
+ end
134
+
135
+ # Every number a new plan must stay clear of.
136
+ #
137
+ # @return [Array<OpenLanes::Plans::Ordinal>] unique, unordered
138
+ def taken
139
+ local = Plans::Tree.new(dir:).taken
140
+ repo = repository or return local
141
+
142
+ (local + branches(repo).flat_map { OpenLanes.plans_at(repo.toplevel, it, plans: repo.plans) }).uniq
143
+ end
144
+
145
+ private
146
+
147
+ # Branches that point at one commit hold one set of plans, so each
148
+ # commit is read once: a branch and its `origin/` twin cost one `ls-tree`.
149
+ #
150
+ # @param repo [Repository]
151
+ # @return [Array<String>] commit names
152
+ def branches(repo)
153
+ out = git(repo.toplevel, "for-each-ref", "--format=%(objectname) %(refname)", *REFS) or return []
154
+
155
+ out.lines.filter_map { |line|
156
+ sha, ref = line.split
157
+ sha unless ref.to_s.end_with?("/HEAD")
158
+ }.uniq
159
+ end
160
+
161
+ # @return [Repository, nil]
162
+ def discover
163
+ base = dir
164
+ base = File.dirname(base) until File.directory?(base)
165
+ out = git(base,
166
+ "rev-parse",
167
+ "--path-format=absolute",
168
+ "--git-dir",
169
+ "--git-common-dir",
170
+ "--show-toplevel") or return nil
171
+ git_dir, common_dir, toplevel = out.lines.map(&:chomp)
172
+ return nil if toplevel.to_s.empty?
173
+
174
+ Repository.new(git_dir:,
175
+ common_dir:,
176
+ toplevel:,
177
+ plans: relative_plans(base, toplevel),
178
+ branch: git(toplevel, "symbolic-ref", "--short", "-q", "HEAD")&.chomp,
179
+ default_branch: default_branch(toplevel))
180
+ end
181
+
182
+ # origin's HEAD names the default branch where a clone recorded it; a
183
+ # repository with no origin, or one never told, falls back to `main`.
184
+ #
185
+ # @param toplevel [String]
186
+ # @return [String]
187
+ def default_branch(toplevel)
188
+ head = git(toplevel, "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD")&.chomp
189
+ head.to_s.empty? ? DEFAULT_BRANCH : head.delete_prefix("origin/")
190
+ end
191
+
192
+ # git answers with resolved paths (`/private/var` on macOS) while +dir+
193
+ # may name the same place through a symlink (`/var`), so both sides are
194
+ # resolved before one is taken from the other.
195
+ #
196
+ # @param base [String] the nearest existing ancestor of +dir+, or +dir+
197
+ # @param toplevel [String]
198
+ # @return [String]
199
+ def relative_plans(base, toplevel)
200
+ tail = dir.delete_prefix(base).delete_prefix("/")
201
+ inside = File.realpath(base).delete_prefix(File.realpath(toplevel)).delete_prefix("/")
202
+ File.join(*[inside, tail].reject(&:empty?)).then { it.empty? ? "." : it }
203
+ end
204
+
205
+ # @param dir [String]
206
+ # @param args [Array<String>]
207
+ # @return [String, nil] stdout, or nil when git failed or is not here
208
+ def git(dir, *)
209
+ out, status = Open3.capture2("git", "-C", dir, *, err: File::NULL)
210
+ status.success? ? out : nil
211
+ rescue SystemCallError
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
3
4
  require "tty/command"
5
+ require "yaml"
4
6
 
5
7
  module OpenLanes
6
8
  module Lifecycle
@@ -109,6 +111,7 @@ module OpenLanes
109
111
  # @param dry_run [Boolean] plan the invocation, do not run it
110
112
  # @param seed [String, nil] what the author already wrote about the
111
113
  # feature (`create --from`); opens spec.md and outranks the repo survey
114
+ # @param today [Date] what `created:` says
112
115
  def initialize(
113
116
  path:,
114
117
  title:,
@@ -116,7 +119,8 @@ module OpenLanes
116
119
  command: TTY::Command.new(printer: :null),
117
120
  timeout: TIMEOUT,
118
121
  dry_run: false,
119
- seed: nil
122
+ seed: nil,
123
+ today: Date.today
120
124
  )
121
125
  @path = path
122
126
  @title = title
@@ -125,6 +129,7 @@ module OpenLanes
125
129
  @timeout = timeout
126
130
  @dry_run = dry_run
127
131
  @seed = seed.to_s.strip.then { |s| s.empty? ? nil : s }
132
+ @today = today
128
133
  end
129
134
 
130
135
  # @return [String]
@@ -146,9 +151,34 @@ module OpenLanes
146
151
  "# #{title}\n\n#{opening}" + HEADINGS.map { |h| "## #{h}\n\n" }.join("\n")
147
152
  end
148
153
 
154
+ # The scaffold under a frontmatter that says when the plan was created
155
+ # (decision N3): the folder name stops carrying any date once the plan
156
+ # is open, and a plan's age is otherwise only in git.
157
+ #
158
+ # Only where a plan may be minted ({Allocator#refuse!}): an opening
159
+ # spec.md written in a worktree or on a feature branch is a plan `main`
160
+ # has not heard of.
161
+ #
149
162
  # @return [String] where it was written
163
+ # @raise [Allocator::Refused] in a linked worktree, or off the default branch
150
164
  def write_scaffold!
165
+ Allocator.new(dir: File.dirname(path)).refuse!
151
166
  File.write(spec_path, scaffold)
167
+ stamp!
168
+ end
169
+
170
+ # Merge settings into spec.md's frontmatter, keeping every key already
171
+ # there, and `created:` whether or not a draft rewrote the file without
172
+ # it. Run after the draft for that reason.
173
+ #
174
+ # @param settings [Hash{String => Object}] e.g. `lane` and `depth`
175
+ # @return [String] the spec's path
176
+ def stamp!(settings = {})
177
+ return spec_path unless File.file?(spec_path)
178
+
179
+ meta, body = Frontmatter.split(File.read(spec_path, encoding: "UTF-8"))
180
+ meta = { "created" => @today }.merge(meta).merge(settings)
181
+ File.write(spec_path, "#{YAML.dump(meta)}---\n\n#{body.sub(/\A\n+/, "")}")
152
182
  spec_path
153
183
  end
154
184
 
@@ -182,7 +212,7 @@ module OpenLanes
182
212
  ["claude",
183
213
  "-p", prompt, "--add-dir", root,
184
214
  "--model", BRIEF_MODEL,
185
- *Adapters::Claude::LEAN,
215
+ *Adapters.lean_flags("claude"),
186
216
  "--allowedTools", ALLOWED_TOOLS.join(","),
187
217
  "--disallowedTools", DENIED_TOOLS.join(",")]
188
218
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OpenLanes
4
4
  module Lifecycle
5
- # Copies the spec's `task-completed-when` and `how-to-verify` into
5
+ # Copies the spec's `task-completed-when` and how to test it into
6
6
  # `plan.md`, so the builders and the reviewer read them in the document
7
7
  # they work from rather than only in the frontmatter of another one.
8
8
  #
@@ -12,7 +12,12 @@ module OpenLanes
12
12
  # agent signs, and a section the agent wrote is never touched.
13
13
  module Completion
14
14
  COMPLETED_WHEN_HEADING = "## Task completed when"
15
- HOW_TO_VERIFY_HEADING = "## How to verify"
15
+ HOW_TO_TEST = "## How to test"
16
+
17
+ # The heading {HOW_TO_TEST} replaced. A plan.md written before the
18
+ # rename still carries it, and without recognising it the harness would
19
+ # append a second copy of the same steps under the new name.
20
+ FORMER_HOW_TO_TEST = "## How to verify"
16
21
 
17
22
  module_function
18
23
 
@@ -25,7 +30,7 @@ module OpenLanes
25
30
  def markdown(spec, present: "")
26
31
  [
27
32
  section(COMPLETED_WHEN_HEADING, spec.completed_when, present),
28
- section(HOW_TO_VERIFY_HEADING, spec.how_to_verify, present)
33
+ section(HOW_TO_TEST, spec.how_to_test, present, also: FORMER_HOW_TO_TEST)
29
34
  ].compact.join("\n")
30
35
  end
31
36
 
@@ -46,11 +51,12 @@ module OpenLanes
46
51
  true
47
52
  end
48
53
 
54
+ # @param also [String, nil] an older heading that counts as present
49
55
  # @return [String, nil]
50
- def section(heading, items, present)
51
- return nil if items.empty? || present.match?(/^#{Regexp.escape(heading)}\s*$/i)
56
+ def section(heading, items, present, also: nil)
57
+ return nil if items.empty? || [heading, also].compact.any? { present.match?(/^#{Regexp.escape(it)}\s*$/i) }
52
58
 
53
- "#{heading}\n\n#{items.map { |item| "- #{item}" }.join("\n")}\n"
59
+ "#{heading}\n\n#{items.map { Plans::Spec.list_item(it) }.join("\n")}\n"
54
60
  end
55
61
  private_class_method :section
56
62
  end