openlanes 3.0.1 → 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 (319) 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 +4 -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 +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  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 +7 -0
  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 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  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 +84 -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 +1175 -117
  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 +6 -6
  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 +12 -120
  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 +223 -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 +17 -13
  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 +60 -11
  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 +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Plans
7
+ # An agent's journal (M2): what it decided, did, left to do, asked and
8
+ # must not forget, kept from one round to the next.
9
+ #
10
+ # The agent writes no file and runs no command. It prints each entry as
11
+ # one line of JSON in its own output, which the harness reads anyway, and
12
+ # the dispatcher keeps it in `state.json` under `memory.<agent>`. One
13
+ # writer, under the lock {PlanState} already takes, is what lets a pair
14
+ # journal at once without either losing the other's line; an agent that
15
+ # wrote the file itself would be a second writer racing the first.
16
+ #
17
+ # {"openlanes_memory": {"kind": "decision", "text": "Money is integer cents"}}
18
+ # {"openlanes_memory": {"kind": "override", "text": "BigDecimal after all", "overrides": 3}}
19
+ #
20
+ # The single key {KEY} is what tells an entry from any other JSON an
21
+ # agent prints: a status object, a `package.json` it quotes, a `jq`
22
+ # result. Nothing else an agent has reason to print starts with that key.
23
+ #
24
+ # There is no timestamp. Entries are numbered in order across the
25
+ # agent's rounds on the plan, and a later one wins over an earlier one;
26
+ # an `override` names the number it overturns.
27
+ #
28
+ # Pure: text in, entries out. The dispatcher decides where they go.
29
+ module Journal
30
+ # The one key an entry line carries.
31
+ KEY = "openlanes_memory"
32
+
33
+ # What an entry may be. `question` is the agent stopping to ask; the
34
+ # harness adds the answer beside it once one arrives (M3).
35
+ KINDS = %w[todo done decision question override note].freeze
36
+
37
+ # Longest text kept, in characters. Every round's prompt carries the
38
+ # journal, so an entry is a line to remember, not a document; one that
39
+ # runs on is cut rather than refused, since what it says first is what
40
+ # it most needed to keep.
41
+ TEXT_LIMIT = 500
42
+
43
+ # A line that may be an entry: the key first, after an optional
44
+ # backtick, since agents quote JSON in Markdown as often as not.
45
+ CANDIDATE = /\A`*\{\s*"#{KEY}"\s*:/
46
+
47
+ # One entry, as the agent printed it or the harness recorded it.
48
+ #
49
+ # @!attribute [r] kind
50
+ # @return [String] one of {KINDS}
51
+ # @!attribute [r] text
52
+ # @return [String]
53
+ # @!attribute [r] overrides
54
+ # @return [Integer, nil] the number of the entry an `override` overturns
55
+ # @!attribute [r] ref
56
+ # @return [String, nil] `B<n>`, for a question the harness recorded
57
+ # from `blocked.md`
58
+ # @!attribute [r] answer
59
+ # @return [String, nil] a person's answer to that question
60
+ Entry = Data.define(:kind, :text, :overrides, :ref, :answer) do
61
+ def initialize(kind:, text:, overrides: nil, ref: nil, answer: nil) = super
62
+
63
+ # @param hash [Hash] as `state.json` keeps it
64
+ # @return [OpenLanes::Plans::Journal::Entry]
65
+ def self.from_h(hash)
66
+ new(kind: hash["kind"].to_s,
67
+ text: hash["text"].to_s,
68
+ overrides: hash["overrides"],
69
+ ref: hash["ref"],
70
+ answer: hash["answer"])
71
+ end
72
+
73
+ # @return [Hash] string keys, nils left out, as the schema wants
74
+ def to_json_h = to_h.transform_keys(&:to_s).compact
75
+ end
76
+
77
+ # A line that announced itself as an entry and was not one, kept so the
78
+ # dispatcher can say so: an agent whose journal is silently dropped
79
+ # believes it is remembering.
80
+ #
81
+ # @!attribute [r] line
82
+ # @return [String]
83
+ # @!attribute [r] reason
84
+ # @return [String]
85
+ Rejection = Data.define(:line, :reason)
86
+
87
+ # One round's worth of one agent's journal.
88
+ #
89
+ # @!attribute [r] number
90
+ # @return [Integer] the agent's journal round on this plan, 1 up,
91
+ # counted across runs and states. Not the dispatcher's round, which
92
+ # restarts at 1 in every run and every state and would land a later
93
+ # run's entries on an earlier run's.
94
+ # @!attribute [r] round
95
+ # @return [Integer, nil] the dispatcher's round it was printed in
96
+ # @!attribute [r] state
97
+ # @return [String, nil] the state the plan was in when it started
98
+ # @!attribute [r] entries
99
+ # @return [Array<Entry>]
100
+ # @!attribute [r] first
101
+ # @return [Integer] the number of its first entry in the whole journal
102
+ Round = Data.define(:number, :round, :state, :entries, :first)
103
+
104
+ module_function
105
+
106
+ # Every entry line in one piece of the agent's text, in order, each
107
+ # read into an {Entry} or a {Rejection}. Lines that do not start with
108
+ # {KEY} are none of this method's business and are skipped.
109
+ #
110
+ # @param text [String, nil] what the agent said
111
+ # @return [Array<Entry, Rejection>]
112
+ def scan(text)
113
+ text.to_s.each_line.filter_map do |raw|
114
+ line = raw.strip
115
+ read(line) if line.match?(CANDIDATE)
116
+ end
117
+ end
118
+
119
+ # @param line [String] one candidate line
120
+ # @return [Entry, Rejection]
121
+ def read(line)
122
+ json = line.delete_prefix("`" * line[/\A`*/].size).delete_suffix("`" * line[/`*\z/].size)
123
+ value = JSON.parse(json)[KEY]
124
+ problem = problem_with(value)
125
+ return Rejection.new(line:, reason: problem) if problem
126
+
127
+ Entry.new(kind: value["kind"], text: clip(value["text"]), overrides: value["overrides"])
128
+ rescue JSON::ParserError => e
129
+ Rejection.new(line:, reason: "not JSON: #{e.message.lines.first.to_s.strip}")
130
+ end
131
+
132
+ # Only the fields an agent may set are read; `ref` and `answer` are the
133
+ # harness's to write, and an agent claiming a person answered something
134
+ # is exactly the line that must not be trusted.
135
+ #
136
+ # @param value [Object]
137
+ # @return [String, nil] why it is not an entry
138
+ def problem_with(value)
139
+ return "#{KEY} is not an object" unless value.is_a?(Hash)
140
+ return "kind #{value["kind"].inspect} is not one of #{KINDS.join(", ")}" unless KINDS.include?(value["kind"])
141
+ return "text is missing" unless value["text"].is_a?(String) && !value["text"].strip.empty?
142
+
143
+ overrides = value["overrides"]
144
+ return nil if overrides.nil?
145
+ return "overrides must be an entry number" unless overrides.is_a?(Integer) && overrides.positive?
146
+
147
+ "only an override may name an entry it overrides" unless value["kind"] == "override"
148
+ end
149
+
150
+ # @param text [String]
151
+ # @return [String] stripped, and cut to {TEXT_LIMIT}
152
+ def clip(text)
153
+ text = text.to_s.strip
154
+ text.length > TEXT_LIMIT ? "#{text[0, TEXT_LIMIT - 1]}…" : text
155
+ end
156
+
157
+ # The line an agent prints, for the prompt's examples, so the prompt and
158
+ # the parser cannot describe two shapes.
159
+ #
160
+ # @param kind [String]
161
+ # @param text [String]
162
+ # @param overrides [Integer, nil]
163
+ # @return [String]
164
+ def line(kind, text, overrides: nil)
165
+ JSON.generate(KEY => { "kind" => kind, "text" => text, "overrides" => overrides }.compact)
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module OpenLanes
6
+ module Plans
7
+ # What an interrupted agent had done, as a record the next round acts on.
8
+ #
9
+ # It replaces the `RESUME: ...` mail, which was prose the next agent had
10
+ # to read and believe. The fields are fixed: freeform JSON would be prose
11
+ # with braces, and no easier to act on.
12
+ #
13
+ # Two writers. The agent, through `openlanes state resume`, when it
14
+ # reaches a polling point after INTERRUPT. The harness, when the agent
15
+ # never did: killed when GRACE ran out, or dead outright. The agent knows
16
+ # what it did, the harness only what it saw, so an agent's entry is never
17
+ # replaced by a harness one from the same round or an earlier one.
18
+ #
19
+ # Pids, tokens and exit codes are not here and cannot be: this lands in
20
+ # the committed `state.json`, and process detail belongs to one machine's
21
+ # `openlanes-state.json`.
22
+ #
23
+ # @!attribute [r] source
24
+ # @return [String] "agent" or "harness"
25
+ # @!attribute [r] at
26
+ # @return [String] ISO 8601
27
+ # @!attribute [r] round
28
+ # @return [Integer]
29
+ # @!attribute [r] status
30
+ # @return [String] one of {Ledger::STATUSES}
31
+ # @!attribute [r] done
32
+ # @return [Array<String>]
33
+ # @!attribute [r] remaining
34
+ # @return [Array<String>]
35
+ # @!attribute [r] next_step
36
+ # @return [String, nil] the one thing to do first
37
+ # @!attribute [r] files_touched
38
+ # @return [Array<String>]
39
+ # @!attribute [r] note
40
+ # @return [String, nil] what the harness saw, for a harness entry
41
+ LastKnownState = Data.define(:source, :at, :round, :status, :done, :remaining, :next_step, :files_touched, :note) do
42
+ # @return [Array<String>]
43
+ def self.sources = %w[agent harness]
44
+
45
+ # @param hash [Hash] as `state.json` holds it
46
+ # @return [OpenLanes::Plans::LastKnownState]
47
+ def self.from_h(hash)
48
+ new(**members.to_h { |k| [k, hash[k.to_s]] }.compact)
49
+ end
50
+
51
+ # @raise [OpenLanes::Error] for a source or status outside the vocabulary
52
+ def initialize(source:, round:, status:, at: Time.now.iso8601, done: [], remaining: [], next_step: nil,
53
+ files_touched: [], note: nil)
54
+ raise Error, "source must be one of #{self.class.sources.join(", ")}" unless self.class.sources.include?(source)
55
+ raise Error, "status must be one of #{Ledger::STATUSES.join(", ")}" unless Ledger::STATUSES.include?(status)
56
+
57
+ super(source:, at: at.respond_to?(:iso8601) ? at.iso8601 : at.to_s, round: Integer(round), status:,
58
+ done: Array(done), remaining: Array(remaining), next_step:, files_touched: Array(files_touched), note:)
59
+ end
60
+
61
+ # @return [Boolean]
62
+ def agent? = source == "agent"
63
+
64
+ # Whether this entry may replace +other+, the one already kept. An
65
+ # agent's entry replaces anything; the harness's only what is not an
66
+ # agent's of the same round or later.
67
+ #
68
+ # @param other [OpenLanes::Plans::LastKnownState, nil]
69
+ # @return [Boolean]
70
+ def supersedes?(other)
71
+ return true if other.nil? || agent?
72
+
73
+ !(other.agent? && other.round >= round)
74
+ end
75
+
76
+ # @return [Hash] as `state.json` holds it, empty fields left out
77
+ def to_json_h
78
+ to_h.transform_keys(&:to_s).reject { |_, v| v.nil? || v == [] }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "time"
6
+
7
+ module OpenLanes
8
+ module Plans
9
+ # An agent is working on this plan right now. The folder name never
10
+ # changes for it, because a rename while an agent holds the old path is
11
+ # how its writes land in a directory that no longer exists.
12
+ #
13
+ # The file lives in the runtime state directory ({OpenLanes::Runtime}),
14
+ # named for the plan's ordinal, not in the plan folder: it says something
15
+ # true only on the machine that wrote it, and keyed by ordinal it
16
+ # survives a rename. A `locked-by.pid` an older release left in the
17
+ # folder is still read ({.legacy_path}), and removed with the lock.
18
+ #
19
+ # @!attribute [r] pid
20
+ # @return [Integer]
21
+ # @!attribute [r] host
22
+ # @return [String]
23
+ # @!attribute [r] started_at
24
+ # @return [Time]
25
+ Lock = Data.define(:pid, :host, :started_at) do
26
+ # The plan folder's parent is the plans directory, and its parent the
27
+ # root {Runtime} keys by, so the harness and an agent handed the same
28
+ # `--dir` find one lock.
29
+ #
30
+ # @param dir [String] a plan folder
31
+ # @return [String] `<locks_dir>/<ordinal>.lock`
32
+ def self.path(dir)
33
+ name = File.basename(dir)
34
+ ordinal = Ordinal.from_dirname(name)&.to_s || name
35
+ File.join(Runtime.locks_dir(root: File.dirname(File.expand_path(dir), 2)), "#{ordinal}.lock")
36
+ end
37
+
38
+ # @param dir [String] a plan folder
39
+ # @return [String] where releases before the state directory wrote it
40
+ def self.legacy_path(dir) = File.join(dir, self::FILENAME)
41
+
42
+ # The one entry point every reader goes through: the state directory
43
+ # first, a legacy file second.
44
+ #
45
+ # @param dir [String] a plan folder
46
+ # @return [OpenLanes::Plans::Lock, nil] nil when there is no lock, or it cannot be read
47
+ def self.read(dir) = parse(path(dir)) || parse(legacy_path(dir))
48
+
49
+ # @param file [String]
50
+ # @return [OpenLanes::Plans::Lock, nil]
51
+ def self.parse(file)
52
+ data = JSON.parse(File.read(file))
53
+ new(pid: Integer(data.fetch("pid")), host: data.fetch("host").to_s, started_at: Time.iso8601(data.fetch("started_at")))
54
+ rescue Errno::ENOENT, Errno::EISDIR, JSON::ParserError, KeyError, ArgumentError, TypeError, NoMethodError
55
+ nil
56
+ end
57
+ private_class_method :parse
58
+
59
+ # Claim the plan for this process. Creating the file is the claim
60
+ # (`O_EXCL`), so two runs that both saw no lock cannot both write it
61
+ # and have the later one win in silence. A lock this process already
62
+ # holds is rewritten, which is how a pair's partner and a renamed
63
+ # folder find it; one left by a local process that has died is removed
64
+ # and the claim tried once more.
65
+ #
66
+ # @param dir [String] a plan folder
67
+ # @param pid [Integer]
68
+ # @param now [Time]
69
+ # @return [OpenLanes::Plans::Lock]
70
+ # @raise [OpenLanes::Plans::Lock::Held] when another live run holds it
71
+ def self.write(dir, pid: Process.pid, now: Time.now)
72
+ lock = new(pid:, host: Socket.gethostname, started_at: now.utc)
73
+ body = "#{JSON.generate(lock.to_json_hash)}\n"
74
+ legacy = parse(legacy_path(dir))
75
+ raise self::Held, "#{dir} is locked by pid #{legacy.pid} on #{legacy.host}" if legacy&.held_elsewhere? && legacy.pid != pid
76
+
77
+ 2.times do
78
+ File.open(path(dir), File::WRONLY | File::CREAT | File::EXCL) { it.write(body) }
79
+ return lock
80
+ rescue Errno::EEXIST
81
+ held = read(dir)
82
+ if held&.local? && held.pid == pid
83
+ File.write(path(dir), body)
84
+ return lock
85
+ end
86
+ raise self::Held, "#{dir} is locked by pid #{held.pid} on #{held.host}" if held && !held.stale?
87
+ raise self::Held, "#{dir} has a lock being written or unreadable" if held.nil? && fresh?(dir)
88
+
89
+ remove(dir)
90
+ end
91
+ raise self::Held, "#{dir}: another run claimed it first"
92
+ end
93
+
94
+ # @param dir [String]
95
+ # @return [Boolean] an unreadable lock young enough to be one another
96
+ # run has just created and not yet filled
97
+ def self.fresh?(dir)
98
+ Time.now - File.mtime(path(dir)) < self::UNREADABLE_GRACE
99
+ rescue Errno::ENOENT
100
+ false
101
+ end
102
+ private_class_method :fresh?
103
+
104
+ # Also clears a legacy file, but only one a dead process on this host
105
+ # left: that is how `resync dirs` keeps clearing old stale locks, and a
106
+ # live or foreign one in the folder is somebody else's claim.
107
+ #
108
+ # @param dir [String] a plan folder
109
+ # @return [void]
110
+ def self.remove(dir)
111
+ FileUtils.rm_f(path(dir))
112
+ legacy = parse(legacy_path(dir))
113
+ FileUtils.rm_f(legacy_path(dir)) if legacy&.stale?
114
+ end
115
+
116
+ # @return [Boolean] whether this machine wrote it, so its pid means something here
117
+ def local? = host == Socket.gethostname
118
+
119
+ # Written on another host. Held for as long as it exists: never stale,
120
+ # never cleared here, since nothing on this machine can tell it is over.
121
+ #
122
+ # @return [Boolean]
123
+ def foreign? = !local?
124
+
125
+ # Only a local lock can be checked; one from another host is never
126
+ # declared dead, since its pid names a process on a machine we cannot see.
127
+ #
128
+ # @return [Boolean]
129
+ def alive?
130
+ return false unless local?
131
+
132
+ Process.kill(0, pid)
133
+ true
134
+ rescue Errno::ESRCH
135
+ false
136
+ rescue Errno::EPERM
137
+ true
138
+ end
139
+
140
+ # @return [Boolean] written on this machine by a process that is gone
141
+ def stale? = local? && !alive?
142
+
143
+ # @return [Boolean] written by this very process, as a pair's partner finds it
144
+ def ours? = local? && pid == Process.pid
145
+
146
+ # Another run is in this folder: a live local process that is not this
147
+ # one, or any process on another machine. Such a folder is neither
148
+ # dispatched nor renamed here.
149
+ #
150
+ # @return [Boolean]
151
+ def held_elsewhere? = !ours? && !stale?
152
+
153
+ # @return [Hash] what the file and the snapshot carry
154
+ def to_json_hash = { pid:, host:, started_at: started_at.utc.iso8601, foreign: foreign? }
155
+ end
156
+
157
+ # The lock file's name in a plan folder, where releases before the
158
+ # runtime state directory wrote it. Read, never written.
159
+ Lock::FILENAME = "locked-by.pid"
160
+
161
+ # Seconds an unreadable lock is taken for one being written. Between the
162
+ # `O_EXCL` create and the write the file is empty; older than this, it is
163
+ # debris from a crash and is cleared like a dead pid's.
164
+ Lock::UNREADABLE_GRACE = 5
165
+
166
+ class Lock
167
+ # Another live run holds the plan's lock; the claim was not made.
168
+ class Held < OpenLanes::Error; end
169
+ end
170
+ end
171
+ end
@@ -36,8 +36,12 @@ module OpenLanes
36
36
 
