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.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +7 -0
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +84 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1175 -117
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +6 -6
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +12 -120
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +223 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +17 -13
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +60 -11
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +64 -5
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +39 -24
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +226 -8
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +167 -6
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +29 -10
- data/lib/openlanes/web.rb +6 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -83,15 +83,34 @@ module OpenLanes
|
|
|
83
83
|
#
|
|
84
84
|
# @param pulls [Array<Hash>] the orphans, from {Resync::Prs}
|
|
85
85
|
# @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>] in pull request order
|
|
86
|
+
# @raise [Allocator::Refused] in a linked worktree, where no plan is
|
|
87
|
+
# minted, so a preview there says so rather than promising folders
|
|
86
88
|
def plan(pulls)
|
|
89
|
+
allocator.refuse!
|
|
90
|
+
@taken = nil
|
|
87
91
|
allocate(gather(pulls))
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
# Adopt every orphan: create its folder and record its pull request.
|
|
91
95
|
#
|
|
96
|
+
# The slots are read and the folders made under the allocation lock, or
|
|
97
|
+
# a `create` running beside this one can take a slot this just handed out.
|
|
98
|
+
#
|
|
92
99
|
# @param pulls [Array<Hash>]
|
|
93
100
|
# @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>] with `path` filled in
|
|
101
|
+
# @raise [Allocator::Refused] in a linked worktree
|
|
94
102
|
def call(pulls)
|
|
103
|
+
allocator.hold { adopt_all(pulls) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [OpenLanes::Lifecycle::Allocator]
|
|
107
|
+
def allocator = @allocator ||= Allocator.new(dir: tree.dir)
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# @param pulls [Array<Hash>]
|
|
112
|
+
# @return [Array<OpenLanes::Lifecycle::Adoption::Adoptee>]
|
|
113
|
+
def adopt_all(pulls)
|
|
95
114
|
adoptees = plan(pulls)
|
|
96
115
|
return adoptees if adoptees.empty?
|
|
97
116
|
|
|
@@ -100,8 +119,6 @@ module OpenLanes
|
|
|
100
119
|
created
|
|
101
120
|
end
|
|
102
121
|
|
|
103
|
-
private
|
|
104
|
-
|
|
105
122
|
# Phase one, in parallel: ask each pull request's branch how far the
|
|
106
123
|
# sequence had got when the work started. Branches are not all rebased
|
|
107
124
|
# onto the same main, so this is per-branch rather than tree-wide, and it
|
|
@@ -124,7 +141,7 @@ module OpenLanes
|
|
|
124
141
|
def major_for(pull)
|
|
125
142
|
candidates = OpenLanes.plans_on_ref(root, pull[:branch].to_s)
|
|
126
143
|
candidates = touched(pull) if candidates.empty?
|
|
127
|
-
candidates = tree.
|
|
144
|
+
candidates = tree.taken if candidates.empty?
|
|
128
145
|
|
|
129
146
|
candidates.map(&:major).max.to_i
|
|
130
147
|
end
|
|
@@ -133,9 +150,8 @@ module OpenLanes
|
|
|
133
150
|
# @return [Array<OpenLanes::Plans::Ordinal>]
|
|
134
151
|
def touched(pull)
|
|
135
152
|
Array(pull[:files]).filter_map { |path|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
index && parts[index + 1] && Plans::Ordinal.from_dirname(parts[index + 1])
|
|
153
|
+
folder = Plans::Tree.plan_folder(path)
|
|
154
|
+
folder && Plans::Ordinal.from_dirname(folder)
|
|
139
155
|
}.uniq
|
|
140
156
|
end
|
|
141
157
|
|
|
@@ -159,9 +175,10 @@ module OpenLanes
|
|
|
159
175
|
end
|
|
160
176
|
|
|
161
177
|
# @param major [Integer]
|
|
162
|
-
# @return [Array<Integer>] minors already used under this major
|
|
178
|
+
# @return [Array<Integer>] minors already used under this major, on
|
|
179
|
+
# this tree or on any branch: a slot taken anywhere is taken
|
|
163
180
|
def minors_taken(major)
|
|
164
|
-
|
|
181
|
+
(@taken ||= allocator.taken).select { |o| o.major == major }.map(&:minor)
|
|
165
182
|
end
|
|
166
183
|
|
|
167
184
|
# Phase three, in parallel: each worker owns a number nobody else can
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Lifecycle
|
|
8
|
+
# Blocked plans age (N8). A ⭕ or 🛑 that other plans are waiting for
|
|
9
|
+
# becomes 💣 after {BOMB_AFTER}, and the run halts on it; one nobody waits
|
|
10
|
+
# for is deferred after {DEFER_AFTER}, with a `delayed.md` naming what
|
|
11
|
+
# brings it back.
|
|
12
|
+
#
|
|
13
|
+
# This is a sweep that fires events, not an invariant that reads the
|
|
14
|
+
# clock. A state whose check looked at the time would rename folders as
|
|
15
|
+
# days passed with nobody firing anything, and asking a question about a
|
|
16
|
+
# folder must never fire one. So 💣 holds by its contents alone, and only
|
|
17
|
+
# this sweep, run by the engine before it assigns work, moves a plan in.
|
|
18
|
+
class Aging
|
|
19
|
+
# How long a plan others depend on may sit blocked before it halts the
|
|
20
|
+
# run. 24 hour multiples, not calendar days: the limit is the same length
|
|
21
|
+
# across a daylight saving change.
|
|
22
|
+
BOMB_AFTER = 7 * 86_400
|
|
23
|
+
|
|
24
|
+
# How long a plan nobody depends on may sit blocked before it is set
|
|
25
|
+
# aside.
|
|
26
|
+
DEFER_AFTER = 14 * 86_400
|
|
27
|
+
|
|
28
|
+
# The states that age. 💣 is not among them: it is already the loudest
|
|
29
|
+
# thing a block can become, and deferring it would quietly drop a plan
|
|
30
|
+
# others are waiting for.
|
|
31
|
+
AGES = %i[blocked product_blocked].freeze
|
|
32
|
+
|
|
33
|
+
# Dependents in these states wait for nothing any more: they shipped
|
|
34
|
+
# (008's {Plans::Dependencies::DONE}) or were dropped.
|
|
35
|
+
CLOSED = (Plans::Dependencies::DONE + %i[discarded]).freeze
|
|
36
|
+
|
|
37
|
+
# What one sweep did, or on a dry run would have done.
|
|
38
|
+
#
|
|
39
|
+
# @!attribute [r] bombed
|
|
40
|
+
# @return [Array<String>] ordinals now 💣
|
|
41
|
+
# @!attribute [r] deferred
|
|
42
|
+
# @return [Array<String>] ordinals now 💤
|
|
43
|
+
Result = Data.define(:bombed, :deferred) do
|
|
44
|
+
def initialize(bombed: [], deferred: []) = super
|
|
45
|
+
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def empty? = bombed.empty? && deferred.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
51
|
+
# @param since [Date, nil] `plans.aging_since`: no plan ages from
|
|
52
|
+
# before local midnight on this day
|
|
53
|
+
# @param now [Time]
|
|
54
|
+
def initialize(tree:, since: nil, now: Time.now)
|
|
55
|
+
@tree = tree
|
|
56
|
+
@since = since
|
|
57
|
+
@now = now
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Bomb or defer every blocked plan among +subjects+ past its limit.
|
|
61
|
+
#
|
|
62
|
+
# @param subjects [Array<OpenLanes::Plans::Subject>, nil] the plans to
|
|
63
|
+
# age; every plan in the tree when nil
|
|
64
|
+
# @param commit [Boolean] false reports what would happen and changes
|
|
65
|
+
# nothing
|
|
66
|
+
# @return [Result]
|
|
67
|
+
def call(subjects: nil, commit: true)
|
|
68
|
+
bombed = []
|
|
69
|
+
deferred = []
|
|
70
|
+
(subjects || @tree.subjects).each do |subject|
|
|
71
|
+
next unless AGES.include?(subject.status.key)
|
|
72
|
+
|
|
73
|
+
age = age_of(subject)
|
|
74
|
+
if waited_for?(subject)
|
|
75
|
+
next if age < BOMB_AFTER
|
|
76
|
+
|
|
77
|
+
bombed << subject.feature.ordinal.to_s if !commit || bomb!(subject, age)
|
|
78
|
+
elsif age >= DEFER_AFTER
|
|
79
|
+
deferred << subject.feature.ordinal.to_s if !commit || defer!(subject, age)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
@tree.reload if commit && !(bombed.empty? && deferred.empty?)
|
|
83
|
+
Result.new(bombed:, deferred:)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
87
|
+
# @return [Float] seconds the plan has been blocked, as far as aging counts
|
|
88
|
+
def age_of(subject) = @now - started_at(subject)
|
|
89
|
+
|
|
90
|
+
# When the clock started: the latest `Blocked` signature, so a plan
|
|
91
|
+
# blocked again after an answer starts over, or `blocked.md`'s mtime
|
|
92
|
+
# when nobody signed, as the control room already reads it. Never
|
|
93
|
+
# before `aging_since`.
|
|
94
|
+
#
|
|
95
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
96
|
+
# @return [Time]
|
|
97
|
+
def started_at(subject)
|
|
98
|
+
signed = Plans::PlanState.for(subject.feature.path).signatures.rfind { it["status"] == "Blocked" }
|
|
99
|
+
start = signed ? parse(signed["at"]) : nil
|
|
100
|
+
start ||= File.mtime(File.join(subject.feature.path, "blocked.md"))
|
|
101
|
+
floor = @since && Time.local(@since.year, @since.month, @since.day)
|
|
102
|
+
floor && floor > start ? floor : start
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
108
|
+
# @return [Boolean] whether a plan still open names this one in `depends_on:`
|
|
109
|
+
def waited_for?(subject)
|
|
110
|
+
@tree.blocks_of(subject.feature.ordinal).any? do |ordinal|
|
|
111
|
+
dependent = @tree.find(ordinal)
|
|
112
|
+
dependent && !CLOSED.include?(dependent.status.key)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Boolean] whether the folder moved
|
|
117
|
+
def bomb!(subject, age)
|
|
118
|
+
subject.machine.promote!(:bomb)
|
|
119
|
+
UI.log("aging: 💣 after #{days(age)} blocked, with plans waiting on it", plan: subject.feature.ordinal.to_s)
|
|
120
|
+
true
|
|
121
|
+
rescue Plans::StateMachine::Refused => e
|
|
122
|
+
UI.log("aging: could not bomb: #{e.message}", level: :warn, plan: subject.feature.ordinal.to_s)
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `delayed.md` goes in first: 💤 refuses a folder without a trigger.
|
|
127
|
+
#
|
|
128
|
+
# @return [Boolean] whether the folder moved
|
|
129
|
+
def defer!(subject, age)
|
|
130
|
+
path = File.join(subject.feature.path, "delayed.md")
|
|
131
|
+
wrote = !File.exist?(path)
|
|
132
|
+
File.open(path, "a") { it.write(delayed(subject, age, fresh: wrote)) }
|
|
133
|
+
subject.machine.promote!(:deferred)
|
|
134
|
+
UI.log("aging: 💤 after #{days(age)} blocked, with no plan waiting on it", plan: subject.feature.ordinal.to_s)
|
|
135
|
+
true
|
|
136
|
+
rescue Plans::StateMachine::Refused => e
|
|
137
|
+
File.delete(path) if wrote && File.exist?(path)
|
|
138
|
+
UI.log("aging: could not defer: #{e.message}", level: :warn, plan: subject.feature.ordinal.to_s)
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [String]
|
|
143
|
+
def delayed(subject, age, fresh:)
|
|
144
|
+
heading = fresh ? "# Deferred: #{subject.feature.ordinal} #{subject.feature.slug}\n\n" : "\n"
|
|
145
|
+
"#{heading}Deferred by aging on #{@now.strftime("%Y-%m-%d")}: blocked #{days(age)} with no answer, " \
|
|
146
|
+
"and no open plan depends on it.\n\n" \
|
|
147
|
+
"Trigger: revisit after the block is answered. The questions are still in `blocked.md`.\n"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param seconds [Numeric]
|
|
151
|
+
# @return [String] "8 days"
|
|
152
|
+
def days(seconds)
|
|
153
|
+
count = (seconds / 86_400).floor
|
|
154
|
+
"#{count} day#{"s" unless count == 1}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param text [String, nil]
|
|
158
|
+
# @return [Time, nil]
|
|
159
|
+
def parse(text)
|
|
160
|
+
Time.iso8601(text.to_s)
|
|
161
|
+
rescue ArgumentError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Lifecycle
|
|
7
|
+
# Who may hand out a plan number, and which numbers are already gone.
|
|
8
|
+
#
|
|
9
|
+
# A number used to be the highest one in the `.plans` of whichever
|
|
10
|
+
# checkout ran `create`. Two checkouts on two branches each saw only
|
|
11
|
+
# their own folders and minted the same number: `main` stopped at 023
|
|
12
|
+
# while 024 to 035 lived on an unmerged branch, so `create` on `main`
|
|
13
|
+
# handed out 024 a second time, and the two plans collided the moment
|
|
14
|
+
# both branches reached `main`. So, decided 2026-10-17:
|
|
15
|
+
#
|
|
16
|
+
# - plans are minted only in the primary checkout, never in a linked
|
|
17
|
+
# worktree, so there is one place numbers come from;
|
|
18
|
+
# - a number counts as taken when it is in that checkout's `.plans`
|
|
19
|
+
# (archive and friends included, N3) or in `.plans` on any local
|
|
20
|
+
# branch or `origin/*` remote-tracking branch, read with `ls-tree`
|
|
21
|
+
# and no fetch;
|
|
22
|
+
# - the numbers are read and the folder made under an exclusive lock in
|
|
23
|
+
# the shared git directory, so two `create`s at once cannot both take
|
|
24
|
+
# the number they both just saw was free.
|
|
25
|
+
#
|
|
26
|
+
# Decided 2026-10-20, on top of that: the primary checkout must also be on
|
|
27
|
+
# the default branch (origin's HEAD, else `main`). A plan minted on a
|
|
28
|
+
# feature branch there is a plan `main` does not know about until that
|
|
29
|
+
# branch merges, which is the same split by another road. The rule covers
|
|
30
|
+
# new folders, their opening `spec.md` and `depends_on:`; every other file
|
|
31
|
+
# in a plan folder is still written from worktrees, as agents do.
|
|
32
|
+
#
|
|
33
|
+
# Outside a git repository there are no branches and no worktrees, and
|
|
34
|
+
# the checkout's own `.plans` is the whole truth, as before.
|
|
35
|
+
#
|
|
36
|
+
# {Plans::Ordinal} stays arithmetic over the list this returns; the git
|
|
37
|
+
# lives here and nowhere else.
|
|
38
|
+
class Allocator
|
|
39
|
+
# The lock file, in `git rev-parse --git-common-dir`: the one directory
|
|
40
|
+
# every worktree of a repository shares.
|
|
41
|
+
LOCK_FILE = "openlanes-ordinal.lock"
|
|
42
|
+
|
|
43
|
+
# Where branches that may carry plans live. Other remotes are someone
|
|
44
|
+
# else's forks; their numbers were never handed out here.
|
|
45
|
+
REFS = %w[refs/heads refs/remotes/origin].freeze
|
|
46
|
+
|
|
47
|
+
# The branch plans are minted on when origin names no default.
|
|
48
|
+
DEFAULT_BRANCH = "main"
|
|
49
|
+
|
|
50
|
+
# Raised when a plan would be minted in a linked worktree, or in the
|
|
51
|
+
# primary checkout off its default branch.
|
|
52
|
+
class Refused < OpenLanes::Error; end
|
|
53
|
+
|
|
54
|
+
# The repository around a `.plans` directory, as git reports it.
|
|
55
|
+
#
|
|
56
|
+
# @!attribute [r] git_dir
|
|
57
|
+
# @return [String] this checkout's git directory, absolute
|
|
58
|
+
# @!attribute [r] common_dir
|
|
59
|
+
# @return [String] the directory every worktree shares, absolute
|
|
60
|
+
# @!attribute [r] toplevel
|
|
61
|
+
# @return [String] this checkout's root
|
|
62
|
+
# @!attribute [r] plans
|
|
63
|
+
# @return [String] the `.plans` directory, relative to +toplevel+
|
|
64
|
+
# @!attribute [r] branch
|
|
65
|
+
# @return [String, nil] the checked-out branch; nil on a detached HEAD
|
|
66
|
+
# @!attribute [r] default_branch
|
|
67
|
+
# @return [String] origin's HEAD, else {DEFAULT_BRANCH}
|
|
68
|
+
Repository = Data.define(:git_dir, :common_dir, :toplevel, :plans, :branch, :default_branch) do
|
|
69
|
+
# A linked worktree has a git directory of its own under the common
|
|
70
|
+
# one; the primary checkout's git directory is the common one.
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def worktree? = git_dir != common_dir
|
|
74
|
+
|
|
75
|
+
# @return [String] the primary checkout, which owns the common directory
|
|
76
|
+
def primary = File.basename(common_dir) == ".git" ? File.dirname(common_dir) : common_dir
|
|
77
|
+
|
|
78
|
+
# @return [String]
|
|
79
|
+
def lock_path = File.join(common_dir, LOCK_FILE)
|
|
80
|
+
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def on_default_branch? = branch == default_branch
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param dir [String] the `.plans` directory; need not exist yet
|
|
86
|
+
def initialize(dir:)
|
|
87
|
+
@dir = File.expand_path(dir)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [String]
|
|
91
|
+
attr_reader :dir
|
|
92
|
+
|
|
93
|
+
# @return [Repository, nil] nil outside a git repository
|
|
94
|
+
def repository = defined?(@repository) ? @repository : (@repository = discover)
|
|
95
|
+
|
|
96
|
+
# @return [String, nil] why a plan may not be minted here, or nil when it may
|
|
97
|
+
def refusal
|
|
98
|
+
repo = repository or return
|
|
99
|
+
if repo.worktree?
|
|
100
|
+
return "plans are created only in the primary checkout, #{repo.primary}; this is a worktree of it"
|
|
101
|
+
end
|
|
102
|
+
return if repo.on_default_branch?
|
|
103
|
+
|
|
104
|
+
"plans are created only on #{repo.default_branch} in the primary checkout; " \
|
|
105
|
+
"#{repo.primary} is on #{repo.branch || "a detached HEAD"}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [void]
|
|
109
|
+
# @raise [Refused] in a linked worktree, or off the default branch
|
|
110
|
+
def refuse!
|
|
111
|
+
message = refusal
|
|
112
|
+
raise Refused, message if message
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Run the block holding the allocation lock: read {#taken} and create
|
|
116
|
+
# the folder inside it, or another `create` can read the same numbers
|
|
117
|
+
# in between.
|
|
118
|
+
#
|
|
119
|
+
# The lock is `flock`, so it dies with the process: a `create` killed
|
|
120
|
+
# mid-way leaves nothing for anyone to break.
|
|
121
|
+
#
|
|
122
|
+
# @yieldreturn [Object]
|
|
123
|
+
# @return [Object] the block's value
|
|
124
|
+
# @raise [Refused] in a linked worktree, or off the default branch
|
|
125
|
+
def hold
|
|
126
|
+
refuse!
|
|
127
|
+
repo = repository or return yield
|
|
128
|
+
|
|
129
|
+
File.open(repo.lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
|
|
130
|
+
lock.flock(File::LOCK_EX)
|
|
131
|
+
yield
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Every number a new plan must stay clear of.
|
|
136
|
+
#
|
|
137
|
+
# @return [Array<OpenLanes::Plans::Ordinal>] unique, unordered
|
|
138
|
+
def taken
|
|
139
|
+
local = Plans::Tree.new(dir:).taken
|
|
140
|
+
repo = repository or return local
|
|
141
|
+
|
|
142
|
+
(local + branches(repo).flat_map { OpenLanes.plans_at(repo.toplevel, it, plans: repo.plans) }).uniq
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Branches that point at one commit hold one set of plans, so each
|
|
148
|
+
# commit is read once: a branch and its `origin/` twin cost one `ls-tree`.
|
|
149
|
+
#
|
|
150
|
+
# @param repo [Repository]
|
|
151
|
+
# @return [Array<String>] commit names
|
|
152
|
+
def branches(repo)
|
|
153
|
+
out = git(repo.toplevel, "for-each-ref", "--format=%(objectname) %(refname)", *REFS) or return []
|
|
154
|
+
|
|
155
|
+
out.lines.filter_map { |line|
|
|
156
|
+
sha, ref = line.split
|
|
157
|
+
sha unless ref.to_s.end_with?("/HEAD")
|
|
158
|
+
}.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @return [Repository, nil]
|
|
162
|
+
def discover
|
|
163
|
+
base = dir
|
|
164
|
+
base = File.dirname(base) until File.directory?(base)
|
|
165
|
+
out = git(base,
|
|
166
|
+
"rev-parse",
|
|
167
|
+
"--path-format=absolute",
|
|
168
|
+
"--git-dir",
|
|
169
|
+
"--git-common-dir",
|
|
170
|
+
"--show-toplevel") or return nil
|
|
171
|
+
git_dir, common_dir, toplevel = out.lines.map(&:chomp)
|
|
172
|
+
return nil if toplevel.to_s.empty?
|
|
173
|
+
|
|
174
|
+
Repository.new(git_dir:,
|
|
175
|
+
common_dir:,
|
|
176
|
+
toplevel:,
|
|
177
|
+
plans: relative_plans(base, toplevel),
|
|
178
|
+
branch: git(toplevel, "symbolic-ref", "--short", "-q", "HEAD")&.chomp,
|
|
179
|
+
default_branch: default_branch(toplevel))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# origin's HEAD names the default branch where a clone recorded it; a
|
|
183
|
+
# repository with no origin, or one never told, falls back to `main`.
|
|
184
|
+
#
|
|
185
|
+
# @param toplevel [String]
|
|
186
|
+
# @return [String]
|
|
187
|
+
def default_branch(toplevel)
|
|
188
|
+
head = git(toplevel, "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD")&.chomp
|
|
189
|
+
head.to_s.empty? ? DEFAULT_BRANCH : head.delete_prefix("origin/")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# git answers with resolved paths (`/private/var` on macOS) while +dir+
|
|
193
|
+
# may name the same place through a symlink (`/var`), so both sides are
|
|
194
|
+
# resolved before one is taken from the other.
|
|
195
|
+
#
|
|
196
|
+
# @param base [String] the nearest existing ancestor of +dir+, or +dir+
|
|
197
|
+
# @param toplevel [String]
|
|
198
|
+
# @return [String]
|
|
199
|
+
def relative_plans(base, toplevel)
|
|
200
|
+
tail = dir.delete_prefix(base).delete_prefix("/")
|
|
201
|
+
inside = File.realpath(base).delete_prefix(File.realpath(toplevel)).delete_prefix("/")
|
|
202
|
+
File.join(*[inside, tail].reject(&:empty?)).then { it.empty? ? "." : it }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @param dir [String]
|
|
206
|
+
# @param args [Array<String>]
|
|
207
|
+
# @return [String, nil] stdout, or nil when git failed or is not here
|
|
208
|
+
def git(dir, *)
|
|
209
|
+
out, status = Open3.capture2("git", "-C", dir, *, err: File::NULL)
|
|
210
|
+
status.success? ? out : nil
|
|
211
|
+
rescue SystemCallError
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
3
4
|
require "tty/command"
|
|
5
|
+
require "yaml"
|
|
4
6
|
|
|
5
7
|
module OpenLanes
|
|
6
8
|
module Lifecycle
|
|
@@ -109,6 +111,7 @@ module OpenLanes
|
|
|
109
111
|
# @param dry_run [Boolean] plan the invocation, do not run it
|
|
110
112
|
# @param seed [String, nil] what the author already wrote about the
|
|
111
113
|
# feature (`create --from`); opens spec.md and outranks the repo survey
|
|
114
|
+
# @param today [Date] what `created:` says
|
|
112
115
|
def initialize(
|
|
113
116
|
path:,
|
|
114
117
|
title:,
|
|
@@ -116,7 +119,8 @@ module OpenLanes
|
|
|
116
119
|
command: TTY::Command.new(printer: :null),
|
|
117
120
|
timeout: TIMEOUT,
|
|
118
121
|
dry_run: false,
|
|
119
|
-
seed: nil
|
|
122
|
+
seed: nil,
|
|
123
|
+
today: Date.today
|
|
120
124
|
)
|
|
121
125
|
@path = path
|
|
122
126
|
@title = title
|
|
@@ -125,6 +129,7 @@ module OpenLanes
|
|
|
125
129
|
@timeout = timeout
|
|
126
130
|
@dry_run = dry_run
|
|
127
131
|
@seed = seed.to_s.strip.then { |s| s.empty? ? nil : s }
|
|
132
|
+
@today = today
|
|
128
133
|
end
|
|
129
134
|
|
|
130
135
|
# @return [String]
|
|
@@ -146,9 +151,34 @@ module OpenLanes
|
|
|
146
151
|
"# #{title}\n\n#{opening}" + HEADINGS.map { |h| "## #{h}\n\n" }.join("\n")
|
|
147
152
|
end
|
|
148
153
|
|
|
154
|
+
# The scaffold under a frontmatter that says when the plan was created
|
|
155
|
+
# (decision N3): the folder name stops carrying any date once the plan
|
|
156
|
+
# is open, and a plan's age is otherwise only in git.
|
|
157
|
+
#
|
|
158
|
+
# Only where a plan may be minted ({Allocator#refuse!}): an opening
|
|
159
|
+
# spec.md written in a worktree or on a feature branch is a plan `main`
|
|
160
|
+
# has not heard of.
|
|
161
|
+
#
|
|
149
162
|
# @return [String] where it was written
|
|
163
|
+
# @raise [Allocator::Refused] in a linked worktree, or off the default branch
|
|
150
164
|
def write_scaffold!
|
|
165
|
+
Allocator.new(dir: File.dirname(path)).refuse!
|
|
151
166
|
File.write(spec_path, scaffold)
|
|
167
|
+
stamp!
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Merge settings into spec.md's frontmatter, keeping every key already
|
|
171
|
+
# there, and `created:` whether or not a draft rewrote the file without
|
|
172
|
+
# it. Run after the draft for that reason.
|
|
173
|
+
#
|
|
174
|
+
# @param settings [Hash{String => Object}] e.g. `lane` and `depth`
|
|
175
|
+
# @return [String] the spec's path
|
|
176
|
+
def stamp!(settings = {})
|
|
177
|
+
return spec_path unless File.file?(spec_path)
|
|
178
|
+
|
|
179
|
+
meta, body = Frontmatter.split(File.read(spec_path, encoding: "UTF-8"))
|
|
180
|
+
meta = { "created" => @today }.merge(meta).merge(settings)
|
|
181
|
+
File.write(spec_path, "#{YAML.dump(meta)}---\n\n#{body.sub(/\A\n+/, "")}")
|
|
152
182
|
spec_path
|
|
153
183
|
end
|
|
154
184
|
|
|
@@ -182,7 +212,7 @@ module OpenLanes
|
|
|
182
212
|
["claude",
|
|
183
213
|
"-p", prompt, "--add-dir", root,
|
|
184
214
|
"--model", BRIEF_MODEL,
|
|
185
|
-
*Adapters
|
|
215
|
+
*Adapters.lean_flags("claude"),
|
|
186
216
|
"--allowedTools", ALLOWED_TOOLS.join(","),
|
|
187
217
|
"--disallowedTools", DENIED_TOOLS.join(",")]
|
|
188
218
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module OpenLanes
|
|
4
4
|
module Lifecycle
|
|
5
|
-
# Copies the spec's `task-completed-when` and
|
|
5
|
+
# Copies the spec's `task-completed-when` and how to test it into
|
|
6
6
|
# `plan.md`, so the builders and the reviewer read them in the document
|
|
7
7
|
# they work from rather than only in the frontmatter of another one.
|
|
8
8
|
#
|
|
@@ -12,7 +12,12 @@ module OpenLanes
|
|
|
12
12
|
# agent signs, and a section the agent wrote is never touched.
|
|
13
13
|
module Completion
|
|
14
14
|
COMPLETED_WHEN_HEADING = "## Task completed when"
|
|
15
|
-
|
|
15
|
+
HOW_TO_TEST = "## How to test"
|
|
16
|
+
|
|
17
|
+
# The heading {HOW_TO_TEST} replaced. A plan.md written before the
|
|
18
|
+
# rename still carries it, and without recognising it the harness would
|
|
19
|
+
# append a second copy of the same steps under the new name.
|
|
20
|
+
FORMER_HOW_TO_TEST = "## How to verify"
|
|
16
21
|
|
|
17
22
|
module_function
|
|
18
23
|
|
|
@@ -25,7 +30,7 @@ module OpenLanes
|
|
|
25
30
|
def markdown(spec, present: "")
|
|
26
31
|
[
|
|
27
32
|
section(COMPLETED_WHEN_HEADING, spec.completed_when, present),
|
|
28
|
-
section(
|
|
33
|
+
section(HOW_TO_TEST, spec.how_to_test, present, also: FORMER_HOW_TO_TEST)
|
|
29
34
|
].compact.join("\n")
|
|
30
35
|
end
|
|
31
36
|
|
|
@@ -46,11 +51,12 @@ module OpenLanes
|
|
|
46
51
|
true
|
|
47
52
|
end
|
|
48
53
|
|
|
54
|
+
# @param also [String, nil] an older heading that counts as present
|
|
49
55
|
# @return [String, nil]
|
|
50
|
-
def section(heading, items, present)
|
|
51
|
-
return nil if items.empty? || present.match?(/^#{Regexp.escape(
|
|
56
|
+
def section(heading, items, present, also: nil)
|
|
57
|
+
return nil if items.empty? || [heading, also].compact.any? { present.match?(/^#{Regexp.escape(it)}\s*$/i) }
|
|
52
58
|
|
|
53
|
-
"#{heading}\n\n#{items.map {
|
|
59
|
+
"#{heading}\n\n#{items.map { Plans::Spec.list_item(it) }.join("\n")}\n"
|
|
54
60
|
end
|
|
55
61
|
private_class_method :section
|
|
56
62
|
end
|