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
@@ -19,6 +19,16 @@ module OpenLanes
19
19
  # whatever release is installed, which can predate `state sign`.
20
20
  EXECUTABLE = File.expand_path("../../../exe/openlanes", __dir__)
21
21
 
22
+ # Most journal entries a prompt carries, newest kept. The journal goes
23
+ # into every round's prompt, resumed or not, so it is paid for in
24
+ # tokens every round: at {Plans::Journal::TEXT_LIMIT} characters an
25
+ # entry, 40 is at most about 5k tokens, small beside the agent's own
26
+ # definition. The newest are the ones kept because a later entry wins
27
+ # over an earlier one, so what is cut is what was most likely already
28
+ # overturned or done; the prompt says how many were cut and how to read
29
+ # them.
30
+ JOURNAL_LIMIT = 40
31
+
22
32
  # @param agent [OpenLanes::Agents::Agent]
23
33
  # @param subject [OpenLanes::Plans::Subject]
24
34
  # @param root [String] the checkout the agent works in
@@ -32,8 +42,11 @@ module OpenLanes
32
42
  # @param seconds [Integer, nil] the enforced clock
33
43
  # @param denied [Array<String>] commands withheld
34
44
  # @param granted [Array<String>] commands lifted for this agent
45
+ # @param rebase [OpenLanes::Vcs::Refresh::Outcome, nil] a rebase stopped
46
+ # on conflicts, which this invocation resolves instead of working the plan
35
47
  def initialize(agent:, subject:, root:, profile:, round: 1, successor: nil, partners: [], control: nil,
36
- instructions: "", max_tokens: nil, fresh_budget: false, seconds: nil, denied: [], granted: [])
48
+ instructions: "", max_tokens: nil, fresh_budget: false, seconds: nil, denied: [], granted: [], rebase: nil)
49
+ @rebase = rebase
37
50
  @agent = agent
38
51
  @subject = subject
39
52
  @root = root
@@ -52,6 +65,8 @@ module OpenLanes
52
65
 
53
66
  # @return [String]
54
67
  def to_s
68
+ return resolution if @rebase
69
+
55
70
  <<~PROMPT
56
71
  #{agent.prompt}
57
72
 
@@ -65,7 +80,8 @@ module OpenLanes
65
80
  Repository root: #{root}
66
81
 
67
82
  #{"The folder's name is not currently justified: #{subject.violation}" if subject.violation}
68
- #{operator_instructions}#{requirements_section}#{completion_section}#{suggestions_section}#{ledger_section(agent, subject, round:, successor:)}#{budget_section}#{time_budget_section(agent)}#{control_section(control, agent, subject)}#{questions_section}#{mailbox_section(agent, subject, partners)}
83
+ #{snapshot_note}
84
+ #{operator_instructions}#{requirements_section}#{completion_section}#{suggestions_section}#{journal_section}#{ledger_section(agent, subject, round:, successor:)}#{budget_section}#{time_budget_section(agent)}#{control_section(control, agent, subject)}#{questions_section}#{mailbox_section(agent, subject, partners)}
69
85
  ## Boundary — enforced, not requested
70
86
 
71
87
  You may read anything, and write source, tests and the plan's own
@@ -93,6 +109,77 @@ module OpenLanes
93
109
  PROMPT
94
110
  end
95
111
 
