openlanes 3.0.1 → 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 (319) 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 +4 -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 +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  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 +7 -0
  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 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  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 +84 -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 +1175 -117
  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 +6 -6
  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 +12 -120
  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 +223 -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 +17 -13
  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 +60 -11
  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 +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
-
5
3
  module OpenLanes
6
4
  module CLI
7
5
  # `openlanes create <words…>` — backs /spec-create.
@@ -57,12 +55,12 @@ module OpenLanes
57
55
 
58
56
  # noinspection RubyMismatchedArgumentType
59
57
  example [
60
- "tax rule dsl # 003.00-⚪️ → tax-rule-dsl, spec.md scaffolded and drafted",
58
+ "tax rule dsl # 003.00━━⚪━━❯tax-rule-dsl, spec.md scaffolded and drafted",
61
59
  "tax rule dsl --no-draft --no-open # scaffold only, nothing shelled out, nothing opened",
62
- "--after 002 schedule k1 # 002.01-🕰️-schedule-k1 (documented after the fact)",
60
+ "--after 002 schedule k1 # 002.01━━⏪━━❯schedule-k1 (documented after the fact)",
63
61
  "--after 018 --prs 12,15 verify # …and write spec.md from what those PRs did",
64
62
  "--after 018 --pr https://…/pull/12 verify # verify the pull request by number or URL",
65
- "--status ready billing sync # opens at ⭐️ instead of ⚪️",
63
+ "--status ready billing sync # opens at ⭐ instead of ⚪",
66
64
  "--from notes/tax-dsl.md # title and opening prose from the file's frontmatter and body",
67
65
  "--lane quick split big migration # r2d2-mechanic does it and opens the PR; no research, spec or plan"
68
66
  ]
@@ -71,222 +69,18 @@ module OpenLanes
71
69
  # @param options [Hash]
72
70
  # @return [void]
73
71
  def call(words:, **options)
74
- dir = options.fetch(:dir, OpenLanes::PLANS_DIR)
75
- FileUtils.mkdir_p(dir)
76
-
77
- words, seed = resolve_seed(words, options)
78
- prs = fetch_prs(options)
79
- result =
80
- Lifecycle.create(dir:,
81
- words:,
82
- after: options[:after],
83
- status: options[:status],
84
- prs:)
85
-
86
- result.either(
87
- ->(path) { created(path, prs, options, seed:) },
88
- ->(message) { refuse("Could not create the plan:\n#{message}", 65) }
89
- )
72
+ workflow = Lifecycle.create_plan(dir: options.fetch(:dir, OpenLanes::PLANS_DIR),
73
+ **options.slice(:root, :after, :status, :prs, :spec, :draft, :lane, :depth),
74
+ from: typed_path(options[:from]))
75
+ created = unwrap(workflow.call(words))
76
+
77
+ open_spec(created.spec) if created.spec && options.fetch(:open, true)
78
+ puts created.path
79
+ success(created.message) unless quiet?(options)
90
80
  end
91
81
 
92
82
  private
93
83
 
