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,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "parallel"
5
+
6
+ module OpenLanes
7
+ module Lifecycle
8
+ # Gives a plan folder to every pull request that has no plan to point at.
9
+ #
10
+ # `resync prs` can resolve most pull requests from their branch or their
11
+ # diff. What it cannot resolve it used to flag for a human — "diff touches
12
+ # 021.00, 022.00, 024.00 and none is obviously primary" — and stop. That is
13
+ # honest but not useful: the work exists, it is unspecified, and leaving it
14
+ # unnumbered means it never appears in the index at all.
15
+ #
16
+ # So an orphan is adopted instead. It gets a retroactive plan of its own,
17
+ # numbered in the gap after the furthest plan it can see, holding the pull
18
+ # request that produced it.
19
+ #
20
+ # == Why the numbering is allocated serially
21
+ #
22
+ # The obvious parallel design — every worker reads the highest number and
23
+ # adds `.01` — is deterministic but not unique, and the collision is the
24
+ # common case rather than the rare one. Fifteen open pull requests branched
25
+ # off the same main all see the same highest number and all compute the same
26
+ # slot. Determinism is not the property that matters here; distinctness is.
27
+ #
28
+ # So the pipeline is **gather in parallel, allocate serially, apply in
29
+ # parallel**. The allocation is pure arithmetic over an array that is
30
+ # already in memory, so the serial section costs microseconds; the two
31
+ # expensive phases — reading each branch and writing each folder — keep the
32
+ # whole fan-out. And because the slots are handed out before any worker
33
+ # starts, no worker can want a number another worker holds: no locks, no
34
+ # retries, and the same input produces the same numbers every run.
35
+ class Adoption
36
+ # One pull request and the plan it is being given.
37
+ #
38
+ # @!attribute [r] pull
39
+ # @return [Hash] as returned by {GitHub#pulls}
40
+ # @!attribute [r] ordinal
41
+ # @return [OpenLanes::Plans::Ordinal] the slot it was allocated
42
+ # @!attribute [r] major
43
+ # @return [Integer] the furthest plan its branch or diff could see
44
+ # @!attribute [r] path
45
+ # @return [String, nil] the folder, once created
46
+ Adoptee = Data.define(:pull, :ordinal, :major, :path) do
47
+ # @return [String] the folder name this pull request earns
48
+ def dirname = OpenLanes::Plans.plan_dirname(ordinal, Plans::STATUS_BY_KEY.fetch(:retroactive), slug)
49
+
50
+ # The PR title, stripped of any prefix, as the folder's tail.
51
+ #
52
+ # @return [String]
53
+ def slug = Creator.slugify(pull[:title].to_s.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
54
+
55
+ # @return [String] a single auditable line
56
+ def to_s = "##{pull[:number]} → #{dirname}"
57
+ end
58
+
59
+ # @param tree [OpenLanes::Plans::Tree]
60
+ # @param github [OpenLanes::Vcs::GitHub]
61
+ # @param root [String] repository root, for reading branches
62
+ # @param jobs [Integer] workers for the two parallel phases
63
+ def initialize(tree:, github: Vcs::GitHub.new, root: nil, jobs: UI.default_jobs)
64
+ @tree = tree
65
+ @github = github
66
+ @root = root || File.dirname(tree.dir)
67
+ @jobs = jobs
68
+ end
69
+
70
+ # @return [OpenLanes::Plans::Tree]
71
+ attr_reader :tree
72
+
73
+ # @return [OpenLanes::Vcs::GitHub]
74
+ attr_reader :github
75
+
76
+ # @return [String]
77
+ attr_reader :root
78
+
79
+ # @return [Integer]
80
+ attr_reader :jobs
81
+
82
+ # Work out what each orphan would be given, without creating anything.
83
+ #
84
+ # @param pulls [Array<Hash>] the orphans, from {Resync::Prs}
85
+ # @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>] in pull request order
86
+ def plan(pulls)
87
+ allocate(gather(pulls))
88
+ end
89
+
90
+ # Adopt every orphan: create its folder and record its pull request.
91
+ #
92
+ # @param pulls [Array<Hash>]
93
+ # @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>] with `path` filled in
94
+ def call(pulls)
95
+ adoptees = plan(pulls)
96
+ return adoptees if adoptees.empty?
97
+
98
+ created = Parallel.map(adoptees, in_threads: jobs) { |adoptee| adopt(adoptee) }
99
+ tree.reload
100
+ created
101
+ end
102
+
103
+ private
104
+
105
+ # Phase one, in parallel: ask each pull request's branch how far the
106
+ # sequence had got when the work started. Branches are not all rebased
107
+ # onto the same main, so this is per-branch rather than tree-wide, and it
108
+ # is a network-free `ls-tree` per pull request.
109
+ #
110
+ # @param pulls [Array<Hash>]
111
+ # @return [Array<Array(Hash, Integer)>] each pull with its major
112
+ def gather(pulls)
113
+ ordered = pulls.sort_by { |pull| pull[:number].to_i }
114
+
115
+ Parallel.map(ordered, in_threads: jobs) { |pull| [pull, major_for(pull)] }
116
+ end
117
+
118
+ # The furthest whole plan this pull request can see: on its own branch
119
+ # first, then whatever it touched, then the tree we are standing in. A
120
+ # pull request whose branch is long gone still has a diff.
121
+ #
122
+ # @param pull [Hash]
123
+ # @return [Integer]
124
+ def major_for(pull)
125
+ candidates = OpenLanes.plans_on_ref(root, pull[:branch].to_s)
126
+ candidates = touched(pull) if candidates.empty?
127
+ candidates = tree.subjects.map { |s| s.feature.ordinal } if candidates.empty?
128
+
129
+ candidates.map(&:major).max.to_i
130
+ end
131
+
132
+ # @param pull [Hash]
133
+ # @return [Array<OpenLanes::Plans::Ordinal>]
134
+ def touched(pull)
135
+ Array(pull[:files]).filter_map { |path|
136
+ parts = path.to_s.split("/")
137
+ index = parts.index(OpenLanes::PLANS_DIR)
138
+ index && parts[index + 1] && Plans::Ordinal.from_dirname(parts[index + 1])
139
+ }.uniq
140
+ end
141
+
142
+ # Phase two, serial and deliberately so. Minors are handed out per major,
143
+ # continuing past every slot the tree already holds, in ascending pull
144
+ # request order — so the numbers follow the order the work was opened in,
145
+ # and two pull requests cannot be given the same one.
146
+ #
147
+ # @param gathered [Array<Array(Hash, Integer)>]
148
+ # @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>]
149
+ def allocate(gathered)
150
+ taken = Hash.new { |h, major| h[major] = minors_taken(major) }
151
+
152
+ gathered.filter_map do |pull, major|
153
+ minor = ((taken[major].max || 0) + 1)
154
+ next if minor > Plans::Ordinal::MAX_MINOR
155
+
156
+ taken[major] << minor
157
+ Adoptee.new(pull:, major:, ordinal: Plans::Ordinal.new(major:, minor:), path: nil)
158
+ end
159
+ end
160
+
161
+ # @param major [Integer]
162
+ # @return [Array<Integer>] minors already used under this major
163
+ def minors_taken(major)
164
+ tree.subjects.map { |s| s.feature.ordinal }.select { |o| o.major == major }.map(&:minor)
165
+ end
166
+
167
+ # Phase three, in parallel: each worker owns a number nobody else can
168
+ # want, so it can create its folder without coordinating with anyone.
169
+ #
170
+ # @param adoptee [OpenLanes::Lifecycle::Adoption::Adoptee]
171
+ # @return [OpenLanes::Lifecycle::Adoption::Adoptee]
172
+ def adopt(adoptee)
173
+ path = File.join(tree.dir, adoptee.dirname)
174
+ return adoptee if File.exist?(path)
175
+
176
+ FileUtils.mkdir_p(path)
177
+ File.write(File.join(path, Plans::PullRequests::FILENAME),
178
+ Plans::PullRequests.render([pull_request_for(adoptee)]))
179
+
180
+ adoptee.with(path:)
181
+ end
182
+
183
+ # The body is what a specification would be written from, so it is worth
184
+ # a second request. A failure here costs the description, not the folder.
185
+ #
186
+ # @param adoptee [OpenLanes::Lifecycle::Adoption::Adoptee]
187
+ # @return [Hash]
188
+ def pull_request_for(adoptee)
189
+ pull = adoptee.pull
190
+ github.pull_request(pull[:number].to_s)
191
+ rescue OpenLanes::Error
192
+ { number: pull[:number], title: pull[:title], url: pull[:url],
193
+ state: pull[:state] || "Unknown", body: "" }
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty/command"
4
+
5
+ module OpenLanes
6
+ module Lifecycle
7
+ # A new plan's opening brief: four questions, and a best-effort first pass
8
+ # at answering them from what the project already has on disk.
9
+ #
10
+ # This is for work that does not exist yet. A plan created `--after` a
11
+ # shipped pull request already has its facts — {CLI::Create} hands those to
12
+ # `yoda-writer`, which reconstructs a specification from a diff, not this.
13
+ # Guessing at a diff and guessing at a feature nobody has built are
14
+ # different jobs, and conflating them is how a retroactive spec ends up
15
+ # full of hedges about something that already, provably, happened.
16
+ #
17
+ # The four headings are the brief `leah-researcher` and `yoda-writer` both
18
+ # read *before* their own chapters: what research needs to settle is a
19
+ # question about the gaps in `## What already exists`, so a scaffold
20
+ # missing that heading forces both of them to first reconstruct a boundary
21
+ # this class already had for free — it just created the folder.
22
+ class Brief
23
+ # The half-agent's name. It has no `agents/*.md` of its own on purpose —
24
+ # a roster entry would put it in `Runner`'s routing, and this pass runs
25
+ # only inside `create`. The name exists so the UI can show it the same
26
+ # way it shows `leah-researcher` and `yoda-writer` on their spinner lines.
27
+ AGENT_NAME = "anakin-briefster"
28
+
29
+ HEADINGS = [
30
+ "What we are trying to achieve",
31
+ "Why it matters",
32
+ "What already exists",
33
+ "What research needs to settle"
34
+ ].freeze
35
+
36
+ # Read whole into the drafting prompt, in order, so the model sees the
37
+ # project's own account of itself before it sees the topic.
38
+ CONTEXT_FILES = %w[CLAUDE.md AGENTS.md README.md].freeze
39
+
40
+ # Relative to the repository root. Not every project keeps one — a
41
+ # missing backlog is silently skipped, not an error.
42
+ BACKLOG_FILE = File.join(OpenLanes::PLANS_DIR, "BACKLOG.md")
43
+
44
+ # Long enough for a handful of Read/Grep/Glob calls over a local
45
+ # checkout; short enough that a hung `claude` does not stall `create`
46
+ # past the point where the scaffold is already there to open by hand.
47
+ TIMEOUT = 60
48
+
49
+ # The fastest model, and the draft is disposable: the prompt prefers an
50
+ # honest blank over a clever inference, so there is no reasoning premium
51
+ # worth paying a larger model for inside a 60-second budget.
52
+ BRIEF_MODEL = "haiku"
53
+
54
+ # Read-only, and no `Bash` — this pass surveys what the repository
55
+ # already says about itself, it does not go looking further. That is
56
+ # `leah-researcher`'s job, and denying her tools here is what keeps the
57
+ # two from quietly doing the same work twice.
58
+ ALLOWED_TOOLS = %w[Read Grep Glob Edit].freeze
59
+ DENIED_TOOLS = %w[WebFetch WebSearch Bash].freeze
60
+
61
+ # The `stdout:` and `stderr:` sections of a {TTY::Command::ExitError}
62
+ # message. stdout runs until stderr starts; stderr runs to the end, because
63
+ # what `claude` prints there is not guaranteed to be one line.
64
+ STDOUT_SECTION = /^[ \t]*stdout:[ \t]*(.*?)(?=\n[ \t]*stderr:|\z)/m
65
+ STDERR_SECTION = /^[ \t]*stderr:[ \t]*(.*)\z/m
66
+
67
+ # How much of what `claude` said survives into a one-line report.
68
+ REASON_LIMIT = 300
69
+
70
+ # A one-line account of a failed `claude` run, built from the error rather
71
+ # than its first line: that first line is the escaped prompt, several
72
+ # thousand characters of it, and reporting it says an invocation failed,
73
+ # at length, and never why.
74
+ #
75
+ # Both streams are kept because they carry different halves. `claude`
76
+ # reports its own failures on stdout; the line naming the *cause* of a 401
77
+ # (`ANTHROPIC_API_KEY ... takes precedence over your claude.ai login`) is
78
+ # on stderr. {Executor} used to own this; it now reads a child process
79
+ # directly and has no exit error to parse, so the briefer keeps it.
80
+ #
81
+ # @param error [TTY::Command::ExitError]
82
+ # @return [String]
83
+ def self.failure_reason(error)
84
+ status = error.message[/^[ \t]*exit status:[ \t]*(\S+)/, 1]
85
+ outcome = status ? "exited #{status}" : "failed"
86
+ said = [STDOUT_SECTION, STDERR_SECTION]
87
+ .filter_map { |section| tail(error.message[section, 1]) }
88
+ .join(" | ")
89
+
90
+ said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}"
91
+ end
92
+
93
+ # @param text [String, nil]
94
+ # @return [String, nil] the last few meaningful lines, on one line
95
+ def self.tail(text)
96
+ lines = text.to_s.split("\n").map(&:strip).reject { |line| line.empty? || line == "Nothing written" }
97
+ return nil if lines.empty?
98
+
99
+ joined = lines.last(3).join(" ")
100
+ joined.length > REASON_LIMIT ? "#{joined[0, REASON_LIMIT - 1]}..." : joined
101
+ end
102
+ private_class_method :tail
103
+
104
+ # @param path [String] the new plan folder, absolute
105
+ # @param title [String] the feature's proper name, e.g. "Tax Rule DSL"
106
+ # @param root [String] repository root, for project context and `--add-dir`
107
+ # @param command [TTY::Command]
108
+ # @param timeout [Integer] seconds before the drafting attempt is abandoned
109
+ # @param dry_run [Boolean] plan the invocation, do not run it
110
+ # @param seed [String, nil] what the author already wrote about the
111
+ # feature (`create --from`); opens spec.md and outranks the repo survey
112
+ def initialize(
113
+ path:,
114
+ title:,
115
+ root:,
116
+ command: TTY::Command.new(printer: :null),
117
+ timeout: TIMEOUT,
118
+ dry_run: false,
119
+ seed: nil
120
+ )
121
+ @path = path
122
+ @title = title
123
+ @root = File.expand_path(root)
124
+ @command = command
125
+ @timeout = timeout
126
+ @dry_run = dry_run
127
+ @seed = seed.to_s.strip.then { |s| s.empty? ? nil : s }
128
+ end
129
+
130
+ # @return [String]
131
+ attr_reader :path, :title, :root
132
+
133
+ # @return [String, nil]
134
+ attr_reader :seed
135
+
136
+ # @return [String] absolute path to the spec, whether or not it exists yet
137
+ def spec_path = File.join(path, "spec.md")
138
+
139
+ # The title and four empty headings — and, when `create --from` supplied
140
+ # one, the author's own prose between them, so the file already says
141
+ # something even if the drafting attempt never returns.
142
+ #
143
+ # @return [String]
144
+ def scaffold
145
+ opening = seed ? "#{seed}\n\n" : ""
146
+ "# #{title}\n\n#{opening}" + HEADINGS.map { |h| "## #{h}\n\n" }.join("\n")
147
+ end
148
+
149
+ # @return [String] where it was written
150
+ def write_scaffold!
151
+ File.write(spec_path, scaffold)
152
+ spec_path
153
+ end
154
+
155
+ # Make a best-effort attempt at the four headings, in place, by shelling
156
+ # out to `claude` the same way {Executor} does for a specialist agent.
157
+ # A failure here is not this method's to hide: the scaffold {#write_scaffold!}
158
+ # already wrote stands on its own, so the caller reports the failure and
159
+ # moves on rather than treating it as fatal to `create`.
160
+ #
161
+ # @return [Array(Boolean, String)] ok, and a one-line note
162
+ def attempt!
163
+ return [true, "dry run — would draft from project context"] if @dry_run
164
+
165
+ begin
166
+ @command.run(*invocation, timeout: @timeout)
167
+ rescue TTY::Command::TimeoutExceeded
168
+ return [false, "timed out after #{@timeout}s"]
169
+ rescue TTY::Command::ExitError => e
170
+ return [false, "claude #{self.class.failure_reason(e)}"]
171
+ end
172
+
173
+ [true, "drafted"]
174
+ end
175
+
176
+ # Exposed so a spec can assert the boundary without running anything.
177
+ #
178
+ # @return [Array<String>]
179
+ def invocation
180
+ # Lean for the same reason every agent is: the draft has fifty
181
+ # seconds, and the operator's plugins would spend some of them loading.
182
+ ["claude",
183
+ "-p", prompt, "--add-dir", root,
184
+ "--model", BRIEF_MODEL,
185
+ *Adapters::Claude::LEAN,
186
+ "--allowedTools", ALLOWED_TOOLS.join(","),
187
+ "--disallowedTools", DENIED_TOOLS.join(",")]
188
+ end
189
+
190
+ private
191
+
192
+ # @return [String]
193
+ def prompt
194
+ <<~PROMPT
195
+ #{author_statement}A new plan folder was just created for a feature that does not exist
196
+ yet: "#{title}".
197
+
198
+ You have 50 seconds. Explore the codebase for the first thirty and
199
+ write for the last twenty; the process is killed at sixty and an
200
+ unwritten draft is lost.
201
+
202
+ #{spec_path} holds a title and four empty headings:
203
+
204
+ #{HEADINGS.map { |h| " * #{h}" }.join("\n")}
205
+
206
+ Make a best-effort first pass at each, drawn only from what THIS
207
+ project already has on disk under #{root} — its own docs, its own
208
+ conventions, code already built, and anything already downloaded or
209
+ gathered that bears on "#{title}". Read/Grep/Glob freely to look for
210
+ it; do not invent facts and do not research the web. Where the
211
+ repository gives you nothing to go on, leave that heading's body as
212
+ a short, honest one-line note saying so, e.g. "_Nothing in the repo
213
+ speaks to this yet — needs research._" A human completes this
214
+ afterward, and a confident-sounding guess costs them more to unwind
215
+ than an honest blank.
216
+
217
+ `## What already exists` matters most: name specific files, modules
218
+ or prior work you found, not just that "some code exists" — that is
219
+ exactly the context a researcher picking this up next cannot see for
220
+ themselves without being told where to look.
221
+
222
+ #{context}
223
+ #{backlog}
224
+ Edit #{spec_path} in place. Keep the title#{", the author's opening prose" if seed} and the four `##`
225
+ headings exactly as they are, in order; add prose under them; write
226
+ nothing outside them; touch no other file.
227
+ PROMPT
228
+ end
229
+
230
+ # The author's own words lead the prompt, ahead of even the feature's
231
+ # announcement, because they outrank everything this pass could survey:
232
+ # where the seed and the repository disagree, the seed is the intent and
233
+ # the repository is merely the past.
234
+ #
235
+ # @return [String] empty without a seed
236
+ def author_statement
237
+ return "" unless seed
238
+
239
+ <<~STATEMENT
240
+ The author has already written the following about this feature. It
241
+ is the primary source: where it answers one of the headings, draw
242
+ from it first, and use the repository survey below only to supplement
243
+ it — never to contradict it.
244
+
245
+ #{seed}
246
+
247
+ STATEMENT
248
+ end
249
+
250
+ # AGENTS.md is commonly a symlink to CLAUDE.md; realpath-deduping keeps
251
+ # the shared content from being inlined twice into every drafting prompt.
252
+ #
253
+ # @return [String]
254
+ def context
255
+ seen = []
256
+ found = CONTEXT_FILES.filter_map { |name|
257
+ file = File.join(root, name)
258
+ next unless File.file?(file)
259
+
260
+ real = File.realpath(file)
261
+ next if seen.include?(real)
262
+
263
+ seen << real
264
+ "### #{name}\n\n#{File.read(file, encoding: "UTF-8")}"
265
+ }
266
+ return "" if found.empty?
267
+
268
+ "## Project context\n\n#{found.join("\n\n")}\n"
269
+ end
270
+
271
+ # A relevant line in the project's own backlog is worth more than
272
+ # anything this pass could infer on its own — it is the one place "why
273
+ # now" might already be written down.
274
+ #
275
+ # @return [String]
276
+ def backlog
277
+ file = File.join(root, BACKLOG_FILE)
278
+ return "" unless File.file?(file)
279
+
280
+ "## #{BACKLOG_FILE}\n\n" \
281
+ "If this backlog mentions \"#{title}\", treat that entry as " \
282
+ "authoritative for `## Why it matters` — it is the project's own " \
283
+ "statement of intent.\n\n" \
284
+ "#{File.read(file, encoding: "UTF-8")}\n"
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Lifecycle
5
+ # Copies the spec's `task-completed-when` and `how-to-verify` into
6
+ # `plan.md`, so the builders and the reviewer read them in the document
7
+ # they work from rather than only in the frontmatter of another one.
8
+ #
9
+ # An agent is asked to copy them; the harness checks. A planner that
10
+ # paraphrased them, or forgot, would leave the criteria in the one place
11
+ # the reviewer was not looking, so a missing section is appended after the
12
+ # agent signs, and a section the agent wrote is never touched.
13
+ module Completion
14
+ COMPLETED_WHEN_HEADING = "## Task completed when"
15
+ HOW_TO_VERIFY_HEADING = "## How to verify"
16
+
17
+ module_function
18
+
19
+ # The sections for the spec's criteria, in the order a reader needs them.
20
+ #
21
+ # @param spec [OpenLanes::Plans::Spec]
22
+ # @param present [String] the plan's current text; sections it already
23
+ # has are left out
24
+ # @return [String] empty when the spec says nothing, or the plan has it
25
+ def markdown(spec, present: "")
26
+ [
27
+ section(COMPLETED_WHEN_HEADING, spec.completed_when, present),
28
+ section(HOW_TO_VERIFY_HEADING, spec.how_to_verify, present)
29
+ ].compact.join("\n")
30
+ end
31
+
32
+ # Append whatever `plan.md` is missing. Writes nothing when it is
33
+ # complete, so it is safe after every round.
34
+ #
35
+ # @param subject [OpenLanes::Plans::Subject]
36
+ # @return [Boolean] whether the file changed
37
+ def copy(subject)
38
+ path = File.join(subject.feature.path, "plan.md")
39
+ return false unless File.file?(path)
40
+
41
+ text = File.read(path, encoding: "UTF-8")
42
+ missing = markdown(Plans::Spec.for(subject), present: text)
43
+ return false if missing.empty?
44
+
45
+ File.write(path, "#{text.rstrip}\n\n#{missing}")
46
+ true
47
+ end
48
+
49
+ # @return [String, nil]
50
+ def section(heading, items, present)
51
+ return nil if items.empty? || present.match?(/^#{Regexp.escape(heading)}\s*$/i)
52
+
53
+ "#{heading}\n\n#{items.map { |item| "- #{item}" }.join("\n")}\n"
54
+ end
55
+ private_class_method :section
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "fileutils"
5
+
6
+ module OpenLanes
7
+ module Lifecycle
8
+ # Mints new plan folders. Backs `/spec-create`.
9
+ #
10
+ # Every failure is a `Failure`, never an exception: creating a folder is the
11
+ # one place a typo becomes permanent, so the caller is made to look.
12
+ class Creator
13
+ include Dry::Monads[:result]
14
+
15
+ # The state a new plan opens in — a specification and nothing else yet.
16
+ DEFAULT_STATUS = :new
17
+
18
+ # The state a retroactive plan is born in: the work is live, but it has
19
+ # neither a specification nor a plan.
20
+ RETROACTIVE_STATUS = :retroactive
21
+
22
+ # @param dir [String] the `.plans` directory
23
+ def initialize(dir:)
24
+ @dir = File.expand_path(dir)
25
+ end
26
+
27
+ # @return [String] the `.plans` directory
28
+ attr_reader :dir
29
+
30
+ # Create a plan folder.
31
+ #
32
+ # @param words [Array<String>] the topic; becomes the slug
33
+ # @param after [String, nil] anchor for a retroactive plan, e.g. "002"
34
+ # @param status [String, Symbol, nil] override the default state
35
+ # @param prs [Array<Hash>, nil] pull requests to record, already fetched;
36
+ # their presence is what makes the folder 🕰️ Retroactive rather than ⚪️
37
+ # @return [Dry::Monads::Result] Success(absolute path) or Failure(message)
38
+ def create(words:, after: nil, status: nil, prs: nil)
39
+ resolved = resolve_status(status, after) or
40
+ return Failure("unknown status: #{status}")
41
+
42
+ slug = self.class.slugify(words)
43
+ return Failure("the topic produced an empty slug") if slug.empty?
44
+
45
+ ordinal = after ? retroactive_ordinal(after) : Plans::Ordinal.next_major(existing)
46
+ return ordinal if ordinal.is_a?(Dry::Monads::Result)
47
+
48
+ build(ordinal, resolved, slug).fmap { |path| record_pull_requests(path, prs) }
49
+ rescue OpenLanes::Error => e
50
+ Failure(e.message)
51
+ end
52
+
53
+ # @return [Array<OpenLanes::Plans::Ordinal>] every number already taken
54
+ def existing
55
+ @existing ||= begin
56
+ return [] unless File.directory?(dir)
57
+
58
+ Dir.children(dir)
59
+ .select { |c| File.directory?(File.join(dir, c)) }
60
+ .filter_map { |c| Plans::Ordinal.from_dirname(c) }
61
+ end
62
+ end
63
+
64
+ # Turn free text into the kebab tail of a folder name.
65
+ #
66
+ # @param words [Array<String>, String]
67
+ # @return [String] possibly empty, which the caller must reject
68
+ def self.slugify(words)
69
+ Array(words).join(" ").downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
70
+ end
71
+
72
+ private
73
+
74
+ # @param status [String, Symbol, nil]
75
+ # @param after [String, nil]
76
+ # @return [OpenLanes::Plans::Status, nil]
77
+ def resolve_status(status, after)
78
+ return OpenLanes::Plans.status(status) if status
79
+
80
+ Plans::STATUS_BY_KEY.fetch(after ? RETROACTIVE_STATUS : DEFAULT_STATUS)
81
+ end
82
+
83
+ # @param after [String]
84
+ # @return [OpenLanes::Plans::Ordinal, Dry::Monads::Result]
85
+ def retroactive_ordinal(after)
86
+ anchor = Plans::Ordinal.parse(after) or return Failure("not a plan number: #{after}")
87
+
88
+ unless existing.any? { |o| o.major == anchor.major }
89
+ return Failure("no plan #{format("%03d", anchor.major)} to anchor against — " \
90
+ "a retroactive plan names the plan its work landed after")
91
+ end
92
+
93
+ Plans::Ordinal.next_minor(existing, major: anchor.major)
94
+ end
95
+
96
+ # Write the pull requests the folder was created from, so 🕰️ Retroactive
97
+ # is justified the moment the folder exists — that state means "the work is
98
+ # live and undocumented", and it is the recorded pull requests that make
99
+ # the first half of that true.
100
+ #
101
+ # @param path [String] the new folder
102
+ # @param prs [Array<Hash>, nil]
103
+ # @return [String] the path, unchanged
104
+ def record_pull_requests(path, prs)
105
+ return path if prs.nil? || prs.empty?
106
+
107
+ File.write(File.join(path, Plans::PullRequests::FILENAME), Plans::PullRequests.render(prs))
108
+ path
109
+ end
110
+
111
+ # @param ordinal [OpenLanes::Plans::Ordinal]
112
+ # @param status [OpenLanes::Plans::Status]
113
+ # @param slug [String]
114
+ # @return [Dry::Monads::Result]
115
+ def build(ordinal, status, slug)
116
+ target = File.join(dir, OpenLanes::Plans.plan_dirname(ordinal, status, slug))
117
+ return Failure("already exists: #{File.basename(target)}") if File.exist?(target)
118
+
119
+ FileUtils.mkdir_p(target)
120
+ Plans::PlanState.for(target).state!(status.key)
121
+ @existing = nil
122
+ Success(target)
123
+ end
124
+ end
125
+ end
126
+ end