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
@@ -43,7 +43,9 @@ module OpenLanes
43
43
  building: :building_ui,
44
44
  building_ui: :ready_for_review,
45
45
  ready_for_review: :in_review,
46
- in_review: :approved,
46
+ in_review: :verifying,
47
+ verifying: :completed,
48
+ completed: :approved,
47
49
  approved: :deployed,
48
50
  rejected: :building_ui,
49
51
  rolled_back: :ready_for_review,
@@ -52,8 +54,8 @@ module OpenLanes
52
54
 
53
55
  # One step back along the spine, for the one move that goes backwards: a
54
56
  # person discarding an interrupted round (W9) sends the plan back to the
55
- # state the work started from. Derived from {SPINE}, walking it from ⚪️:
56
- # each state on that path steps back to the one before it, as far as 👀,
57
+ # state the work started from. Derived from {SPINE}, walking it from ⚪:
58
+ # each state on that path steps back to the one before it, as far as 🏁,
57
59
  # the last state an agent works in. States off the path (🔴 💩 ⏪ and
58
60
  # the birth state) have no single state behind them and step back
59
61
  # nowhere.
@@ -67,8 +69,8 @@ module OpenLanes
67
69
  # a block outranks progress, and `retroactive` is last because it is the
68
70
  # absence of documents rather than the presence of any.
69
71
  PREFERENCE = %i[
70
- discarded rolled_back shit deferred blocked product_blocked
71
- deployed approved rejected in_review ready_for_review building_ui building planned ready_for_planning
72
+ discarded rolled_back shit deferred blocked product_blocked bomb
73
+ deployed approved rejected completed verifying in_review ready_for_review building_ui building planned ready_for_planning
72
74
  researched new
73
75
  retroactive
74
76
  ].freeze
@@ -77,10 +79,10 @@ module OpenLanes
77
79
  # family the current name always wins, because re-deriving it would be a
78
80
  # guess dressed up as a correction.
79
81
  #
80
- # ⭕️ and 🅱️ both mean "a human must decide before this moves"; *which*
82
+ # ⭕ and 🛑 both mean "a human must decide before this moves"; *which*
81
83
  # human is recorded nowhere but the emoji.
82
84
  #
83
- # ⭐️ 🟡 🎨 look identical on disk: a `spec.md` and a `plan.md` with work
85
+ # ⭐ 🟡 🎨 look identical on disk: a `spec.md` and a `plan.md` with work
84
86
  # units in it. Whether nobody has started, the back end is under way, or
85
87
  # the interface is, is recorded only by the agent that renamed the folder
86
88
  # when it started. 🟢 👀 🔴 add an open pull request and are just as
@@ -91,16 +93,16 @@ module OpenLanes
91
93
  # arriving from outside lands on the weakest member its contents fit.
92
94
  # Contents that fit the family justify only its floor, never its ceiling.
93
95
  FAMILIES = [
94
- %i[blocked product_blocked],
95
- %i[planned building building_ui ready_for_review in_review rejected]
96
+ %i[blocked product_blocked bomb],
97
+ %i[planned building building_ui ready_for_review in_review verifying completed rejected]
96
98
  ].freeze
97
99
 
98
100
  # States the agent loop leaves alone: work that is finished (✅ 😎), work
99
- # that was dropped (❌), and work waiting on a human (⭕️ 🅱️ ☢️).
101
+ # that was dropped (❌), and work waiting on a human (⭕ 🛑 💣 💤).
100
102
  # Everything else is fair game for a specialist.
101
103
  #
102
- # ✅ Approved is here deliberately. `hansolo-reviewer` advancing a plan to
103
- # it is the end of the loop, not a step in it: nothing merges, and no agent
104
+ # ✅ Approved is here deliberately. A plan reaching it, from 🧪 once its
105
+ # pull request merged, is the end of the loop, not a step in it: no agent
104
106
  # handles `approved`. Merging is the one act in this lifecycle that changes
105
107
  # a branch everyone else builds on, and an autonomous loop that does it
106
108
  # unattended has no way to be wrong quietly.
@@ -108,7 +110,7 @@ module OpenLanes
108
110
  # Moving `approved` out of this list is therefore a decision about blast
109
111
  # radius rather than about topology. If it ever moves, something has to own