94
- # `--from FILE` trades the words argument for a markdown file: the
95
- # frontmatter's `title:` names the folder, and the body travels on to
96
- # {Brief} as the author's own opening statement. Words and a file are
97
- # two answers to one question — the folder's name — so holding both is
98
- # refused rather than silently ranked.
99
- #
100
- # @param words [Array<String>]
101
- # @param options [Hash]
102
- # @return [Array(Array<String>, String, nil)] the slug words, and the
103
- # seed body when a file supplied them
104
- def resolve_seed(words, options)
105
- file = typed_path(options[:from])
106
- return words, nil unless file
107
-
108
- unless words.empty?
109
- refuse(
110
- "--from names the topic by its frontmatter title;\n" \
111
- "drop the words, or drop --from",
112
- 64
113
- )
114
- end
115
- if options[:prs]
116
- refuse(
117
- "--prs reconstructs spec.md from the pull requests, so a seed file\n" \
118
- "would be ignored; use one or the other",
119
- 64
120
- )
121
- end
122
- unless File.file?(file)
123
- refuse("Could not read the seed file:\n#{file} does not exist", 66)
124
- end
125
-
126
- title, body = parse_seed(File.read(file, encoding: "UTF-8"))
127
- if title.to_s.strip.empty?
128
- refuse(
129
- "The seed file needs a frontmatter title, e.g.\n" \
130
- "---\ntitle: Tax Rule DSL\n---",
131
- 65
132
- )
133
- end
134
-
135
- [title.split, body]
136
- end
137
-
138
- # Frontmatter that will not parse used to be reported as a missing
139
- # title, which sends the author looking for a `title:` line that is
140
- # sitting right there. Say which of the two actually went wrong.
141
- #
142
- # @param content [String]
143
- # @return [Array(String, String)] title and body
144
- def parse_seed(content)
145
- meta, body = Frontmatter.split(content)
146
- [meta["title"].to_s, body.strip]
147
- rescue Psych::Exception => e
148
- refuse(
149
- "The seed file's frontmatter is not valid YAML:\n#{e.message}",
150
- 65
151
- )
152
- end
153
-
154
- # A retroactive plan documents work that landed *somewhere* in the
155
- # sequence, and only its author knows where. Guessing would put the
156
- # number — the one thing that never changes — in the wrong place.
157
- #
158
- # @param options [Hash]
159
- # @return [Array<Hash>, nil]
160
- def fetch_prs(options)
161
- return nil unless options[:prs]
162
-
163
- unless options[:after]
164
- refuse(
165
- "--prs documents work that already shipped;\n" \
166
- "name the plan it landed after with --after, e.g. --after 018",
167
- 64
168
- )
169
- end
170
-
171
- Vcs.github.pull_requests(Vcs::GitHub.parse_refs(options[:prs]))
172
- rescue OpenLanes::Error => e
173
- refuse("Could not read the pull requests:\n#{e.message}", 65)
174
- end
175
-
176
- # A plan with recorded pull requests already has its facts — hand it to
177
- # the writer that reconstructs a specification from a diff. One without
178
- # them does not exist yet, and reconstructing is not the job; {#brief} is.
179
- #
180
- # @param path [String]
181
- # @param prs [Array<Hash>, nil]
182
- # @param options [Hash]
183
- # @return [void]
184
- def created(path, prs, options, seed: nil)
185
- from_prs = prs && !prs.empty?
186
- path =
187
- if from_prs
188
- synthesize(path, options) if options.fetch(:spec, true)
189
- else
190
- brief(path, options, seed:)
191
- end || path
192
- puts path
193
- return if quiet?(options)
194
-
195
- feature = Plans::Feature.parse(path)
196
- success(
197
- "Created #{File.basename(path)}\n\n" \
198
- "#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
199
- "#{next_step(path, feature, from_prs:)}"
200
- )
201
- end
202
-
203
- # Hand the folder to the writer that already knows how to write a
204
- # retroactive specification, then let `resync dirs` decide what the
205
- # folder has become — 🕰️ is only true while there is no `spec.md`.
206
- #
207
- # @param path [String]
208
- # @param options [Hash]
209
- # @return [String] the folder's path, which the resync may have renamed
210
- def synthesize(path, options)
211
- agent = OpenLanes::Agents.registry.find(OpenLanes::RETROACTIVE_WRITER) or
212
- return path
213
- root = options[:root] || File.dirname(path, 2)
214
-
215
- feature = Plans::Feature.parse(path)
216
- ok, note = on_agent_row("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}",
217
- agent: agent.name,
218
- plan: feature.ordinal.to_s) do |line|
219
- Execution.executor(root:).call(agent, Plans.subject(feature), &line)
220
- end
221
- warn_about(note) unless ok
222
-
223
- settle(path)
224
- end
225
-
226
- # A folder with no pull requests to reconstruct from is a feature that
227
- # does not exist yet. {Brief} writes the four headings a human still has
228
- # to answer, makes a best-effort pass at them from what the project
229
- # already has on disk, and — unless told not to — opens the result for
230
- # a human to finish. The folder's state never moves: ⚪️ New only ever
231
- # claimed that a specification exists, not that it is complete.
232
- #
233
- # @param path [String] the path to the folder
234
- # @param seed [String, nil] the seed body from the `--from` file
235
- # @param options [Hash] the options hash
236
- # @return [String] +path+, unchanged
237
- def brief(path, options, seed: nil)
238
- feature = Plans::Feature.parse(path)
239
- return path if feature.status.key == :retroactive
240
-
241
- root = options[:root] || File.dirname(path, 2)
242
- brief = Lifecycle.brief(path:, title: feature.title, root:, seed:)
243
- brief.write_scaffold!
244
-
245
- if options.fetch(:draft, true)
246
- # Painted the way Runner::Task#label paints a roster agent, so the
247
- # half-agent reads as one of them on the terminal.
248
- label =
249
- "#{UI.paint(Lifecycle::Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
250
- ok, note = on_agent_row(label, agent: Lifecycle::Brief::AGENT_NAME, plan: feature.ordinal.to_s) { brief.attempt! }
251
- warn_about_draft(note) unless ok
252
- end
253
-
254
- stamp(brief.spec_path, options)
255
- open_spec(brief.spec_path) if options.fetch(:open, true)
256
- path
257
- end
258
-
259
- # One agent writing spec.md, drawn as a spinner line.
260
- #
261
- # @param label [String] the spinner line's text
262
- # @param agent [String]
263
- # @param plan [String]
264
- # @yieldparam line [OpenLanes::UI::Line]
265
- # @return [Object] the block's value
266
- def on_agent_row(label, agent:, plan:, &block)
267
- Execution.on_interrupt do
268
- UI.concurrently([plan],
269
- label,
270
- jobs: 1,
271
- label: ->(_) { label },
272
- fields: ->(_) { { plan:, agent: } }) { |_, line| block.call(line) }.first
273
- end
274
- end
275
-
276
- # Writes `--lane` and `--depth` into spec.md's frontmatter, after the
277
- # draft, so a drafting agent that rewrites the file cannot drop them.
278
- #
279
- # @param spec_path [String]
280
- # @param options [Hash]
281
- # @return [void]
282
- def stamp(spec_path, options)
283
- settings = { "lane" => options[:lane], "depth" => options[:depth] }.compact
284
- return if settings.empty? || !File.file?(spec_path)
285
-
286
- meta, body = Frontmatter.split(File.read(spec_path, encoding: "UTF-8"))
287
- File.write(spec_path, "#{YAML.dump(meta.merge(settings))}---\n#{body}")
288
- end
289
-
290
84
  # @param spec_path [String]
