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
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module OpenLanes
6
+ module Lifecycle
7
+ # `openlanes create` without the command: read the seed, fetch the pull
8
+ # requests, mint the folder, and either reconstruct its spec from the
9
+ # pull requests or scaffold and draft a new one.
10
+ #
11
+ # The spinner line and the warning boxes for a writer that fell through
12
+ # draw on STDERR as the work happens. The folder, the success text and
13
+ # the `spec.md` worth opening come back for the command to print.
14
+ class CreateWorkflow
15
+ include UI
16
+ include Dry::Monads[:result]
17
+ include Dry::Monads::Do.for(:call)
18
+
19
+ # What a create made.
20
+ #
21
+ # @!attribute [r] path
22
+ # @return [String] the folder, where it ended up
23
+ # @!attribute [r] message
24
+ # @return [String] the success text: what was made and the next step
25
+ # @!attribute [r] spec
26
+ # @return [String, nil] the scaffolded `spec.md`, for a person to open;
27
+ # nil when nothing was scaffolded
28
+ Created = Data.define(:path, :message, :spec)
29
+
30
+ # @param dir [String] the `.plans` directory
31
+ # @param root [String, nil] repository root (default: the .plans parent)
32
+ # @param after [String, nil] mint a retroactive plan in the gap after this one
33
+ # @param status [String, nil] open in this state instead of the default
34
+ # @param prs [String, nil] pull request numbers or URLs, comma separated
35
+ # @param from [String, nil] a seed markdown file, already resolved
36
+ # @param spec [Boolean] with +prs+, write spec.md from them
37
+ # @param draft [Boolean] without +prs+, attempt the four headings
38
+ # @param lane [String, nil] written into spec.md's frontmatter
39
+ # @param depth [String, nil] written into spec.md's frontmatter
40
+ def initialize(dir:, root: nil, after: nil, status: nil, prs: nil, from: nil, spec: true, draft: true,
41
+ lane: nil, depth: nil)
42
+ @dir = dir
43
+ @root = root
44
+ @after = after
45
+ @status = status
46
+ @prs = prs
47
+ @from = from
48
+ @spec = spec
49
+ @draft = draft
50
+ @settings = { "lane" => lane, "depth" => depth }.compact
51
+ end
52
+ # rubocop:enable Metrics/ParameterLists
53
+
54
+ # @param words [Array<String>] the topic; empty when +from+ names it
55
+ # @return [Dry::Monads::Result] Success({Created}), or Failure({Refusal})
56
+ # with 64 (usage), 65 (bad data) or 66 (no seed file)
57
+ def call(words)
58
+ FileUtils.mkdir_p(@dir)
59
+ words, seed = yield resolve_seed(words)
60
+ prs = yield fetch_prs
61
+ path = yield mint(words, prs)
62
+
63
+ Success(created(path, prs, seed))
64
+ end
65
+
66
+ private
67
+
68
+ # @param message [String]
69
+ # @param status [Integer]
70
+ # @return [Dry::Monads::Result::Failure]
71
+ def refusal(message, status) = Failure(Refusal.new(message:, status:))
72
+
73
+ # `--from FILE` trades the words argument for a markdown file: the
74
+ # frontmatter's `title:` names the folder, and the body travels on to
75
+ # {Brief} as the author's own opening statement. Words and a file are
76
+ # two answers to one question — the folder's name — so holding both is
77
+ # refused rather than silently ranked.
78
+ #
79
+ # @param words [Array<String>]
80
+ # @return [Dry::Monads::Result] Success([slug words, seed body or nil])
81
+ def resolve_seed(words)
82
+ return Success([words, nil]) unless @from
83
+
84
+ unless words.empty?
85
+ return refusal("--from names the topic by its frontmatter title;\ndrop the words, or drop --from", 64)
86
+ end
87
+ if @prs
88
+ return refusal("--prs reconstructs spec.md from the pull requests, so a seed file\n" \
89
+ "would be ignored; use one or the other",
90
+ 64)
91
+ end
92
+ return refusal("Could not read the seed file:\n#{@from} does not exist", 66) unless File.file?(@from)
93
+
94
+ parse_seed(File.read(@from, encoding: "UTF-8"))
95
+ end
96
+
97
+ # Frontmatter that will not parse used to be reported as a missing
98
+ # title, which sends the author looking for a `title:` line that is
99
+ # sitting right there. Say which of the two actually went wrong.
100
+ #
101
+ # @param content [String]
102
+ # @return [Dry::Monads::Result] Success([title words, body])
103
+ def parse_seed(content)
104
+ meta, body = Frontmatter.split(content)
105
+ title = meta["title"].to_s
106
+ if title.strip.empty?
107
+ return refusal("The seed file needs a frontmatter title, e.g.\n---\ntitle: Tax Rule DSL\n---", 65)
108
+ end
109
+
110
+ Success([title.split, body.strip])
111
+ rescue Psych::Exception => e
112
+ refusal("The seed file's frontmatter is not valid YAML:\n#{e.message}", 65)
113
+ end
114
+
115
+ # A retroactive plan documents work that landed *somewhere* in the
116
+ # sequence, and only its author knows where. Guessing would put the
117
+ # number — the one thing that never changes — in the wrong place.
118
+ #
119
+ # @return [Dry::Monads::Result] Success(Array<Hash>) or Success(nil) without +prs+
120
+ def fetch_prs
121
+ return Success(nil) unless @prs
122
+
123
+ unless @after
124
+ return refusal("--prs documents work that already shipped;\n" \
125
+ "name the plan it landed after with --after, e.g. --after 018",
126
+ 64)
127
+ end
128
+
129
+ Success(Vcs.github.pull_requests(Vcs::GitHub.parse_refs(@prs)))
130
+ rescue OpenLanes::Error => e
131
+ refusal("Could not read the pull requests:\n#{e.message}", 65)
132
+ end
133
+
134
+ # @param words [Array<String>]
135
+ # @param prs [Array<Hash>, nil]
136
+ # @return [Dry::Monads::Result] Success(path)
137
+ def mint(words, prs)
138
+ Lifecycle.create(dir: @dir, words:, after: @after, status: @status, prs:)
139
+ .alt_map { |message| Refusal.new(message: "Could not create the plan:\n#{message}", status: 65) }
140
+ end
141
+
142
+ # A plan with recorded pull requests already has its facts — hand it to
143
+ # the writer that reconstructs a specification from a diff. One without
144
+ # them does not exist yet, and reconstructing is not the job; {#brief} is.
145
+ #
146
+ # @param path [String]
147
+ # @param prs [Array<Hash>, nil]
148
+ # @param seed [String, nil]
149
+ # @return [Created]
150
+ def created(path, prs, seed)
151
+ from_prs = prs && !prs.empty?
152
+ spec = nil
153
+ if from_prs
154
+ path = synthesize(path) if @spec
155
+ else
156
+ spec = brief(path, seed)
157
+ end
158
+
159
+ feature = Plans::Feature.parse(path)
160
+ Created.new(path:,
161
+ spec:,
162
+ message: "Created #{File.basename(path)}\n\n" \
163
+ "#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
164
+ "#{next_step(path, feature, from_prs:)}")
165
+ end
166
+
167
+ # Hand the folder to the writer that already knows how to write a
168
+ # retroactive specification, then let `resync dirs` decide what the
169
+ # folder has become — ⏪ is only true while there is no `spec.md`.
170
+ #
171
+ # @param path [String]
172
+ # @return [String] the folder's path, which the resync may have renamed
173
+ def synthesize(path)
174
+ agent = OpenLanes::Agents.registry.find(OpenLanes::RETROACTIVE_WRITER) or return path
175
+
176
+ feature = Plans::Feature.parse(path)
177
+ ok, note = on_agent_row("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}",
178
+ agent: agent.name,
179
+ plan: feature.ordinal.to_s) do |line|
180
+ Execution.executor(root: root_of(path)).call(agent, Plans.subject(feature), &line)
181
+ end
182
+ unless ok
183
+ error("The folder was created, but spec.md was not written:\n#{note}\n\n" \
184
+ "The pull requests are recorded. Run `openlanes run` to retry.")
185
+ end
186
+
187
+ settle(path)
188
+ end
189
+
190
+ # A folder with no pull requests to reconstruct from is a feature that
191
+ # does not exist yet. {Brief} writes the four headings a human still has
192
+ # to answer and makes a best-effort pass at them from what the project
193
+ # already has on disk. The folder's state never moves: ⚪ New only ever
194
+ # claimed that a specification exists, not that it is complete.
195
+ #
196
+ # @param path [String]
197
+ # @param seed [String, nil] the seed body from the `--from` file
198
+ # @return [String, nil] the scaffolded spec.md; nil for a retroactive folder
199
+ def brief(path, seed)
200
+ feature = Plans::Feature.parse(path)
201
+ return if feature.status.key == :retroactive
202
+
203
+ brief = Lifecycle.brief(path:, title: feature.title, root: root_of(path), seed:)
204
+ brief.write_scaffold!
205
+ draft(brief, feature, seed) if @draft
206
+ # After the draft, so a drafting agent that rewrites the file cannot
207
+ # drop `--lane` and `--depth`.
208
+ brief.stamp!(@settings)
209
+ brief.spec_path
210
+ end
211
+
212
+ # @param brief [OpenLanes::Lifecycle::Brief]
213
+ # @param feature [OpenLanes::Plans::Feature]
214
+ # @param seed [String, nil]
215
+ # @return [void]
216
+ def draft(brief, feature, seed)
217
+ # Painted the way Runner::Task#label paints a roster agent, so the
218
+ # half-agent reads as one of them on the terminal.
219
+ label = "#{UI.paint(Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
220
+ ok, note = on_agent_row(label, agent: Brief::AGENT_NAME, plan: feature.ordinal.to_s) { brief.attempt! }
221
+ return if ok
222
+
223
+ error("spec.md was scaffolded, but the drafting attempt did not finish:\n#{note}\n\n" \
224
+ "The four headings are there, empty. Fill them in by hand, or hand off to leah-researcher.")
225
+ end
226
+
227
+ # @param path [String] a plan folder
228
+ # @return [String] the repository root the agents work in
229
+ def root_of(path) = @root || File.dirname(path, 2)
230
+
231
+ # One agent writing spec.md, drawn as a spinner line.
232
+ #
233
+ # @param label [String] the spinner line's text
234
+ # @param agent [String]
235
+ # @param plan [String]
236
+ # @yieldparam line [OpenLanes::UI::Line]
237
+ # @return [Object] the block's value
238
+ def on_agent_row(label, agent:, plan:, &block)
239
+ Execution.on_interrupt do
240
+ UI.concurrently([plan],
241
+ label,
242
+ jobs: 1,
243
+ label: ->(_) { label },
244
+ fields: ->(_) { { plan:, agent: } }) { |_, line| block.call(line) }.first
245
+ end
246
+ end
247
+
248
+ # @param path [String]
249
+ # @return [String] where the folder ended up
250
+ def settle(path)
251
+ change = Lifecycle.resync_dirs(tree: Plans.tree(File.dirname(path)))
252
+ .call(commit: true)
253
+ .find { |c| c.source == path }
254
+ change ? change.target : path
255
+ end
256
+
257
+ # @param path [String]
258
+ # @param feature [OpenLanes::Plans::Feature]
259
+ # @param from_prs [Boolean]
260
+ # @return [String]
261
+ def next_step(path, feature, from_prs:)
262
+ spec = File.join(File.basename(path), "spec.md")
263
+ return "Next: write #{spec}" unless feature.status.key == :new && File.file?(File.join(path, "spec.md"))
264
+ if from_prs
265
+ return "Next: read #{spec} — it was written from the pull requests, so check it against what actually shipped"
266
+ end
267
+
268
+ "Next: fill in the four headings in #{spec}, then hand off to leah-researcher"
269
+ end
270
+ end
271
+ end
272
+ end
@@ -33,7 +33,7 @@ module OpenLanes
33
33
  # @param after [String, nil] anchor for a retroactive plan, e.g. "002"
