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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # A plan's number, and therefore its identity. Set once when the folder is
6
+ # created and never changed: branch names, pull request titles and every
7
+ # `pull-requests.md` join on it, and renumbering breaks all of them silently.
8
+ #
9
+ # Always rendered `NNN.MM`. `002.00` is an ordinary plan, specified before it
10
+ # was built. `002.01` is retroactive — work that shipped between 002 and 003
11
+ # and was documented afterwards. The decimal marks a **sibling**, not
12
+ # containment: 002.01 is not part of 002.
13
+ #
14
+ # `000` is where the sequence starts, so the first plan of a project is
15
+ # `000.00`. It also absorbs the older meaning — work that predates the plan
16
+ # discipline — since either way it sorts first, which is where it belongs.
17
+ #
18
+ # @!attribute [r] major
19
+ # @return [Integer] 0..999
20
+ # @!attribute [r] minor
21
+ # @return [Integer] 0..99; zero for an ordinary plan
22
+ class Ordinal < Data.define(:major, :minor)
23
+ include Comparable
24
+
25
+ # The canonical form, plus the bare `NNN` that trees written before this
26
+ # rule still use and must remain readable.
27
+ PATTERN = /\A(\d{1,3})(?:\.(\d{1,2}))?\z/
28
+
29
+ # The most retroactive slots a single gap can hold.
30
+ MAX_MINOR = 99
31
+
32
+ # @param text [String, nil] e.g. "3", "003", "003.00", "018.01"
33
+ # @return [OpenLanes::Plans::Ordinal, nil] nil when it is not a plan number
34
+ def self.parse(text)
35
+ m = PATTERN.match(text.to_s.strip)
36
+ m && new(major: m[1].to_i, minor: m[2].to_i)
37
+ end
38
+
39
+ # Pull the number off the front of a folder name.
40
+ #
41
+ # @param dirname [String] e.g. "018.01-✅--verify-against-filed-returns"
42
+ # @return [OpenLanes::Plans::Ordinal, nil]
43
+ def self.from_dirname(dirname) = parse(dirname.to_s[/\A[\d.]+/])
44
+
45
+ # The next ordinary plan after everything in +existing+.
46
+ #
47
+ # The first plan in an empty tree is `000.00`, not `001.00`: the sequence
48
+ # counts from zero so that the very first specification — usually the
49
+ # project's own — sorts above everything and needs no gap reserved for it.
50
+ #
51
+ # @param existing [Array<OpenLanes::Plans::Ordinal>]
52
+ # @return [OpenLanes::Plans::Ordinal]
53
+ def self.next_major(existing)
54
+ return new(major: 0, minor: 0) if existing.empty?
55
+
56
+ new(major: existing.map(&:major).max + 1, minor: 0)
57
+ end
58
+
59
+ # The next retroactive slot in the gap after +major+.
60
+ #
61
+ # @param existing [Array<OpenLanes::Plans::Ordinal>]
62
+ # @param major [Integer] the plan the work landed after
63
+ # @return [OpenLanes::Plans::Ordinal]
64
+ # @raise [OpenLanes::Error] when the gap is full
65
+ def self.next_minor(existing, major:)
66
+ taken = existing.select { |o| o.major == major }.map(&:minor).max || 0
67
+ if taken >= MAX_MINOR
68
+ raise Error, "all #{MAX_MINOR} retroactive slots after #{format("%03d", major)} are taken"
69
+ end
70
+
71
+ new(major:, minor: taken + 1)
72
+ end
73
+
74
+ # @return [Boolean] whether this plan was documented after the fact
75
+ def retroactive? = minor.positive?
76
+
77
+ # @return [String] the canonical `NNN.MM`
78
+ def to_s = format("%03d.%02d", major, minor)
79
+
80
+ # @return [String] what a pull request title carries
81
+ def to_prefix = "[#{self}]"
82
+
83
+ # @param other [Object]
84
+ # @return [Integer, nil]
85
+ def <=>(other)
86
+ return nil unless other.is_a?(self.class)
87
+
88
+ [major, minor] <=> [other.major, other.minor]
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "socket"
7
+ require "time"
8
+ require "tmpdir"
9
+
10
+ module OpenLanes
11
+ module Plans
12
+ # `state.json` in a plan folder: what has happened to this plan, in a form
13
+ # a program can read without parsing prose.
14
+ #
15
+ # Three kinds of record live here, written by three kinds of writer:
16
+ #
17
+ # stages the harness: one per agent per round, Started when it is
18
+ # dispatched and settled when it returns, with the adapter,
19
+ # model, effort, tokens and seconds it ran on
20
+ # signatures the agents, through `openlanes state sign`: the ledger
21
+ # lines they used to append to plan.md and spec.md
22
+ # messages the agents, through `openlanes mail send`: what a pair
23
+ # says to each other, and the RESUME notes an interrupted
24
+ # agent leaves for its successor
25
+ #
26
+ # A stage still Started whose harness process is gone is a crash, which is
27
+ # how a restarted run finds what it has to resume.
28
+ #
29
+ # The folder name stays the source of truth for the plan's state; `state`
30
+ # here is the last state the harness moved the folder to, kept so the file
31
+ # is readable on its own.
32
+ #
33
+ # Every write takes a lock, re-reads, changes, and renames a temporary file
34
+ # over the old one, so two agents signing in the same second cannot lose
35
+ # each other's line, and a reader never sees half a file. The lock lives in
36
+ # the system temp dir, keyed by the plan's number, because the folder it
37
+ # protects is renamed under it as the plan moves.
38
+ class PlanState
39
+ FILENAME = "state.json"
40
+
41
+ # Bumped when a field changes meaning. Readers accept older versions.
42
+ VERSION = 1
43
+
44
+ # Where the schema is published, so an editor can validate the file.
45
+ SCHEMA_ID = "https://github.com/kigster/openlanes/blob/main/schemas/plan-state.schema.json"
46
+
47
+ # The schema in this checkout.
48
+ SCHEMA_PATH = File.expand_path("../../../schemas/plan-state.schema.json", __dir__)
49
+
50
+ # @param dir [String] a plan folder
51
+ # @return [OpenLanes::Plans::PlanState]
52
+ def self.for(dir) = new(dir:)
53
+
54
+ # @param dir [String] a plan folder
55
+ def initialize(dir:)
56
+ @dir = dir
57
+ end
58
+
59
+ # @return [String]
60
+ attr_reader :dir
61
+
62
+ # @return [String]
63
+ def path = File.join(dir, FILENAME)
64
+
65
+ # @return [Boolean]
66
+ def exist? = File.file?(path)
67
+
68
+ # The whole document, or a fresh one when there is none yet.
69
+ #
70
+ # @return [Hash]
71
+ def read
72
+ return skeleton unless exist?
73
+
74
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
75
+ data.is_a?(Hash) ? skeleton.merge(data) : skeleton
76
+ rescue JSON::ParserError => e
77
+ raise Error, "#{path} is not valid JSON: #{e.message.lines.first.strip}"
78
+ end
79
+
80
+ # Change the document under the lock and write it back atomically.
81
+ #
82
+ # @yieldparam data [Hash] mutate in place
83
+ # @return [Hash] what was written
84
+ def update
85
+ locked do
86
+ data = read
87
+ yield data
88
+ data["plan"] = identity
89
+ data["updated_at"] = Time.now.iso8601
90
+ write(data)
91
+ data
92
+ end
93
+ end
94
+
95
+ # @return [Array<Hash>]
96
+ def stages = read["stages"]
97
+
98
+ # @return [Array<Hash>]
99
+ def signatures = read["signatures"]
100
+
101
+ # @return [Array<Hash>]
102
+ def messages = read["messages"]
103
+
104
+ # @return [String, nil]
105
+ def state = read["state"]
106
+
107
+ # ---- the harness --------------------------------------------------------
108
+
109
+ # Record or update one agent's round. A second call for the same agent
110
+ # and round updates the first, so a round is one record from dispatch
111
+ # to settlement.
112
+ #
113
+ # @param agent [String]
114
+ # @param round [Integer]
115
+ # @param fields [Hash] status, started_at, finished_at, adapter, model,
116
+ # effort, up, down, seconds, note, to, run
117
+ # @return [Hash] the stage as written
118
+ def stage!(agent:, round:, **fields)
119
+ stage = nil
120
+ update do |data|
121
+ stage = data["stages"].find { |s| s["agent"] == agent && s["round"] == round }
122
+ unless stage
123
+ stage = { "agent" => agent, "round" => round }
124
+ data["stages"] << stage
125
+ end
126
+ stage.merge!(fields.transform_keys(&:to_s).compact)
127
+ end
128
+ stage
129
+ end
130
+
131
+ # @param key [Symbol, String] the state the folder was just moved to
132
+ # @return [void]
133
+ def state!(key)
134
+ update { |data| data["state"] = key.to_s }
135
+ end
136
+
137
+ # Stages a dead harness left Started. Nobody is working them, and nobody
138
+ # will finish them unless a run picks them up.
139
+ #
140
+ # @param alive [Proc] pid → Boolean
141
+ # @param pid [Integer] this run, which is never its own crash
142
+ # @return [Array<Hash>]
143
+ def stranded(alive: method(:alive?), pid: Process.pid)
144
+ stages.select do |s|
145
+ run = s["run"] || {}
146
+ s["status"] == "Started" && run["pid"] != pid && run["host"] == Socket.gethostname && !alive.call(run["pid"])
147
+ end
148
+ end
149
+
150
+ # ---- the agents -------------------------------------------------------
151
+
152
+ # One ledger line, in JSON. What `openlanes state sign` writes.
153
+ #
154
+ # @param agent [String]
155
+ # @param round [Integer]
156
+ # @param status [String] one of {Ledger::STATUSES}
157
+ # @param note [String, nil]
158
+ # @param next_agent [String, nil] only after Completed
159
+ # @param at [Time]
160
+ # @return [Hash] the signature as written
161
+ # @raise [OpenLanes::Error] for a status outside the ledger's vocabulary,
162
+ # or a handoff after anything but Completed
163
+ def sign!(agent:, round:, status:, note: nil, next_agent: nil, at: Time.now)
164
+ raise Error, "status must be one of #{Ledger::STATUSES.join(", ")}" unless Ledger::STATUSES.include?(status)
165
+ raise Error, "only a Completed signature may name who is next" if next_agent && status != "Completed"
166
+ raise Error, "a signature needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
167
+
168
+ signature = { "at" => at.iso8601, "agent" => agent, "status" => status, "round" => Integer(round),
169
+ "note" => note, "next" => next_agent }.compact
170
+ update { |data| data["signatures"] << signature }
171
+ signature
172
+ end
173
+
174
+ # Append one message and give it the next number.
175
+ #
176
+ # @param from [String]
177
+ # @param to [String]
178
+ # @param body [String]
179
+ # @param at [Time]
180
+ # @param after [Integer] numbers already taken elsewhere, so a plan
181
+ # whose older messages are in mailbox.md keeps counting from there
182
+ # @param command [String, nil] the id of the page's command that sent
183
+ # it. A command is applied at least once (W13), so a second append
184
+ # under the same id, to the same reader, returns the first and adds
185
+ # nothing: an engine that died between the append and recording the
186
+ # reply does not say the same thing twice.
187
+ # @return [Hash]
188
+ def message!(from:, to:, body:, at: Time.now, after: 0, command: nil)
189
+ message = nil
190
+ update do |data|
191
+ message = command && data["messages"].find { |m| m["command"] == command && m["to"] == to }
192
+ next if message
193
+
194
+ number = [data["messages"].map { |m| m["number"] }.max.to_i, after].max + 1
195
+ message = { "number" => number, "at" => at.iso8601, "from" => from, "to" => to, "body" => body }
196
+ message["command"] = command if command
197
+ data["messages"] << message
198
+ end
199
+ message
200
+ end
201
+
202
+ # The signatures as ledger entries, so the dispatcher reads them exactly
203
+ # as it reads a line in plan.md. Each signature takes two line numbers,
204
+ # the entry and its handoff, so {Ledger.handoff_after} pairs them.
205
+ #
206
+ # @return [OpenLanes::Plans::Ledger::Reading]
207
+ def ledger
208
+ entries = []
209
+ handoffs = []
210
+ signatures.each_with_index do |sig, index|
211
+ at = parse_time(sig["at"])
212
+ line = (index * 2) + 1
213
+ entries << Ledger::Entry.new(at:,
214
+ agent: sig["agent"],
215
+ status: sig["status"],
216
+ round: sig["round"].to_i,
217
+ note: sig["note"],
218
+ file: FILENAME,
219
+ line:)
220
+ handoffs << Ledger::Handoff.new(at:, next: sig["next"], file: FILENAME, line: line + 1) if sig["next"]
221
+ end
222
+ Ledger::Reading.new(entries:, handoffs:, problems: [])
223
+ end
224
+
225
+ private
226
+
227
+ # @return [Hash]
228
+ def skeleton
229
+ { "$schema" => SCHEMA_ID, "version" => VERSION, "plan" => identity, "state" => nil,
230
+ "updated_at" => nil, "stages" => [], "signatures" => [], "messages" => [] }
231
+ end
232
+
233
+ # The plan's number and slug, from the folder name it has right now.
234
+ #
235
+ # @return [Hash]
236
+ def identity
237
+ feature = Feature.parse(dir)
238
+ feature ? { "ordinal" => feature.ordinal.to_s, "slug" => feature.slug } : { "ordinal" => nil, "slug" => File.basename(dir) }
239
+ end
240
+
241
+ # @param data [Hash]
242
+ # @return [void]
243
+ def write(data)
244
+ tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
245
+ File.write(tmp, "#{JSON.pretty_generate(data)}\n")
246
+ File.rename(tmp, path)
247
+ ensure
248
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
249
+ end
250
+
251
+ # @return [Object] the block's value
252
+ def locked
253
+ lock = File.join(Dir.tmpdir, "openlanes-plan-#{lock_key}.lock")
254
+ File.open(lock, File::RDWR | File::CREAT, 0o644) do |f|
255
+ f.flock(File::LOCK_EX)
256
+ yield
257
+ end
258
+ end
259
+
260
+ # The plans directory and the plan's number: stable while the folder's
261
+ # name changes underneath.
262
+ #
263
+ # @return [String]
264
+ def lock_key
265
+ plans = File.expand_path(File.dirname(dir))
266
+ Digest::SHA256.hexdigest("#{plans}\0#{identity["ordinal"] || File.basename(dir)}")[0, 16]
267
+ end
268
+
269
+ # @param text [String, nil]
270
+ # @return [Time]
271
+ def parse_time(text)
272
+ Time.iso8601(text.to_s)
273
+ rescue ArgumentError
274
+ Time.at(0)
275
+ end
276
+
277
+ # @param pid [Integer, nil]
278
+ # @return [Boolean]
279
+ def alive?(pid)
280
+ return false unless pid.is_a?(Integer) && pid.positive?
281
+
282
+ Process.kill(0, pid)
283
+ true
284
+ rescue Errno::ESRCH
285
+ false
286
+ rescue Errno::EPERM
287
+ true
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # One row of a plan's `pull-requests.md`.
6
+ #
7
+ # @!attribute [r] number
8
+ # @return [String, nil] as written, without the `#`
9
+ # @!attribute [r] title
10
+ # @return [String]
11
+ # @!attribute [r] url
12
+ # @return [String, nil]
13
+ # @!attribute [r] state
14
+ # @return [String] normalised words plus emoji, e.g. "Open 🟡"
15
+ PullRequest = Data.define(:number, :title, :url, :state) do
16
+ # Still awaiting a decision. A closed-unmerged pull request is finished
17
+ # business, so it is neither open nor merged.
18
+ #
19
+ # @return [Boolean]
20
+ def open? = state.match?(/\b(?:open|wip|draft)\b/i)
21
+
22
+ # @return [Boolean]
23
+ def merged? = state.match?(/\bmerged\b/i) && !state.match?(/\bunmerged\b/i)
24
+
25
+ # @return [String] "#92 — Send mail through Resend"
26
+ def label = number ? "##{number} — #{title}" : title
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # Extracts the pull request roll-up from a plan folder.
6
+ class PullRequests
7
+ # A markdown link whose text may contain escaped brackets, because every
8
+ # title this tool writes now opens with `\[NNN.MM\]`. Matching `[^\]]+`
9
+ # stops at the escaped `]` and loses the URL with it.
10
+ LINK = /\[((?:\\.|[^\]\\])+)\]\((https?:[^)\s]+)\)/
11
+
12
+ # Canonical wording for the states we recognise, most specific first.
13
+ STATES = [
14
+ [/\bclosed\b|\bunmerged\b|\babandoned\b/i, "Closed 🔴"],
15
+ [/\bmerged\b/i, "Merged 🟣"],
16
+ [/\bdraft\b|\bwip\b/i, "WIP 🟡"],
17
+ [/\bopen\b/i, "Open 🟡"]
18
+ ].freeze
19
+
20
+ # Filenames that may hold the table.
21
+ CANDIDATES = %w[pull-requests.md pull_requests.md prs.md].freeze
22
+
23
+ # The file this class writes, and the first one it looks for.
24
+ FILENAME = CANDIDATES.first
25
+
26
+ # Render `pull-requests.md` for a set of pull requests fetched from GitHub.
27
+ #
28
+ # The table is what {#parse} reads back and what the state machine judges
29
+ # a folder by. The descriptions below it are for a reader — human or
30
+ # agent — synthesizing a specification from work that already shipped:
31
+ # the table says *which* pull requests, the bodies say *what they did*,
32
+ # and a retroactive `spec.md` cannot be written from numbers alone.
33
+ #
34
+ # Prose after the table is ignored by the parser, so the two can coexist
35
+ # in one file rather than needing a scratch file the folder rules forbid.
36
+ #
37
+ # @param prs [Array<Hash>] `{number:, title:, url:, state:, body:}`
38
+ # @return [String]
39
+ def self.render(prs)
40
+ rows = prs.map { |pr|
41
+ "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |"
42
+ }
43
+
44
+ <<~MARKDOWN
45
+ # Pull Requests
46
+
47
+ | Pull Request Number | Pull Request Name | Status |
48
+ | ------------------: | :---------------- | -----: |
49
+ #{rows.join("\n")}
50
+
51
+ ## What these pull requests did
52
+
53
+ #{prs.map { |pr| describe(pr) }.join("\n")}
54
+ MARKDOWN
55
+ end
56
+
57
+ # The table alone, for splicing into a file that already says other
58
+ # things: the ledger blocks luke and rey write before a pull request
59
+ # exists. Rewriting the whole file deleted their signatures.
60
+ #
61
+ # @param prs [Array<Hash>]
62
+ # @return [String] header, rule and rows, newline-terminated
63
+ def self.table(prs)
64
+ rows = prs.map { |pr| "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |" }
65
+ ["| Pull Request Number | Pull Request Name | Status |",
66
+ "| ------------------: | :---------------- | -----: |", *rows].join("\n") + "\n"
67
+ end
68
+
69
+ # Write the table into +path+ without touching anything else there.
70
+ #
71
+ # @param path [String]
72
+ # @param prs [Array<Hash>]
73
+ # @return [void]
74
+ def self.upsert(path, prs)
75
+ return File.write(path, render(prs)) unless File.file?(path)
76
+
77
+ lines = File.read(path, encoding: "UTF-8").lines
78
+ start = lines.index { |l| l.strip.start_with?("|") && lines[lines.index(l) + 1].to_s.strip.start_with?("|") }
79
+ if start
80
+ stop = start
81
+ stop += 1 while lines[stop + 1]&.strip&.start_with?("|")
82
+ lines[start..stop] = [table(prs)]
83
+ else
84
+ at = lines.index { |l| l.start_with?("# ") }
85
+ insertion = ["\n", table(prs)]
86
+ at ? lines.insert(at + 1, *insertion) : lines.unshift("# Pull Requests\n", *insertion, "\n")
87
+ end
88
+ File.write(path, lines.join)
89
+ end
90
+
91
+ # @param pr [Hash]
92
+ # @return [String]
93
+ def self.describe(pr)
94
+ body = pr[:body].to_s.strip
95
+ body = "_No description was written on the pull request._" if body.empty?
96
+
97
+ <<~MARKDOWN
98
+ ### ##{pr[:number]} — #{pr[:title]}
99
+
100
+ #{pr[:url]}
101
+
102
+ #{body}
103
+ MARKDOWN
104
+ end
105
+
106
+ # Three characters have to survive a round trip through a table cell.
107
+ #
108
+ # A `|` ends the cell, and a title containing one is not unusual —
109
+ # "fix: guard against a || b".
110
+ #
111
+ # `[` and `]` are newer and cost more. Every title this tool writes now
112
+ # opens with a plan number, so the row reads `[[013.00] Ship it](url)` —
113
+ # and a markdown link whose text starts with `[` does not parse. The
114
+ # title came back with the URL glued to it and `url` came back nil, which
115
+ # is not a cosmetic loss: it is every pull request link on the page, and
116
+ # the attachment on every Linear issue.
117
+ #
118
+ # @param text [String]
119
+ # @return [String]
120
+ def self.escape(text)
121
+ text.to_s.gsub(/([\[\]|])/) { "\\#{$1}" }.gsub(/\s+/, " ").strip
122
+ end
123
+
124
+ # @param dir [String] absolute path to the plan folder
125
+ def initialize(dir:)
126
+ @dir = dir
127
+ end
128
+
129
+ # @return [Array<OpenLanes::Plans::PullRequest>] possibly empty
130
+ def all = @all ||= parse
131
+
132
+ private
133
+
134
+ # @return [String]
135
+ attr_reader :dir
136
+
137
+ # @return [Array<OpenLanes::Plans::PullRequest>]
138
+ def parse
139
+ path = CANDIDATES.map { |f| File.join(dir, f) }.find { |p| File.file?(p) }
140
+ return [] unless path
141
+
142
+ text = File.read(path, encoding: "UTF-8")
143
+ table = pick_table(Markdown.tables(text))
144
+ return scrape(text) unless table
145
+
146
+ rows = table[:rows].filter_map { |cells| row_to_pr(cells, columns(table[:header])) }
147
+ rows.empty? ? scrape(text) : rows
148
+ end
149
+
150
+ # @param tables [Array<Hash>]
151
+ # @return [Hash, nil]
152
+ def pick_table(tables)
153
+ tables.find { |t|
154
+ t[:header].any? { |h| h.match?(/pull\s*request|\bpr\b|\A#\z|\Anumber\z/i) } &&
155
+ t[:rows].any? { |r| r.any? { |c| c.match?(%r{/(?:pull|merge_requests)/\d+}) } }
156
+ } || tables.find { |t| t[:rows].any? { |r| r.any? { |c| c.match?(%r{/pull/\d+}) } } }
157
+ end
158
+
159
+ # @param header [Array<String>]
160
+ # @return [Hash{Symbol => Integer, nil}]
161
+ def columns(header)
162
+ {
163
+ number: header.index { |h| h.match?(/number|\A#\z|\Apr\z|\Apr\s*#/i) },
164
+ title: header.index { |h| h.match?(/name|title|summary|description|scope/i) },
165
+ state: header.index { |h| h.match?(/status|state/i) }
166
+ }
167
+ end
168
+
169
+ # @param cells [Array<String>]
170
+ # @param idx [Hash{Symbol => Integer, nil}]
171
+ # @return [OpenLanes::Plans::PullRequest, nil]
172
+ def row_to_pr(cells, idx)
173
+ title_cell = cells[idx[:title] || 1].to_s
174
+
175
+ # The title comes from the title column and the URL from wherever it is.
176
+ # Scanning every cell for the first link and taking both from it reads
177
+ # `| [#24](...) | Split verified into signed_off |` as an issue called
178
+ # "#24", because the number column is a link too and comes first.
179
+ titled = title_cell.match(LINK)
180
+ link = titled || cells.filter_map { |c| c.match(LINK) }.first
181
+
182
+ url = link && link[2]
183
+ title = titled ? titled[1] : unformat(title_cell)
184
+ title = title.gsub(/\\([\[\]|\\])/, '\1') # undo the escaping {.render} applies
185
+ number = cells[idx[:number] || 0].to_s[/\d+/] || url&.[](%r{/(?:pull|merge_requests)/(\d+)}, 1)
186
+ return nil if title.empty? && url.nil?
187
+
188
+ PullRequest.new(number:,
189
+ url:,
190
+ title: title.empty? ? "Pull request" : title,
191
+ state: normalize(cells[idx[:state] || -1].to_s))
192
+ end
193
+
194
+ # When there is no parsable table, scrape bare links so a folder that
195
+ # plainly has pull requests does not report none.
196
+ #
197
+ # @param text [String]
198
+ # @return [Array<OpenLanes::Plans::PullRequest>]
199
+ def scrape(text)
200
+ text.scan(%r{https?://\S+?/(?:pull|merge_requests)/(\d+)}).flatten.uniq.map do |num|
201
+ PullRequest.new(number: num,
202
+ title: "Pull request ##{num}",
203
+ state: "Unknown",
204
+ url: text[%r{https?://\S+?/(?:pull|merge_requests)/#{num}\b}])
205
+ end
206
+ end
207
+
208
+ # Strip the markdown a title was wrapped in without eating the title.
209
+ #
210
+ # An underscore between letters is not emphasis, it is an identifier —
211
+ # `signed_off`, `as_of`, `pull_requests`. Removing every underscore turned
212
+ # "Split verified into signed_off" into "signedoff" on the way to Linear.
213
+ #
214
+ # @param cell [String]
215
+ # @return [String]
216
+ def unformat(cell)
217
+ cell.to_s.gsub(/[*`]/, "").sub(/\A_+/, "").sub(/_+\z/, "").strip
218
+ end
219
+
220
+ # @param cell [String]
221
+ # @return [String]
222
+ def normalize(cell)
223
+ STATES.each { |pattern, label| return label if cell.match?(pattern) }
224
+ stripped = cell.gsub(/[*_`]/, "").strip
225
+ stripped.empty? ? "Unknown" : stripped
226
+ end
227
+ end
228
+ end
229
+ end