openlanes 3.0.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 (466) hide show
  1. checksums.yaml +7 -0
  2. data/agents/hansolo-reviewer.md +59 -0
  3. data/agents/lando-broker.md +70 -0
  4. data/agents/leah-researcher.md +60 -0
  5. data/agents/luke-backend.md +73 -0
  6. data/agents/palpatine-planner.md +61 -0
  7. data/agents/r2d2-mechanic.md +57 -0
  8. data/agents/rey-frontend.md +68 -0
  9. data/agents/yoda-writer.md +67 -0
  10. data/bin/factory-e2e +157 -0
  11. data/bin/plan-number +164 -0
  12. data/bin/setup +26 -0
  13. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/plan.md +3 -0
  14. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/pull-requests.md +12 -0
  15. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/spec.md +5 -0
  16. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/lib/cart.rb +9 -0
  17. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/spec/cart_spec.rb +7 -0
  18. data/evals/cases/hansolo-reviewer/review-medium/fail/run.json +1 -0
  19. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/plan.md +3 -0
  20. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/pull-requests.md +15 -0
  21. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/spec.md +5 -0
  22. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/lib/cart.rb +9 -0
  23. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/spec/cart_spec.rb +7 -0
  24. data/evals/cases/hansolo-reviewer/review-medium/pass/run.json +1 -0
  25. data/evals/cases/hansolo-reviewer/review-medium.yml +60 -0
  26. data/evals/cases/lando-broker/unblock-fast/fail/plan/blocked.md +9 -0
  27. data/evals/cases/lando-broker/unblock-fast/fail/plan/spec.md +16 -0
  28. data/evals/cases/lando-broker/unblock-fast/fail/run.json +1 -0
  29. data/evals/cases/lando-broker/unblock-fast/pass/plan/spec.md +18 -0
  30. data/evals/cases/lando-broker/unblock-fast/pass/run.json +1 -0
  31. data/evals/cases/lando-broker/unblock-fast.yml +44 -0
  32. data/evals/cases/leah-researcher/research-deep/fail/plan/spec.md +26 -0
  33. data/evals/cases/leah-researcher/research-deep/fail/run.json +1 -0
  34. data/evals/cases/leah-researcher/research-deep/pass/plan/spec.md +39 -0
  35. data/evals/cases/leah-researcher/research-deep/pass/run.json +1 -0
  36. data/evals/cases/leah-researcher/research-deep.yml +51 -0
  37. data/evals/cases/leah-researcher/research-fast/fail/plan/spec.md +27 -0
  38. data/evals/cases/leah-researcher/research-fast/fail/run.json +1 -0
  39. data/evals/cases/leah-researcher/research-fast/pass/plan/spec.md +23 -0
  40. data/evals/cases/leah-researcher/research-fast/pass/run.json +1 -0
  41. data/evals/cases/leah-researcher/research-fast.yml +55 -0
  42. data/evals/cases/luke-backend/build-medium/fail/plan/plan-backend.md +7 -0
  43. data/evals/cases/luke-backend/build-medium/fail/plan/plan-frontend.md +1 -0
  44. data/evals/cases/luke-backend/build-medium/fail/plan/plan.md +3 -0
  45. data/evals/cases/luke-backend/build-medium/fail/plan/pull-requests.md +8 -0
  46. data/evals/cases/luke-backend/build-medium/fail/plan/spec.md +8 -0
  47. data/evals/cases/luke-backend/build-medium/fail/repo/Gemfile +2 -0
  48. data/evals/cases/luke-backend/build-medium/fail/repo/lib/cart.rb +11 -0
  49. data/evals/cases/luke-backend/build-medium/fail/repo/spec/cart_spec.rb +7 -0
  50. data/evals/cases/luke-backend/build-medium/fail/run.json +1 -0
  51. data/evals/cases/luke-backend/build-medium/pass/plan/plan-backend.md +7 -0
  52. data/evals/cases/luke-backend/build-medium/pass/plan/plan-frontend.md +1 -0
  53. data/evals/cases/luke-backend/build-medium/pass/plan/plan.md +3 -0
  54. data/evals/cases/luke-backend/build-medium/pass/plan/pull-requests.md +8 -0
  55. data/evals/cases/luke-backend/build-medium/pass/plan/spec.md +8 -0
  56. data/evals/cases/luke-backend/build-medium/pass/repo/Gemfile +2 -0
  57. data/evals/cases/luke-backend/build-medium/pass/repo/lib/cart.rb +11 -0
  58. data/evals/cases/luke-backend/build-medium/pass/repo/spec/cart_spec.rb +11 -0
  59. data/evals/cases/luke-backend/build-medium/pass/run.json +1 -0
  60. data/evals/cases/luke-backend/build-medium.yml +68 -0
  61. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan-backend.md +6 -0
  62. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +8 -0
  63. data/evals/cases/palpatine-planner/plan-deep/fail/plan/spec.md +14 -0
  64. data/evals/cases/palpatine-planner/plan-deep/fail/run.json +1 -0
  65. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-backend.md +32 -0
  66. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-frontend.md +1 -0
  67. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan.md +28 -0
  68. data/evals/cases/palpatine-planner/plan-deep/pass/plan/spec.md +14 -0
  69. data/evals/cases/palpatine-planner/plan-deep/pass/run.json +1 -0
  70. data/evals/cases/palpatine-planner/plan-deep.yml +50 -0
  71. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-backend.md +48 -0
  72. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-frontend.md +1 -0
  73. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +12 -0
  74. data/evals/cases/palpatine-planner/plan-fast/fail/plan/spec.md +12 -0
  75. data/evals/cases/palpatine-planner/plan-fast/fail/run.json +1 -0
  76. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-backend.md +8 -0
  77. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-frontend.md +1 -0
  78. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan.md +12 -0
  79. data/evals/cases/palpatine-planner/plan-fast/pass/plan/spec.md +12 -0
  80. data/evals/cases/palpatine-planner/plan-fast/pass/run.json +1 -0
  81. data/evals/cases/palpatine-planner/plan-fast.yml +47 -0
  82. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/plan.md +7 -0
  83. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/pull-requests.md +6 -0
  84. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/spec.md +6 -0
  85. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/README.md +3 -0
  86. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter/formatter.rb +12 -0
  87. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter.rb +15 -0
  88. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/spec/greeter_spec.rb +7 -0
  89. data/evals/cases/r2d2-mechanic/task-fast/fail/run.json +1 -0
  90. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/plan.md +7 -0
  91. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/pull-requests.md +6 -0
  92. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/spec.md +6 -0
  93. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/README.md +3 -0
  94. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/lib/greeter.rb +3 -0
  95. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/spec/greeter_spec.rb +7 -0
  96. data/evals/cases/r2d2-mechanic/task-fast/pass/run.json +1 -0
  97. data/evals/cases/r2d2-mechanic/task-fast.yml +59 -0
  98. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/plan.md +7 -0
  99. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/pull-requests.md +6 -0
  100. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/spec.md +6 -0
  101. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/Gemfile +2 -0
  102. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/lib/greeter.rb +6 -0
  103. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/spec/greeter_spec.rb +11 -0
  104. data/evals/cases/r2d2-mechanic/task-medium/fail/run.json +1 -0
  105. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/plan.md +7 -0
  106. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/pull-requests.md +6 -0
  107. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/spec.md +6 -0
  108. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/Gemfile +2 -0
  109. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/lib/greeter.rb +6 -0
  110. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/spec/greeter_spec.rb +11 -0
  111. data/evals/cases/r2d2-mechanic/task-medium/pass/run.json +1 -0
  112. data/evals/cases/r2d2-mechanic/task-medium.yml +58 -0
  113. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan-frontend.md +7 -0
  114. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan.md +4 -0
  115. data/evals/cases/rey-frontend/ui-medium/fail/plan/pull-requests.md +8 -0
  116. data/evals/cases/rey-frontend/ui-medium/fail/plan/spec.md +5 -0
  117. data/evals/cases/rey-frontend/ui-medium/fail/repo/lib/cart.rb +5 -0
  118. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.js +4 -0
  119. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.test.js +3 -0
  120. data/evals/cases/rey-frontend/ui-medium/fail/run.json +1 -0
  121. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan-frontend.md +7 -0
  122. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan.md +4 -0
  123. data/evals/cases/rey-frontend/ui-medium/pass/plan/pull-requests.md +8 -0
  124. data/evals/cases/rey-frontend/ui-medium/pass/plan/spec.md +5 -0
  125. data/evals/cases/rey-frontend/ui-medium/pass/repo/lib/cart.rb +3 -0
  126. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.js +4 -0
  127. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.test.js +3 -0
  128. data/evals/cases/rey-frontend/ui-medium/pass/run.json +1 -0
  129. data/evals/cases/rey-frontend/ui-medium.yml +55 -0
  130. data/evals/cases/yoda-writer/spec-medium/fail/plan/plan.md +5 -0
  131. data/evals/cases/yoda-writer/spec-medium/fail/plan/spec.md +45 -0
  132. data/evals/cases/yoda-writer/spec-medium/fail/run.json +1 -0
  133. data/evals/cases/yoda-writer/spec-medium/pass/plan/plan.md +0 -0
  134. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +63 -0
  135. data/evals/cases/yoda-writer/spec-medium/pass/run.json +1 -0
  136. data/evals/cases/yoda-writer/spec-medium.yml +40 -0
  137. data/exe/depot +1 -0
  138. data/exe/lanes +1 -0
  139. data/exe/openlanes +32 -0
  140. data/lib/openlanes/adapters/base.rb +69 -0
  141. data/lib/openlanes/adapters/claude.rb +124 -0
  142. data/lib/openlanes/adapters/codex.rb +33 -0
  143. data/lib/openlanes/adapters/model.rb +37 -0
  144. data/lib/openlanes/adapters/pi.rb +22 -0
  145. data/lib/openlanes/adapters/stream.rb +196 -0
  146. data/lib/openlanes/adapters.rb +92 -0
  147. data/lib/openlanes/agents/agent.rb +103 -0
  148. data/lib/openlanes/agents/profile.rb +57 -0
  149. data/lib/openlanes/agents/registry.rb +151 -0
  150. data/lib/openlanes/agents/roster.rb +103 -0
  151. data/lib/openlanes/agents/routing.rb +88 -0
  152. data/lib/openlanes/agents.rb +20 -0
  153. data/lib/openlanes/cli/agents/subcommands/describe.rb +66 -0
  154. data/lib/openlanes/cli/agents/subcommands/list.rb +20 -0
  155. data/lib/openlanes/cli/base.rb +263 -0
  156. data/lib/openlanes/cli/create/create.rb +354 -0
  157. data/lib/openlanes/cli/docs/docs.rb +40 -0
  158. data/lib/openlanes/cli/eval/eval.rb +110 -0
  159. data/lib/openlanes/cli/index/index.rb +40 -0
  160. data/lib/openlanes/cli/init/init.rb +145 -0
  161. data/lib/openlanes/cli/linear/linear.rb +36 -0
  162. data/lib/openlanes/cli/linear/subcommands/import.rb +174 -0
  163. data/lib/openlanes/cli/linear/subcommands/projects.rb +55 -0
  164. data/lib/openlanes/cli/list_plans/list_plans.rb +21 -0
  165. data/lib/openlanes/cli/mail/mail.rb +31 -0
  166. data/lib/openlanes/cli/mail/subcommands/read.rb +43 -0
  167. data/lib/openlanes/cli/mail/subcommands/send.rb +43 -0
  168. data/lib/openlanes/cli/resync/subcommands/dirs.rb +48 -0
  169. data/lib/openlanes/cli/resync/subcommands/prs.rb +111 -0
  170. data/lib/openlanes/cli/run/run.rb +544 -0
  171. data/lib/openlanes/cli/skills/subcommands/install.rb +92 -0
  172. data/lib/openlanes/cli/start/start.rb +110 -0
  173. data/lib/openlanes/cli/state/state.rb +26 -0
  174. data/lib/openlanes/cli/state/subcommands/show.rb +30 -0
  175. data/lib/openlanes/cli/state/subcommands/sign.rb +48 -0
  176. data/lib/openlanes/cli/states/states.rb +15 -0
  177. data/lib/openlanes/cli/unblock/unblock.rb +235 -0
  178. data/lib/openlanes/cli/version/version.rb +13 -0
  179. data/lib/openlanes/cli/web/web.rb +100 -0
  180. data/lib/openlanes/cli/worktree/worktree.rb +85 -0
  181. data/lib/openlanes/cli.rb +162 -0
  182. data/lib/openlanes/engine/board.rb +104 -0
  183. data/lib/openlanes/engine/dispatcher.rb +1297 -0
  184. data/lib/openlanes/engine/progress_log.rb +125 -0
  185. data/lib/openlanes/engine/runner.rb +224 -0
  186. data/lib/openlanes/engine/state_file.rb +190 -0
  187. data/lib/openlanes/engine/tally.rb +177 -0
  188. data/lib/openlanes/engine.rb +19 -0
  189. data/lib/openlanes/evals/case.rb +191 -0
  190. data/lib/openlanes/evals/changes.rb +67 -0
  191. data/lib/openlanes/evals/check.rb +224 -0
  192. data/lib/openlanes/evals/evidence.rb +71 -0
  193. data/lib/openlanes/evals/live.rb +140 -0
  194. data/lib/openlanes/evals/recording.rb +38 -0
  195. data/lib/openlanes/evals/run.rb +34 -0
  196. data/lib/openlanes/evals/score.rb +24 -0
  197. data/lib/openlanes/evals/scorer.rb +42 -0
  198. data/lib/openlanes/evals/verdict.rb +18 -0
  199. data/lib/openlanes/evals.rb +56 -0
  200. data/lib/openlanes/execution/checks.rb +73 -0
  201. data/lib/openlanes/execution/child.rb +228 -0
  202. data/lib/openlanes/execution/clock.rb +121 -0
  203. data/lib/openlanes/execution/control.rb +194 -0
  204. data/lib/openlanes/execution/executor.rb +849 -0
  205. data/lib/openlanes/execution/prompt.rb +455 -0
  206. data/lib/openlanes/execution/transcript.rb +531 -0
  207. data/lib/openlanes/execution.rb +19 -0
  208. data/lib/openlanes/launcher.rb +69 -0
  209. data/lib/openlanes/lifecycle/adoption.rb +197 -0
  210. data/lib/openlanes/lifecycle/brief.rb +288 -0
  211. data/lib/openlanes/lifecycle/completion.rb +58 -0
  212. data/lib/openlanes/lifecycle/creator.rb +126 -0
  213. data/lib/openlanes/lifecycle/lanes.rb +74 -0
  214. data/lib/openlanes/lifecycle/resync.rb +428 -0
  215. data/lib/openlanes/lifecycle/unblocker.rb +236 -0
  216. data/lib/openlanes/lifecycle.rb +28 -0
  217. data/lib/openlanes/linear/api.rb +224 -0
  218. data/lib/openlanes/linear/attribution.rb +185 -0
  219. data/lib/openlanes/linear/fuzzy.rb +68 -0
  220. data/lib/openlanes/linear/import.rb +314 -0
  221. data/lib/openlanes/linear/issue.rb +187 -0
  222. data/lib/openlanes/linear/mapping.rb +116 -0
  223. data/lib/openlanes/linear/push.rb +197 -0
  224. data/lib/openlanes/linear/survey.rb +174 -0
  225. data/lib/openlanes/linear/unit.rb +274 -0
  226. data/lib/openlanes/linear.rb +41 -0
  227. data/lib/openlanes/live/claims.rb +71 -0
  228. data/lib/openlanes/live/command.rb +122 -0
  229. data/lib/openlanes/live/commands.rb +505 -0
  230. data/lib/openlanes/live/keys.rb +93 -0
  231. data/lib/openlanes/live/memory_store.rb +265 -0
  232. data/lib/openlanes/live/meter.rb +51 -0
  233. data/lib/openlanes/live/node.rb +88 -0
  234. data/lib/openlanes/live/publisher.rb +179 -0
  235. data/lib/openlanes/live/repo.rb +60 -0
  236. data/lib/openlanes/live/run_record.rb +133 -0
  237. data/lib/openlanes/live/run_state.rb +101 -0
  238. data/lib/openlanes/live/session.rb +107 -0
  239. data/lib/openlanes/live/snapshot.rb +322 -0
  240. data/lib/openlanes/live/stations.rb +67 -0
  241. data/lib/openlanes/live/store.rb +277 -0
  242. data/lib/openlanes/live.rb +109 -0
  243. data/lib/openlanes/plans/dev_work.rb +56 -0
  244. data/lib/openlanes/plans/feature.rb +131 -0
  245. data/lib/openlanes/plans/index.rb +208 -0
  246. data/lib/openlanes/plans/ledger.rb +255 -0
  247. data/lib/openlanes/plans/mailbox.rb +164 -0
  248. data/lib/openlanes/plans/ordinal.rb +92 -0
  249. data/lib/openlanes/plans/plan_state.rb +291 -0
  250. data/lib/openlanes/plans/pull_request.rb +29 -0
  251. data/lib/openlanes/plans/pull_requests.rb +229 -0
  252. data/lib/openlanes/plans/spec.rb +213 -0
  253. data/lib/openlanes/plans/state_machine.rb +433 -0
  254. data/lib/openlanes/plans/status.rb +365 -0
  255. data/lib/openlanes/plans/subject.rb +133 -0
  256. data/lib/openlanes/plans/tree.rb +79 -0
  257. data/lib/openlanes/plans.rb +39 -0
  258. data/lib/openlanes/presentation/diagram.rb +149 -0
  259. data/lib/openlanes/presentation/documentation.rb +438 -0
  260. data/lib/openlanes/presentation/reporter.rb +181 -0
  261. data/lib/openlanes/presentation/viewer.rb +62 -0
  262. data/lib/openlanes/presentation.rb +23 -0
  263. data/lib/openlanes/skills/installer.rb +68 -0
  264. data/lib/openlanes/skills/skill.rb +60 -0
  265. data/lib/openlanes/skills.rb +25 -0
  266. data/lib/openlanes/support/config.rb +217 -0
  267. data/lib/openlanes/support/frontmatter.rb +38 -0
  268. data/lib/openlanes/support/markdown.rb +56 -0
  269. data/lib/openlanes/support/project.rb +79 -0
  270. data/lib/openlanes/support/ui.rb +671 -0
  271. data/lib/openlanes/vcs/github.rb +171 -0
  272. data/lib/openlanes/vcs/publisher.rb +221 -0
  273. data/lib/openlanes/vcs/worktree.rb +248 -0
  274. data/lib/openlanes/vcs.rb +19 -0
  275. data/lib/openlanes/version.rb +10 -0
  276. data/lib/openlanes/web/app.rb +176 -0
  277. data/lib/openlanes/web/context.rb +66 -0
  278. data/lib/openlanes/web/demo.rb +169 -0
  279. data/lib/openlanes/web/demo_board.rb +286 -0
  280. data/lib/openlanes/web/events.rb +104 -0
  281. data/lib/openlanes/web/launcher.rb +143 -0
  282. data/lib/openlanes/web/server.rb +77 -0
  283. data/lib/openlanes/web/stores.rb +36 -0
  284. data/lib/openlanes/web.rb +92 -0
  285. data/lib/openlanes.rb +151 -0
  286. data/openlanes.gemspec +87 -0
  287. data/schemas/plan-state.schema.json +101 -0
  288. data/skills/openlanes/SKILL.md +48 -0
  289. data/skills/openlanes-documents/SKILL.md +61 -0
  290. data/skills/openlanes-documents/references/blocked-template.md +49 -0
  291. data/skills/openlanes-documents/references/closing-templates.md +59 -0
  292. data/skills/openlanes-documents/references/plan-template.md +61 -0
  293. data/skills/openlanes-documents/references/retroactive-spec.md +43 -0
  294. data/skills/openlanes-documents/references/spec-template.md +45 -0
  295. data/skills/openlanes-run/SKILL.md +90 -0
  296. data/skills/openlanes-setup/SKILL.md +64 -0
  297. data/skills/openlanes-spec/SKILL.md +90 -0
  298. data/skills/openlanes-status/SKILL.md +32 -0
  299. data/skills/openlanes-sync/SKILL.md +22 -0
  300. data/web/dist/assets/apl-B4CMkyY2.js +2 -0
  301. data/web/dist/assets/asciiarmor-Df11BRmG.js +2 -0
  302. data/web/dist/assets/asn1-EdZsLKOL.js +2 -0
  303. data/web/dist/assets/asterisk-B-8jnY81.js +2 -0
  304. data/web/dist/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  305. data/web/dist/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  306. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  307. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  308. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  309. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  310. data/web/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
  311. data/web/dist/assets/clike-B9uivgTg.js +2 -0
  312. data/web/dist/assets/clojure-BMjYHr_A.js +2 -0
  313. data/web/dist/assets/cmake-BQqOBYOt.js +2 -0
  314. data/web/dist/assets/cobol-CWcv1MsR.js +2 -0
  315. data/web/dist/assets/coffeescript-S37ZYGWr.js +2 -0
  316. data/web/dist/assets/commonlisp-DBKNyK5s.js +2 -0
  317. data/web/dist/assets/crystal-SjHAIU92.js +2 -0
  318. data/web/dist/assets/css-BnMrqG3P.js +2 -0
  319. data/web/dist/assets/cypher-C_CwsFkJ.js +2 -0
  320. data/web/dist/assets/d-pRatUO7H.js +2 -0
  321. data/web/dist/assets/diff-DbItnlRl.js +2 -0
  322. data/web/dist/assets/dockerfile-BKs6k2Af.js +2 -0
  323. data/web/dist/assets/dtd-DF_7sFjM.js +2 -0
  324. data/web/dist/assets/dylan-DwRh75JA.js +2 -0
  325. data/web/dist/assets/ebnf-CDyGwa7X.js +2 -0
  326. data/web/dist/assets/ecl-Cabwm37j.js +2 -0
  327. data/web/dist/assets/eiffel-CnydiIhH.js +2 -0
  328. data/web/dist/assets/elm-vLlmbW-K.js +2 -0
  329. data/web/dist/assets/erlang-BNw1qcRV.js +2 -0
  330. data/web/dist/assets/factor-kuTfRLto.js +2 -0
  331. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  332. data/web/dist/assets/fcl-Kvtd6kyn.js +2 -0
  333. data/web/dist/assets/forth-Ffai-XNe.js +2 -0
  334. data/web/dist/assets/fortran-DYz_wnZ1.js +2 -0
  335. data/web/dist/assets/gas-Bneqetm1.js +2 -0
  336. data/web/dist/assets/gherkin-heZmZLOM.js +2 -0
  337. data/web/dist/assets/groovy-D9Dt4D0W.js +2 -0
  338. data/web/dist/assets/haskell-Cw1EW3IL.js +2 -0
  339. data/web/dist/assets/haxe-H-WmDvRZ.js +2 -0
  340. data/web/dist/assets/http-DBlCnlav.js +2 -0
  341. data/web/dist/assets/idl-BEugSyMb.js +2 -0
  342. data/web/dist/assets/index-2xPC-eHt.js +2 -0
  343. data/web/dist/assets/index-8dNhRmiO.js +8 -0
  344. data/web/dist/assets/index-B7-O9ujQ.js +2 -0
  345. data/web/dist/assets/index-BdR34cXC.js +2 -0
  346. data/web/dist/assets/index-Bp7W-ZTq.js +2 -0
  347. data/web/dist/assets/index-Byyxbh_z.js +4 -0
  348. data/web/dist/assets/index-C0VmkrO-.js +2 -0
  349. data/web/dist/assets/index-CI74IkWP.js +198 -0
  350. data/web/dist/assets/index-CYxcbpRM.js +3 -0
  351. data/web/dist/assets/index-D0rmJN3h.js +2 -0
  352. data/web/dist/assets/index-DB4QwuYn.js +2 -0
  353. data/web/dist/assets/index-DDI7zoAS.js +2 -0
  354. data/web/dist/assets/index-DGLUuEXi.js +2 -0
  355. data/web/dist/assets/index-OR-odsl9.js +2 -0
  356. data/web/dist/assets/index-P3vSkiU-.js +2 -0
  357. data/web/dist/assets/index-a4EHR-u4.css +1 -0
  358. data/web/dist/assets/index-mRV4XhJZ.js +2 -0
  359. data/web/dist/assets/index-popk9wbW.js +2 -0
  360. data/web/dist/assets/index-uM88Njdj.js +2 -0
  361. data/web/dist/assets/javascript-iXu5QeM3.js +2 -0
  362. data/web/dist/assets/julia-DuME0IfC.js +2 -0
  363. data/web/dist/assets/livescript-BwQOo05w.js +2 -0
  364. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  365. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  366. data/web/dist/assets/lua-VAEuO923.js +2 -0
  367. data/web/dist/assets/mathematica-DTrFuWx2.js +2 -0
  368. data/web/dist/assets/mbox-CNhZ1qSd.js +2 -0
  369. data/web/dist/assets/mirc-CjQqDB4T.js +2 -0
  370. data/web/dist/assets/mllike-CXdrOF99.js +2 -0
  371. data/web/dist/assets/modelica-Dc1JOy9r.js +2 -0
  372. data/web/dist/assets/mscgen-BA5vi2Kp.js +2 -0
  373. data/web/dist/assets/mumps-BT43cFF4.js +2 -0
  374. data/web/dist/assets/nginx-DdIZxoE0.js +2 -0
  375. data/web/dist/assets/nsis-LY0VT_u5.js +2 -0
  376. data/web/dist/assets/ntriples-BfvgReVJ.js +2 -0
  377. data/web/dist/assets/octave-Ck1zUtKM.js +2 -0
  378. data/web/dist/assets/overpass-cyrillic-400-normal-CZsFKZCk.woff +0 -0
  379. data/web/dist/assets/overpass-cyrillic-400-normal-tXDxWGRy.woff2 +0 -0
  380. data/web/dist/assets/overpass-cyrillic-600-normal-B-hc8bdn.woff2 +0 -0
  381. data/web/dist/assets/overpass-cyrillic-600-normal-CXB2Mrig.woff +0 -0
  382. data/web/dist/assets/overpass-cyrillic-800-normal-CP6RWG8X.woff2 +0 -0
  383. data/web/dist/assets/overpass-cyrillic-800-normal-PJW4A9xW.woff +0 -0
  384. data/web/dist/assets/overpass-cyrillic-ext-400-normal-AKUz2ZI5.woff2 +0 -0
  385. data/web/dist/assets/overpass-cyrillic-ext-400-normal-C2ZjKngx.woff +0 -0
  386. data/web/dist/assets/overpass-cyrillic-ext-600-normal-CW4Fknsm.woff2 +0 -0
  387. data/web/dist/assets/overpass-cyrillic-ext-600-normal-Zx-mtRSD.woff +0 -0
  388. data/web/dist/assets/overpass-cyrillic-ext-800-normal-CIth-cA7.woff2 +0 -0
  389. data/web/dist/assets/overpass-cyrillic-ext-800-normal-hx3cL3ZN.woff +0 -0
  390. data/web/dist/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  391. data/web/dist/assets/overpass-latin-400-normal-c_C8zulu.woff +0 -0
  392. data/web/dist/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  393. data/web/dist/assets/overpass-latin-600-normal-DhYQrzaY.woff +0 -0
  394. data/web/dist/assets/overpass-latin-800-normal-Bzx_XP5l.woff2 +0 -0
  395. data/web/dist/assets/overpass-latin-800-normal-D6-q4f0l.woff +0 -0
  396. data/web/dist/assets/overpass-latin-ext-400-normal-BIkXRwl6.woff2 +0 -0
  397. data/web/dist/assets/overpass-latin-ext-400-normal-DUq_h-_z.woff +0 -0
  398. data/web/dist/assets/overpass-latin-ext-600-normal-Cyj4Nio0.woff2 +0 -0
  399. data/web/dist/assets/overpass-latin-ext-600-normal-DQKKMGzp.woff +0 -0
  400. data/web/dist/assets/overpass-latin-ext-800-normal-D6WKWGNs.woff +0 -0
  401. data/web/dist/assets/overpass-latin-ext-800-normal-DZ60AB77.woff2 +0 -0
  402. data/web/dist/assets/overpass-mono-cyrillic-400-normal-B144qALP.woff2 +0 -0
  403. data/web/dist/assets/overpass-mono-cyrillic-400-normal-XK9_jwT8.woff +0 -0
  404. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-Dgejvo4A.woff2 +0 -0
  405. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-DgxwtUZy.woff +0 -0
  406. data/web/dist/assets/overpass-mono-latin-400-normal-DqAOLzCO.woff +0 -0
  407. data/web/dist/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  408. data/web/dist/assets/overpass-mono-latin-ext-400-normal-5YIw-E8g.woff +0 -0
  409. data/web/dist/assets/overpass-mono-latin-ext-400-normal-DBdJzBs_.woff2 +0 -0
  410. data/web/dist/assets/overpass-mono-vietnamese-400-normal-DrLp_cHj.woff +0 -0
  411. data/web/dist/assets/overpass-mono-vietnamese-400-normal-jmOeL0rj.woff2 +0 -0
  412. data/web/dist/assets/overpass-vietnamese-400-normal-BKRCEiXy.woff2 +0 -0
  413. data/web/dist/assets/overpass-vietnamese-400-normal-CCWAWVG7.woff +0 -0
  414. data/web/dist/assets/overpass-vietnamese-600-normal-CjS-4vQf.woff2 +0 -0
  415. data/web/dist/assets/overpass-vietnamese-600-normal-DlMMlPfM.woff +0 -0
  416. data/web/dist/assets/overpass-vietnamese-800-normal-BB8K9-Vo.woff2 +0 -0
  417. data/web/dist/assets/overpass-vietnamese-800-normal-DZUygvj_.woff +0 -0
  418. data/web/dist/assets/oz-BzwKVEFT.js +2 -0
  419. data/web/dist/assets/pascal--L3eBynH.js +2 -0
  420. data/web/dist/assets/perl-CdXCOZ3F.js +2 -0
  421. data/web/dist/assets/pig-CevX1Tat.js +2 -0
  422. data/web/dist/assets/powershell-CFHJl5sT.js +2 -0
  423. data/web/dist/assets/properties-C78fOPTZ.js +2 -0
  424. data/web/dist/assets/protobuf-ChK-085T.js +2 -0
  425. data/web/dist/assets/pug-DeIclll2.js +2 -0
  426. data/web/dist/assets/puppet-DMA9R1ak.js +2 -0
  427. data/web/dist/assets/python-BuPzkPfP.js +2 -0
  428. data/web/dist/assets/q-pXgVlZs6.js +2 -0
  429. data/web/dist/assets/r-B6wPVr8A.js +2 -0
  430. data/web/dist/assets/rpm-CTu-6PCP.js +2 -0
  431. data/web/dist/assets/ruby-BK5aLJev.js +2 -0
  432. data/web/dist/assets/sas-B4kiWyti.js +2 -0
  433. data/web/dist/assets/scheme-C41bIUwD.js +2 -0
  434. data/web/dist/assets/shell-CjFT_Tl9.js +2 -0
  435. data/web/dist/assets/sieve-C3Gn_uJK.js +2 -0
  436. data/web/dist/assets/simple-mode-GW_nhZxv.js +2 -0
  437. data/web/dist/assets/smalltalk-CnHTOXQT.js +2 -0
  438. data/web/dist/assets/solr-DehyRSwq.js +2 -0
  439. data/web/dist/assets/sparql-DkYu6x3z.js +2 -0
  440. data/web/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
  441. data/web/dist/assets/sql-D0XecflT.js +2 -0
  442. data/web/dist/assets/stex-C3f8Ysf7.js +2 -0
  443. data/web/dist/assets/stylus-B533Al4x.js +2 -0
  444. data/web/dist/assets/swift-BzpIVaGY.js +2 -0
  445. data/web/dist/assets/tcl-DVfN8rqt.js +2 -0
  446. data/web/dist/assets/textile-CnDTJFAw.js +2 -0
  447. data/web/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
  448. data/web/dist/assets/tiki-DGYXhP31.js +2 -0
  449. data/web/dist/assets/toml-Bm5Em-hy.js +2 -0
  450. data/web/dist/assets/troff-wAsdV37c.js +2 -0
  451. data/web/dist/assets/ttcn-CfJYG6tj.js +2 -0
  452. data/web/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
  453. data/web/dist/assets/turtle-B1tBg_DP.js +2 -0
  454. data/web/dist/assets/vb-CmGdzxic.js +2 -0
  455. data/web/dist/assets/vbscript-BuJXcnF6.js +2 -0
  456. data/web/dist/assets/velocity-D8B20fx6.js +2 -0
  457. data/web/dist/assets/verilog-C6RDOZhf.js +2 -0
  458. data/web/dist/assets/vhdl-lSbBsy5d.js +2 -0
  459. data/web/dist/assets/webidl-ZXfAyPTL.js +2 -0
  460. data/web/dist/assets/xquery-DzFWVndE.js +2 -0
  461. data/web/dist/assets/yacas-BJ4BC0dw.js +2 -0
  462. data/web/dist/assets/z80-Hz9HOZM7.js +2 -0
  463. data/web/dist/index.html +14 -0
  464. data/web/dist/vite.svg +1 -0
  465. data/web/src/api/fixtures/board.json +512 -0
  466. metadata +907 -0