112
+ # What an agent resolving a stopped rebase is told. Its own definition
113
+ # comes first, for who it is and what it builds, and so its own
114
+ # "a rebase stopped" section is read; everything after says this
115
+ # invocation is not plan work. No ledger section: a signature here would
116
+ # be read as this round's verdict on the plan, which it is not.
117
+ #
118
+ # @return [String]
119
+ def resolution
120
+ base = @rebase.base
121
+ files = @rebase.files.map { " #{it}" }.join("\n")
122
+ denied = @denied.map { " #{it}" }.join("\n")
123
+ stop = "Your control file is #{control}. Read it between steps: STOP means stage what you resolved and end your turn now."
124
+ <<~PROMPT
125
+ #{agent.prompt}
126
+
127
+ ---
128
+
129
+ ## This invocation: resolve a stopped rebase, and nothing else
130
+
131
+ Plan folder : #{subject.feature.path}
132
+ Plan number : #{subject.feature.ordinal}
133
+ Repository root: #{root}
134
+
135
+ The harness rebased this plan's branch onto `#{base}`, so the plan builds
136
+ on current code, and the rebase stopped on conflicts in:
137
+
138
+ #{files}
139
+
140
+ This is not plan work. Do not work your plan's units, do not sign the
141
+ ledger, and do not write `blocked.md`: a conflict is never a question
142
+ for a person. Do exactly this:
143
+
144
+ 1. Read what the branch is for (`spec.md` and `plan.md` in the plan
145
+ folder), the commit being replayed (`git show REBASE_HEAD`), and what
146
+ landed upstream (`git log --oneline -20 #{base}`).
147
+ 1. Resolve every conflict marker in the files above, keeping the intent
148
+ of both sides. Where they genuinely contradict each other, favour what
149
+ landed on `#{base}` and adapt the branch's change to it.
150
+ 1. Record each judgement call, one line per file, with its reason, under
151
+ `## Rebase decisions` in the plan folder's `plan.md`, so a reviewer
152
+ sees what was chosen and why.
153
+ 1. Run the tests that cover the files you changed.
154
+ 1. `git add` each file you resolved, then end your turn.
155
+
156
+ Never run `git rebase` (`--continue`, `--skip`, `--abort`), `git commit`,
157
+ or anything else that moves HEAD (`git reset`, `git switch`, `git merge`,
158
+ `git cherry-pick`, `git checkout <branch>`). The harness continues the
159
+ rebase after you end; if the next commit conflicts too, you are invoked
160
+ again for it.
161
+
162
+ If you cannot resolve a file, or the tests fail because of the merge and
163
+ you cannot fix that, leave the file conflicted (`git checkout -m -- <path>`
164
+ puts the markers back) and end your turn. The harness then hands the
165
+ rebase on, or aborts it, which restores the branch exactly.
166
+
167
+ #{stop if control}
168
+
169
+ ## Boundary — enforced, not requested
170
+
171
+ #{withheld_preamble(profile)}
172
+
173
+ #{denied}
174
+
175
+ The harness checks afterwards that HEAD has not moved. Claim each file
176
+ before you write it, naming yourself:
177
+
178
+ AGENT_ID=#{agent.name} alock acquire <path> "<why>"
179
+ AGENT_ID=#{agent.name} alock release-all
180
+ PROMPT
181
+ end
182
+
96
183
  # What a compacted agent is told when its session is resumed (W28). Its
97
184
  # own instructions survive in the compacted summary; what it needs is
98
185
  # to know that the INTERRUPT it just obeyed is over, and that the
@@ -106,9 +193,9 @@ module OpenLanes
106
193
  session and the same round on plan #{subject.feature.ordinal}.
107
194
 
108
195
  The INTERRUPT you were sent was only to pause you for the compaction.
109
- It is over: do not end your turn because of it. Read your RESUME note,
196
+ It is over: do not end your turn because of it. Read your resume record,
110
197
 
111
- #{cli} mail read --dir "#{plans_dir}" --plan #{subject.feature.ordinal} --for #{agent.name}
198
+ #{cli} state show --dir "#{plans_dir}" --plan #{subject.feature.ordinal} | jq '."last-known-state"."#{agent.name}"'
112
199
 
113
200
  then carry on from its exact next step, as your original instructions
114
201
  say, and sign your ledger when the work is done, as before. If you had
@@ -122,6 +209,18 @@ module OpenLanes
122
209
 
123
210
  attr_reader :agent, :subject, :root, :profile, :round, :successor, :partners, :control
124
211
 
212
+ # In a worktree the repository root holds its own `.plans`, a snapshot
213
+ # the publisher refreshes for the pull request. An agent that edited or
214
+ # signed it would be writing to a copy the next publish throws away.
215
+ #
216
+ # @return [String, nil]
217
+ def snapshot_note
218
+ return nil if File.expand_path(subject.feature.path).start_with?("#{File.expand_path(root)}/")
219
+
220
+ "The plan lives only at the plan folder above. Any `.plans` under the repository root is a\n" \
221
+ "snapshot the harness overwrites when it publishes: never read the plan from it or write to it."
222
+ end
223
+
125
224
  # Only a CLI that actually refuses the commands may be described as