110
112
  # `approved -> deployed`, and today nothing does.
111
- SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
113
+ SETTLED = %i[approved deployed discarded blocked product_blocked bomb deferred].freeze
112
114
 
113
115
  # Every state an agent that may sign `Blocked` works in. A block the
114
116
  # machine refuses is reported as a failed attempt and parks nothing, so a
@@ -127,19 +129,19 @@ module OpenLanes
127
129
  OpenLanes::Plans::STATUSES.each { |status| state status.key }
128
130
 
129
131
  event :specify, guard: :justified? do
130
- transitions from: %i[retroactive blocked product_blocked deferred], to: :new
132
+ transitions from: %i[retroactive blocked product_blocked bomb deferred], to: :new
131
133
  end
132
134
 
133
135
  event :research, guard: :justified? do
134
- transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
136
+ transitions from: %i[new retroactive blocked product_blocked bomb deferred], to: :researched
135
137
  end
136
138
 
137
139
  # The specification is done and the planner has not started. Entered
138
140
  # by the harness when `yoda-writer` completes; `resync` reaches it on
139
- # a blank `plan.md`. Without it ⭐️ Planned required a file only the
140
- # agent handling ⭐️ Planned writes, and nothing could enter it.
141
+ # a blank `plan.md`. Without it ⭐ Planned required a file only the
142
+ # agent handling ⭐ Planned writes, and nothing could enter it.
141
143
  event :ready_to_plan, guard: :justified? do
142
- transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
144
+ transitions from: %i[researched new retroactive shit blocked product_blocked bomb deferred],
143
145
  to: :ready_for_planning
144
146
  end
145
147
 
@@ -149,12 +151,12 @@ module OpenLanes
149
151
  # planned. What research buys is not enforced here; it is enforced by
150
152
  # `yoda-writer` handling `researched` and nothing else.
151
153
  event :plan, guard: :justified? do
152
- transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked deferred],
154
+ transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked bomb deferred],
153
155
  to: :planned
154
156
  end
155
157
 
156
158
  event :build, guard: :justified? do
157
- transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked deferred],
159
+ transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked bomb deferred],
158
160
  to: :building
159
161
  end
160
162
 
@@ -178,12 +180,34 @@ module OpenLanes
178
180
  transitions from: %i[ready_for_review], to: :in_review
179
181
  end
180
182
 
183
+ # hansolo's approval no longer ends the loop (plan 014, M1): the app is
184
+ # run through the pull request's `## How to test` first, by
185
+ # chewie-verifier, before anything may merge.
186
+ event :verify, guard: :justified? do
187
+ transitions from: %i[in_review], to: :verifying
188
+ end
189
+
190
+ # Every pull request is `ready-to-merge` (P1): palpatine weighs each
191
+ # one, and the engine merges them one at a time.
192
+ event :complete, guard: :justified? do
193
+ transitions from: %i[verifying], to: :completed
194
+ end
195
+
196
+ # `in_review` stays a source: a person who merges on GitHub while
197
+ # hansolo is still reading has approved it all the same (M4). So do
198
+ # 🧪 and 🏁, for a merge before the engine or palpatine has ruled,
199
+ # and for the `merge-manually` pull request a person merges (P3).
181
200
  event :approve, guard: :justified? do
182
- transitions from: %i[in_review retroactive], to: :approved
201
+ transitions from: %i[in_review verifying completed retroactive], to: :approved
183
202
  end
184
203
 
204
+ # From 🧪 as well as 👀: a verification that fails sends the plan back
205
+ # to the builders exactly as a review that fails does. From 🏁 too:
206
+ # a pull request that no longer rebases cleanly onto `main` after
207
+ # its sibling merged goes back to luke and rey (P5), and a person may
208
+ # still reject one on GitHub while it waits for them.
185
209
  event :request_changes, guard: :justified? do
186
- transitions from: %i[in_review], to: :rejected
210
+ transitions from: %i[in_review verifying completed], to: :rejected
187
211
  end
188
212
 
189
213
  event :slop, guard: :justified? do
@@ -202,12 +226,21 @@ module OpenLanes
202
226
  transitions from: %i[deployed], to: :rolled_back
203
227
  end
204
228
 