291
85
  # @return [void]
292
86
  def open_spec(spec_path)
@@ -301,54 +95,6 @@ module OpenLanes
301
95
  #
302
96
  # @return [Boolean]
303
97
  def macos? = RbConfig::CONFIG["host_os"].to_s.match?(/darwin/)
304
-
305
- # @param path [String]
306
- # @return [String] where the folder ended up
307
- def settle(path)
308
- tree = Plans.tree(File.dirname(path))
309
- change =
310
- OpenLanes::Lifecycle::Resync::Dirs
311
- .new(tree:)
312
- .call(commit: true)
313
- .find { |c| c.source == path }
314
- change ? change.target : path
315
- end
316
-
317
- # @param note [String]
318
- # @return [void]
319
- def warn_about(note)
320
- error(
321
- "The folder was created, but spec.md was not written:\n#{note}\n\n" \
322
- "The pull requests are recorded. Run `openlanes run` to retry."
323
- )
324
- end
325
-
326
- # @param note [String]
327
- # @return [void]
328
- def warn_about_draft(note)
329
- error(
330
- "spec.md was scaffolded, but the drafting attempt did not finish:\n#{note}\n\n" \
331
- "The four headings are there, empty. Fill them in by hand, or hand off to leah-researcher."
332
- )
333
- end
334
-
335
- # @param path [String]
336
- # @param feature [OpenLanes::Plans::Feature]
337
- # @return [String]
338
- def next_step(path, feature, from_prs:)
339
- spec = File.join(File.basename(path), "spec.md")
340
- unless feature.status.key == :new &&
341
- File.file?(File.join(path, "spec.md"))
342
- return "Next: write #{spec}"
343
- end
344
- if from_prs
345
- return (
346
- "Next: read #{spec} — it was written from the pull requests, so check it against what actually shipped"
347
- )
348
- end
349
-
350
- "Next: fill in the four headings in #{spec}, then hand off to leah-researcher"
351
- end
352
98
  end