126
225
  # refusing them.
127
226
  #
@@ -178,6 +277,80 @@ module OpenLanes
178
277
  SECTION
179
278
  end
180
279
 
280
+ # The journal (M2): how to keep it, and what it already holds. Said to
281
+ # every agent, identically, for the same reason as the ledger. What it
282
+ # holds is put here by the harness, not left for the agent to look up,
283
+ # so reading it is enforced rather than requested, and a resumed
284
+ # session, which may have compacted the round that wrote it, gets it too.
285
+ #
286
+ # @return [String]
287
+ def journal_section
288
+ <<~SECTION
289
+
290
+ ## Your journal - record as you go, read before you start
291
+
292
+ Keep a journal of this plan, for yourself in later rounds. Record each
293
+ of these the moment it happens, not at the end, so a round that is
294
+ killed still leaves everything you printed:
295
+
296
+ todo work you have found that still needs doing
297
+ done work you have finished
298
+ decision a choice you made, and why
299
+ question something you stopped to ask a person
300
+ override a decision that overturns an earlier entry, with its number
301
+ note anything else you must not forget
302
+
303
+ Write nothing to a file and run no command for it. Print each entry in
304
+ your reply, as one line of JSON on a line of its own; the harness reads
305
+ your output and keeps it in `state.json`:
306
+
307
+ #{Plans::Journal.line("decision", "Store money as integer cents, as the API already does")}
308
+ #{Plans::Journal.line("override", "Store money as BigDecimal: the spec's B1 answer needs fractions", overrides: 3)}
309
+
310
+ Entries are numbered from 1 across all your rounds on this plan, as
311
+ listed below, and a later entry wins over an earlier one. Keep each to
312
+ one line under #{Plans::Journal::TEXT_LIMIT} characters.
313
+ #{journal_so_far}
314
+ SECTION
315
+ end
316
+
317
+ # @return [String]
318
+ def journal_so_far
319
+ rounds = Plans::PlanState.for(subject.feature.path).journal(agent.name)
320
+ numbered = rounds.flat_map { |r| r.entries.each_with_index.map { |e, i| [r, r.first + i, e] } }
321
+ return "\nYour journal on this plan is empty: this is your first round to keep one.\n" if numbered.empty?
322
+
323
+ overridden = numbered.each_with_object({}) { |(_, n, e), map| map[e.overrides] = n if e.overrides }
324
+ shown = numbered.last(JOURNAL_LIMIT)
325
+ cut = numbered.size - shown.size
326
+ lines = shown.chunk_while { |a, b| a.first.equal?(b.first) }.flat_map do |group|
327
+ round = group.first.first
328
+ ["", " Journal round #{round.number}#{" (#{round.state})" if round.state}:",
329
+ *group.map { |_, n, e| " #{n}. #{journal_line(e)}#{" (overridden by #{overridden[n]})" if overridden[n]}" }]
330
+ end
331
+ omitted = if cut.positive?
332
+ "\n#{cut} earlier entr#{cut == 1 ? "y is" : "ies are"} not shown; read them with\n\n " \
333
+ "#{cli} state show --dir \"#{File.dirname(subject.feature.path)}\" --plan #{subject.feature.ordinal} " \
334
+ "| jq '.memory.\"#{agent.name}\"'\n"
335
+ end
336
+ "\nRead your journal so far before you do anything else. Do not redo what it says is done;\n" \
337
+ "check the files it names rather than taking it on trust.\n#{lines.join("\n")}\n#{omitted}"
338
+ rescue OpenLanes::Error
339
+ "\nYour journal could not be read: state.json is not valid JSON.\n"
340
+ end
341
+
342
+ # @param entry [OpenLanes::Plans::Journal::Entry]
343
+ # @return [String]
344
+ def journal_line(entry)
345
+ label = case entry.kind
346
+ when "override" then entry.overrides ? "override of #{entry.overrides}" : "override"
347
+ when "question" then entry.ref ? "question #{entry.ref}" : "question"
348
+ else entry.kind
349
+ end
350
+ answer = entry.answer ? " -> answered: #{entry.answer}" : ""
351
+ "[#{label}] #{entry.text}#{answer}"
352
+ end
353
+
181
354
  # The section `run --max-tokens` adds. Stating the number is what lets
182
355
  # the agent finish before it, rather than discovering the cap by dying
183
356
  # on it with half a file written.
@@ -266,7 +439,13 @@ module OpenLanes
266
439
  #{cli} mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
267
440
 
268
441
  Pass `--after N`, with the number of the last message you have read,
269
- to see only what is new. Write to it when you land an interface your
442
+ to see only what is new. Right after reading a message addressed to
443
+ you, ack it by its number, so the page stops warning that it is
444
+ unread:
445
+
446
+ #{cli} mail ack --dir "#{plans_dir}" --plan #{plan} --for #{agent.name} N
447
+
448
+ Write to it when you land an interface your
270
449
  partner is waiting on, when you amend the contract, when you need
271
450
  something from their half, and when you finish:
272
451
 
@@ -275,7 +454,7 @@ module OpenLanes
275
454
  Every message is appended with a number and a timestamp and never
276
455
  edited, so a person can read the exchange after the round. A question
277
456
  your partner has not answered within a few steps is not a reason to
278
- stop: write your assumption into implementation-plan.md and carry on.
457
+ stop: write your assumption into contract.md and carry on.
279
458
  SECTION
280
459
  end
281
460
 
@@ -283,9 +462,11 @@ module OpenLanes
283
462
  # the advisory clock writes into it whether or not anyone is watching.
284
463
  #
285
464
  # INTERRUPT is what makes a restart after Ctrl-C safe: the agent that
286
- # was cut off leaves a mailbox note to whoever picks the plan up, and
287
- # every agent is told to read its mail first, so the next run resumes
288
- # from the note rather than redoing work already on disk.
465
+ # was cut off leaves a resume record (`state resume`, plan 003) for
466
+ # whoever picks the plan up, and every agent is told to read it first,
467
+ # so the next run resumes from it rather than redoing work already on
468
+ # disk. A record, not a mail: fixed fields the next round can act on,
469
+ # and the harness writes one itself when the agent could not.
289
470
  #
290
471
  # @param control [String, nil]
291
472
  # @param agent [OpenLanes::Agents::Agent]
@@ -307,6 +488,12 @@ module OpenLanes
307
488
  finish the essential remainder now. STOP means write what you have,
308
489
  sign your ledger entry, and end your turn.
309
490
 
491
+ PAUSE means the run lost touch with the machines it shares work with.
492
+ Finish the write you are in, start nothing new, and wait: reread this
493
+ file every thirty seconds until it says anything other than PAUSE.
494
+ Your time does not run while you wait. RESUME means carry on from
495
+ where you paused.
496
+
310
497
  HOLD means a person wants to say something to you. Finish the write
311
498
  you are in, then read your mail and wait for their next message
312
499
  before you do anything else; check every thirty seconds:
@@ -315,30 +502,36 @@ module OpenLanes
315
502
 
316
503
  INTERRUPT means the operator pressed Ctrl-C and the run will be started
317
504
  again later. Start nothing new. Finish the write you are in the middle
318
- of, so no file is left half-written, then leave a resume note for
505
+ of, so no file is left half-written, then leave a resume record for
319
506
  whoever runs next — most likely you — saying what is done, what is
320
- not, and the exact next step:
507
+ not, the exact next step and the files you touched:
321
508
 
322
- #{cli} mail send --dir "#{plans_dir}" --plan #{plan} --from #{agent.name} --to #{agent.name} "RESUME: ..."
509
+ #{cli} state resume --dir "#{plans_dir}" --plan #{plan} --agent #{agent.name} --round #{round} --done "A,B" --remaining "C" --next-step "..." --files "x.rb,y.rb"
323
510
 
324
511
  Then sign `--status Interrupted`, with no `--next`, and
325
512
  end your turn.
326
513
 
327
- Before you start, check for such a note from an earlier run:
514
+ Before you start, check for such a record from an earlier run, and
515
+ read your mail, where a person may have left a RESUME note:
328
516
 
517
+ #{cli} state show --dir "#{plans_dir}" --plan #{plan} | jq '."last-known-state"."#{agent.name}"'
329
518
  #{cli} mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
330
519
 
331
- If there is a RESUME note, continue from it and do not redo what it
332
- says is done; check the files it names rather than taking it on trust.
520
+ If there is a record, continue from its next step and do not redo what
521
+ it says is done; check the files it names rather than taking it on
522
+ trust. A record whose source is "harness" was written for you after
523
+ you were stopped: it holds only what the harness saw.
333
524
  SECTION
334
525
  end
335
526
 
336
- # How to mark a yes/no question in `blocked.md` (W30). The page offers
337
- # Yes and No buttons for exactly the questions marked this way and never
338
- # guesses from the wording, so an agent that does not mark one gets a
339
- # text box, and one that marks a two-way choice gets a meaningless yes.
340
- # Said once here, for every agent that writes `blocked.md`, rather than
341
- # in each definition, so the marker cannot drift between them.
527
+ # How to write a question in `blocked.md` so a person can answer it
528
+ # with one click (D3): context, then lettered options, one recommended.
529
+ # The page draws a button per option and always adds a text box, so an
530
+ # agent that writes the shape gets buttons, and one that does not gets
531
+ # only the box. Said once here, for every agent that writes
532
+ # `blocked.md`, rather than in each definition, so the shape cannot
533
+ # drift between them. {Plans::Choices} reads it, and still reads the
534
+ # W30 yes/no mark in files written before.
342
535
  #
343
536
  # @return [String] empty for an agent whose definition never mentions
344
537
  # `blocked.md`, and for the agent that drains it
@@ -347,20 +540,29 @@ module OpenLanes
347
540
 
348
541
  <<~SECTION
349
542
 
350
- ## Questions in blocked.md: yes/no questions
543
+ ## Questions in blocked.md: context, then lettered options
544
+
545
+ Write every question for a person in one shape: a short context
546
+ paragraph saying what you need decided and why, then the options,
547
+ each on its own line, lettered `a)`, `b)`, `c)`, with exactly one
548
+ marked `(Recommended)`:
549
+
550
+ ## B2 Where the second machine comes from
351
551
 
352
- When a question can be answered with a plain yes or no, ask it that
353
- way and mark it, on its own line straight under its heading:
552
+ The chat spec needs someone on the other end, and steps 2 to 4 wait on it.
354
553
 
355
- ## B2 Rebase onto main
356
- #{Live::Snapshot::YES_NO_MARK}
554
+ a) A second demo process on another port, started by just web-e2e. (Recommended)
555
+ b) The demo answers its own invites. Fewer processes, but the test covers a fake.
556
+ c) Narrow the spec to steps 1 and 5 and cover the rest in the Ruby suite.
357
557
 
358
- The pull request conflicts in lib/openlanes/cli.rb. Should I rebase onto main?
558
+ A yes/no question is `a) yes` and `b) no`. Do not add an option for
559
+ "something else": the page always offers "Type something instead".
359
560
 
360
561
  The person answers with a button, and the answer reaches you as the
361
- bare word `yes` or `no`, so branch on the word. Mark only a real yes/no
362
- question: "Should I start it, or skip the integration specs?" is a
363
- choice between two things, and needs words.
562
+ letter and the option, e.g. `b) The demo answers its own invites. …`,
563
+ or as the bare word `yes` or `no`; anything else is their own words.
564
+ A question with no sensible options is a context paragraph alone, and
565
+ gets a text box.
364
566
  SECTION
365
567
  end
366
568
 
@@ -372,17 +574,17 @@ module OpenLanes
372
574
  # @return [String] empty when the spec says neither
373
575
  def completion_section
374
576
  spec = Plans::Spec.for(subject)
375
- return "" if spec.completed_when.empty? && spec.how_to_verify.empty?
577
+ return "" if spec.completed_when.empty? && spec.how_to_test.empty?
376
578
 
377
- lists = [["The task is finished when", spec.completed_when], ["How to verify it", spec.how_to_verify]]
579
+ lists = [["The task is finished when", spec.completed_when], ["How to test it", spec.how_to_test]]
378
580
  body = lists.reject { |_, items| items.empty? }.map do |title, items|
379
- "#{title}:\n\n#{items.map { |item| "- #{item}" }.join("\n")}\n"
581
+ "#{title}:\n\n#{items.map { Plans::Spec.list_item(it) }.join("\n")}\n"
380
582
  end
381
583
 
382
584
  "\n## Completion criteria — from the spec's author\n\n" \
383
585
  "Do not sign Completed until the first list holds, and run each check in the second. " \
384
586
  "If you write plan.md, copy both lists into it verbatim, under `## Task completed when` and " \
385
- "`## How to verify`; the harness adds them if you do not. A reviewer rejects work that fails a check.\n\n" \
587
+ "`#{Lifecycle::Completion::HOW_TO_TEST}`; the harness adds them if you do not. A reviewer rejects work that fails a check.\n\n" \
386
588
  "#{body.join("\n")}"
387
589
  end
388
590
 
@@ -57,10 +57,33 @@ module OpenLanes
57
57
  # @!attribute [r] session
58
58
  # @return [String, nil] the CLI's session id, from its `init` event,
59
59
  # which is what `--resume` carries the agent on in (W28)
60
- Progress = Data.define(:activity, :up, :down, :subagents, :pid, :message, :context, :session) do
61
- def initialize(pid: nil, message: nil, context: nil, session: nil, **rest) = super
60
+ # @!attribute [r] running
61
+ # @return [Array<String>] the sub-agents working right now, by name,
62
+ # oldest first; empty for a stream that never says (codex, pi)
63
+ # @!attribute [r] journal
64
+ # @return [Array<OpenLanes::Plans::Journal::Entry, OpenLanes::Plans::Journal::Rejection>]
65
+ # every journal line (M2) the agent has printed so far, in order. The
66
+ # whole list rather than the latest, because a snapshot can be
67
+ # skipped by a reader that is busy, and an entry must not be: the
68
+ # dispatcher keeps its place in the list and writes what is past it.
69
+ Progress = Data.define(:activity,
70
+ :up,
71
+ :down,
72
+ :subagents,
73
+ :pid,
74
+ :message,
75
+ :context,
76
+ :session,
77
+ :running,
78
+ :journal) do
79
+ def initialize(pid: nil, message: nil, context: nil, session: nil, running: [], journal: [], **rest) = super
62
80
  end
63
81
 
82
+ # The tools that start a sub-agent. `claude` called it `Task` and renamed
83
+ # it `Agent`; a transcript that knew only one name would show a run on the
84
+ # other CLI version with no sub-agents at all.
85
+ DELEGATES = %w[Task Agent].freeze
86
+
64
87
  # The one kind of line the trace does not keep.
65
88
  #
66
89
  # `--include-partial-messages` is what makes {#meter} possible, and it
@@ -82,7 +105,7 @@ module OpenLanes
82
105
  "Read" => "reading", "Write" => "writing", "Edit" => "editing",
83
106
  "MultiEdit" => "editing", "NotebookEdit" => "editing",
84
107
  "Bash" => "running", "Grep" => "searching for", "Glob" => "looking for",
85
- "Task" => "delegating", "TodoWrite" => "planning",
108
+ "Task" => "delegating", "Agent" => "delegating", "TodoWrite" => "planning",
86
109
  "WebFetch" => "fetching", "WebSearch" => "searching the web for",
87
110
  "SendUserMessage" => "saying"
88
111
  }.freeze
