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,849 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require "tmpdir"
6
+
7
+ module OpenLanes
8
+ module Execution
9
+ # Runs one agent against one plan by shelling out to a coding agent CLI:
10
+ # `claude` unless the agent's definition or the plan's spec.md names
11
+ # another {OpenLanes::Adapters adapter}.
12
+ #
13
+ # The autonomy boundary is "docs plus code, but nothing leaves the machine",
14
+ # and it is enforced twice over:
15
+ #
16
+ # BEFORE — the agent is told, and `--disallowedTools` withholds the tools
17
+ # that would let it push.
18
+ # AFTER — the harness checks that HEAD did not move and no new remote ref
19
+ # appeared. A prompt is a request; a check is a guarantee, and only
20
+ # one of them survives a model deciding it knows better.
21
+ class Executor
22
+ # What one invocation did, and what it spent doing it.
23
+ #
24
+ # {#to_ary} is deliberate: every caller of {Executor#call} destructures
25
+ # `ok, note = executor.call(...)`, and the meter is an addition to that
26
+ # answer rather than a replacement for it. Callers that want the tokens
27
+ # ask for them by name.
28
+ #
29
+ # @!attribute [r] ok
30
+ # @return [Boolean]
31
+ # @!attribute [r] note
32
+ # @return [String] one line, for the report
33
+ # @!attribute [r] up
34
+ # @return [Integer] tokens sent, sub-agents included
35
+ # @!attribute [r] down
36
+ # @return [Integer] tokens generated
37
+ # @!attribute [r] subagents
38
+ # @return [Integer] sub-agents this agent spawned
39
+ # @!attribute [r] delegated
40
+ # @return [Integer] of {#up}, how much arrived as an unsplit sub-agent
41
+ # total rather than as a direction of its own
42
+ # @!attribute [r] seconds
43
+ # @return [Float] wall clock, from argv to exit
44
+ # @!attribute [r] killed
45
+ # @return [Symbol, nil] :timeout when the clock's backstop fired, :key
46
+ # when somebody pressed k, :budget when the token meter crossed the
47
+ # cap, :quit when the grace period after q ran out, nil otherwise
48
+ # @!attribute [r] pid
49
+ # @return [Integer, nil]
50
+ # @!attribute [r] cached
51
+ # @return [Integer] of {#up}, read back from the prompt cache
52
+ # @return [Data]
53
+ Result = Data.define(:ok, :note, :up, :down, :subagents, :delegated, :seconds, :killed, :pid, :cached) do
54
+ def initialize(killed: nil, pid: nil, cached: 0, **rest) = super
55
+
56
+ # @return [Integer] new input, cache writes and output
57
+ def fresh = up - cached + down
58
+
59
+ # @return [Array(Boolean, String)]
60
+ def to_ary = [ok, note]
61
+ end
62
+
63
+ # A person's request to compact one running agent's session (W28),
64
+ # and, once the harness has acted on it, how that went.
65
+ # {Live::Commands} holds on to it and replies once {#finished?}.
66
+ #
67
+ # @!attribute [r] model
68
+ # @return [String, nil] the model the session carries on in; nil keeps
69
+ # the one it was started on
70
+ # @!attribute [r] status
71
+ # @return [String, nil] "done" once the continuation has started,
72
+ # "refused" when it could not be, nil while under way
73
+ # @!attribute [r] reason
74
+ # @return [String, nil]
75
+ # @!attribute [r] restart
76
+ # @return [Boolean] whether the agent, stopped for a compaction that
77
+ # could not happen, gets its round back to start again afresh
78
+ Compaction = Struct.new(:model, :status, :reason, :restart) do
79
+ # @return [Boolean]
80
+ def finished? = !status.nil?
81
+
82
+ # @param reason [String]
83
+ # @return [void]
84
+ def done!(reason) = settle("done", reason)
85
+
86
+ # @param reason [String]
87
+ # @param restart [Boolean] the agent stopped for nothing, and is
88
+ # started again from its resume note in a fresh session
89
+ # @return [void]
90
+ def refuse!(reason, restart: false)
91
+ self.restart = restart
92
+ settle("refused", restart ? "#{reason}; it restarts from its resume note in a fresh session" : reason)
93
+ end
94
+
95
+ private
96
+
97
+ # The reason is set before the status, so a reader on another thread
98
+ # that sees it finished also sees why.
99
+ #
100
+ # @return [void]
101
+ def settle(status, reason)
102
+ self.reason = reason
103
+ self.status = status
104
+ end
105
+ end
106
+
107
+ # What the dispatcher holds on a running invocation, so a keypress can
108
+ # reach it: the process, its clock and its control file.
109
+ #
110
+ # Filled in by {#call} once the child exists. Every method tolerates the
111
+ # gap before that, because the keyboard does not wait.
112
+ class Handle
113
+ def initialize
114
+ @mutex = Mutex.new
115
+ @requested = nil
116
+ @compaction = nil
117
+ @closed = false
118
+ end
119
+
120
+ # @return [OpenLanes::Execution::Child, nil] the process running now,
121
+ # which after a compaction is no longer the first one
122
+ attr_accessor :child
123
+
124
+ # @return [String, nil] the CLI's session id, once its stream named it
125
+ attr_accessor :session
126
+
127
+ # @return [OpenLanes::Execution::Executor::Compaction, nil] the latest
128
+ # compaction asked for
129
+ def compaction = @mutex.synchronize { @compaction }
130
+
131
+ # @return [OpenLanes::Execution::Clock, nil]
132
+ attr_accessor :clock
133
+
134
+ # @return [String, nil]
135
+ attr_reader :control
136
+
137
+ # The control file, once {Executor#call} has registered it. A stop
138
+ # asked for before then is written the moment it exists, so an agent
139
+ # still starting up when Stop All came is not left running.
140
+ #
141
+ # @param path [String, nil]
142
+ # @return [void]
143
+ def control=(path)
144
+ @mutex.synchronize do
145
+ @control = path
146
+ Control.write(path, Control::STOP) if path && @stop_requested
147
+ end
148
+ end
149
+
150
+ # Stop at the next step, now or as soon as there is a control file.
151
+ #
152
+ # @return [void]
153
+ def request_stop!
154
+ @mutex.synchronize do
155
+ @stop_requested = true
156
+ Control.write(@control, Control::STOP) if @control
157
+ end
158
+ end
159
+
160
+ # @return [Symbol, nil] why the process was killed, once it was
161
+ attr_reader :killed
162
+
163
+ # @return [Integer, nil]
164
+ def pid = child&.pid
165
+
166
+ # @return [Integer, nil]
167
+ def remaining = clock&.remaining
168
+
169
+ # @return [Symbol, nil]
170
+ def phase = clock&.phase
171
+
172
+ # STOP, a grace period, then {Child#terminate}: TERM and KILL to the
173
+ # agent's whole process group, and its pipe closed, so the invocation
174
+ # ends even when a tool the agent started is what hangs. The grace is
175
+ # what lets an agent mid-write finish the line.
176
+ #
177
+ # @param grace [Integer] seconds
178
+ # @param reason [Symbol] :key, :timeout, :budget or :quit
179
+ # @return [void]
180
+ def kill!(grace: 15, reason: :key)
181
+ @killed ||= reason
182
+ Control.write(control, Control::STOP) if control
183
+ sleep(grace) if grace.positive?
184
+ child&.terminate
185
+ end
186
+
187
+ # @param seconds [Integer]
188
+ # @return [void]
189
+ def extend!(seconds) = clock&.extend!(seconds)
190
+
191
+ # Ask for the session to be compacted (W28): INTERRUPT, so the agent
192
+ # leaves a resume note and ends its turn, and SIGKILL after +grace+
193
+ # seconds if it has not. {Executor#call} then compacts the session and
194
+ # carries it on. Asked twice while under way, it is the same request.
195
+ #
196
+ # @param model [String, nil] what the session carries on in
197
+ # @param grace [Integer] seconds before the agent is made to stop
198
+ # @return [OpenLanes::Execution::Executor::Compaction]
199
+ def compact!(model: nil, grace: Control::GRACE)
200
+ process = nil
201
+ @mutex.synchronize do
202
+ return @compaction if @compaction && !@compaction.finished?
203
+
204
+ @compaction = Compaction.new(model:)
205
+ if @closed
206
+ @compaction.refuse!("the agent finished before it could be compacted")
207
+ return @compaction
208
+ end
209
+ # Aimed at the process running now, before the request is visible:
210
+ # once it is, {Executor#call} may already be compacting, and an
211
+ # INTERRUPT or a kill sent after that would stop the resumed session.
212
+ process = child
213
+ Control.write(control, Control::INTERRUPT) if control
214
+ @requested = @compaction
215
+ end
216
+ stop_after(grace, process)
217
+ @compaction
218
+ end
219
+
220
+ # @return [OpenLanes::Execution::Executor::Compaction, nil] the request
221
+ # to act on now, handed out once
222
+ def take_compaction = @mutex.synchronize { @requested.tap { @requested = nil } }
223
+
224
+ # The invocation is over: a request that arrives now, or one that came
225
+ # too late to be acted on, is refused rather than left waiting.
226
+ #
227
+ # @return [void]
228
+ def close!
229
+ @mutex.synchronize do
230
+ @closed = true
231
+ @requested&.refuse!("the agent finished before it could be compacted")
232
+ @requested = nil
233
+ end
234
+ end
235
+
236
+ private
237
+
238
+ # @param grace [Integer]
239
+ # @param process [OpenLanes::Execution::Child, nil] the one asked to stop
240
+ # @return [void]
241
+ def stop_after(grace, process)
242
+ return unless process
243
+
244
+ Thread.new do
245
+ sleep(grace) if grace.positive?
246
+ process.terminate if process.equal?(child)
247
+ end
248
+ end
249
+ end
250
+
251
+ # The transcripts of one invocation's sessions, added up: the agent's own
252
+ # and, once compacted, the `/compact` and the continuation after it. One
253
+ # car on the board, so one meter, which must not drop when the second
254
+ # session starts counting from zero.
255
+ class Sessions
256
+ def initialize = @all = []
257
+
258
+ # @param transcript [OpenLanes::Execution::Transcript]
259
+ # @return [OpenLanes::Execution::Executor::Sessions]
260
+ def <<(transcript)
261
+ @all << transcript
262
+ self
263
+ end
264
+
265
+ # @return [OpenLanes::Execution::Transcript] the session running now
266
+ def current = @all.last
267
+
268
+ # @return [OpenLanes::Execution::Transcript, nil] the one before it
269
+ def previous = @all[-2]
270
+
271
+ # @return [Integer]
272
+ def up = @all.sum(&:up)
273
+
274
+ # @return [Integer]
275
+ def down = @all.sum(&:down)
276
+
277
+ # @return [Integer]
278
+ def cached = @all.sum(&:cached)
279
+
280
+ # @return [Integer]
281
+ def fresh = up - cached + down
282
+
283
+ # @return [Integer]
284
+ def spawned = @all.sum(&:spawned)
285
+
286
+ # @return [Integer]
287
+ def delegated = @all.sum(&:delegated)
288
+
289
+ # @return [Integer]
290
+ def tools = @all.sum(&:tools)
291
+
292
+ # What the sessions already over spent, added to the running one's
293
+ # progress so the meter carries on rather than starting again.
294
+ #
295
+ # @param progress [OpenLanes::Execution::Transcript::Progress]
296
+ # @return [OpenLanes::Execution::Transcript::Progress]
297
+ def carry(progress)
298
+ before = @all[0...-1]
299
+ progress.with(up: progress.up + before.sum(&:up),
300
+ down: progress.down + before.sum(&:down),
301
+ subagents: progress.subagents + before.sum(&:spawned))
302
+ end
303
+ end
304
+
305
+ # Tools no agent may use under this autonomy level, whatever its definition
306
+ # asks for. Git itself is reachable through Bash, which is why the
307
+ # after-check exists as well.
308
+ #
309
+ # The exception is an agent that declares `network: true`. Closed is the
310
+ # right default — most specialists here read the repository and write to
311
+ # it, and a model that decides to go looking online mid-task is a model
312
+ # doing something nobody asked for. But a researcher inverts that: reading
313
+ # the internet is the entire job, and denying it silently produced an
314
+ # agent that ran, found nothing, and reported success.
315
+ DENIED_TOOLS = %w[WebFetch WebSearch].freeze
316
+
317
+ # Commands that leave the machine, denied to every agent by default.
318
+ #
319
+ # These are passed to `claude` as `Bash(<command>:*)` tool specifiers, so
320
+ # they are withheld rather than merely discouraged. This list spent a while
321
+ # as a regular expression that nothing referenced — a guard in the shape of
322
+ # a constant, enforcing nothing — which is exactly how `gh pr review` came
323
+ # to be reachable by an agent nobody had granted it to.
324
+ FORBIDDEN_COMMANDS = [
325
+ "git push", "git commit",
326
+ "gh pr create", "gh pr edit", "gh pr merge",
327
+ "gh pr review", "gh pr comment",
328
+ "gh release create"
329
+ ].freeze
330
+
331
+ # The subset no agent's `may:` can lift, however its definition is written.
332
+ #
333
+ # Pushing and merging change a branch everybody else builds on, and an
334
+ # unattended loop doing either has no way to be wrong quietly. Reviewing
335
+ # does not: an approval is reversible, visible, and attributable to the
336
+ # identity that made it. That difference is the whole line between
337
+ # `hansolo-reviewer` approving and `hansolo-reviewer` merging.
338
+ UNGRANTABLE = ["git push", "gh pr merge"].freeze
339
+
340
+ # How much of what the agent said survives into a one-line report.
341
+ REASON_LIMIT = 300
342
+
343
+ # Seconds an agent gets when neither its frontmatter nor `--timeout` says.
344
+ DEFAULT_TIMEOUT = 900
345
+
346
+ # Where the raw stream of each invocation is kept.
347
+ #
348
+ # Under `run -j` several agents work at once, and more than one
349
+ # `openlanes` may be driving the same checkout, so a trace is named
350
+ # per invocation rather than shared. To get an agent's final answer back
351
+ # out of one afterwards:
352
+ #
353
+ # jq -r 'select(.type=="result").result' <trace>
354
+ #
355
+ # and to replay what it did, tool call by tool call:
356
+ #
357
+ # jq -r 'select(.type=="assistant")
358
+ # | .message.content[]?
359
+ # | select(.type=="tool_use")
360
+ # | "\(.name) \(.input|tostring[0:80])"' <trace>
361
+ #
362
+ # Outside the repository on purpose: the harness checks afterwards that the
363
+ # agent moved nothing it should not have, and a megabyte of NDJSON dropped
364
+ # into the working tree is exactly the kind of thing that check would then
365
+ # have to learn to ignore.
366
+ TRACE_DIR = File.join(Dir.tmpdir, "openlanes-traces")
367
+
368
+ # Environment variables the `claude` CLI reads as credentials, in
369
+ # preference to a claude.ai login.
370
+ #
371
+ # A project `.env` that sets one of these for the application's own use
372
+ # reaches every agent a run spawns. `claude` then authenticates with that
373
+ # key rather than the login, and a stale or unrelated one turns an entire
374
+ # run into `401 API key is invalid`, three minutes per agent. The CLI warns
375
+ # rather than unsets. Driving it with an API key on purpose is legitimate,
376
+ # and nothing here can tell the two apart.
377
+ CREDENTIAL_VARS = %w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN].freeze
378
+
379
+ # @param env [Hash]
380
+ # @return [Array<String>] credential variables currently set
381
+ def self.foreign_credentials(env = ENV)
382
+ CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
383
+ end
384
+
385
+ # @param root [String] the repository the agents work in
386
+ # @param spawn [Proc] `argv, chdir:, env:` → {OpenLanes::Execution::Child}, swappable so the
387
+ # suite can play back a fake process without spawning one
388
+ # @param timeout [Integer, nil] `--timeout`: a cap on every agent's clock,
389
+ # or nil to let each agent's own frontmatter clock stand
390
+ # @param dry_run [Boolean] plan the invocation, do not run it
391
+ # @param trace_dir [String] where each invocation's raw stream is kept
392
+ # @param instructions [String, nil] what `run --prompt` typed, appended
393
+ # to the agent's own prompt. The command only accepts it alongside
394
+ # `--agent`, so exactly one agent ever hears it.
395
+ # @param model [String, nil] what `run --model` typed. The flag actually
396
+ # typed beats what an agent's frontmatter declares, the same precedence
397
+ # every other flag here follows; nil leaves each agent its own choice.
398
+ # @param max_tokens [Integer, nil] budget per invocation, input plus
399
+ # output, sub-agents included. The prompt states it so the agent can
400
+ # plan to finish inside it, and the meter enforces it so the statement
401
+ # is true. nil is unmetered.
402
+ # @param fresh_budget [Boolean] count only new tokens against
403
+ # +max_tokens+, leaving out cache reads; what `eval --live` uses
404
+ # @param lean [Boolean] start agents without the operator's personal
405
+ # plugins, skills, hooks and MCP servers; `run --user-config` turns it off
406
+ def initialize(root:, spawn: Child.method(:spawn), timeout: nil, dry_run: false,
407
+ trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, lean: true,
408
+ fresh_budget: false)
409
+ @root = File.expand_path(root)
410
+ @spawn = spawn
411
+ @timeout = timeout
412
+ @dry_run = dry_run
413
+ @trace_dir = trace_dir
414
+ @instructions = instructions.to_s.strip
415
+ @model = model
416
+ @max_tokens = max_tokens
417
+ @lean = lean
418
+ @fresh_budget = fresh_budget
419
+ @preferred = {}
420
+ end
421
+
422
+ # @return [String]
423
+ attr_reader :root
424
+
425
+ # @return [String, nil] what `run --model` typed
426
+ attr_reader :model
427
+
428
+ # Which CLI, model and effort this agent gets on this plan.
429
+ #
430
+ # @param agent [OpenLanes::Agents::Agent]
431
+ # @param subject [OpenLanes::Plans::Subject, nil]
432
+ # @return [OpenLanes::Agents::Profile]
433
+ def profile_for(agent, subject = nil)
434
+ spec = subject ? Plans::Spec.for(subject) : nil
435
+ Agents::Profile.resolve(agent, spec:, model: preferred_model(agent, subject) || @model)
436
+ end
437
+
438
+ # A model a person chose for one agent on one plan while the run was
439
+ # going (W24), taken from that agent's next invocation. It beats
440
+ # `run --model` because it was said later, about this agent in
441
+ # particular. nil forgets the choice.
442
+ #
443
+ # @param ordinal [#to_s]
444
+ # @param agent [String] the agent's name
445
+ # @param model [String, nil]
446
+ # @return [void]
447
+ def prefer_model(ordinal, agent, model)
448
+ key = [ordinal.to_s, agent.to_s]
449
+ model.nil? ? @preferred.delete(key) : @preferred[key] = model.to_s
450
+ end
451
+
452
+ # @param agent [OpenLanes::Agents::Agent]
453
+ # @param subject [OpenLanes::Plans::Subject, nil]
454
+ # @return [String, nil]
455
+ def preferred_model(agent, subject)
456
+ subject && @preferred[[subject.feature.ordinal.to_s, agent.name]]
457
+ end
458
+
459
+ # The clock this agent runs against: the tighter of its own frontmatter
460
+ # and `--timeout`. A flag that could only loosen was useless the day
461
+ # somebody wanted a quick run.
462
+ #
463
+ # {DEFAULT_TIMEOUT} steps in only when neither names a figure. It used
464
+ # to be the flag's default instead, which silently cut every 1200-second
465
+ # agent to 900 on the runs where nobody had typed `--timeout` at all.
466
+ #
467
+ # @param agent [OpenLanes::Agents::Agent]
468
+ # @return [Integer]
469
+ def timeout_for(agent) = [agent.timeout, @timeout].compact.min || DEFAULT_TIMEOUT
470
+
471
+ # @param agent [OpenLanes::Agents::Agent]
472
+ # @param subject [OpenLanes::Plans::Subject]
473
+ # @param root [String] the checkout the agent works in
474
+ # @param round [Integer] which attempt on this plan this is
475
+ # @param successor [String, nil] who the agent names on its `next:` line
476
+ # @param handle [OpenLanes::Execution::Executor::Handle, nil] filled in for the caller
477
+ # @param partners [Array<OpenLanes::Agents::Agent>] the other agents working this
478
+ # plan right now, named in the prompt beside the plan's mailbox
479
+ # @return [OpenLanes::Execution::Executor::Result]
480
+ # @yieldparam progress [OpenLanes::Execution::Transcript::Progress]
481
+ def call(agent, subject, root: @root, round: 1, successor: nil, handle: nil, partners: [], &on_progress)
482
+ started = UI.monotonic
483
+ if @dry_run
484
+ return Result.new(ok: true,
485
+ note: "dry run - would invoke #{agent.name}",
486
+ up: 0,
487
+ down: 0,
488
+ subagents: 0,
489
+ delegated: 0,
490
+ seconds: 0.0)
491
+ end
492
+
493
+ handle ||= Handle.new
494
+ before = head(root)
495
+ trace = trace_path(agent, subject)
496
+ profile = profile_for(agent, subject)
497
+ control = Control.register(@trace_dir, "#{subject.feature.ordinal}-#{agent.name}")
498
+ handle.control = control
499
+ argv = invocation(agent, subject, root:, control:, round:, successor:, partners:)
500
+ sessions = Sessions.new
501
+ child = launch(argv, root:, profile:, trace:, handle:, sessions:, &on_progress)
502
+ clock = Clock.new(seconds: timeout_for(agent),
503
+ control:,
504
+ on_expire: -> { handle.kill!(grace: 0, reason: :timeout) })
505
+ handle.clock = clock
506
+ clock.start
507
+
508
+ begin
509
+ status = drain(child, sessions, handle)
510
+ while (compaction = handle.take_compaction)
511
+ resumed = settling(compaction, agent) do
512
+ carry_on(compaction, agent, subject, root:, profile:, trace:, handle:, sessions:, &on_progress)
513
+ end
514
+ status = resumed if resumed
515
+ end
516
+ ensure
517
+ handle.close!
518
+ clock.stop
519
+ Control.release(control)
520
+ end
521
+ transcript = sessions.current
522
+
523
+ if handle.killed
524
+ return spent(sessions,
525
+ started,
526
+ ok: false,
527
+ pid: handle.pid,
528
+ killed: handle.killed,
529
+ note: "#{killed_note(handle.killed, clock)}, last seen #{transcript.activity || "starting up"} - trace: #{trace}")
530
+ end
531
+ unless status.success?
532
+ return failure(sessions,
533
+ started,
534
+ "#{profile.adapter.executable} exited #{status.exitstatus}: #{said(transcript)} - trace: #{trace}",
535
+ pid: handle.pid)
536
+ end
537
+ if transcript.failed?
538
+ return failure(sessions,
539
+ started,
540
+ "#{profile.adapter.executable} reported: #{transcript.error} - trace: #{trace}",
541
+ pid: handle.pid)
542
+ end
543
+
544
+ violation = boundary_violation(before, root)
545
+ return failure(sessions, started, violation, pid: handle.pid) if violation
546
+
547
+ spent(sessions,
548
+ started,
549
+ ok: true,
550
+ pid: handle.pid,
551
+ note: "completed#{" - #{sessions.tools} tool calls" if sessions.tools.positive?}")
552
+ end
553
+
554
+ # A failed invocation still spent what it spent, and a run that burned two
555
+ # hundred thousand tokens before timing out is a different fact from one
556
+ # that failed to authenticate and spent nothing. Both used to report the
557
+ # same thing.
558
+ #
559
+ # @return [OpenLanes::Execution::Executor::Result]
560
+ def failure(transcript, started, note, pid: nil) = spent(transcript, started, ok: false, note:, pid:)
561
+
562
+ # @return [OpenLanes::Execution::Executor::Result]
563
+ def spent(transcript, started, ok:, note:, pid: nil, killed: nil)
564
+ Result.new(ok:,
565
+ note:,
566
+ up: transcript.up,
567
+ down: transcript.down,
568
+ subagents: transcript.spawned,
569
+ delegated: transcript.delegated,
570
+ seconds: UI.monotonic - started,
571
+ killed:,
572
+ pid:,
573
+ cached: transcript.cached)
574
+ end
575
+
576
+ # @param transcript [OpenLanes::Execution::Transcript]
577
+ # @return [String] the last few plain lines, clipped
578
+ def said(transcript)
579
+ text = (transcript.failed? ? transcript.error : transcript.plain.last(3).join(" ")).to_s.strip
580
+ return "said nothing" if text.empty?
581
+
582
+ text.length > REASON_LIMIT ? "#{text[0, REASON_LIMIT - 1]}..." : text
583
+ end
584
+
585
+ # @param reason [Symbol]
586
+ # @param clock [OpenLanes::Execution::Clock]
587
+ # @return [String]
588
+ def killed_note(reason, _clock)
589
+ case reason
590
+ when :timeout then "timed out (killed #{Clock::GRACE}s after STOP)"
591
+ when :budget then "token budget of #{@max_tokens} exceeded"
592
+ when :quit then "still running after #{Control.interrupted? ? "ctrl-c" : "q"}, killed once the grace period ran out"
593
+ else "killed by request"
594
+ end
595
+ end
596
+
597
+ # The exact argv, exposed so a spec can assert the boundary flags without
598
+ # running anything.
599
+ #
600
+ # @param agent [OpenLanes::Agents::Agent]
601
+ # @param subject [OpenLanes::Plans::Subject]
602
+ # @param round [Integer] which attempt on this plan this is
603
+ # @param successor [String, nil] who the agent names on its `next:` line
604
+ # @param partners [Array<OpenLanes::Agents::Agent>]
605
+ # @return [Array<String>]
606
+ def invocation(agent, subject, root: @root, control: nil, round: 1, successor: nil, partners: [])
607
+ profile = profile_for(agent, subject)
608
+ profile.adapter.argv(
609
+ Adapters::Invocation.new(
610
+ prompt: prompt_for(agent, subject, root, control:, round:, successor:, partners:, profile:),
611
+ root:,
612
+ model: profile.model,
613
+ effort: profile.effort,
614
+ allowed_tools: agent.allowed_tools,
615
+ denied_tools: denied_for(agent),
616
+ denied_commands: denied_commands(agent),
617
+ network: agent.network,
618
+ lean: @lean
619
+ )
620
+ )
621
+ end
622
+
623
+ # What this particular agent may not touch: the network tools unless it
624
+ # asked for them, plus every forbidden command it has not been granted.
625
+ # Both decisions are recorded in a reviewable file rather than passed as a
626
+ # flag by whoever happened to start the run.
627
+ #
628
+ # @param agent [OpenLanes::Agents::Agent]
629
+ # @return [Array<String>]
630
+ def denied_for(agent)
631
+ tools = agent.network ? [] : DENIED_TOOLS
632
+ tools + denied_commands(agent).map { |command| "Bash(#{command}:*)" }
633
+ end
634
+
635
+ # @param agent [OpenLanes::Agents::Agent]
636
+ # @return [Array<String>] commands withheld from this agent
637
+ def denied_commands(agent) = FORBIDDEN_COMMANDS - granted_to(agent)
638
+
639
+ # @param agent [OpenLanes::Agents::Agent]
640
+ # @return [Array<String>] what its `may:` actually buys it
641
+ def granted_to(agent) = agent.may - UNGRANTABLE
642
+
643
+ private
644
+
645
+ # Start one session's process and the transcript that reads it. Every
646
+ # session of an invocation shares its trace file, which is opened for
647
+ # appending, so a compacted run reads back as one stream.
648
+ #
649
+ # @return [OpenLanes::Execution::Child]
650
+ def launch(argv, root:, profile:, trace:, handle:, sessions:, &on_progress)
651
+ transcript = profile.adapter.transcript(trace:) do |progress|
652
+ handle.session ||= progress.session
653
+ on_progress&.call(sessions.carry(progress))
654
+ end
655
+ sessions << transcript
656
+ child = @spawn.call(argv, chdir: root, env: profile.adapter.environment)
657
+ handle.child = child
658
+ transcript.pid = child.pid
659
+ child
660
+ end
661
+
662
+ # @return [Process::Status]
663
+ def drain(child, sessions, handle)
664
+ child.each_chunk do |out|
665
+ sessions.current.push(out)
666
+ abort_if_over(sessions, handle)
667
+ end
668
+ status = child.wait
669
+ sessions.current.finish
670
+ status
671
+ end
672
+
673
+ # The agent has stopped for its compaction (W28): its session is
674
+ # compacted, then resumed with {Prompt#continuation}, on the model the
675
+ # request names or the one it was on, under the same handle. The
676
+ # request is answered once the continuation has started, which is the
677
+ # moment a person can see it carry on.
678
+ #
679
+ # @param compaction [OpenLanes::Execution::Executor::Compaction]
680
+ # @return [Process::Status, nil] how the continuation ended; nil when
681
+ # there was none, and the agent's own stop stands
682
+ def carry_on(compaction, agent, subject, root:, profile:, trace:, handle:, sessions:, &)
683
+ model = compaction.model || profile.model
684
+ if handle.killed
685
+ compaction.refuse!("#{agent.name} was stopped first (#{handle.killed})")
686
+ return nil
687
+ end
688
+ unless (why = uncompactable(handle, profile))
689
+ compacted = compact(agent, model, root:, profile:, trace:, handle:, sessions:)
690
+ why = compacted unless compacted == true
691
+ end
692
+ if why
693
+ compaction.refuse!("could not compact #{agent.name}: #{why}", restart: !handle.killed)
694
+ return nil
695
+ end
696
+
697
+ Control.write(handle.control, "")
698
+ prompt = Prompt.new(agent:, subject:, root:, profile:, control: handle.control).continuation
699
+ child = launch(resumption(agent, profile, root:, session: handle.session, prompt:, model:),
700
+ root:, profile:, trace:, handle:, sessions:,
701
+ &)
702
+ compaction.done!(compacted_note(agent, sessions, model, compaction))
703
+ drain(child, sessions, handle)
704
+ end
705
+
706
+ # A compaction whose `/compact` or continuation could not even be
707
+ # started is refused before the error goes on up, so the person who
708
+ # asked is answered rather than left waiting on a thread that died.
709
+ #
710
+ # @param compaction [OpenLanes::Execution::Executor::Compaction]
711
+ # @return [Process::Status, nil] what the block returned
712
+ def settling(compaction, agent)
713
+ yield
714
+ rescue StandardError => e
715
+ compaction.refuse!("could not compact #{agent.name}: #{e.message}") unless compaction.finished?
716
+ raise
717
+ end
718
+
719
+ # @return [String, nil] why the session cannot be compacted, if it cannot
720
+ def uncompactable(handle, profile)
721
+ return "#{profile.adapter.name} cannot resume a session" unless profile.adapter.compacts?
722
+
723
+ "no session id to resume" unless handle.session
724
+ end
725
+
726
+ # `/compact` sent into the stopped session.
727
+ #
728
+ # @return [true, String] true, or why it failed
729
+ def compact(agent, model, root:, profile:, trace:, handle:, sessions:)
730
+ argv = resumption(agent,
731
+ profile,
732
+ root:,
733
+ session: handle.session,
734
+ prompt: profile.adapter.compact_prompt,
735
+ model:,
736
+ slash_commands: true)
737
+ status = drain(launch(argv, root:, profile:, trace:, handle:, sessions:), sessions, handle)
738
+ transcript = sessions.current
739
+ return "the agent was stopped during /compact (#{handle.killed})" if handle.killed
740
+ return "/compact exited #{status.exitstatus}: #{said(transcript)}" unless status.success?
741
+ return "/compact reported: #{transcript.error}" if transcript.failed?
742
+ return "/compact did not compact: #{said(transcript)}" unless transcript.compacted
743
+
744
+ true
745
+ end
746
+
747
+ # @return [String] the reply a person reads
748
+ def compacted_note(agent, sessions, model, compaction)
749
+ sizes = sessions.previous.compacted.values_at("pre_tokens", "post_tokens")
750
+ tokens = sizes.all? ? " from #{sizes.first} to #{sizes.last} tokens" : ""
751
+ "#{agent.name} compacted#{tokens} and carries on#{" on #{model}" if compaction.model}"
752
+ end
753
+
754
+ # The argv that carries an existing session on, with the boundary flags
755
+ # every invocation gets.
756
+ #
757
+ # @return [Array<String>]
758
+ def resumption(agent, profile, root:, session:, prompt:, model:, slash_commands: false)
759
+ profile.adapter.argv(
760
+ Adapters::Invocation.new(prompt:,
761
+ root:,
762
+ model:,
763
+ effort: profile.effort,
764
+ allowed_tools: agent.allowed_tools,
765
+ denied_tools: denied_for(agent),
766
+ denied_commands: denied_commands(agent),
767
+ network: agent.network,
768
+ lean: @lean,
769
+ resume: session,
770
+ slash_commands:)
771
+ )
772
+ end
773
+
774
+ # One file per invocation. The plan number and the agent's name make it
775
+ # findable; the pid and the clock keep two concurrent runs from writing to
776
+ # the same one. See {TRACE_DIR} for how to read one back.
777
+ #
778
+ # @param agent [OpenLanes::Agents::Agent]
779
+ # @param subject [OpenLanes::Plans::Subject]
780
+ # @return [String]
781
+ def trace_path(agent, subject)
782
+ FileUtils.mkdir_p(@trace_dir)
783
+ name = format("%s-%s-%s-%d-%04x.ndjson",
784
+ Time.now.strftime("%Y%m%d-%H%M%S"),
785
+ subject.feature.ordinal,
786
+ agent.name,
787
+ Process.pid,
788
+ rand(0x10000))
789
+ File.join(@trace_dir, name)
790
+ end
791
+
792
+ # @param agent [OpenLanes::Agents::Agent]
793
+ # @param subject [OpenLanes::Plans::Subject]
794
+ # @param round [Integer]
795
+ # @param successor [String, nil]
796
+ # @param partners [Array<OpenLanes::Agents::Agent>]
797
+ # @return [String]
798
+ def prompt_for(agent, subject, root = @root, control: nil, round: 1, successor: nil, partners: [],
799
+ profile: profile_for(agent, subject))
800
+ Prompt.new(agent:,
801
+ subject:,
802
+ root:,
803
+ profile:,
804
+ round:,
805
+ successor:,
806
+ partners:,
807
+ control:,
808
+ instructions: @instructions,
809
+ max_tokens: @max_tokens,
810
+ fresh_budget: @fresh_budget,
811
+ seconds: timeout_for(agent),
812
+ denied: denied_commands(agent),
813
+ granted: granted_to(agent)).to_s
814
+ end
815
+
816
+ # The token budget crossed, or the grace period after `q` spent. Both go
817
+ # through the handle so the kill is the same kill a keypress makes.
818
+ #
819
+ # @param transcript [OpenLanes::Execution::Executor::Sessions]
820
+ # @param handle [OpenLanes::Execution::Executor::Handle]
821
+ # @return [void]
822
+ def abort_if_over(transcript, handle)
823
+ spent = @fresh_budget ? transcript.fresh : transcript.up + transcript.down
824
+ if @max_tokens&.positive? && spent > @max_tokens
825
+ handle.kill!(grace: 0, reason: :budget)
826
+ elsif Control.overdue?
827
+ handle.kill!(grace: 0, reason: :quit)
828
+ end
829
+ end
830
+
831
+ # @return [String, nil] current commit, or nil outside a repository
832
+ def head(root = @root)
833
+ out = `git -C #{root.shellescape} rev-parse HEAD 2>/dev/null`.strip
834
+ out.empty? ? nil : out
835
+ end
836
+
837
+ # @param before [String, nil]
838
+ # @return [String, nil] what boundary was crossed, or nil
839
+ def boundary_violation(before, root = @root)
840
+ return nil if before.nil?
841
+
842
+ after = head(root)
843
+ return "agent committed (HEAD moved #{before[0, 7]} → #{after[0, 7]}) — the boundary is docs plus code, no commits" if after != before
844
+
845
+ nil
846
+ end
847
+ end
848
+ end
849
+ end