openlanes 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # A question for a person, read as D3 shapes it: a short context paragraph,
6
+ # then lettered options `a)`, `b)`, `c)`, one of them recommended. The page
7
+ # draws a button per option, and `openlanes unblock` would offer the same
8
+ # ones, so both read them here rather than each guessing from the prose.
9
+ #
10
+ # Agents are told to write only that shape, but files written before D3,
11
+ # and agents that drift, use others. {.parse} reads them forgivingly:
12
+ #
13
+ # - lettered: `a)`, `(a)` or `a.` at the start of a line, or `a)` and
14
+ # `(a)` inline, the way agents actually run a whole choice into one
15
+ # paragraph; bullet-prefixed or bold-wrapped (`- **a)**`) alike
16
+ # - numbered: `1.`, `1)` or `Option 1:` at the start of a line
17
+ # - the W30 mark, `answers: yes, no`, which reads as `a) yes` `b) no`
18
+ #
19
+ # It is pure: text in, a value out, no file and no network. Whether the
20
+ # text really asks the reader to choose is a judgement the parser cannot
21
+ # make about a numbered list of steps already done, which is why the
22
+ # snapshot can ask {Live::ChoiceJudge} as well.
23
+ #
24
+ # @example
25
+ # choices = Plans::Choices.parse("Rebase or merge?\n\na) Rebase (Recommended)\nb) Merge")
26
+ # choices.context # => "Rebase or merge?"
27
+ # choices.options.map(&:answer) # => ["a) Rebase", "b) Merge"]
28
+ # choices.recommended.letter # => "a"
29
+ class Choices < Data.define(:context, :options)
30
+ # The text the person's choice sends back to the agent, beside how the
31
+ # option is shown. A yes/no option answers with the bare word, as W30
32
+ # promised agents ("branch on the word"); every other answers with its
33
+ # letter and label, so the agent can match either.
34
+ #
35
+ # @!attribute letter [String] "a", "b", …
36
+ # @!attribute label [String] what the button says, the marker stripped
37
+ # @!attribute recommended [Boolean]
38
+ # @!attribute answer [String] what choosing it sends
39
+ Option = Data.define(:letter, :label, :recommended, :answer) do
40
+ # @return [Hash] the page's `QuestionOption`
41
+ def to_h = { letter:, label:, recommended:, answer: }
42
+ end
43
+
44
+ # How an agent marked a yes/no question before D3 (W30), on its own line.
45
+ # Still read, and still what the prompt shows for a plain yes/no.
46
+ YES_NO_MARK = "answers: yes, no"
47
+
48
+ # {YES_NO_MARK}, read forgivingly: brackets, case and spacing vary.
49
+ YES_NO = /answers:\s*\[?\s*yes\s*,\s*no\s*\]?/i
50
+
51
+ # A letter's marker. At the start of a line (after a bullet or bold) it
52
+ # may be `a)`, `(a)` or `a.`, either case; inline only the parenthesised
53
+ # lowercase forms count, because `e.g.` and `I.` are prose.
54
+ LETTER_LINE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?(?:\(([a-z])\)|([a-z])[).])(?:\*\*)?[ \t]+/i
55
+ LETTER_INLINE = /(?<=[\s:])(?:\*\*)?(?:\(([a-z])\)|([a-z])\))(?:\*\*)?[ \t]+/
56
+
57
+ # A number's marker, line start only: `1.`, `1)`, `Option 1:`.
58
+ NUMBER_LINE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?(?:option[ \t]+(\d{1,2})[:.)]|(\d{1,2})[.)])(?:\*\*)?[ \t]+/i
59
+
60
+ # A numbered list is also how agents report steps already done, so one
61
+ # counts as a choice only after something that asks.
62
+ ASKS = /\?|\b(?:choose|chose|pick|prefer|option|which|decide)\b/i
63
+
64
+ # The recommendation, in the ways it gets written: bracketed anywhere
65
+ # (`(Recommended)`, `[recommended]`), or bare as the option's first or
66
+ # last word ("Recommended. Rebase first"). A bare "recommended" mid-label
67
+ # is prose ("the recommended layout") and stays where it is.
68
+ RECOMMENDED = /
69
+ [[:blank:]]*(?:\*\*|_)?[(\[][[:blank:]]*recommended[[:blank:]]*[)\]](?:\*\*|_)?[[:blank:]]*[.:,;]?
70
+ | \A(?:\*\*|_)?recommended(?:\*\*|_)?[[:blank:]]*[.:,;—–-][[:blank:]]*
71
+ | [[:blank:]]*[—–-][[:blank:]]*(?:\*\*|_)?recommended(?:\*\*|_)?\.?\z
72
+ /ix
73
+
74
+ # The option the page's text box already is. D3 makes it the last
75
+ # choice of every question, so an agent that writes it out gets no
76
+ # second, dead button.
77
+ SOMETHING_ELSE = /\A(?:type|write|say)\s+something\s+(?:else\s+)?instead\b|\Asomething\s+else\b|\Aother\b/i
78
+
79
+ # A label that runs into a new paragraph is not a label: the list ended
80
+ # and prose followed it, so the options are not the end of the text.
81
+ PARAGRAPH = /\n[ \t]*\n/
82
+
83
+ class << self
84
+ # @param text [String, nil] a question's body, Markdown
85
+ # @return [OpenLanes::Plans::Choices, nil] nil when the text offers
86
+ # fewer than two options, or its options are not where it ends
87
+ def parse(text)
88
+ text = text.to_s.strip
89
+ return nil if text.empty?
90
+
91
+ marked(text) || lettered(text) || numbered(text)
92
+ end
93
+
94
+ # A yes/no question about +context+.
95
+ #
96
+ # @param context [String]
97
+ # @return [OpenLanes::Plans::Choices]
98
+ def yes_no(context)
99
+ new(context: context.to_s.strip,
100
+ options: [Option.new(letter: "a", label: "yes", recommended: false, answer: "yes"),
101
+ Option.new(letter: "b", label: "no", recommended: false, answer: "no")])
102
+ end
103
+
104
+ # The D3 shape, written out: the context, a blank line, then one
105
+ # lettered line per label with the recommended one marked. {.parse}
106
+ # reads it back into the same labels, which is how a rewritten
107
+ # question proves it kept its shape.
108
+ #
109
+ # @param context [String]
110
+ # @param labels [Array<String>] one line each
111
+ # @param recommended [Integer] the index of the recommended label
112
+ # @return [String] Markdown, ending in a newline
113
+ def write(context:, labels:, recommended:)
114
+ lines = labels.each_with_index.map do |label, i|
115
+ "#{("a".ord + i).chr}) #{label}#{" (Recommended)" if i == recommended}"
116
+ end
117
+ "#{context.to_s.strip}\n\n#{lines.join("\n")}\n"
118
+ end
119
+
120
+ private
121
+
122
+ # The W30 mark, on a line of its own.
123
+ def marked(text)
124
+ line = /^[ \t]*#{YES_NO.source}[ \t]*$\n?/i
125
+ return nil unless text.match?(line)
126
+
127
+ yes_no(text.gsub(line, ""))
128
+ end
129
+
130
+ # A line's marker also matches inline from its letter on, so the
131
+ # inline match inside one (the `a)` of `- a)`) is dropped: kept, it
132
+ # would leave the bullet behind at the end of the context.
133
+ def lettered(text)
134
+ lines = scan(text, LETTER_LINE) { |m| (m[1] || m[2]).downcase.ord - "a".ord }
135
+ inline = scan(text, LETTER_INLINE) { |m| (m[1] || m[2]).ord - "a".ord }
136
+ .reject { |start, _, _| lines.any? { |from, to, _| (from...to).cover?(start) } }
137
+ run(text, (lines + inline).sort_by(&:first))
138
+ end
139
+
140
+ def numbered(text)
141
+ markers = scan(text, NUMBER_LINE) { |m| (m[1] || m[2]).to_i - 1 }
142
+ choices = run(text, markers)
143
+ choices if choices&.context&.match?(ASKS)
144
+ end
145
+
146
+ # Every marker in +text+ as `[start, finish, index]`, index 0 for `a`
147
+ # or `1`.
148
+ def scan(text, pattern)
149
+ found = []
150
+ text.scan(pattern) do
151
+ match = Regexp.last_match
152
+ found << [match.begin(0), match.end(0), yield(match)]
153
+ end
154
+ found
155
+ end
156
+
157
+ # The last run of markers that counts 0, 1, 2… up to the end of the
158
+ # text. The last, because a context may itself mention "a) or b)"
159
+ # before the real options; and to the end, because a list followed by
160
+ # more prose was something other than a choice.
161
+ def run(text, markers)
162
+ markers.each_index.reverse_each do |first|
163
+ next unless markers[first].last.zero?
164
+
165
+ picked = sequence(markers.drop(first))
166
+ next if picked.size < 2
167
+
168
+ choices = build(text, picked)
169
+ return choices if choices
170
+ end
171
+ nil
172
+ end
173
+
174
+ # From a marker for index 0, the next marker for 1, then 2, skipping
175
+ # anything out of order (an `(e)` quoted inside a label).
176
+ def sequence(markers)
177
+ markers.each_with_object([]) do |marker, picked|
178
+ picked << marker if marker.last == picked.size
179
+ end
180
+ end
181
+
182
+ def build(text, picked)
183
+ labels = picked.each_with_index.map do |(_, finish, _), i|
184
+ text[finish...(picked[i + 1]&.first || text.size)].strip
185
+ end
186
+ return nil if labels.any? { it.empty? || it.match?(PARAGRAPH) }
187
+
188
+ options = labels.each_with_index.map { |label, i| option(label, i) }
189
+ options.pop if options.last&.label&.match?(SOMETHING_ELSE)
190
+ return nil if options.size < 2
191
+
192
+ new(context: text[0...picked.first.first].strip, options: only_one_recommended(options))
193
+ end
194
+
195
+ def option(raw, index)
196
+ recommended = raw.match?(RECOMMENDED)
197
+ label = unbold(raw.sub(RECOMMENDED, " ").strip).strip
198
+ letter = ("a".ord + index).chr
199
+ Option.new(letter:, label:, recommended:, answer: "#{letter}) #{label}")
200
+ end
201
+
202
+ # A bold marker leaves its partner behind: `**a) Rebase**` becomes
203
+ # `Rebase**`. An odd count of `**` means one of them lost its pair.
204
+ def unbold(label)
205
+ label = label.sub('**', "") if label.scan("**").size.odd?
206
+ label = label[2...-2].strip if label.start_with?("**") && label.end_with?("**") && label.size > 4
207
+ label
208
+ end
209
+
210
+ # D3 says exactly one; an agent that recommends two has recommended
211
+ # the first, and the page shows one tag rather than several.
212
+ def only_one_recommended(options)
213
+ first = options.index(&:recommended)
214
+ options.each_with_index.map { |o, i| o.recommended && i != first ? o.with(recommended: false) : o }
215
+ end
216
+ end
217
+
218
+ # @return [OpenLanes::Plans::Choices::Option, nil]
219
+ def recommended = options.find(&:recommended)
220
+
221
+ # @return [Boolean] whether the options are yes and no
222
+ def yes_no? = options.map(&:label) == %w[yes no]
223
+
224
+ # @return [Array<Hash>] the page's `QuestionOption[]`
225
+ def to_a = options.map(&:to_h)
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # Who waits for whom, read from every plan's `depends_on:` and nothing
6
+ # else. Only the forward edge is written down; `blocks` is derived here
7
+ # every time and never stored, so dropping a dependency cannot leave a
8
+ # stale reverse entry behind in the plan it used to name (N6).
9
+ #
10
+ # Every answer is an array of canonical `NNN.MM` strings, sorted. Asking
11
+ # about a plan the tree does not hold answers empty rather than raising.
12
+ #
13
+ # Plans joined by an unfinished edge, in either direction, form a group,
14
+ # and a group works on one plan at a time (#95): the first in topological
15
+ # order that may start, so two plans that will meet in a common dependent
16
+ # are never built against each other's moving code. Separate groups run
17
+ # side by side. An edge to a finished plan joins nothing, so the
18
+ # dependents of a shipped plan part ways.
19
+ class Dependencies
20
+ # The states in which a dependency is done. Everything else, blocked and
21
+ # deferred included, keeps the dependent waiting.
22
+ DONE = %i[approved deployed].freeze
23
+
24
+ # The states before the build. Work in them is documents only, so a plan
25
+ # here gives up its group's turn to one whose code is already under way.
26
+ BEFORE_BUILD = %i[retroactive new researched ready_for_planning planned].freeze
27
+
28
+ # @param tree [OpenLanes::Plans::Tree]
29
+ # @param status_of [#call, nil] the state to judge a plan by, when it is
30
+ # not the folder's name. A dry run renames nothing, so without this a
31
+ # prerequisite whose contents already say ✅ would still hold its
32
+ # dependents back, while a real run would have reconciled it and let
33
+ # them start.
34
+ def initialize(tree, status_of: nil)
35
+ @tree = tree
36
+ @status_of = status_of || :status.to_proc
37
+ end
38
+
39
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
40
+ # @return [Array<String>, nil] the declared list, nil when the key is absent
41
+ def depends_on(ordinal) = declared[key(ordinal)]
42
+
43
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
44
+ # @return [Array<String>] every plan whose `depends_on` names this one
45
+ def blocks_of(ordinal) = reverse.fetch(key(ordinal), [])
46
+
47
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
48
+ # @return [Array<String>] declared entries that are not done, unknown ones included
49
+ def waiting_on(ordinal) = edges(ordinal).reject { done?(it) }
50
+
51
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
52
+ # @return [Array<String>] declared entries with no folder in the tree
53
+ def unknown(ordinal) = edges(ordinal).reject { states.key?(it) }
54
+
55
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
56
+ # @return [Array<String>] declared entries naming a done plan, which the gate ignores
57
+ def closed(ordinal) = edges(ordinal).select { done?(it) }
58
+
59
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
60
+ # @return [Array<String>, nil] a shortest cycle through this plan,
61
+ # starting and ending with it, or nil when it is on none
62
+ def cycle_of(ordinal)
63
+ wanted = key(ordinal)
64
+ return nil unless looped?(wanted)
65
+
66
+ (@rings ||= {})[wanted] ||= ring_from(wanted)
67
+ end
68
+
69
+ # One shortest cycle per tangle of plans that wait on each other, each
70
+ # starting and ending at the tangle's lowest ordinal. A tangle can hold
71
+ # more cycles than this; {#cycle_of} names one through any plan in it.
72
+ #
73
+ # @return [Array<Array<String>>]
74
+ def cycles = @cycles ||= components.select { looped?(it.first) }.map { cycle_of(it.first) }.sort
75
+
76
+ # Unfinished plans joined by an unfinished edge, in either direction.
77
+ #
78
+ # @return [Array<Array<String>>] each group sorted, groups by first ordinal
79
+ def groups
80
+ @groups ||= begin
81
+ # Union-find, walked in a loop rather than recursively, so a long
82
+ # chain of plans cannot raise SystemStackError inside a tick.
83
+ root = unfinished.to_h { [it, it] }
84
+ find = lambda do |x|
85
+ x = root[x] = root[root[x]] until root[x] == x
86
+ x
87
+ end
88
+ unfinished.each do |from|
89
+ open_edges.fetch(from).each { |to| root[find.call(from)] = find.call(to) if root.key?(to) }
90
+ end
91
+ unfinished.group_by { find.call(it) }.values.map(&:sort).sort
92
+ end
93
+ end
94
+
95
+ # Each group in topological order, prerequisites first, ties by
96
+ # ordinal. Plans on a cycle cannot be ordered and come last.
97
+ #
98
+ # @return [Array<Array<String>>] parallel to {#groups}
99
+ def order = @order ||= groups.map { sorted(it) }
100
+
101
+ # The plan whose turn it is in this plan's group, or nil when no plan
102
+ # in it may start: the first in {#order} that may, preferring one whose
103
+ # build is already under way, so work already done is never stranded.
104
+ #
105
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
106
+ # @return [String, nil]
107
+ def head(ordinal)
108
+ wanted = key(ordinal)
109
+ line = order.find { it.include?(wanted) } or return nil
110
+ (@heads ||= {}).fetch(line.first) do
111
+ ready = line.select { startable?(it) }
112
+ @heads[line.first] = ready.find { !BEFORE_BUILD.include?(states[it]) } || ready.first
113
+ end
114
+ end
115
+
116
+ # Why the engine must not start this plan, or nil when it may. A cycle
117
+ # outranks a plain wait, since waiting on a cycle never ends. An entry
118
+ # naming no plan is said apart from a real wait: a typo such as
119
+ # "099.00" holds the plan forever, and "waiting on 099.00" in the held
120
+ # report read like a wait that would end on its own. A plan free of
121
+ # all three still waits while another plan holds its group's turn.
122
+ #
123
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
124
+ # @return [String, nil] e.g. "waiting on 034.00", "unknown plan 099.00",
125
+ # "cycle: 034.00 → 050.00 → 034.00" or "queued behind 034.00"
126
+ def reason(ordinal)
127
+ own = own_reason(ordinal) and return own
128
+
129
+ first = head(ordinal)
130
+ "queued behind #{first}" if first && first != key(ordinal)
131
+ end
132
+
133
+ private
134
+
135
+ # {#reason} without the group's turn: what this plan's own edges say.
136
+ #
137
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
138
+ # @return [String, nil]
139
+ def own_reason(ordinal)
140
+ cycle = cycle_of(ordinal)
141
+ return "cycle: #{cycle.join(" → ")}" if cycle
142
+
143
+ missing = unknown(ordinal)
144
+ open = waiting_on(ordinal) - missing
145
+ parts = []
146
+ parts << "unknown plan #{missing.join(", ")}" unless missing.empty?
147
+ parts << "waiting on #{open.join(", ")}" unless open.empty?
148
+ parts.join("; ") unless parts.empty?
149
+ end
150
+
151
+ # @return [Boolean] whether this plan could take its group's turn:
152
+ # neither parked nor done, and free of its own reasons to wait
153
+ def startable?(ordinal) = !StateMachine::SETTLED.include?(states[ordinal]) && own_reason(ordinal).nil?
154
+
155
+ # @return [Array<String>] plans in the tree that are not done
156
+ def unfinished = @unfinished ||= declared.keys.reject { done?(it) }.sort
157
+
158
+ # Kahn's algorithm over the group's open edges, taking the lowest
159
+ # ordinal whenever several plans are free. The free list stays sorted
160
+ # by binary insertion, so a group of thousands sorts in a blink.
161
+ #
162
+ # @param group [Array<String>]
163
+ # @return [Array<String>]
164
+ def sorted(group)
165
+ members = group.to_set
166
+ pending = group.to_h { |plan| [plan, open_edges.fetch(plan).count { members.include?(it) }] }
167
+ free = pending.select { |_, n| n.zero? }.keys.sort
168
+ out = []
169
+ until free.empty?
170
+ plan = free.shift
171
+ out << plan
172
+ pending.delete(plan)
173
+ blocks_of(plan).each do |waiter|
174
+ next unless pending.key?(waiter) && (pending[waiter] -= 1).zero?
175
+
176
+ free.insert(free.bsearch_index { it >= waiter } || free.size, waiter)
177
+ end
178
+ end
179
+ out + pending.keys.sort
180
+ end
181
+
182
+ # @return [String]
183
+ def key(ordinal) = (ordinal.is_a?(Ordinal) ? ordinal : Ordinal.parse(ordinal)).to_s
184
+
185
+ # @return [Array<String>]
186
+ def edges(ordinal) = depends_on(ordinal) || []
187
+
188
+ # @return [Boolean]
189
+ def done?(ordinal) = DONE.include?(states[ordinal])
190
+
191
+ # @return [Hash{String => Symbol}] ordinal => status key
192
+ def states = @states ||= @tree.subjects.to_h { [it.feature.ordinal.to_s, @status_of.call(it).key] }
193
+
194
+ # @return [Hash{String => Array<String>, nil}]
195
+ def declared
196
+ @declared ||= @tree.subjects.to_h { [it.feature.ordinal.to_s, Spec.for(it).depends_on] }
197
+ end
198
+
199
+ # @return [Hash{String => Array<String>}]
200
+ def reverse
201
+ @reverse ||= declared.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(from, tos), out|
202
+ Array(tos).each { out[it] << from }
203
+ end.transform_values(&:sort)
204
+ end
205
+
206
+ # The edges that can still hold anything up: entries not yet done that
207
+ # name a plan in the tree. A dependency on a ✅ plan is already met, so
208
+ # it closes no cycle; two plans that once named each other stop being
209
+ # deadlocked the moment one of them ships.
210
+ #
211
+ # @return [Hash{String => Array<String>}]
212
+ def open_edges = @open_edges ||= declared.keys.to_h { [it, waiting_on(it).select { declared.key?(it) }] }
213
+
214
+ # @return [Boolean] whether this plan sits on a cycle of open edges
215
+ def looped?(ordinal)
216
+ group = group_of[ordinal] or return false
217
+ group.size > 1 || open_edges.fetch(ordinal).include?(ordinal)
218
+ end
219
+
220
+ # @return [Hash{String => Array<String>}] ordinal => its strongly connected component
221
+ def group_of = @group_of ||= components.each_with_object({}) { |g, out| g.each { out[it] = g } }
222
+
223
+ # Tarjan's strongly connected components over {#open_edges}, so each
224
+ # plan is visited once. Enumerating every path instead revisited shared
225
+ # subgraphs on each one: 28 plans each waiting on the two before took
226
+ # 2.6 seconds and two million visits, and the dispatcher asks on every
227
+ # tick. The walk keeps its own stack of frames rather than recursing,
228
+ # so a chain of a few thousand plans cannot raise SystemStackError
229
+ # inside a dispatcher tick.
230
+ #
231
+ # @return [Array<Array<String>>] each component sorted
232
+ def components
233
+ @components ||= begin
234
+ index = {}
235
+ low = {}
236
+ stack = []
237
+ on_stack = {}
238
+ out = []
239
+ open_edges.keys.sort.each do |root|
240
+ next if index.key?(root)
241
+
242
+ frames = [[root, 0]]
243
+ index[root] = low[root] = index.size
244
+ stack << root
245
+ on_stack[root] = true
246
+ until frames.empty?
247
+ v, i = frames.last
248
+ w = open_edges.fetch(v)[i]
249
+ if w
250
+ frames.last[1] += 1
251
+ if !index.key?(w)
252
+ index[w] = low[w] = index.size
253
+ stack << w
254
+ on_stack[w] = true
255
+ frames << [w, 0]
256
+ elsif on_stack[w]
257
+ low[v] = [low[v], index[w]].min
258
+ end
259
+ next
260
+ end
261
+
262
+ frames.pop
263
+ parent = frames.last&.first
264
+ low[parent] = [low[parent], low[v]].min if parent
265
+ next unless low[v] == index[v]
266
+
267
+ group = []
268
+ group << stack.pop.tap { on_stack.delete(it) } until group.last == v
269
+ out << group.sort
270
+ end
271
+ end
272
+ out
273
+ end
274
+ end
275
+
276
+ # Breadth-first from +start+ back to itself inside its component, so
277
+ # the cycle named is a shortest one and the walk is linear.
278
+ #
279
+ # @param start [String]
280
+ # @return [Array<String>]
281
+ def ring_from(start)
282
+ group = group_of.fetch(start)
283
+ parent = { start => nil }
284
+ queue = [start]
285
+ until queue.empty?
286
+ node = queue.shift
287
+ open_edges.fetch(node).sort.each do |nxt|
288
+ next unless group.include?(nxt)
289
+
290
+ if nxt == start
291
+ path = [node]
292
+ path.unshift(parent[path.first]) while parent[path.first]
293
+ return path + [start]
294
+ end
295
+ next if parent.key?(nxt)
296
+
297
+ parent[nxt] = node
298
+ queue << nxt
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
4
+
3
5
  module OpenLanes