229
+ # 💣 leaves back through the block it came from: once an answer is in,
230
+ # `blocked.md` says whether the question was technical or product, and
231
+ # the ordinary unblock path takes it from there.
205
232
  event :block, guard: :justified? do
206
- transitions from: BLOCKABLE, to: :blocked
233
+ transitions from: BLOCKABLE + %i[bomb], to: :blocked
207
234
  end
208
235
 
209
236
  event :block_on_product, guard: :justified? do
210
- transitions from: BLOCKABLE, to: :product_blocked
237
+ transitions from: BLOCKABLE + %i[bomb], to: :product_blocked
238
+ end
239
+
240
+ # Fired by the aging sweep alone, never by `resync`: a plan others
241
+ # depend on has sat blocked past the limit, and the run halts on it.
242
+ event :bomb, guard: :justified? do
243
+ transitions from: %i[blocked product_blocked], to: :bomb
211
244
  end
212
245
 
213
246
  event :defer, guard: :justified? do
@@ -372,21 +405,21 @@ module OpenLanes
372
405
  # The state a folder's contents justify — the answer to "well, what should
373
406
  # it be, then?". This is what `resync dirs` renames toward.
374
407
  #
375
- # Invariants are *minimum* requirements, not exact matches: a ⚪️ folder
376
- # that has since grown a `plan.md` still satisfies ⚪️, and is nonetheless
377
- # ⭐️ now. So the furthest-justified state wins, and the caller compares it
408
+ # Invariants are *minimum* requirements, not exact matches: a ⚪ folder
409
+ # that has since grown a `plan.md` still satisfies ⚪, and is nonetheless
410
+ # ⭐ now. So the furthest-justified state wins, and the caller compares it
378
411
  # against the current one to decide whether anything should move.
379
412
  #
380
413
  # The exception is a {FAMILIES family}, whose members contents cannot tell
381
414
  # apart. Two rules cover it:
382
415
  #
383
416
  # - **Already inside one** — the current name wins. Re-deriving it would be
384
- # a guess dressed up as a correction, and every ⭕️ would become 🅱️.
417
+ # a guess dressed up as a correction, and every ⭕ would become 🛑.
385
418
  # - **Arriving from outside** — the *weakest* member the contents fit
386
419
  # wins, not the furthest. A 📋 folder whose `plan.md` grew work units
387
- # lands on ⭐️, not 🟡, because nobody has started. A ✅ folder found
420
+ # lands on ⭐, not 🟡, because nobody has started. A ✅ folder found
388
421
  # with an open pull request is demonstrably back in the PR phase, and
389
- # ⭐️ refuses an open pull request, so it lands on 🟡 rather than
422
+ # ⭐ refuses an open pull request, so it lands on 🟡 rather than
390
423
  # claiming 🔴.
391
424
  #
392
425
  # @return [OpenLanes::Plans::Status, nil] nil when nothing fits at all
@@ -57,7 +57,7 @@ module OpenLanes
57
57
  RESEARCH_CHAPTER = /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i
58
58
 
59
59
  # What proves `plan.md` holds a plan rather than the blank file
60
- # `yoda-writer` leaves for `palpatine-planner`: any section heading. The
60
+ # `yoda-writer` leaves for `luthen-rael-planner`: any section heading. The
61
61
  # ledger notes are stripped first, so an agent's `Started` line does not
62
62
  # count as a plan.
63
63
  PLAN_HEADING = /^[ \t]{0,3}\#{1,3}[ \t]+\S/