37
37
  # One entry.
38
38
  #
39
+ # @!attribute [r] id
40
+ # @return [String, nil] what a drained copy is recognised by; nil on
41
+ # messages from before ids, and on every mailbox.md one
39
42
  # @!attribute [r] number
40
- # @return [Integer] 1-based, in order of writing
43
+ # @return [Integer] 1-based, in order of writing, within one copy of
44
+ # the plan's files
41
45
  # @!attribute [r] at
42
46
  # @return [String] when, as {TIME_FORMAT} writes it
43
47
  # @!attribute [r] from
@@ -46,7 +50,18 @@ module OpenLanes
46
50
  # @return [String] the agent it is for
47
51
  # @!attribute [r] body
48
52
  # @return [String]
49
- Message = Data.define(:number, :at, :from, :to, :body) do
53
+ # @!attribute [r] read
54
+ # @return [String] "yes" once its recipient ran `mail ack`, "no" until
55
+ # then. Set by the reader, never by delivery: a stream ack says the
56
+ # bytes arrived, not that anybody read them.
57
+ Message = Data.define(:number, :at, :from, :to, :body, :read, :id) do
58
+ # A message written before acks existed carries no `read`, and reads
59
+ # as unread: nobody ever said they read it.
60
+ def initialize(number:, at:, from:, to:, body:, read: "no", id: nil) = super
61
+
62
+ # @return [Boolean]
63
+ def read? = read == "yes"
64
+
50
65
  # @return [String] as it stands in the file
