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,12 @@
1
+ # Plan
2
+
3
+ Six units, U6 is the integration check.
4
+
5
+ ## Order
6
+
7
+ U1, then U2 to U5 in parallel, then U6.
8
+
9
+ > [!NOTE]
10
+ >
11
+ > [2026-09-04 01:10:00 PM PDT] [ agent: luthen-rael-planner status: Started, round 1 ]
12
+ > [2026-09-04 01:14:30 PM PDT] [ agent: luthen-rael-planner status: Completed, round 1 ]
@@ -8,5 +8,5 @@ One unit; it is also the integration check, since there is no front end.
8
8
 
9
9
  > [!NOTE]
10
10
  >
11
- > [2026-09-04 01:10:00 PM PDT] [ agent: palpatine-planner status: Started, round 1 ]
12
- > [2026-09-04 01:14:30 PM PDT] [ agent: palpatine-planner status: Completed, round 1 ]
11
+ > [2026-09-04 01:10:00 PM PDT] [ agent: luthen-rael-planner status: Started, round 1 ]
12
+ > [2026-09-04 01:14:30 PM PDT] [ agent: luthen-rael-planner status: Completed, round 1 ]
@@ -1,5 +1,5 @@
1
1
  id: plan-fast
2
- agent: palpatine-planner
2
+ agent: luthen-rael-planner
3
3
  depth: fast
4
4
  description: >
5
5
  A one-change spec. At fast depth the plan is one or two units with every
