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,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Presentation
5
+ # `/spec-status` — every plan, its state in icon and words, and its pull
6
+ # requests as clickable links.
7
+ #
8
+ # {#render} returns a string and prints nothing. Whoever calls it decides
9
+ # where it goes, which keeps the table pipeable.
10
+ class Reporter
11
+ # Columns, so the header and the rows cannot drift apart.
12
+ HEADINGS = ["Plan", "⬇", "Status", "Feature", "Pull Requests"].freeze
13
+
14
+ # Cells given to the plan number: "018.01" with room to grow.
15
+ ORDINAL_WIDTH = 7
16
+
17
+ # Cells given to a status icon. Emoji vary in both character count and
18
+ # display width, so the cell is padded to this by {UI.fit} rather than
19
+ # trusted to be any particular size — see the note there.
20
+ EMOJI_WIDTH = 2
21
+
22
+ # Cells given to a pull request's state, so the URLs line up beneath each
23
+ # other whichever states the tree happens to contain.
24
+ STATE_WIDTH = Plans::PullRequests::STATES.map { |_, label| UI.display_width(label) }.max
25
+
26
+ # @param tree [OpenLanes::Plans::Tree]
27
+ def initialize(tree:)
28
+ @tree = tree
29
+ end
30
+
31
+ # @return [OpenLanes::Plans::Tree]
32
+ attr_reader :tree
33
+
34
+ # @return [String] the whole table, newline-terminated
35
+ def render
36
+ return "No plans found in #{tree.dir}\n" if tree.subjects.empty?
37
+
38
+ [header, *tree.subjects.flat_map { |s| rows_for(s) }, "", footer].join("\n") + "\n"
39
+ end
40
+
41
+ # @return [Hash{Symbol => Integer}] how many plans sit in each state
42
+ def totals
43
+ tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status.key] += 1 }
44
+ end
45
+
46
+ # Plans whose folder name their contents do not justify.
47
+ #
48
+ # @return [Array<OpenLanes::Plans::Subject>]
49
+ def inconsistent = tree.subjects.reject(&:consistent?)
50
+
51
+ private
52
+
53
+ # @return [Integer] widest status label, so the column lines up
54
+ def label_width = @label_width ||= tree.subjects.map { |s| UI.display_width(s.status.label) }.max.to_i + 4
55
+
56
+ # @return [Integer] widest feature name
57
+ def title_width = @title_width ||= tree.subjects.map { |s| UI.display_width(s.feature.title) }.max.to_i + 2
58
+
59
+ # The one place the table's shape is written down. The heading, the plan
60
+ # rows, the continuation lines and the violations all come through here, so
61
+ # a column cannot widen in one of them and stay put in the others.
62
+ #
63
+ # Cells arrive already fitted and already painted, in that order, because
64
+ # the order matters: escape codes count toward a string's length, so
65
+ # padding a coloured string pads it to a width of which several characters
66
+ # are invisible, and the column collapses by exactly that much.
67
+ #
68
+ # @return [String]
69
+ def row(flag, ordinal, emoji, label, title, tail)
70
+ format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
71
+ end
72
+
73
+ # Cell at which the pull requests start, so a plan's second and third pull
74
+ # requests sit under its first. Measured by running blanks through {#row}
75
+ # rather than counted by hand, so the indent cannot drift when the layout
76
+ # changes.
77
+ #
78
+ # @return [Integer]
79
+ def pr_column
80
+ @pr_column ||= row(" ",
81
+ " " * ORDINAL_WIDTH,
82
+ " " * EMOJI_WIDTH,
83
+ " " * label_width,
84
+ " " * title_width,
85
+ "").length
86
+ end
87
+
88
+ # @return [Integer] cell at which the feature name starts
89
+ def title_column = pr_column - title_width - 1
90
+
91
+ # @return [String]
92
+ def header
93
+ text = row(" ",
94
+ UI.fit(HEADINGS[0], ORDINAL_WIDTH),
95
+ UI.fit(HEADINGS[1], EMOJI_WIDTH),
96
+ UI.fit(HEADINGS[2], label_width),
97
+ UI.fit(HEADINGS[3], title_width),
98
+ HEADINGS[4])
99
+
100
+ rule = " " + ("─" * (UI.display_width(text) - 2))
101
+
102
+ [UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
103
+ end
104
+
105
+ # One line per plan, plus a continuation line for each extra pull request
106
+ # so that every URL is visible and clickable rather than truncated.
107
+ #
108
+ # @param subject [OpenLanes::Plans::Subject]
109
+ # @return [Array<String>]
110
+ def rows_for(subject)
111
+ feature = subject.feature
112
+ prs = subject.pull_requests
113
+
114
+ first = row(
115
+ subject.consistent? ? " " : UI.paint("!", :red),
116
+ UI.paint(UI.fit(feature.ordinal.to_s, ORDINAL_WIDTH), :bold),
117
+ UI.fit(feature.status.emoji, EMOJI_WIDTH),
118
+ UI.paint(UI.fit(feature.status.label, label_width), :yellow),
119
+ UI.paint(UI.fit(feature.title, title_width), :blue, :bold),
120
+ prs.empty? ? UI.paint("[none yet]", :bright_red) : link(prs.first)
121
+ )
122
+
123
+ rest = prs.drop(1).map { |pr| (" " * pr_column) + link(pr) }
124
+
125
+ [first, *rest, *violation_line(subject)]
126
+ end
127
+
128
+ # The URL is left bare rather than wrapped in an OSC 8 hyperlink: the table
129
+ # is as likely to be piped into a file as read in a terminal, and every
130
+ # terminal worth the name linkifies a bare URL anyway.
131
+ #
132
+ # @param pr [OpenLanes::Plans::PullRequest]
133
+ # @return [String]
134
+ def link(pr)
135
+ colour = state_colour(pr)
136
+
137
+ format("#%4s %s %s",
138
+ pr.number,
139
+ UI.paint(UI.fit(pr.state, STATE_WIDTH), colour),
140
+ UI.paint(pr.url.to_s, colour))
141
+ end
142
+
143
+ # @param pr [OpenLanes::Plans::PullRequest]
144
+ # @return [Symbol] a pastel style name
145
+ def state_colour(pr)
146
+ if pr.merged?
147
+ :magenta
148
+ elsif pr.open?
149
+ :yellow
150
+ else
151
+ :blue
152
+ end
153
+ end
154
+
155
+ # @param subject [OpenLanes::Plans::Subject]
156
+ # @return [Array<String>]
157
+ def violation_line(subject)
158
+ return [] if subject.consistent?
159
+
160
+ [(" " * title_column) + UI.paint("↳ #{subject.violation}", :red)]
161
+ end
162
+
163
+ # @return [String]
164
+ def footer
165
+ counts = totals.map { |key, n| "#{Plans::STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
166
+ lines = [" #{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}"]
167
+
168
+ unless inconsistent.empty?
169
+ lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"
170
+ end
171
+
172
+ tree.duplicates.each do |ordinal, dirnames|
173
+ lines << " #{UI.paint("#{ordinal} is claimed by #{dirnames.size} folders — a number is an identity:", :red)}"
174
+ dirnames.each { |d| lines << " #{UI.paint(d, :bright_black)}" }
175
+ end
176
+
177
+ lines.join("\n")
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Presentation
5
+ # Hands Markdown files to a renderer outside this process: the system
6
+ # viewer via `open`, or mdfried in the terminal.
7
+ #
8
+ # Both paths shell out, so the class takes its two effects as lambdas and
9
+ # the specs assert on the commands instead of launching viewers on the box
10
+ # running the suite.
11
+ class Viewer
12
+ # What to run when mdfried is asked for but not installed. Homebrew is the
13
+ # one package manager the toolchain already assumes (see the Brewfile
14
+ # convention), so there is no fallback chain to get wrong.
15
+ INSTALL = "brew install mdfried >/dev/null"
16
+
17
+ # How to notice mdfried is missing without depending on `which`, which
18
+ # differs across shells; `command -v` is POSIX.
19
+ DETECT = "command -v mdfried >/dev/null"
20
+
21
+ # @param launch [#call] runs a command, returns truthy on success
22
+ # @param pipe [#call] runs a command, yielding its stdin as an IO
23
+ def initialize(launch: ->(*cmd) { system(*cmd) },
24
+ pipe: ->(cmd, &block) { IO.popen(cmd, "w", &block) })
25
+ @launch = launch
26
+ @pipe = pipe
27
+ end
28
+
29
+ # Opens each file in whatever the system has configured for Markdown.
30
+ #
31
+ # @param paths [Array<String>]
32
+ # @return [void]
33
+ def open(paths)
34
+ Array(paths).each { |path| @launch.call("open", path) }
35
+ end
36
+
37
+ # Streams each file's content into mdfried, installing it first when it
38
+ # is missing.
39
+ #
40
+ # @param paths [Array<String>]
41
+ # @return [void]
42
+ # @raise [OpenLanes::Error] when mdfried is absent and brew cannot fix that
43
+ def mdfried(paths)
44
+ ensure_mdfried!
45
+ Array(paths).each do |path|
46
+ @pipe.call("mdfried") { |io| io.write(File.read(path)) }
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # @return [void]
53
+ # @raise [OpenLanes::Error]
54
+ def ensure_mdfried!
55
+ return if @launch.call(DETECT)
56
+
57
+ @launch.call(INSTALL) or
58
+ raise Error, "mdfried is not installed, and `brew install mdfried` failed"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # Everything a person reads: the live dashboard, the status table, the
5
+ # generated conventions and the state diagram. These render strings and
6
+ # draw on STDERR; none of them changes a plan.
7
+ module Presentation
8
+ module_function
9
+
10
+ # @return [String] the conventions document, derived
11
+ def documentation = Documentation.new.render
12
+
13
+ # @return [String] the state machine, drawn for a terminal
14
+ def diagram = Diagram.new.render
15
+
16
+ # @param tree [OpenLanes::Plans::Tree]
17
+ # @return [OpenLanes::Presentation::Reporter]
18
+ def reporter(tree:) = Reporter.new(tree:)
19
+
20
+ # @return [OpenLanes::Presentation::Viewer]
21
+ def viewer = Viewer.new
22
+ end
23
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module OpenLanes
6
+ module Skills
7
+ # Copies the bundled skills into a skill directory.
8
+ #
9
+ # Copies, never links: a link into the gem's versioned install path
10
+ # dangles the moment `gem cleanup` removes that version, and the skill
11
+ # disappears with no error anywhere. Re-running after an upgrade is the
12
+ # price, and the report says what that run changed.
13
+ class Installer
14
+ # What happened to one skill.
15
+ #
16
+ # @!attribute [r] skill
17
+ # @return [OpenLanes::Skills::Skill]
18
+ # @!attribute [r] action
19
+ # @return [Symbol] :installed, :kept or :replaced
20
+ # @!attribute [r] target
21
+ # @return [String]
22
+ Outcome = Data.define(:skill, :action, :target)
23
+
24
+ # @param into [String] the destination directory
25
+ # @param skills [Array<OpenLanes::Skills::Skill>]
26
+ def initialize(into:, skills:)
27
+ @into = File.expand_path(into)
28
+ @skills = skills
29
+ end
30
+
31
+ # @return [String]
32
+ attr_reader :into
33
+
34
+ # @param overwrite [Boolean] replace a skill already there
35
+ # @param dry_run [Boolean] decide, and change nothing
36
+ # @return [Array<Outcome>]
37
+ def call(overwrite: false, dry_run: false)
38
+ @skills.map do |skill|
39
+ target = File.join(into, skill.name)
40
+ action = action_for(skill, overwrite)
41
+ copy(skill, target, replacing: action == :replaced) unless dry_run || action == :kept
42
+ Outcome.new(skill:, action:, target:)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # @return [Symbol]
49
+ def action_for(skill, overwrite)
50
+ return :installed unless skill.installed?(into)
51
+
52
+ overwrite ? :replaced : :kept
53
+ end
54
+
55
+ # A link already standing where the skill goes is removed, not
56
+ # followed: replacing what it points at would edit somebody's checkout.
57
+ #
58
+ # @return [void]
59
+ def copy(skill, target, replacing:)
60
+ if replacing
61
+ File.symlink?(target) ? File.unlink(target) : FileUtils.rm_rf(target)
62
+ end
63
+ FileUtils.mkdir_p(into)
64
+ FileUtils.cp_r(skill.path, target)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Skills
5
+ # One bundled skill: a directory holding a `SKILL.md` whose frontmatter
6
+ # names and describes it.
7
+ #
8
+ # @!attribute [r] name
9
+ # @return [String] the frontmatter `name`, else the directory's
10
+ # @!attribute [r] description
11
+ # @return [String] the whole frontmatter `description`
12
+ # @!attribute [r] path
13
+ # @return [String] the skill's directory
14
+ Skill = Data.define(:name, :description, :path) do
15
+ # What `skills install -h` prints beside the name: a description is
16
+ # written for an agent deciding whether to load it, and runs long.
17
+ #
18
+ # @return [String]
19
+ def summary = description.split(/(?<=[.!?])\s+/, 2).first.to_s.strip
20
+
21
+ # @param into [String]
22
+ # @return [Boolean] whether a skill of this name is already there
23
+ def installed?(into) = File.exist?(File.join(into, name)) || File.symlink?(File.join(into, name))
24
+ end
25
+
26
+ class Skill
27
+ FILENAME = "SKILL.md"
28
+
29
+ # @param dir [String]
30
+ # @return [Array<OpenLanes::Skills::Skill>] every `<dir>/*/SKILL.md`, by name
31
+ def self.all(dir)
32
+ Dir.glob(File.join(dir, "*", FILENAME)).map { load(File.dirname(it)) }.sort_by(&:name)
33
+ end
34
+
35
+ # @param path [String] a skill directory
36
+ # @return [OpenLanes::Skills::Skill]
37
+ def self.load(path)
38
+ meta = frontmatter(File.read(File.join(path, FILENAME), encoding: "UTF-8"))
39
+ new(name: meta.fetch("name", File.basename(path)).to_s,
40
+ description: meta.fetch("description", "").to_s.gsub(/\s+/, " ").strip,
41
+ path:)
42
+ end
43
+
44
+ # Agents read a SKILL.md leniently, and a description with `word: word`
45
+ # in it is not valid YAML as a plain scalar. Refusing such a skill
46
+ # here would hide one the agents load fine, so a header YAML rejects
47
+ # is read a line at a time instead.
48
+ #
49
+ # @param text [String]
50
+ # @return [Hash{String => String}]
51
+ def self.frontmatter(text)
52
+ Frontmatter.split(text).first
53
+ rescue Psych::Exception
54
+ header = text[/\A---\s*\n(.*?)\n---/m, 1].to_s
55
+ header.scan(/^([a-z][\w-]*):[ \t]*(.*)$/).to_h.transform_values { it.strip.delete_prefix('"').delete_suffix('"') }
56
+ end
57
+ private_class_method :frontmatter
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # The agent skills that ship inside the gem, under `skills/`, and the copy
5
+ # that puts them where a coding agent looks for them.
6
+ module Skills
7
+ # Where the bundled skills live: beside `agents/`, at the gem root.
8
+ SOURCE_DIR = File.expand_path("../../skills", __dir__)
9
+
10
+ # The shared skill store that `~/.claude/skills` and the other agents'
11
+ # skill directories link into.
12
+ DESTINATION = File.join(Dir.home, ".agents", "skills")
13
+
14
+ module_function
15
+
16
+ # @param dir [String] a directory of `<name>/SKILL.md`
17
+ # @return [Array<OpenLanes::Skills::Skill>] by name
18
+ def bundled(dir: SOURCE_DIR) = Skill.all(dir)
19
+
20
+ # @param into [String]
21
+ # @param source [String]
22
+ # @return [OpenLanes::Skills::Installer]
23
+ def installer(into: DESTINATION, source: SOURCE_DIR) = Installer.new(into:, skills: bundled(dir: source))
24
+ end
25
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module OpenLanes
6
+ # A project's `.openlanes.yml`, at its root:
7
+ #
8
+ # project: Equilibris App # heading for INDEX.md; default: the root's name
9
+ # lane: full # default lane for `openlanes create`
10
+ # frontend: false # a spec's `frontend:` when spec.md omits it
11
+ # jobs: 4 # how many plans `openlanes run` works on at once
12
+ # redis_url: redis://10.0.4.2:6379/0 # the Redis the live run shares (W16)
13
+ # commands: # per-command option defaults
14
+ # run:
15
+ # timeout: 1800
16
+ #
17
+ # Precedence, loudest first: a flag typed on the command line, the plan's
18
+ # spec.md frontmatter, this file, the built-in default. The file supplies
19
+ # defaults and nothing else.
20
+ #
21
+ # `jobs`, `lane` and `project` are each one command's option said once for
22
+ # the whole project, so each has a second spelling under `commands:`.
23
+ # Writing both is an error rather than a tie-break: two answers to one
24
+ # question is a file somebody will edit in the wrong place.
25
+ #
26
+ # Anything unknown is an error naming the file, never a shrug: a config
27
+ # silently ignored is a timeout somebody set that stops applying with no
28
+ # signal anywhere.
29
+ #
30
+ # @!attribute [r] path
31
+ # @return [String, nil] the file, nil when the project has none
32
+ # @!attribute [r] project
33
+ # @return [String, nil]
34
+ # @!attribute [r] lane
35
+ # @return [Symbol, nil] one of {Plans::Spec::LANES}
36
+ # @!attribute [r] frontend
37
+ # @return [Boolean, nil] nil when each plan decides
38
+ # @!attribute [r] jobs
39
+ # @return [Integer, nil]
40
+ # @!attribute [r] redis_url
41
+ # @return [String, nil] where the live run keeps its board, its commands
42
+ # and its plan claims; `--redis-url` beats it, and it beats `$REDIS_URL`
43
+ # @!attribute [r] commands
44
+ # @return [Hash{String => Hash{Symbol => Object}}] by command name, as
45
+ # typed: "run", "resync dirs"
46
+ Config = Data.define(:path, :project, :lane, :frontend, :jobs, :redis_url, :commands) do
47
+ def initialize(path: nil, project: nil, lane: nil, frontend: nil, jobs: nil, redis_url: nil, commands: {})
48
+ super
49
+ end
50
+
51
+ # One command's option defaults, the top-level spelling folded in.
52
+ #
53
+ # @param command [String, Symbol] e.g. :run or "resync dirs"
54
+ # @return [Hash{Symbol => Object}]
55
+ def for(command)
56
+ name = command.to_s
57
+ folded = Config::SHORTHANDS.filter_map { |key, owner|
58
+ value = public_send(key)
59
+ [key, value.is_a?(Symbol) ? value.to_s : value] if owner == name && !value.nil?
60
+ }.to_h
61
+ commands.fetch(name, {}).merge(folded)
62
+ end
63
+
64
+ # @return [Boolean] whether the project has a file at all
65
+ def file? = !path.nil?
66
+
67
+ # The file `openlanes init` writes: only what was set, in the order the
68
+ # keys are documented, so it reads like the example it came from.
69
+ #
70
+ # @return [String]
71
+ def to_yaml
72
+ settings = { "project" => project, "lane" => lane&.to_s, "frontend" => frontend, "jobs" => jobs,
73
+ "redis_url" => redis_url }
74
+ settings["commands"] = commands.transform_values { it.transform_keys(&:to_s) } unless commands.empty?
75
+ YAML.dump(settings.compact).delete_prefix("---\n")
76
+ end
77
+ end
78
+
79
+ class Config
80
+ # The file, at the project root.
81
+ FILE = Project::FILE
82
+
83
+ # Every key the top level may hold.
84
+ KEYS = %w[project lane frontend jobs redis_url commands].freeze
85
+
86
+ # Schemes a `redis_url:` may use.
87
+ REDIS_SCHEMES = %w[redis rediss unix].freeze
88
+
89
+ # Top-level keys that are one command's option, by the command that takes it.
90
+ SHORTHANDS = { project: "index", lane: "create", jobs: "run" }.freeze
91
+
92
+ class << self
93
+ # @param root [String] the project root
94
+ # @return [OpenLanes::Config] empty when the root holds no file
95
+ # @raise [OpenLanes::Error] when the file exists and cannot be used
96
+ def load(root)
97
+ path = File.join(root, FILE)
98
+ return new unless File.file?(path)
99
+
100
+ parse(File.read(path, encoding: "UTF-8"), path:)
101
+ end
102
+
103
+ # The project the process is working in. {CLI::Base} has already moved
104
+ # to the root, so that is where this looks; the file is reread only
105
+ # when it changes, because routing asks for it on every tick.
106
+ #
107
+ # @return [OpenLanes::Config]
108
+ def current
109
+ root = Project.root || Dir.pwd
110
+ path = File.join(root, FILE)
111
+ key = [path, File.file?(path) && File.mtime(path)]
112
+ return @current.last if @current&.first == key
113
+
114
+ load(root).tap { |config| @current = [key, config] }
115
+ end
116
+
117
+ # @param text [String] the whole file
118
+ # @param path [String] for every error message
119
+ # @return [OpenLanes::Config]
120
+ def parse(text, path:)
121
+ data = YAML.safe_load(text) || {}
122
+ fail_with(path, "expected a mapping of settings at the top") unless data.is_a?(Hash)
123
+
124
+ settings = data.transform_keys(&:to_s)
125
+ unknown = settings.keys - KEYS
126
+ unless unknown.empty?
127
+ fail_with(path,
128
+ "unknown key#{"s" unless unknown.size == 1} #{unknown.join(", ")}; " \
129
+ "known: #{KEYS.join(", ")}")
130
+ end
131
+
132
+ config = new(path:,
133
+ project: project(settings, path),
134
+ lane: lane(settings, path),
135
+ frontend: frontend(settings, path),
136
+ jobs: jobs(settings, path),
137
+ redis_url: redis_url(settings, path),
138
+ commands: commands(settings["commands"], path))
139
+ conflicts!(config, path)
140
+ config
141
+ rescue Psych::Exception => e
142
+ fail_with(path, "is not valid YAML (#{e.message.lines.first.to_s.strip})")
143
+ end
144
+
145
+ private
146
+
147
+ # @return [void] never returns
148
+ def fail_with(path, message) = raise(Error, "#{path}: #{message}")
149
+
150
+ # @return [String, nil]
151
+ def project(settings, path)
152
+ value = settings["project"]
153
+ return nil if value.nil?
154
+ return value.strip if value.is_a?(String) && !value.strip.empty?
155
+
156
+ fail_with(path, "project: must be a non-empty name, not #{value.inspect}")
157
+ end
158
+
159
+ # @return [Symbol, nil]
160
+ def lane(settings, path)
161
+ return nil unless settings.key?("lane")
162
+
163
+ value = settings["lane"].to_s.strip.downcase.to_sym
164
+ return value if Plans::Spec::LANES.include?(value)
165
+
166
+ fail_with(path, "lane: #{settings["lane"].inspect} is not one of #{Plans::Spec::LANES.join(", ")}")
167
+ end
168
+
169
+ # @return [Boolean, nil]
170
+ def frontend(settings, path)
171
+ value = settings["frontend"]
172
+ return value if [true, false, nil].include?(value)
173
+
174
+ fail_with(path, "frontend: #{value.inspect} is not true or false")
175
+ end
176
+
177
+ # @return [Integer, nil]
178
+ def jobs(settings, path)
179
+ value = settings["jobs"]
180
+ return value if value.nil? || (value.is_a?(Integer) && value.positive?)
181
+
182
+ fail_with(path, "jobs: #{value.inspect} is not a whole number above zero")
183
+ end
184
+
185
+ # @return [String, nil]
186
+ def redis_url(settings, path)
187
+ value = settings["redis_url"]
188
+ return nil if value.nil?
189
+ return value.strip if value.is_a?(String) && REDIS_SCHEMES.include?(value.strip[/\A([a-z]+):/, 1])
190
+
191
+ fail_with(path, "redis_url: #{value.inspect} is not a #{REDIS_SCHEMES.map { "#{it}://" }.join(", ")} URL")
192
+ end
193
+
194
+ # @return [Hash{String => Hash{Symbol => Object}}]
195
+ def commands(value, path)
196
+ return {} if value.nil?
197
+
198
+ fail_with(path, "commands: must be a mapping of command name to its options") unless value.is_a?(Hash)
199
+
200
+ value.to_h do |name, options|
201
+ fail_with(path, "commands.#{name}: must be a mapping of option to value") unless options.is_a?(Hash)
202
+
203
+ [name.to_s.strip, options.transform_keys { it.to_s.tr("-", "_").to_sym }]
204
+ end
205
+ end
206
+
207
+ # @return [void]
208
+ def conflicts!(config, path)
209
+ SHORTHANDS.each do |key, owner|
210
+ next if config.public_send(key).nil? || !config.commands.fetch(owner, {}).key?(key)
211
+
212
+ fail_with(path, "#{key} and commands.#{owner}.#{key} are the same setting; keep one")
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+
6
+ module OpenLanes
7
+ # Splits a markdown file into its YAML frontmatter and its body.
8
+ #
9
+ # The agent definitions and `create --from` both read frontmatter, and both
10
+ # used to call `YAML.safe_load` on their own. Its defaults refuse to build a
11
+ # `Date`, so an ordinary `date: 2026-08-31` raised Psych::DisallowedClass and
12
+ # `create` reported a missing `title:` on a file whose title was right there.
13
+ # One parser, one list of permitted classes, so that cannot happen twice.
14
+ module Frontmatter
15
+ # rubocop: disable Lint/OrAssignmentToConstant
16
+ # Frontmatter, then body.
17
+ PATTERN ||= /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
18
+
19
+ # Dates and timestamps are ordinary frontmatter, so they load. Nothing else
20
+ # does: the point of `safe_load` is that a seed file cannot name a class.
21
+ PERMITTED_CLASSES ||= [Date, Time].freeze
22
+ # rubocop: enable Lint/OrAssignmentToConstant
23
+
24
+ class << self
25
+ # @param content [String] a whole markdown file
26
+ # @return [Array(Hash, String)] the frontmatter and the body. A file with
27
+ # no frontmatter is all body, and frontmatter that is not a mapping —
28
+ # a bare list, a lone string — reads as no keys rather than raising.
29
+ # @raise [Psych::Exception] when the frontmatter is not valid YAML
30
+ def split(content)
31
+ match = PATTERN.match(content) or return [{}, content]
32
+
33
+ meta = YAML.safe_load(match[1], permitted_classes: PERMITTED_CLASSES)
34
+ [meta.is_a?(Hash) ? meta : {}, match[2]]
35
+ end
36
+ end
37
+ end
38
+ end