353
99
  end
354
100
  end
@@ -11,6 +11,7 @@ module OpenLanes
11
11
  standalone!
12
12
  desc "Score agents against evals/cases: recorded folders by default, real runs with --live"
13
13
 
14
+ argument :name, required: false, desc: "Only this agent's cases, as `--agent` says it"
14
15
  option :agent, aliases: ["-a"], desc: "Only this agent's cases"
15
16
  option :case, aliases: ["-c"], desc: "Only the case with this id"
16
17
  option :depth,
@@ -36,9 +37,11 @@ module OpenLanes
36
37
  "--live -c research-fast --max-tokens 100000"
37
38
  ]
38
39
 
40
+ # @param name [String, nil] the positional spelling of `--agent`
39
41
  # @param options [Hash]
40
42
  # @return [void]
41
- def call(**options)
43
+ def call(name: nil, **options)
44
+ options = options.merge(agent: name) if name && options[:agent].nil?
42
45
  quiet?(options)
43
46
  refuse("--max-tokens only applies to --live runs.", 64) if options[:max_tokens] && !options[:live]
44
47
  cases = selected(options)
@@ -21,6 +21,9 @@ module OpenLanes
21
21
  type: :boolean,
22
22
  desc: "Whether plans have a front end; leave it out to let each plan decide"
23
23
  option :jobs, aliases: ["-j"], desc: "How many plans `openlanes run` works on at once (default: cores - 2)"
24
+ option :quality,
25
+ values: Config::QUALITIES.map(&:to_s),
26
+ desc: "desired_codebase_quality: high or mvp (R4); leave it out to be asked on the first ok-not-perfect review"
24
27
  option :yes,
25
28
  type: :boolean,
26
29
  default: false,
@@ -43,6 +46,7 @@ module OpenLanes
43
46
  # @return [void]
44
47
  def call(**options)
45
48
  quiet?(options)
49
+ prepare(options)
46
50
  root = Project.init_root
47
51
  path = File.join(root, Config::FILE)
48
52
  plans = File.expand_path(options.fetch(:dir, OpenLanes::PLANS_DIR), root)
@@ -62,7 +66,28 @@ module OpenLanes
62
66
 
63
67
  private
64
68
 
65
- # The four values, each from its flag, else asked on a terminal, else
69
+ # `openlanes setup`, but only when the machine is not ready, so a new
70
+ # project goes setup, init, start in one go and a ready machine is
71
+ # not made to wait on brew. A check that still fails is reported and
72
+ # init carries on: the file it writes needs none of them.
73
+ #
74
+ # @param options [Hash]
75
+ # @return [void]
76
+ def prepare(options)
77
+ return if machine.ready?
78
+
79
+ UI.line("This machine is missing something openlanes needs; running openlanes setup first.")
80
+ outcome = machine.call(dry_run: dry_run?(options))
81
+ $stderr.write(OpenLanes::Setup.render(outcome.checks))
82
+ warn("Still not ready: run each fix above, then openlanes setup.") unless outcome.ok?
83
+ end
84
+
85
+ # A named seam, so a spec can stand in for brew, gh and Redis.
86
+ #
87
+ # @return [OpenLanes::Setup::Machine]
88
+ def machine = @machine ||= OpenLanes::Setup.machine
89
+
90
+ # The five values, each from its flag, else asked on a terminal, else
66
91
  # its default. The result is parsed back as the file would be, so
67
92
  # `init` cannot write a file every other command then refuses.
68
93
  #
@@ -75,8 +100,9 @@ module OpenLanes
75
100
  lane = options[:lane] || (ask ? prompt.select("Default lane for new plans:", Plans::Spec::LANES.map(&:to_s)) : "full")