@@ -0,0 +1,20 @@
1
+ # Plan
2
+
3
+ 1. U1 Remove the last reader of `legacy_email`, then drop the column.
4
+
5
+ ## Merge risk, PR #21
6
+
7
+ **Verdict:** auto
8
+
9
+ **Triggers:** none.
10
+
11
+ **Signals:** none.
12
+
13
+ **Unplanned files:** none.
14
+
15
+ **Rollback:** `git revert` the merge.
16
+
17
+ > [!NOTE]
18
+ >
19
+ > [2026-10-20 10:00:00 AM PDT] [ agent: palpatine-risk-assessor status: Started, round 1 ]
20
+ > [2026-10-20 10:01:00 AM PDT] [ agent: palpatine-risk-assessor status: Completed, round 1 (auto) ]
@@ -0,0 +1,5 @@
1
+ # Pull Requests
2
+
3
+ | Pull Request Number | Pull Request Name | Status |
4
+ | ------------------: | :---------------- | -----: |
5
+ | 21 | [[001.00] Drop Legacy Email](https://github.com/example/accounts/pull/21) | Open 🟡 |
@@ -0,0 +1,5 @@
1
+ # Drop Legacy Email
2
+
3
+ ## Goal
4
+
5
+ `users.legacy_email` is gone; nothing reads it any more.
@@ -0,0 +1,5 @@
1
+ class DropLegacyEmail < ActiveRecord::Migration[8.0]
2
+ def change
3
+ remove_column :users, :legacy_email, :string
4
+ end
5
+ end
@@ -0,0 +1 @@
1
+ {"seconds":60,"tokens":15000,"state":"completed"}
@@ -0,0 +1,25 @@
1
+ # Plan
2
+
3
+ 1. U1 Remove the last reader of `legacy_email`, then drop the column.
4
+
5
+ ## Merge risk, PR #21
6
+
7
+ **Verdict:** manual
8
+
9
+ **Triggers:** schema and data: `db/migrate/20261020120000_drop_legacy_email.rb:3` drops `users.legacy_email`; the old code still running during the deploy overlap may read it.
10
+
11
+ **Signals:** none.
12
+
13
+ **Unplanned files:** none.
14
+
15
+ **Rollback:** a revert does not restore the dropped data; it needs a restore of the column from the last backup. Without a backup the drop is permanent.
16
+
17
+ **For the human:**
18
+
19
+ - Confirm a backup of `users.legacy_email` exists before merging.
20
+ - Confirm no deployed code reads the column.
21
+
22
+ > [!NOTE]
23
+ >
24
+ > [2026-10-20 10:00:00 AM PDT] [ agent: palpatine-risk-assessor status: Started, round 1 ]
25
+ > [2026-10-20 10:03:00 AM PDT] [ agent: palpatine-risk-assessor status: Completed, round 1 (manual) ]
@@ -0,0 +1,5 @@
1
+ # Pull Requests
2
+
3
+ | Pull Request Number | Pull Request Name | Status |
4
+ | ------------------: | :---------------- | -----: |
5
+ | 21 | [[001.00] Drop Legacy Email](https://github.com/example/accounts/pull/21) | Open 🟡 |
@@ -0,0 +1,5 @@
1
+ # Drop Legacy Email
2
+
3
+ ## Goal
4
+
5
+ `users.legacy_email` is gone; nothing reads it any more.
@@ -0,0 +1,5 @@
1
+ class DropLegacyEmail < ActiveRecord::Migration[8.0]
2
+ def change
3
+ remove_column :users, :legacy_email, :string
4
+ end
5
+ end
@@ -0,0 +1 @@
1
+ {"seconds":180,"tokens":40000,"state":"completed"}
@@ -0,0 +1,47 @@
1
+ id: risk-medium
2
+ agent: palpatine-risk-assessor
3
+ depth: medium
4
+ description: >
5
+ One ready-to-merge pull request whose branch adds a migration that drops
6
+ `users.legacy_email`, a hard trigger (schema and data). palpatine must
7
+ append a "Merge risk, PR #21" section naming the migration, write a
8
+ rollback, and sign manual. Signing auto means the trigger was missed.
9
+ palpatine judges; it changes no code.
10
+ fixture:
11
+ state: completed
12
+ slug: drop-legacy-email
13
+ files:
14
+ spec.md: |
15
+ # Drop Legacy Email
16
+
17
+ ## Goal
18
+
19
+ `users.legacy_email` is gone; nothing reads it any more.
20
+ plan.md: |
21
+ # Plan
22
+
23
+ 1. U1 Remove the last reader of `legacy_email`, then drop the column.
24
+ pull-requests.md: |
25
+ # Pull Requests
26
+
27
+ | Pull Request Number | Pull Request Name | Status |
28
+ | ------------------: | :---------------- | -----: |
29
+ | 21 | [[001.00] Drop Legacy Email](https://github.com/example/accounts/pull/21) | Open 🟡 |
30
+ repo:
31
+ db/migrate/20261020120000_drop_legacy_email.rb: |
32
+ class DropLegacyEmail < ActiveRecord::Migration[8.0]
33
+ def change
34
+ remove_column :users, :legacy_email, :string
35
+ end
36
+ end
37
+ expect:
38
+ signed: manual
39
+ sections:
40
+ plan.md: ["Merge risk, PR #21"]
41
+ contains:
42
+ plan.md: ["/\\*\\*Verdict:\\*\\*\\s*manual/", "drop_legacy_email", "/\\*\\*Rollback:\\*\\*/"]
43
+ untouched: ["**/*"]
44
+ depth:
45
+ medium:
46
+ max_seconds: 600
47
+ max_tokens: 150000
@@ -60,4 +60,4 @@ When this ships, a cart cannot hold a quantity checkout would refuse, the limit
60
60
  >
61
61
  > [2026-09-04 12:02:11 PM PDT] [ agent: yoda-writer status: Started, round 1 ]
62
62
  > [2026-09-04 12:09:40 PM PDT] [ agent: yoda-writer status: Completed, round 1 ]
63
- > [2026-09-04 12:09:41 PM PDT] [ next: palpatine-planner ]
63
+ > [2026-09-04 12:09:41 PM PDT] [ next: luthen-rael-planner ]
@@ -47,30 +47,44 @@ module OpenLanes
47
47
  # @return [OpenLanes::Adapters::Model, nil]
48
48
  def model_entry(name) = models.find { |m| m.id == name.to_s }
49
49
 
50
+ # What this CLI can do. Abstract on purpose: every adapter declares all
51
+ # of it, and the Executor and Dispatcher branch on these, never on
52
+ # {#name}.
53
+ #
54
+ # @return [OpenLanes::Adapters::Capabilities]
55
+ def capabilities = raise NotImplementedError, "#{self.class} must declare its capabilities"
56
+
57
+ # The flags that start this CLI without the operator's personal
58
+ # plugins, skills, hooks and MCP servers; empty where {#argv} builds
59
+ # them itself.
60
+ #
61
+ # @return [Array<String>]
62
+ def lean_flags = []
63
+
50
64
  # What, sent into a resumed session, makes this CLI compact it (W28);
51
65
  # nil for a CLI that cannot resume one, whose agents are therefore never
52
66
  # compacted mid-round.
53
67
  #
54
68
  # @return [String, nil]
55
- def compact_prompt = nil
69
+ def compact_prompt = capabilities.compact_prompt
56
70
 
57
71
  # @return [Boolean] whether an agent on this CLI can be compacted and
58
72
  # carried on in the same session
59
- def compacts? = !compact_prompt.nil?
73
+ def compacts? = capabilities.compacts
60
74
 
61
75
  # Whether {#argv} honours {Invocation#resume}. The copilot (W17) keeps
62
76
  # one conversation across messages only on a CLI that says yes; on any
63
77
  # other each message starts afresh, and says so.
64
78
  #
65
79
  # @return [Boolean]
66
- def resumes? = false
80
+ def resumes? = capabilities.resumes
67
81
 
68
82
  # Whether the CLI itself refuses the denied tools, rather than only being
69
83
  # asked not to use them. The prompt says which, because telling an agent
70
84
  # a guard exists when it does not is how it stops being careful.
71
85
  #
72
86
  # @return [Boolean]
73
- def enforces_tool_denial? = false
87
+ def enforces_tool_denial? = capabilities.enforces_tool_denial
74
88
  end
75
89
  end
76
90
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Adapters
5
+ # What one CLI can do, declared in one place. Every adapter states every
6
+ # field: a capability inherited as a silent default is how codex was once
7
+ # assumed to refuse denied tools it never refused.
8
+ #
9
+ # @!attribute [r] compacts
10
+ # @return [Boolean] an agent on this CLI can be compacted and carried on
11
+ # in the same session
12
+ # @!attribute [r] resumes
13
+ # @return [Boolean] {Base#argv} honours {Invocation#resume}
14
+ # @!attribute [r] enforces_tool_denial
15
+ # @return [Boolean] the CLI itself refuses the denied tools
16
+ # @!attribute [r] compact_prompt
17
+ # @return [String, nil] what, sent into a resumed session, compacts it;
18
+ # nil exactly when {#compacts} is false
19
+ Capabilities = Data.define(:compacts, :resumes, :enforces_tool_denial, :compact_prompt) do
20
+ def initialize(compacts:, resumes:, enforces_tool_denial:, compact_prompt:)
21
+ if compacts == compact_prompt.nil?
22
+ raise ArgumentError, "compact_prompt must be given exactly when compacts is true"
23
+ end
24
+
25
+ super
26
+ end
27
+ end
28
+ end
29
+ end
@@ -105,18 +105,21 @@ module OpenLanes
105
105
  rank && rank > LADDER.index(CEILING) ? CEILING : model.to_s
106
106
  end
107
107
 
108
- # @return [Boolean]
109
- def enforces_tool_denial? = true
108
+ # `--disallowedTools` is refused by the CLI itself, and `--resume
109
+ # <session id>` carries a conversation on. `claude -p --resume <id>
110
+ # "/compact"` compacts the session and prints a `compact_boundary`
111
+ # event; the agent cannot run it on itself, because `/compact` is the
112
+ # caller's command, not a tool.
113
+ CAPABILITIES = Capabilities.new(compacts: true,
114
+ resumes: true,
115
+ enforces_tool_denial: true,
116
+ compact_prompt: "/compact")
117
+
118
+ # @return [OpenLanes::Adapters::Capabilities]
119
+ def capabilities = CAPABILITIES
110
120
 
111
- # @return [Boolean] `--resume <session id>` carries a conversation on
112
- def resumes? = true
113
-
114
- # `claude -p --resume <id> "/compact"` compacts the session and prints a
115
- # `compact_boundary` event; the agent cannot run it on itself, because
116
- # `/compact` is the caller's command, not a tool.
117
- #
118
- # @return [String]
119
- def compact_prompt = "/compact"
121
+ # @return [Array<String>]
122
+ def lean_flags = LEAN
120
123
 
121
124
  # @return [Hash{String => String}]
122
125
  def environment = { AUTOCOMPACT_VARIABLE => AUTOCOMPACT.to_s }
@@ -13,6 +13,15 @@ module OpenLanes
13
13
  # Codex says `xhigh` for the top of its scale and has no `max`.
14
14
  EFFORT = { "low" => "low", "medium" => "medium", "high" => "high", "xhigh" => "xhigh", "max" => "xhigh" }.freeze
15
15
 
16
+ # No session to resume, so nothing to compact, and no denylist of its own.
17
+ CAPABILITIES = Capabilities.new(compacts: false,
18
+ resumes: false,
19
+ enforces_tool_denial: false,
20
+ compact_prompt: nil)
21
+
22
+ # @return [OpenLanes::Adapters::Capabilities]
23
+ def capabilities = CAPABILITIES
24
+
16
25
  # @param invocation [OpenLanes::Adapters::Invocation]
17
26
  # @return [Array<String>]
18
27
  def argv(invocation)
@@ -8,6 +8,16 @@ module OpenLanes
8
8
  # Pi's `--thinking` scale already uses these words.
9
9
  THINKING = Adapters::EFFORTS
10
10
 
11
+ # `--no-session` every time: nothing resumes, nothing compacts, and
12
+ # nothing refuses a denied tool but the after-check.
13
+ CAPABILITIES = Capabilities.new(compacts: false,
14
+ resumes: false,
15
+ enforces_tool_denial: false,
16
+ compact_prompt: nil)
17
+
18
+ # @return [OpenLanes::Adapters::Capabilities]
19
+ def capabilities = CAPABILITIES
20
+
11
21
  # @param invocation [OpenLanes::Adapters::Invocation]
12
22
  # @return [Array<String>]
13
23
  def argv(invocation)
@@ -15,6 +15,15 @@ module OpenLanes
15
15
  # its events keeps working, and one that stops reporting usage shows a
16
16
  # meter at zero rather than a crash.
17
17
  class Stream
18
+ # Events that carry part of a message still being written. Only its
19
+ # finished lines are read out of one, and the closing message does not
20
+ # read them again (see {#take_partial}).
21
+ PARTIAL = /delta|update|partial|chunk/i
22
+
23
+ # Events that repeat messages already closed: pi's `turn_end` and
24
+ # `agent_end`, or anything carrying a `messages` list.
25
+ SUMMARY = /turn|agent|session|run/i
26
+
18
27
  # @param trace [String, nil] where every line is kept verbatim
19
28
  # @yieldparam progress [OpenLanes::Execution::Transcript::Progress]
20
29
  def initialize(trace: nil, &on_progress)
@@ -30,6 +39,11 @@ module OpenLanes
30
39
  @activity = nil
31
40
  @result = nil
32
41
  @error = nil
42
+ @journal = []
43
+ @heard = {}
44
+ @closed = Hash.new(0)
45
+ @open = { text: +"", taken: 0 }
46
+ @pending = Hash.new(0)
33
47
  end
34
48
 
35
49
  # @return [Integer] tokens sent, cached input included
@@ -59,6 +73,10 @@ module OpenLanes
59
73
  # @return [String, nil]
60
74
  attr_reader :trace_path
61
75
 
76
+ # @return [Array<OpenLanes::Plans::Journal::Entry, OpenLanes::Plans::Journal::Rejection>]
77
+ # the journal lines printed so far
78
+ def journal = @journal.dup
79
+
62
80
  # @return [Integer, nil]
63
81
  attr_accessor :pid
64
82
 
@@ -79,8 +97,19 @@ module OpenLanes
79
97
  # @return [Boolean]
80
98
  def failed? = !@error.nil?
81
99
 
100
+ # No sub-agents, running or spawned: codex and pi never say they delegated.
101
+ #
82
102
  # @return [OpenLanes::Execution::Transcript::Progress]
83
- def progress = Execution::Transcript::Progress.new(activity:, up:, down:, subagents: 0, pid:, message: nil)
103
+ def progress
104
+ Execution::Transcript::Progress.new(activity:,
105
+ up:,
106
+ down:,
107
+ subagents: 0,
108
+ pid:,
109
+ message: nil,
110
+ running: [],
111
+ journal: @journal.dup.freeze)
112
+ end
84
113
 
85
114
  # @param chunk [String, nil]
86
115
  # @return [void]
@@ -137,9 +166,124 @@ module OpenLanes
137
166
  @activity = said.lines.first.to_s.strip[0, Execution::Transcript::LIMIT]
138
167
  @result = said
139
168
  end
169
+ unless type.match?(/tool|command|exec/i)
170
+ if type.match?(PARTIAL)
171
+ take_partial(event)
172
+ else
173
+ take_journal(event, summary: type.match?(SUMMARY) || event.key?("messages"))
174
+ end
175
+ end
140
176
  @on_progress&.call(progress)
141
177
  end
142
178
 
179
+ # Journal lines (M2) in what the agent itself said, in every text block
180
+ # of every message it finished. Not {#text_in}, which takes the first
181
+ # `text` anywhere in the event: that can be a tool's output, which would
182
+ # make a file the agent read into its memory, and it misses a line in a
183
+ # message's second block.
184
+ #
185
+ # A CLI read by shape delivers the same message more than once: as it
186
+ # closes, and again in a summary of the turn or the run. A message with
187
+ # an id is taken once by its id. One without is always taken as it
188
+ # closes, since an agent may mean `todo U1` again after it said `done
189
+ # U1`; a summary skips as many copies of its words as have closed, and
190
+ # takes the rest.
191
+ #
192
+ # @param event [Hash]
193
+ # @param summary [Boolean] whether the event repeats closed messages
194
+ # @return [void]
195
+ def take_journal(event, summary:)
196
+ seen = Hash.new(0)
197
+ messages_in(event).each do |message|
198
+ texts = texts_of(message)
199
+ next if texts.empty?
200
+
201
+ if (id = message["id"])
202
+ next if @heard.key?(id)
203
+
204
+ @heard[id] = true
205
+ else
206
+ next if summary && (seen[texts] += 1) <= @closed[texts]
207
+
208
+ @closed[texts] += 1
209
+ end
210
+ texts.flat_map { Plans::Journal.scan(it) }.each { keep(it) }
211
+ @open = { text: +"", taken: 0 }
212
+ end
213
+ end
214
+
215
+ # The finished lines of a message still being written, as they arrive,
216
+ # so an agent killed before the message closes keeps what it printed.
217
+ # An event carries either the message so far (pi's `message`, codex's
218
+ # `item`) or only the newest piece of it (a `delta`).
219
+ #
220
+ # @param event [Hash]
221
+ # @return [void]
222
+ def take_partial(event)
223
+ whole = messages_in(event).map { texts_of(it).join("\n") }.reject(&:empty?).last
224
+ whole ? @open[:text] = +whole : @open[:text] << delta_in(event).to_s
225
+ cut = @open[:text].rindex("\n") or return
226
+
227
+ entries = Plans::Journal.scan(@open[:text][0..cut])
228
+ entries.drop(@open[:taken]).each do |entry|
229
+ @journal << entry
230
+ @pending[entry] += 1
231
+ end
232
+ @open[:taken] = entries.size
233
+ end
234
+
235
+ # An entry from a closing message, unless it was already taken from that
236
+ # message while it was being written.
237
+ #
238
+ # @param entry [OpenLanes::Plans::Journal::Entry, OpenLanes::Plans::Journal::Rejection]
239
+ # @return [void]
240
+ def keep(entry)
241
+ return @pending[entry] -= 1 if @pending[entry].positive?
242
+
243
+ @journal << entry
244
+ end
245
+
246
+ # @param event [Hash]
247
+ # @return [String, nil] the newest piece of text a partial event carries
248
+ def delta_in(event)
249
+ [event["delta"], event.dig("assistantMessageEvent", "delta")].each do |delta|
250
+ return delta if delta.is_a?(String)
251
+ return delta["text"] if delta.is_a?(Hash) && delta["text"].is_a?(String)
252
+ end
253
+ nil
254
+ end
255
+
256
+ # The agent's own messages an event carries: one under `item` (codex)
257
+ # or `message` (pi), a list under `messages`, or the event itself when
258
+ # it is a message with its words at the top level. A message that names
259
+ # a role other than the assistant's is a prompt or a tool result, and
260
+ # an item typed as anything but a message is a command, a file change
261
+ # or reasoning.
262
+ #
263
+ # @param event [Hash]
264
+ # @return [Array<Hash>]
265
+ def messages_in(event)
266
+ itself = event.key?("content") || event["text"].is_a?(String) ? event : nil
267
+ found = [event["item"], event["message"], *Array(event["messages"]), itself]
268
+ found.select do |message|
269
+ message.is_a?(Hash) &&
270
+ [nil, "assistant"].include?(message["role"]) &&
271
+ (message["type"].nil? || message["type"].to_s.match?(/message|assistant/i))
272
+ end
273
+ end
274
+
275
+ # @param message [Hash]
276
+ # @return [Array<String>] its text, block by block, tool calls left out
277
+ def texts_of(message)
278
+ content = message["content"]
279
+ blocks = if content.is_a?(Array)
280
+ content.select { it.is_a?(Hash) && [nil, "text", "output_text"].include?(it["type"]) }.map { it["text"] }
281
+ else
282
+ [message["text"], content]
283
+ end
284
+ blocks.select { it.is_a?(String) && !it.strip.empty? }
285
+ end
286
+
143
287
  # Usage counts arrive per turn, so they are added rather than replaced.
144
288
  #
145
289
  # @param event [Hash]
@@ -98,6 +98,13 @@ module OpenLanes
98
98
  klass.new
99
99
  end
100
100
 
101
+ # The lean-start flags of one CLI, so a caller building its own argv
102
+ # never names an adapter class.
103
+ #
104
+ # @param name [String, Symbol, nil]
105
+ # @return [Array<String>]
106
+ def lean_flags(name = DEFAULT) = self.for(name).lean_flags
107
+
101
108
  # @param name [String, Symbol, nil]
102
109
  # @return [Boolean]
103
110
  def known?(name) = registry.key?((name || DEFAULT).to_s)
@@ -89,11 +89,12 @@ module OpenLanes
89
89
  # @return [Boolean]
90
90
  def serves?(lane) = lanes.empty? || lanes.include?(lane.to_sym)
91
91
 
92
- # The word after the hyphen: `researcher`, `backend`. What the screen's
93
- # agent column shows, the name being too long for it.
92
+ # The word after the last hyphen: `researcher`, `backend`, and `planner`
93
+ # for `luthen-rael-planner`, whose name has two words before its role.
94
+ # What the screen's agent column shows, the name being too long for it.
94
95
  #
95
96
  # @return [String]
96
- def role = name.split("-", 2).last.to_s
97
+ def role = name.split("-").last.to_s
97
98
  end
98
99
 
99
100
  # More rounds than this costs tokens and buys nothing: an agent that has
@@ -27,6 +27,13 @@ module OpenLanes
27
27
  # folder by what its files justify.
28
28
  BY_CONTENTS = "by contents"
29
29
 
30
+ # What bb8-resolver is offered: no plan state, but the stopped rebases
31
+ # the harness hands it (D10). An empty cell read as "does nothing".
32
+ REBASES = "stopped rebases, from the harness"
33
+
34
+ # It writes, so "read-only" would be false; it moves no plan either.
35
+ STAGES_ONLY = "stages, never moves a plan"
36
+
30
37
  # @param agents [OpenLanes::Agents::Registry]
31
38
  def initialize(agents: Registry.new)
32
39
  @agents = agents
@@ -50,7 +57,7 @@ module OpenLanes
50
57
  # A state as its words alone, deliberately without the emoji that every
51
58
  # other rendering of a state carries. The table widget pads a cell to a
52
59
  # width it measures in characters, while the terminal draws a status emoji
53
- # in two cells; the variation-selector ones (⭕️ 🅱️ ⚪️ ⭐️ 🕰️) are two
60
+ # in two cells; the variation-selector ones (⭕ 🛑 ⚪ ⭐ ⏪) are two
54
61
  # characters drawn in two cells, so no single count is right for all of
55
62
  # them. A row carrying one lands short or long and the frame stops lining
56
63
  # up. The label says the same thing unambiguously, and is worth more than
@@ -66,12 +73,17 @@ module OpenLanes
66
73
 
67
74
  # @param agent [OpenLanes::Agents::Agent]
68
75
  # @return [String]
69
- def handles(agent) = agent.handles.map { |key| state(key) }.join(", ")
76
+ def handles(agent)
77
+ return REBASES if agent.name == Engine::Rebase::RESOLVER
78
+
79
+ agent.handles.map { |key| state(key) }.join(", ")
80
+ end
70
81
 
71
82
  # @param agent [OpenLanes::Agents::Agent]
72
83
  # @return [String]
73
84
  def advances(agent)
74
85
  return state(agent.advances_to) if agent.advances_to
86
+ return STAGES_ONLY if agent.name == Engine::Rebase::RESOLVER
75
87
  return BY_CONTENTS if agent.handles.any? && agent.handles.all? { |key| Plans::StateMachine::SETTLED.include?(key) }
76
88
 
77
89
  READ_ONLY
@@ -16,6 +16,13 @@ module OpenLanes
16
16
  # and "no front-end units" starts nobody.
17
17
  UNIT_HEADING = /^[ \t]{0,3}\#{2,3}[ \t]+\S/
18
18
 
19
+ # A work unit inside a section of `plan.md`: the section is the `##`,
20
+ # so only a deeper heading is a unit.
21
+ SECTION_UNIT_HEADING = /^[ \t]{0,3}\#{3,4}[ \t]+\S/
22
+
23
+ # Any second-level heading, which ends the section before it.
24
+ SECTION_HEADING = /^[ \t]{0,3}\#\#[ \t]+(.+?)[ \t#]*$/
25
+
19
26
  module_function
20
27
 
21
28
  # @param agent [OpenLanes::Agents::Agent]
@@ -36,7 +43,7 @@ module OpenLanes
36
43
  #
37
44
  # {Agent#needs} only ever chooses between agents. When lanes and
38
45
  # switches leave one candidate, that one runs: a plan parked at 🎨 is
39
- # rey-frontend's to finish whatever `plan-frontend.md` says, and an
46
+ # rey-frontend's to finish whatever `plan.md`'s `## Frontend` says, and an
40
47
  # operator who typed `--agent rey-frontend` meant it.
41
48
  #
42
49
  # @param agents [Array<OpenLanes::Agents::Agent>]
@@ -79,10 +86,43 @@ module OpenLanes
79
86
  # @return [Boolean]
80
87
  def reviewable?(agent, subject) = agent.phase.to_s != "review" || subject.pull_requests.any?(&:open?)
81
88
 
89
+ # Whether +need+ holds a work unit. A need is a file (`plan-frontend.md`)
90
+ # or a section of one (`plan.md#frontend`: the `## Frontend` section,
91
+ # matched without regard to case). Units moved into `plan.md` in plan
92
+ # 003; a folder planned before that still holds `plan-<section>.md`, and
93
+ # is read from there when `plan.md` has no such section, so no folder
94
+ # needs migrating.
95
+ #
82
96
  # @param subject [OpenLanes::Plans::Subject]
83
- # @param file [String]
97
+ # @param need [String]
84
98
  # @return [Boolean]
85
- def units?(subject, file) = Plans::Ledger.stripped(subject.read(file)).match?(UNIT_HEADING)
99
+ def units?(subject, need)
100
+ file, section = need.split("#", 2)
101
+ return Plans::Ledger.stripped(subject.read(file)).match?(UNIT_HEADING) unless section
102
+
103
+ body = section_of(Plans::Ledger.stripped(subject.read(file)), section)
104
+ return body.match?(SECTION_UNIT_HEADING) if body
105
+
106
+ units?(subject, "plan-#{section.downcase}.md")
107
+ end
108
+
109
+ # @param text [String]
110
+ # @param name [String]
111
+ # @return [String, nil] the lines under `## name`, up to the next `##`;
112
+ # nil when there is no such heading
113
+ def section_of(text, name)
114
+ body = nil
115
+ text.each_line do |line|
116
+ if (heading = SECTION_HEADING.match(line))
117
+ break if body
118
+
119
+ body = +"" if heading[1].strip.casecmp?(name)
120
+ elsif body
121
+ body << line
122
+ end
123
+ end
124
+ body
125
+ end
86
126
  end
87
127
  end
88
128
  end