@@ -0,0 +1,671 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/hash"
4
+ require "etc"
5
+ require "fileutils"
6
+ require "dry/cli/ui"
7
+ require "pastel"
8
+ require "stringio"
9
+ require "tty/screen"
10
+ require "unicode/display_width"
11
+
12
+ module OpenLanes
13
+ # Everything the user sees that is not the deliverable itself.
14
+ #
15
+ # Include it and you get `info`, `warn`, `error` and `success` as instance
16
+ # methods, each drawing a dry-cli-ui box on **STDERR**. STDERR is deliberate:
17
+ # the documents and tables these commands produce own STDOUT, so every
18
+ # command composes in a pipe.
19
+ #
20
+ # @example
21
+ # class Thing
22
+ # include OpenLanes::UI
23
+ # def run = success("Linked 4 skills")
24
+ # end
25
+ module UI
26
+ # Widest a box may be drawn, regardless of how wide the terminal is.
27
+ MAX_WIDTH = 100
28
+
29
+ # Narrowest, so a small terminal still produces readable boxes.
30
+ MIN_WIDTH = 60
31
+
32
+ # Below this many items a progress bar is noise: it appears and vanishes
33
+ # before the eye resolves it, and the line it prints is longer than the work.
34
+ PROGRESS_THRESHOLD = 3
35
+
36
+ # What an item contributes to its log line's columns when its caller has
37
+ # nothing to say about it. A round header is such an item, and it still
38
+ # has to line up with the agent lines under it.
39
+ NO_FIELDS = ->(_item) { {} }
40
+
41
+ # What an item's countdown starts from when its caller has no opinion:
42
+ # nothing, so no timer is drawn. A line without a deadline showing 0:00
43
+ # forever would read as an agent perpetually out of time.
44
+ NO_TIMEOUT = ->(_item) {}
45
+
46
+ # The countdown turns red with this many seconds left — late enough to
47
+ # stay calm through a normal run, early enough to look up before the
48
+ # executor pulls the plug.
49
+ TIMER_WARNING = 60
50
+
51
+ # How a block's RETURN VALUE is judged when its caller has no opinion:
52
+ # nothing is ever a failure. The distinction exists because {Runner}'s
53
+ # executor reports failure by returning a not-ok result rather than by
54
+ # raising — and a line that drew ✓ "done" over a timed-out agent, while
55
+ # the round table under it said FAIL, was the contradiction this closes.
56
+ NO_FAILURE = ->(_result) {}
57
+
58
+ # Longest phrase a spinner line can carry without wrapping into the next.
59
+ SAID_LIMIT = 56
60
+
61
+ # One item's line on the screen, and the same news written to the log.
62
+ #
63
+ # These are two readers of one story and used to be told it separately:
64
+ # the spinner got the phrase, the log got a start and a finish, and an
65
+ # animated run wrote nothing about what any agent was actually doing. A
66
+ # {OpenLanes::Execution::Transcript::Progress} arrives here several times a
67
+ # second; the line is redrawn every time, and the log takes a line only
68
+ # when the phrase itself changes, which is a few dozen times an agent.
69
+ #
70
+ # The drawing belongs to dry-cli-ui. This keeps the parts the text after
71
+ # the agent's name is made of — identity, countdown, meter, phrase — and
72
+ # hands the whole of it to the {Dry::CLI::UI::Line} whenever one changes.
73
+ class Line
74
+ # @param fields [Hash] plan, status and agent, for the log's columns
75
+ # @param handle [Dry::CLI::UI::Line, nil] what dry-cli-ui draws this
76
+ # item's line from; nil where nothing is being drawn
77
+ # @param timeout [Integer, nil] seconds until the executor abandons
78
+ # this agent; drawn as a countdown on the line, nil draws nothing
79
+ def initialize(fields: {}, handle: nil, timeout: nil)
80
+ @fields = fields
81
+ @handle = handle
82
+ @timeout = timeout
83
+ @started = UI.monotonic
84
+ @phrase = nil
85
+ @pid = nil
86
+ @ticker = nil
87
+ # The meter starts at zero rather than appearing with the first
88
+ # number, which would shift everything after it sideways.
89
+ @parts = { pid: "", timer: "", meter: UI.meter(nil), activity: "" }
90
+ @lock = Mutex.new
91
+ end
92
+
93
+ # @return [Float] seconds this agent has been alive
94
+ def seconds = UI.monotonic - @started
95
+
96
+ # @return [String] the same, as the log and the report write it
97
+ def alive = "#{seconds.round}s"
98
+
99
+ # @param message [String]
100
+ # @return [void]
101
+ def note(message) = UI.log(message, **@fields, seconds:)
102
+
103
+ # @return [void]
104
+ def start
105
+ redraw(pid: identity)
106
+ tick
107
+ note("started")
108
+ end
109
+
110
+ # What remains on this agent's clock, or nil when it has none. Floored
111
+ # at zero: the executor's kill and this thread's wake-up race by up to
112
+ # a second, and a line reading `-0:01` accuses the wrong party.
113
+ #
114
+ # @return [Integer, nil]
115
+ def remaining
116
+ return nil unless @timeout
117
+
118
+ [@timeout - seconds, 0].max.round
119
+ end
120
+
121
+ # The countdown, redrawn once a second on its own thread. Progress
122
+ # updates cannot drive it — they arrive only while the agent is
123
+ # talking, and a stalled agent is exactly when the clock matters most.
124
+ #
125
+ # @return [void]
126
+ def tick
127
+ return unless @handle && @timeout
128
+
129
+ redraw(timer: UI.countdown(remaining))
130
+ @ticker ||= Thread.new do
131
+ loop do
132
+ sleep(1)
133
+ left = remaining
134
+ redraw(timer: UI.countdown(left))
135
+ break unless left.positive?
136
+ end
137
+ rescue StandardError
138
+ # A dying line must not take the round down with it.
139
+ end
140
+ end
141
+
142
+ # @return [void]
143
+ def stop_ticker
144
+ @ticker&.kill
145
+ @ticker = nil
146
+ end
147
+
148
+ # What sits between the agent's name and its activity: the `claude`
149
+ # process and the round, `[36123, round 01]`, so a line on the screen
150
+ # can be matched to a process in `ps` and a row in the report. The pid
151
+ # is unknowable until the child has been spawned and found, so it reads
152
+ # `…` until then; a caller with neither fact gets nothing at all.
153
+ #
154
+ # @return [String]
155
+ def identity
156
+ round = @fields[:round]
157
+ return "" if round.nil? && @pid.nil?
158
+
159
+ inner = [(@pid || "…").to_s, ("round #{round}" if round)].compact.join(", ")
160
+ UI.paint("[#{inner}]", :bright_black)
161
+ end
162
+
163
+ # What the line shows after the agent's name.
164
+ #
165
+ # @return [String] e.g. `[36123, round 01] 14:59 ↑4.9k ↓512: reading spec.md`
166
+ def detail
167
+ parts = @lock.synchronize { @parts.dup }
168
+ [parts[:pid], parts[:timer] + parts[:meter]].map(&:rstrip).reject(&:empty?).join(" ") + parts[:activity]
169
+ end
170
+
171
+ # @return [void]
172
+ def done
173
+ stop_ticker
174
+ note("finished after #{alive}")
175
+ end
176
+
177
+ # Ends the line as a failure without raising: the executor reports a
178
+ # timed-out agent by returning, and the line must still say 𝘅.
179
+ #
180
+ # @param reason [String]
181
+ # @return [void]
182
+ def failed(reason)
183
+ stop_ticker
184
+ @handle&.fail(reason)
185
+ note("failed after #{alive}: #{reason}")
186
+ end
187
+
188
+ # @param update [OpenLanes::Execution::Transcript::Progress]
189
+ # @return [void]
190
+ def call(update)
191
+ if update.respond_to?(:pid) && update.pid && update.pid != @pid
192
+ @pid = update.pid
193
+ redraw(pid: identity)
194
+ note("claude is pid #{@pid}")
195
+ end
196
+ redraw(meter: UI.meter(update), activity: UI.said(update.activity))
197
+ return if update.activity.nil? || update.activity == @phrase
198
+
199
+ @phrase = update.activity
200
+ note(update.activity)
201
+ end
202
+
203
+ # So a caller can pass this straight on as a block: {Executor} yields
204
+ # to it from the thread it reads the agent's stream on.
205
+ #
206
+ # @return [Proc]
207
+ def to_proc = method(:call).to_proc
208
+
209
+ private
210
+
211
+ # Called from the reader thread and the ticker at once, which is why
212
+ # the parts are replaced under a lock rather than assembled in place.
213
+ #
214
+ # @param parts [Hash{Symbol => String}]
215
+ # @return [void]
216
+ def redraw(**parts)
217
+ @lock.synchronize { @parts.merge!(parts) }
218
+ @handle&.detail = detail
219
+ end
220
+ end
221
+
222
+ class << self
223
+ # Set by the CLI's --quiet. Silences spinners and bars along with
224
+ # everything else, so a quiet run really is quiet.
225
+ #
226
+ # @return [Boolean]
227
+ attr_accessor :quiet
228
+
229
+ # @return [Pastel] colour engine, disabled when STDERR is not a terminal
230
+ def pastel = @pastel ||= Pastel.new(enabled: color?)
231
+
232
+ # Forget everything memoized here.
233
+ #
234
+ # Pastel captures `enabled:` once, at construction. Anything that changes
235
+ # the answer afterwards — a test stubbing `tty?`, a caller setting
236
+ # NO_COLOR late — would otherwise be ignored for the rest of the process,
237
+ # and the first answer would leak into every later call.
238
+ #
239
+ # @return [void]
240
+ def reset!
241
+ remove_instance_variable(:@pastel) if instance_variable_defined?(:@pastel)
242
+ self.quiet = false
243
+ self.log_path = nil
244
+ end
245
+
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`.
250
+ #
251
+ # @return [String, nil]
252
+ attr_accessor :log_path
253
+
254
+ # Something else that hears every {.log} line, as it is logged: the
255
+ # live board keeps the latest few for the page. Taps are called on the
256
+ # logging thread and must not raise.
257
+ #
258
+ # @param tap [#call] receives the message and its fields
259
+ # @return [#call] the tap, to hand back to {.untap_log}
260
+ def tap_log(tap)
261
+ @log_taps = [*log_taps, tap]
262
+ tap
263
+ end
264
+
265
+ # @param tap [#call]
266
+ # @return [void]
267
+ def untap_log(tap)
268
+ @log_taps = log_taps - [tap]
269
+ end
270
+
271
+ # @return [Array<#call>]
272
+ def log_taps = @log_taps || []
273
+
274
+ # Append one timestamped line to {.log_path}. A no-op with nothing set.
275
+ # Safe to call from several threads at once.
276
+ #
277
+ # @param message [String]
278
+ # @return [void]
279
+ def log(message, **fields)
280
+ 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
288
+ end
289
+
290
+ # Cells the meter takes on a spinner line, per direction.
291
+ #
292
+ # Fixed, and padded to it, because the numbers grow as the agent works and
293
+ # a column that sizes itself to them drags the whole line sideways every
294
+ # few seconds.
295
+ METER_WIDTH = 7
296
+
297
+ # The token counter that sits between the spinner and the agent's name.
298
+ #
299
+ # Up is everything sent, cache reads included, which is most of it. Down
300
+ # is what the model generated. Sub-agent spend is folded into up, since
301
+ # `claude` reports a sub-agent's total without splitting it.
302
+ #
303
+ # @param update [OpenLanes::Execution::Transcript::Progress, nil]
304
+ # @return [String]
305
+ def meter(update)
306
+ paint(fit("↑#{abbreviate(update&.up)}", METER_WIDTH), :bright_blue) +
307
+ paint(fit("↓#{abbreviate(update&.down)}", METER_WIDTH), :bright_magenta)
308
+ end
309
+
310
+ # Cells the countdown takes, `MM:SS ` included — the run default of
311
+ # 900s reads `15:00`, and padding to a fixed width keeps the columns
312
+ # to its right from stepping sideways once `9:59` loses a digit.
313
+ TIMER_WIDTH = 6
314
+
315
+ # The countdown that sits between the spinner and the meter: what is
316
+ # left of the agent's timeout, quiet grey until the last
317
+ # {TIMER_WARNING} seconds, red from there down.
318
+ #
319
+ # @param left [Integer, nil] seconds remaining; nil draws nothing
320
+ # @return [String]
321
+ def countdown(left)
322
+ return "" if left.nil?
323
+
324
+ text = fit(format("%d:%02d", left / 60, left % 60), TIMER_WIDTH)
325
+ paint(text, left <= TIMER_WARNING ? :red : :bright_black)
326
+ end
327
+
328
+ # Token counts run to seven figures, and seven figures on a spinner line
329
+ # is four cells of noise about a number nobody reads to the digit.
330
+ #
331
+ # @param count [Integer]
332
+ # @return [String] e.g. "512", "4.9k", "121k", "1.6M"
333
+ def abbreviate(count)
334
+ count = count.to_i
335
+ case count
336
+ when 0...1_000 then count.to_s
337
+ when 1_000...10_000 then "#{(count / 1000.0).round(1)}k"
338
+ when 10_000...1_000_000 then "#{(count / 1000.0).round}k"
339
+ when 1_000_000...10_000_000 then "#{(count / 1_000_000.0).round(1)}M"
340
+ else "#{(count / 1_000_000.0).round}M"
341
+ end
342
+ end
343
+
344
+ # @param phrase [String, nil]
345
+ # @return [String] the phrase as a spinner line carries it, cut to
346
+ # {SAID_LIMIT}
347
+ def said(phrase)
348
+ text = phrase.to_s
349
+ return "" if text.empty?
350
+
351
+ text = "#{text[0, SAID_LIMIT - 1]}…" if text.length > SAID_LIMIT
352
+ paint(": #{text}", :green, :bold)
353
+ end
354
+
355
+ # @return [Boolean] whether STDERR is an interactive terminal
356
+ def tty? = $stderr.tty?
357
+
358
+ # Whether animated output is worth drawing at all. A pipe, a CI log or a
359
+ # --quiet run gets none: spinner frames written to a file are line noise.
360
+ #
361
+ # @return [Boolean]
362
+ def animate? = tty? && !quiet
363
+
364
+ # Indeterminate work — one call whose duration cannot be predicted, such
365
+ # as a network round trip. The spinner runs until the block returns.
366
+ #
367
+ # @param message [String] what is being waited on
368
+ # @yieldparam activity [Proc] phrase -> void, for news about the work
369
+ # @yieldreturn [Object] whatever the work produces
370
+ # @return [Object] the block's value, untouched
371
+ def spinning(message)
372
+ return yield(logging_activity(message)) unless animate?
373
+
374
+ console.spinner(message) { |line| yield(activity_for(line)) }
375
+ end
376
+
377
+ # Determinate work — N items of roughly equal cost. Yields each item and
378
+ # advances the bar; returns the collection so it can be chained.
379
+ #
380
+ # @param items [Array]
381
+ # @param message [String]
382
+ # @yieldparam item [Object]
383
+ # @return [Array] +items+
384
+ def stepping(items, message, &)
385
+ list = items.to_a
386
+ return list.each(&) unless animate? && list.size >= PROGRESS_THRESHOLD
387
+
388
+ console.progress(message, total: list.size) do |bar|
389
+ list.each do |item|
390
+ yield item
391
+ bar.advance
392
+ end
393
+ end
394
+ list
395
+ end
396
+
397
+ # The dry-cli-ui console every box, spinner and bar here draws through.
398
+ #
399
+ # Both of its streams are STDERR, because dry-cli-ui sends `info` and
400
+ # `success` to its `out`, and STDOUT here belongs to the deliverable.
401
+ # Colour and animation are decided once, by {.color?} and {.animate?},
402
+ # rather than by the gem looking at the stream a second time and
403
+ # possibly disagreeing about --quiet.
404
+ #
405
+ # Built on every call rather than memoized: `$stderr` is swapped out by
406
+ # the specs and by `output(...).to_stderr`, and a console holding on to
407
+ # the stream it was born with would write past every one of them.
408
+ #
409
+ # @return [Dry::CLI::UI::Console]
410
+ def console
411
+ Dry::CLI::UI::Console.new(out: $stderr, err: $stderr, color: color?, animate: animate?, box_width: width)
412
+ end
413
+
414
+ # Run a block over many items at once, one line each.
415
+ #
416
+ # This is the shape for work that is independent and slow: each item gets
417
+ # its own line, its own thread and its own success or failure mark, so a
418
+ # long round reads as progress rather than as a hang.
419
+ #
420
+ # Results come back in the order the items were given, not the order they
421
+ # finished — a caller that had to re-sort them would be a caller that
422
+ # eventually forgets to.
423
+ #
424
+ # Every path here — one item, several without a terminal, several with
425
+ # one — reports something. `jobs <= 1 || list.size <= 1` used to bypass
426
+ # all of it and run silently, which is exactly the shape a `--plan
427
+ # NNN.MM` round takes: one plan, one agent, nothing printed until the
428
+ # whole thing finished and it was too late to tell "working" from "hung."
429
+ #
430
+ # Several items are a dry-cli-ui task list, capped at `jobs` at once. A
431
+ # failing item is caught inside its own task, so its line reads 𝘅 and
432
+ # its siblings carry on.
433
+ #
434
+ # @param items [Array]
435
+ # @param message [String] the header line
436
+ # @param jobs [Integer] how many run at once
437
+ # @param label [Proc] item -> the text on its line
438
+ # @param failure [Proc] the block's return value -> a reason when that
439
+ # value reports a failure, nil when it reports success. The block
440
+ # returning normally is not the same fact as the work having worked.
441
+ # @param header [Hash] log columns for the header line itself, so the
442
+ # line announcing a round carries the same round number as the agent
443
+ # lines under it rather than a blank cell
444
+ # @yieldparam item [Object]
445
+ # @yieldparam line [Line]
446
+ # @return [Array] one result per item, in input order
447
+ def concurrently(items, message, jobs:, label: :to_s.to_proc, fields: NO_FIELDS,
448
+ failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT, &)
449
+ list = items.to_a
450
+ return [] if list.empty?
451
+
452
+ log(message, **header)
453
+
454
+ if jobs <= 1 || list.size <= 1
455
+ report_line(message) unless animate?
456
+ return list.map { |item| once(item, label, fields, failure:, timeout:, &) }
457
+ end
458
+
459
+ results = Concurrent::Hash.new
460
+ progress.tasks(message, concurrent: jobs) do |tasks|
461
+ list.each_with_index do |item, index|
462
+ tasks.task(label.call(item)) do |handle|
463
+ line = Line.new(fields: fields.call(item), handle:, timeout: timeout.call(item))
464
+ results[index] = attempt(item, line, failure, &)
465
+ rescue StandardError => e
466
+ results[index] = e
467
+ end
468
+ end
469
+ end
470
+ list.each_index.map { |i| results[i] }
471
+ end
472
+
473
+ # The same news, with no spinner to put it on. A piped or CI run still
474
+ # wants it, in the log where the rest of that run's progress goes.
475
+ #
476
+ # @param text [String] the item's label
477
+ # @return [Proc] phrase -> void
478
+ def logging_activity(text) = ->(phrase) { log("#{text}: #{phrase}") }
479
+
480
+ # A callable that writes what the work is doing after its spinner's label.
481
+ #
482
+ # @param line [Dry::CLI::UI::Line]
483
+ # @return [Proc] phrase -> void
484
+ def activity_for(line)
485
+ ->(phrase) { line.detail = phrase.to_s.empty? ? "" : paint("— #{phrase}", :green, :bold) }
486
+ end
487
+
488
+ # One item, no concurrency to speak of: a serial round (`--isolation
489
+ # shared`), or the last plan left in a parallel one. A live spinner on a
490
+ # terminal; a start line and a finish line with an elapsed time otherwise.
491
+ #
492
+ # @param item [Object]
493
+ # @param label [Proc]
494
+ # @yieldparam item [Object]
495
+ # @return [Object]
496
+ # @raise [StandardError] whatever the block raised, once its line says so
497
+ def once(item, label, fields = NO_FIELDS, failure: NO_FAILURE, timeout: NO_TIMEOUT, &)
498
+ progress.spinner(label.call(item)) do |handle|
499
+ attempt(item, Line.new(fields: fields.call(item), handle:, timeout: timeout.call(item)), failure, &)
500
+ end
501
+ end
502
+
503
+ # Runs one item and says how it went, on its line and in the log.
504
+ #
505
+ # @param item [Object]
506
+ # @param line [Line]
507
+ # @param failure [Proc]
508
+ # @return [Object] the block's value
509
+ def attempt(item, line, failure)
510
+ line.start
511
+ begin
512
+ result = yield(item, line)
513
+ rescue StandardError => e
514
+ line.failed(e.message.lines.first.to_s.strip)
515
+ raise
516
+ end
517
+ if (reason = failure.call(result))
518
+ line.failed(reason)
519
+ else
520
+ line.done
521
+ end
522
+ result
523
+ end
524
+
525
+ # The console per-item lines go through: {.console}, or one that
526
+ # writes nowhere under --quiet, so a quiet run really is quiet while
527
+ # the work, the log and the results stay exactly the same.
528
+ #
529
+ # @return [Dry::CLI::UI::Console]
530
+ def progress
531
+ return console unless quiet
532
+
533
+ Dry::CLI::UI::Console.new(out: StringIO.new, err: StringIO.new, color: false, animate: false)
534
+ end
535
+
536
+ # @return [Float] a monotonic clock reading, immune to wall-clock changes
537
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
538
+
539
+ # @param started [Float] a {#monotonic} reading taken before the work began
540
+ # @return [String] e.g. "42s"
541
+ def elapsed(started) = "#{(monotonic - started).round}s"
542
+
543
+ # A progress line, thread-safe and quiet-aware — the one thing every
544
+ # non-spinner path above needs and would otherwise have to reimplement.
545
+ #
546
+ # @param text [String]
547
+ # @param bullet [String]
548
+ # @return [void]
549
+ def report_line(text, bullet: "·")
550
+ return if quiet
551
+
552
+ (@print_mutex ||= Mutex.new).synchronize { line(text, bullet:) }
553
+ end
554
+
555
+ # A sensible worker count: agents are mostly waiting on a model rather
556
+ # than burning CPU, so this is deliberately close to the core count. Two
557
+ # are left for the machine, and the cap keeps a very large tree from
558
+ # opening fifty subprocesses at once.
559
+ #
560
+ # @return [Integer]
561
+ def default_jobs = (Etc.nprocessors - 2).clamp(1, 12)
562
+
563
+ # Honours NO_COLOR — https://no-color.org
564
+ #
565
+ # @return [Boolean]
566
+ def color? = tty? && !ENV.key?("NO_COLOR")
567
+
568
+ # @return [Integer] a box width that fits the terminal but stays readable
569
+ def width = (TTY::Screen.width - 4).clamp(MIN_WIDTH, MAX_WIDTH)
570
+
571
+ # @param text [String]
572
+ # @param styles [Array<Symbol>] pastel style names
573
+ # @return [String] decorated when colour is on, bare otherwise
574
+ def paint(text, *styles) = color? ? pastel.decorate(text.to_s, *styles) : text.to_s
575
+
576
+ # @param text [String]
577
+ # @return [Integer] how many terminal cells the text occupies
578
+ def display_width(text) = ::Unicode::DisplayWidth.of(text.to_s)
579
+
580
+ # Pad or truncate to an exact number of terminal *cells*.
581
+ #
582
+ # `format`'s "%-20.20s" counts characters, and a character is not a cell.
583
+ # "✅" is one character two cells wide; "🅱️" is two characters one cell
584
+ # wide. Any column laid out with %s therefore drifts by one for every
585
+ # emoji whose two counts disagree — which is every emoji, in one
586
+ # direction or the other.
587
+ #
588
+ # @param text [String] unpainted; escape codes count as characters and
589
+ # would be padded like any other
590
+ # @param width [Integer] terminal cells
591
+ # @return [String]
592
+ def fit(text, width)
593
+ text = text.to_s
594
+ text = text[0..-2] while display_width(text) > width
595
+ text + (" " * (width - display_width(text)))
596
+ end
597
+
598
+ # Every box is drawn by dry-cli-ui, on STDERR, through {.console}.
599
+ #
600
+ # Not through `Kernel.warn`, which is a **no-op** when `$VERBOSE` is
601
+ # nil, which is what `-W0` sets — and `RUBYOPT=-W0` is common in CI
602
+ # images and agent harnesses. Routed through `Kernel.warn`, every box
603
+ # this tool draws silently disappears in exactly the environments where
604
+ # a failure most needs explaining. The console writes to its stream.
605
+ #
606
+ # dry-cli-ui wraps before it frames, so a long line no longer costs the
607
+ # box its last row, which is where the instruction lives. One run
608
+ # reported four of its ten failures and cut the fifth mid-sentence.
609
+ #
610
+ # @param kind [Symbol] :info, :warn, :error or :success
611
+ # @param message [String]
612
+ # @return [void]
613
+ def box(kind, message) = console.public_send(kind, message.to_s)
614
+
615
+ # A framed panel centered on the screen, for the keyboard help. Unlike
616
+ # {.box} it positions itself absolutely, so it overlays whatever the
617
+ # spinners are drawing rather than scrolling in below them — the next
618
+ # repaint draws over it, which is all the dismissal a help screen needs.
619
+ #
620
+ # @param title [String]
621
+ # @param text [String]
622
+ # @return [void]
623
+ def popup(title, text) = console.popup(text.to_s, title:)
624
+
625
+ # A framed table, returned rather than printed: a table is a
626
+ # deliverable, so it belongs on STDOUT and the caller picks the stream.
627
+ #
628
+ # @param rows [Array<Array<#to_s>>]
629
+ # @param header [Array<#to_s>, nil]
630
+ # @return [String] the table ending in a newline, or "" when there are no rows
631
+ def table(rows, header: nil)
632
+ Dry::CLI::UI::Widgets::Table.new(Dry::CLI::UI::Terminal.new($stdout, color: color?)).render(rows, header:)
633
+ end
634
+
635
+ # A single unadorned line, for per-item progress that does not deserve
636
+ # a box of its own.
637
+ #
638
+ # @param message [String]
639
+ # @param bullet [String]
640
+ # @return [void]
641
+ # rubocop:disable-next Style/StderrPuts -- see {.box}: `warn` is a no-op under -W0.
642
+ def line(message, bullet: "·") = $stderr.puts(" #{paint(bullet, :bright_black)} #{message}")
643
+ end
644
+
645
+ # @param message [String]
646
+ # @return [void]
647
+ def info(message) = UI.box(:info, message)
648
+
649
+ # @param message [String]
650
+ # @return [void]
651
+ def warn(message) = UI.box(:warn, message)
652
+
653
+ # @param message [String]
654
+ # @return [void]
655
+ def error(message) = UI.box(:error, message)
656
+
657
+ # @param message [String]
658
+ # @return [void]
659
+ def success(message) = UI.box(:success, message)
660
+
661
+ # @param message [String]
662
+ # @param bullet [String]
663
+ # @return [void]
664
+ def say(message, bullet: "·") = UI.line(message, bullet:)
665
+
666
+ # @param text [String]
667
+ # @param styles [Array<Symbol>]
668
+ # @return [String]
669
+ def paint(text, *styles) = UI.paint(text, *styles)
670
+ end
671
+ end