76
101
  frontend = options.key?(:frontend) ? options[:frontend] : (ask_frontend if ask)
77
102
  jobs = options[:jobs] || (prompt.ask("Plans to run at once (blank: cores - 2):") if ask)
103
+ quality = options[:quality] || (ask_quality if ask)
78
104
 
79
- draft = Config.new(project:, lane: lane.to_sym, frontend:, jobs: count(jobs))
105
+ draft = Config.new(project:, lane: lane.to_sym, frontend:, jobs: count(jobs), desired_codebase_quality: quality&.to_sym)
80
106
  Config.parse(draft.to_yaml, path: File.join(root, Config::FILE))
81
107
  end
82
108
 
@@ -86,6 +112,17 @@ module OpenLanes
86
112
  { "YES" => true, "NO" => false }[choice]
87
113
  end
88
114
 
115
+ # R4's question. Left unanswered under -y, so the first
116
+ # `review-ok-not-perfect` pull request asks it instead.
117
+ #
118
+ # @return [String]
119
+ def ask_quality
120
+ prompt.select("Please indicate the desired quality of the codebase (this affects the severity of items " \
121
+ "found during review that will require rework):",
122
+ { "High quality, but slower and more expensive: a review-ok-not-perfect PR returns to developers" => "high",
123
+ "Good enough, but faster and cheaper, think MVP: review-ok-not-perfect is accepted" => "mvp" })
124
+ end
125
+
89
126
  # @param value [String, Integer, nil]
90
127
  # @return [Integer, nil]
91
128
  def count(value)
@@ -39,7 +39,7 @@ module OpenLanes
39
39
  # @param options [Hash]
40
40
  # @return [void]
41
41
  def call(team:, **options)
42
- tree = tree_for(options)
42
+ tree = tree_for(options).everything
43
43
  require_project!(options)
44
44
  import = build(tree, team, resolve_project(team, tree, options), options)
45
45
 
@@ -16,7 +16,7 @@ module OpenLanes
16
16
  # @param options [Hash]
17
17
  # @return [void]
18
18
  def call(team:, **options)
19
- tree = tree_for(options)
19
+ tree = tree_for(options).everything
20
20
  _api, survey = survey_for(team, tree)
21
21
 
22
22
  survey.projects.each { |project| puts row(project) }
@@ -6,12 +6,18 @@ module OpenLanes
6
6
  class ListPlans < Base
7
7
  desc "Print every plan, its state, and its pull requests"
8
8
 
9
- example ["", "-D .plans", "| less -R"]
9
+ option :all,
10
+ type: :boolean,
11
+ default: false,
12
+ desc: "Also list the plans in archive/, deferred/ and abandoned/"
13
+
14
+ example ["", "--all", "-D .plans", "| less -R"]
10
15
 
11
16
  # @param options [Hash]
12
17
  # @return [void]
13
18
  def call(**options)
14
- reporter = Presentation.reporter(tree: tree_for(options))
19
+ tree = tree_for(options)
20
+ reporter = Presentation.reporter(tree: options[:all] ? tree.everything : tree)
15
21
  $stdout.write(reporter.render)
16
22
 
