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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aasm"
4
+
5
+ module OpenLanes
6
+ module Plans
7
+ # The state machine for one pull request (decision P2).
8
+ #
9
+ # A plan's state is the emoji in its folder name; a pull request's is the
10
+ # {ReviewLabel} it carries on GitHub. A plan may have several pull
11
+ # requests, each somewhere else in review, so the two machines are kept
12
+ # apart: {StateMachine} moves folders, this one says which label may
13
+ # follow which. Like {StateMachine}, it keeps no state of its own. It is
14
+ # read off the labels and the row's GitHub state, and the caller makes
15
+ # the change on GitHub through {Vcs::Review} or {Vcs::GitHub}.
16
+ #
17
+ # `merged` and `closed` are not labels. They are GitHub's own states,
18
+ # and they outrank any label still on the pull request.
19
+ class PullRequestMachine
20
+ include AASM
21
+
22
+ # Raised when a move is refused, naming both ends.
23
+ class Refused < Error; end
24
+
25
+ # Label name => state key: `ready-to-merge` is `:ready_to_merge`.
26
+ #
27
+ # @param name [String]
28
+ # @return [Symbol]
29
+ def self.key_for(name) = name.to_s.tr("-", "_").to_sym
30
+
31
+ # The state with no label on it yet: opened before the publisher
32
+ # labelled it, or by a person.
33
+ UNLABELLED = :unlabelled
34
+
35
+ aasm do
36
+ state UNLABELLED, initial: true
37
+ ReviewLabel::ALL.each { |label| state PullRequestMachine.key_for(label.name) }
38
+ state :merged
39
+ state :closed
40
+
41
+ event :submit do
42
+ transitions from: %i[unlabelled review_rejected review_vulnerability], to: :ready_for_review
43
+ end
44
+
45
+ event :start_review do
46
+ transitions from: %i[unlabelled ready_for_review], to: :review_in_progress
47
+ end
48
+
49
+ # The reviewers' verdicts, and chewie's rejection after them. A
50
+ # rejection also reaches back from `ready-to-merge` and
51
+ # `merge-manually`: a pull request that no longer rebases onto
52
+ # `main` after its sibling merged goes back to the builders (P5), and
53
+ # a person may reject one they were asked to merge.
54
+ event :reject do
55
+ transitions from: %i[review_in_progress review_ok_not_perfect review_approved ready_to_merge merge_manually],
56
+ to: :review_rejected
57
+ end
58
+
59
+ event :flag_vulnerability do
60
+ transitions from: %i[review_in_progress review_rejected], to: :review_vulnerability
61
+ end
62
+
63
+ event :pass_with_doubts do
64
+ transitions from: %i[review_in_progress], to: :review_ok_not_perfect
65
+ end
66
+
67
+ event :approve do
68
+ transitions from: %i[review_in_progress], to: :review_approved
69
+ end
70
+
71
+ # chewie verified it, CI is green and the coverage gate holds.
72
+ event :ready do
73
+ transitions from: %i[review_ok_not_perfect review_approved], to: :ready_to_merge
74
+ end
75
+
76
+ # palpatine's two verdicts, as the engine acted on them. Only a
77
+ # `ready-to-merge` pull request is ruled on.
78
+ event :merge_automatically do
79
+ transitions from: %i[ready_to_merge], to: :auto_merged
80
+ end
81
+
82
+ event :leave_for_a_person do
83
+ transitions from: %i[ready_to_merge], to: :merge_manually
84
+ end
85
+
86
+ # GitHub's own states. A person may merge or close at any point; an
87
+ # `auto-merged` pull request is merged already.
88
+ event :merged do
89
+ transitions from: ReviewLabel::ALL.map { PullRequestMachine.key_for(it.name) } + [UNLABELLED], to: :merged
90
+ end
91
+
92
+ event :close do
93
+ transitions from: (ReviewLabel::ALL.map { PullRequestMachine.key_for(it.name) } - %i[auto_merged]) + [UNLABELLED],
94
+ to: :closed
95
+ end
96
+ end
97
+
98
+ # Where a pull request stands, from its row and its labels. GitHub's
99
+ # merged or closed wins over any label; among labels, the one furthest
100
+ # along {ReviewLabel::ALL} wins, since a stale verdict left beside a
101
+ # newer one is the older of the two.
102
+ #
103
+ # @param pull [PullRequest] its row in `pull-requests.md`
104
+ # @param labels [Array<String>] its labels on GitHub now
105
+ # @return [Symbol]
106
+ def self.state_of(pull, labels)
107
+ return :merged if pull.merged?
108
+ return :closed unless pull.open?
109
+
110
+ label = ReviewLabel::ALL.rfind { labels.include?(it.name) }
111
+ label ? key_for(label.name) : UNLABELLED
112
+ end
113
+
114
+ # @param pull [PullRequest]
115
+ # @param labels [Array<String>]
116
+ def initialize(pull, labels)
117
+ @pull = pull
118
+ @key = self.class.state_of(pull, labels)
119
+ end
120
+
121
+ # @return [PullRequest]
122
+ attr_reader :pull
123
+
124
+ # @return [Symbol] the state right now
125
+ attr_reader :key
126
+
127
+ # AASM keeps state in a column; this one is read off GitHub, and a move
128
+ # is only ever asked about, never stored here.
129
+ #
130
+ # @return [Symbol]
131
+ def aasm_read_state(_name = :default) = @key
132
+
133
+ # @return [Boolean]
134
+ def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name)
135
+
136
+ # @return [Boolean]
137
+ def aasm_write_state_without_persistence(new_state, _name = :default)
138
+ @key = new_state
139
+ true
140
+ end
141
+
142
+ # @return [Boolean] whether palpatine's verdict has been acted on (P2)
143
+ def complete? = %i[auto_merged merge_manually].include?(key)
144
+
145
+ # @param event [Symbol]
146
+ # @return [Boolean] whether +event+ may fire from here
147
+ def may?(event) = aasm.may_fire_event?(event)
148
+
149
+ # Take one step, so the caller learns the move is legal before it
150
+ # changes anything on GitHub.
151
+ #
152
+ # @param event [Symbol]
153
+ # @return [Symbol] the state now occupied
154
+ # @raise [Refused] when the move is not in the topology
155
+ def fire!(event)
156
+ raise Refused, "PR ##{pull.number.to_s.delete("#")} is #{key}; #{event} does not apply" unless may?(event)
157
+
158
+ aasm.fire!(event)
159
+ key
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # A label that says where a pull request stands in review (decision R3,
6
+ # which replaced M3's set).
7
+ #
8
+ # A pull request carries at most one of these at a time; a new verdict
9
+ # replaces the previous one. The one exception is {VULNERABILITY}, which
10
+ # sits beside {REJECTED}. This file is the only place under `lib/` that
11
+ # spells a label's name, so the CLI help, the docs table, the `gh` calls
12
+ # and the label sync cannot drift apart.
13
+ ReviewLabel = Data.define(:name, :colour, :meaning, :passing) do
14
+ alias_method :passing?, :passing
15
+
16
+ # @return [String] the name, so a label interpolates as itself
17
+ def to_s = name
18
+ end
19
+
20
+ class ReviewLabel
21
+ ALL = [
22
+ new(name: "ready-for-review",
23
+ colour: "c2e0c6",
24
+ meaning: "luke and rey are done; the PR waits for a free review slot",
25
+ passing: false),
26
+ new(name: "review-in-progress", colour: "FFBE20", meaning: "the reviewers have started", passing: false),
27
+ new(name: "review-rejected", colour: "F90000", meaning: "at least one ⛔️ or 💣", passing: false),
28
+ new(name: "review-vulnerability",
29
+ colour: "FF0050",
30
+ meaning: "at least one 💣; sits beside review-rejected",
31
+ passing: false),
32
+ new(name: "review-ok-not-perfect", colour: "E8E890", meaning: "no ⛔️ or 💣, but more 🤔 than review.max_hmm (5 by default)", passing: true),
33
+ new(name: "review-approved", colour: "109a10", meaning: "no ⛔️ or 💣, few 🤔", passing: true),
34
+ new(name: "ready-to-merge",
35
+ colour: "5319e7",
36
+ meaning: "chewie-verifier verified it and its E2E tests pass",
37
+ passing: true),
38
+ # palpatine's two verdicts, as the engine acted on them (P2).
39
+ new(name: "auto-merged",
40
+ colour: "953384",
41
+ meaning: "palpatine-risk-assessor judged it safe and the engine merged it",
42
+ passing: true),
43
+ new(name: "merge-manually",
44
+ colour: "0e8a16",
45
+ meaning: "palpatine-risk-assessor, or merge.by: human, left it for a person to merge",
46
+ passing: true)
47
+ ].freeze
48
+
49
+ # @param name [String]
50
+ # @return [ReviewLabel]
51
+ def self.named(name) = ALL.find { |l| l.name == name }
52
+
53
+ # The label the publisher puts on a pull request it has just opened.
54
+ INITIAL = named("ready-for-review")
55
+
56
+ # The verdict accepted even while CI is red.
57
+ REJECTED = named("review-rejected")
58
+
59
+ # The one label that sits beside another: always next to {REJECTED},
60
+ # since a 💣 is also a rejection. Accepted while CI is red for the same
61
+ # reason {REJECTED} is.
62
+ VULNERABILITY = named("review-vulnerability")
63
+
64
+ # The verdict for a review with no ⛔️ or 💣 but too many 🤔.
65
+ OK_NOT_PERFECT = named("review-ok-not-perfect")
66
+
67
+ # The verdict for everything else.
68
+ APPROVED = named("review-approved")
69
+
70
+ # chewie-verifier's label, and the one the coverage gate withholds.
71
+ READY_TO_MERGE = named("ready-to-merge")
72
+
73
+ # The engine merged it on palpatine's `auto`.
74
+ AUTO_MERGED = named("auto-merged")
75
+
76
+ # palpatine said `manual`, or `merge.by: human` overrode its `auto`.
77
+ MERGE_MANUALLY = named("merge-manually")
78
+
79
+ # Either verdict: the pull request is complete (P2).
80
+ COMPLETE = [AUTO_MERGED, MERGE_MANUALLY].freeze
81
+
82
+ # Not a verdict, and so not in {ALL}: {Vcs::LabelSync} never creates or
83
+ # recolours it. Its presence on a repository is the one sign that
84
+ # {Vcs::LabelMigration} has finished there. The colour of
85
+ # `ready-to-merge` could not be that sign: an earlier release recoloured
86
+ # it without moving a single pull request.
87
+ MIGRATED = new(name: "openlanes-r3-labels",
88
+ colour: "ededed",
89
+ meaning: "openlanes moved this repository's open PRs from M3's review labels to R3's",
90
+ passing: false)
91
+
92
+ # M3's names for verdicts R3 kept under another name: `review-passed`
93
+ # is R3's {OK_NOT_PERFECT}, and chewie's `ok-to-merge` is R3's
94
+ # {READY_TO_MERGE}.
95
+ RENAMED = { "review-passed" => OK_NOT_PERFECT, "ok-to-merge" => READY_TO_MERGE }.freeze
96
+
97
+ # M3's verdicts that R3 dropped. Nothing sets them any more, but pull
98
+ # requests labelled before R3 still carry them, so every new verdict
99
+ # takes them off: otherwise #62's `review-passed` would sit beside its
100
+ # next verdict, two at once. LabelSync never deletes them from the
101
+ # repository; that stays a person's call.
102
+ RETIRED = RENAMED.keys.freeze
103
+
104
+ class << self
105
+ # @return [Array<String>] every label name, in R3 order
106
+ def names = ALL.map(&:name)
107
+
108
+ # @return [Array<String>] every name a verdict replaces: R3's and {RETIRED}
109
+ def known = names + RETIRED
110
+
111
+ # @return [Array<ReviewLabel>] the verdicts refused while CI is red
112
+ def passing = ALL.select(&:passing?)
113
+
114
+ # The labels a new one takes off the pull request. Everything else in
115
+ # the set, {RETIRED} included, except that setting {VULNERABILITY} leaves {REJECTED} where
116
+ # it is, and setting {REJECTED} leaves {VULNERABILITY}: the two are
117
+ # set as a pair, one call each, in either order.
118
+ #
119
+ # @param label [ReviewLabel]
120
+ # @return [Array<String>]
121
+ def displaced_by(label)
122
+ kept = [label, *(PAIR if PAIR.include?(label))].map(&:name)
123
+ known - kept
124
+ end
125
+
126
+ # @param name [String, ReviewLabel]
127
+ # @return [ReviewLabel]
128
+ # @raise [OpenLanes::Error] naming the valid labels
129
+ def fetch(name)
130
+ named(name.to_s) ||
131
+ raise(Error, "unknown review label #{name.to_s.inspect}; one of: #{names.join(", ")}")
132
+ end
133
+ end
134
+
135
+ # The two labels that may sit together.
136
+ PAIR = [REJECTED, VULNERABILITY].freeze
137
+ end
138
+ end
139
+ end
@@ -20,6 +20,7 @@ module OpenLanes
20
20
  # - `openlanes create` opens an editor and writes no draft
