openlanes 3.0.0 → 3.1.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 (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Plans
5
+ # Who waits for whom, read from every plan's `depends_on:` and nothing
6
+ # else. Only the forward edge is written down; `blocks` is derived here
7
+ # every time and never stored, so dropping a dependency cannot leave a
8
+ # stale reverse entry behind in the plan it used to name (N6).
9
+ #
10
+ # Every answer is an array of canonical `NNN.MM` strings, sorted. Asking
11
+ # about a plan the tree does not hold answers empty rather than raising.
12
+ #
13
+ # Plans joined by an unfinished edge, in either direction, form a group,
14
+ # and a group works on one plan at a time (#95): the first in topological
15
+ # order that may start, so two plans that will meet in a common dependent
16
+ # are never built against each other's moving code. Separate groups run
17
+ # side by side. An edge to a finished plan joins nothing, so the
18
+ # dependents of a shipped plan part ways.
19
+ class Dependencies
20
+ # The states in which a dependency is done. Everything else, blocked and
21
+ # deferred included, keeps the dependent waiting.
22
+ DONE = %i[approved deployed].freeze
23
+
24
+ # The states before the build. Work in them is documents only, so a plan
25
+ # here gives up its group's turn to one whose code is already under way.
26
+ BEFORE_BUILD = %i[retroactive new researched ready_for_planning planned].freeze
27
+
28
+ # @param tree [OpenLanes::Plans::Tree]
29
+ # @param status_of [#call, nil] the state to judge a plan by, when it is
30
+ # not the folder's name. A dry run renames nothing, so without this a
31
+ # prerequisite whose contents already say ✅ would still hold its
32
+ # dependents back, while a real run would have reconciled it and let
33
+ # them start.
34
+ def initialize(tree, status_of: nil)
35
+ @tree = tree
36
+ @status_of = status_of || :status.to_proc
37
+ end
38
+
39
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
40
+ # @return [Array<String>, nil] the declared list, nil when the key is absent
41
+ def depends_on(ordinal) = declared[key(ordinal)]
42
+
43
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
44
+ # @return [Array<String>] every plan whose `depends_on` names this one
45
+ def blocks_of(ordinal) = reverse.fetch(key(ordinal), [])
46
+
47
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
48
+ # @return [Array<String>] declared entries that are not done, unknown ones included
49
+ def waiting_on(ordinal) = edges(ordinal).reject { done?(it) }
50
+
51
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
52
+ # @return [Array<String>] declared entries with no folder in the tree
53
+ def unknown(ordinal) = edges(ordinal).reject { states.key?(it) }
54
+
55
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
56
+ # @return [Array<String>] declared entries naming a done plan, which the gate ignores
57
+ def closed(ordinal) = edges(ordinal).select { done?(it) }
58
+
59
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
60
+ # @return [Array<String>, nil] a shortest cycle through this plan,
61
+ # starting and ending with it, or nil when it is on none
62
+ def cycle_of(ordinal)
63
+ wanted = key(ordinal)
64
+ return nil unless looped?(wanted)
65
+
66
+ (@rings ||= {})[wanted] ||= ring_from(wanted)
67
+ end
68
+
69
+ # One shortest cycle per tangle of plans that wait on each other, each
70
+ # starting and ending at the tangle's lowest ordinal. A tangle can hold
71
+ # more cycles than this; {#cycle_of} names one through any plan in it.
72
+ #
73
+ # @return [Array<Array<String>>]
74
+ def cycles = @cycles ||= components.select { looped?(it.first) }.map { cycle_of(it.first) }.sort
75
+
76
+ # Unfinished plans joined by an unfinished edge, in either direction.
77
+ #
78
+ # @return [Array<Array<String>>] each group sorted, groups by first ordinal
79
+ def groups
80
+ @groups ||= begin
81
+ # Union-find, walked in a loop rather than recursively, so a long
82
+ # chain of plans cannot raise SystemStackError inside a tick.
83
+ root = unfinished.to_h { [it, it] }
84
+ find = lambda do |x|
85
+ x = root[x] = root[root[x]] until root[x] == x
86
+ x
87
+ end
88
+ unfinished.each do |from|
89
+ open_edges.fetch(from).each { |to| root[find.call(from)] = find.call(to) if root.key?(to) }
90
+ end
91
+ unfinished.group_by { find.call(it) }.values.map(&:sort).sort
92
+ end
93
+ end
94
+
95
+ # Each group in topological order, prerequisites first, ties by
96
+ # ordinal. Plans on a cycle cannot be ordered and come last.
97
+ #
98
+ # @return [Array<Array<String>>] parallel to {#groups}
99
+ def order = @order ||= groups.map { sorted(it) }
100
+
101
+ # The plan whose turn it is in this plan's group, or nil when no plan
102
+ # in it may start: the first in {#order} that may, preferring one whose
103
+ # build is already under way, so work already done is never stranded.
104
+ #
105
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
106
+ # @return [String, nil]
107
+ def head(ordinal)
108
+ wanted = key(ordinal)
109
+ line = order.find { it.include?(wanted) } or return nil
110
+ (@heads ||= {}).fetch(line.first) do
111
+ ready = line.select { startable?(it) }
112
+ @heads[line.first] = ready.find { !BEFORE_BUILD.include?(states[it]) } || ready.first
113
+ end
114
+ end
115
+
116
+ # Why the engine must not start this plan, or nil when it may. A cycle
117
+ # outranks a plain wait, since waiting on a cycle never ends. An entry
118
+ # naming no plan is said apart from a real wait: a typo such as
119
+ # "099.00" holds the plan forever, and "waiting on 099.00" in the held
120
+ # report read like a wait that would end on its own. A plan free of
121
+ # all three still waits while another plan holds its group's turn.
122
+ #
123
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
124
+ # @return [String, nil] e.g. "waiting on 034.00", "unknown plan 099.00",
125
+ # "cycle: 034.00 → 050.00 → 034.00" or "queued behind 034.00"
126
+ def reason(ordinal)
127
+ own = own_reason(ordinal) and return own
128
+
129
+ first = head(ordinal)
130
+ "queued behind #{first}" if first && first != key(ordinal)
131
+ end
132
+
133
+ private
134
+
135
+ # {#reason} without the group's turn: what this plan's own edges say.
136
+ #
137
+ # @param ordinal [OpenLanes::Plans::Ordinal, String]
138
+ # @return [String, nil]
139
+ def own_reason(ordinal)
140
+ cycle = cycle_of(ordinal)
141
+ return "cycle: #{cycle.join(" → ")}" if cycle
142
+
143
+ missing = unknown(ordinal)
144
+ open = waiting_on(ordinal) - missing
145
+ parts = []
146
+ parts << "unknown plan #{missing.join(", ")}" unless missing.empty?
147
+ parts << "waiting on #{open.join(", ")}" unless open.empty?
148
+ parts.join("; ") unless parts.empty?
149
+ end
150
+
151
+ # @return [Boolean] whether this plan could take its group's turn:
152
+ # neither parked nor done, and free of its own reasons to wait
153
+ def startable?(ordinal) = !StateMachine::SETTLED.include?(states[ordinal]) && own_reason(ordinal).nil?
154
+
155
+ # @return [Array<String>] plans in the tree that are not done
156
+ def unfinished = @unfinished ||= declared.keys.reject { done?(it) }.sort
157
+
158
+ # Kahn's algorithm over the group's open edges, taking the lowest
159
+ # ordinal whenever several plans are free. The free list stays sorted
160
+ # by binary insertion, so a group of thousands sorts in a blink.
161
+ #
162
+ # @param group [Array<String>]
163
+ # @return [Array<String>]
164
+ def sorted(group)
165
+ members = group.to_set
166
+ pending = group.to_h { |plan| [plan, open_edges.fetch(plan).count { members.include?(it) }] }
167
+ free = pending.select { |_, n| n.zero? }.keys.sort
168
+ out = []
169
+ until free.empty?
170
+ plan = free.shift
171
+ out << plan
172
+ pending.delete(plan)
173
+ blocks_of(plan).each do |waiter|
174
+ next unless pending.key?(waiter) && (pending[waiter] -= 1).zero?
175
+
176
+ free.insert(free.bsearch_index { it >= waiter } || free.size, waiter)
177
+ end
178
+ end
179
+ out + pending.keys.sort
180
+ end
181
+
182
+ # @return [String]
183
+ def key(ordinal) = (ordinal.is_a?(Ordinal) ? ordinal : Ordinal.parse(ordinal)).to_s
184
+
185
+ # @return [Array<String>]
186
+ def edges(ordinal) = depends_on(ordinal) || []
187
+
188
+ # @return [Boolean]
189
+ def done?(ordinal) = DONE.include?(states[ordinal])
190
+
191
+ # @return [Hash{String => Symbol}] ordinal => status key
192
+ def states = @states ||= @tree.subjects.to_h { [it.feature.ordinal.to_s, @status_of.call(it).key] }
193
+
194
+ # @return [Hash{String => Array<String>, nil}]
195
+ def declared
196
+ @declared ||= @tree.subjects.to_h { [it.feature.ordinal.to_s, Spec.for(it).depends_on] }
197
+ end
198
+
199
+ # @return [Hash{String => Array<String>}]
200
+ def reverse
201
+ @reverse ||= declared.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(from, tos), out|
202
+ Array(tos).each { out[it] << from }
203
+ end.transform_values(&:sort)
204
+ end
205
+
206
+ # The edges that can still hold anything up: entries not yet done that
207
+ # name a plan in the tree. A dependency on a ✅ plan is already met, so
208
+ # it closes no cycle; two plans that once named each other stop being
209
+ # deadlocked the moment one of them ships.
210
+ #
211
+ # @return [Hash{String => Array<String>}]
212
+ def open_edges = @open_edges ||= declared.keys.to_h { [it, waiting_on(it).select { declared.key?(it) }] }
213
+
214
+ # @return [Boolean] whether this plan sits on a cycle of open edges
215
+ def looped?(ordinal)
216
+ group = group_of[ordinal] or return false
217
+ group.size > 1 || open_edges.fetch(ordinal).include?(ordinal)
218
+ end
219
+
220
+ # @return [Hash{String => Array<String>}] ordinal => its strongly connected component
221
+ def group_of = @group_of ||= components.each_with_object({}) { |g, out| g.each { out[it] = g } }
222
+
223
+ # Tarjan's strongly connected components over {#open_edges}, so each
224
+ # plan is visited once. Enumerating every path instead revisited shared
225
+ # subgraphs on each one: 28 plans each waiting on the two before took
226
+ # 2.6 seconds and two million visits, and the dispatcher asks on every
227
+ # tick. The walk keeps its own stack of frames rather than recursing,
228
+ # so a chain of a few thousand plans cannot raise SystemStackError
229
+ # inside a dispatcher tick.
230
+ #
231
+ # @return [Array<Array<String>>] each component sorted
232
+ def components
233
+ @components ||= begin
234
+ index = {}
235
+ low = {}
236
+ stack = []
237
+ on_stack = {}
238
+ out = []
239
+ open_edges.keys.sort.each do |root|
240
+ next if index.key?(root)
241
+
242
+ frames = [[root, 0]]
243
+ index[root] = low[root] = index.size
244
+ stack << root
245
+ on_stack[root] = true
246
+ until frames.empty?
247
+ v, i = frames.last
248
+ w = open_edges.fetch(v)[i]
249
+ if w
250
+ frames.last[1] += 1
251
+ if !index.key?(w)
252
+ index[w] = low[w] = index.size
253
+ stack << w
254
+ on_stack[w] = true
255
+ frames << [w, 0]
256
+ elsif on_stack[w]
257
+ low[v] = [low[v], index[w]].min
258
+ end
259
+ next
260
+ end
261
+
262
+ frames.pop
263
+ parent = frames.last&.first
264
+ low[parent] = [low[parent], low[v]].min if parent
265
+ next unless low[v] == index[v]
266
+
267
+ group = []
268
+ group << stack.pop.tap { on_stack.delete(it) } until group.last == v
269
+ out << group.sort
270
+ end
271
+ end
272
+ out
273
+ end
274
+ end
275
+
276
+ # Breadth-first from +start+ back to itself inside its component, so
277
+ # the cycle named is a shortest one and the walk is linear.
278
+ #
279
+ # @param start [String]
280
+ # @return [Array<String>]
281
+ def ring_from(start)
282
+ group = group_of.fetch(start)
283
+ parent = { start => nil }
284
+ queue = [start]
285
+ until queue.empty?
286
+ node = queue.shift
287
+ open_edges.fetch(node).sort.each do |nxt|
288
+ next unless group.include?(nxt)
289
+
290
+ if nxt == start
291
+ path = [node]
292
+ path.unshift(parent[path.first]) while parent[path.first]
293
+ return path + [start]
294
+ end
295
+ next if parent.key?(nxt)
296
+
297
+ parent[nxt] = node
298
+ queue << nxt
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
4
+
3
5
  module OpenLanes
4
6
  module Plans
5
7
  # Lowercase in the middle of a title, capitalised at the front.
@@ -45,22 +47,36 @@ module OpenLanes
45
47
  }.join(" ")
