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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # The stops on the page's board: the {Plans::StateMachine::SPINE}, with
6
+ # the states a person does not need told apart folded into one stop.
7
+ #
8
+ # The grouping and the labels are the page's vocabulary and are written
9
+ # here; which agents work at each stop is not, and comes from each
10
+ # agent's `handles:`. A spec walks the spine to prove every state on it
11
+ # has a stop, in spine order.
12
+ module Stations
13
+ # One stop.
14
+ #
15
+ # @!attribute [r] key
16
+ # @return [Symbol] a spine state's key, or :pr
17
+ # @!attribute [r] label
18
+ # @return [String]
19
+ # @!attribute [r] states
20
+ # @return [Array<Symbol>] the states a plan at this stop may be in
21
+ # @!attribute [r] agents
22
+ # @return [Array<String>] agents whose `handles:` names one of {#states}
23
+ Station = Data.define(:key, :label, :states, :agents) do
24
+ # @return [Hash] `Station` in `types.ts`
25
+ def wire = { key: key.to_s, label:, agents: }
26
+ end
27
+
28
+ # Key, label and the states folded into it, in spine order. ⭐️ Planned
29
+ # sits with 🟡 Building and 🟢 Ready for Review with 👀 In Review,
30
+ # because on the board each pair is one stop with the same agents.
31
+ GROUPS = [
32
+ [:new, "New", %i[new]],
33
+ [:researched, "Researched", %i[researched retroactive]],
34
+ [:ready_for_planning, "Planning", %i[ready_for_planning]],
35
+ [:building, "Building", %i[planned building]],
36
+ [:building_ui, "Building UI", %i[building_ui]],
37
+ [:in_review, "Review", %i[ready_for_review in_review]],
38
+ [:pr, "Pull request", %i[approved deployed]]
39
+ ].freeze
40
+
41
+ module_function
42
+
43
+ # @param registry [OpenLanes::Agents::Registry]
44
+ # @return [Array<Station>]
45
+ def all(registry)
46
+ GROUPS.map do |key, label, states|
47
+ Station.new(key:, label:, states:, agents: registry.all.select { |a| a.handles.intersect?(states) }.map(&:name))
48
+ end
49
+ end
50
+
51
+ # The stop a plan in +state+ is shown at. A state off the spine (🔴,
52
+ # 💩, 😱) is shown where the spine takes it next; one with no way
53
+ # forward (⭕️, 🅱️) where the plan was when it stopped, which only
54
+ # its history knows.
55
+ #
56
+ # @param state [Symbol]
57
+ # @param before [Symbol, nil] the state the plan was worked in last
58
+ # @return [Symbol]
59
+ def key_for(state, before: nil)
60
+ [state, Plans::StateMachine::SPINE[state], before].each do |candidate|
61
+ found = GROUPS.find { |_, _, states| states.include?(candidate) } and return found.first
62
+ end
63
+ GROUPS.first.first
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis-client"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # Redis, narrowed to what the contract uses: strings with an expiry,
8
+ # `SET NX EX` and compare-and-delete for claims, hashes, streams with a
9
+ # consumer group, `SCAN` by prefix, and `MULTI`.
10
+ #
11
+ # Narrow on purpose. {MemoryStore} answers the same methods, and the
12
+ # suite runs one shared set of examples against both, so every method
13
+ # added here is a method the fake must earn too. A store that offered
14
+ # all of Redis would let the engine come to depend on a command the
15
+ # fake only pretends to understand.
16
+ #
17
+ # Every connection failure surfaces as {Unavailable}, so a caller that
18
+ # wants to keep running without Redis rescues one class, not a gem's.
19
+ class Store
20
+ # One stream entry: its id and its fields.
21
+ #
22
+ # @!attribute [r] id
23
+ # @return [String] `<ms>-<seq>`
24
+ # @!attribute [r] fields
25
+ # @return [Hash{String => String}] empty for an entry trimmed away
26
+ # while it was still pending
27
+ Entry = Data.define(:id, :fields)
28
+
29
+ # Deletes the key only when it still holds the value this process wrote.
30
+ # A plain GET then DEL would delete a claim another node took in between.
31
+ COMPARE_AND_DELETE = <<~LUA
32
+ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end
33
+ LUA
34
+
35
+ # Extends the key only when it still holds this process's value, so a
36
+ # renewal can never keep alive a claim that has passed to someone else.
37
+ COMPARE_AND_EXPIRE = <<~LUA
38
+ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end
39
+ LUA
40
+
41
+ # Seconds a connection attempt or a command may take before the store
42
+ # reports Redis as unavailable. Short, because the engine asks on its
43
+ # tick and a hung Redis must not hang the run.
44
+ TIMEOUT = 2.0
45
+
46
+ # The writes {#multi} accepts. Reads inside a transaction answer
47
+ # nothing until it commits, so none are offered.
48
+ WRITES = %i[set del expire hset hdel xadd xack].freeze
49
+
50
+ # Records the writes of one {#multi} block, to be applied together.
51
+ class Transaction
52
+ def initialize
53
+ @calls = []
54
+ end
55
+
56
+ # @return [Array<Array(Symbol, Array, Hash)>]
57
+ attr_reader :calls
58
+
59
+ WRITES.each do |name|
60
+ define_method(name) do |*args, **kwargs|
61
+ @calls << [name, args, kwargs]
62
+ nil
63
+ end
64
+ end
65
+ end
66
+
67
+ # @param url [String]
68
+ # @param timeout [Float] seconds
69
+ def initialize(url:, timeout: TIMEOUT)
70
+ @url = url
71
+ @client = RedisClient.config(url:, timeout:, reconnect_attempts: 1).new_client
72
+ end
73
+
74
+ # @return [String]
75
+ attr_reader :url
76
+
77
+ # @return [Boolean] whether Redis answered
78
+ def ping = call("PING") == "PONG"
79
+
80
+ # @return [void]
81
+ def close = @client.close
82
+
83
+ # ---- strings ----------------------------------------------------------
84
+
85
+ # @param key [String]
86
+ # @return [String, nil]
87
+ def get(key) = call("GET", key)
88
+
89
+ # @param key [String]
90
+ # @param value [String]
91
+ # @param ex [Integer, nil] seconds to live
92
+ # @param nx [Boolean] only when the key does not exist
93
+ # @return [Boolean] whether the value was written
94
+ def set(key, value, ex: nil, nx: false) = call(*set_command(key, value, ex:, nx:)) == "OK"
95
+
96
+ # @param keys [Array<String>]
97
+ # @return [Integer] how many were removed
98
+ def del(*keys) = keys.empty? ? 0 : call("DEL", *keys)
99
+
100
+ # @param key [String]
101
+ # @param seconds [Integer]
102
+ # @return [Boolean] whether the key exists to expire
103
+ def expire(key, seconds) = call("EXPIRE", key, seconds) == 1
104
+
105
+ # @param key [String]
106
+ # @return [Integer] seconds left, -1 without an expiry, -2 when absent
107
+ def ttl(key) = call("TTL", key)
108
+
109
+ # @param key [String]
110
+ # @param expected [String]
111
+ # @return [Boolean] whether the key held +expected+ and is now gone
112
+ def compare_and_delete(key, expected) = call("EVAL", COMPARE_AND_DELETE, 1, key, expected) == 1
113
+
114
+ # @param key [String]
115
+ # @param expected [String]
116
+ # @param seconds [Integer]
117
+ # @return [Boolean] whether the key held +expected+ and was extended
118
+ def compare_and_expire(key, expected, seconds)
119
+ call("EVAL", COMPARE_AND_EXPIRE, 1, key, expected, seconds) == 1
120
+ end
121
+
122
+ # ---- hashes -----------------------------------------------------------
123
+
124
+ # @return [Boolean] whether the field is new
125
+ def hset(key, field, value) = call("HSET", key, field, value) == 1
126
+
127
+ # @return [String, nil]
128
+ def hget(key, field) = call("HGET", key, field)
129
+
130
+ # @return [Hash{String => String}]
131
+ def hgetall(key) = call("HGETALL", key).to_h
132
+
133
+ # @return [Integer] how many fields were removed
134
+ def hdel(key, *fields) = fields.empty? ? 0 : call("HDEL", key, *fields)
135
+
136
+ # ---- streams ----------------------------------------------------------
137
+
138
+ # @param key [String]
139
+ # @param fields [Hash{#to_s => #to_s}]
140
+ # @param maxlen [Integer, nil] trimmed to about this many entries
141
+ # @return [String] the new entry's id
142
+ def xadd(key, fields, maxlen: nil) = call(*xadd_command(key, fields, maxlen:))
143
+
144
+ # @return [Integer] entries in the stream
145
+ def xlen(key) = call("XLEN", key)
146
+
147
+ # Creates the group, and the stream with it. Asking twice is not an
148
+ # error: every engine start asks.
149
+ #
150
+ # @param key [String]
151
+ # @param group [String]
152
+ # @param from [String] "0" to deliver what is already there, "$" for
153
+ # only what arrives later
154
+ # @return [Boolean] whether the group is new
155
+ def xgroup_create(key, group, from: "0")
156
+ call("XGROUP", "CREATE", key, group, from, "MKSTREAM") == "OK"
157
+ rescue RedisClient::CommandError => e
158
+ raise unless e.message.start_with?("BUSYGROUP")
159
+
160
+ false
161
+ end
162
+
163
+ # @param key [String]
164
+ # @param group [String]
165
+ # @param consumer [String]
166
+ # @param id [String] ">" for entries never delivered, "0" for this
167
+ # consumer's delivered and unacknowledged ones
168
+ # @param count [Integer]
169
+ # @return [Array<Entry>]
170
+ def xreadgroup(key, group, consumer, id: ">", count: 100)
171
+ entries(call("XREADGROUP", "GROUP", group, consumer, "COUNT", count, "STREAMS", key, id)&.fetch(key, nil))
172
+ end
173
+
174
+ # @return [Integer] how many were acknowledged
175
+ def xack(key, group, *ids) = ids.empty? ? 0 : call("XACK", key, group, *ids)
176
+
177
+ # Entries after +id+. With +block+ it waits up to that many
178
+ # milliseconds for one to arrive.
179
+ #
180
+ # @param key [String]
181
+ # @param id [String] "$" for only what arrives from now
182
+ # @param count [Integer]
183
+ # @param block [Integer, nil] milliseconds
184
+ # @return [Array<Entry>]
185
+ def xread(key, id: "0", count: 100, block: nil)
186
+ reply = if block
187
+ guarded { @client.blocking_call((block / 1000.0) + TIMEOUT, "XREAD", "BLOCK", block, "COUNT", count, "STREAMS", key, id) }
188
+ else
189
+ call("XREAD", "COUNT", count, "STREAMS", key, id)
190
+ end
191
+ entries(reply&.fetch(key, nil))
192
+ end
193
+
194
+ # @return [Array<Entry>] oldest first
195
+ def xrange(key, start: "-", stop: "+", count: 100) = entries(call("XRANGE", key, start, stop, "COUNT", count))
196
+
197
+ # @return [Array<Entry>] newest first
198
+ def xrevrange(key, stop: "+", start: "-", count: 100) = entries(call("XREVRANGE", key, stop, start, "COUNT", count))
199
+
200
+ # ---- keys -------------------------------------------------------------
201
+
202
+ # Every key starting with +prefix+, by cursor, so a large keyspace is
203
+ # never asked for in one blocking `KEYS`.
204
+ #
205
+ # @param prefix [String]
206
+ # @return [Array<String>]
207
+ def scan(prefix)
208
+ cursor = "0"
209
+ found = []
210
+ loop do
211
+ cursor, batch = call("SCAN", cursor, "MATCH", "#{glob_escape(prefix)}*", "COUNT", 500)
212
+ found.concat(batch)
213
+ break if cursor == "0"
214
+ end
215
+ found.uniq.sort
216
+ end
217
+
218
+ # ---- transactions -----------------------------------------------------
219
+
220
+ # The writes the block makes, applied atomically.
221
+ #
222
+ # @yieldparam tx [Transaction]
223
+ # @return [void]
224
+ def multi
225
+ tx = Transaction.new
226
+ yield tx
227
+ return if tx.calls.empty?
228
+
229
+ guarded { @client.multi { |t| tx.calls.each { |name, args, kwargs| t.call(*command_for(name, args, kwargs)) } } }
230
+ nil
231
+ end
232
+
233
+ private
234
+
235
+ # @return [Object] the reply
236
+ def call(*command) = guarded { @client.call(*command) }
237
+
238
+ # @return [Object] the block's value
239
+ # @raise [OpenLanes::Live::Unavailable]
240
+ def guarded
241
+ yield
242
+ rescue RedisClient::CommandError
243
+ raise
244
+ rescue RedisClient::Error => e
245
+ raise Unavailable, "Redis at #{Live.mask(url)} is unavailable: #{e.message.sub(/\s*\(redis:.*\)\z/, "")}"
246
+ end
247
+
248
+ # @return [Array] the raw command for one transaction write
249
+ def command_for(name, args, kwargs)
250
+ case name
251
+ when :set then set_command(*args, **kwargs)
252
+ when :xadd then xadd_command(*args, **kwargs)
253
+ else [name.to_s.upcase, *args]
254
+ end
255
+ end
256
+
257
+ # @return [Array]
258
+ def set_command(key, value, ex: nil, nx: false)
259
+ ["SET", key, value, *("NX" if nx), *(["EX", ex] if ex)]
260
+ end
261
+
262
+ # @return [Array]
263
+ def xadd_command(key, fields, maxlen: nil)
264
+ ["XADD", key, *(["MAXLEN", "~", maxlen] if maxlen), "*", *fields.flat_map { |k, v| [k.to_s, v.to_s] }]
265
+ end
266
+
267
+ # @param raw [Array, nil] `[[id, [field, value, ...]], ...]`
268
+ # @return [Array<Entry>]
269
+ def entries(raw)
270
+ Array(raw).map { |id, pairs| Entry.new(id:, fields: Array(pairs).each_slice(2).to_h) }
271
+ end
272
+
273
+ # @return [String] +prefix+ with glob metacharacters taken literally
274
+ def glob_escape(prefix) = prefix.gsub(/[*?\[\]\\]/) { "\\#{it}" }
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ # The live run in Redis (plan 004): the engine publishes its board there
7
+ # and reads the page's commands from there, and machines sharing one Redis
8
+ # see each other's boards and never take each other's plans (W6, W13, W26).
9
+ #
10
+ # The engine builds a {Session}. The API server reads boards with {.board},
11
+ # queues commands with {.enqueue} and follows answers with {.replies}, all
12
+ # over the same {Keys}, so neither side spells a key.
13
+ module Live
14
+ # Where Redis is when nothing says otherwise (W16).
15
+ DEFAULT_URL = "redis://localhost:6379/0"
16
+
17
+ # Redis could not be reached, or stopped answering.
18
+ class Unavailable < Error; end
19
+
20
+ module_function
21
+
22
+ # W16's order: `--redis-url`, then `.openlanes.yml`'s `redis_url:`, then
23
+ # `$REDIS_URL`, then {DEFAULT_URL}.
24
+ #
25
+ # @param option [String, nil] what `--redis-url` typed
26
+ # @param config [OpenLanes::Config]
27
+ # @param env [Hash]
28
+ # @return [String]
29
+ def redis_url(option: nil, config: Config.current, env: ENV)
30
+ [option, config.redis_url, env["REDIS_URL"]].map { it.to_s.strip }.find { !it.empty? } || DEFAULT_URL
31
+ end
32
+
33
+ # The address with any password replaced, for logs and the page.
34
+ #
35
+ # @param url [String]
36
+ # @return [String]
37
+ def mask(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)([^:@/]*):[^@/]+@}i, '\1\2:***@')
38
+
39
+ # @param url [String]
40
+ # @return [OpenLanes::Live::Store]
41
+ def store(url) = Store.new(url:)
42
+
43
+ # @return [OpenLanes::Live::MemoryStore]
44
+ def memory_store(**) = MemoryStore.new(**)
45
+
46
+ # A store that has answered, or the reason there is none.
47
+ #
48
+ # @param url [String]
49
+ # @return [OpenLanes::Live::Store]
50
+ # @raise [OpenLanes::Live::Unavailable]
51
+ def connect(url)
52
+ store = Store.new(url:)
53
+ store.ping or raise Unavailable, "Redis at #{mask(url)} did not answer PING"
54
+ store
55
+ end
56
+
57
+ # @return [OpenLanes::Live::Session]
58
+ def session(**) = Session.new(**)
59
+
60
+ # This checkout's keys, for the API server beside its engine.
61
+ #
62
+ # @param root [String]
63
+ # @return [OpenLanes::Live::Keys]
64
+ def keys(root:, node: Node.current(root:)) = Keys.new(repo: Repo.for(root), node: node.name)
65
+
66
+ # `Board` in `web/src/api/types.ts`: this node's snapshot and every other
67
+ # live node's on the same repository. A node is live while its snapshot
68
+ # key exists; one whose key has expired is left out.
69
+ #
70
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
71
+ # @param keys [OpenLanes::Live::Keys]
72
+ # @return [Hash] `{me: Hash or nil, others: Array<Hash>}`, parsed JSON
73
+ def board(store:, keys:)
74
+ registered = store.hgetall(keys.nodes).keys
75
+ snapshots = (registered | [keys.node]).to_h { |name| [name, store.get(keys.snapshot(name))] }.compact
76
+ # A node is in the registry for as long as it heartbeats; one that died
77
+ # without closing never takes itself out, so whoever reads prunes it.
78
+ store.hdel(keys.nodes, *(registered - snapshots.keys))
79
+ others = snapshots.except(keys.node).values.map { JSON.parse(it) }
80
+ others.each { |snapshot| snapshot["node"]["me"] = false }
81
+ { me: snapshots[keys.node]&.then { JSON.parse(it) }, others: others.sort_by { it.dig("node", "host").to_s } }
82
+ end
83
+
84
+ # Queue one command for this node's engine.
85
+ #
86
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
87
+ # @param keys [OpenLanes::Live::Keys]
88
+ # @param input [Hash, String] a `Command`
89
+ # @return [OpenLanes::Live::Reply] accepted
90
+ # @raise [OpenLanes::Live::Command::Invalid] for the API server's 422
91
+ def enqueue(store:, keys:, input:)
92
+ command = Command.parse(input)
93
+ store.xadd(keys.commands, { json: JSON.generate(command.wire) })
94
+ Reply.new(id: command.id, status: "accepted")
95
+ end
96
+
97
+ # Replies after +after+, oldest first, with the stream id of each so the
98
+ # caller can carry on from the last.
99
+ #
100
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
101
+ # @param keys [OpenLanes::Live::Keys]
102
+ # @param after [String] a stream id, "0" for all kept, "$" for new only
103
+ # @param block [Integer, nil] milliseconds to wait for one
104
+ # @return [Array<Array(String, Hash)>] stream id and the `Reply`
105
+ def replies(store:, keys:, after: "$", block: nil)
106
+ store.xread(keys.replies, id: after, block:).map { |entry| [entry.id, JSON.parse(entry.fields["json"])] }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # Telling "this belongs to no specification" apart from "nobody could tell".
6
+ #
7
+ # Both end up without a plan number, and filing them under one marker loses
8
+ # the difference between a finished thought and an open question. A
9
+ # dependency bump genuinely implements no specification and never will —
10
+ # that is an assertion, and `[dev]` records it. A pull request that adds
11
+ # Schedule K-1 for four tax years plainly implements *something*; nothing
12
+ # here could work out what, and `[none]` records that instead, as a job left
13
+ # for a human rather than a verdict.
14
+ #
15
+ # The bar for `[dev]` is deliberately high. Marking real work as `[dev]`
16
+ # tells everyone afterwards it was never worth a plan, and nobody re-opens a
17
+ # question that looks settled. `[none]` is visibly unfinished, so the cost of
18
+ # guessing wrong in that direction is somebody spending a minute on it.
19
+ module DevWork
20
+ module_function
21
+
22
+ # Titles that say what they are: dependency bumps, release chores, CI.
23
+ TITLES = [
24
+ /\Abump\s+\S+\s+from\s+\S+\s+to\s+\S+/i,
25
+ /\A(?:chore|ci|build|deps|style|refactor)(?:\([^)]*\))?:/i,
26
+ /\Abump\s+(?:to\s+)?v?\d+\.\d+/i
27
+ ].freeze
28
+
29
+ # Paths that are how the project is built rather than what it does.
30
+ PLUMBING = %r{\A(?:\.github/|\.circleci/|\.devcontainer/|bin/|scripts/|
31
+ Dockerfile|\.dockerignore|Gemfile(?:\.lock)?|justfile|Rakefile|
32
+ \.rubocop\.ya?ml|\.standard\.ya?ml|\.gitignore|\.tool-versions)}x
33
+
34
+ # Files that say nothing either way, so they neither prove plumbing nor
35
+ # disprove it — every pull request in this tool's world touches `.plans`.
36
+ NEUTRAL = %r{\A(?:#{PLANS_DIR}/|CHANGELOG|README)}
37
+
38
+ # @param title [String]
39
+ # @param files [Array<String>] paths in the diff
40
+ # @return [Boolean] whether this can be *asserted* to implement no plan
41
+ def developer?(title, files = [])
42
+ return true if TITLES.any? { |pattern| title.to_s.match?(pattern) }
43
+
44
+ judged = Array(files).reject { |path| path.match?(NEUTRAL) }
45
+ return false if judged.empty?
46
+
47
+ judged.all? { |path| path.match?(PLUMBING) }
48
+ end
49
+
50
+ # @param title [String]
51
+ # @param files [Array<String>]
52
+ # @return [String] the marker to stamp on it
53
+ def marker(title, files = []) = developer?(title, files) ? NO_PLAN_PREFIX : NONE_PREFIX
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # Lowercase in the middle of a title, capitalised at the front.
6
+ SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
7
+
8
+ # Slug words that are really acronyms and should shout.
9
+ ACRONYMS = %w[
10
+ abac ai api aws cdn ci cd cli cms cors cpu crm css csv db dns dsl e2e ec2 etl gcp gdpr gpu gui
11
+ html http https iam id ide jwt json k8s llm ml mcp mvp npm oauth orm otp pdf pii poc pr prs qa
12
+ rbac rds rest rls rpc rss s3 saas sdk seo sns spa sql sqs sre ssh sso ssl ssr tls tui ts tsx ui
13
+ ux uuid vpc vpn xml yaml
14
+ ].freeze
15
+
16
+ # Words with a house spelling that neither capitalize nor upcase gets right.
17
+ SPECIAL_CASE = {
18
+ "github" => "GitHub", "gitlab" => "GitLab", "graphql" => "GraphQL", "ios" => "iOS",
19
+ "javascript" => "JavaScript", "macos" => "macOS", "nodejs" => "Node.js", "oauth" => "OAuth",
20
+ "openai" => "OpenAI", "postgres" => "PostgreSQL", "postgresql" => "PostgreSQL",
21
+ "typescript" => "TypeScript", "uuidv7" => "UUIDv7", "websocket" => "WebSocket"
22
+ }.freeze
23
+
24
+ # Turn a kebab slug into a proper name: `law-as-data` → "Law as Data".
25
+ #
26
+ # @param slug [String]
27
+ # @return [String]
28
+ def self.titleize(slug)
29
+ words = slug.to_s.split(/[-_\s.]+/).reject(&:empty?)
30
+ return "" if words.empty?
31
+
32
+ words.each_with_index.map { |word, i|
33
+ lower = word.downcase
34
+ if SPECIAL_CASE.key?(lower)
35
+ SPECIAL_CASE[lower]
36
+ elsif ACRONYMS.include?(lower)
37
+ lower.upcase
38
+ elsif i.positive? && SMALL_WORDS.include?(lower)
39
+ lower
40
+ elsif lower.match?(/\A\d+\z/)
41
+ lower
42
+ else
43
+ lower.sub(/\A./, &:upcase)
44
+ end
45
+ }.join(" ")
46
+ end
47
+
48
+ # The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
49
+ #
50
+ # The spaced arrow after the emoji is deliberate: an emoji renders two cells
51
+ # wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
52
+ # as if the emoji and the slug were touching. Everything that mints or
53
+ # renames a folder goes through here; {Feature.parse} accepts the older
54
+ # `--` and single-dash spellings too, and `resync dirs` migrates them on
55
+ # contact.
56
+ #
57
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
58
+ # @param status [OpenLanes::Plans::Status]
59
+ # @param slug [String]
60
+ # @return [String]
61
+ def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
62
+
63
+ # One `NNN.MM-<emoji> → <slug>` folder, decoded.
64
+ #
65
+ # @!attribute [r] ordinal
66
+ # @return [OpenLanes::Plans::Ordinal]
67
+ # @!attribute [r] status
68
+ # @return [OpenLanes::Plans::Status]
69
+ # @!attribute [r] slug
70
+ # @return [String]
71
+ # @!attribute [r] dirname
72
+ # @return [String] exactly as it is on disk
73
+ # @!attribute [r] path
74
+ # @return [String] absolute
75
+ Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
76
+ include Comparable
77
+
78
+ # Decode a folder name, or return nil when it is not a plan folder.
79
+ #
80
+ # @param path [String] absolute path to a candidate directory
81
+ # @return [OpenLanes::Plans::Feature, nil]
82
+ def self.parse(path)
83
+ dirname = File.basename(path)
84
+ ordinal = Ordinal.from_dirname(dirname) or return nil
85
+
86
+ rest = dirname.sub(/\A[\d.]+[-_]/, "")
87
+ head, tail = rest.split(/\s*→\s*|[-_]/, 2)
88
+
89
+ # A leading segment with no ASCII word character is the status emoji.
90
+ # The slug strips any further separators: the canonical spelling puts
91
+ # a spaced arrow after the emoji, and folders from before that rule put
92
+ # two dashes, or one.
93
+ status = (OpenLanes::Plans.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
94
+ slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
95
+
96
+ new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
97
+ end
98
+
99
+ # @return [String] proper name, e.g. "Law as Data"
100
+ def title = OpenLanes::Plans.titleize(slug)
101
+
102
+ # The folder name this feature would carry in a given state — the slug
103
+ # never moves, and the number is always rendered canonically, so this is
104
+ # also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
105
+ #
106
+ # @param status [OpenLanes::Plans::Status]
107
+ # @return [String]
108
+ def dirname_as(status) = OpenLanes::Plans.plan_dirname(ordinal, status, slug)
109
+
110
+ # The number exactly as the folder writes it, which is not always the
111
+ # canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
112
+ # "018.00".
113
+ #
114
+ # @return [String]
115
+ def dirname_ordinal = dirname.to_s[/\A[\d.]+/].to_s
116
+
117
+ # @return [Boolean] whether the number is written in full `NNN.MM` form
118
+ def padded? = dirname_ordinal == ordinal.to_s
119
+
120
+ # Whether the folder is already named the way this tool would name it —
121
+ # number padded, emoji matching the state it claims, slug unchanged.
122
+ #
123
+ # @return [Boolean]
124
+ def canonical? = dirname == dirname_as(status)
125
+
126
+ # @param other [Object]
127
+ # @return [Integer, nil]
128
+ def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
129
+ end
130
+ end
131
+ end