@@ -143,7 +143,7 @@ module OpenLanes
143
143
  STATUSES = [
144
144
  Status.new(
145
145
  key: :new,
146
- emoji: "⚪️",
146
+ emoji: "⚪",
147
147
  label: "New",
148
148
  requires: %w[spec.md],
149
149
  note: "a specification exists; it has not been planned yet",
@@ -173,7 +173,7 @@ module OpenLanes
173
173
  ),
174
174
  Status.new(
175
175
  key: :planned,
176
- emoji: "⭐️",
176
+ emoji: "⭐",
177
177
  label: "Planned",
178
178
  requires: %w[spec.md plan.md],
179
179
  note: "specified and planned; nobody has started building",
@@ -193,7 +193,7 @@ module OpenLanes
193
193
  # written by opening a pull request, which happens once, when the last
194
194
  # implementer advances the plan to Ready for Review. Requiring the file to
195
195
  # *enter* Building would mean nothing could ever justify entering it, and
196
- # `palpatine-planner` finishing `plan.md` would loop forever between itself
196
+ # `luthen-rael-planner` finishing `plan.md` would loop forever between itself
197
197
  # and a state it can never actually reach. Ready for Review is where the
198
198
  # file becomes a real requirement, once something has had the chance to
199
199
  # write it.
@@ -240,6 +240,28 @@ module OpenLanes
240
240
  note: "a reviewer has picked it up and has not ruled yet",
241
241
  invariant: open_pull_request("In Review")
242
242
  ),
243
+ Status.new(
244
+ key: :verifying,
245
+ emoji: "🧪",
246
+ label: "Verifying",
247
+ requires: %w[spec.md plan.md pull-requests.md],
248
+ note: "the review approved it; chewie runs the app through How to test before anything merges",
249
+ invariant: open_pull_request("Verifying")
250
+ ),
251
+ # Completed is not done. `ready-to-merge` is a pull request's state
252
+ # (P2), and once every pull request of the plan carries it the plan is
253
+ # 🏁: palpatine rules on each, the engine merges the `auto` ones one at
254
+ # a time and rebases the rest onto `main`, and `merge-manually` ones
255
+ # wait for a person. At least one is still open, or the plan would be
256
+ # ✅ (P3).
257
+ Status.new(
258
+ key: :completed,
259
+ emoji: "🏁",
260
+ label: "Completed",
261
+ requires: %w[spec.md plan.md pull-requests.md],
262
+ note: "every pull request is `ready-to-merge`; palpatine weighs each, and they merge one at a time",
263
+ invariant: open_pull_request("Completed")
264
+ ),
243
265
  Status.new(
244
266
  key: :rejected,
245
267
  emoji: "🔴",
@@ -287,7 +309,7 @@ module OpenLanes
287
309
  ),
288
310
  Status.new(
289
311
  key: :blocked,
290
- emoji: "⭕️",
312
+ emoji: "⭕",
291
313
  label: "Technical Block",
292
314
  requires: %w[blocked.md],
293
315
  note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide",
@@ -295,15 +317,28 @@ module OpenLanes
295
317
  ),
296
318
  Status.new(
297
319
  key: :product_blocked,
298
- emoji: "🅱️",
320
+ emoji: "🛑",
299
321
  label: "Product Block",
300
322
  requires: %w[blocked.md],
301
323
  note: "cannot proceed; `blocked.md` names what a product manager must decide",
302
324
  invariant: open_block("Product Block")
303
325
  ),
326
+ # 💣 is entered only by the aging sweep's `bomb` event, never by what a
327
+ # folder holds: its contents are a ⭕'s, and a check that read the clock
328
+ # would rename folders as days pass with nobody firing anything. Its
329
+ # check is a block's, open question and all: a weaker one would let
330
+ # `resync` carry a drained ⭕ sideways into 💣 instead of out.
331
+ Status.new(
332
+ key: :bomb,
333
+ emoji: "💣",
334
+ label: "Bomb",
335
+ requires: %w[blocked.md],
336
+ note: "blocked too long while other plans depend on it; the run halts until a human answers",
337
+ invariant: open_block("Bomb")
338
+ ),
304
339
  Status.new(
305
340
  key: :deferred,
306
- emoji: "☢️",
341
+ emoji: "💤",
307
342
  label: "Deferred",
308
343
  requires: %w[delayed.md],
309
344
  note: "could proceed and chose not to yet; `delayed.md` must name the trigger",
@@ -314,7 +349,7 @@ module OpenLanes
314
349
  ),
315
350
  Status.new(
316
351
  key: :retroactive,
317
- emoji: "🕰️",
352
+ emoji: "⏪",
318
353
  label: "Retroactive",
319
354
  requires: [],
320
355
  note: "the feature is live, but has neither a specification nor a plan",
@@ -334,12 +369,26 @@ module OpenLanes
334
369
  )
335
370
  ].freeze
336
371
 
337
- # Emoji => status, folded so ⚪ and ⚪️ both resolve.
338
- STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }.freeze
339
-
340
372
  # Symbol => status.
341
373
  STATUS_BY_KEY = STATUSES.to_h { |s| [s.key, s] }.freeze
