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,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "semantic_logger"
5
+
6
+ module OpenLanes
7
+ # The one place a process sets SemanticLogger up, so the engine and the
8
+ # control room's server write the same file the same way.
9
+ #
10
+ # `openlanes run` and `openlanes start` log as `engine`; the server they
11
+ # spawn, and `openlanes web` alone, log as `web`. Both append to
12
+ # `<project root>/log/development.log` unless told otherwise, and the
13
+ # engine hands its server the file, level and format through {Settings#env},
14
+ # so a `--log` typed on `run` moves both processes rather than one.
15
+ #
16
+ # Two processes share one file safely because each opens it in append mode
17
+ # and writes a whole line per call: the kernel puts every O_APPEND write at
18
+ # the end, so lines from the two interleave but never tear. Each line carries
19
+ # its process id, which is how a reader tells them apart.
20
+ #
21
+ # Logging is synchronous. SemanticLogger's default background thread keeps
22
+ # its queue in memory, and a CLI that exits, is killed by its own run, or is
23
+ # TERMed by {Web::Launcher#stop} would take the last lines with it.
24
+ #
25
+ # @example
26
+ # Log.setup(Log::Settings.from(options, root:), name: "engine")
27
+ # Log.logger.info("started", plan: "003.00")
28
+ module Log
29
+ # Where both processes log unless `--log` says otherwise, relative to the
30
+ # project root.
31
+ DEFAULT_FILE = File.join("log", "development.log")
32
+
33
+ # `--log -`: the log goes to STDOUT instead of a file.
34
+ STDOUT_FILE = "-"
35
+
36
+ # Lowest first. Anything below the chosen level is not written.
37
+ LEVELS = %w[debug info warn error fatal].freeze
38
+
39
+ # The level a run logs at when nothing says otherwise.
40
+ DEFAULT_LEVEL = "info"
41
+
42
+ # SemanticLogger's colour formatter kept to one line per entry, as
43
+ # `standard` is: the stock one writes an exception's backtrace over many
44
+ # lines, which `tail -F` in the mprocs `logs` pane would scatter between
45
+ # the other process's lines.
46
+ class OneLineColor < SemanticLogger::Formatters::Color
47
+ # @return [String, nil]
48
+ def message = ("-- #{log.message.delete("\n")}" if log.message)
49
+
50
+ # @return [String, nil]
51
+ def exception
52
+ return unless log.exception
53
+
54
+ "-- Exception: #{color}#{log.exception.class}: #{log.exception.message.delete("\n")}#{color_map.clear}"
55
+ end
56
+ end
57
+
58
+ # `--log-format` to SemanticLogger's formatter. `standard` is the one-line
59
+ # formatter rather than the default one, which writes a stack trace over
60
+ # many lines and breaks "one line per entry"; `color` is the same line in
61
+ # colour, by level, for a person reading the file with `tail -F` or `less -R`.
62
+ FORMATS = { "color" => OneLineColor, "standard" => :one_line, "json" => :json }.freeze
63
+
64
+ # The format a run logs in when nothing says otherwise. Plain, so a log
65
+ # written in CI or by any project using the gem greps without first
66
+ # stripping escape codes; `just start` asks for `--log-format color`.
67
+ DEFAULT_FORMAT = "standard"
68
+
69
+ # How the engine tells the server it spawns where to log. The environment
70
+ # rather than argv, for the same reason as the Redis URL: the server's
71
+ # command line stays the same whatever the run was told.
72
+ ENV_FILE = "OPENLANES_LOG"
73
+ ENV_LEVEL = "OPENLANES_LOG_LEVEL"
74
+ ENV_FORMAT = "OPENLANES_LOG_FORMAT"
75
+
76
+ # The password in a URL's userinfo, wherever the URL sits in a line: a
77
+ # git or Redis error, or an agent's failure reason, can quote a remote
78
+ # with its credentials, and the log outlives the run. The same rule as
79
+ # {Live.mask}, which only looks at a string that is one URL.
80
+ CREDENTIALS = %r{([a-z][a-z0-9+.-]*://[^:@/\s]*):[^@/\s]+@}i
81
+
82
+ # The logger a process uses when {.setup} was never told its name.
83
+ DEFAULT_NAME = "openlanes"
84
+
85
+ # A level or format nobody knows. Raised before anything is opened, so a
86
+ # typo is a refusal rather than a run that silently logs at the default.
87
+ class Invalid < ArgumentError; end
88
+
89
+ # Where a process logs, at what level, in which format.
90
+ #
91
+ # @!attribute [r] file
92
+ # @return [String] an absolute path, or {STDOUT_FILE}
93
+ # @!attribute [r] level
94
+ # @return [String] one of {LEVELS}
95
+ # @!attribute [r] format
96
+ # @return [String] one of the keys of {FORMATS}
97
+ Settings = Data.define(:file, :level, :format) do
98
+ # Flags first, then the environment the engine handed its server, then
99
+ # the defaults. A relative file is taken from the project root, not the
100
+ # directory the command was typed in, so `log/development.log` is the
101
+ # same file for a run typed three directories down.
102
+ #
103
+ # @param options [Hash] the command's options: `:log`, `:log_level`, `:log_format`
104
+ # @param root [String] the project root
105
+ # @param env [Hash]
106
+ # @return [Settings]
107
+ # @raise [Invalid] for a level or format that is not one of the known ones
108
+ def self.from(options = {}, root:, env: ENV)
109
+ pick = ->(key, name) { [options[key], env[name]].map { it.to_s.strip }.find { !it.empty? } }
110
+ file = pick.call(:log, ENV_FILE) || DEFAULT_FILE
111
+ level = (pick.call(:log_level, ENV_LEVEL) || DEFAULT_LEVEL).downcase
112
+ format = (pick.call(:log_format, ENV_FORMAT) || DEFAULT_FORMAT).downcase
113
+ raise Invalid, "--log-level #{level}: use one of #{LEVELS.join(", ")}" unless LEVELS.include?(level)
114
+ raise Invalid, "--log-format #{format}: use one of #{FORMATS.keys.join(", ")}" unless FORMATS.key?(format)
115
+
116
+ file = File.expand_path(file, root) unless file == STDOUT_FILE
117
+ new(file:, level:, format:)
118
+ end
119
+
120
+ # @return [Boolean] whether the log goes to STDOUT rather than a file
121
+ def stdout? = file == STDOUT_FILE
122
+
123
+ # @return [Hash{String => String}] for a child process's environment
124
+ def env = { ENV_FILE => file, ENV_LEVEL => level, ENV_FORMAT => format }
125
+ end
126
+
127
+ class << self
128
+ # The settings {.setup} was last given, nil before then.
129
+ #
130
+ # @return [Settings, nil]
131
+ attr_reader :settings
132
+
133
+ # Point SemanticLogger at the one file (or STDOUT), replacing whatever
134
+ # it wrote to before, so calling this twice in one process (the suite
135
+ # does) never leaves two appenders writing every line twice.
136
+ #
137
+ # @param settings [Settings]
138
+ # @param name [String] what this process's lines are signed with,
139
+ # `engine` or `web`
140
+ # @return [Settings]
141
+ def setup(settings, name: DEFAULT_NAME)
142
+ SemanticLogger.sync!
143
+ SemanticLogger.clear_appenders!
144
+ SemanticLogger.application = "openlanes"
145
+ SemanticLogger.default_level = settings.level.to_sym
146
+ formatter = FORMATS.fetch(settings.format)
147
+ formatter = formatter.new if formatter.is_a?(Class)
148
+ if settings.stdout?
149
+ SemanticLogger.add_appender(io: $stdout, formatter:)
150
+ else
151
+ FileUtils.mkdir_p(File.dirname(settings.file))
152
+ SemanticLogger.add_appender(file_name: settings.file, formatter:)
153
+ end
154
+ @name = name
155
+ @settings = settings
156
+ end
157
+
158
+ # @return [Boolean] whether {.setup} has run in this process
159
+ def configured? = !@settings.nil?
160
+
161
+ # The file lines go to, or nil when they go nowhere or to STDOUT.
162
+ #
163
+ # @return [String, nil]
164
+ def path = settings&.then { it.stdout? ? nil : it.file }
165
+
166
+ # @param name [String, nil] another name than this process's own
167
+ # @return [SemanticLogger::Logger]
168
+ def logger(name = nil) = SemanticLogger[name || @name || DEFAULT_NAME]
169
+
170
+ # One {UI.log} line, at +level+, with its fields as the payload. A
171
+ # no-op until {.setup} has run: commands that never log (`list-plans`,
172
+ # `docs`) still call code that reports through {UI.log}.
173
+ #
174
+ # @param message [String]
175
+ # @param level [Symbol] one of {LEVELS}
176
+ # @param fields [Hash] plan, status, agent, round, seconds and the like
177
+ # @return [void]
178
+ def event(message, level: :info, **fields)
179
+ return unless configured?
180
+
181
+ payload = fields.compact.transform_values do |value|
182
+ case value
183
+ when Float then value.round(1)
184
+ when String then redact(value)
185
+ else value
186
+ end
187
+ end
188
+ logger.public_send(level, redact(message.to_s), payload.empty? ? nil : payload)
189
+ end
190
+
191
+ # @param text [String]
192
+ # @return [String] +text+ with every URL password replaced by `***`
193
+ def redact(text) = text.gsub(CREDENTIALS, '\1:***@')
194
+
195
+ # Forget the setup and stop writing. For the suite, which sets logging
196
+ # up in a temp directory and must not leave an appender writing there.
197
+ #
198
+ # @return [void]
199
+ def reset!
200
+ SemanticLogger.clear_appenders!
201
+ @settings = nil
202
+ @name = nil
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # Why a workflow would not do what it was asked, and the exit status that
5
+ # says so. Every command workflow fails with one of these inside a
6
+ # `Dry::Monads::Failure`, so the command that called it refuses the same
7
+ # way whichever workflow said no. The statuses follow sysexits, as
8
+ # {CLI::Base#refuse} documents.
9
+ #
10
+ # @!attribute [r] message
11
+ # @return [String] what the person reads in the error box
12
+ # @!attribute [r] status
13
+ # @return [Integer] the exit status
14
+ Refusal = Data.define(:message, :status)
15
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module OpenLanes
7
+ # Where a project's runtime files live: outside `.plans/`, under the XDG
8
+ # state directory. A lock or a run record inside `.plans/` sat beside
9
+ # committed documents, so every `git status` and every PR snapshot had to
10
+ # learn to ignore it, and a rename of a locked folder moved the lock with it.
11
+ #
12
+ # Keyed by the project root (the directory that holds `.plans/`), so two
13
+ # checkouts never share a state directory, and the harness and an agent
14
+ # handed the same `--dir` address the same one.
15
+ module Runtime
16
+ module_function
17
+
18
+ # @param env [Hash] the environment to read
19
+ # @return [String] absolute `XDG_STATE_HOME`, else `~/.local/state`. An
20
+ # empty or relative value is ignored, as the XDG spec says.
21
+ def state_home(env = ENV)
22
+ value = env["XDG_STATE_HOME"].to_s
23
+ return value if !value.empty? && value.start_with?("/")
24
+
25
+ File.join(Dir.home, ".local", "state")
26
+ end
27
+
28
+ # @param root [String] the directory that holds `.plans/`
29
+ # @return [String] `<state_home>/openlanes/<basename>-<6 hex>`, created on demand
30
+ def dir(root:)
31
+ real = begin
32
+ File.realpath(root)
33
+ rescue Errno::ENOENT
34
+ File.expand_path(root)
35
+ end
36
+ path = File.join(state_home, "openlanes", "#{File.basename(real)}-#{Digest::SHA256.hexdigest(real)[0, 6]}")
37
+ FileUtils.mkdir_p(path)
38
+ path
39
+ end
40
+
41
+ # @param root [String]
42
+ # @return [String] the directory plan locks are written to
43
+ def locks_dir(root:)
44
+ path = File.join(dir(root:), "locks")
45
+ FileUtils.mkdir_p(path)
46
+ path
47
+ end
48
+
49
+ # @param root [String]
50
+ # @return [String] the directory each run's {Engine::EventJournal} is appended to
51
+ def journal_dir(root:)
52
+ path = File.join(dir(root:), "journal")
53
+ FileUtils.mkdir_p(path)
54
+ path
55
+ end
56
+
57
+ # @param root [String]
58
+ # @return [String] the run's {Engine::StateFile}
59
+ def state_file(root:) = File.join(dir(root:), "openlanes-state.json")
60
+ end
61
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # The one place a child process is started, so a spec can start none.
5
+ #
6
+ # Named `Spawner`, not `Process`: Zeitwerk would resolve an
7
+ # `OpenLanes::Process` ahead of `::Process` at every bare `Process.kill`
8
+ # and `Process.wait` under `lib/openlanes`.
9
+ module Spawner
10
+ # Starts real processes.
11
+ module System
12
+ module_function
13
+
14
+ # @param env [Hash{String => String}]
15
+ # @param argv [Array<String>]
16
+ # @param options [Hash] as `Process.spawn` takes them
17
+ # @return [Integer] the pid
18
+ def spawn(env, *argv, **) = ::Process.spawn(env, *argv, **)
19
+ end
20
+
21
+ # Records what would have been started and hands back scripted pids.
22
+ class Fake
23
+ # One recorded call.
24
+ Call = Data.define(:env, :argv, :options)
25
+
26
+ # @return [Array<Call>]
27
+ def calls = @mutex.synchronize { @calls.dup }
28
+
29
+ # @param pids [Array<Integer>] handed out in order; a counter from
30
+ # `first_pid` once they run out
31
+ # @param first_pid [Integer]
32
+ def initialize(pids: [], first_pid: 90_000)
33
+ @pids = pids.dup
34
+ @next = first_pid
35
+ @calls = []
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ # @param env [Hash{String => String}]
40
+ # @param argv [Array<String>]
41
+ # @param options [Hash]
42
+ # @return [Integer]
43
+ def spawn(env, *argv, **options)
44
+ @mutex.synchronize do
45
+ @calls << Call.new(env:, argv:, options:)
46
+ @pids.shift || (@next += 1)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "concurrent/hash"
4
4
  require "etc"
5
- require "fileutils"
6
5
  require "dry/cli/ui"
7
6
  require "pastel"
8
7
  require "stringio"
@@ -97,8 +96,9 @@ module OpenLanes
97
96
  def alive = "#{seconds.round}s"
98
97
 
99
98
  # @param message [String]
99
+ # @param level [Symbol] the entry's level in the log
100
100
  # @return [void]
101
- def note(message) = UI.log(message, **@fields, seconds:)
101
+ def note(message, level: :info) = UI.log(message, level:, **@fields, seconds:)
102
102
 
103
103
  # @return [void]
104
104
  def start
@@ -182,7 +182,7 @@ module OpenLanes
182
182
  def failed(reason)
183
183
  stop_ticker
184
184
  @handle&.fail(reason)
185
- note("failed after #{alive}: #{reason}")
185
+ note("failed after #{alive}: #{reason}", level: :warn)
186
186
  end
187
187
 
188
188
  # @param update [OpenLanes::Execution::Transcript::Progress]
@@ -219,6 +219,10 @@ module OpenLanes
219
219
  end
220
220
  end
221
221
 
222
+ # Serialises {.report_line} across worker threads. Created here, once:
223
+ # a lazy `||=` let two threads that arrived together each make their own.
224
+ @print_mutex = Mutex.new
225
+
222
226
  class << self
223
227
  # Set by the CLI's --quiet. Silences spinners and bars along with
224
228
  # everything else, so a quiet run really is quiet.
@@ -240,16 +244,16 @@ module OpenLanes
240
244
  def reset!
241
245
  remove_instance_variable(:@pastel) if instance_variable_defined?(:@pastel)
242
246
  self.quiet = false
243
- self.log_path = nil
244
247
  end
245
248
 
246
- # Where {.log} appends to, if anywhere. nil (the default) means nowhere;
247
- # `openlanes run` sets this before the loop starts, so a round
248
- # started under a tool that discards STDERR — an agent's own Bash call,
249
- # for instance — still leaves something to `tail -f`.
249
+ # Where {.log} writes, if anywhere: the file {Log.setup} was given
250
+ # (`-` for STDOUT), nil before then. `openlanes run` sets logging up
251
+ # before the loop starts, so a round started under a tool that discards
252
+ # STDERR (an agent's own Bash call, for instance) still leaves
253
+ # something to `tail -f`.
250
254
  #
251
255
  # @return [String, nil]
252
- attr_accessor :log_path
256
+ def log_path = Log.settings&.file
253
257
 
254
258
  # Something else that hears every {.log} line, as it is logged: the
255
259
  # live board keeps the latest few for the page. Taps are called on the
@@ -271,20 +275,18 @@ module OpenLanes
271
275
  # @return [Array<#call>]
272
276
  def log_taps = @log_taps || []
273
277
 
274
- # Append one timestamped line to {.log_path}. A no-op with nothing set.
275
- # Safe to call from several threads at once.
278
+ # Tell every tap, then write one entry to the log {Log.setup} opened,
279
+ # with the fields as its payload. The file is in addition to what the
280
+ # taps draw, never instead of it. Safe to call from several threads at once.
276
281
  #
277
282
  # @param message [String]
283
+ # @param level [Symbol] the entry's level in the log; taps hear every
284
+ # level alike, since the terminal has no levels to filter by
285
+ # @param fields [Hash] plan, status, agent, round, seconds, to
278
286
  # @return [void]
279
- def log(message, **fields)
287
+ def log(message, level: :info, **fields)
280
288
  log_taps.each { |tap| tap.call(message, fields) }
281
- path = log_path or return
282
-
283
- line = Engine::ProgressLog.render(message, **fields)
284
- (@log_mutex ||= Mutex.new).synchronize do
285
- FileUtils.mkdir_p(File.dirname(path))
286
- File.open(path, "a") { |f| f.puts(line) }
287
- end
289
+ Log.event(message, level:, **fields)
288
290
  end
289
291
 
290
292
  # Cells the meter takes on a spinner line, per direction.
@@ -549,7 +551,7 @@ module OpenLanes
549
551
  def report_line(text, bullet: "·")
550
552
  return if quiet
551
553
 
552
- (@print_mutex ||= Mutex.new).synchronize { line(text, bullet:) }
554
+ @print_mutex.synchronize { line(text, bullet:) }
553
555
  end
554
556
 
555
557
  # A sensible worker count: agents are mostly waiting on a model rather
@@ -573,9 +575,25 @@ module OpenLanes
573
575
  # @return [String] decorated when colour is on, bare otherwise
574
576
  def paint(text, *styles) = color? ? pastel.decorate(text.to_s, *styles) : text.to_s
575
577
 
578
+ # The emoji variation selector U+FE0F, or a zero-width joiner U+200D.
579
+ EMOJI_SEQUENCE = /[\u{FE0F}\u{200D}]/
580
+
581
+ # `unicode-display_width` counts 🅱️, ☢️ and 🕰️ as one cell, while
582
+ # terminals with Unicode 9 widths draw them two, and every padded column
583
+ # came out one cell short (decision N2). A character the text explicitly
584
+ # asks to see as an emoji is two cells here, whatever the gem says.
585
+ # Measured per grapheme, so a ZWJ sequence such as 🏳️‍🌈, which the gem
586
+ # counts as three and which carries its selector mid-sequence, is the
587
+ # two cells a terminal draws, not four.
588
+ #
576
589
  # @param text [String]
577
590
  # @return [Integer] how many terminal cells the text occupies
578
- def display_width(text) = ::Unicode::DisplayWidth.of(text.to_s)
591
+ def display_width(text)
592
+ text = text.to_s
593
+ return ::Unicode::DisplayWidth.of(text) unless text.match?(EMOJI_SEQUENCE)
594
+
595
+ text.each_grapheme_cluster.sum { it.match?(EMOJI_SEQUENCE) ? 2 : ::Unicode::DisplayWidth.of(it) }
596
+ end
579
597
 
580
598
  # Pad or truncate to an exact number of terminal *cells*.
581
599
  #
@@ -612,6 +630,77 @@ module OpenLanes
612
630
  # @return [void]
613
631
  def box(kind, message) = console.public_send(kind, message.to_s)
614
632
 
633
+ # Labelled lines for a box, the labels padded to one width:
634
+ #
635
+ # Progress: /var/folders/…/openlanes-minesweeper.log
636
+ # Live board: redis://host:6379 as kig@host
637
+ # (c016…:734c…)
638
+ #
639
+ # A value given as several parts is joined with spaces while it fits
640
+ # the box, and each part that does not starts a continuation line
641
+ # under the values, so a long id breaks where it was meant to rather
642
+ # than wherever the box's own wrap runs out of room.
643
+ #
644
+ # @param rows [Array<Array(String, String | Array<String>)>] label and value
645
+ # @param room [Integer] text columns inside the box
646
+ # @return [String]
647
+ def labelled(rows, room: box_room)
648
+ pad = rows.map { |label, _| display_width("#{label}:") }.max.to_i + 1
649
+ free = [room - pad, 1].max
650
+ rows.flat_map { |label, parts|
651
+ lines = Array(parts).map(&:to_s).each_with_object([]) do |part, acc|
652
+ if acc.any? && display_width("#{acc.last} #{part}") <= free
653
+ acc[-1] = "#{acc.last} #{part}"
654
+ else
655
+ acc.concat(broken(part, free))
656
+ end
657
+ end
658
+ lines.each_with_index.map { |text, i| (i.zero? ? fit("#{label}:", pad) : " " * pad) + text }
659
+ }.join("\n")
660
+ end
661
+
662
+ # A value wider than its column, cut into pieces that fit: after a
663
+ # `/` where it can be, so a path breaks between directories, and
664
+ # anywhere when one name is wider than the column on its own. Left
665
+ # to the box, a path wider than the room after its label wrapped
666
+ # onto the next line whole, under the label rather than beside it,
667
+ # and was then cut mid-name with no indent.
668
+ #
669
+ # @param text [String]
670
+ # @param free [Integer] columns
671
+ # @return [Array<String>] the pieces, each at most +free+ wide
672
+ def broken(text, free)
673
+ return [text] if display_width(text) <= free
674
+
675
+ text.split(%r{(?<=/)}).flat_map { cells(it, free) }.each_with_object([+""]) do |piece, acc|
676
+ acc.last.empty? || display_width(acc.last + piece) <= free ? acc.last << piece : acc << piece.dup
677
+ end
678
+ end
679
+
680
+ # A piece cut by terminal cells rather than characters, one grapheme
681
+ # cluster at a time, so a wide name such as `界界界` is never cut into
682
+ # a chunk wider than +free+ (nor a combining mark off its letter).
683
+ #
684
+ # @param text [String]
685
+ # @param free [Integer] columns
686
+ # @return [Array<String>]
687
+ def cells(text, free)
688
+ text.each_grapheme_cluster.with_object([+""]) do |grapheme, acc|
689
+ acc << +"" unless acc.last.empty? || display_width(acc.last + grapheme) <= free
690
+ acc.last << grapheme
691
+ end
692
+ end
693
+
694
+ # Text columns inside a box, as dry-cli-ui works them out: the box is
695
+ # {.width} or the terminal less two, whichever is narrower, and a
696
+ # border and two columns of padding go on either side.
697
+ #
698
+ # @return [Integer]
699
+ def box_room
700
+ terminal = tty? ? TTY::Screen.width : Dry::CLI::UI::Terminal::DEFAULT_WIDTH
701
+ [width, terminal - 2].min - 6
702
+ end
703
+
615
704
  # A framed panel centered on the screen, for the keyboard help. Unlike
616
705
  # {.box} it positions itself absolutely, so it overlays whatever the
617
706
  # spinners are drawing rather than scrolling in below them — the next
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Vcs
5
+ # One word for a pull request's CI, from `gh pr checks` buckets (M3).
6
+ module CiGate
7
+ # Buckets that say nothing about the code: a skipped job is not a verdict.
8
+ NEUTRAL = %w[pass skipping].freeze
9
+
10
+ module_function
11
+
12
+ # An unknown bucket counts as red (open question 5): a state `gh` grows
13
+ # later must not wave a passing label through.
14
+ #
15
+ # @param checks [Array<Hash>] `{name:, bucket:}`, as {GitHub#checks} returns
16
+ # @return [Symbol] `:green`, `:red`, `:pending`, or `:none` when no
17
+ # check ran
18
+ def verdict(checks)
19
+ buckets = checks.map { |c| c[:bucket].to_s }
20
+ # Every job skipped means CI did not run (a spent Actions budget can
21
+ # report it this way), which is the same as no checks, not a pass.
22
+ return :none if buckets.all?("skipping")
23
+
24
+ return :red if buckets.any? { |b| b != "pending" && !NEUTRAL.include?(b) }
25
+ return :pending if buckets.include?("pending")
26
+
27
+ :green
28
+ end
29
+ end
30
+ end
31
+ end