46
48
  end
47
49
 
48
- # The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
50
+ # The heavy rule between the parts of a folder name. No space anywhere, so
51
+ # a name needs no quoting in a shell, and nothing an emoji can swallow.
52
+ RULE = "━━"
53
+
54
+ # What points from the state to the slug.
55
+ POINTER = "❯"
56
+
57
+ # The one place a plan folder's name is spelled out (decision N1):
58
+ # `NNN.MM━━<emoji>━━❯<slug>`, or `NNN.MM━━<emoji>━━❯[YYYY-MM-DD]━━<slug>`
59
+ # once the plan has been moved into an inactive subfolder (N3).
49
60
  #
50
- # The spaced arrow after the emoji is deliberate: an emoji renders two cells
51
- # wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
52
- # as if the emoji and the slug were touching. Everything that mints or
53
- # renames a folder goes through here; {Feature.parse} accepts the older
54
- # `--` and single-dash spellings too, and `resync dirs` migrates them on
55
- # contact.
61
+ # The earlier `NNN.MM-<emoji> → <slug>` put a space in every name, and the
62
+ # `--` before it let a two-cell emoji swallow the dash beside it.
63
+ # Everything that mints or renames a folder goes through here; {Feature.parse}
64
+ # still reads every older spelling, and `resync dirs` migrates them.
56
65
  #
