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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # What a `run` cost, once it is over.
6
+ #
7
+ # The loop reports movement — which plan went from ⭐️ to 🟡, which agent
8
+ # failed and why — and says nothing about the bill. A round of five agents
9
+ # against a large repository spends several million tokens and the better
10
+ # part of an hour, and until this existed the only record of that was a
11
+ # directory of trace files nobody reads.
12
+ #
13
+ # {#render} returns a string and prints nothing, so the caller decides where
14
+ # it goes, the same way {Reporter} works.
15
+ class Tally
16
+ # One agent's share of the run, added up across every round it ran in.
17
+ #
18
+ # @!attribute [r] name
19
+ # @return [String]
20
+ # @!attribute [r] invocations
21
+ # @return [Integer] times this agent was started
22
+ # @!attribute [r] subagents
23
+ # @return [Integer] sub-agents it spawned, across all of them
24
+ # @!attribute [r] up
25
+ # @return [Integer] tokens sent, sub-agent spend included
26
+ # @!attribute [r] down
27
+ # @return [Integer] tokens generated
28
+ # @!attribute [r] seconds
29
+ # @return [Float] how long it was running, added up
30
+ Row = Data.define(:name, :invocations, :subagents, :up, :down, :seconds)
31
+
32
+ # Cells per column, so the header, the rows and the rule cannot drift.
33
+ WIDTHS = { name: 22, invocations: 6, subagents: 11, up: 9, down: 9, seconds: 8 }.freeze
34
+
35
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
36
+ # @param seconds [Float] wall clock for the whole loop, which is not the
37
+ # sum of the agents' own times: several of them run at once
38
+ # @param rounds [Integer, nil] accepted so an older caller still works, and
39
+ # otherwise unused: the loop has no rounds of its own any more, each
40
+ # agent counts its own per plan, so the summary counts invocations
41
+ def initialize(attempts:, seconds:, rounds: nil)
42
+ @attempts = attempts.reject { |a| a.ordinal == "?" }
43
+ @seconds = seconds
44
+ @rounds = rounds
45
+ end
46
+
47
+ # @return [Array<OpenLanes::Engine::Runner::Attempt>]
48
+ attr_reader :attempts
49
+
50
+ # @return [Float] wall clock
51
+ attr_reader :seconds
52
+
53
+ # @return [Integer, nil]
54
+ attr_reader :rounds
55
+
56
+ # Plans an agent was actually started against, counted once each however
57
+ # many rounds they took.
58
+ #
59
+ # @return [Integer]
60
+ def plans = attempts.map(&:ordinal).uniq.size
61
+
62
+ # @return [Integer] tokens sent, everything included
63
+ def up = attempts.sum(&:up)
64
+
65
+ # @return [Integer] tokens generated
66
+ def down = attempts.sum(&:down)
67
+
68
+ # @return [Integer] sub-agents spawned by every agent in the run
69
+ def subagents = attempts.sum(&:subagents)
70
+
71
+ # Sub-agent spend that arrived as one number. `claude` reports what a
72
+ # sub-agent cost without splitting it between the two directions, so this
73
+ # much of {#up} is really up and down together.
74
+ #
75
+ # @return [Integer]
76
+ def delegated = attempts.sum(&:delegated)
77
+
78
+ # @return [Float] seconds of agent time, added up across all of them
79
+ def busy = attempts.sum(&:seconds)
80
+
81
+ # How many agents were running at any given moment, averaged over the
82
+ # run: agent-seconds divided by wall-clock seconds. Below the `-j` ceiling
83
+ # by however much the round spent waiting for its slowest member.
84
+ #
85
+ # @return [Float]
86
+ def concurrency = seconds.positive? ? busy / seconds : 0.0
87
+
88
+ # @return [Array<OpenLanes::Engine::Tally::Row>] one per agent, dearest first
89
+ def by_agent
90
+ attempts.group_by(&:agent).map { |name, list|
91
+ Row.new(name:,
92
+ invocations: list.size,
93
+ subagents: list.sum(&:subagents),
94
+ up: list.sum(&:up),
95
+ down: list.sum(&:down),
96
+ seconds: list.sum(&:seconds))
97
+ }.sort_by { |row| -row.up }
98
+ end
99
+
100
+ # @return [String] the table and the summary line under it
101
+ def render
102
+ return summary if attempts.empty?
103
+
104
+ ([header, rule] + by_agent.map { |row| line(row) } + [rule, totals, "", summary]).join("\n")
105
+ end
106
+
107
+ # @return [String] the one-line version, for a box
108
+ def summary
109
+ [
110
+ "#{plans} plan#{"s" unless plans == 1} addressed",
111
+ "#{attempts.size} invocation#{"s" unless attempts.size == 1}",
112
+ duration(seconds),
113
+ "#{format("%.1f", concurrency)} agents at a time",
114
+ "↑ #{UI.abbreviate(up)} ↓ #{UI.abbreviate(down)}"
115
+ ].join(" · ") + delegation
116
+ end
117
+
118
+ private
119
+
120
+ # @return [String] the caveat, where there is one to make
121
+ def delegation
122
+ return "" if delegated.zero?
123
+
124
+ "\n#{subagents} sub-agent#{"s" unless subagents == 1} reported #{UI.abbreviate(delegated)} " \
125
+ "tokens without splitting them, counted above as ↑."
126
+ end
127
+
128
+ # @return [String]
129
+ def header
130
+ cell("Agent", :name) + %i[invocations subagents up down seconds]
131
+ .zip(["runs", "sub-agents", "↑", "↓", "time"])
132
+ .map { |key, text| right(text, key) }.join
133
+ end
134
+
135
+ # @return [String]
136
+ def rule = WIDTHS.values.map { |width| ("─" * (width - 1)) + " " }.join
137
+
138
+ # @param row [OpenLanes::Engine::Tally::Row]
139
+ # @return [String]
140
+ def line(row)
141
+ cell(row.name, :name) + right(row.invocations.to_s, :invocations) +
142
+ right(row.subagents.to_s, :subagents) + right(UI.abbreviate(row.up), :up) +
143
+ right(UI.abbreviate(row.down), :down) + right(duration(row.seconds), :seconds)
144
+ end
145
+
146
+ # @return [String]
147
+ def totals
148
+ cell("", :name) + right(attempts.size.to_s, :invocations) + right(subagents.to_s, :subagents) +
149
+ right(UI.abbreviate(up), :up) + right(UI.abbreviate(down), :down) + right(duration(busy), :seconds)
150
+ end
151
+
152
+ # @param text [String]
153
+ # @param key [Symbol]
154
+ # @return [String] padded to its column, left-justified
155
+ def cell(text, key) = UI.fit(text, WIDTHS.fetch(key))
156
+
157
+ # @param text [String]
158
+ # @param key [Symbol]
159
+ # @return [String] padded to its column, right-justified
160
+ def right(text, key)
161
+ width = WIDTHS.fetch(key) - 1
162
+ text = UI.fit(text, width).rstrip
163
+ UI.fit("", width - UI.display_width(text)) + text + " "
164
+ end
165
+
166
+ # @param seconds [Numeric]
167
+ # @return [String] e.g. "42s", "12m40s", "1h04m"
168
+ def duration(seconds)
169
+ total = seconds.to_i
170
+ return "#{total}s" if total < 60
171
+ return "#{total / 60}m#{format("%02ds", total % 60)}" if total < 3600
172
+
173
+ "#{total / 3600}h#{format("%02dm", (total % 3600) / 60)}"
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # The run loop: which agent works which plan, in what order, until a round
5
+ # changes nothing. It decides; {Execution} carries the decision out.
6
+ module Engine
7
+ module_function
8
+
9
+ # @return [OpenLanes::Engine::Runner]
10
+ def runner(**) = Runner.new(**)
11
+
12
+ # @param tree [OpenLanes::Plans::Tree]
13
+ # @return [OpenLanes::Engine::StateFile]
14
+ def state_file(tree) = StateFile.new(path: StateFile.for(tree))
15
+
16
+ # @return [OpenLanes::Engine::Tally]
17
+ def tally(**) = Tally.new(**)
18
+ end
19
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module OpenLanes
6
+ module Evals
7
+ # One eval: a fixture plan, the agent given it, the depth asked for, and
8
+ # what the folder must look like afterwards.
9
+ #
10
+ # Validation refuses rather than ignores. A misspelt expectation would
11
+ # otherwise be a check that silently never runs, and an eval that passes
12
+ # because it checked nothing is worse than no eval at all.
13
+ #
14
+ # @!attribute [r] id
15
+ # @return [String] the file's basename, unique within its agent
16
+ # @!attribute [r] agent
17
+ # @return [String] an `agents/*.md` name
18
+ # @!attribute [r] depth
19
+ # @return [Symbol] one of {OpenLanes::Plans::Spec::DEPTHS}
20
+ # @!attribute [r] description
21
+ # @return [String]
22
+ # @!attribute [r] fixture
23
+ # @return [OpenLanes::Evals::Case::Fixture]
24
+ # @!attribute [r] expect
25
+ # @return [Hash{String => Object}] as written, string keys
26
+ # @!attribute [r] path
27
+ # @return [String] the YAML file
28
+ Case = Data.define(:id, :agent, :depth, :description, :fixture, :expect, :path) do
29
+ # The same case held to another depth's bounds.
30
+ #
31
+ # @param depth [Symbol, String, nil] nil keeps the case's own
32
+ # @return [OpenLanes::Evals::Case]
33
+ def at(depth) = depth.nil? ? self : with(depth: Case.depth!(depth, path))
34
+
35
+ # The bounds for {#depth}, or for the one given. A depth the case says
36
+ # nothing about has no bounds, so only the outcome checks apply.
37
+ #
38
+ # @param level [Symbol]
39
+ # @return [Hash{String => Object}]
40
+ def bounds(level = depth) = expect.fetch("depth", {}).fetch(level.to_s, {})
41
+
42
+ # @return [String] where this case keeps its recordings
43
+ def dir = path.delete_suffix(File.extname(path))
44
+
45
+ # @param kind [Symbol, String] :pass or :fail
46
+ # @return [OpenLanes::Evals::Recording, nil]
47
+ def recording(kind) = Recording.at(File.join(dir, kind.to_s))
48
+
49
+ # @return [Array<OpenLanes::Evals::Check>] every check, outcome first
50
+ def checks = Check.for(self)
51
+
52
+ # @return [String] `agent/id`, how the CLI names a case
53
+ def to_s = "#{agent}/#{id}"
54
+ end
55
+
56
+ class Case
57
+ # What the plan folder, and the repository around it, start as.
58
+ #
59
+ # @!attribute [r] state
60
+ # @return [Symbol] a status key
61
+ # @!attribute [r] slug
62
+ # @return [String]
63
+ # @!attribute [r] files
64
+ # @return [Hash{String => String}] plan-folder file to content
65
+ # @!attribute [r] repo
66
+ # @return [Hash{String => String}] repository path to content
67
+ Fixture = Data.define(:state, :slug, :files, :repo)
68
+
69
+ TOP = %w[id agent depth description fixture expect].freeze
70
+ FIXTURE = %w[state slug files repo].freeze
71
+ EXPECT = %w[state signed files_exist files_absent sections contains excludes changed_paths untouched depth].freeze
72
+ BOUNDS = %w[words count changed_files changed_lines max_seconds max_tokens].freeze
73
+
74
+ # What each bound may carry. `words` and `count` take one mapping or a
75
+ # list of them, so a case can bound two sections of one file.
76
+ BOUND_KEYS = {
77
+ "words" => %w[file section min max],
78
+ "count" => %w[file section pattern min max],
79
+ "changed_files" => %w[min max],
80
+ "changed_lines" => %w[min max]
81
+ }.freeze
82
+
83
+ class << self
84
+ # @param dir [String]
85
+ # @param registry [OpenLanes::Agents::Registry]
86
+ # @return [Array<OpenLanes::Evals::Case>]
87
+ def all(dir, registry:)
88
+ Dir.glob(File.join(File.expand_path(dir), "*", "*.yml")).map { |path| load(path, registry:) }
89
+ end
90
+
91
+ # @param path [String]
92
+ # @param registry [OpenLanes::Agents::Registry]
93
+ # @return [OpenLanes::Evals::Case]
94
+ # @raise [OpenLanes::Evals::Invalid]
95
+ def load(path, registry:)
96
+ data = YAML.safe_load_file(path)
97
+ refuse(path, "is not a mapping") unless data.is_a?(Hash)
98
+
99
+ from(data, path:, registry:)
100
+ rescue Psych::Exception => e
101
+ refuse(path, "is not valid YAML (#{e.message.lines.first.strip})")
102
+ end
103
+
104
+ # @param data [Hash]
105
+ # @param path [String]
106
+ # @param registry [OpenLanes::Agents::Registry]
107
+ # @return [OpenLanes::Evals::Case]
108
+ def from(data, path:, registry:)
109
+ unknown(path, data, TOP, "")
110
+ id = data["id"].to_s
111
+ refuse(path, "id #{id.inspect} must match the file name") unless id == File.basename(path, ".yml")
112
+ agent = data["agent"].to_s
113
+ refuse(path, "names no agent called #{agent.inspect}") unless registry.find(agent)
114
+ refuse(path, "sits under #{File.basename(File.dirname(path))}/ but names #{agent}") unless File.basename(File.dirname(path)) == agent
115
+
116
+ new(id:,
117
+ agent:,
118
+ depth: depth!(data["depth"], path),
119
+ description: data["description"].to_s.strip,
120
+ fixture: fixture(data["fixture"], path),
121
+ expect: expectations(data["expect"], path),
122
+ path:)
123
+ end
124
+
125
+ # @param value [Object]
126
+ # @param path [String]
127
+ # @return [Symbol]
128
+ def depth!(value, path)
129
+ depth = value.to_s.strip.downcase.to_sym
130
+ return depth if Plans::Spec::DEPTHS.include?(depth)
131
+
132
+ refuse(path, "depth #{value.inspect} is not one of #{Plans::Spec::DEPTHS.join(", ")}")
133
+ end
134
+
135
+ private
136
+
137
+ # @return [OpenLanes::Evals::Case::Fixture]
138
+ def fixture(data, path)
139
+ refuse(path, "has no fixture") unless data.is_a?(Hash)
140
+ unknown(path, data, FIXTURE, "fixture.")
141
+ state = Plans.status(data["state"].to_s) or refuse(path, "fixture.state #{data["state"].inspect} is not a status")
142
+ files = files(data.fetch("files", {}), path, "fixture.files")
143
+ refuse(path, "fixture.files has no spec.md") unless files.key?(Plans::Spec::FILENAME)
144
+
145
+ Fixture.new(state: state.key,
146
+ slug: data.fetch("slug", File.basename(path, ".yml")).to_s,
147
+ files:,
148
+ repo: files(data.fetch("repo", {}), path, "fixture.repo"))
149
+ end
150
+
151
+ # @return [Hash{String => String}]
152
+ def files(data, path, where)
153
+ refuse(path, "#{where} must map file names to contents") unless data.is_a?(Hash)
154
+ data.to_h { |name, body| [name.to_s, body.to_s] }
155
+ end
156
+
157
+ # @return [Hash]
158
+ def expectations(data, path)
159
+ refuse(path, "has no expect") unless data.is_a?(Hash)
160
+ unknown(path, data, EXPECT, "expect.")
161
+ if data.key?("state") && !Plans.status(data["state"].to_s)
162
+ refuse(path, "expect.state #{data["state"].inspect} is not a status")
163
+ end
164
+ depth = data.fetch("depth", {})
165
+ refuse(path, "expect.depth must map fast, medium or deep to bounds") unless depth.is_a?(Hash)
166
+ depth.each do |level, bounds|
167
+ depth!(level, path)
168
+ refuse(path, "expect.depth.#{level} must be a mapping") unless bounds.is_a?(Hash)
169
+ unknown(path, bounds, BOUNDS, "expect.depth.#{level}.")
170
+ bounds.slice(*BOUND_KEYS.keys).each do |name, value|
171
+ Array(value.is_a?(Hash) ? [value] : value).each do |bound|
172
+ refuse(path, "expect.depth.#{level}.#{name} must be a mapping") unless bound.is_a?(Hash)
173
+ unknown(path, bound, BOUND_KEYS.fetch(name), "expect.depth.#{level}.#{name}.")
174
+ end
175
+ end
176
+ end
177
+ data
178
+ end
179
+
180
+ # @return [void]
181
+ def unknown(path, data, allowed, prefix)
182
+ extra = data.keys.map(&:to_s) - allowed
183
+ refuse(path, "#{extra.map { |k| "#{prefix}#{k}" }.join(", ")}: unknown, expected one of #{allowed.join(", ")}") unless extra.empty?
184
+ end
185
+
186
+ # @return [void] never returns
187
+ def refuse(path, message) = raise(Invalid, "#{path}: #{message}")
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Evals
5
+ # What an agent changed in the repository: which paths, and how many lines.
6
+ #
7
+ # Computed by comparing the fixture's files with a directory, not by asking
8
+ # git, so a recording on disk and a live checkout are measured by the same
9
+ # code. Lines are counted as a multiset difference, added plus removed,
10
+ # which is what a reviewer reads as the size of a diff without needing one.
11
+ #
12
+ # @!attribute [r] paths
13
+ # @return [Array<String>] relative, sorted
14
+ # @!attribute [r] lines
15
+ # @return [Integer] lines added plus lines removed
16
+ Changes = Data.define(:paths, :lines) do
17
+ # @param glob [String]
18
+ # @return [Array<String>] changed paths matching it
19
+ def matching(glob) = paths.select { |p| Changes.match?(glob, p) }
20
+ end
21
+
22
+ class Changes
23
+ # Never part of the diff: git's own bookkeeping and the plans, which the
24
+ # plan-folder checks already cover.
25
+ IGNORED = [".git", OpenLanes::PLANS_DIR].freeze
26
+
27
+ FNMATCH = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
28
+
29
+ class << self
30
+ # @param before [Hash{String => String}] the fixture's repository
31
+ # @param dir [String] the repository afterwards
32
+ # @return [OpenLanes::Evals::Changes]
33
+ def between(before, dir)
34
+ after = snapshot(dir)
35
+ paths = (before.keys | after.keys).reject { |p| before[p] == after[p] }.sort
36
+ lines = paths.sum { |p| line_delta(before[p].to_s, after[p].to_s) }
37
+ new(paths:, lines:)
38
+ end
39
+
40
+ # @param glob [String]
41
+ # @param path [String]
42
+ # @return [Boolean]
43
+ def match?(glob, path) = File.fnmatch?(glob, path, FNMATCH)
44
+
45
+ private
46
+
47
+ # @return [Hash{String => String}]
48
+ def snapshot(dir)
49
+ root = File.expand_path(dir)
50
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: root).each_with_object({}) do |rel, out|
51
+ next if IGNORED.include?(rel.split("/").first)
52
+
53
+ full = File.join(root, rel)
54
+ out[rel] = File.read(full, encoding: "UTF-8") if File.file?(full)
55
+ end
56
+ end
57
+
58
+ # @return [Integer]
59
+ def line_delta(old, new)
60
+ a = old.lines.tally
61
+ b = new.lines.tally
62
+ (a.keys | b.keys).sum { |line| (a.fetch(line, 0) - b.fetch(line, 0)).abs }
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Evals
5
+ # One deterministic question about a finished folder.
6
+ #
7
+ # Two families, matching the two things D8 asks of every agent:
8
+ #
9
+ # outcome — state, signed, files, sections, contents, changed paths:
10
+ # did it finish the task?
11
+ # depth — words, counts, diff size, seconds, tokens, bounded per
12
+ # depth: did it work at the level it was asked for? Too
13
+ # shallow for deep fails, and so does overdone for fast.
14
+ #
15
+ # @!attribute [r] kind
16
+ # @return [Symbol] which judge answers it
17
+ # @!attribute [r] name
18
+ # @return [String] how a report names it
19
+ # @!attribute [r] args
20
+ # @return [Hash{Symbol => Object}] what the judge is given
21
+ Check = Data.define(:kind, :name, :args) do
22
+ # @param evidence [OpenLanes::Evals::Evidence]
23
+ # @return [OpenLanes::Evals::Verdict]
24
+ def call(evidence)
25
+ ok, detail = Check::Judges.public_send(kind, evidence, **args)
26
+ Verdict.new(name:, ok:, detail:)
27
+ end
28
+ end
29
+
30
+ class Check
31
+ class << self
32
+ # @param kase [OpenLanes::Evals::Case]
33
+ # @return [Array<OpenLanes::Evals::Check>]
34
+ def for(kase) = outcome(kase.expect) + depth(kase.bounds, kase.depth)
35
+
36
+ private
37
+
38
+ # @return [Array<OpenLanes::Evals::Check>]
39
+ def outcome(expect)
40
+ list = []
41
+ list << new(kind: :state, name: "state #{expect["state"]}", args: { expected: expect["state"].to_sym }) if expect["state"]
42
+ if expect["signed"]
43
+ note = expect["signed"] == true ? nil : expect["signed"].to_s
44
+ list << new(kind: :signed, name: "signed Completed#{" (#{note})" if note}", args: { note: })
45
+ end
46
+ Array(expect["files_exist"]).each { |f| list << new(kind: :file_exists, name: "has #{f}", args: { file: f }) }
47
+ Array(expect["files_absent"]).each { |f| list << new(kind: :file_absent, name: "no #{f}", args: { file: f }) }
48
+ expect.fetch("sections", {}).each do |file, headings|
49
+ list << new(kind: :sections, name: "sections in #{file}", args: { file:, headings: Array(headings) })
50
+ end
51
+ %w[contains excludes].each do |kind|
52
+ expect.fetch(kind, {}).each do |file, texts|
53
+ Array(texts).each { |text| list << new(kind: kind.to_sym, name: "#{file} #{kind} #{text}", args: { file:, text: }) }
54
+ end
55
+ end
56
+ Array(expect["changed_paths"]).each { |g| list << new(kind: :changed, name: "changed #{g}", args: { glob: g }) }
57
+ Array(expect["untouched"]).each { |g| list << new(kind: :untouched, name: "untouched #{g}", args: { glob: g }) }
58
+ list
59
+ end
60
+
61
+ # @return [Array<OpenLanes::Evals::Check>]
62
+ def depth(bounds, level)
63
+ list = []
64
+ %w[words count].each do |kind|
65
+ Array(bounds[kind].is_a?(Hash) ? [bounds[kind]] : bounds[kind]).each do |b|
66
+ where = b["section"] ? "#{b["file"]}##{b["section"]}" : b["file"]
67
+ what = kind == "count" ? "count /#{b["pattern"]}/ in #{where}" : "words in #{where}"
68
+ args = { file: b["file"], section: b["section"], min: b["min"], max: b["max"] }
69
+ args[:pattern] = b["pattern"] if kind == "count"
70
+ list << new(kind: kind.to_sym, name: "#{what} #{range(b)} @#{level}", args:)
71
+ end
72
+ end
73
+ %w[changed_files changed_lines].each do |kind|
74
+ next unless (b = bounds[kind])
75
+
76
+ list << new(kind: kind.to_sym, name: "#{kind.tr("_", " ")} #{range(b)} @#{level}", args: { min: b["min"], max: b["max"] })
77
+ end
78
+ list << new(kind: :seconds, name: "seconds ≤ #{bounds["max_seconds"]} @#{level}", args: { max: bounds["max_seconds"] }) if bounds["max_seconds"]
79
+ list << new(kind: :tokens, name: "tokens ≤ #{bounds["max_tokens"]} @#{level}", args: { max: bounds["max_tokens"] }) if bounds["max_tokens"]
80
+ list
81
+ end
82
+
83
+ # @return [String] `10..40`, `≥ 10` or `≤ 40`
84
+ def range(bound)
85
+ min, max = bound.values_at("min", "max")
86
+ return "#{min}..#{max}" if min && max
87
+
88
+ min ? "≥ #{min}" : "≤ #{max}"
89
+ end
90
+ end
91
+
92
+ # The answers. Each takes the evidence and its check's arguments and
93
+ # returns `[ok, detail]`; {Check#call} names the verdict.
94
+ module Judges
95
+ module_function
96
+
97
+ # Where the folder ended, and whether that state's own invariant holds
98
+ # of what is in it. A recording that claims 🔎 without a Research
99
+ # chapter fails here exactly as a rename would be refused.
100
+ #
101
+ # @return [Array(Boolean, String)]
102
+ def state(evidence, expected:)
103
+ actual = evidence.state or return [false, "the run recorded no final state"]
104
+ return [false, "ended #{actual}, expected #{expected}"] unless actual == expected
105
+
106
+ status = Plans.status(expected)
107
+ violation = status.violation(evidence.subject_as(status))
108
+ violation ? [false, violation] : [true, status.to_s]
109
+ end
110
+
111
+ # @return [Array(Boolean, String)]
112
+ def signed(evidence, note:)
113
+ agent = evidence.agent
114
+ entry = Plans::Ledger.last_for(Plans::Ledger.read(evidence.folder, agent.ledger), agent.name)
115
+ return [false, "#{agent.name} signed none of #{agent.ledger.join(", ")}"] unless entry
116
+ return [false, "last entry says #{entry.status}"] unless entry.completed?
117
+ if note && !entry.note.to_s.downcase.include?(note.downcase)
118
+ return [false, "signed Completed (#{entry.note || "no note"}), expected #{note}"]
119
+ end
120
+
121
+ [true, "#{entry.file}:#{entry.line}"]
122
+ end
123
+
124
+ # @return [Array(Boolean, String)]
125
+ def file_exists(evidence, file:)
126
+ File.exist?(File.join(evidence.folder, file)) ? [true, "present"] : [false, "#{file} is missing"]
127
+ end
128
+
129
+ # @return [Array(Boolean, String)]
130
+ def file_absent(evidence, file:)
131
+ File.exist?(File.join(evidence.folder, file)) ? [false, "#{file} is still there"] : [true, "absent"]
132
+ end
133
+
134
+ # @return [Array(Boolean, String)]
135
+ def sections(evidence, file:, headings:)
136
+ body = evidence.text(file) or return [false, "#{file} is missing"]
137
+ missing = headings.reject { |h| body.lines.any? { |l| l.match?(Evidence.heading(h)) } }
138
+ missing.empty? ? [true, "all #{headings.size} present"] : [false, "missing #{missing.map { |h| "## #{h}" }.join(", ")}"]
139
+ end
140
+
141
+ # @return [Array(Boolean, String)]
142
+ def contains(evidence, file:, text:)
143
+ body = evidence.read(file) or return [false, "#{file} is missing"]
144
+ found?(body, text) ? [true, "found"] : [false, "#{file} does not mention #{text}"]
145
+ end
146
+
147
+ # @return [Array(Boolean, String)]
148
+ def excludes(evidence, file:, text:)
149
+ body = evidence.read(file) or return [true, "#{file} is absent"]
150
+ found?(body, text) ? [false, "#{file} still says #{text}"] : [true, "not found"]
151
+ end
152
+
153
+ # @return [Array(Boolean, String)]
154
+ def changed(evidence, glob:)
155
+ changes = evidence.changes or return [false, "no repository was recorded"]
156
+ hits = changes.matching(glob)
157
+ hits.empty? ? [false, "nothing matching #{glob} changed"] : [true, hits.join(", ")]
158
+ end
159
+
160
+ # @return [Array(Boolean, String)]
161
+ def untouched(evidence, glob:)
162
+ changes = evidence.changes or return [false, "no repository was recorded"]
163
+ hits = changes.matching(glob)
164
+ hits.empty? ? [true, "untouched"] : [false, "changed #{hits.join(", ")}"]
165
+ end
166
+
167
+ # @return [Array(Boolean, String)]
168
+ def words(evidence, file:, section: nil, min: nil, max: nil)
169
+ body = evidence.text(file, section) or return [false, missing(file, section)]
170
+ within(body.scan(/[[:alnum:]][[:alnum:]'’_-]*/).size, min, max, "words")
171
+ end
172
+
173
+ # @return [Array(Boolean, String)]
174
+ def count(evidence, file:, pattern:, section: nil, min: nil, max: nil)
175
+ body = evidence.text(file, section) or return [false, missing(file, section)]
176
+ within(body.scan(Regexp.new(pattern)).size, min, max, "matches")
177
+ end
178
+
179
+ # @return [Array(Boolean, String)]
180
+ def changed_files(evidence, min: nil, max: nil)
181
+ changes = evidence.changes or return [false, "no repository was recorded"]
182
+ within(changes.paths.size, min, max, "files changed")
183
+ end
184
+
185
+ # @return [Array(Boolean, String)]
186
+ def changed_lines(evidence, min: nil, max: nil)
187
+ changes = evidence.changes or return [false, "no repository was recorded"]
188
+ within(changes.lines, min, max, "lines changed")
189
+ end
190
+
191
+ # @return [Array(Boolean, String)]
192
+ def seconds(evidence, max:)
193
+ spent = evidence.run.seconds or return [false, "not measured"]
194
+ within(spent.round, nil, max, "seconds")
195
+ end
196
+
197
+ # @return [Array(Boolean, String)]
198
+ def tokens(evidence, max:)
199
+ spent = evidence.run.tokens or return [false, "not measured"]
200
+ within(spent, nil, max, "tokens")
201
+ end
202
+
203
+ # @return [Array(Boolean, String)]
204
+ def within(value, min, max, unit)
205
+ return [false, "#{value} #{unit}, fewer than #{min}"] if min && value < min
206
+ return [false, "#{value} #{unit}, more than #{max}"] if max && value > max
207
+
208
+ [true, "#{value} #{unit}"]
209
+ end
210
+
211
+ # `/.../` is a pattern; anything else is a literal.
212
+ #
213
+ # @return [Boolean]
214
+ def found?(body, text)
215
+ text = text.to_s
216
+ text.length > 2 && text.start_with?("/") && text.end_with?("/") ? body.match?(Regexp.new(text[1..-2])) : body.include?(text)
217
+ end
218
+
219
+ # @return [String]
220
+ def missing(file, section) = section ? "#{file} has no ## #{section}" : "#{file} is missing"
221
+ end
222
+ end
223
+ end
224
+ end