@@ -114,9 +137,13 @@ module OpenLanes
114
137
  @streamed = {}
115
138
  @tasks = {}
116
139
  @spawned = 0
140
+ @running = {}
117
141
  @context = nil
118
142
  @session = nil
119
143
  @compacted = nil
144
+ @journal = []
145
+ @partial = {}
146
+ @pending = Hash.new(0)
120
147
  end
121
148
 
122
149
  # Everything sent to the model, which on any turn after the first is
@@ -172,12 +199,25 @@ module OpenLanes
172
199
  pid: @pid,
173
200
  message: @message,
174
201
  context: @context,
175
- session: @session)
202
+ session: @session,
203
+ running: @running.values,
204
+ journal: @journal.dup.freeze)
176
205
  end
177
206
 
207
+ # The sub-agents started and not yet answered, by name. A count would
208
+ # have been enough for the meter; the page draws each one beside its
209
+ # parent, so it needs to know which.
210
+ #
211
+ # @return [Array<String>] oldest first
212
+ def running = @running.values
213
+
178
214
  # @return [String, nil] the session id from the stream's `init` event
179
215
  attr_reader :session
180
216
 
217
+ # @return [Array<OpenLanes::Plans::Journal::Entry, OpenLanes::Plans::Journal::Rejection>]
218
+ # the journal lines printed so far
219
+ def journal = @journal.dup
220
+
181
221
  # What a `/compact` reported in its `compact_boundary` event:
182
222
  # `pre_tokens`, `post_tokens` and the rest of its `compact_metadata`.
183
223
  #
@@ -286,15 +326,78 @@ module OpenLanes
286
326
  def handle(event)
287
327
  case event["type"]
288
328
  when "assistant"
329
+ grew = capture_journal(event)
289
330
  capture_message(event)
290
331
  @tools += tool_calls(event)
291
- announce(phrase_for(event))
292
- when "stream_event" then meter(event)
332
+ delegate(event)
333
+ # A journal line is published even when the phrase is the one
334
+ # already showing: the dispatcher keeps only the snapshots it is
335
+ # handed, so one held back here is lost if the round is killed
336
+ # before anything else publishes.
337
+ published = announce(phrase_for(event))
338
+ publish if grew && !published
339
+ when "user" then collect(event)
340
+ when "stream_event"
341
+ stream_journal(event)
342
+ meter(event)
293
343
  when "system" then system_event(event)
294
344
  when "result" then record_result(event)
295
345
  end
296
346
  end
297
347
 
348
+ # Journal lines (M2) in the agent's own text. A sub-agent's are left
349
+ # out: it was not told the format, the journal is its parent's, and a
350
+ # line it printed reaches the parent as a tool result the parent can
351
+ # repeat if it matters. Text only, never a tool's input, so a file the
352
+ # agent writes that happens to hold such a line is not a journal entry.
353
+ #
354
+ # @param event [Hash]
355
+ # @return [Boolean] whether the journal grew
356
+ def capture_journal(event)
357
+ return false unless event["parent_tool_use_id"].nil?
358
+
359
+ lines = blocks_of(event, "text").flat_map { Plans::Journal.scan(it["text"]) }
360
+ fresh = lines.reject do |line|
361
+ next false unless @pending[line].positive?
362
+
363
+ @pending[line] -= 1
364
+ true
365
+ end
366
+ @journal.concat(fresh)
367
+ !fresh.empty?
368
+ end
369
+
370
+ # The finished lines of a text block still being streamed, as they
371
+ # arrive (`--include-partial-messages`), so an agent killed before the
372
+ # block's `assistant` event keeps what it printed. {#capture_journal}
373
+ # then skips them when that event repeats them. Main agent and
374
+ # `text_delta` only: a sub-agent's text and a tool's input stream too.
375
+ #
376
+ # @param event [Hash] a `stream_event`
377
+ # @return [void]
378
+ def stream_journal(event)
379
+ inner = event["event"]
380
+ return unless event["parent_tool_use_id"].nil? && inner.is_a?(Hash)
381
+
382
+ case inner["type"]
383
+ when "message_start" then @partial = {}
384
+ when "content_block_start" then @partial[inner["index"]] = { text: +"", taken: 0 }
385
+ when "content_block_delta"
386
+ return unless inner.dig("delta", "type") == "text_delta"
387
+
388
+ block = (@partial[inner["index"]] ||= { text: +"", taken: 0 })
389
+ block[:text] << inner.dig("delta", "text").to_s
390
+ cut = block[:text].rindex("\n") or return
391
+
392
+ entries = Plans::Journal.scan(block[:text][0..cut])
393
+ fresh = entries.drop(block[:taken])
394
+ block[:taken] = entries.size
395
+ fresh.each { @pending[it] += 1 }
396
+ @journal.concat(fresh)
397
+ publish unless fresh.empty?
398
+ end
399
+ end
400
+
298
401
  # `--brief` gives the agent SendUserMessage, and what it sends is the
