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
@@ -31,26 +31,49 @@ module OpenLanes
31
31
 
32
32
  # Command kind to the method that applies it.
33
33
  HANDLERS = {
34
- "answer" => :answer,
35
- "hold_blocked" => :hold_blocked,
36
- "release_hold" => :release_hold,
37
- "resume" => :resume,
38
- "discard" => :discard,
39
- "message" => :message,
40
- "hold" => :signal,
41
- "wrap_up" => :signal,
42
- "compact" => :compact,
43
- "stop" => :signal,
44
- "stop_all" => :stop_all,
45
- "set_model" => :set_model,
46
- "switch_model" => :switch_model,
47
- "add_plan" => :add_plan,
48
- "create_plans" => :create_plans
34
+ "answer" => :answer,
35
+ "hold_blocked" => :hold_blocked,
36
+ "release_hold" => :release_hold,
37
+ "resume" => :resume,
38
+ "discard" => :discard,
39
+ "message" => :message,
40
+ "hold" => :signal,
41
+ "wrap_up" => :signal,
42
+ "compact" => :compact,
43
+ "stop" => :signal,
44
+ "stop_all" => :stop_all,
45
+ "set_model" => :set_model,
46
+ "switch_model" => :switch_model,
47
+ "add_plan" => :add_plan,
48
+ "create_plans" => :create_plans,
49
+ "respond" => :respond,
50
+ "pause_discuss" => :pause_discuss,
51
+ "resume_anyway" => :resume_anyway,
52
+ "stop_plan" => :stop_plan,
53
+ "restart_plan" => :restart_plan,
54
+ "set_concurrency" => :set_concurrency
49
55
  }.freeze
50
56
 
57
+ # How long "Resume anyway" lifts a 💣's halt.
58
+ RESUME_FOR = 24 * 3600
59
+
51
60
  # Plans a person may not add to a run: there is nothing left to do.
52
61
  FINISHED = Snapshot::FINISHED
53
62
 
63
+ # The copilot turn that answers a slash command: `"<id>.1"`, the turn
64
+ # the page's "reading" waits for. {Web::Demo} writes the same one, so
65
+ # the demo's copilot ends its wait as the engine's does.
66
+ #
67
+ # @param reply [OpenLanes::Live::Reply]
68
+ # @return [OpenLanes::Live::Copilot::Turn]
69
+ def self.echo_turn(reply)
70
+ Copilot::Turn.new(id: "#{reply.id}.1",
71
+ from: "copilot",
72
+ kind: "text",
73
+ text: [reply.status, reply.reason].compact.join(": "),
74
+ at: reply.at)
75
+ end
76
+
54
77
  # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
55
78
  # @param keys [OpenLanes::Live::Keys]
56
79
  # @param node [OpenLanes::Live::Node]
@@ -106,9 +129,19 @@ module OpenLanes
106
129
  @ready = true
107
130
  end
108
131
 
132
+ # A group that went with a Redis restarted empty is made again, and
133
+ # the read tried once more; that it vanished once is no reason to stop
134
+ # the engine, as it did (#59).
135
+ #
109
136
  # @param id [String] "0" for this consumer's pending, ">" for new
110
137
  # @return [Array<OpenLanes::Live::Store::Entry>]
111
- def read(id) = @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
138
+ def read(id)
139
+ @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
140
+ rescue GroupMissing
141
+ @ready = false
142
+ prepare
143
+ @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
144
+ end
112
145
 
113
146
  # Answer the commands whose work finished since the last tick. A
114
147
  # command still at work is neither answered nor acknowledged, so an
@@ -131,8 +164,11 @@ module OpenLanes
131
164
  return answer_again(entry, first)
132
165
  end
133
166
 
167
+ # Said on the terminal and in the log: what a person asked for from
168
+ # the page is part of the run's story, beside what the agents did.
169
+ UI.log("from the page: #{command.kind.tr("_", " ")}", **{ plan: command.plan, agent: command.agent }.compact)
134
170
  status, reason = apply(command, dispatcher)