21
21
  # how-to-verify:
22
22
  # - bundle exec rspec spec/openlanes/cli/create_spec.rb
23
+ # depends_on: ["034.00"] # plans that must be ✅ before this one starts
23
24
  # ---
24
25
  #
25
26
  # The body is the specification; this is how it should be worked. A value
@@ -49,6 +50,13 @@ module OpenLanes
49
50
  # @!attribute [r] how_to_verify
50
51
  # @return [Array<String>] the author's `how-to-verify`: the commands or
51
52
  # checks that show it is.
53
+ # @!attribute [r] depends_on
54
+ # @return [Array<String>, nil] canonical `NNN.MM` ordinals of the plans
55
+ # this one waits for. nil when the key is absent ("not checked yet"),
56
+ # which is not the same as `[]` ("checked, none").
57
+ # @!attribute [r] how_to_test
58
+ # @return [Array<String>] the items under {HOW_TO_TELL} in the body, or
59
+ # {#how_to_verify} when the spec has no such section (until plan 007).
52
60
  # @!attribute [r] problems
53
61
  # @return [Array<String>] what was ignored, and why
54
62
  Spec = Data.define(:lane,
@@ -59,9 +67,11 @@ module OpenLanes
59
67
  :requirements,
60
68
  :completed_when,
61
69
  :how_to_verify,
70
+ :depends_on,
71
+ :how_to_test,
62
72
  :problems) do