57
66
  # @param ordinal [OpenLanes::Plans::Ordinal, String]
58
67
  # @param status [OpenLanes::Plans::Status]
59
68
  # @param slug [String]
69
+ # @param archived_on [Date, nil] the date the plan was moved aside
60
70
  # @return [String]
61
- def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
71
+ def self.plan_dirname(ordinal, status, slug, archived_on: nil)
72
+ date = archived_on && "[#{archived_on.strftime("%F")}]#{RULE}"
73
+ "#{ordinal}#{RULE}#{status.emoji}#{RULE}#{POINTER}#{date}#{slug}"
74
+ end
75
+
76
+ # The current spelling, with or without the archive date.
77
+ CURRENT_FORM = /\A[\d.]+#{RULE}(?<emoji>[^━]+)#{RULE}#{POINTER}(?:\[(?<date>\d{4}-\d{2}-\d{2})\]#{RULE})?(?<slug>.+)\z/
62
78
 
63
- # One `NNN.MM-<emoji> → <slug>` folder, decoded.
79
+ # One plan folder, decoded.
64
80
  #
65
81
  # @!attribute [r] ordinal
66
82
  # @return [OpenLanes::Plans::Ordinal]
@@ -72,9 +88,14 @@ module OpenLanes
72
88
  # @return [String] exactly as it is on disk