342
374
 
375
+ # The emoji each state wore before every one became a single code point
376
+ # (decision N2). Folders, ledgers and snapshots written before then still
377
+ # carry them, and a plan named with one must keep its state until
378
+ # `resync dirs` renames it. Read only, never written. `⚪️ ⭐️ ⭕️` fold onto
379
+ # their successors anyway; the rest are different characters altogether,
380
+ # and `🅱` appears both with and without its variation selector.
381
+ LEGACY_EMOJI = {
382
+ "⚪️" => :new, "⭐️" => :planned, "⭕️" => :blocked, "🅱️" => :product_blocked, "🅱" => :product_blocked,
383
+ "☢️" => :deferred, "🕰️" => :retroactive
384
+ }.freeze
385
+
386
+ # Emoji => status, folded so ⚪ and ⚪️ both resolve, and so does every
387
+ # {LEGACY_EMOJI} spelling.
388
+ STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }
389
+ .merge(LEGACY_EMOJI.to_h { |e, key| [fold_emoji(e), STATUS_BY_KEY.fetch(key)] }) { |_, now, _| now }
390
+ .freeze
391
+
343
392
  # @param emoji [String, nil]
344
393
  # @return [OpenLanes::Plans::Status, nil]
345
394
  def self.status_for_emoji(emoji) = STATUS_BY_EMOJI[fold_emoji(emoji)]
@@ -122,10 +122,14 @@ module OpenLanes
122
122
  raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
123
123
  end
124
124
 
125
+ from = @feature.status
125
126
  @reads = {}
126
127
  @pull_requests = nil
127
128
  @feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
128
129
  PlanState.for(target).state!(status.key)
130
+ # Every transition passes through here, whoever fires it, so this is
131
+ # the one line in the log that says a plan changed state.
132
+ UI.log("moved: #{from} → #{status}", plan: @feature.ordinal.to_s)
129
133
  @feature
130
134
  end
131
135
  end
@@ -3,20 +3,71 @@
3
3
  module OpenLanes
4
4
  module Plans
5
5
  # A project's `.plans` directory: the folders in it, decoded and ordered.
6
+ #
7
+ # Finished, deferred and discarded plans live one level down, in the
8
+ # subfolders {INACTIVE_DIRS} names (decision N3), so the top level holds
9
+ # only the plans somebody may still work on. A tree reads the top level
10
+ # by default; `all: true` reads the subfolders too. Plan numbers span
11
+ # both, since a number is never reused.
6
12
  class Tree
13
+ # Where a plan in an inactive state lives, keyed by status key. The one
14
+ # table `resync dirs` moves folders by and every reader skips by.
15
+ INACTIVE_DIRS = {
16
+ approved: "archive",
17
+ deployed: "archive",
18
+ deferred: "deferred",
19
+ discarded: "abandoned"
20
+ }.freeze
21
+
22
+ # The subfolders themselves, in the order `list-plans --all` shows them.
23
+ SUBFOLDERS = INACTIVE_DIRS.values.uniq.freeze
24
+
25
+ # @param status [OpenLanes::Plans::Status]
26
+ # @return [String, nil] the subfolder a plan in this state belongs in, nil for the top level
27
+ def self.subfolder_for(status) = INACTIVE_DIRS[status.key]
28
+
29
+ # The plan folder a repository-relative path is inside, looking through
30
+ # an inactive subfolder: `.plans/archive/003.00━━✅━━❯[…]━━x/spec.md`
31
+ # names the plan `003.00━━✅━━❯[…]━━x`, not a plan called `archive`.
32
+ #
33
+ # @param path [String] e.g. a path from a pull request's diff
34
+ # @return [String, nil] the folder's name, nil when the path is in no plan
35
+ def self.plan_folder(path)
36
+ parts = path.to_s.split("/")
37
+ index = parts.index(OpenLanes::PLANS_DIR) or return nil
38
+ parts = parts.drop(index + 1)
39
+ parts = parts.drop(1) if SUBFOLDERS.include?(parts.first)
40
+ parts.length >= 2 ? parts.first : nil # a file directly under .plans belongs to no plan
41
+ end
42
+
7
43
  # @param dir [String] the `.plans` directory