63
73
  def initialize(lane: :full, frontend: nil, depth: nil, phases: {}, suggestions: [], requirements: [],
64
- completed_when: [], how_to_verify: [], problems: [])
74
+ completed_when: [], how_to_verify: [], how_to_test: [], depends_on: nil, problems: [])
65
75
  super
66
76
  end
67
77
 
@@ -106,8 +116,32 @@ module OpenLanes
106
116
  COMPLETED_WHEN_KEY = "task-completed-when"
107
117
  HOW_TO_VERIFY_KEY = "how-to-verify"
108
118
 
119
+ # The plans this one waits for. Quoted canonical ordinals only: YAML
120
+ # reads a bare `[034]` as octal 28, a silent wrong answer.
121
+ DEPENDS_ON_KEY = "depends_on"
122
+
123
+ # Where `how-to-verify:` lives now (decision M10): a section of the
124
+ # spec's body, written by its author like every other, rather than a
125
+ # key in its frontmatter. `resync dirs` moves the key's items here.
126
+ HOW_TO_VERIFY_HEADING = "## How to tell it's working?"
127
+
128
+ # The same section, by the name the publisher refuses a pull request
129
+ # without: plan.md must carry its copy.
130
+ HOW_TO_TELL = HOW_TO_VERIFY_HEADING
131
+
132
+ # The heading on a line of its own. An author's editor may type a curly apostrophe (`it’s`) or leave the
133
+ # question mark off; either must still find the section, not drop the
134
+ # checks without a word.
135
+ HOW_TO_VERIFY_LINE = /^##[ \t]+how to tell it['‘’]s working\??[ \t]*$/i
136
+
109
137
  FILENAME = "spec.md"