4
6
  module Plans
5
7
  # Lowercase in the middle of a title, capitalised at the front.
@@ -45,22 +47,36 @@ module OpenLanes
45
47
  }.join(" ")
46
48
  end
47
49
 
48
- # The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
50
+ # The heavy rule between the parts of a folder name. No space anywhere, so
51
+ # a name needs no quoting in a shell, and nothing an emoji can swallow.
52
+ RULE = "━━"
53
+
54
+ # What points from the state to the slug.
55
+ POINTER = "❯"
56
+
57
+ # The one place a plan folder's name is spelled out (decision N1):
58
+ # `NNN.MM━━<emoji>━━❯<slug>`, or `NNN.MM━━<emoji>━━❯[YYYY-MM-DD]━━<slug>`
59
+ # once the plan has been moved into an inactive subfolder (N3).
49
60
  #
50
- # The spaced arrow after the emoji is deliberate: an emoji renders two cells
51
- # wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
52
- # as if the emoji and the slug were touching. Everything that mints or
53
- # renames a folder goes through here; {Feature.parse} accepts the older
54
- # `--` and single-dash spellings too, and `resync dirs` migrates them on
55
- # contact.
61
+ # The earlier `NNN.MM-<emoji> → <slug>` put a space in every name, and the
62
+ # `--` before it let a two-cell emoji swallow the dash beside it.
63
+ # Everything that mints or renames a folder goes through here; {Feature.parse}
64
+ # still reads every older spelling, and `resync dirs` migrates them.
56
65
  #