17
23
  exit 1 unless reporter.inconsistent.empty?
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module CLI
5
+ module Mail
6
+ # `openlanes mail ack` — say a message was read.
7
+ #
8
+ # Delivery cannot say it: a message that reached `state.json` may sit
9
+ # there for a whole round while its reader never polls. Only the reader
10
+ # knows, so only the reader writes `read: "yes"`.
11
+ class Ack < Base
12
+ include Locating
13
+
14
+ desc "Mark a message in a plan's mailbox as read"
15
+
16
+ argument :number, type: :string, required: true, desc: "The message's number, as `mail read` prints it"
17
+
18
+ option :plan, required: true, desc: "Which plan: NNN or NNN.MM"
19
+ option :for, required: true, desc: "The agent that read it, who must be its recipient"
20
+
21
+ example [
22
+ "--plan 003 --for rey-frontend 4 # rey read #4"
23
+ ]
24
+
25
+ # @param number [String]
26
+ # @param options [Hash]
27
+ # @return [void]
28
+ def call(number:, **options)
29
+ quiet?(options)
30
+ mailbox = mailbox_for(options)
31
+ message = begin
32
+ mailbox.ack(number, by: options[:for])
33
+ rescue OpenLanes::Error => e
34
+ refuse(e.message, 65)
35
+ end
36
+
37
+ return if quiet?(options)
38
+
39
+ # A mailbox.md message is history and already counts as read, so
40
+ # nothing was written; saying "read by" would claim a record that
41
+ # does not exist.
42
+ if mailbox.legacy?(message.number)
43
+ return UI.line("##{message.number} is a legacy message in #{File.basename(mailbox.dir)}/#{Plans::Mailbox::FILENAME}, " \
44
+ "already counted as read; nothing was written")
45
+ end
46
+
47
+ UI.line("##{message.number} read by #{message.to}, in #{File.basename(mailbox.dir)}/#{Plans::PlanState::FILENAME}")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module CLI
5
+ module Mail
6
+ # `openlanes mail render` — the whole exchange, as Markdown, on STDOUT.
7
+ #
8
+ # The JSON is the record. This prints a view of it and writes no file,
9
+ # so there is never a second copy in the plan folder to disagree with
10
+ # the first.
11
+ class Render < Base
12
+ include Locating
13
+
14
+ desc "Print a plan's whole mailbox as Markdown"
15
+
16
+ option :plan, required: true, desc: "Which plan: NNN or NNN.MM"
17
+
18
+ example [
19
+ "--plan 003 # every message, with read or unread",
20
+ "--plan 003 > mail.md # keep a copy wherever you like"
21
+ ]
22
+
23
+ # @param options [Hash]
24
+ # @return [void]
25
+ def call(**options)
26
+ puts mailbox_for(options).render
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -30,6 +30,7 @@ module OpenLanes
30
30
  rescue OpenLanes::Error => e
31
31
  refuse(e.message, 64)
32
32
  end
33
+ publish(mailbox, message)
33
34
 
34
35
  return if quiet?(options)
35
36
 
@@ -37,6 +38,31 @@ module OpenLanes
37
38
  # four-line frame around "#7 → rey-frontend" is noise it reads.
38
39
  UI.line("##{message.number} → #{message.to}, in #{File.basename(mailbox.dir)}/#{Plans::PlanState::FILENAME}")
39
40
  end
41
+
42
+ private
43
+
44
+ # Hand the message to the harness over Redis, when there is one.
45
+ # `state.json` already has it, so no Redis is not a failure and says
46
+ # nothing: an agent that sees a warning on every send learns to
47
+ # ignore warnings. The keys are the plans directory's checkout, not
48
+ # this process's, because an agent in a worktree sends to the run in
49
+ # the main checkout.
50
+ #
51
+ # @param mailbox [OpenLanes::Plans::Mailbox]
52
+ # @param message [OpenLanes::Plans::Mailbox::Message]
53
+ # @return [void]
54
+ def publish(mailbox, message)
55
+ feature = Plans::Feature.parse(mailbox.dir) or return
56
+ root = File.dirname(mailbox.dir, 2)
57
+ store = Live.connect(Live.redis_url(config: Config.current))
58
+ begin
59
+ Live::Mail.new(store:, keys: Live.keys(root:)).publish(plan: feature.ordinal, message:)
60
+ ensure
61
+ store.close
62
+ end
63
+ rescue Live::Unavailable
64
+ nil
65
+ end
40
66
  end
41
67
  end
42
68
  end
@@ -4,42 +4,83 @@ module OpenLanes
4
4
  module CLI
5
5
  # `openlanes resync …`
6
6
  module Resync
7
- # `resync dirs` — reconcile folder names with folder contents.
7
+ # `resync dirs` — reconcile folder names and places with folder contents.
8
8
  class Dirs < Base
9
- desc "Rename plan folders so the name matches the contents and the NNN.MM form"
9
+ desc "Rename and move plan folders so the name, the place and the contents agree"
10
10
 