34
34
  # @param status [String, Symbol, nil] override the default state
35
35
  # @param prs [Array<Hash>, nil] pull requests to record, already fetched;
36
- # their presence is what makes the folder 🕰️ Retroactive rather than ⚪️
36
+ # their presence is what makes the folder ⏪ Retroactive rather than ⚪
37
37
  # @return [Dry::Monads::Result] Success(absolute path) or Failure(message)
38
38
  def create(words:, after: nil, status: nil, prs: nil)
39
39
  resolved = resolve_status(status, after) or
@@ -42,23 +42,51 @@ module OpenLanes
42
42
  slug = self.class.slugify(words)
43
43
  return Failure("the topic produced an empty slug") if slug.empty?
44
44
 
45
- ordinal = after ? retroactive_ordinal(after) : Plans::Ordinal.next_major(existing)
46
- return ordinal if ordinal.is_a?(Dry::Monads::Result)
45
+ # Read the taken numbers and make the folder under one lock, fresh
46
+ # each time: a number read before the lock was taken may be gone.
47
+ allocator.hold do
48
+ @existing = nil
49
+ ordinal = after ? retroactive_ordinal(after) : Plans::Ordinal.next_major(existing)
50
+ return ordinal if ordinal.is_a?(Dry::Monads::Result)
47
51
 