8
- def initialize(dir:)
44
+ # @param all [Boolean] also read the inactive subfolders
45
+ def initialize(dir:, all: false)
9
46
  @dir = File.expand_path(dir)
47
+ @all = all
10
48
  end
11
49
 
12
50
  # @return [String] absolute path to the `.plans` directory
13
51
  attr_reader :dir
14
52
 
53
+ # @return [Boolean] whether the inactive subfolders are read too
54
+ def all? = @all
55
+
56
+ # The same `.plans`, inactive subfolders included.
57
+ #
58
+ # @return [OpenLanes::Plans::Tree]
59
+ def everything = all? ? self : (@everything ||= Tree.new(dir:, all: true))
60
+
15
61
  # @return [Boolean]
16
62
  def exist? = File.directory?(dir)
17
63
 
18
- # @return [Array<OpenLanes::Plans::Feature>] ordered by number
19
- def features = @features ||= directories.filter_map { |p| Feature.parse(p) }.sort
64
+ # Open plans first, then each subfolder in {SUBFOLDERS} order; by
65
+ # number within each group.
66
+ #
67
+ # @return [Array<OpenLanes::Plans::Feature>]
68
+ def features
69
+ @features ||= directories.filter_map { |p| Feature.parse(p) }.sort_by { |f| [group(f), f] }
70
+ end
20
71
 
21
72
  # @return [Array<OpenLanes::Plans::Subject>] ordered by number
22
73
  def subjects = @subjects ||= features.map { |f| Subject.new(f) }
@@ -24,6 +75,12 @@ module OpenLanes
24
75
  # @return [Array<OpenLanes::Plans::Ordinal>]
25
76
  def ordinals = features.map(&:ordinal)
26
77
 
78
+ # Every number in use, the inactive subfolders included: what a new
79
+ # plan's number must stay clear of.
80
+ #
81
+ # @return [Array<OpenLanes::Plans::Ordinal>]
82
+ def taken = everything.ordinals
83
+
27
84
  # Directories that carry no plan number. Reported, never processed.
28
85
  #
29
86
  # @return [Array<String>]
@@ -36,11 +93,13 @@ module OpenLanes
36
93
  # make every one of those joins ambiguous, and nothing downstream can tell
37
94
  # which folder a `[002.00]` pull request belongs to.
38
95
  #
96
+ # An archived plan still holds its number, so the subfolders count.
97
+ #
39
98
  # @return [Hash{OpenLanes::Plans::Ordinal => Array<String>}] number => dirnames
40
99
  def duplicates
41
- features.group_by(&:ordinal)
42
- .select { |_, group| group.size > 1 }
43
- .transform_values { |group| group.map(&:dirname) }
100
+ everything.features.group_by(&:ordinal)
101
+ .select { |_, group| group.size > 1 }
102
+ .transform_values { |group| group.map(&:dirname) }
44
103
  end
45
104
 
46
105
  # @param ordinal [OpenLanes::Plans::Ordinal, String]
@@ -53,23 +112,55 @@ module OpenLanes
53
112
  # @return [Boolean] whether a plan with this number exists on disk
54
113
  def include?(ordinal) = !find(ordinal).nil?
55
114
 
115
+ # Built from {#everything}, not from the open plans alone. A shipped
116
+ # plan is moved into `archive/`, and read from the top level only, the
117
+ # prerequisite a dependent waits for would vanish the moment it was
118
+ # done: unknown, never done, and its dependent held forever.
119
+ #
120
+ # @return [OpenLanes::Plans::Dependencies] who waits for whom, read from
121
+ # every `depends_on:` in this tree, the inactive subfolders included
122
+ def dependencies = @dependencies ||= Dependencies.new(everything)
123
+
124
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
125
+ # @return [Array<String>] every plan that waits for this one; derived, never stored
126
+ def blocks_of(ordinal) = dependencies.blocks_of(ordinal)
127
+
56
128
  # Forget everything read from disk, so a caller that has just renamed
57
129
  # folders sees the new state.
58
130
  #
59
131
  # @return [self]
60
132
  def reload
61
- @features = @subjects = @skipped = nil
133
+ @features = @subjects = @skipped = @dependencies = nil
134
+ @everything&.reload
62
135
  self
63
136
  end
64
137
 
65
138
  private
66
139
 