57
66
  # @param ordinal [OpenLanes::Plans::Ordinal, String]
58
67
  # @param status [OpenLanes::Plans::Status]
59
68
  # @param slug [String]
69
+ # @param archived_on [Date, nil] the date the plan was moved aside
60
70
  # @return [String]
61
- def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
71
+ def self.plan_dirname(ordinal, status, slug, archived_on: nil)
72
+ date = archived_on && "[#{archived_on.strftime("%F")}]#{RULE}"
73
+ "#{ordinal}#{RULE}#{status.emoji}#{RULE}#{POINTER}#{date}#{slug}"
74
+ end
75
+
76
+ # The current spelling, with or without the archive date.
77
+ CURRENT_FORM = /\A[\d.]+#{RULE}(?<emoji>[^━]+)#{RULE}#{POINTER}(?:\[(?<date>\d{4}-\d{2}-\d{2})\]#{RULE})?(?<slug>.+)\z/
62
78
 
63
- # One `NNN.MM-<emoji> → <slug>` folder, decoded.
79
+ # One plan folder, decoded.
64
80
  #
65
81
  # @!attribute [r] ordinal
66
82
  # @return [OpenLanes::Plans::Ordinal]
@@ -72,9 +88,14 @@ module OpenLanes
72
88
  # @return [String] exactly as it is on disk