135
- return reply(entry, command.id, status, reason) unless status == :pending
171
+ return reply(entry, command.id, status, reason, echo: command.payload["source"] == "copilot") unless status == :pending
136
172
 
137
173
  @pending[entry.id] = [entry, command.id, reason]
138
174
  nil
@@ -154,25 +190,36 @@ module OpenLanes
154
190
  ["refused", "#{command.kind} failed: #{e.message}"]
155
191
  end
156
192
 
193
+ # A command the copilot sent (a slash command, plan 020.00) is also
194
+ # answered in the conversation, as turn `"<id>.1"`: that is the turn the
195
+ # page's "reading" waits for, and the conversation keeps the outcome.
196
+ # It is written in the same `MULTI`, so a redelivery, which
197
+ # {#answer_again} handles, can never write it twice.
198
+ #
199
+ # @param echo [Boolean] whether to answer in the copilot stream too
157
200
  # @return [OpenLanes::Live::Reply]
158
- def reply(entry, id, status, reason)
201
+ def reply(entry, id, status, reason, echo: false)
159
202
  reply = Reply.new(id:, status:, reason:, at: @clock.call)
160
203
  json = reply.to_json
161
- @store.multi do |tx|
204
+ turn = Commands.echo_turn(reply) if echo
205
+ EventBus.for(@store).transaction do |tx|
162
206
  tx.hset(@keys.done, id, json)
163
- tx.xadd(@keys.replies, { json: }, maxlen: Keys::REPLIES_LENGTH)
207
+ tx.append(@keys.replies, { json: }, maxlen: Keys::REPLIES_LENGTH)
208
+ tx.append(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH) if turn
164
209
  tx.xack(@keys.commands, Keys::GROUP, entry.id)
165
210
  @record&.write(tx)
166
211
  end
212
+ News.publish(store: @store, keys: @keys, kind: "reply")
167
213
  reply
168
214
  end
169
215
 
170
216
  # @return [OpenLanes::Live::Reply] the first reply, sent again
171
217
  def answer_again(entry, first)
172
- @store.multi do |tx|
173
- tx.xadd(@keys.replies, { json: first }, maxlen: Keys::REPLIES_LENGTH)
218
+ EventBus.for(@store).transaction do |tx|
219
+ tx.append(@keys.replies, { json: first }, maxlen: Keys::REPLIES_LENGTH)
174
220
  tx.xack(@keys.commands, Keys::GROUP, entry.id)
175
221
  end
222
+ News.publish(store: @store, keys: @keys, kind: "reply")
176
223
  data = JSON.parse(first)
177
224
  Reply.new(id: data["id"], status: data["status"], reason: data["reason"], at: Time.iso8601(data["at"]))
178
225
  end
@@ -214,17 +261,25 @@ module OpenLanes
214
261
 
215
262
  # `## A<n>` under the question, signed, for `lando-broker` to fold in
216
263
  # (W10). The dispatcher hands the plan to the broker once every
217
- # question has an answer, and the plan leaves ⭕️ when the file is
264
+ # question has an answer, and the plan leaves ⭕ when the file is
218
265
  # drained. The answer carries the command's id in a comment, so a
219
266
  # command applied again after a crash finds it and writes nothing.
220
267
  #
221
268
  # @return [String, nil]
222
269
  def answer(command, dispatcher)
270
+ return extension(command, dispatcher) if command["question"].to_s.match?(/\AX\d+\z/i)
271
+ return quality(command, dispatcher) if command["question"].to_s.casecmp?(Engine::QualityQuestion::NAME)
272
+
223
273
  subject, question = question!(command, dispatcher)
224
- path = File.join(subject.feature.path, "blocked.md")
225
- return nil if File.read(path).include?(marker(command))
274
+ return nil if File.read(File.join(subject.feature.path, "blocked.md")).include?(marker(command))
226
275
  return "B#{question.number} already has an answer waiting" if question.answered
227
276
 
277
+ write_answer(subject, question, command, dispatcher)
278
+ end
279
+
280
+ # @return [nil]
281
+ def write_answer(subject, question, command, dispatcher)
282
+ path = File.join(subject.feature.path, "blocked.md")
228
283
  File.open(path, "a") do |file|