48
- build(ordinal, resolved, slug).fmap { |path| record_pull_requests(path, prs) }
52
+ build(ordinal, resolved, slug).fmap { |path| record_pull_requests(path, prs) }
53
+ end
49
54
  rescue OpenLanes::Error => e
50
55
  Failure(e.message)
51
56
  end
52
57
 
58
+ # Archived, deferred and abandoned plans keep their numbers (N3), so a
59
+ # number that lives only in `archive/` is still taken; so is one that
60
+ # lives only on another branch, or two checkouts mint it twice.
61
+ #
53
62
  # @return [Array<OpenLanes::Plans::Ordinal>] every number already taken
54
- def existing
55
- @existing ||= begin
56
- return [] unless File.directory?(dir)
63
+ def existing = @existing ||= allocator.taken
64
+
65
+ # @return [OpenLanes::Lifecycle::Allocator] where numbers may come from
66
+ def allocator = @allocator ||= Allocator.new(dir:)
57
67
 
58
- Dir.children(dir)
59
- .select { |c| File.directory?(File.join(dir, c)) }
60
- .filter_map { |c| Plans::Ordinal.from_dirname(c) }
68
+ # How many words of an idea name its plan.
69
+ TOPIC_WORDS = 8
70
+
71
+ # The words a free-form idea's plan is named after: a frontmatter
72
+ # `title:`, else its first line, a heading's `#` marks dropped.
73
+ #
74
+ # Taking the first words of the raw text named the minesweeper trial's
75
+ # plan `minesweeper-minesweeper-is-played-on-a-grid`: the idea opened
76
+ # with `# Minesweeper` and then a paragraph starting with the same
77
+ # word, and the heading's `#` was dropped while its word was kept.
78
+ #
79
+ # @param idea [String] Markdown, as the page's idea dialog sends it
80
+ # @return [Array<String>] at most {TOPIC_WORDS} words, possibly none
81
+ def self.topic(idea)
82
+ meta, body = begin
83
+ Frontmatter.split(idea.to_s)
84
+ rescue Psych::Exception
85
+ [{}, idea.to_s.sub(Frontmatter::PATTERN, '\\2')]
61
86
  end