73
89
  # @!attribute [r] path
74
90
  # @return [String] absolute
75
- Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
91
+ # @!attribute [r] archived_on
92
+ # @return [Date, nil] the date in the name of a plan moved aside, else nil
93
+ Feature = Data.define(:ordinal, :status, :slug, :dirname, :path, :archived_on) do
76
94
  include Comparable
77
95
 
96
+ # @param archived_on [Date, nil] optional, so callers that predate it still build one
97
+ def initialize(archived_on: nil, **) = super
98
+
78
99
  # Decode a folder name, or return nil when it is not a plan folder.
79
100
  #
80
101
  # @param path [String] absolute path to a candidate directory
@@ -83,29 +104,67 @@ module OpenLanes
83
104
  dirname = File.basename(path)
84
105
  ordinal = Ordinal.from_dirname(dirname) or return nil
85
106
 
107
+ current(dirname, ordinal, path) || legacy(dirname, ordinal, path)
108
+ end
109
+
110
+ # `NNN.MM━━<emoji>━━❯<slug>`, dated or not.
111
+ #
112
+ # An emoji nothing answers to reads as ⚪ New rather than falling through
113
+ # to {.legacy}, which cannot split this spelling and took the whole name
114
+ # for the slug; the next rename then wrote
115
+ # `001.00━━⭕━━❯001.00━━🛑━━❯slug`. A name already nested that way is
116
+ # read down to its innermost slug, so `resync dirs` can undo it.
117
+ #
118
+ # @return [OpenLanes::Plans::Feature, nil]
119
+ def self.current(dirname, ordinal, path)
120
+ m = CURRENT_FORM.match(dirname) or return nil
121
+ status = OpenLanes::Plans.status_for_emoji(m[:emoji]) || STATUS_BY_KEY.fetch(:new)
122
+ archived_on = m[:date] && Date.iso8601(m[:date], exception: false)
123
+
124
+ new(ordinal:, status:, slug: innermost(m[:slug], ordinal), dirname:, path:, archived_on:)
125
+ end
126
+
127
+ # @param slug [String]
128
+ # @param ordinal [OpenLanes::Plans::Ordinal]
129
+ # @return [String] +slug+ without any plan prefix of the same ordinal
130
+ def self.innermost(slug, ordinal)
131
+ return slug unless Ordinal.from_dirname(slug) == ordinal
132
+
133
+ parse(slug)&.slug || slug
134
+ end
135
+
136
+ # `NNN.MM-<emoji> → <slug>`, `NNN-<emoji>--<slug>` and `NNN-<emoji>-<slug>`.
137
+ #
138
+ # @return [OpenLanes::Plans::Feature]
139
+ def self.legacy(dirname, ordinal, path)
86
140
  rest = dirname.sub(/\A[\d.]+[-_]/, "")