229
284
  file.write("\n## A#{question.number}\n\n#{command["text"]}\n\n" \
230
285
  "Answered by #{@node.user} on #{@clock.call.strftime("%Y-%m-%d")}\n#{marker(command)}\n")
@@ -234,6 +289,83 @@ module OpenLanes
234
289
  nil
235
290
  end
236
291
 
292
+ # An answer to the harness's request for more time or rounds (E1).
293
+ # The page sends the chosen option, "b) Extend Time by 10 minutes";
294
+ # only its letter counts, so there is nothing to parse but that.
295
+ #
296
+ # @return [String]
297
+ def extension(command, dispatcher)
298
+ subject = subject!(command, dispatcher)
299
+ number = command["question"][1..].to_i
300
+ letter = command["text"].to_s.strip[/\A([a-c])\)?(?:\s|\z)/i, 1] or
301
+ raise Refusal, "X#{number} takes a), b) or c), not #{command["text"].to_s.strip[0, 40].inspect}"
302
+ settled = dispatcher.answer_extension(subject, number, letter.downcase, by: @node.user) or
303
+ raise Refusal, "#{command.plan} has no open request X#{number}"
304
+ settled["grant"] == "live" ? "#{settled["agent"]} has #{settled["seconds"] / 60} more minutes" : "#{settled["agent"]} gets one more round"
305
+ end
306
+
307
+ # R4's answer: a) high or b) mvp, written into `.openlanes.yml` (U3).
308
+ # Only the letter counts, as for {#extension}.
309
+ #
310
+ # @return [String]
311
+ def quality(command, dispatcher)
312
+ subject = subject!(command, dispatcher)
313
+ name = Engine::QualityQuestion::NAME
314
+ letter = command["text"].to_s.strip[/\A([ab])\)?(?:\s|\z)/i, 1] or
315
+ raise Refusal, "#{name} takes a) or b), not #{command["text"].to_s.strip[0, 40].inspect}"
316
+ chosen = Engine::QualityQuestion.answer!(subject.feature.path, letter, by: @node.user, root: File.dirname(dispatcher.runner.tree.dir)) or
317
+ raise Refusal, "#{command.plan} has no open question #{name}"
318
+ dispatcher.wake!
319
+ "desired_codebase_quality: #{chosen} in #{Config::FILE}"
320
+ end
321
+
322
+ # ---- bombs ------------------------------------------------------------
323
+
324
+ # The 💣 notice's answer box (plan 009): the first open question with
325
+ # no answer yet gets this one, in exactly the form {#answer} writes,
326
+ # so the drain that clears a ⭕ clears the 💣 too.
327
+ #
328
+ # @return [String, nil]
329
+ def respond(command, dispatcher)
330
+ subject = bomb!(command, dispatcher)
331
+ return nil if File.read(File.join(subject.feature.path, "blocked.md")).include?(marker(command))
332
+
333
+ question = Lifecycle::Unblocker.questions(subject).reject(&:answered).first or
334
+ raise Refusal, "every question #{command.plan} asks already has an answer waiting"
335
+ write_answer(subject, question, command, dispatcher)
336
+ "answered B#{question.number}"
337
+ end
338
+
339
+ # Pause and Discuss (N10): every agent on this machine holds, and the
340
+ # page opens the copilot on the plan. No file changes.
341
+ #
342
+ # @return [String]
343
+ def pause_discuss(command, dispatcher)
344
+ bomb!(command, dispatcher)
345
+ held = dispatcher.running.map(&:key).select { dispatcher.signal(it, Execution::Control::HOLD) }
346
+ held.each { @run_state.hold_car(it, held: true) }
347
+ "held #{held.size} agent#{"s" unless held.size == 1}; discuss #{command.plan} in the copilot"
348
+ end
349
+
350
+ # Resume anyway for {RESUME_FOR}: the halt this 💣 causes lapses on its
351
+ # own, with no config edit to undo.
352
+ #
353
+ # @return [String]
354
+ def resume_anyway(command, dispatcher)
355
+ subject = bomb!(command, dispatcher)
356
+ till = Plans::PlanState.for(subject.feature.path).resume_anyway!(till: @clock.call + RESUME_FOR)
357
+ dispatcher.wake!
358
+ "#{command.plan} halts nothing until #{till.utc.iso8601}"
359
+ end
360
+
361
+ # @return [OpenLanes::Plans::Subject] the plan, refused unless it is 💣
362
+ def bomb!(command, dispatcher)
363
+ subject = subject!(command, dispatcher)
364
+ raise Refusal, "#{command.plan} is #{subject.status}, not a 💣" unless subject.status.key == :bomb
365
+
366
+ subject
367
+ end
368
+
237
369
  # @return [nil]