73
89
  # @!attribute [r] path
74
90
  # @return [String] absolute
75
- Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
91
+ # @!attribute [r] archived_on
92
+ # @return [Date, nil] the date in the name of a plan moved aside, else nil
93
+ Feature = Data.define(:ordinal, :status, :slug, :dirname, :path, :archived_on) do
76
94
  include Comparable
77
95
 
96
+ # @param archived_on [Date, nil] optional, so callers that predate it still build one
97
+ def initialize(archived_on: nil, **) = super
98
+
78
99
  # Decode a folder name, or return nil when it is not a plan folder.
79
100
  #
80
101
  # @param path [String] absolute path to a candidate directory
@@ -83,29 +104,67 @@ module OpenLanes
83
104
  dirname = File.basename(path)
84
105
  ordinal = Ordinal.from_dirname(dirname) or return nil
85
106
 
107
+ current(dirname, ordinal, path) || legacy(dirname, ordinal, path)
108
+ end
109
+
110
+ # `NNN.MM━━<emoji>━━❯<slug>`, dated or not.
111
+ #
112
+ # An emoji nothing answers to reads as ⚪ New rather than falling through
113
+ # to {.legacy}, which cannot split this spelling and took the whole name
114
+ # for the slug; the next rename then wrote
115
+ # `001.00━━⭕━━❯001.00━━🛑━━❯slug`. A name already nested that way is
116
+ # read down to its innermost slug, so `resync dirs` can undo it.
117
+ #
118
+ # @return [OpenLanes::Plans::Feature, nil]
119
+ def self.current(dirname, ordinal, path)
120
+ m = CURRENT_FORM.match(dirname) or return nil
121
+ status = OpenLanes::Plans.status_for_emoji(m[:emoji]) || STATUS_BY_KEY.fetch(:new)
122
+ archived_on = m[:date] && Date.iso8601(m[:date], exception: false)
123
+
124
+ new(ordinal:, status:, slug: innermost(m[:slug], ordinal), dirname:, path:, archived_on:)
125
+ end
126
+
127
+ # @param slug [String]
128
+ # @param ordinal [OpenLanes::Plans::Ordinal]
129
+ # @return [String] +slug+ without any plan prefix of the same ordinal
130
+ def self.innermost(slug, ordinal)
131
+ return slug unless Ordinal.from_dirname(slug) == ordinal
132
+
133
+ parse(slug)&.slug || slug
134
+ end
135
+
136
+ # `NNN.MM-<emoji> → <slug>`, `NNN-<emoji>--<slug>` and `NNN-<emoji>-<slug>`.
137
+ #
138
+ # @return [OpenLanes::Plans::Feature]
139
+ def self.legacy(dirname, ordinal, path)
86
140
  rest = dirname.sub(/\A[\d.]+[-_]/, "")