299
402
  # one line on the screen the agent itself chose. It outranks the tool
300
403
  # phrase until the agent sends another.
@@ -313,6 +416,50 @@ module OpenLanes
313
416
  end
314
417
  end
315
418
 
419
+ # A sub-agent is running from the `Task` call that starts it until the
420
+ # `tool_result` that answers that call. Its name is the `subagent_type`
421
+ # the call asked for, which is what a person would recognise
422
+ # ("leah-researcher"), and its `description` only when it named none.
423
+ # A sub-agent's own calls carry a `parent_tool_use_id` and are read the
424
+ # same way, so one it starts in turn is a sub-agent too.
425
+ #
426
+ # @param event [Hash]
427
+ # @return [void]
428
+ def delegate(event)
429
+ started = blocks_of(event, "tool_use").select { DELEGATES.include?(it["name"]) && it["id"] }
430
+ return if started.empty?
431
+
432
+ started.each do |block|
433
+ input = block["input"].is_a?(Hash) ? block["input"] : {}
434
+ name = [input["subagent_type"], input["description"]].map { it.to_s.strip }.find { !it.empty? }
435
+ @running[block["id"]] = name || "sub-agent"
436
+ end
437
+ publish
438
+ end
439
+
440
+ # The `tool_result` that answers a `Task` call is the sub-agent's
441
+ # return, so it leaves the list there.
442
+ #
443
+ # @param event [Hash]
444
+ # @return [void]
445
+ def collect(event)
446
+ done = blocks_of(event, "tool_result").filter_map { it["tool_use_id"] }.select { @running.key?(it) }
447
+ return if done.empty?
448
+
449
+ done.each { @running.delete(it) }
450
+ publish
451
+ end
452
+
453
+ # @param event [Hash]
454
+ # @param type [String]
455
+ # @return [Array<Hash>] the message's content blocks of that type
456
+ def blocks_of(event, type)
457
+ blocks = event.dig("message", "content")
458
+ return [] unless blocks.is_a?(Array)
459
+
460
+ blocks.select { it.is_a?(Hash) && it["type"] == type }
461
+ end
462
+
316
463
  # @param event [Hash]