110
138
 
139
+ # A list item under the heading; +marker+ ends at its content column.
140
+ LIST_ITEM = /\A(?<marker>\s*(?:[-*]|\d+\.)\s+)(?<text>.+)/
141
+
142
+ # The opening of a fenced code block, and its fence.
143
+ FENCE = /\A(\s*)(`{3,}|~{3,})/
144
+
111
145
  class << self
112
146
  # @param subject [#read]
113
147
  # @return [OpenLanes::Plans::Spec]
@@ -120,15 +154,68 @@ module OpenLanes
120
154
  File.file?(path) ? parse(File.read(path, encoding: "UTF-8")) : new
121
155
  end
122
156
 
157
+ # The spec's first paragraph of prose, which the dashboard shows when a
158
+ # person hovers a plan's title: headings, comments, lists, quotes and
159
+ # code are passed over, so a spec that opens with `## Problem
160
+ # statement` answers with the problem itself.
161
+ #
162
+ # @param text [String] a whole spec.md
163
+ # @param limit [Integer] characters kept, the rest cut at a word
164
+ # @return [String] "" when the spec has no prose paragraph
165
+ def summary(text, limit: 600)
166
+ _, body = Frontmatter.split(text)
167
+ paragraph = body.to_s.gsub(/<!--.*?-->/m, "").split(/\n\s*\n/).map(&:strip).find do |block|
168
+ !block.empty? && !block.match?(/\A(?:#|>|[-*+] |\d+\. |```|~~~|\||<)/)
169
+ end.to_s.gsub(/\s*\n\s*/, " ")
170
+ paragraph.length > limit ? "#{paragraph[0, limit].sub(/\s+\S*\z/, "")}…" : paragraph
171
+ rescue Psych::Exception
172
+ ""
173
+ end
174
+
123
175
  # @param text [String] a whole spec.md
124
176
  # @return [OpenLanes::Plans::Spec]
125
177
  def parse(text)
126
- meta, = Frontmatter.split(text)
127
- from(meta)
178
+ meta, body = Frontmatter.split(text)
179
+ spec = from(meta)
180
+ told = section_items(body.to_s)
181
+ checks = (spec.how_to_verify + told).uniq
182
+ # how_to_test prefers the body's own section; the frontmatter key
183
+ # stands in until plan 007 migrates old specs.
184
+ spec.with(how_to_verify: checks, how_to_test: told.empty? ? spec.how_to_verify : told)
128
185
  rescue Psych::Exception => e
129
186
  new(problems: ["spec.md frontmatter is not valid YAML (#{e.message.lines.first.strip})"])
130
187
  end
131
188
 
189
+ # Move a `how-to-verify:` frontmatter key into the body (M10): its
190
+ # items are appended to `## How to tell it's working?`, which is added
191
+ # at the end when the spec has none. The rest of the frontmatter is
192
+ # kept as written, comments and all, and a frontmatter left empty is
193
+ # dropped.
194
+ #
195
+ # @param text [String] a whole spec.md
196
+ # @return [String] +text+ itself when there is no key to move, or no
197
+ # way to remove it that leaves the other keys exactly as they were
198
+ def migrate_how_to_verify(text)
199
+ match = Frontmatter::PATTERN.match(text) or return text
200
+ meta, = Frontmatter.split(text)
201
+ return text unless meta.key?(HOW_TO_VERIFY_KEY)
202
+
203
+ front = without_key("#{match[1]}\n", HOW_TO_VERIFY_KEY, meta) or return text
204
+ items = notes(meta, HOW_TO_VERIFY_KEY, [])
205
+ head = front.strip.empty? ? "" : "---\n#{front}---\n"
206
+ "#{head}#{append_checks(match[2], items)}"
207
+ rescue Psych::Exception
208
+ text
209
+ end
210
+
211
+ # One check as a Markdown list item. A check written as a YAML block
212
+ # scalar spans lines; its continuation lines are indented under the
213
+ # item, so they stay part of it rather than becoming loose prose.
214
+ #
215
+ # @param item [String]
216
+ # @return [String] without a trailing newline
217
+ def list_item(item) = "- #{item.gsub("\n", "\n ")}"
218
+
132
219
  # @param meta [Hash]
133
220
  # @return [OpenLanes::Plans::Spec]
134
221
  def from(meta)
@@ -141,11 +228,140 @@ module OpenLanes
141
228
  requirements: notes(meta, REQUIREMENTS_KEY, problems),
142
229
  completed_when: notes(meta, COMPLETED_WHEN_KEY, problems),
143
230
  how_to_verify: notes(meta, HOW_TO_VERIFY_KEY, problems),
231
+ depends_on: depends_on(meta, problems),
232
+ how_to_test: notes(meta, HOW_TO_VERIFY_KEY, []),
144
233
  problems:)
145
234
  end
146
235
 
147
236
  private
148
237
 
238
+ # The YAML source of +yaml+ with +key+'s entry cut out, from the
239
+ # key's line to the end of its value as Psych located them. A regex
240
+ # over the text took a blank line between list items for the end of
241
+ # the entry, leaving the later items behind, and never matched a
242
+ # quoted key at all. The cut is kept only when what is left loads as
243
+ # the same mapping less that key; anything else (a flow mapping, a key
244
+ # sharing a line) is not cut.
245
+ #
246
+ # @param yaml [String]
247
+ # @param key [String]
248
+ # @param meta [Hash] +yaml+, loaded
249
+ # @return [String, nil]
250
+ def without_key(yaml, key, meta)
251
+ root = Psych.parse(yaml)&.root
252
+ return nil unless root.is_a?(Psych::Nodes::Mapping)
253
+
254
+ name, value = root.children.each_slice(2).find { |k, _| k.is_a?(Psych::Nodes::Scalar) && k.value == key }
255
+ return nil unless name
256
+
257
+ stop = value.end_column.zero? ? value.end_line : value.end_line + 1
258
+ lines = yaml.lines
259
+ rest = (lines[0...name.start_line] + lines[stop..].to_a).join
260
+ left, = Frontmatter.split("---\n#{rest}\n---\n")
261
+ rest if left == meta.except(key)
262
+ end
263
+
264
+ # The checks under `## How to tell it's working?`, in document order,
265
+ # up to the next heading of the same level. The owner wants how-to-test
266
+ # as specific and technical as it can be, and prose can be just as
267
+ # specific as a bullet (2026-10-14, on M10), so prose is a check too
268
+ # rather than the author's aside that the agents never see:
269
+ #
270
+ # - A list item is one check, with its indented continuation lines and
271
+ # any fenced block indented under it, blank lines in the fence
272
+ # included. Its lines lose the item's indent and keep the rest, so
273
+ # a command's own indentation survives.
274
+ # - A prose paragraph (consecutive non-blank, non-list lines) is one
275
+ # check, its lines as written.
276
+ # - A fenced block not indented under an item joins the prose
277
+ # paragraph just before it, or is a check of its own.
278
+ #
279
+ # Inside a fence nothing is a list item or a heading, so a
280
+ # `- not an item` line there stays in the code. {list_item} writes any
281
+ # of these back as one item, which this reads back unchanged.
282
+ #
283
+ # @param body [String]
284
+ # @return [Array<String>]
285
+ def section_items(body)
286
+ start = body =~ HOW_TO_VERIFY_LINE or return []
287
+ checks = []
288
+ state = { kind: nil, indent: 0, fence: nil }
289
+ body[start..].lines.drop(1).each do |line|
290
+ line = line.chomp
291
+ if state[:fence]
292
+ fence_line(checks, state, line)
293
+ elsif line.start_with?("## ")
294
+ break
295
+ else
296
+ section_line(checks, state, line)
297
+ end
298
+ end
299
+ checks.map(&:strip)
300
+ end
301
+
302
+ # A line inside a fenced block, the closing fence included.
303
+ def fence_line(checks, state, line)
304
+ checks[-1] = "#{checks.last}\n#{state[:kind] == :item ? dedent(line, state[:indent]) : line}"
305
+ fence = state[:fence]
306
+ state[:fence] = nil if line.strip.match?(/\A#{Regexp.escape(fence[0])}{#{fence.length},}\z/)
307
+ end
308
+
309
+ # A line outside any fence: it opens a fence, an item or a paragraph,
310
+ # or continues the one it follows.
311
+ def section_line(checks, state, line)
312
+ if (fence = line[FENCE, 2])
313
+ open_fence(checks, state, line, fence)
314
+ elsif (item = line.match(LIST_ITEM))
315
+ checks << item[:text]
316
+ state.merge!(kind: :item, indent: item.end(:marker))
317
+ elsif line.strip.empty?
318
+ state[:kind] = :after_prose if state[:kind] == :prose
319
+ elsif state[:kind] == :item && line.match?(/\A[ \t]/)
320
+ checks[-1] = "#{checks.last}\n#{dedent(line, state[:indent])}"
321
+ elsif state[:kind] == :prose
322
+ checks[-1] = "#{checks.last}\n#{line}"
323
+ else
324
+ checks << line
325
+ state[:kind] = :prose
326
+ end
327
+ end
328
+
329
+ # An indented fence under an open item belongs to it; any other joins
330
+ # the paragraph before it, or starts a check of its own.
331
+ def open_fence(checks, state, line, fence)
332
+ if state[:kind] == :item && line.match?(/\A[ \t]/)
333
+ checks[-1] = "#{checks.last}\n#{dedent(line, state[:indent])}"
334
+ elsif %i[prose after_prose].include?(state[:kind])
335
+ checks[-1] = "#{checks.last}\n#{line}"
336
+ state[:kind] = :prose
337
+ else
338
+ checks << line
339
+ state[:kind] = :prose
340
+ end
341
+ state[:fence] = fence
342
+ end
343
+
344
+ # +line+ less up to +indent+ leading blanks.
345
+ def dedent(line, indent) = line.sub(/\A[ \t]{0,#{indent}}/, "")
346
+
347
+ # @param body [String]
348
+ # @param items [Array<String>]
349
+ # @return [String]
350
+ def append_checks(body, items)
351
+ list = items.map { "#{list_item(it)}\n" }.join
352
+ body = "#{body}\n" unless body.end_with?("\n")
353
+ start = body =~ HOW_TO_VERIFY_LINE
354
+ return "#{body.rstrip}\n\n#{HOW_TO_VERIFY_HEADING}\n\n#{list}" unless start
355
+
356
+ heading_end = start + body[start..].index("\n").to_i + 1
357
+ rest = body[heading_end..].to_s
358
+ stop = rest =~ /^## / || rest.length
359
+ section = rest[0...stop].rstrip
360
+ joined = section.empty? ? "\n#{list}" : "#{section}\n#{list}"
361
+ tail = stop < rest.length ? "\n#{rest[stop..]}" : ""
362
+ "#{body[0...heading_end]}#{joined}#{tail}"
363
+ end
364
+
149
365
  # @return [Symbol, nil]
150
366
  def choice(meta, key, allowed, default, problems)
151
367
  return default unless meta.key?(key)
@@ -182,6 +398,26 @@ module OpenLanes
182
398
  items.map { |item| item.to_s.strip }.reject(&:empty?)
183
399
  end
184
400
 
401
+ # Each entry must be a string {Ordinal} can read; anything else is
402
+ # dropped and named, never coerced, since `034` has already become 28
403
+ # by the time it reaches here. `"034"` is normalised to `"034.00"`, so
404
+ # every caller compares one form.
405
+ #
406
+ # @return [Array<String>, nil] nil when the key is absent
407
+ def depends_on(meta, problems)
408
+ return nil unless meta.key?(DEPENDS_ON_KEY)
409
+
410
+ value = meta[DEPENDS_ON_KEY]
411
+ items = value.nil? ? [] : Array(value)
412
+ items.filter_map do |item|
413
+ ordinal = item.is_a?(String) && Ordinal.parse(item)
414
+ next ordinal.to_s if ordinal
415
+
416
+ problems << "#{DEPENDS_ON_KEY}: #{item.inspect} is not a quoted plan number like \"034.00\""
417
+ nil
418
+ end.uniq.sort
419
+ end
420
+
185
421
  # @return [Hash]
186
422
  def phases(value, problems)
187
423
  return {} if value.nil?