87
141
  head, tail = rest.split(/\s*→\s*|[-_]/, 2)
88
142
 
89
143
  # A leading segment with no ASCII word character is the status emoji.
90
- # The slug strips any further separators: the canonical spelling puts
91
- # a spaced arrow after the emoji, and folders from before that rule put
92
- # two dashes, or one.
144
+ # The slug strips any further separators: the arrow spelling put a
145
+ # spaced arrow after the emoji, and folders from before that put two
146
+ # dashes, or one.
93
147
  status = (OpenLanes::Plans.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
94
148
  slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
95
149
 
96
150
  new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
97
151
  end
152
+ private_class_method :current, :legacy, :innermost
98
153
 
99
154
  # @return [String] proper name, e.g. "Law as Data"
100
155
  def title = OpenLanes::Plans.titleize(slug)
101
156
 
102
157
  # The folder name this feature would carry in a given state — the slug
103
- # never moves, and the number is always rendered canonically, so this is
104
- # also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
158
+ # and any archive date never move, and the number is always rendered
159
+ # canonically, so this is also what repairs a folder written
160
+ # `018-⚪️--foo` before the `NNN.MM` rule.
105
161
  #
106
162
  # @param status [OpenLanes::Plans::Status]
163
+ # @param archived_on [Date, nil] defaults to the date the name carries now
107
164
  # @return [String]
108
- def dirname_as(status) = OpenLanes::Plans.plan_dirname(ordinal, status, slug)
165
+ def dirname_as(status, archived_on: self.archived_on)
166
+ OpenLanes::Plans.plan_dirname(ordinal, status, slug, archived_on:)
167
+ end
109
168
 
110
169
  # The number exactly as the folder writes it, which is not always the
111
170
  # canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Plans
7
+ # An agent's journal (M2): what it decided, did, left to do, asked and
8
+ # must not forget, kept from one round to the next.
9
+ #
10
+ # The agent writes no file and runs no command. It prints each entry as
11
+ # one line of JSON in its own output, which the harness reads anyway, and
12
+ # the dispatcher keeps it in `state.json` under `memory.<agent>`. One
13
+ # writer, under the lock {PlanState} already takes, is what lets a pair
14
+ # journal at once without either losing the other's line; an agent that
15
+ # wrote the file itself would be a second writer racing the first.
16
+ #
17
+ # {"openlanes_memory": {"kind": "decision", "text": "Money is integer cents"}}
18
+ # {"openlanes_memory": {"kind": "override", "text": "BigDecimal after all", "overrides": 3}}
19
+ #
20
+ # The single key {KEY} is what tells an entry from any other JSON an
21
+ # agent prints: a status object, a `package.json` it quotes, a `jq`
22
+ # result. Nothing else an agent has reason to print starts with that key.
23
+ #
24
+ # There is no timestamp. Entries are numbered in order across the
25
+ # agent's rounds on the plan, and a later one wins over an earlier one;
26
+ # an `override` names the number it overturns.
27
+ #
28
+ # Pure: text in, entries out. The dispatcher decides where they go.
29
+ module Journal
30
+ # The one key an entry line carries.
31
+ KEY = "openlanes_memory"
32
+
33
+ # What an entry may be. `question` is the agent stopping to ask; the
34
+ # harness adds the answer beside it once one arrives (M3).
35
+ KINDS = %w[todo done decision question override note].freeze
36
+
37
+ # Longest text kept, in characters. Every round's prompt carries the
38
+ # journal, so an entry is a line to remember, not a document; one that
39
+ # runs on is cut rather than refused, since what it says first is what
40
+ # it most needed to keep.
41
+ TEXT_LIMIT = 500
42
+
43
+ # A line that may be an entry: the key first, after an optional
44
+ # backtick, since agents quote JSON in Markdown as often as not.
45
+ CANDIDATE = /\A`*\{\s*"#{KEY}"\s*:/
46
+
47
+ # One entry, as the agent printed it or the harness recorded it.
48
+ #
49
+ # @!attribute [r] kind
50
+ # @return [String] one of {KINDS}
51
+ # @!attribute [r] text
52
+ # @return [String]
53
+ # @!attribute [r] overrides
54
+ # @return [Integer, nil] the number of the entry an `override` overturns
55
+ # @!attribute [r] ref
56
+ # @return [String, nil] `B<n>`, for a question the harness recorded
57
+ # from `blocked.md`
58
+ # @!attribute [r] answer
59
+ # @return [String, nil] a person's answer to that question
60
+ Entry = Data.define(:kind, :text, :overrides, :ref, :answer) do
61
+ def initialize(kind:, text:, overrides: nil, ref: nil, answer: nil) = super
62
+
63
+ # @param hash [Hash] as `state.json` keeps it
64
+ # @return [OpenLanes::Plans::Journal::Entry]
65
+ def self.from_h(hash)
66
+ new(kind: hash["kind"].to_s,
67
+ text: hash["text"].to_s,
68
+ overrides: hash["overrides"],
69
+ ref: hash["ref"],
70
+ answer: hash["answer"])
71
+ end
72
+
73
+ # @return [Hash] string keys, nils left out, as the schema wants
74
+ def to_json_h = to_h.transform_keys(&:to_s).compact
75
+ end
76
+
77
+ # A line that announced itself as an entry and was not one, kept so the
78
+ # dispatcher can say so: an agent whose journal is silently dropped
79
+ # believes it is remembering.
80
+ #
81
+ # @!attribute [r] line
82
+ # @return [String]
83
+ # @!attribute [r] reason
84
+ # @return [String]
85
+ Rejection = Data.define(:line, :reason)
86
+
87
+ # One round's worth of one agent's journal.
88
+ #
89
+ # @!attribute [r] number
90
+ # @return [Integer] the agent's journal round on this plan, 1 up,
91
+ # counted across runs and states. Not the dispatcher's round, which
92
+ # restarts at 1 in every run and every state and would land a later
93
+ # run's entries on an earlier run's.
94
+ # @!attribute [r] round
95
+ # @return [Integer, nil] the dispatcher's round it was printed in
96
+ # @!attribute [r] state
97
+ # @return [String, nil] the state the plan was in when it started
98
+ # @!attribute [r] entries
99
+ # @return [Array<Entry>]
100
+ # @!attribute [r] first
101
+ # @return [Integer] the number of its first entry in the whole journal
102
+ Round = Data.define(:number, :round, :state, :entries, :first)
103
+
104
+ module_function
105
+
106
+ # Every entry line in one piece of the agent's text, in order, each
107
+ # read into an {Entry} or a {Rejection}. Lines that do not start with
108
+ # {KEY} are none of this method's business and are skipped.
109
+ #
110
+ # @param text [String, nil] what the agent said
111
+ # @return [Array<Entry, Rejection>]
112
+ def scan(text)
113
+ text.to_s.each_line.filter_map do |raw|
114
+ line = raw.strip
115
+ read(line) if line.match?(CANDIDATE)
116
+ end
117
+ end
118
+
119
+ # @param line [String] one candidate line
120
+ # @return [Entry, Rejection]
121
+ def read(line)
122
+ json = line.delete_prefix("`" * line[/\A`*/].size).delete_suffix("`" * line[/`*\z/].size)
123
+ value = JSON.parse(json)[KEY]
124
+ problem = problem_with(value)
125
+ return Rejection.new(line:, reason: problem) if problem
126
+
127
+ Entry.new(kind: value["kind"], text: clip(value["text"]), overrides: value["overrides"])
128
+ rescue JSON::ParserError => e
129
+ Rejection.new(line:, reason: "not JSON: #{e.message.lines.first.to_s.strip}")
130
+ end
131
+
132
+ # Only the fields an agent may set are read; `ref` and `answer` are the
133
+ # harness's to write, and an agent claiming a person answered something
134
+ # is exactly the line that must not be trusted.
135
+ #
136
+ # @param value [Object]
137
+ # @return [String, nil] why it is not an entry
138
+ def problem_with(value)
139
+ return "#{KEY} is not an object" unless value.is_a?(Hash)
140
+ return "kind #{value["kind"].inspect} is not one of #{KINDS.join(", ")}" unless KINDS.include?(value["kind"])
141
+ return "text is missing" unless value["text"].is_a?(String) && !value["text"].strip.empty?
142
+
143
+ overrides = value["overrides"]
144
+ return nil if overrides.nil?
145
+ return "overrides must be an entry number" unless overrides.is_a?(Integer) && overrides.positive?
146
+
147
+ "only an override may name an entry it overrides" unless value["kind"] == "override"
148
+ end
149
+
150
+ # @param text [String]
151
+ # @return [String] stripped, and cut to {TEXT_LIMIT}
152
+ def clip(text)
153
+ text = text.to_s.strip
154
+ text.length > TEXT_LIMIT ? "#{text[0, TEXT_LIMIT - 1]}…" : text
155
+ end
156
+
157
+ # The line an agent prints, for the prompt's examples, so the prompt and
158
+ # the parser cannot describe two shapes.
159
+ #
160
+ # @param kind [String]
161
+ # @param text [String]
162
+ # @param overrides [Integer, nil]
163
+ # @return [String]
164
+ def line(kind, text, overrides: nil)
165
+ JSON.generate(KEY => { "kind" => kind, "text" => text, "overrides" => overrides }.compact)
166
+ end
167
+ end
168
+ end
169
+ end