87
+ title = meta["title"].to_s.strip
88
+ line = title.empty? ? body.lines.map(&:strip).find { !it.empty? }.to_s : title
89
+ line.sub(/\A\#{1,6}\s+/, "").split.first(TOPIC_WORDS)
62
90
  end
63
91
 
64
92
  # Turn free text into the kebab tail of a folder name.
@@ -93,7 +121,7 @@ module OpenLanes
93
121
  Plans::Ordinal.next_minor(existing, major: anchor.major)
94
122
  end
95
123
 
96
- # Write the pull requests the folder was created from, so 🕰️ Retroactive
124
+ # Write the pull requests the folder was created from, so ⏪ Retroactive
97
125
  # is justified the moment the folder exists — that state means "the work is
98
126
  # live and undocumented", and it is the recorded pull requests that make
99
127
  # the first half of that true.
@@ -8,7 +8,7 @@ module OpenLanes
8
8
  # trusts, so research and rewriting are skipped: the harness writes the
9
9
  # blank `plan.md` yoda-writer would have left and moves the folder to 📋.
10
10
  # A plan in the `quick` lane is a short mechanical task: the harness
11
- # writes a one-unit `plan.md` and moves the folder to ⭐️, where
11
+ # writes a one-unit `plan.md` and moves the folder to ⭐, where
12
12
  # r2d2-mechanic is the only builder that serves the lane.
13
13
  #
14
14
  # Both moves are ordinary transitions through the state machine, so the
@@ -14,23 +14,35 @@ module OpenLanes
14
14
  #
15
15
  # - **The emoji is wrong** — the contents justify a different state. A
16
16
  # folder whose status already holds is left alone, which is what stops
17
- # ⭕️ Blocked and 🅱️ Product Blocked — deliberately identical invariants,
17
+ # ⭕ Blocked and 🛑 Product Blocked — deliberately identical invariants,
18
18
  # distinguished only by the name — from collapsing into one.
19
- # - **The spelling is not canonical** — a folder written `018-⚪️--foo`
20
- # before the `NNN.MM` padding rule or the ` → ` separator, or
21
- # `18.1-⚪️--foo` by hand. Unpadded numbers read fine and sort wrong —
19
+ # - **The spelling is not canonical** — a folder written `018-⚪--foo`
20
+ # before the `NNN.MM` padding rule or the `━━❯` separator, or
21
+ # `18.1-⚪--foo` by hand. Unpadded numbers read fine and sort wrong —
22
22
  # `018.09` < `018.1` < `018.10` puts a single mixed-width folder in the
23
- # middle of the range. Both are renamed, to `018.00-⚪️ → foo` and
24
- # `018.01-⚪️ → foo`.
23
+ # middle of the range. Both are renamed, to `018.00━━⚪━━❯foo` and
24
+ # `018.01━━⚪━━❯foo`.
25
25
  #
26
- # The repairs are applied in that order reversed, as **up to two renames
27
- # per folder**: first the spelling, under the status the folder already
28
- # claims — which is how a `--`-era folder migrates to the ` → ` form —
29
- # and only then the status its contents justify. Both renames aim at
30
- # {Feature.plan_dirname}, so they cannot disagree about the final name;
31
- # splitting them keeps each auditable on its own, instead of one report
32
- # line where a spelling migration also appears to have opinions about
33
- # the state of the work.
26
+ # - **The folder is in the wrong place** — a plan in an inactive state
27
+ # (✅ 😎 💤 ❌) belongs in the subfolder {Plans::Tree::INACTIVE_DIRS}
28
+ # names, under a name dated by {Vcs::RenameDate}; a plan in any other
29
+ # state belongs at the top level, undated. Moving a plan back out is
30
+ # how one is revived: the state machine renames it in place, and the
31
+ # next resync brings it home (decision N3).
32
+ #
33
+ # The repairs are applied as **up to three renames per folder**: first
34
+ # the spelling, under the status the folder already claims — which is
35
+ # how an older folder migrates to the `NNN.MM━━<emoji>━━❯<slug>` form —
36
+ # then the status its contents justify, then the move. All of them aim
37
+ # at {Feature.plan_dirname}, so they cannot disagree about the final
38
+ # name; splitting them keeps each auditable on its own, instead of one
39
+ # report line where a spelling migration also appears to have opinions
40
+ # about the state of the work.
41
+ #
42
+ # A folder an agent holds a lock on ({Plans::Lock}) keeps its name, the
43
+ # same as one passed in +except+. A lock left by a process that is gone
44
+ # from this machine is removed by a committing run; one from another
45
+ # machine is reported and never touched, since its pid means nothing here.
34
46
  class Dirs
35
47
  # A proposed rename.
36
48
  #
@@ -49,7 +61,46 @@ module OpenLanes
49
61
  Change = Data.define(:dirname, :from, :to, :source, :target, :reason) do
50
62
  # @return [String] a single auditable line — `⇒` rather than `→`,
51
63
  # which the folder names themselves now contain
52
- def to_s = "#{dirname} ⇒ #{File.basename(target)} (#{reason})"
64
+ def to_s = "#{dirname} ⇒ #{target_name} (#{reason})"
65
+
66
+ # @return [String] the new name, with its subfolder when it is in one
67
+ def target_name
68
+ parent = File.basename(File.dirname(target))
69
+ Plans::Tree::SUBFOLDERS.include?(parent) ? "#{parent}/#{File.basename(target)}" : File.basename(target)
70
+ end
71
+ end
72
+
73
+ # A spec.md whose `how-to-verify:` key moves into its body (M10).
74
+ #
75
+ # @!attribute [r] dirname
76
+ # @return [String] the plan folder, as it stands
77
+ # @!attribute [r] path
78
+ # @return [String] the spec, absolute
79
+ Rewrite = Data.define(:dirname, :path) do
80
+ # @return [String]
81
+ def reason = "how-to-verify: moves into #{Plans::Spec::HOW_TO_VERIFY_HEADING}"
82
+
83
+ # @return [String]
84
+ def to_s = "#{dirname}/#{File.basename(path)} (#{reason})"
85
+ end
86
+
87
+ # A lock file found on the way.
88
+ #
89
+ # @!attribute [r] dirname
90
+ # @return [String] the plan folder it is in
91
+ # @!attribute [r] path
92
+ # @return [String] the plan folder, absolute
93
+ # @!attribute [r] lock
94
+ # @return [OpenLanes::Plans::Lock]
95
+ HeldLock = Data.define(:dirname, :path, :lock) do
96
+ # @return [Boolean] written on this machine by a process that is gone
97
+ def stale? = lock.stale?
98
+
99
+ # @return [Boolean] written on another machine, so never cleared here
100
+ def foreign? = !lock.local?
101
+
102
+ # @return [String]
103
+ def to_s = "#{dirname}: locked by pid #{lock.pid} on #{lock.host} since #{lock.started_at.utc.iso8601}"
53
104
  end
54
105
 
55
106
  # The state a folder's contents justify, or the one it claims when
@@ -78,17 +129,44 @@ module OpenLanes
78
129
  #
79
130
  # @return [Array<OpenLanes::Lifecycle::Resync::Dirs::Change>]
80
131
  def plan
81
- tree.subjects.flat_map do |subject|
82
- @except.include?(subject.feature.ordinal.to_s) ? [] : changes_for(subject)
132
+ held = held_paths
133
+ tree.everything.subjects.flat_map { |subject| left_alone?(subject, held) ? [] : changes_for(subject) }
134
+ end
135
+
136
+ # Specs that still carry `how-to-verify:` in their frontmatter.
137
+ #
138
+ # @return [Array<OpenLanes::Lifecycle::Resync::Dirs::Rewrite>]
139
+ def rewrites
140
+ held = held_paths
141
+ tree.everything.subjects.filter_map do |subject|
142
+ next if left_alone?(subject, held)
143
+
144
+ path = File.join(subject.feature.path, Plans::Spec::FILENAME)
145
+ next unless File.file?(path)
146
+
147
+ text = File.read(path, encoding: "UTF-8")
148
+ Rewrite.new(dirname: subject.feature.dirname, path:) unless Plans::Spec.migrate_how_to_verify(text).equal?(text)
149
+ end
150
+ end
151
+
152
+ # Every lock file in the tree, inactive subfolders included.
153
+ #
154
+ # @return [Array<OpenLanes::Lifecycle::Resync::Dirs::HeldLock>]
155
+ def locks
156
+ tree.everything.subjects.filter_map do |subject|
157
+ lock = Plans::Lock.read(subject.feature.path) or next
158
+ HeldLock.new(dirname: subject.feature.dirname, path: subject.feature.path, lock:)
83
159
  end
84
160
  end
85
161
 
86
- # @param commit [Boolean] actually rename
162
+ # @param commit [Boolean] actually rename, and clear stale local locks
87
163
  # @return [Array<OpenLanes::Lifecycle::Resync::Dirs::Change>] what was proposed
88
164
  def call(commit: false)
89
165
  changes = plan
90
166
  return changes unless commit
91
167
 
168
+ locks.select(&:stale?).each { |held| Plans::Lock.remove(held.path) }
169
+ rewrites.each { |rewrite| rewrite_spec(rewrite.path) }
92
170
  UI.stepping(changes, "Renaming") { |change| rename(change) }
93
171
  tree.reload
94
172
  changes
@@ -96,6 +174,22 @@ module OpenLanes
96
174
 
97
175
  private
98
176
 
177
+ # @return [Array<String>] plan folders a live or foreign lock holds
178
+ def held_paths = locks.reject(&:stale?).map(&:path)
179
+
180
+ # @param subject [OpenLanes::Plans::Subject]
181
+ # @param held [Array<String>]
182
+ # @return [Boolean] whether an agent may be inside it right now
183
+ def left_alone?(subject, held)
184
+ @except.include?(subject.feature.ordinal.to_s) || held.include?(subject.feature.path)
185
+ end
186
+
187
+ # @param path [String] a spec.md
188
+ # @return [void]
189
+ def rewrite_spec(path)
190
+ File.write(path, Plans::Spec.migrate_how_to_verify(File.read(path, encoding: "UTF-8")))
191
+ end
192
+
99
193
  # A folder moves when the name it has is not the name it should have.
100
194
  # {target} says what that name is; this says how to get there, as up
101
195
  # to two renames applied in order. The status rename starts from where
@@ -113,40 +207,71 @@ module OpenLanes
113
207
 
114
208
  spelled = feature.dirname_as(subject.status)
115
209
  if spelled != feature.dirname
116
- steps << step(feature,
117
- from: subject.status,
210
+ steps << step(from: subject.status,
118
211
  to: subject.status,
119
212
  source: feature.path,
213
+ target: beside(feature.path, spelled),
120
214
  reason: spelling_reason(feature))
121
215
  end
122
216
 
123
217
  if fit.key != subject.status.key
124
218
  source = steps.empty? ? feature.path : steps.last.target
125
- steps << step(feature,
126
- from: subject.status,
219
+ steps << step(from: subject.status,
127
220
  to: fit,
128
221
  source:,
222
+ target: beside(source, feature.dirname_as(fit)),
129
223
  reason: subject.violation || "contents now justify #{fit.label}")
130
224
  end
131
225
 
226
+ source = steps.empty? ? feature.path : steps.last.target
227
+ home = home_for(feature, fit)
228
+ steps << step(from: fit, to: fit, source:, target: home, reason: placement_reason(feature, fit)) if home != source
229
+
132
230
  steps
133
231
  end
134
232
 
135
- # @param feature [OpenLanes::Plans::Feature]
136
233
  # @param from [OpenLanes::Plans::Status]
137
234
  # @param to [OpenLanes::Plans::Status]
138
235
  # @param source [String] absolute path the rename starts from
236
+ # @param target [String] absolute path it ends at
139
237
  # @param reason [String]
140
238
  # @return [OpenLanes::Lifecycle::Resync::Dirs::Change]
141
- def step(feature, from:, to:, source:, reason:)
142
- Change.new(
143
- dirname: File.basename(source),
144
- from: from.key,
145
- to: to.key,
146
- source:,
147
- target: File.join(File.dirname(feature.path), feature.dirname_as(to)),
148
- reason:
149
- )
239
+ def step(from:, to:, source:, target:, reason:)
240
+ Change.new(dirname: File.basename(source), from: from.key, to: to.key, source:, target:, reason:)
241
+ end
242
+
243
+ # @param path [String]
244
+ # @param name [String]
245
+ # @return [String] +name+ in the same directory as +path+
246
+ def beside(path, name) = File.join(File.dirname(path), name)
247
+
248
+ # Where a plan in this state lives, and under which name: an inactive
249
+ # plan in its subfolder, dated, keeping a date it already carries
250
+ # there; any other plan at the top level, undated.
251
+ #
252
+ # @param feature [OpenLanes::Plans::Feature]
253
+ # @param status [OpenLanes::Plans::Status]
254
+ # @return [String] absolute
255
+ def home_for(feature, status)
256
+ subfolder = Plans::Tree.subfolder_for(status)
257
+ return File.join(tree.dir, feature.dirname_as(status, archived_on: nil)) unless subfolder
258
+
259
+ parent = File.join(tree.dir, subfolder)
260
+ date = (feature.archived_on if File.dirname(feature.path) == parent) || Vcs::RenameDate.for(feature.path)
261
+ File.join(parent, feature.dirname_as(status, archived_on: date))
262
+ end
263
+
264
+ # @param feature [OpenLanes::Plans::Feature]
265
+ # @param status [OpenLanes::Plans::Status]
266
+ # @return [String]
267
+ def placement_reason(feature, status)
268
+ subfolder = Plans::Tree.subfolder_for(status)
269
+ here = File.dirname(feature.path)
270
+ return "#{status.label} lives in #{subfolder}/" if subfolder && here != File.join(tree.dir, subfolder)
271
+ return "a plan moved aside carries the date it was moved" if subfolder
272
+ return "#{status.label} is not inactive; back to #{File.basename(tree.dir)}/" if here != tree.dir
273
+
274
+ "an open plan's name carries no date"
150
275
  end
151
276
 
152
277
  # @param feature [OpenLanes::Plans::Feature]
@@ -154,19 +279,26 @@ module OpenLanes
154
279
  def spelling_reason(feature)
155
280
  return "#{feature.dirname_ordinal} is not padded to #{feature.ordinal}" unless feature.padded?
156
281
 
157
- "name is not in canonical NNN.MM-<emoji> → <slug> form"
282
+ "name is not in canonical NNN.MM━━<emoji>━━❯<slug> form"
158
283
  end
159
284
 
160
285
  # A rename that finds its target occupied has not happened, and saying
161
286
  # nothing about it would leave the folder misnamed with a report
162
287
  # claiming otherwise. Two folders can want the same canonical name —
163
- # `018-⚪️--foo` and `018.00-⚪️ → foo` are the same plan written twice.
288
+ # `018-⚪--foo` and `018.00━━⚪━━❯foo` are the same plan written twice.
164
289
  #
165
290
  # @param change [OpenLanes::Lifecycle::Resync::Dirs::Change]
166
291
  # @return [void]
167
292
  # @raise [OpenLanes::Error] when the target name is already taken
168
293
  def rename(change)
169
- return if OpenLanes.move_directory(change.source, change.target)
294
+ if OpenLanes.move_directory(change.source, change.target)
295
+ # Counted like any other move, so a session left in the state
296
+ # the plan has just been taken out of is not resumed when it
297
+ # comes back. A folder with no state.json has no session to lose.
298
+ state = Plans::PlanState.for(change.target)
299
+ state.state!(change.to) if change.from != change.to && state.exist?
300
+ return
301
+ end
170
302
 
171
303
  raise Error, "cannot rename #{change.dirname} — #{File.basename(change.target)} already exists"
172
304
  end
@@ -346,7 +478,7 @@ module OpenLanes
346
478
  match = BRANCH_PATTERN.match(pull[:branch].to_s) or return nil
347
479
  ordinal = Plans::Ordinal.parse(match[1])
348
480
 
349
- unless tree.include?(ordinal)
481
+ unless tree.everything.include?(ordinal)
350
482
  return flag(pull, "branch names #{ordinal}, which has no folder in #{File.basename(tree.dir)}")
351
483
  end
352
484
 
@@ -369,13 +501,9 @@ module OpenLanes
369
501
  # @param path [String] a path from the diff
370
502
  # @return [OpenLanes::Plans::Ordinal, nil]
371
503
  def ordinal_for_path(path)
372
- parts = path.to_s.split("/")
373
- index = parts.index(OpenLanes::PLANS_DIR) or return nil
374
- folder = parts[index + 1] or return nil
375
- return nil if parts.length <= index + 2 # a file directly under .plans belongs to no plan
376
-
504
+ folder = Plans::Tree.plan_folder(path) or return nil
377
505
  ordinal = Plans::Ordinal.from_dirname(folder)
378
- ordinal if ordinal && tree.include?(ordinal)
506
+ ordinal if ordinal && tree.everything.include?(ordinal)
379
507
  end
380
508
 
381
509
  # @param pull [Hash]