87
141
  head, tail = rest.split(/\s*→\s*|[-_]/, 2)
88
142
 
89
143
  # A leading segment with no ASCII word character is the status emoji.
90
- # The slug strips any further separators: the canonical spelling puts
91
- # a spaced arrow after the emoji, and folders from before that rule put
92
- # two dashes, or one.
144
+ # The slug strips any further separators: the arrow spelling put a
145
+ # spaced arrow after the emoji, and folders from before that put two
146
+ # dashes, or one.
93
147
  status = (OpenLanes::Plans.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
94
148
  slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
95
149
 
96
150
  new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
97
151
  end
152
+ private_class_method :current, :legacy, :innermost
98
153
 
99
154
  # @return [String] proper name, e.g. "Law as Data"
100
155
  def title = OpenLanes::Plans.titleize(slug)
101
156
 
102
157
  # The folder name this feature would carry in a given state — the slug
103
- # never moves, and the number is always rendered canonically, so this is
104
- # also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
158
+ # and any archive date never move, and the number is always rendered
159
+ # canonically, so this is also what repairs a folder written
160
+ # `018-⚪️--foo` before the `NNN.MM` rule.
105
161
  #
106
162
  # @param status [OpenLanes::Plans::Status]
163
+ # @param archived_on [Date, nil] defaults to the date the name carries now
107
164
  # @return [String]
108
- def dirname_as(status) = OpenLanes::Plans.plan_dirname(ordinal, status, slug)
165
+ def dirname_as(status, archived_on: self.archived_on)
166
+ OpenLanes::Plans.plan_dirname(ordinal, status, slug, archived_on:)
167
+ end
109
168
 
110
169
  # The number exactly as the folder writes it, which is not always the
111
170
  # canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders