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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+
6
+ module OpenLanes
7
+ module Plans
8
+ # `openlanes index` — the project's plans as one browsable page.
9
+ #
10
+ # {Reporter} answers "what is the state of things" in a terminal, in columns,
11
+ # for someone standing at a prompt. This answers "what is this project, and
12
+ # where is everything" for someone arriving at the repository on the web: the
13
+ # goal of each plan in its own words, every pull request as a link, and every
14
+ # document in the folder one click away.
15
+ #
16
+ # It is generated for the same reason the conventions document is. A
17
+ # hand-written index of twenty plans cannot survive a rename, and renaming is
18
+ # exactly what `resync dirs` exists to do — the first hand-made INDEX.md this
19
+ # replaced had all 82 of its links pointing at folder names that had been
20
+ # padded to `NNN.MM` underneath it.
21
+ class Index
22
+ # The file this writes, relative to the `.plans` directory.
23
+ FILENAME = "INDEX.md"
24
+
25
+ # Documents that get a proper name rather than their filename.
26
+ ARTIFACT_NAMES = {
27
+ "spec.md" => "Spec",
28
+ "plan.md" => "Plan",
29
+ "pull-requests.md" => "Pull Requests",
30
+ "linear.md" => "Linear",
31
+ "blocked.md" => "Blocked",
32
+ "delayed.md" => "Deferred",
33
+ "rewrite.md" => "Rewrite",
34
+ "deployed.md" => "Deployed",
35
+ "rollback.md" => "Rollback",
36
+ "discarded.md" => "Discarded"
37
+ }.freeze
38
+
39
+ # @param tree [OpenLanes::Plans::Tree]
40
+ # @param project [String, nil] the heading; defaults to the repository name
41
+ def initialize(tree:, project: nil)
42
+ @tree = tree
43
+ @project = project
44
+ end
45
+
46
+ # @return [OpenLanes::Plans::Tree]
47
+ attr_reader :tree
48
+
49
+ # @return [String] the project's name, titleized from its directory
50
+ def project = @project ||= OpenLanes::Plans.titleize(File.basename(File.dirname(tree.dir)))
51
+
52
+ # @return [String] the whole document
53
+ def render
54
+ [heading, *tree.subjects.map { |subject| section(subject) }].join("\n") + footer
55
+ end
56
+
57
+ # Write it next to the plans it describes.
58
+ #
59
+ # @param path [String, nil] override the destination
60
+ # @return [String] where it was written
61
+ def write(path = nil)
62
+ path ||= File.join(tree.dir, FILENAME)
63
+ File.write(path, render)
64
+ path
65
+ end
66
+
67
+ private
68
+
69
+ # @return [String]
70
+ def heading
71
+ <<~MARKDOWN
72
+ # Project #{project}
73
+
74
+ > [!IMPORTANT]
75
+ > **This file is auto generated.** To regenerate it, run `openlanes index`.
76
+ > Editing it by hand lasts until the next `resync dirs` renames a folder.
77
+
78
+ ## Current specifications and their status
79
+
80
+ MARKDOWN
81
+ end
82
+
83
+ # One plan: a heading that carries its number and state, then a table.
84
+ #
85
+ # Raw HTML rather than a markdown table because the cells are not one-liners
86
+ # — a goal is a paragraph or two and a plan may have a dozen pull requests,
87
+ # neither of which a pipe-delimited row can hold.
88
+ #
89
+ # @param subject [OpenLanes::Plans::Subject]
90
+ # @return [String]
91
+ def section(subject)
92
+ feature = subject.feature
93
+
94
+ <<~MARKDOWN
95
+ ## #{feature.ordinal} — #{feature.status.emoji} #{escape(feature.title)}
96
+
97
+ <table>
98
+ <thead>
99
+ <tr>
100
+ <th align="left">Status</th>
101
+ <th align="left">Pull Requests</th>
102
+ <th align="left">What it is</th>
103
+ <th align="left">Artifacts</th>
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ <tr>
108
+ <td valign="top">#{status_cell(subject)}</td>
109
+ <td valign="top">#{pulls_cell(subject)}</td>
110
+ <td valign="top">#{goal_cell(subject)}</td>
111
+ <td valign="top">#{artifacts_cell(feature)}</td>
112
+ </tr>
113
+ </tbody>
114
+ </table>
115
+
116
+ MARKDOWN
117
+ end
118
+
119
+ # The emoji and the words, kept on one line — a status that wraps mid-label
120
+ # makes the column look like two states rather than one.
121
+ #
122
+ # @param subject [OpenLanes::Plans::Subject]
123
+ # @return [String]
124
+ def status_cell(subject)
125
+ cell = "#{subject.status.emoji}&nbsp;#{escape(subject.status.label)}"
126
+ return cell if subject.consistent?
127
+
128
+ "#{cell}<br><sub>⚠️ #{escape(subject.violation)}</sub>"
129
+ end
130
+
131
+ # @param subject [OpenLanes::Plans::Subject]
132
+ # @return [String]
133
+ def pulls_cell(subject)
134
+ prs = subject.pull_requests
135
+ return "<em>—</em>" if prs.empty?
136
+
137
+ items = prs.map { |pr|
138
+ "<li>#{link(pr.url, pr.label)} #{escape(pr.state)}</li>"
139
+ }
140
+ "<ul>#{items.join}</ul>"
141
+ end
142
+
143
+ # @param subject [OpenLanes::Plans::Subject]
144
+ # @return [String]
145
+ def goal_cell(subject)
146
+ paragraphs = subject.goal
147
+ return "<em>No specification yet.</em>" if paragraphs.empty?
148
+
149
+ paragraphs.map { |p| "<p>#{inline(p)}</p>" }.join
150
+ end
151
+
152
+ # Markdown inside a raw HTML block is not parsed — GitHub stops parsing
153
+ # markdown the moment it sees a block-level tag — so a goal quoted verbatim
154
+ # would show its own `**` and backticks. Only the three that carry meaning
155
+ # are translated; single-underscore emphasis is deliberately left alone,
156
+ # because `deleted_at` is more common in these documents than italics.
157
+ #
158
+ # @param text [String] markdown, unescaped
159
+ # @return [String] HTML
160
+ def inline(text)
161
+ escape(text)
162
+ .gsub(/\[([^\]]+)\]\((https?:[^)\s]+)\)/) { %(<a href="#{$2}">#{$1}</a>) }
163
+ .gsub(/\*\*([^*]+)\*\*/) { "<strong>#{$1}</strong>" }
164
+ .gsub(/`([^`]+)`/) { "<code>#{$1}</code>" }
165
+ .tr("\n", " ")
166
+ end
167
+
168
+ # Every document in the folder, linked. The folder name is percent-encoded
169
+ # because it contains an emoji, and a raw one in an href is a broken link
170
+ # on GitHub.
171
+ #
172
+ # @param feature [OpenLanes::Plans::Feature]
173
+ # @return [String]
174
+ def artifacts_cell(feature)
175
+ files = Dir.children(feature.path).select { |f| File.file?(File.join(feature.path, f)) }
176
+ return "<em>empty</em>" if files.empty?
177
+
178
+ # The documents that carry the lifecycle come first, in lifecycle order;
179
+ # whatever else the folder holds follows alphabetically.
180
+ known = ARTIFACT_NAMES.keys
181
+ files = files.sort_by { |f| [known.index(f) || known.size, f] }
182
+
183
+ items = files.map { |file|
184
+ href = "#{ERB::Util.url_encode(feature.dirname)}/#{ERB::Util.url_encode(file)}"
185
+ "<li>#{link(href, ARTIFACT_NAMES.fetch(file, file))}</li>"
186
+ }
187
+ "<ul>#{items.join}</ul>"
188
+ end
189
+
190
+ # @return [String]
191
+ def footer
192
+ counts = tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status] += 1 }
193
+ .map { |status, n| "#{status.emoji} #{n}" }.join(" &nbsp; ")
194
+
195
+ "\n---\n\n#{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} &nbsp; #{counts}\n"
196
+ end
197
+
198
+ # @param href [String]
199
+ # @param text [String]
200
+ # @return [String]
201
+ def link(href, text) = %(<a href="#{escape(href)}">#{escape(text)}</a>)
202
+
203
+ # @param text [String]
204
+ # @return [String]
205
+ def escape(text) = CGI.escapeHTML(text.to_s)
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module OpenLanes
6
+ module Plans
7
+ # The dated notes an agent leaves in the document it owns.
8
+ #
9
+ # This is the only place in the codebase that knows the syntax. The
10
+ # dispatcher reads it to learn whether an agent finished and who it named
11
+ # next; the state file records it; the screen paints it. An agent writes
12
+ # one entry when it starts and one when it stops, in a GitHub NOTE alert,
13
+ # one entry per line:
14
+ #
15
+ # > [!NOTE]
16
+ # >
17
+ # > [2026-09-04 11:29:20 AM PDT] [ agent: leah-researcher status: Started, round 1 ]
18
+ # > [2026-09-04 11:44:03 AM PDT] [ agent: leah-researcher status: Completed, round 1 ]
19
+ # > [2026-09-04 11:44:04 AM PDT] [ next: yoda-writer ]
20
+ #
21
+ # The verbs are a closed set. A line that looks like an entry but does not
22
+ # parse is reported as a {Problem}, never dropped, because an agent that
23
+ # wrote "Done" has said nothing the harness can act on and silence would
24
+ # read as a stall.
25
+ module Ledger
26
+ # Everything an agent may claim. `Completed` is the only verb a `next:`
27
+ # line may follow.
28
+ STATUSES = ["Started", "Completed", "Almost completed", "Interrupted", "Blocked"].freeze
29
+
30
+ # Local time with the zone spelled out, so an entry written on one
31
+ # machine reads correctly on another.
32
+ TIME_FORMAT = "%Y-%m-%d %I:%M:%S %p %Z"
33
+
34
+ # An entry. `**` around the status is how the harness marks the one line
35
+ # it writes itself, the Interrupted that precedes a signature.
36
+ ENTRY = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*agent:\s*(?<agent>[\w-]+)\s+status:\s*(?<bold>\*\*)?
37
+ (?<status>#{STATUSES.map { |s| Regexp.escape(s) }.join("|")}),\s*round\s*(?<round>\d+)
38
+ (?:\s*\((?<note>[^)]*)\))?\s*(?:\*\*)?\s*\]\s*\z/x
39
+
40
+ # The handoff line.
41
+ NEXT = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*next:\s*(?<next>[\w-]+)\s*\]\s*\z/
42
+
43
+ # A line that is trying to be one of the two above. Anything matching
44
+ # this and neither of those is a {Problem}.
45
+ ATTEMPT = /\A>\s*\[[^\]]+\]\s*\[/
46
+
47
+ # The whole alert block, for {.stripped}.
48
+ BLOCK = /^[ \t]*>[ \t]*\[!NOTE\][ \t]*\n(?:[ \t]*>.*\n?)*/
49
+
50
+ # @!attribute [r] at
51
+ # @return [Time]
52
+ # @!attribute [r] agent
53
+ # @return [String] the definition's name, `leah-researcher`
54
+ # @!attribute [r] status
55
+ # @return [String] one of {STATUSES}
56
+ # @!attribute [r] round
57
+ # @return [Integer]
58
+ # @!attribute [r] note
59
+ # @return [String, nil] the parenthesised tail, `rejected 1/2`
60
+ # @!attribute [r] file
61
+ # @return [String] the document it was read from
62
+ # @!attribute [r] line
63
+ # @return [Integer] 1-based
64
+ # @!attribute [r] bold
65
+ # @return [Boolean] written `**bold**`, which only the harness does
66
+ Entry = Data.define(:at, :agent, :status, :round, :note, :file, :line, :bold) do
67
+ def initialize(note: nil, bold: false, **rest) = super
68
+
69
+ # @return [Boolean]
70
+ def completed? = status == "Completed"
71
+
72
+ # @return [Boolean] whether the agent gets another round
73
+ def retry? = ["Almost completed", "Interrupted"].include?(status)
74
+
75
+ # @return [Boolean]
76
+ def blocked? = status == "Blocked"
77
+ end
78
+
79
+ # @!attribute [r] next
80
+ # @return [String] the agent named
81
+ Handoff = Data.define(:at, :next, :file, :line)
82
+
83
+ # @!attribute [r] text
84
+ # @return [String] the line as written
85
+ Problem = Data.define(:file, :line, :text)
86
+
87
+ # What one read of a plan folder yielded.
88
+ Reading = Data.define(:entries, :handoffs, :problems) do
89
+ # @return [OpenLanes::Plans::Ledger::Reading]
90
+ def self.empty = new(entries: [], handoffs: [], problems: [])
91
+
92
+ # @param other [OpenLanes::Plans::Ledger::Reading]
93
+ # @return [OpenLanes::Plans::Ledger::Reading]
94
+ def +(other)
95
+ self.class.new(entries: entries + other.entries,
96
+ handoffs: handoffs + other.handoffs,
97
+ problems: problems + other.problems)
98
+ end
99
+ end
100
+
101
+ class << self
102
+ # @param text [String] a whole document
103
+ # @param file [String] its name, carried on every entry
104
+ # @return [OpenLanes::Plans::Ledger::Reading]
105
+ def parse(text, file:)
106
+ entries = []
107
+ handoffs = []
108
+ problems = []
109
+ text.to_s.each_line.with_index(1) do |raw, line|
110
+ stripped = raw.chomp
111
+ if (m = ENTRY.match(stripped))
112
+ entries << Entry.new(at: parse_time(m[:at]),
113
+ agent: m[:agent],
114
+ status: m[:status],
115
+ round: m[:round].to_i,
116
+ note: m[:note]&.strip,
117
+ file:,
118
+ line:,
119
+ bold: !m[:bold].nil?)
120
+ elsif (m = NEXT.match(stripped))
121
+ handoffs << Handoff.new(at: parse_time(m[:at]), next: m[:next], file:, line:)
122
+ elsif ATTEMPT.match?(stripped)
123
+ problems << Problem.new(file:, line:, text: stripped.strip)
124
+ end
125
+ end
126
+ Reading.new(entries:, handoffs:, problems:)
127
+ end
128
+
129
+ # Every document an agent may sign, in the order its definition lists
130
+ # them, then the signatures in `state.json`. A missing file is not a
131
+ # problem; an agent that has not reached `pull-requests.md` yet has not
132
+ # written it. `state.json` comes last, so on a tie in time a signature
133
+ # made through `openlanes state sign` outranks a line in prose.
134
+ #
135
+ # @param dir [String] the plan folder
136
+ # @param files [Array<String>]
137
+ # @return [OpenLanes::Plans::Ledger::Reading]
138
+ def read(dir, files)
139
+ markdown(dir, files) + signed(dir)
140
+ end
141
+
142
+ # A `state.json` that will not parse is reported like an unreadable
143
+ # line, not raised: one bad file must not stop the loop for every plan.
144
+ #
145
+ # @param dir [String]
146
+ # @return [OpenLanes::Plans::Ledger::Reading]
147
+ def signed(dir)
148
+ PlanState.for(dir).ledger
149
+ rescue Error => e
150
+ Reading.new(entries: [], handoffs: [], problems: [Problem.new(file: PlanState::FILENAME, line: 1, text: e.message)])
151
+ end
152
+
153
+ # @param dir [String]
154
+ # @param files [Array<String>]
155
+ # @return [OpenLanes::Plans::Ledger::Reading]
156
+ def markdown(dir, files)
157
+ files.each_with_index.reduce(Reading.empty) do |reading, (name, _index)|
158
+ path = File.join(dir, name)
159
+ next reading unless File.file?(path)
160
+
161
+ found = parse(File.read(path, encoding: "UTF-8"), file: name)
162
+ reading + Reading.new(entries: found.entries.map { |e| e.with(file: name) },
163
+ handoffs: found.handoffs,
164
+ problems: found.problems)
165
+ end
166
+ end
167
+
168
+ # @param time [Time]
169
+ # @return [String]
170
+ def timestamp(time = Time.now) = time.strftime(TIME_FORMAT)
171
+
172
+ # Agents write what `date` prints, and `Time.strptime` cannot read a
173
+ # zone abbreviation back into an offset. The abbreviation is dropped
174
+ # and the time read as local, which is right on the machine that wrote
175
+ # it and off by a zone anywhere else, which the tick tolerates: entries
176
+ # are compared with each other, never with the wall clock.
177
+ #
178
+ # @param text [String]
179
+ # @return [Time]
180
+ def parse_time(text)
181
+ Time.strptime(text.to_s.strip.sub(/\s+[A-Z]{2,5}\z/, ""), "%Y-%m-%d %I:%M:%S %p")
182
+ rescue ArgumentError
183
+ Time.at(0)
184
+ end
185
+
186
+ # @param entry [OpenLanes::Plans::Ledger::Entry]
187
+ # @return [String] one quoted line, no newline
188
+ def render(entry)
189
+ tail = entry.note ? " (#{entry.note})" : ""
190
+ body = "#{entry.status}, round #{entry.round}#{tail}"
191
+ body = "**#{body}**" if entry.bold
192
+ "> [#{timestamp(entry.at)}] [ agent: #{entry.agent} status: #{body} ]"
193
+ end
194
+
195
+ # @param handoff [OpenLanes::Plans::Ledger::Handoff]
196
+ # @return [String]
197
+ def render_handoff(handoff) = "> [#{timestamp(handoff.at)}] [ next: #{handoff.next} ]"
198
+
199
+ # @param lines [Array<String>] already quoted
200
+ # @return [String] a NOTE alert, newline-terminated
201
+ def block(*lines) = (["> [!NOTE]", ">"] + lines.flatten).join("\n") + "\n"
202
+
203
+ # Append one block. Separated from what is there by a blank line so the
204
+ # alert renders as its own block rather than merging into a paragraph.
205
+ #
206
+ # @param path [String]
207
+ # @param lines [Array<String>]
208
+ # @return [void]
209
+ def append(path, *lines)
210
+ existing = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
211
+ glue = if existing.empty?
212
+ ""
213
+ elsif existing.end_with?("\n\n")
214
+ ""
215
+ elsif existing.end_with?("\n")
216
+ "\n"
217
+ else
218
+ "\n\n"
219
+ end
220
+ File.write(path, existing + glue + block(*lines))
221
+ end
222
+
223
+ # The entry that tells the harness where this agent stands: the latest
224
+ # by clock, then by the order its documents are listed, then by line.
225
+ #
226
+ # @param reading [OpenLanes::Plans::Ledger::Reading]
227
+ # @param agent [String]
228
+ # @return [OpenLanes::Plans::Ledger::Entry, nil]
229
+ def last_for(reading, agent)
230
+ files = reading.entries.map(&:file).uniq
231
+ reading.entries.select { |e| e.agent == agent }
232
+ .max_by { |e| [e.at, files.index(e.file), e.line] }
233
+ end
234
+
235
+ # The `next:` that belongs to an entry: same file, a later line, and no
236
+ # other entry between the two. A handoff written under somebody else's
237
+ # entry is not this agent's handoff.
238
+ #
239
+ # @param reading [OpenLanes::Plans::Ledger::Reading]
240
+ # @param entry [OpenLanes::Plans::Ledger::Entry]
241
+ # @return [OpenLanes::Plans::Ledger::Handoff, nil]
242
+ def handoff_after(reading, entry)
243
+ candidates = reading.handoffs.select { |h| h.file == entry.file && h.line > entry.line }
244
+ handoff = candidates.min_by(&:line) or return nil
245
+ between = reading.entries.any? { |e| e.file == entry.file && e.line > entry.line && e.line < handoff.line }
246
+ between ? nil : handoff
247
+ end
248
+
249
+ # @param text [String]
250
+ # @return [String] the document without its ledger blocks
251
+ def stripped(text) = text.to_s.gsub(BLOCK, "")
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # The one channel two agents on the same plan have to each other.
6
+ #
7
+ # `luke-backend` and `rey-frontend` build one plan in one worktree at the
8
+ # same time, but each is its own `claude -p` process. Claude Code can
9
+ # message between local sessions, and the prompts used to say so, but a
10
+ # session is named after its directory plus a random suffix, so a pair had
11
+ # to guess its partner from a list of every session on the machine by start
12
+ # time. It worked once, by luck. With ten plans running there would have
13
+ # been ten `openlanes-xx` entries and nothing to tell them apart.
14
+ #
15
+ # So the mailbox lives in the plan folder, append-only, one numbered entry
16
+ # per message, readable by `openlanes mail read` during the round and by a
17
+ # person after it. The harness names the partner in each agent's prompt,
18
+ # so nobody guesses.
19
+ #
20
+ # Messages are kept in the plan's `state.json` ({PlanState}), next to the
21
+ # stages and signatures a restart reads. Plans from before that kept them
22
+ # in `mailbox.md`; those are still read, and numbering carries on from
23
+ # them, but nothing writes there any more.
24
+ class Mailbox
25
+ # Where messages used to be written, and are still read from.
26
+ FILENAME = "mailbox.md"
27
+
28
+ # Between the fields of a heading. Neither a name nor a timestamp
29
+ # contains it, which is what makes the heading parseable.
30
+ SEPARATOR = " · "
31
+
32
+ # `## 3 · 2026-09-06 10:30:53 -0700 · rey-frontend → luke-backend`
33
+ HEADING = /\A\#\# (\d+) · (.+?) · (\S+) → (\S+)\s*\z/
34
+
35
+ TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z"
36
+
37
+ # One entry.
38
+ #
39
+ # @!attribute [r] number
40
+ # @return [Integer] 1-based, in order of writing
41
+ # @!attribute [r] at
42
+ # @return [String] when, as {TIME_FORMAT} writes it
43
+ # @!attribute [r] from
44
+ # @return [String] the agent that wrote it
45
+ # @!attribute [r] to
46
+ # @return [String] the agent it is for
47
+ # @!attribute [r] body
48
+ # @return [String]
49
+ Message = Data.define(:number, :at, :from, :to, :body) do
50
+ # @return [String] as it stands in the file
51
+ def to_s = "## #{number}#{SEPARATOR}#{at}#{SEPARATOR}#{from} → #{to}\n\n#{body.strip}\n"
52
+ end
53
+
54
+ # @param dir [String] the plan folder
55
+ def initialize(dir:)
56
+ @dir = dir
57
+ end
58
+
59
+ # @return [String] the plan folder
60
+ attr_reader :dir
61
+
62
+ # @return [String] where new messages are written
63
+ def path = state.path
64
+
65
+ # @return [String] where messages from before `state.json` are read
66
+ def legacy_path = File.join(dir, FILENAME)
67
+
68
+ # @return [Boolean] whether any message has been written
69
+ def exist? = !messages.empty?
70
+
71
+ # Every message, in the order it was written.
72
+ #
73
+ # @return [Array<OpenLanes::Plans::Mailbox::Message>]
74
+ def messages
75
+ (legacy + state.messages.map { |m| from_state(m) }).sort_by(&:number)
76
+ end
77
+
78
+ # What is waiting for one agent.
79
+ #
80
+ # @param name [String] the reader
81
+ # @param after [Integer] a number the reader has already seen; only
82
+ # messages above it are returned
83
+ # @return [Array<OpenLanes::Plans::Mailbox::Message>]
84
+ def for(name, after: 0)
85
+ messages.select { |m| m.to == name && m.number > after }
86
+ end
87
+
88
+ # Append one message and give it the next number.
89
+ #
90
+ # {PlanState} takes a lock around the read and the write, so two agents
91
+ # finishing a unit in the same second cannot both take the same number.
92
+ #
93
+ # @param from [String]
94
+ # @param to [String]
95
+ # @param body [String]
96
+ # @param at [Time]
97
+ # @param command [String, nil] the page command that sent it; the same
98
+ # id to the same reader appends nothing the second time
99
+ # @return [OpenLanes::Plans::Mailbox::Message] as written, or as first
100
+ # written under +command+
101
+ # @raise [OpenLanes::Error] on an empty body, which would be a heading
102
+ # with nothing under it for the reader to act on, or on a sender or
103
+ # recipient that is blank or holds whitespace
104
+ def append(from:, to:, body:, at: Time.now, command: nil)
105
+ text = body.to_s.strip
106
+ raise Error, "a message needs a body" if text.empty?
107
+ raise Error, "a message needs --from and --to, each one agent name" unless name?(from) && name?(to)
108
+
109
+ from_state(state.message!(from:, to:, body: text, at:, after: legacy.map(&:number).max.to_i, command:))
110
+ end
111
+
112
+ private
113
+
114
+ # @return [OpenLanes::Plans::PlanState]
115
+ def state = PlanState.for(dir)
116
+
117
+ # @return [Array<OpenLanes::Plans::Mailbox::Message>] what mailbox.md holds
118
+ def legacy
119
+ return [] unless File.file?(legacy_path)
120
+
121
+ parse(File.read(legacy_path, encoding: "UTF-8"))
122
+ end
123
+
124
+ # @param message [Hash] as {PlanState} stores it
125
+ # @return [OpenLanes::Plans::Mailbox::Message]
126
+ def from_state(message)
127
+ at = begin
128
+ Time.iso8601(message["at"].to_s).strftime(TIME_FORMAT)
129
+ rescue ArgumentError
130
+ message["at"].to_s
131
+ end
132
+ Message.new(number: message["number"], at:, from: message["from"], to: message["to"], body: message["body"])
133
+ end
134
+
135
+ # @param value [Object]
136
+ # @return [Boolean] one token, as {HEADING} expects a name to be
137
+ def name?(value) = value.to_s.match?(/\A\S+\z/)
138
+
139
+ # Only a line shaped exactly like {HEADING} starts a message, so a body
140
+ # may hold headings of its own, code, anything.
141
+ #
142
+ # @param text [String]
143
+ # @return [Array<OpenLanes::Plans::Mailbox::Message>]
144
+ def parse(text)
145
+ messages = []
146
+ current = nil
147
+ body = []
148
+
149
+ text.to_s.each_line(chomp: true) do |line|
150
+ if (heading = HEADING.match(line))
151
+ 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: "")
153
+ body = []
154
+ elsif current
155
+ body << line
156
+ end
157
+ end
158
+
159
+ messages << current.with(body: body.join("\n").strip) if current
160
+ messages
161
+ end
162
+ end
163
+ end
164
+ end