openlanes 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "tty/command"
6
+
7
+ module OpenLanes
8
+ module Engine
9
+ # The plans an agent outside openlanes is working on, as `alock` (the
10
+ # agent-lock gem) records them, and the engine's own claim in the same
11
+ # register (decision W31).
12
+ #
13
+ # The Redis claim (W26) keeps engines apart and the lock file keeps two
14
+ # runs on one machine apart, but an agent a person started by hand reads
15
+ # neither. It does read alock: every agent here claims what it is about
16
+ # to write. So the engine stays out of a plan whose folder, or whose
17
+ # worktree, somebody else holds, and holds the folder itself while its
18
+ # agents are in it, so that agent sees the engine too. With alock on
19
+ # Redis this reaches across machines as well.
20
+ #
21
+ # `alock acquire` is the atomic step: it refuses (exit 1) when anybody
22
+ # holds an overlapping scope, so two contenders cannot both win. The
23
+ # listing only keeps the engine from asking, tick after tick, for plans
24
+ # it would be refused.
25
+ #
26
+ # alock is a command, not a library: its holder is the process that runs
27
+ # it, skipping shells, which is the engine itself, so a lock ends with
28
+ # the engine that took it. A machine without alock has no agent using it
29
+ # either, and the engine carries on without it, said once.
30
+ class AgentLocks
31
+ # How long one `alock list` answers for. The dispatcher asks on every
32
+ # one-second tick, and each answer starts a Ruby process.
33
+ FRESH = 5
34
+
35
+ # One lock, as `alock list --json` reports it.
36
+ #
37
+ # @!attribute [r] agent
38
+ # @return [String] the holder's agent id
39
+ # @!attribute [r] scope
40
+ # @return [String] relative to {#tree}, e.g. `.plans/003.00━━🟡━━❯x/**`
41
+ # @!attribute [r] tree
42
+ # @return [String] the checkout the lock was taken in
43
+ # @!attribute [r] intent
44
+ # @return [String, nil]
45
+ Held = Data.define(:agent, :scope, :tree, :intent) do
46
+ # @return [String] the scope's literal part: `**` is "", `a/b/**` is "a/b"
47
+ def fixed = scope.split("/").take_while { !it.match?(/[*?\[{]/) }.join("/")
48
+
49
+ # @return [String] who holds it and why, for the log
50
+ def to_s = "#{agent} (#{intent || "no intent given"})"
51
+ end
52
+
53
+ # @param root [String] the repository whose plans are locked
54
+ # @param id [String] the agent id the engine's own locks carry
55
+ # @param own [Array<String>] the engine's agents, who claim with
56
+ # `AGENT_ID=<name>` or `<name>-<suffix>` as their prompt tells them to
57
+ # @param command [TTY::Command]
58
+ # @param clock [#call] monotonic seconds
59
+ def initialize(root:, id: "openlanes@#{Socket.gethostname}:#{Process.pid}", own: [],
60
+ command: TTY::Command.new(printer: :null), clock: -> { UI.monotonic })
61
+ @root = File.realpath(root)
62
+ @id = id
63
+ @own = own
64
+ @failing = nil
65
+ @command = command
66
+ @clock = clock
67
+ @mutex = Mutex.new
68
+ @snapshot = nil
69
+ @taken_at = nil
70
+ @off = false
71
+ end
72
+
73
+ # @return [String]
74
+ attr_reader :id
75
+
76
+ # Somebody else's live lock that overlaps the plan's folder, or any
77
+ # lock taken inside its worktree. The engine's own agents are not
78
+ # somebody else: they claim each file before writing it, so counting
79
+ # them would keep a pair's partner out of the plan while the first one
80
+ # works in it.
81
+ #
82
+ # @param dir [String] the plan folder
83
+ # @param checkout [String, nil] the plan's worktree, when the run isolates
84
+ # @return [Held, nil]
85
+ def held_elsewhere(dir, checkout: nil)
86
+ folder = real(dir)
87
+ work = checkout && real(checkout)
88
+ held.find do |lock|
89
+ next false if ours?(lock.agent)
90
+
91
+ tree = real(lock.tree)
92
+ (work && tree == work) || (inside?(folder, tree) && overlap?(relative(folder, tree), lock.fixed))
93
+ end
94
+ end
95
+
96
+ # Anybody's live lock taken inside one checkout, the engine's own
97
+ # included: a worktree with a lock in it is in use, whoever holds it.
98
+ #
99
+ # @param checkout [String]
100
+ # @return [Held, nil]
101
+ def held_in(checkout)
102
+ work = real(checkout)
103
+ held.find { real(it.tree) == work }
104
+ end
105
+
106
+ # Claim the plan's folder. False when somebody holds it, in which case
107
+ # the plan is left alone; true when alock is not installed.
108
+ #
109
+ # @param dir [String] the plan folder
110
+ # @param intent [String] what the engine is about to do there
111
+ # @return [Boolean]
112
+ def acquire(dir, intent)
113
+ run("acquire", scope(dir), intent, dir: File.dirname(dir))
114
+ end
115
+
116
+ # @param dir [String] the plan folder, as it was named when acquired
117
+ # @return [void]
118
+ def release(dir)
119
+ run("release", scope(dir), dir: File.dirname(dir))
120
+ end
121
+
122
+ private
123
+
124
+ # @param agent [String] a lock's holder
125
+ # @return [Boolean] the engine itself, one of its agents, or a sub-agent of one
126
+ def ours?(agent) = agent == @id || @own.any? { agent == it || agent.start_with?("#{it}-") }
127
+
128
+ # @return [Array<Held>] every active lock, at most {FRESH} seconds old
129
+ def held
130
+ @mutex.synchronize do
131
+ return [] if @off
132
+ return @snapshot if @snapshot && @clock.call - @taken_at < FRESH
133
+
134
+ @snapshot = list
135
+ @taken_at = @clock.call
136
+ @snapshot
137
+ end
138
+ end
139
+
140
+ # @return [Array<Held>]
141
+ def list
142
+ out = @command.run("alock", "list", "--json", "--dir", @root).out
143
+ listed = JSON.parse(out).filter_map do |lock|
144
+ next unless lock["status"] == "active"
145
+
146
+ Held.new(agent: lock["agent_id"].to_s, scope: lock["scope"].to_s, tree: lock["tree"].to_s, intent: lock["intent"])
147
+ end
148
+ @failing = nil
149
+ listed
150
+ rescue TTY::Command::ExitError, JSON::ParserError => e
151
+ failing(e)
152
+ []
153
+ rescue SystemCallError
154
+ missing
155
+ []
156
+ end
157
+
158
+ # @return [Boolean] exit 0; false on exit 1, which is a refusal
159
+ def run(*args, dir:)
160
+ return true if @off
161
+
162
+ result = @command.run!("alock", *args, "--dir", dir, env: { "AGENT_ID" => @id })
163
+ @mutex.synchronize { @snapshot = nil }
164
+ # 1 is a refusal; anything above is alock failing, which is said, and
165
+ # counts as a refusal too: a plan nobody could check is left alone.
166
+ if result.exit_status > 1
167
+ left = args.first == "release" ? "; #{args[1]} stays held until the engine exits" : ""
168
+ UI.log("alock #{args.first} #{args[1]} failed: #{(result.err.to_s + result.out.to_s).lines.first.to_s.strip}#{left}",
169
+ level: :warn)
170
+ end
171
+ result.success?
172
+ rescue SystemCallError
173
+ missing
174
+ true
175
+ end
176
+
177
+ # Said once per failure, not on every tick it lasts: the listing is
178
+ # asked for every few seconds, and the same line each time buried the
179
+ # log. A listing that works again clears it.
180
+ #
181
+ # @param error [Exception]
182
+ # @return [void]
183
+ def failing(error)
184
+ # TTY::Command's message is the command line first; the reason is in
185
+ # the stderr lines after it.
186
+ reason = error.message.lines.map(&:strip).reject(&:empty?).find { it.start_with?("stderr:") } ||
187
+ error.message.lines.first.to_s.strip
188
+ return if @failing == reason
189
+
190
+ @failing = reason
191
+ UI.log("alock list failed, plans unchecked until it works: #{reason}", level: :warn)
192
+ end
193
+
194
+ # @return [void]
195
+ def missing
196
+ return if @off
197
+
198
+ @off = true
199
+ UI.log("alock is not installed: plans are not checked against agents outside openlanes", level: :warn)
200
+ end
201
+
202
+ # The folder and everything in it: a directory scope covers its
203
+ # contents, and the folder's own files are what the agents write.
204
+ #
205
+ # @param dir [String]
206
+ # @return [String] relative to the folder's parent, where alock runs
207
+ def scope(dir) = "#{File.basename(dir)}/**"
208
+
209
+ # @param path [String]
210
+ # @return [String]
211
+ def real(path) = File.exist?(path) ? File.realpath(path) : File.expand_path(path)
212
+
213
+ # @return [Boolean]
214
+ def inside?(path, tree) = path == tree || path.start_with?("#{tree}/")
215
+
216
+ # @return [String]
217
+ def relative(path, tree) = path == tree ? "" : path.delete_prefix("#{tree}/")
218
+
219
+ # alock's own rule: two scopes conflict when either one's literal part
220
+ # contains the other's, and "" (the whole tree) contains everything.
221
+ #
222
+ # @param path [String]
223
+ # @param fixed [String]
224
+ # @return [Boolean]
225
+ def overlap?(path, fixed)
226
+ fixed.empty? || path.empty? || path == fixed ||
227
+ path.start_with?("#{fixed}/") || fixed.start_with?("#{path}/")
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # Where a plan goes once its rejections reach `merge.max_rejections`
6
+ # (M6): to the arbitrator, `obi-wan-kenobi-arbitrator` (M7, M8), which
7
+ # plan 015 builds.
8
+ #
9
+ # Until then this is the seam it plugs into, and its default only says
10
+ # so. The plan is handled as any other rejection, so nothing waits on a
11
+ # ruling nobody can give yet; holding it under Needs you at the cap is
12
+ # plan 014.02's.
13
+ class Arbitration
14
+ # @param plan [OpenLanes::Plans::Subject]
15
+ # @param count [Integer] rejections so far, hansolo's and chewie's
16
+ # @param max [Integer] `merge.max_rejections`
17
+ # @return [void]
18
+ def call(plan:, count:, max:)
19
+ UI.log("rejected #{count} of #{max}: at the cap, and no arbitrator is built yet (plan 015)",
20
+ level: :warn,
21
+ plan: plan.feature.ordinal.to_s)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -3,12 +3,18 @@
3
3
  module OpenLanes
4
4
  module Engine
5
5
  # What the screen draws: everything about the run, as values, refreshed
6
- # once a second by the dispatcher. The screen paints it and knows nothing
6
+ # every tick by the dispatcher. The screen paints it and knows nothing
7
7
  # about threads; the dispatcher builds it and knows nothing about cells.
8
8
  #
9
9
  # @!attribute [r] rows
10
10
  # @return [Array<OpenLanes::Engine::Board::Row>] one per agent invocation still
11
11
  # worth showing: running, or finished within the last few seconds
12
+ # @!attribute [r] waiting_on
13
+ # @return [Hash{String => Array<Integer>}] plan to the pull requests open
14
+ # and waiting on a person, as the human-mode watch last saw them (M4)
15
+ # @!attribute [r] jobs
16
+ # @return [Integer] how many agents may run at once now, after any
17
+ # `/concurrency` from the copilot (plan 020.00)
12
18
  Board = Data.define(:started_at,
13
19
  :status,
14
20
  :plans,
@@ -23,8 +29,10 @@ module OpenLanes
23
29
  :dialog,
24
30
  :help,
25
31
  :about,
26
- :hidden) do
27
- def initialize(selected: nil, dialog: nil, help: false, about: nil, hidden: 0, **rest) = super
32
+ :hidden,
33
+ :waiting_on,
34
+ :jobs) do
35
+ def initialize(selected: nil, dialog: nil, help: false, about: nil, hidden: 0, waiting_on: {}, jobs: 1, **rest) = super
28
36
  end
29
37
 
30
38
  # @!attribute [r] key
@@ -52,6 +60,13 @@ module OpenLanes
52
60
  # @!attribute [r] session
53
61
  # @return [String, nil] the CLI's session id, once its stream named it;
54
62
  # what a compaction resumes (W28)
63
+ # @!attribute [r] unacked
64
+ # @return [Integer] messages in the plan's mailbox nobody has acked yet.
65
+ # Data for the screen only (D2): no round, route or rename reads it,
66
+ # because an unanswered message is a warning, never an input.
67
+ # @!attribute [r] running
68
+ # @return [Array<String>] its sub-agents still at work, by name. Empty
69
+ # once the row is done, and for an adapter whose stream never says
55
70
  Board::Row = Data.define(:key,
56
71
  :at,
57
72
  :ordinal,
@@ -75,12 +90,28 @@ module OpenLanes
75
90
  :history,
76
91
  :context,
77
92
  :since,
78
- :session) do
93
+ :session,
94
+ :unacked,
95
+ :running) do
79
96
  def initialize(pr: nil, phase: nil, remaining: nil, frame: 0, bold: false, message: nil,
80
- elapsed: 0, subagents: 0, history: [], context: nil, since: nil, session: nil, **rest)
97
+ elapsed: 0, subagents: 0, history: [], context: nil, since: nil, session: nil, unacked: 0, running: [], **rest)
81
98
  super