317
464
  # @return [Integer] how many tools this one message called
318
465
  def tool_calls(event)
@@ -432,6 +579,9 @@ module OpenLanes
432
579
  @error = @result if event["is_error"]
433
580
  settle(event["usage"])
434
581
  @spawned = [@spawned, event.dig("subagent_stats", "spawned").to_i].max
582
+ # The invocation is over, so whatever it started is over with it,
583
+ # answered or not.
584
+ @running.clear
435
585
  publish
436
586
  end
437
587
 
@@ -512,12 +662,13 @@ module OpenLanes
512
662
  end
513
663
 
514
664
  # @param phrase [String, nil]
515
- # @return [void]
665
+ # @return [Boolean] whether it published
516
666
  def announce(phrase)
517
- return if phrase.nil? || phrase == @activity
667
+ return false if phrase.nil? || phrase == @activity
518
668
 
519
669
  @activity = phrase
520
670
  publish
671
+ true
521
672
  end
522
673
 
523
674
  # Hand the caller a whole snapshot rather than the one field that moved.
@@ -15,5 +15,26 @@ module OpenLanes
15
15
  # @param signals [Array<String>] handled as Ctrl-C is
16
16
  # @return [Object] whatever the block returns
17
17
  def on_interrupt(signals: %w[INT], &) = Control.on_interrupt(signals, &)
18
+
19
+ # `claude` prefers a credential in the environment to a claude.ai login,
20
+ # so a project `.env` that direnv loads on `cd` can redirect every agent
21
+ # a run spawns to a key meant for the application itself.
22
+ #
23
+ # Said once, before agents start, because the failure arrives three
24
+ # minutes later, once per agent, and reads like an agent problem rather
25
+ # than an environment one. Words only: a command or a workflow draws it.
26
+ #
27
+ # @return [String, nil] the warning, or nil when no credential shadows the login
28
+ def credentials_notice
29
+ names = Executor.foreign_credentials
30
+ return if names.empty?
31
+
32
+ one = names.size == 1
33
+ "#{names.join(" and ")} #{one ? "is" : "are"} set in this shell, so every agent authenticates " \
34
+ "with #{one ? "it" : "them"} rather than with your claude.ai login.\n" \
35
+ "A stale one fails every agent in the run with `401 API key is invalid`, " \
36
+ "three minutes at a time.\n" \
37
+ "If it came from a project .env, run `unset #{names.join(" ")}` first."
38
+ end
18
39
  end
19
40
  end