67
- # @return [Array<String>] absolute paths of every subdirectory
140
+ # @param feature [OpenLanes::Plans::Feature]
141
+ # @return [Integer] 0 for the top level, else one past the subfolder's index
142
+ def group(feature)
143
+ parent = File.basename(File.dirname(feature.path))
144
+ File.dirname(feature.path) == dir ? 0 : SUBFOLDERS.index(parent).to_i + 1
145
+ end
146
+
147
+ # @return [Array<String>] absolute paths of every plan-shaped subdirectory
68
148
  def directories
69
149
  return [] unless exist?
70
150
 
71
- Dir.children(dir)
72
- .map { |c| File.join(dir, c) }
151
+ top = children(dir).reject { |p| SUBFOLDERS.include?(File.basename(p)) }
152
+ return top unless all?
153
+
154
+ top + SUBFOLDERS.flat_map { |name| children(File.join(dir, name)) }
155
+ end
156
+
157
+ # @param parent [String]
158
+ # @return [Array<String>]
159
+ def children(parent)
160
+ return [] unless File.directory?(parent)
161
+
162
+ Dir.children(parent)
163
+ .map { |c| File.join(parent, c) }
73
164
  .select { |p| File.directory?(p) }
74
165
  .reject { |p| File.basename(p).start_with?(".") }