82
99
  end
83
100
 
101
+ # The count a row carries, read from the plan folder's mailbox, so the
102
+ # dispatcher fills the field in one call and the snapshot and the board
103
+ # cannot count differently (both ask Mailbox#unacked).
104
+ #
105
+ # @param dir [String] the plan folder
106
+ # @return [Integer] 0 for a plan nobody has written to, and for one
107
+ # whose `state.json` cannot be read: a count for the screen must not
108
+ # stop the tick that draws it
109
+ def self.unacked_in(dir)
110
+ Plans.mailbox(dir).unacked
111
+ rescue OpenLanes::Error, SystemCallError
112
+ 0
113
+ end
114
+
84
115
  # @param history [Array<String>] newest first
85
116
  # @param text [String, nil]
86
117
  # @return [Array<String>] +text+ on top, unless it is empty or repeats
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module OpenLanes
7
+ module Engine
8
+ # The dispatcher's half of D9: an open question in a blocked plan's
9
+ # `blocked.md` that offers no choices is sent to the {Live::ChoiceConverter}
10
+ # and, once the model has answered, rewritten in place into D3's shape, so
11
+ # the page, lando-broker and everything else read the same options.
12
+ #
13
+ # The call runs on a thread of its own, like a drain, and its result is
14
+ # applied on a later tick, where the folder is looked up afresh. Nothing is
15
+ # written when the question changed meanwhile, was answered, or the plan
16
+ # was taken by another machine or another job.
17
+ #
18
+ # Each text is sent once. A rewrite, or an answer the model gave that is
19
+ # not a D3 question, is recorded in the plan's `state.json` under the
20
+ # SHA256 of the text, so a restarted run does not pay for it again. A call
21
+ # that failed (no network, a 4xx, a timeout) is remembered only for this
22
+ # process: the next run may well get through.
23
+ class Conversions
24
+ # One question sent to the model.
25
+ Job = Struct.new(:ordinal, :number, :digest, :original, :thread)
26
+
27
+ # @param converter [#call, nil] a {Live::ChoiceConverter}; nil converts nothing
28
+ # @param runner [OpenLanes::Engine::Runner]
29
+ # @param find [#call] an ordinal to the plan's {Plans::Subject} as the
30
+ # folder is now, nil when it is gone
31
+ def initialize(converter:, runner:, find:)
32
+ @converter = converter
33
+ @runner = runner
34
+ @find = find
35
+ @jobs = []
36
+ @sent = []
37
+ end
38
+
39
+ # @return [Array<String>] plans with a question at the model right now
40
+ def ordinals = @jobs.map(&:ordinal)
41
+
42
+ # Apply what came back, then send what has not been sent.
43
+ #
44
+ # @param busy [Array<String>] plans nobody but their holder may touch
45
+ # @param start [Boolean] false applies answers and sends nothing new
46
+ # @return [void]
47
+ def call(busy:, start: true)
48
+ return if @converter.nil?
49
+
50
+ @jobs.reject { it.thread.alive? }.each { settle(it, busy) }
51
+ return unless start
52
+
53
+ @runner.blocked.each do |subject|
54
+ ordinal = subject.feature.ordinal.to_s
55
+ next if busy.include?(ordinal)
56
+
57
+ prose(subject).each do |question, body|
58
+ next unless @runner.claim(subject)
59
+
60
+ send_off(ordinal, question, body)
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ # Open questions with a body and no choices in it, not yet sent.
68
+ #
69
+ # @param subject [OpenLanes::Plans::Subject]
70
+ # @return [Array<Array(Lifecycle::Unblocker::Question, String)>]
71
+ def prose(subject)
72
+ bodies = Plans::BlockedFile.bodies(subject.read("blocked.md").to_s)
73
+ state = Plans::PlanState.for(subject.feature.path)
74
+ Lifecycle::Unblocker.questions(subject).reject(&:answered).filter_map do |question|
75
+ body = bodies.fetch(question.number, "").strip
76
+ next if body.empty? || question.heading.match?(Plans::Choices::YES_NO) || Plans::Choices.parse(body)
77
+
78
+ digest = Digest::SHA256.hexdigest(body)
79
+ next if @sent.include?(digest) || state.converted?(digest)
80
+
81
+ [question, body]
82
+ end
83
+ end
84
+
85
+ # @return [void]
86
+ def send_off(ordinal, question, body)
87
+ digest = Digest::SHA256.hexdigest(body)
88
+ @sent << digest
89
+ job = Job.new(ordinal:, number: question.number, digest:, original: body)
90
+ heading = question.heading
91
+ job.thread = Thread.new do
92
+ Thread.current.report_on_exception = false
93
+ @converter.call(heading:, body:)
94
+ rescue StandardError => e
95
+ e
96
+ end
97
+ @jobs << job
98
+ end
99
+
100
+ # @param job [Job]
101
+ # @param busy [Array<String>]
102
+ # @return [void]
103
+ def settle(job, busy)
104
+ @jobs.delete(job)
105
+ outcome = job.thread.value
106
+ subject = @find.call(Plans::Ordinal.parse(job.ordinal))
107
+ return couldnt(job, "the plan's folder is gone") if subject.nil?
108
+ return couldnt(job, "the plan was taken meanwhile") if busy.include?(job.ordinal)
109
+
110
+ case outcome
111
+ when Live::ChoiceConverter::Result then apply(job, subject, outcome)
112
+ when Live::ChoiceConverter::Invalid then reject(job, subject, outcome)
113
+ else couldnt(job, outcome.message.lines.first.to_s.strip)
114
+ end
115
+ end
116
+
117
+ # @return [void]
118
+ def apply(job, subject, result)
119
+ path = File.join(subject.feature.path, "blocked.md")
120
+ text = subject.read("blocked.md").to_s
121
+ open = Lifecycle::Unblocker.questions(subject).find { it.number == job.number && !it.answered }
122
+ current = Plans::BlockedFile.bodies(text).fetch(job.number, "").strip
123
+ return couldnt(job, "it changed or was answered while the model was writing") if !open || current != job.original
124
+
125
+ write(path, Plans::BlockedFile.replace(text, job.number, result.body))
126
+ Plans::PlanState.for(subject.feature.path).conversion!(question: job.number,
127
+ digest: job.digest,
128
+ original: job.original,
129
+ model: result.model,
130
+ outcome: "converted")
131
+ UI.log("questions: converted B#{job.number} to #{result.count} choices", plan: job.ordinal)
132
+ end
133
+
134
+ # @return [void]
135
+ def reject(job, subject, error)
136
+ Plans::PlanState.for(subject.feature.path).conversion!(question: job.number,
137
+ digest: job.digest,
138
+ original: job.original,
139
+ model: @converter.model,
140
+ outcome: "rejected",
141
+ note: error.message)
142
+ couldnt(job, error.message)
143
+ end
144
+
145
+ # @return [void]
146
+ def couldnt(job, why) = UI.log("questions: could not convert B#{job.number}: #{why}", level: :warn, plan: job.ordinal)
147
+
148
+ # A reader never sees half a file.
149
+ #
150
+ # @return [void]
151
+ def write(path, text)
152
+ tmp = "#{path}.#{Process.pid}.tmp"
153
+ File.write(tmp, text)
154
+ File.rename(tmp, path)
155
+ ensure
156
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
157
+ end
158
+ end
159
+ end
160
+ end