51
66
  def to_s = "## #{number}#{SEPARATOR}#{at}#{SEPARATOR}#{from} → #{to}\n\n#{body.strip}\n"
52
67
  end
@@ -109,6 +124,100 @@ module OpenLanes
109
124
  from_state(state.message!(from:, to:, body: text, at:, after: legacy.map(&:number).max.to_i, command:))
110
125
  end
111
126
 
127
+ # Keep a message the harness drained from Redis, unless this plan
128
+ # already has it, here or in `mailbox.md`. A different message whose
129
+ # number is taken is kept under the next free number: see
130
+ # {PlanState#deliver!}.
131
+ #
132
+ # @param message [Hash] as {Live::Mail#drain} returns it
133
+ # @return [OpenLanes::Plans::Mailbox::Message, nil] as written, nil when
134
+ # it was already here
135
+ # @raise [OpenLanes::Error] for one with no positive number, no body,
136
+ # or a sender or recipient that is not one agent name
137
+ def deliver(message)
138
+ number = message["number"]
139
+ raise Error, "a delivered message needs a positive number" unless number.is_a?(Integer) && number.positive?
140
+ raise Error, "a message needs a body" if message["body"].to_s.strip.empty?
141
+ raise Error, "a message needs --from and --to, each one agent name" unless name?(message["from"]) && name?(message["to"])
142
+
143
+ old = legacy
144
+ return nil if old.any? { |m| same_as_legacy?(m, message) }
145
+
146
+ state.deliver!(message, reserved: old.map(&:number))&.then { from_state(it) }
147
+ end
148
+
149
+ # How many messages nobody has acked. The dashboard shows it, and
150
+ # nothing else reads it: an unacked message is a warning, never an input
151
+ # to a round (D3).
152
+ #
153
+ # @param recipient [String, nil] one reader, or everybody
154
+ # @return [Integer]
155
+ def unacked(recipient = nil)
156
+ messages.count { |m| !m.read? && (recipient.nil? || m.to == recipient) }
157
+ end
158
+
159
+ # The unacked messages, one entry per sender and recipient, so the
160
+ # dashboard can say who is waiting on whom rather than only how many.
161
+ #
162
+ # @return [Array<Hash{Symbol => String, Integer}>] `from`, `to`, `count`
163
+ # and `since`, the oldest one's timestamp as written, oldest pair first
164
+ def unacked_pairs
165
+ messages.reject(&:read?).group_by { [it.from, it.to] }
166
+ .map { |(from, to), sent| { from:, to:, count: sent.size, since: sent.map(&:at).min.to_s } }
167
+ .sort_by { it[:since] }
168
+ end
169
+
170
+ # Whether +number+ is a `mailbox.md` message: history, counted as read,
171
+ # where an ack records nothing.
172
+ #
173
+ # @param number [Integer, String]
174
+ # @return [Boolean]
175
+ def legacy?(number)
176
+ number = Integer(number.to_s, exception: false)
177
+ legacy.any? { |m| m.number == number }
178
+ end
179
+
180
+ # Mark one message read. A `mailbox.md` number is returned as it
181
+ # stands, with nothing written: ask {#legacy?} before saying it was
182
+ # recorded.
183
+ #
184
+ # @param number [Integer]
185
+ # @param by [String] the reader, who must be its recipient
186
+ # @return [OpenLanes::Plans::Mailbox::Message] as it stands now
187
+ # @raise [OpenLanes::Error] for a number with no message, or a reader
188
+ # it was not addressed to
189
+ def ack(number, by:)
190
+ number = Integer(number.to_s, exception: false)
191
+ raise Error, "a message number is a positive integer" unless number&.positive?
192
+
193
+ if (old = legacy.find { |m| m.number == number })
194
+ raise Error, "message ##{number} is for #{old.to}, not #{by}" unless old.to == by
195
+
196
+ return old
197
+ end
198
+
199
+ message = state.ack!(number, by:)
200
+ raise Error, "no message ##{number} in #{File.basename(dir)}" unless message
201
+
202
+ from_state(message)
203
+ end
204
+
205
+ # The whole exchange as Markdown, for a person after the round. Returns
206
+ # the text and writes nothing: the JSON is the record, and a rendered
207
+ # copy beside it would be a second one to drift.
208
+ #
209
+ # @return [String]
210
+ def render
211
+ title = "# Mailbox: #{File.basename(dir)}\n"
212
+ return "#{title}\nNo messages.\n" if messages.empty?
213
+
214
+ entries = messages.map do |m|
215
+ "## #{m.number}#{SEPARATOR}#{m.at}#{SEPARATOR}#{m.from} → #{m.to}#{SEPARATOR}" \
216
+ "#{m.read? ? "read" : "unread"}\n\n#{m.body.strip}\n"
217
+ end
218
+ ([title] + entries).join("\n")
219
+ end
220
+
112
221
  private