11
- dry_run_option "Rename nothing; print what would be renamed"
11
+ dry_run_option "Rename nothing; print what would be renamed or moved"
12
12
 
13
13
  example [
14
- "-n # show what would be renamed",
14
+ "-n # show what would be renamed or moved",
15
15
  " # do it"
16
16
  ]
17
17
 
18
18
  # @param options [Hash]
19
19
  # @return [void]
20
20
  def call(**options)
21
- changes = OpenLanes::Lifecycle.resync_dirs(tree: tree_for(options))
22
- .call(commit: !dry_run?(options))
21
+ resync = OpenLanes::Lifecycle.resync_dirs(tree: tree_for(options))
22
+ locks = resync.locks
23
+ rewrites = resync.rewrites
24
+ changes = resync.call(commit: !dry_run?(options))
23
25
 
24
- if changes.empty?
25
- success("Every folder is already named NNN.MM-<emoji> → <slug> and the emoji matches.") unless quiet?(options)
26
+ report_locks(locks, options)
27
+ report_rewrites(rewrites, options)
28
+
29
+ if changes.empty? && rewrites.empty?
30
+ unless quiet?(options)
31
+ success("Every folder is already named NNN.MM━━<emoji>━━❯<slug>, in its place, and the emoji matches.")
32
+ end
26
33
  return
27
34
  end
28
35
 
29
36
  changes.each do |change|
30
- puts "#{change.dirname}\t#{File.basename(change.target)}\t#{change.reason}"
37
+ puts "#{change.dirname}\t#{change.target_name}\t#{change.reason}"
31
38
  next if quiet?(options)
32
39
 
33
- say("#{paint(change.dirname, :bright_black)} → #{File.basename(change.target)}")
40
+ say("#{paint(change.dirname, :bright_black)} → #{change.target_name}")
34
41
  say(" #{paint(change.reason, :yellow)}", bullet: " ")
35
42
  end
36
43
 
37
44
  return if quiet?(options)
38
45
 
46
+ total = changes.size + rewrites.size
39
47
  if dry_run?(options)
40
- dry_run_footer(changes.size, "rename#{"s" unless changes.size == 1}")
48
+ dry_run_footer(total, "change#{"s" unless total == 1}")
41
49
  else
42
- success("Renamed #{changes.size} folder#{"s" unless changes.size == 1}.")
50
+ success("Renamed #{changes.size} folder#{"s" unless changes.size == 1}, " \
51
+ "rewrote #{rewrites.size} spec#{"s" unless rewrites.size == 1}.")
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # @param rewrites [Array<OpenLanes::Lifecycle::Resync::Dirs::Rewrite>]
58
+ # @param options [Hash]
59
+ # @return [void]
60
+ def report_rewrites(rewrites, options)
61
+ rewrites.each do |rewrite|
62
+ puts "#{rewrite.dirname}/spec.md\t#{rewrite.dirname}/spec.md\t#{rewrite.reason}"
63
+ say("#{rewrite.dirname}/spec.md: #{paint(rewrite.reason, :yellow)}") unless quiet?(options)
64
+ end
65
+ end
66
+
67
+ # A lock keeps its folder's name, so a folder that did not move needs
68
+ # a word about why. A dead local lock is cleared by a real run; one
69
+ # from another machine is only ever reported.
70
+ #
71
+ # @param locks [Array<OpenLanes::Lifecycle::Resync::Dirs::HeldLock>]
72
+ # @param options [Hash]
73
+ # @return [void]
74
+ def report_locks(locks, options)
75
+ return if quiet?(options)
76
+
77
+ locks.each do |held|
78
+ verdict = if held.foreign? then "another machine's lock; left alone"
79
+ elsif held.stale? then dry_run?(options) ? "its process is gone; would clear it" : "its process is gone; cleared"
80
+ else "an agent is working in it; not renamed"
81
+ end
82
+ say("🔑 #{held}")
83
+ say(" #{paint(verdict, :yellow)}", bullet: " ")
43
84
  end
44
85
  end
45
86
  end