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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # What the author of `spec.md` asked of the harness, in its frontmatter:
6
+ #
7
+ # ---
8
+ # lane: quick # full (default) | plan | quick
9
+ # frontend: false # true forces rey-frontend, false forbids it
10
+ # depth: fast # fast | medium | deep
11
+ # phases:
12
+ # build: { adapter: codex, model: gpt-5-codex, effort: medium }
13
+ # review: { model: opus }
14
+ # implementation_suggestions:
15
+ # - Ruby, with dry-cli for the command line
16
+ # - Reuse the gems already in the Gemfile before adding new ones
17
+ # implementation_requirements:
18
+ # - The database is PG-strict
19
+ # task-completed-when:
20
+ # - `openlanes create` opens an editor and writes no draft
21
+ # how-to-verify:
22
+ # - bundle exec rspec spec/openlanes/cli/create_spec.rb
23
+ # ---
24
+ #
25
+ # The body is the specification; this is how it should be worked. A value
26
+ # this does not recognise is ignored and named in {#problems}, rather than
27
+ # stopping the run: a typo in `lane:` should cost the author their lane,
28
+ # not their plan.
29
+ #
30
+ # @!attribute [r] lane
31
+ # @return [Symbol] :full, :plan or :quick
32
+ # @!attribute [r] frontend
33
+ # @return [Boolean, nil] nil when the plan decides
34
+ # @!attribute [r] depth
35
+ # @return [Symbol, nil] :fast, :medium or :deep
36
+ # @!attribute [r] phases
37
+ # @return [Hash{String => Hash{String => String}}] per-phase
38
+ # `adapter`, `model` and `effort`
39
+ # @!attribute [r] suggestions
40
+ # @return [Array<String>] the author's `implementation_suggestions`:
41
+ # language, libraries, an approach. Offered to the agents, never
42
+ # imposed on them.
43
+ # @!attribute [r] requirements
44
+ # @return [Array<String>] the author's `implementation_requirements`:
45
+ # constraints the work must meet exactly as written.
46
+ # @!attribute [r] completed_when
47
+ # @return [Array<String>] the author's `task-completed-when`: what
48
+ # must be true for the task to count as finished.
49
+ # @!attribute [r] how_to_verify
50
+ # @return [Array<String>] the author's `how-to-verify`: the commands or
51
+ # checks that show it is.
52
+ # @!attribute [r] problems
53
+ # @return [Array<String>] what was ignored, and why
54
+ Spec = Data.define(:lane,
55
+ :frontend,
56
+ :depth,
57
+ :phases,
58
+ :suggestions,
59
+ :requirements,
60
+ :completed_when,
61
+ :how_to_verify,
62
+ :problems) do
63
+ def initialize(lane: :full, frontend: nil, depth: nil, phases: {}, suggestions: [], requirements: [],
64
+ completed_when: [], how_to_verify: [], problems: [])
65
+ super
66
+ end
67
+
68
+ # @param phase [String, Symbol, nil]
69
+ # @return [Hash{String => String}] what the author set for that phase
70
+ def override_for(phase) = phases.fetch(phase.to_s, {})
71
+
72
+ # @return [Boolean]
73
+ def default? = self == Spec.new
74
+ end
75
+
76
+ class Spec
77
+ # Every route a plan can take through the agents.
78
+ # full — research, specification, planning, build, review
79
+ # plan — the spec is already what the author wants: plan, build, review
80
+ # quick — a short mechanical task: one agent builds it, then review
81
+ LANES = %i[full plan quick].freeze
82
+
83
+ # How much each phase should think, and what an eval holds it to.
84
+ DEPTHS = %i[fast medium deep].freeze
85
+
86
+ # What a depth means as an effort, when nothing more specific is set.
87
+ EFFORT_FOR_DEPTH = { fast: "low", medium: "medium", deep: "high" }.freeze
88
+
89
+ # The keys a `phases:` entry may set.
90
+ PHASE_KEYS = %w[adapter model effort].freeze
91
+
92
+ # The frontmatter key for what the author would build it with. Optional,
93
+ # and a suggestion: an agent that finds a better route takes it and says
94
+ # why, so nothing here is validated against what is installed.
95
+ SUGGESTIONS_KEY = "implementation_suggestions"
96
+
97
+ # The other side of {SUGGESTIONS_KEY}: constraints the agents must meet
98
+ # exactly as written (`The database is PG-strict`). They are not weighed
99
+ # against anything, so an agent that cannot meet one signs Blocked
100
+ # instead of substituting its own.
101
+ REQUIREMENTS_KEY = "implementation_requirements"
102
+
103
+ # How the author says the task is finished, and how to tell. Hyphenated
104
+ # because that is how the author named them; they are copied into
105
+ # `plan.md` by {OpenLanes::Lifecycle::Completion}.
106
+ COMPLETED_WHEN_KEY = "task-completed-when"
107
+ HOW_TO_VERIFY_KEY = "how-to-verify"
108
+
109
+ FILENAME = "spec.md"
110
+
111
+ class << self
112
+ # @param subject [#read]
113
+ # @return [OpenLanes::Plans::Spec]
114
+ def for(subject) = parse(subject.read(FILENAME).to_s)
115
+
116
+ # @param dir [String] a plan folder
117
+ # @return [OpenLanes::Plans::Spec]
118
+ def load(dir)
119
+ path = File.join(dir, FILENAME)
120
+ File.file?(path) ? parse(File.read(path, encoding: "UTF-8")) : new
121
+ end
122
+
123
+ # @param text [String] a whole spec.md
124
+ # @return [OpenLanes::Plans::Spec]
125
+ def parse(text)
126
+ meta, = Frontmatter.split(text)
127
+ from(meta)
128
+ rescue Psych::Exception => e
129
+ new(problems: ["spec.md frontmatter is not valid YAML (#{e.message.lines.first.strip})"])
130
+ end
131
+
132
+ # @param meta [Hash]
133
+ # @return [OpenLanes::Plans::Spec]
134
+ def from(meta)
135
+ problems = []
136
+ new(lane: choice(meta, "lane", LANES, :full, problems),
137
+ frontend: switch(meta, "frontend", problems),
138
+ depth: choice(meta, "depth", DEPTHS, nil, problems),
139
+ phases: phases(meta["phases"], problems),
140
+ suggestions: notes(meta, SUGGESTIONS_KEY, problems),
141
+ requirements: notes(meta, REQUIREMENTS_KEY, problems),
142
+ completed_when: notes(meta, COMPLETED_WHEN_KEY, problems),
143
+ how_to_verify: notes(meta, HOW_TO_VERIFY_KEY, problems),
144
+ problems:)
145
+ end
146
+
147
+ private
148
+
149
+ # @return [Symbol, nil]
150
+ def choice(meta, key, allowed, default, problems)
151
+ return default unless meta.key?(key)
152
+
153
+ value = meta[key].to_s.strip.downcase.to_sym
154
+ return value if allowed.include?(value)
155
+
156
+ problems << "#{key}: #{meta[key].inspect} is not one of #{allowed.join(", ")}"
157
+ default
158
+ end
159
+
160
+ # @return [Boolean, nil]
161
+ def switch(meta, key, problems)
162
+ return nil unless meta.key?(key)
163
+ return meta[key] if [true, false].include?(meta[key])
164
+
165
+ problems << "#{key}: #{meta[key].inspect} is not true or false"
166
+ nil
167
+ end
168
+
169
+ # A bare string is one item; a list is several.
170
+ #
171
+ # @param key [String] one of the keys above that holds free text
172
+ # @return [Array<String>]
173
+ def notes(meta, key, problems)
174
+ return [] if meta[key].nil?
175
+
176
+ items = meta[key].is_a?(Array) ? meta[key] : [meta[key]]
177
+ unless items.all? { |item| item.is_a?(String) || item.is_a?(Numeric) }
178
+ problems << "#{key}: must be text or a list of text"
179
+ return []
180
+ end
181
+
182
+ items.map { |item| item.to_s.strip }.reject(&:empty?)
183
+ end
184
+
185
+ # @return [Hash]
186
+ def phases(value, problems)
187
+ return {} if value.nil?
188
+
189
+ unless value.is_a?(Hash)
190
+ problems << "phases: must be a mapping of phase to adapter, model and effort"
191
+ return {}
192
+ end
193
+
194
+ value.each_with_object({}) do |(phase, settings), out|
195
+ next problems << "phases.#{phase}: must be a mapping" unless settings.is_a?(Hash)
196
+
197
+ kept = settings.transform_keys(&:to_s).slice(*PHASE_KEYS).transform_values(&:to_s)
198
+ (settings.keys.map(&:to_s) - PHASE_KEYS).each { |k| problems << "phases.#{phase}.#{k}: unknown key" }
199
+ if kept.key?("effort") && !Adapters.effort?(kept["effort"])
200
+ problems << "phases.#{phase}.effort: #{kept["effort"].inspect} is not one of #{Adapters::EFFORTS.join(", ")}"
201
+ kept.delete("effort")
202
+ end
203
+ if kept.key?("adapter") && !Adapters.known?(kept["adapter"])
204
+ problems << "phases.#{phase}.adapter: #{kept["adapter"].inspect} is not one of #{Adapters.names.join(", ")}"
205
+ kept.delete("adapter")
206
+ end
207
+ out[phase.to_s] = kept
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aasm"
4
+
5
+ module OpenLanes
6
+ module Plans
7
+ # The state machine for one plan folder.
8
+ #
9
+ # The whole topology is the `aasm` block below, and it is nowhere else. To
10
+ # change what may follow what, edit an event's `from:` list. To add a state,
11
+ # add it to {STATUSES} and give it an event. There is no second table to keep
12
+ # in step: {inbound}, {outbound}, {edge?} and `terminal?` are all read back
13
+ # off these declarations.
14
+ #
15
+ # Two rules hold the rest together:
16
+ #
17
+ # 1. **The guard for entering a state is that state's own invariant.** There
18
+ # is one guard method, {#justified?}, and it reads the destination off the
19
+ # transition in flight — so a destination is named once, in `to:`.
20
+ # 2. **The state lives in the folder name.** There is no column and no
21
+ # database. A successful transition renames the directory, which is why
22
+ # firing an event is a real side effect and asking a question is not.
23
+ class StateMachine
24
+ include AASM
25
+
26
+ # Raised when a transition is refused, carrying the reason rather than
27
+ # AASM's "failed callback #17".
28
+ class Refused < Error; end
29
+
30
+ # The forward path a bare {#promote!} walks. Everything off this spine —
31
+ # blocking, deferring, discarding — has to be named explicitly, which is
32
+ # the entire reason for having a machine rather than a rename.
33
+ #
34
+ # 🔴 rejoins at 🎨 rather than continuing: fixing review comments puts the
35
+ # work back through both halves of building, it does not skip the reviewer
36
+ # and it does not assume the half nobody complained about still holds.
37
+ SPINE = {
38
+ retroactive: :planned,
39
+ new: :researched,
40
+ researched: :ready_for_planning,
41
+ ready_for_planning: :planned,
42
+ planned: :building,
43
+ building: :building_ui,
44
+ building_ui: :ready_for_review,
45
+ ready_for_review: :in_review,
46
+ in_review: :approved,
47
+ approved: :deployed,
48
+ rejected: :building_ui,
49
+ rolled_back: :ready_for_review,
50
+ shit: :planned
51
+ }.freeze
52
+
53
+ # One step back along the spine, for the one move that goes backwards: a
54
+ # person discarding an interrupted round (W9) sends the plan back to the
55
+ # state the work started from. Derived from {SPINE}, walking it from ⚪️:
56
+ # each state on that path steps back to the one before it, as far as 👀,
57
+ # the last state an agent works in. States off the path (🔴 💩 ⏪ and
58
+ # the birth state) have no single state behind them and step back
59
+ # nowhere.
60
+ RETREAT = Enumerator.produce(:new) { SPINE.fetch(it) { raise StopIteration } }
61
+ .take_while { it != :approved }
62
+ .each_cons(2).to_h { |before, after| [after, before] }
63
+ .freeze
64
+
65
+ # Preference order when several states fit a folder's contents at once.
66
+ # Read top to bottom: the loudest fact wins. A discard outranks everything,
67
+ # a block outranks progress, and `retroactive` is last because it is the
68
+ # absence of documents rather than the presence of any.
69
+ PREFERENCE = %i[
70
+ discarded rolled_back shit deferred blocked product_blocked
71
+ deployed approved rejected in_review ready_for_review building_ui building planned ready_for_planning
72
+ researched new
73
+ retroactive
74
+ ].freeze
75
+
76
+ # States whose members a folder's *contents* cannot tell apart. Within a
77
+ # family the current name always wins, because re-deriving it would be a
78
+ # guess dressed up as a correction.
79
+ #
80
+ # ⭕️ and 🅱️ both mean "a human must decide before this moves"; *which*
81
+ # human is recorded nowhere but the emoji.
82
+ #
83
+ # ⭐️ 🟡 🎨 look identical on disk: a `spec.md` and a `plan.md` with work
84
+ # units in it. Whether nobody has started, the back end is under way, or
85
+ # the interface is, is recorded only by the agent that renamed the folder
86
+ # when it started. 🟢 👀 🔴 add an open pull request and are just as
87
+ # mute about whether a reviewer has seen it. So `resync` never moves
88
+ # between any of them; they advance by events alone.
89
+ #
90
+ # Order matters: the members run weakest claim first, and a folder
91
+ # arriving from outside lands on the weakest member its contents fit.
92
+ # Contents that fit the family justify only its floor, never its ceiling.
93
+ FAMILIES = [
94
+ %i[blocked product_blocked],
95
+ %i[planned building building_ui ready_for_review in_review rejected]
96
+ ].freeze
97
+
98
+ # States the agent loop leaves alone: work that is finished (✅ 😎), work
99
+ # that was dropped (❌), and work waiting on a human (⭕️ 🅱️ ☢️).
100
+ # Everything else is fair game for a specialist.
101
+ #
102
+ # ✅ Approved is here deliberately. `hansolo-reviewer` advancing a plan to
103
+ # it is the end of the loop, not a step in it: nothing merges, and no agent
104
+ # handles `approved`. Merging is the one act in this lifecycle that changes
105
+ # a branch everyone else builds on, and an autonomous loop that does it
106
+ # unattended has no way to be wrong quietly.
107
+ #
108
+ # Moving `approved` out of this list is therefore a decision about blast
109
+ # radius rather than about topology. If it ever moves, something has to own
110
+ # `approved -> deployed`, and today nothing does.
111
+ SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
112
+
113
+ # Every state an agent that may sign `Blocked` works in. A block the
114
+ # machine refuses is reported as a failed attempt and parks nothing, so a
115
+ # state missing here turns an honest "I need a decision" into a failure.
116
+ # The agents spec checks every prompt that describes blocking against it.
117
+ #
118
+ # Review is absent: a reviewer rules, it does not ask.
119
+ BLOCKABLE = %i[new researched retroactive ready_for_planning planned building building_ui rejected].freeze
120
+
121
+ # Rerouting a transition below makes the hand-drawn
122
+ # `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
123
+ # the mermaid source in the generated document is derived from this block.
124
+ aasm do
125
+ # The vocabulary is defined once, in {STATUSES}. This machine may not
126
+ # quietly know a different set.
127
+ OpenLanes::Plans::STATUSES.each { |status| state status.key }
128
+
129
+ event :specify, guard: :justified? do
130
+ transitions from: %i[retroactive blocked product_blocked deferred], to: :new
131
+ end
132
+
133
+ event :research, guard: :justified? do
134
+ transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
135
+ end
136
+
137
+ # The specification is done and the planner has not started. Entered
138
+ # by the harness when `yoda-writer` completes; `resync` reaches it on
139
+ # a blank `plan.md`. Without it ⭐️ Planned required a file only the
140
+ # agent handling ⭐️ Planned writes, and nothing could enter it.
141
+ event :ready_to_plan, guard: :justified? do
142
+ transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
143
+ to: :ready_for_planning
144
+ end
145
+
146
+ # `new` stays in this list. The spine routes a plan through research, and
147
+ # that is what the agent loop follows — but a specification somebody has
148
+ # already researched by hand should not have to pretend otherwise to get
149
+ # planned. What research buys is not enforced here; it is enforced by
150
+ # `yoda-writer` handling `researched` and nothing else.
151
+ event :plan, guard: :justified? do
152
+ transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked deferred],
153
+ to: :planned
154
+ end
155
+
156
+ event :build, guard: :justified? do
157
+ transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked deferred],
158
+ to: :building
159
+ end
160
+
161
+ # Building hands off rather than finishing. A plan with no interface work
162
+ # still passes through Building UI: `rey-frontend` says there is nothing
163
+ # to build and moves it on, which costs one cheap round and keeps the
164
+ # spine one shape instead of two. `rejected` re-enters here rather than
165
+ # going straight back to review, because a change request reopens the
166
+ # implementation and neither half can assume the other still holds.
167
+ event :build_ui, guard: :justified? do
168
+ transitions from: %i[building rejected], to: :building_ui
169
+ end
170
+
171
+ # The pair lands one pull request, and whichever half finishes last
172
+ # carries the plan to review from wherever the folder stands.
173
+ event :submit, guard: :justified? do
174
+ transitions from: %i[building building_ui rejected rolled_back], to: :ready_for_review
175
+ end
176
+
177
+ event :review, guard: :justified? do
178
+ transitions from: %i[ready_for_review], to: :in_review
179
+ end
180
+
181
+ event :approve, guard: :justified? do
182
+ transitions from: %i[in_review retroactive], to: :approved
183
+ end
184
+
185
+ event :request_changes, guard: :justified? do
186
+ transitions from: %i[in_review], to: :rejected
187
+ end
188
+
189
+ event :slop, guard: :justified? do
190
+ transitions from: %i[in_review], to: :shit
191
+ end
192
+
193
+ event :deploy, guard: :justified? do
194
+ transitions from: %i[approved], to: :deployed
195
+ end
196
+
197
+ # 🚀 is not the end of the line. A release that breaks in production comes
198
+ # back through ⏪, which needs its own proof on disk: by the time work is
199
+ # deployed every pull request is merged, so 🟢's "at least one open pull
200
+ # request" guard would refuse a direct return, permanently.
201
+ event :rollback, guard: :justified? do
202
+ transitions from: %i[deployed], to: :rolled_back
203
+ end
204
+
205
+ event :block, guard: :justified? do
206
+ transitions from: BLOCKABLE, to: :blocked
207
+ end
208
+
209
+ event :block_on_product, guard: :justified? do
210
+ transitions from: BLOCKABLE, to: :product_blocked
211
+ end
212
+
213
+ event :defer, guard: :justified? do
214
+ transitions from: BLOCKABLE + %i[blocked product_blocked], to: :deferred
215
+ end
216
+
217
+ # Anything, from anywhere, may be dropped for good.
218
+ event :discard, guard: :justified? do
219
+ transitions from: OpenLanes::Plans::STATUSES.map(&:key) - %i[discarded], to: :discarded
220
+ end
221
+
222
+ # Back one step, fired only by {#retreat!}. It is the one event with
223
+ # several destinations, and it is left out of {inbound}, {outbound}
224
+ # and {event_for}: those describe how work moves on, which is what
225
+ # the documentation, the diagram and {#may?} are about, and a person
226
+ # sending work back is not a way for it to move on.
227
+ event :step_back, guard: :justified? do
228
+ RETREAT.each { |from, to| transitions from:, to: }
229
+ end
230
+
231
+ # A transition is not a bookkeeping entry: it is the folder moving.
232
+ after_all_transitions :rename!
233
+ end
234
+
235
+ class << self
236
+ # Destination => the event that reaches it. Every event above has exactly
237
+ # one `to:`, which is what lets `--to <state>` name a destination rather
238
+ # than making callers learn the verbs.
239
+ #
240
+ # @return [Hash{Symbol => Symbol}]
241
+ def event_for
242
+ @event_for ||= forward_events.each_with_object({}) { |event, map|
243
+ event.transitions.each { |t| map[t.to] ||= event.name }
244
+ }.freeze
245
+ end
246
+
247
+ # Destination => the states that may legitimately reach it.
248
+ #
249
+ # `retroactive` is absent: it is a birth state, produced by
250
+ # `create --after` for work that shipped undocumented.
251
+ #
252
+ # @return [Hash{Symbol => Array<Symbol>}]
253
+ def inbound
254
+ @inbound ||= forward_events.each_with_object({}) { |event, map|
255
+ event.transitions.each { |t| (map[t.to] ||= []).concat(Array(t.from)) }
256
+ }.transform_values { |froms| froms.uniq.freeze }.freeze
257
+ end
258
+
259
+ # Source => the states it may reach.
260
+ #
261
+ # @return [Hash{Symbol => Array<Symbol>}]
262
+ def outbound_map
263
+ @outbound_map ||= inbound.each_with_object({}) { |(to, froms), map|
264
+ froms.each { |from| (map[from] ||= []) << to }
265
+ }.transform_values(&:freeze).freeze
266
+ end
267
+
268
+ # @param key [Symbol]
269
+ # @return [Array<Symbol>]
270
+ def outbound(key) = outbound_map.fetch(key, [])
271
+
272
+ # @param from [Symbol]
273
+ # @param to [Symbol]
274
+ # @return [Boolean] whether the topology permits this edge at all
275
+ def edge?(from, to) = outbound(from).include?(to)
276
+
277
+ # Every event but {RETREAT}'s.
278
+ #
279
+ # @return [Array<AASM::Core::Event>]
280
+ def forward_events = aasm.events.reject { it.name == :step_back }
281
+
282
+ # @param key [Symbol]
283
+ # @return [Array<Symbol>] the family +key+ belongs to, empty when it has none
284
+ def family_of(key) = FAMILIES.find { |family| family.include?(key) } || []
285
+ end
286
+
287
+ # @param subject [#status, #file?, #read, #pull_requests, #rename_to]
288
+ def initialize(subject)
289
+ @subject = subject
290
+ @key = subject.status.key
291
+ end
292
+
293
+ # @return [Object] the plan folder this machine speaks for
294
+ attr_reader :subject
295
+
296
+ # @return [Symbol] the state right now
297
+ attr_reader :key
298
+
299
+ # @return [OpenLanes::Plans::Status]
300
+ def status = STATUS_BY_KEY.fetch(key)
301
+
302
+ # AASM keeps state in an ORM column; we keep it in a directory name. These
303
+ # three methods are the whole of that adaptation.
304
+ #
305
+ # @return [Symbol]
306
+ def aasm_read_state(_name = :default) = @key
307
+
308
+ # @return [Boolean]
309
+ def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name)
310
+
311
+ # @return [Boolean]
312
+ def aasm_write_state_without_persistence(new_state, _name = :default)
313
+ @key = new_state
314
+ true
315
+ end
316
+
317
+ # States reachable right now, guards applied. A step back is not among
318
+ # them: only {#retreat!} takes it.
319
+ #
320
+ # @return [Array<Symbol>]
321
+ def allowed = aasm.states(permitted: true).map(&:name) & self.class.outbound(key)
322
+
323
+ # @param to [Symbol]
324
+ # @return [Boolean] whether that move is permitted right now
325
+ def may?(to) = allowed.include?(to)
326
+
327
+ # The next state along the spine, whether or not its guard passes.
328
+ #
329
+ # @return [Symbol, nil]
330
+ def spine_next = SPINE[key]
331
+
332
+ # Move the folder. With no argument it walks one step along the {SPINE}.
333
+ #
334
+ # A refusal is information — it means the phase has not actually happened
335
+ # yet — so it arrives as {Refused} carrying the reason, never as `false`.
336
+ #
337
+ # @param to [Symbol] destination, defaulting to the next spine state
338
+ # @return [OpenLanes::Plans::Status] the state now occupied
339
+ # @raise [OpenLanes::Plans::StateMachine::Refused]
340
+ def promote!(to = spine_next)
341
+ raise Refused, "#{describe(key)} is terminal — nothing follows it" if to.nil?
342
+ raise Refused, "nothing can reach #{describe(to)}" unless self.class.event_for.key?(to)
343
+ raise Refused, refusal(to) unless may?(to)
344
+
345
+ aasm.fire!(self.class.event_for.fetch(to))
346
+ status
347
+ end
348
+
349
+ # @return [Symbol, nil] the state one step back, nil when there is none
350
+ def previous = RETREAT[key]
351
+
352
+ # Move the folder one step back along the spine (W9).
353
+ #
354
+ # @return [OpenLanes::Plans::Status] the state now occupied
355
+ # @raise [OpenLanes::Plans::StateMachine::Refused] when nothing is
356
+ # behind this state, or the folder's contents do not justify it
357
+ def retreat!
358
+ refusal = retreat_refusal and raise Refused, refusal
359
+
360
+ aasm.fire!(:step_back)
361
+ status
362
+ end
363
+
364
+ # @return [String, nil] why {#retreat!} would be refused, nil when it would not
365
+ def retreat_refusal
366
+ to = previous or return "#{describe(key)} has no state behind it to go back to"
367
+ return nil if aasm.may_fire_event?(:step_back)
368
+
369
+ STATUS_BY_KEY.fetch(to).violation(subject) || "#{describe(key)} cannot go back to #{describe(to)}"
370
+ end
371
+
372
+ # The state a folder's contents justify — the answer to "well, what should
373
+ # it be, then?". This is what `resync dirs` renames toward.
374
+ #
375
+ # Invariants are *minimum* requirements, not exact matches: a ⚪️ folder
376
+ # that has since grown a `plan.md` still satisfies ⚪️, and is nonetheless
377
+ # ⭐️ now. So the furthest-justified state wins, and the caller compares it
378
+ # against the current one to decide whether anything should move.
379
+ #
380
+ # The exception is a {FAMILIES family}, whose members contents cannot tell
381
+ # apart. Two rules cover it:
382
+ #
383
+ # - **Already inside one** — the current name wins. Re-deriving it would be
384
+ # a guess dressed up as a correction, and every ⭕️ would become 🅱️.
385
+ # - **Arriving from outside** — the *weakest* member the contents fit
386
+ # wins, not the furthest. A 📋 folder whose `plan.md` grew work units
387
+ # lands on ⭐️, not 🟡, because nobody has started. A ✅ folder found
388
+ # with an open pull request is demonstrably back in the PR phase, and
389
+ # ⭐️ refuses an open pull request, so it lands on 🟡 rather than
390
+ # claiming 🔴.
391
+ #
392
+ # @return [OpenLanes::Plans::Status, nil] nil when nothing fits at all
393
+ def best_fit
394
+ fitting = STATUSES.select { |s| s.satisfied_by?(subject) }
395
+ return nil if fitting.empty?
396
+
397
+ best = PREFERENCE.filter_map { |k| fitting.find { |s| s.key == k } }.first || fitting.first
398
+ return status if self.class.family_of(key).include?(best.key)
399
+
400
+ family = self.class.family_of(best.key)
401
+ return best if family.empty?
402
+
403
+ floor = family.find { |k| fitting.any? { |s| s.key == k } }
404
+ STATUS_BY_KEY.fetch(floor)
405
+ end
406
+
407
+ private
408
+
409
+ # The one guard. Entering a state is permitted exactly when that state's
410
+ # invariant holds for this folder's contents.
411
+ #
412
+ # @return [Boolean]
413
+ def justified? = STATUS_BY_KEY.fetch(aasm.to_state).satisfied_by?(subject)
414
+
415
+ # @param to [Symbol]
416
+ # @return [String]
417
+ def refusal(to)
418
+ return "#{describe(key)} cannot become #{describe(to)}" unless self.class.edge?(key, to)
419
+
420
+ STATUS_BY_KEY.fetch(to).violation(subject)
421
+ end
422
+
423
+ # @param key [Symbol]
424
+ # @return [String]
425
+ def describe(key) = STATUS_BY_KEY.fetch(key).to_s
426
+
427
+ # The folder moves. {Subject} owns the path, so it owns the move.
428
+ #
429
+ # @return [void]
430
+ def rename! = subject.rename_to(STATUS_BY_KEY.fetch(aasm.to_state))
431
+ end
432
+ end
433
+ end