75
166
  .sort
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # The review label the reviewers' comments add up to (decisions R2, R3).
6
+ #
7
+ # Every review comment opens with an emoji that declares how much it
8
+ # matters. Counted across all the reviewers together, in order: any ⛔️ or
9
+ # 💣 rejects (and a 💣 adds {ReviewLabel::VULNERABILITY} beside the
10
+ # rejection); otherwise more than `max_hmm` 🤔 is
11
+ # {ReviewLabel::OK_NOT_PERFECT}; otherwise {ReviewLabel::APPROVED}. A clean
12
+ # small pull request is approved even if it earned no ✅.
13
+ #
14
+ # Pure: it reads strings and answers labels. The threshold comes in as an
15
+ # argument, so where it is configured ({Config#max_hmm}) is not this
16
+ # class's business, and nothing here reads GitHub.
17
+ #
18
+ # @!attribute [r] labels
19
+ # @return [Array<ReviewLabel>] one verdict, or {ReviewLabel::REJECTED}
20
+ # and {ReviewLabel::VULNERABILITY} together
21
+ # @!attribute [r] tally
22
+ # @return [Hash{Symbol => Integer}] counted comments, by {MARKS} key
23
+ # @!attribute [r] ignored
24
+ # @return [Array<Comment>] 💣 or 🔓 comments from a reviewer other than
25
+ # {SECURITY_REVIEWER}, left out of the tally
26
+ Verdict = Data.define(:labels, :tally, :ignored) do
27
+ # @return [Boolean] whether the labels let the pull request go on
28
+ def passing? = labels.all?(&:passing?)
29
+
30
+ # @return [Array<String>] the label names, as `openlanes review` takes them
31
+ def names = labels.map(&:name)
32
+ end
33
+
34
+ class Verdict
35
+ # One review comment, and the agent that wrote it. The author has to be
36
+ # carried alongside the body: every reviewer comments from the same
37
+ # GitHub account (R2), so GitHub's author says nothing about who wrote it.
38
+ Comment = Data.define(:agent, :body)
39
+
40
+ # The opening emoji, by what they mean. ⛔ is matched with and without
41
+ # its variation selector (U+FE0F), since editors and models drop it as
42
+ # often as they keep it; the others tolerate a stray one too.
43
+ MARKS = {
44
+ great: "\u2705", # ✅ Great!
45
+ hmm: "\u{1F914}", # 🤔 Hmmm...
46
+ must_fix: "\u26D4", # ⛔️ Must Fix, with or without U+FE0F after it
47
+ flaw: "\u{1F4A3}", # 💣 Security Flaw
48
+ concern: "\u{1F513}" # 🔓 Security Concern
49
+ }.freeze
50
+
51
+ # The words each emoji is written with, for a person reading the tally.
52
+ WORDS = {
53
+ great: "Great!", hmm: "Hmmm...", must_fix: "Must Fix", flaw: "Security Flaw", concern: "Security Concern"
54
+ }.freeze
55
+
56
+ # Marks only the security reviewer may use. From anyone else a 💣 would
57
+ # reject a pull request on a reviewer's say-so outside its remit, so it
58
+ # is dropped and reported rather than counted.
59
+ SECURITY_MARKS = %i[flaw concern].freeze
60
+
61
+ # The one agent whose 💣 and 🔓 count.
62
+ SECURITY_REVIEWER = "vader-security-reviewer"
63
+
64
+ # How many 🤔 a review may carry and still be approved.
65
+ DEFAULT_MAX_HMM = 5
66
+
67
+ # Markdown a comment may open with before its emoji: a heading, a
68
+ # quote, bold. Anything else first means the comment declares nothing.
69
+ LEAD = /\A[\s#>*_]*/
70
+
71
+ class << self
72
+ # @param comments [Array<Comment, Hash>] each with `agent:` and `body:`
73
+ # @param max_hmm [Integer] the most 🤔 an approval may carry
74
+ # @return [Verdict]
75
+ def call(comments, max_hmm: DEFAULT_MAX_HMM)
76
+ tally = MARKS.keys.to_h { [it, 0] }
77
+ ignored = []
78
+ comments.each do |raw|
79
+ comment = coerce(raw)
80
+ mark = mark_of(comment.body)
81
+ next if mark.nil?
82
+
83
+ if SECURITY_MARKS.include?(mark) && comment.agent.to_s != SECURITY_REVIEWER
84
+ ignored << comment
85
+ next
86
+ end
87
+ tally[mark] += 1
88
+ end
89
+ new(labels: labels_for(tally, max_hmm), tally:, ignored:)
90
+ end
91
+
92
+ # The mark a comment opens with.
93
+ #
94
+ # @param body [String]
95
+ # @return [Symbol, nil] a {MARKS} key; nil for a comment that declares
96
+ # nothing, such as the "Reviewed by ..." header
97
+ def mark_of(body)
98
+ text = body.to_s.sub(LEAD, "")
99
+ MARKS.find { |_, emoji| text.start_with?(emoji) }&.first
100
+ end
101
+
102
+ private
103
+
104
+ # @return [Comment]
105
+ def coerce(raw)
106
+ return raw if raw.is_a?(Comment)
107
+
108
+ Comment.new(agent: raw[:agent] || raw["agent"], body: raw[:body] || raw["body"])
109
+ end
110
+
111
+ # @return [Array<ReviewLabel>]
112
+ def labels_for(tally, max_hmm)
113
+ if tally[:must_fix].positive? || tally[:flaw].positive?
114
+ [ReviewLabel::REJECTED, *(ReviewLabel::VULNERABILITY if tally[:flaw].positive?)]
115
+ elsif tally[:hmm] > max_hmm
116
+ [ReviewLabel::OK_NOT_PERFECT]
117
+ else
118
+ [ReviewLabel::APPROVED]
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -35,5 +35,24 @@ module OpenLanes
35
35
  # @param project [String, nil]
36
36
  # @return [OpenLanes::Plans::Index]
37
37
  def index(tree:, project: nil) = Index.new(tree:, project:)
38
+
39
+ # The review label the reviewers' comments add up to (R3). A 💣 or 🔓
40
+ # from anyone but the security reviewer is left out, and said in the log,
41
+ # so a reviewer straying outside its remit is visible rather than silent.
42
+ #
43
+ # @param comments [Array<OpenLanes::Plans::Verdict::Comment, Hash>] each
44
+ # with `agent:` and `body:`
45
+ # @param max_hmm [Integer] defaults to `review.max_hmm`, or
46
+ # `$OPENLANES_REVIEW_MAX_HMM` over it
47
+ # @return [OpenLanes::Plans::Verdict]
48
+ def verdict(comments, max_hmm: Config.current.max_hmm)
49
+ Verdict.call(comments, max_hmm:).tap do |verdict|
50
+ verdict.ignored.each do |comment|
51
+ UI.log("ignored #{Verdict::MARKS[Verdict.mark_of(comment.body)]} from #{comment.agent || "an unnamed reviewer"}: " \
52
+ "only #{Verdict::SECURITY_REVIEWER} may use it",
53
+ level: :warn)
54
+ end
55
+ end
56
+ end
38
57
  end
39
58
  end