238
370
  def hold_blocked(command, dispatcher) = hold_question(command, dispatcher, held: true)
239
371
 
@@ -414,7 +546,7 @@ module OpenLanes
414
546
  nil
415
547
  end
416
548
 
417
- # The idea becomes a ⚪️ plan whose spec.md opens with it, then joins the run.
549
+ # The idea becomes a ⚪ plan whose spec.md opens with it, then joins the run.
418
550
  #
419
551
  # @return [String]
420
552
  def create_plans(command, dispatcher)
@@ -424,15 +556,15 @@ module OpenLanes
424
556
  return created(made.feature, dispatcher)
425
557
  end
426
558
 
427
- path = Lifecycle.create(dir: tree.dir, words: idea.split.first(8)).either(
559
+ path = Lifecycle.create(dir: tree.dir, words: Lifecycle::Creator.topic(idea)).either(
428
560
  ->(created) { created },
429
561
  ->(problem) { raise Refusal, "could not create the plan: #{problem}" }
430
562
  )
431
563
  feature = Plans::Feature.parse(path)
432
- spec = Lifecycle.brief(path:, title: feature.title, root: File.dirname(tree.dir), seed: "#{idea}\n\n#{marker(command)}")
433
- .write_scaffold!
434
- settings = idea_frontmatter(command.payload["frontmatter"])
435
- File.write(spec, "#{YAML.dump(settings)}---\n\n#{File.read(spec)}") unless settings.empty?
564
+ seed = [idea, decisions(command.payload["decisions"]), marker(command)].compact.join("\n\n")
565
+ brief = Lifecycle.brief(path:, title: feature.title, root: File.dirname(tree.dir), seed:)
566
+ brief.write_scaffold!
567
+ brief.stamp!(idea_frontmatter(command.payload["frontmatter"]))
436
568
  created(feature, dispatcher)
437
569
  end
438
570
 
@@ -459,6 +591,22 @@ module OpenLanes
459
591
  end
460
592
  end
461
593
 
594
+ # The interview's answers (M6) as a `## Decisions` section of the
595
+ # spec, so the agents downstream find them made instead of blocking to
596
+ # ask. A skipped interview sends none, and the spec is as it was.
597
+ #
598
+ # @param list [Array<Hash>, nil] `payload.decisions`: `question`, `answer`
599
+ # @return [String, nil]
600
+ def decisions(list)
601
+ rows = Array(list).filter_map do |entry|
602
+ next unless entry.is_a?(Hash)
603
+
604
+ question, answer = entry.transform_keys(&:to_s).values_at("question", "answer").map { it.to_s.gsub(/\s+/, " ").strip }
605
+ "- **#{question}** #{answer}" unless question.empty? || answer.empty?
606
+ end
607
+ "## Decisions\n\n#{rows.join("\n")}" unless rows.empty?
608
+ end
609
+
462
610
  # @return [String]
463
611
  def created(feature, dispatcher)
464
612
  claim!(feature.ordinal)
@@ -488,6 +636,48 @@ module OpenLanes
488
636
  json ? JSON.parse(json).values_at("user", "host").join("@") : "another machine"
489
637
  end
490
638
 
639
+ # ---- lanes (plan 020.00) ----------------------------------------------
640
+
641
+ # `/stop NNN`: every agent on the plan stops, and the plan leaves the board.
642
+ #
643
+ # @return [String]
644
+ def stop_plan(command, dispatcher)
645
+ ordinal = ordinal!(command)
646
+ told = dispatcher.stop_plan(ordinal)
647
+ told.each { @run_state.hold_car(it, held: false) }
648
+ "stopped #{told.size} agent#{"s" unless told.size == 1} on #{ordinal} and took it off the board"
649
+ end
650
+
651
+ # `/restart NNN`: a lane that lost its agent is dispatched again.
652
+ #
653
+ # @return [String]
654
+ def restart_plan(command, dispatcher)
655
+ ordinal = ordinal!(command)
656
+ dispatcher.restart_plan(ordinal)
657
+ "#{ordinal} restarts at the next tick"
658
+ end
659
+
660
+ # `/concurrency N`. Lowering it stops nobody; the run waits for agents
661
+ # to finish until it is under the new limit.
662
+ #
663
+ # @return [String]
664
+ def set_concurrency(command, dispatcher)
665
+ limit = command.payload["limit"]
666
+ raise Refusal, "concurrency must be a whole number of at least 1, not #{limit.inspect}" unless limit.is_a?(Integer) && limit >= 1
667
+
668
+ before = dispatcher.runner.jobs
669
+ dispatcher.concurrency = limit
670
+ @run_state.concurrency = limit
671
+ over = dispatcher.running.size - limit
672
+ wait = over.positive? ? "; the run waits for #{over} agent#{"s" unless over == 1} to finish" : ""
673
+ "concurrency #{before} → #{limit}#{wait}"
674
+ end
675
+
676
+ # @return [String] the plan's ordinal, normalised
677
+ def ordinal!(command)
678
+ (Plans::Ordinal.parse(command.plan.to_s) or raise Refusal, "#{command.plan} is not a plan number").to_s
679
+ end
680
+
491
681
  # ---- lookups ----------------------------------------------------------
492
682
 
493
683
  # @return [OpenLanes::Plans::Subject]
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ class Copilot
6
+ # What c3po-operator is told with each message: the board as it stands,
7
+ # where to read further, and the person's words.
8
+ #
9
+ # The board lives in Redis, which the agent cannot read: its Bash runs
10
+ # only `openlanes` commands that read state, and none of them prints the
11
+ # live board. So the server reads the snapshot and writes it in here,
12
+ # fresh with every message, since the run moves between two questions.
13
+ # It is written as lines rather than the snapshot's JSON because the
14
+ # agent pays for every token of it on every message.
15
+ class Brief
16
+ # Recent log lines carried.
17
+ LOG_LINES = 20
18
+
19
+ # Characters of a question's body carried; the agent reads the rest
20
+ # from blocked.md when it needs it.
21
+ BODY = 400
22
+
23
+ # @param text [String] what the person typed
24
+ # @param board [Hash, nil] this node's `Snapshot` as parsed JSON
25
+ # @param others [Array<Hash>] other machines' snapshots
26
+ # @param plans_dir [String]
27
+ # @param trace_dir [String]
28
+ def initialize(text:, board:, plans_dir:, trace_dir:, others: [])
29
+ @text = text
30
+ @board = board
31
+ @others = others
32
+ @plans_dir = plans_dir
33
+ @trace_dir = trace_dir
34
+ end
35
+
36
+ # @return [String]
37
+ def to_s
38
+ [board, others, where, "## The person says\n\n#{@text}"].compact.join("\n\n")
39
+ end
40
+
41
+ private
42
+
43
+ # @return [String]
44
+ def board
45
+ return "## The board\n\nNo engine is publishing a board for this checkout: no run is going, or it has ended." unless @board
46
+
47
+ run = @board["run"] || {}
48
+ lines = ["## The board at #{@board["at"]}",
49
+ "",
50
+ "Run #{run["status"]}, #{run["agents_working"].to_i} agents working, ↑ #{run["up"].to_i} ↓ #{run["down"].to_i} tokens, " \
51
+ "$#{format("%.2f", run["cost"].to_f)}."]
52
+ plans = Array(@board["plans"])
53
+ lines << (plans.empty? ? "No plans on this machine's board." : "")
54
+ plans.each { lines.concat(plan(it)) }
55
+ backlog = Array(@board["backlog"])
56
+ lines << "Backlog: #{backlog.map { "#{it["ordinal"]} #{it["title"]} (#{it.dig("status", "label")})" }.join("; ")}" unless backlog.empty?
57
+ lines.concat(log)
58
+ lines.join("\n")
59
+ end
60
+
61
+ # @param plan [Hash]
62
+ # @return [Array<String>]
63
+ def plan(plan)
64
+ lines = ["- #{plan["ordinal"]} #{plan["title"]}: #{plan.dig("status", "emoji")} #{plan.dig("status", "label")}, " \
65
+ "#{plan["lane"]} lane, #{minutes(plan["elapsed"])} worked, $#{format("%.2f", plan["cost"].to_f)}" \
66
+ "#{", PR ##{plan.dig("pr", "number")} #{plan.dig("pr", "state")}" if plan["pr"]}"]
67
+ Array(plan["cars"]).each { lines << car(it) }
68
+ Array(plan["questions"]).each { lines << question(it) }
69
+ if (stop = plan["interrupted"])
70
+ lines << " - interrupted: #{stop["agent"]} at #{stop["at"]}: #{stop["note"]}"
71
+ end
72
+ lines
73
+ end
74
+
75
+ # @param car [Hash]
76
+ # @return [String]
77
+ def car(car)
78
+ " - car #{car["key"]}: #{car["state"]}#{", #{car["phase"]}" if car["phase"]}, round #{car["round"]}/#{car["rounds"]}, " \
79
+ "#{car["model"]}, ↑ #{car["up"].to_i} ↓ #{car["down"].to_i}, #{minutes(car["elapsed"])} elapsed" \
80
+ "#{", #{minutes(car["remaining"])} left" if car["remaining"]}#{", held" if car["held"]}: #{car["message"].to_s.inspect}"
81
+ end
82
+
83
+ # @param question [Hash]
84
+ # @return [String]
85
+ def question(question)
86
+ body = question["body"].to_s.gsub(/\s+/, " ").strip
87
+ body = "#{body[0, BODY]}…" if body.length > BODY
88
+ " - question #{question["id"]} (#{question["owner"]}, asked by #{question["agent"] || "unknown"}" \
89
+ "#{", held" if question["held"]}): #{question["heading"]}: #{body}"
90
+ end
91
+
92
+ # @return [Array<String>]
93
+ def log
94
+ lines = Array(@board["log"]).last(LOG_LINES)
95
+ return [] if lines.empty?
96
+
97
+ ["", "Recent log, newest last:"] + lines.map { " #{it["at"]} #{it["source"]} #{it["level"]}: #{it["text"]}" }
98
+ end
99
+
100
+ # @return [String, nil]
101
+ def others
102
+ return nil if @others.empty?
103
+
104
+ machines = @others.map do |snapshot|
105
+ node = snapshot["node"] || {}
106
+ plans = Array(snapshot["plans"]).map { "#{it["ordinal"]} #{it.dig("status", "label")}" }
107
+ "- #{node["user"]}@#{node["host"]}: #{plans.empty? ? "no plans" : plans.join(", ")}"
108
+ end
109
+ "## Other machines (read only)\n\n#{machines.join("\n")}"
110
+ end
111
+
112
+ # @return [String]
113
+ def where
114
+ <<~TEXT.strip
115
+ ## Where to read more
116
+
117
+ - Plan folders: #{@plans_dir}. Each holds spec.md, plan.md, blocked.md when blocked, and state.json with its signatures and mailbox.
118
+ - Agents' transcripts: #{@trace_dir}, one NDJSON file per invocation, named by time, plan and agent.
119
+ TEXT
120
+ end
121
+
122
+ # @param seconds [Numeric, nil]
123
+ # @return [String]
124
+ def minutes(seconds) = "#{(seconds.to_f / 60).round}m"
125
+ end
126
+ end
127
+ end
128
+ end