113
222
 
114
223
  # @return [OpenLanes::Plans::PlanState]
@@ -129,7 +238,32 @@ module OpenLanes
129
238
  rescue ArgumentError
130
239
  message["at"].to_s
131
240
  end
132
- Message.new(number: message["number"], at:, from: message["from"], to: message["to"], body: message["body"])
241
+ Message.new(number: message["number"],
242
+ at:,
243
+ from: message["from"],
244
+ to: message["to"],
245
+ body: message["body"],
246
+ read: message["read"] == "yes" ? "yes" : "no",
247
+ id: message["id"])
248
+ end
249
+
250
+ # mailbox.md has no ids, so a drained copy of one of its messages is
251
+ # recognised by what it says.
252
+ #
253
+ # @param old [OpenLanes::Plans::Mailbox::Message]
254
+ # @param message [Hash]
255
+ # @return [Boolean]
256
+ def same_as_legacy?(old, message)
257
+ old.from == message["from"] && old.to == message["to"] && old.body.strip == message["body"].to_s.strip &&
258
+ time_of(old.at) == time_of(message["at"])
259
+ end
260
+
261
+ # @param text [String, nil]
262
+ # @return [Time, nil]
263
+ def time_of(text)
264
+ Time.parse(text.to_s)
265
+ rescue ArgumentError
266
+ nil
133
267
  end
134
268
 
135
269
  # @param value [Object]
@@ -149,7 +283,14 @@ module OpenLanes
149
283
  text.to_s.each_line(chomp: true) do |line|
150
284
  if (heading = HEADING.match(line))
151
285
  messages << current.with(body: body.join("\n").strip) if current
152
- current = Message.new(number: heading[1].to_i, at: heading[2], from: heading[3], to: heading[4], body: "")
286
+ # mailbox.md predates acking, and nobody is left to ack it: it
287
+ # is history, so it counts as read rather than as a warning forever.
288
+ current = Message.new(number: heading[1].to_i,
289
+ at: heading[2],
290
+ from: heading[3],
291
+ to: heading[4],
292
+ body: "",
293
+ read: "yes")
153
294
  body = []
154
295
  elsif current
155
296
  body << line