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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Engine
7
+ class EventJournal
8
+ # Rebuilds the in-flight picture from a journal file: the run, each
9
+ # plan's last known state, and every agent that started and has not
10
+ # ended, with its pid, session and last offset.
11
+ #
12
+ # A kill can tear the last line, so a line that does not parse is
13
+ # counted in `torn` and skipped. A line whose `v` is newer than
14
+ # {EventJournal::VERSION} is counted in `newer` rather than read, so an
15
+ # old reader never invents a picture from fields it does not know.
16
+ module Replay
17
+ # One agent the journal says is running.
18
+ Agent = Data.define(:plan, :agent, :round, :pid, :started_at, :session_id, :output_file, :offset)
19
+
20
+ # What the journal says was going on when it stopped.
21
+ #
22
+ # @!attribute [r] run
23
+ # @return [Hash, nil] the `run_started` event, and `ended` when a `run_ended` followed
24
+ # @!attribute [r] plans
25
+ # @return [Hash{String => String}] plan number to the last state a transition named
26
+ # @!attribute [r] agents
27
+ # @return [Array<Agent>] agents started and not ended, in start order
28
+ # @!attribute [r] torn
29
+ # @return [Integer] lines that did not parse
30
+ # @!attribute [r] newer
31
+ # @return [Integer] lines from a newer schema version, skipped
32
+ # @!attribute [r] seq
33
+ # @return [Integer] the last `seq` read
34
+ InFlight = Data.define(:run, :plans, :agents, :torn, :newer, :seq) do
35
+ # @return [Boolean] the run started and no `run_ended` followed
36
+ def running? = !run.nil? && !run.key?("ended")
37
+ end
38
+
39
+ module_function
40
+
41
+ # @param path [String] a journal file
42
+ # @return [InFlight]
43
+ def call(path)
44
+ state = { run: nil, plans: {}, agents: {}, torn: 0, newer: 0, seq: 0 }
45
+ File.foreach(path) { read(state, it) }
46
+ InFlight.new(run: state[:run],
47
+ plans: state[:plans],
48
+ agents: state[:agents].values,
49
+ torn: state[:torn],
50
+ newer: state[:newer],
51
+ seq: state[:seq])
52
+ end
53
+
54
+ # @api private
55
+ def read(state, line)
56
+ return if line.strip.empty?
57
+
58
+ event = JSON.parse(line)
59
+ return state[:newer] += 1 if event["v"].to_i > VERSION
60
+
61
+ state[:seq] = event["seq"].to_i
62
+ apply(state, event)
63
+ rescue JSON::ParserError
64
+ state[:torn] += 1
65
+ end
66
+
67
+ # @api private
68
+ def apply(state, event)
69
+ key = [event["plan"], event["agent"]]
70
+ agents = state[:agents]
71
+ case event["kind"]
72
+ when "run_started" then state[:run] = event
73
+ when "run_ended" then state[:run] = (state[:run] || {}).merge("ended" => event)
74
+ when "transition" then state[:plans][event["plan"]] = event["to"]
75
+ when "agent_started" then agents[key] = start(event)
76
+ when "agent_session" then agents[key] &&= agents[key].with(session_id: event["session_id"])
77
+ when "agent_offset" then agents[key] &&= agents[key].with(offset: event["offset"])
78
+ when "agent_ended" then agents.delete(key)
79
+ end
80
+ end
81
+
82
+ # @api private
83
+ def start(event)
84
+ Agent.new(plan: event["plan"],
85
+ agent: event["agent"],
86
+ round: event["round"],
87
+ pid: event["pid"],
88
+ started_at: event["started_at"],
89
+ session_id: event["session_id"],
90
+ output_file: event["output_file"],
91
+ offset: 0)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "socket"
7
+ require "time"
8
+
9
+ module OpenLanes
10
+ module Engine
11
+ # The node's progress journal: one append-only NDJSON file per run under
12
+ # {Runtime.journal_dir}, one schema-versioned line per engine event,
13
+ # flushed and fsync'd before {#append} returns. Each line is then mirrored
14
+ # to the {Live::EventBus} on {Live::Keys#journal}.
15
+ #
16
+ # The file is the record, not Redis. {StateFile} is a snapshot rewritten
17
+ # every tick, and the Redis board keeps a hundred entries that vanish with
18
+ # the server, so neither could say what a killed engine had been doing.
19
+ # The bus is best-effort: a publish that raises is reported once and the
20
+ # line stays on disk.
21
+ #
22
+ # Named `EventJournal`, never `Journal`: {Plans::Journal} is the agents'
23
+ # own memory in `state.json`, a different thing that shares the word.
24
+ #
25
+ # @example
26
+ # journal = EventJournal.new(root: Dir.pwd, run_id: "r1", node: "mbp:abc123")
27
+ # journal.append(:transition, plan: "003.00", from: "planned", to: "building", event: "build")
28
+ # EventJournal::Replay.call(journal.path).agents
29
+ class EventJournal
30
+ # The schema every line validates against.
31
+ SCHEMA_PATH = File.expand_path("../../../schemas/journal-event.schema.json", __dir__)
32
+
33
+ # The `v` of every line this writer appends. A reader that meets a
34
+ # newer one reports it rather than guessing at the fields.
35
+ VERSION = 1
36
+
37
+ # Every `kind` the schema accepts, in the order a run produces them.
38
+ KINDS = %w[
39
+ run_started run_ended plan_assigned agent_started agent_session
40
+ agent_offset agent_ended transition extension question
41
+ ].freeze
42
+
43
+ # @return [String] the NDJSON file this journal appends to
44
+ attr_reader :path
45
+
46
+ # @return [String]
47
+ attr_reader :run_id
48
+
49
+ # @param root [String] the project root, keying {Runtime.journal_dir}
50
+ # @param run_id [String] names the file; one file per run, never truncated
51
+ # @param node [String] this node's `<node id>:<checkout id>`
52
+ # @param bus [Live::EventBus::Base, nil] where each line is mirrored
53
+ # @param channel [String, nil] the bus channel, {Live::Keys#journal}
54
+ # @param clock [#now]
55
+ def initialize(root:, run_id: self.class.run_id, node: self.class.node, bus: nil, channel: nil, clock: Time)
56
+ @run_id = run_id
57
+ @node = node
58
+ @bus = bus
59
+ @channel = channel
60
+ @clock = clock
61
+ @path = File.join(Runtime.journal_dir(root:), "#{run_id}.ndjson")
62
+ @lock = Mutex.new
63
+ @seq = last_seq
64
+ @warned = false
65
+ # O_APPEND, so each line lands whole at the end even beside another
66
+ # writer, and one `write` per line, so a kill tears at most the last.
67
+ @file = File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644)
68
+ seal_torn_tail
69
+ end
70
+
71
+ # Append one event, durable on return.
72
+ #
73
+ # @param kind [String, Symbol] one of {KINDS}
74
+ # @param fields [Hash] the kind's own fields; `nil` values are kept,
75
+ # because the schema tells "unknown" from "absent"
76
+ # @return [Hash] the event as written
77
+ # @raise [ArgumentError] on a kind outside {KINDS}
78
+ def append(kind, **fields)
79
+ kind = kind.to_s
80
+ raise ArgumentError, "unknown journal event kind: #{kind}" unless KINDS.include?(kind)
81
+
82
+ event = @lock.synchronize do
83
+ @seq += 1
84
+ line = { "v" => VERSION, "seq" => @seq, "at" => @clock.now.utc.iso8601(6), "node" => @node,
85
+ "run_id" => @run_id, "kind" => kind }.merge(fields.transform_keys(&:to_s))
86
+ @file.write("#{JSON.generate(line)}\n")
87
+ @file.flush
88
+ @file.fsync
89
+ line
90
+ end
91
+ mirror(event)
92
+ event
93
+ end
94
+
95
+ # @return [void]
96
+ def close = @lock.synchronize { @file.close unless @file.closed? }
97
+
98
+ # @param root [String]
99
+ # @return [String, nil] the most recently written journal under +root+'s runtime directory
100
+ def self.latest(root:)
101
+ Dir.glob(File.join(Runtime.journal_dir(root:), "*.ndjson")).max_by { File.mtime(it) }
102
+ end
103
+
104
+ # @param clock [#now]
105
+ # @return [String] `YYYYMMDD-HHMMSS-<6 hex>`, sortable and unique per run
106
+ def self.run_id(clock: Time) = "#{clock.now.utc.strftime("%Y%m%d-%H%M%S")}-#{SecureRandom.hex(3)}"
107
+
108
+ # @return [String] the host and this process, when no live node id is known
109
+ def self.node = "#{Socket.gethostname}:#{Process.pid}"
110
+
111
+ private
112
+
113
+ # A file reopened for the same run carries on numbering where it stopped.
114
+ def last_seq
115
+ return 0 unless File.exist?(@path)
116
+
117
+ File.foreach(@path).reduce(0) do |seq, line|
118
+ JSON.parse(line)["seq"].to_i
119
+ rescue JSON::ParserError
120
+ seq
121
+ end
122
+ end
123
+
124
+ # A run killed mid-write leaves a fragment with no newline. The next
125
+ # line appended straight after it fused with it, and the replay then
126
+ # dropped both as one torn line, losing an event it had acknowledged.
127
+ def seal_torn_tail
128
+ return if File.empty?(@path)
129
+ return if File.open(@path, "rb") { it.seek(-1, IO::SEEK_END) && it.read(1) } == "\n"
130
+
131
+ @file.write("\n")
132
+ @file.flush
133
+ @file.fsync
134
+ end
135
+
136
+ def mirror(event)
137
+ return unless @bus && @channel
138
+
139
+ @bus.publish(@channel, JSON.generate(event))
140
+ rescue StandardError => e
141
+ return if @warned
142
+
143
+ @warned = true
144
+ UI.log("journal mirror to the event bus failed (#{e.class}: #{e.message}); the file still has every event")
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module OpenLanes
6
+ module Engine
7
+ # An agent that runs out of time or rounds asks for more instead of
8
+ # simply ending (E1). The harness, not the agent, asks: a question on the
9
+ # plan, `X<n>`, with three fixed choices, kept in the plan's `state.json`
10
+ # so it survives a restart and can be answered from any control room.
11
+ #
12
+ # - Time: asked while the agent still runs, {ASK_AT} seconds before its
13
+ # clock's zero. A time choice answered then moves the live deadline, and
14
+ # the agent carries on in the same session.
15
+ # - Rounds: asked once the agent has used every round it had on the plan's
16
+ # state without finishing. Every choice gives it one more round; the
17
+ # time choices also lengthen that round's clock.
18
+ #
19
+ # Nobody may be watching, so each kind is granted once without an answer:
20
+ # ten more minutes when the clock reaches {AUTO_AT}, and one more round at
21
+ # once on a run with no control room, or {AUTO_AFTER} seconds after asking
22
+ # on one that has one. After that the request waits for a person, and the
23
+ # limit applies as it always did: STOP at zero, no round past the last.
24
+ #
25
+ # Nothing here starts, stops or renames anything; the dispatcher asks and
26
+ # acts on the answers.
27
+ class Extensions
28
+ # Seconds left on a clock when the time request is asked: the same
29
+ # moment the agent itself is told "5 minutes left".
30
+ ASK_AT = 300
31
+
32
+ # Seconds left when an unanswered time request is granted on its own:
33
+ # before the clock's WRAP_UP at 60, so the agent is not already
34
+ # wrapping up when the time arrives.
35
+ AUTO_AT = 120
36
+
37
+ # Seconds a rounds request waits for a person on a staying run before
38
+ # it is granted on its own.
39
+ AUTO_AFTER = 300
40
+
41
+ # Who answered a request nobody else did.
42
+ AUTO = "auto"
43
+
44
+ # One of the three choices every request offers.
45
+ #
46
+ # @!attribute [r] letter
47
+ # @return [String]
48
+ # @!attribute [r] label
49
+ # @return [String]
50
+ # @!attribute [r] seconds
51
+ # @return [Integer] extra seconds on the clock, 0 for a round alone
52
+ Choice = Data.define(:letter, :label, :seconds)
53
+
54
+ # @return [Array<Choice>]
55
+ CHOICES = [
56
+ Choice.new(letter: "a", label: "Extend Rounds by 1", seconds: 0),
57
+ Choice.new(letter: "b", label: "Extend Time by 10 minutes", seconds: 600),
58
+ Choice.new(letter: "c", label: "Extend Time by 20 minutes", seconds: 1200)
59
+ ].freeze
60
+
61
+ # The choice granted without an answer, by what ran out.
62
+ DEFAULTS = { "time" => "b", "rounds" => "a" }.freeze
63
+
64
+ # @param stay [Boolean] whether a control room can answer at all
65
+ # @param auto [Boolean] false to ask and never grant on its own
66
+ # @param now [Proc] the wall clock
67
+ def initialize(stay:, auto: true, now: -> { Clock::System.now })
68
+ @stay = stay
69
+ @auto = auto
70
+ @now = now
71
+ # Rounds used are counted in memory, from this run's start; rounds
72
+ # granted are counted from the same moment, or a restart would hand
73
+ # back every round ever granted on top of a fresh allowance.
74
+ @since = now.call
75
+ end
76
+
77
+ # A running agent whose clock is nearly out: ask once per round, and
78
+ # grant ten minutes on its own once per agent and state.
79
+ #
80
+ # @param job [#task, #state, #handle] the dispatcher's job
81
+ # @param path [String] the plan folder as it is named now
82
+ # @return [Hash, nil] the request, when there is one
83
+ def watch(job, path)
84
+ left = job.handle.remaining or return nil
85
+ return nil if left > ASK_AT
86
+
87
+ plan = Plans::PlanState.for(path)
88
+ agent = job.task.agent.name
89
+ state = job.state.to_s
90
+ request = pending(plan.extensions, agent, state) ||
91
+ unless asked?(plan.extensions, agent, state, job.task.round, "time")
92
+ plan.ask_extension!(agent:, state:, round: job.task.round, limit: "time", at: @now.call)
93
+ end
94
+ return request unless @auto && request && left <= AUTO_AT && !auto?(plan.extensions, agent, state, "live")
95
+
96
+ answer!(path, request["number"], DEFAULTS.fetch("time"), by: AUTO, live: job.handle)
97
+ end
98
+
99
+ # An agent with no rounds left on the plan's state: ask once per
100
+ # exhaustion, and grant a round on its own once per agent and state.
101
+ #
102
+ # @param path [String]
103
+ # @param agent [String]
104
+ # @param state [#to_s] the status key
105
+ # @param round [Integer] the last round it ran
106
+ # @return [Hash, nil] the request, when there is one
107
+ def exhausted(path, agent, state, round)
108
+ plan = Plans::PlanState.for(path)
109
+ state = state.to_s
110
+ all = plan.extensions
111
+ request = pending(all, agent, state) ||
112
+ unless asked?(all, agent, state, round, "rounds")
113
+ plan.ask_extension!(agent:, state:, round:, limit: "rounds", at: @now.call)
114
+ end
115
+ return request unless @auto && request && !auto?(all, agent, state, "round") && waited?(request)
116
+
117
+ answer!(path, request["number"], DEFAULTS.fetch("rounds"), by: AUTO)
118
+ end
119
+
120
+ # Settle a request with a choice. A time choice reaches a clock still
121
+ # running when +live+ is given; anything else is one more round, with
122
+ # the choice's seconds added to that round's clock.
123
+ #
124
+ # @param path [String]
125
+ # @param number [Integer]
126
+ # @param letter [String] "a", "b" or "c"
127
+ # @param by [String]
128
+ # @param live [OpenLanes::Execution::Executor::Handle, nil] the asking agent's, while it runs
129
+ # @return [Hash, nil] the request as settled; nil when it was settled already
130
+ # @raise [ArgumentError] on a letter that is not one of {CHOICES}
131
+ def answer!(path, number, letter, by:, live: nil)
132
+ choice = CHOICES.find { it.letter == letter.to_s.downcase } or
133
+ raise ArgumentError, "#{letter} is not one of #{CHOICES.map { "#{it.letter})" }.join(", ")}"
134
+ plan = Plans::PlanState.for(path)
135
+ request = plan.extensions.find { it["number"] == Integer(number) }
136
+ return nil if request.nil? || request.key?("settled_at")
137
+
138
+ # Only a time request moves a running clock: every choice on a
139
+ # rounds request is a round, even while the agent was resumed.
140
+ grant = live && request["limit"] == "time" && choice.seconds.positive? ? "live" : "round"
141
+ settled = plan.settle_extension!(number, answer: choice.letter, by:, grant:, seconds: choice.seconds, at: @now.call)
142
+ # Someone else settled it between the read above and this write.
143
+ return nil unless settled
144
+
145
+ # The clock told the agent STOP, or stopped, before the time got
146
+ # there: the time comes with a round instead of going nowhere.
147
+ settled = plan.amend_extension!(number, grant: "round") if grant == "live" && !live.extend!(choice.seconds)
148
+ UI.log("#{by == AUTO ? "auto-extended" : "extended by #{by}"}: #{request["agent"]} #{choice.label.downcase}",
149
+ plan: File.basename(path)[/\A\d{3}\.\d{2}/])
150
+ settled
151
+ end
152
+
153
+ # The rounds a person or this class has added to an agent's own.
154
+ #
155
+ # @param path [String]
156
+ # @param agent [String]
157
+ # @param state [#to_s]
158
+ # @return [Integer]
159
+ def rounds(path, agent, state)
160
+ granted(Plans::PlanState.for(path).extensions, agent, state.to_s).count { Time.iso8601(it["settled_at"]) >= @since.floor }
161
+ end
162
+
163
+ # The extra seconds a granted round runs on, handed out once.
164
+ #
165
+ # @param path [String]
166
+ # @param agent [String]
167
+ # @param state [#to_s]
168
+ # @return [Integer] 0 when the round has none
169
+ def take_seconds(path, agent, state)
170
+ plan = Plans::PlanState.for(path)
171
+ grant = granted(plan.extensions, agent, state.to_s).find { it["seconds"].to_i.positive? && !it["spent"] }
172
+ return 0 unless grant
173
+
174
+ plan.spend_extension!(grant["number"])
175
+ grant["seconds"]
176
+ end
177
+
178
+ # A request nobody needs any more: the agent's round ended, however it
179
+ # ended. A time card left open would say "running out of time" about an
180
+ # agent no longer running, and stand in for the next round's own ask.
181
+ #
182
+ # @param path [String]
183
+ # @param agent [String]
184
+ # @param state [#to_s]
185
+ # @return [void]
186
+ def close!(path, agent, state, why: "finished")
187
+ plan = Plans::PlanState.for(path)
188
+ request = pending(plan.extensions, agent, state.to_s) or return
189
+ plan.settle_extension!(request["number"], closed: why, at: @now.call)
190
+ end
191
+
192
+ class << self
193
+ # The requests a plan still has open for its state, as the page's
194
+ # `Question`s, beside the ones `blocked.md` asks.
195
+ #
196
+ # @param subject [OpenLanes::Plans::Subject]
197
+ # @param plan [OpenLanes::Plans::PlanState]
198
+ # @return [Array<Hash>]
199
+ def questions(subject, plan)
200
+ state = subject.status.key.to_s
201
+ plan.extensions.reject { it.key?("settled_at") || it["state"] != state }.map { question(subject, it) }
202
+ end
203
+
204
+ private
205
+
206
+ # @return [Hash]
207
+ def question(subject, request)
208
+ time = request["limit"] == "time"
209
+ recommended = DEFAULTS.fetch(request["limit"])
210
+ { id: "#{subject.feature.ordinal}/X#{request["number"]}",
211
+ number: request["number"],
212
+ kind: "extension",
213
+ heading: time ? "#{request["agent"]} is running out of time" : "#{request["agent"]} is out of rounds",
214
+ body: body(subject, request, time),
215
+ agent: request["agent"],
216
+ owner: "technical",
217
+ options: CHOICES.map do |choice|
218
+ { letter: choice.letter,
219
+ label: choice.label,
220
+ recommended: choice.letter == recommended,
221
+ answer: "#{choice.letter}) #{choice.label}" }
222
+ end,
223
+ held: false,
224
+ asked_at: request["asked_at"] }
225
+ end
226
+
227
+ # @return [String]
228
+ def body(subject, request, time)
229
+ if time
230
+ "Round #{request["round"]} on #{subject.status} has under #{ASK_AT / 60} minutes left on its clock. " \
231
+ "More time lets it carry on where it is; another round starts it again from its resume note."
232
+ else
233
+ "It has used every round it had on #{subject.status} without finishing. " \
234
+ "Each choice gives it one more round; the time choices also lengthen that round's clock."
235
+ end
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ # @return [Hash, nil] the agent's unsettled request on this state
242
+ def pending(all, agent, state) = all.find { it["agent"] == agent && it["state"] == state && !it.key?("settled_at") }
243
+
244
+ # @return [Array<Hash>] the requests that gave the agent a round on this state
245
+ def granted(all, agent, state) = all.select { it["grant"] == "round" && it["agent"] == agent && it["state"] == state }
246
+
247
+ # A time request answered with time on the running clock does not
248
+ # count: when that time runs low too, the round asks again, so a
249
+ # person can add to an automatic grant.
250
+ #
251
+ # @return [Boolean] whether this round already asked about this limit
252
+ def asked?(all, agent, state, round, limit)
253
+ all.any? do |r|
254
+ r["agent"] == agent && r["state"] == state && r["round"] == round && r["limit"] == limit && r["grant"] != "live"
255
+ end
256
+ end
257
+
258
+ # @return [Boolean] whether the one grant of this kind nobody answered is spent
259
+ def auto?(all, agent, state, grant)
260
+ all.any? { it["agent"] == agent && it["state"] == state && it["by"] == AUTO && it["grant"] == grant }
261
+ end
262
+
263
+ # @return [Boolean] whether a rounds request has waited long enough for a person
264
+ def waited?(request)
265
+ return true unless @stay
266
+
267
+ @now.call - Time.iso8601(request["asked_at"]) >= AUTO_AFTER
268
+ end
269
+ end
270
+ end
271
+ end