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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# A question for a person, read as D3 shapes it: a short context paragraph,
|
|
6
|
+
# then lettered options `a)`, `b)`, `c)`, one of them recommended. The page
|
|
7
|
+
# draws a button per option, and `openlanes unblock` would offer the same
|
|
8
|
+
# ones, so both read them here rather than each guessing from the prose.
|
|
9
|
+
#
|
|
10
|
+
# Agents are told to write only that shape, but files written before D3,
|
|
11
|
+
# and agents that drift, use others. {.parse} reads them forgivingly:
|
|
12
|
+
#
|
|
13
|
+
# - lettered: `a)`, `(a)` or `a.` at the start of a line, or `a)` and
|
|
14
|
+
# `(a)` inline, the way agents actually run a whole choice into one
|
|
15
|
+
# paragraph; bullet-prefixed or bold-wrapped (`- **a)**`) alike
|
|
16
|
+
# - numbered: `1.`, `1)` or `Option 1:` at the start of a line
|
|
17
|
+
# - the W30 mark, `answers: yes, no`, which reads as `a) yes` `b) no`
|
|
18
|
+
#
|
|
19
|
+
# It is pure: text in, a value out, no file and no network. Whether the
|
|
20
|
+
# text really asks the reader to choose is a judgement the parser cannot
|
|
21
|
+
# make about a numbered list of steps already done, which is why the
|
|
22
|
+
# snapshot can ask {Live::ChoiceJudge} as well.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# choices = Plans::Choices.parse("Rebase or merge?\n\na) Rebase (Recommended)\nb) Merge")
|
|
26
|
+
# choices.context # => "Rebase or merge?"
|
|
27
|
+
# choices.options.map(&:answer) # => ["a) Rebase", "b) Merge"]
|
|
28
|
+
# choices.recommended.letter # => "a"
|
|
29
|
+
class Choices < Data.define(:context, :options)
|
|
30
|
+
# The text the person's choice sends back to the agent, beside how the
|
|
31
|
+
# option is shown. A yes/no option answers with the bare word, as W30
|
|
32
|
+
# promised agents ("branch on the word"); every other answers with its
|
|
33
|
+
# letter and label, so the agent can match either.
|
|
34
|
+
#
|
|
35
|
+
# @!attribute letter [String] "a", "b", …
|
|
36
|
+
# @!attribute label [String] what the button says, the marker stripped
|
|
37
|
+
# @!attribute recommended [Boolean]
|
|
38
|
+
# @!attribute answer [String] what choosing it sends
|
|
39
|
+
Option = Data.define(:letter, :label, :recommended, :answer) do
|
|
40
|
+
# @return [Hash] the page's `QuestionOption`
|
|
41
|
+
def to_h = { letter:, label:, recommended:, answer: }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# How an agent marked a yes/no question before D3 (W30), on its own line.
|
|
45
|
+
# Still read, and still what the prompt shows for a plain yes/no.
|
|
46
|
+
YES_NO_MARK = "answers: yes, no"
|
|
47
|
+
|
|
48
|
+
# {YES_NO_MARK}, read forgivingly: brackets, case and spacing vary.
|
|
49
|
+
YES_NO = /answers:\s*\[?\s*yes\s*,\s*no\s*\]?/i
|
|
50
|
+
|
|
51
|
+
# A letter's marker. At the start of a line (after a bullet or bold) it
|
|
52
|
+
# may be `a)`, `(a)` or `a.`, either case; inline only the parenthesised
|
|
53
|
+
# lowercase forms count, because `e.g.` and `I.` are prose.
|
|
54
|
+
LETTER_LINE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?(?:\(([a-z])\)|([a-z])[).])(?:\*\*)?[ \t]+/i
|
|
55
|
+
LETTER_INLINE = /(?<=[\s:])(?:\*\*)?(?:\(([a-z])\)|([a-z])\))(?:\*\*)?[ \t]+/
|
|
56
|
+
|
|
57
|
+
# A number's marker, line start only: `1.`, `1)`, `Option 1:`.
|
|
58
|
+
NUMBER_LINE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?(?:option[ \t]+(\d{1,2})[:.)]|(\d{1,2})[.)])(?:\*\*)?[ \t]+/i
|
|
59
|
+
|
|
60
|
+
# A numbered list is also how agents report steps already done, so one
|
|
61
|
+
# counts as a choice only after something that asks.
|
|
62
|
+
ASKS = /\?|\b(?:choose|chose|pick|prefer|option|which|decide)\b/i
|
|
63
|
+
|
|
64
|
+
# The recommendation, in the ways it gets written: bracketed anywhere
|
|
65
|
+
# (`(Recommended)`, `[recommended]`), or bare as the option's first or
|
|
66
|
+
# last word ("Recommended. Rebase first"). A bare "recommended" mid-label
|
|
67
|
+
# is prose ("the recommended layout") and stays where it is.
|
|
68
|
+
RECOMMENDED = /
|
|
69
|
+
[[:blank:]]*(?:\*\*|_)?[(\[][[:blank:]]*recommended[[:blank:]]*[)\]](?:\*\*|_)?[[:blank:]]*[.:,;]?
|
|
70
|
+
| \A(?:\*\*|_)?recommended(?:\*\*|_)?[[:blank:]]*[.:,;—–-][[:blank:]]*
|
|
71
|
+
| [[:blank:]]*[—–-][[:blank:]]*(?:\*\*|_)?recommended(?:\*\*|_)?\.?\z
|
|
72
|
+
/ix
|
|
73
|
+
|
|
74
|
+
# The option the page's text box already is. D3 makes it the last
|
|
75
|
+
# choice of every question, so an agent that writes it out gets no
|
|
76
|
+
# second, dead button.
|
|
77
|
+
SOMETHING_ELSE = /\A(?:type|write|say)\s+something\s+(?:else\s+)?instead\b|\Asomething\s+else\b|\Aother\b/i
|
|
78
|
+
|
|
79
|
+
# A label that runs into a new paragraph is not a label: the list ended
|
|
80
|
+
# and prose followed it, so the options are not the end of the text.
|
|
81
|
+
PARAGRAPH = /\n[ \t]*\n/
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# @param text [String, nil] a question's body, Markdown
|
|
85
|
+
# @return [OpenLanes::Plans::Choices, nil] nil when the text offers
|
|
86
|
+
# fewer than two options, or its options are not where it ends
|
|
87
|
+
def parse(text)
|
|
88
|
+
text = text.to_s.strip
|
|
89
|
+
return nil if text.empty?
|
|
90
|
+
|
|
91
|
+
marked(text) || lettered(text) || numbered(text)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A yes/no question about +context+.
|
|
95
|
+
#
|
|
96
|
+
# @param context [String]
|
|
97
|
+
# @return [OpenLanes::Plans::Choices]
|
|
98
|
+
def yes_no(context)
|
|
99
|
+
new(context: context.to_s.strip,
|
|
100
|
+
options: [Option.new(letter: "a", label: "yes", recommended: false, answer: "yes"),
|
|
101
|
+
Option.new(letter: "b", label: "no", recommended: false, answer: "no")])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The D3 shape, written out: the context, a blank line, then one
|
|
105
|
+
# lettered line per label with the recommended one marked. {.parse}
|
|
106
|
+
# reads it back into the same labels, which is how a rewritten
|
|
107
|
+
# question proves it kept its shape.
|
|
108
|
+
#
|
|
109
|
+
# @param context [String]
|
|
110
|
+
# @param labels [Array<String>] one line each
|
|
111
|
+
# @param recommended [Integer] the index of the recommended label
|
|
112
|
+
# @return [String] Markdown, ending in a newline
|
|
113
|
+
def write(context:, labels:, recommended:)
|
|
114
|
+
lines = labels.each_with_index.map do |label, i|
|
|
115
|
+
"#{("a".ord + i).chr}) #{label}#{" (Recommended)" if i == recommended}"
|
|
116
|
+
end
|
|
117
|
+
"#{context.to_s.strip}\n\n#{lines.join("\n")}\n"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# The W30 mark, on a line of its own.
|
|
123
|
+
def marked(text)
|
|
124
|
+
line = /^[ \t]*#{YES_NO.source}[ \t]*$\n?/i
|
|
125
|
+
return nil unless text.match?(line)
|
|
126
|
+
|
|
127
|
+
yes_no(text.gsub(line, ""))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A line's marker also matches inline from its letter on, so the
|
|
131
|
+
# inline match inside one (the `a)` of `- a)`) is dropped: kept, it
|
|
132
|
+
# would leave the bullet behind at the end of the context.
|
|
133
|
+
def lettered(text)
|
|
134
|
+
lines = scan(text, LETTER_LINE) { |m| (m[1] || m[2]).downcase.ord - "a".ord }
|
|
135
|
+
inline = scan(text, LETTER_INLINE) { |m| (m[1] || m[2]).ord - "a".ord }
|
|
136
|
+
.reject { |start, _, _| lines.any? { |from, to, _| (from...to).cover?(start) } }
|
|
137
|
+
run(text, (lines + inline).sort_by(&:first))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def numbered(text)
|
|
141
|
+
markers = scan(text, NUMBER_LINE) { |m| (m[1] || m[2]).to_i - 1 }
|
|
142
|
+
choices = run(text, markers)
|
|
143
|
+
choices if choices&.context&.match?(ASKS)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Every marker in +text+ as `[start, finish, index]`, index 0 for `a`
|
|
147
|
+
# or `1`.
|
|
148
|
+
def scan(text, pattern)
|
|
149
|
+
found = []
|
|
150
|
+
text.scan(pattern) do
|
|
151
|
+
match = Regexp.last_match
|
|
152
|
+
found << [match.begin(0), match.end(0), yield(match)]
|
|
153
|
+
end
|
|
154
|
+
found
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The last run of markers that counts 0, 1, 2… up to the end of the
|
|
158
|
+
# text. The last, because a context may itself mention "a) or b)"
|
|
159
|
+
# before the real options; and to the end, because a list followed by
|
|
160
|
+
# more prose was something other than a choice.
|
|
161
|
+
def run(text, markers)
|
|
162
|
+
markers.each_index.reverse_each do |first|
|
|
163
|
+
next unless markers[first].last.zero?
|
|
164
|
+
|
|
165
|
+
picked = sequence(markers.drop(first))
|
|
166
|
+
next if picked.size < 2
|
|
167
|
+
|
|
168
|
+
choices = build(text, picked)
|
|
169
|
+
return choices if choices
|
|
170
|
+
end
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# From a marker for index 0, the next marker for 1, then 2, skipping
|
|
175
|
+
# anything out of order (an `(e)` quoted inside a label).
|
|
176
|
+
def sequence(markers)
|
|
177
|
+
markers.each_with_object([]) do |marker, picked|
|
|
178
|
+
picked << marker if marker.last == picked.size
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build(text, picked)
|
|
183
|
+
labels = picked.each_with_index.map do |(_, finish, _), i|
|
|
184
|
+
text[finish...(picked[i + 1]&.first || text.size)].strip
|
|
185
|
+
end
|
|
186
|
+
return nil if labels.any? { it.empty? || it.match?(PARAGRAPH) }
|
|
187
|
+
|
|
188
|
+
options = labels.each_with_index.map { |label, i| option(label, i) }
|
|
189
|
+
options.pop if options.last&.label&.match?(SOMETHING_ELSE)
|
|
190
|
+
return nil if options.size < 2
|
|
191
|
+
|
|
192
|
+
new(context: text[0...picked.first.first].strip, options: only_one_recommended(options))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def option(raw, index)
|
|
196
|
+
recommended = raw.match?(RECOMMENDED)
|
|
197
|
+
label = unbold(raw.sub(RECOMMENDED, " ").strip).strip
|
|
198
|
+
letter = ("a".ord + index).chr
|
|
199
|
+
Option.new(letter:, label:, recommended:, answer: "#{letter}) #{label}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A bold marker leaves its partner behind: `**a) Rebase**` becomes
|
|
203
|
+
# `Rebase**`. An odd count of `**` means one of them lost its pair.
|
|
204
|
+
def unbold(label)
|
|
205
|
+
label = label.sub('**', "") if label.scan("**").size.odd?
|
|
206
|
+
label = label[2...-2].strip if label.start_with?("**") && label.end_with?("**") && label.size > 4
|
|
207
|
+
label
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# D3 says exactly one; an agent that recommends two has recommended
|
|
211
|
+
# the first, and the page shows one tag rather than several.
|
|
212
|
+
def only_one_recommended(options)
|
|
213
|
+
first = options.index(&:recommended)
|
|
214
|
+
options.each_with_index.map { |o, i| o.recommended && i != first ? o.with(recommended: false) : o }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [OpenLanes::Plans::Choices::Option, nil]
|
|
219
|
+
def recommended = options.find(&:recommended)
|
|
220
|
+
|
|
221
|
+
# @return [Boolean] whether the options are yes and no
|
|
222
|
+
def yes_no? = options.map(&:label) == %w[yes no]
|
|
223
|
+
|
|
224
|
+
# @return [Array<Hash>] the page's `QuestionOption[]`
|
|
225
|
+
def to_a = options.map(&:to_h)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# Who waits for whom, read from every plan's `depends_on:` and nothing
|
|
6
|
+
# else. Only the forward edge is written down; `blocks` is derived here
|
|
7
|
+
# every time and never stored, so dropping a dependency cannot leave a
|
|
8
|
+
# stale reverse entry behind in the plan it used to name (N6).
|
|
9
|
+
#
|
|
10
|
+
# Every answer is an array of canonical `NNN.MM` strings, sorted. Asking
|
|
11
|
+
# about a plan the tree does not hold answers empty rather than raising.
|
|
12
|
+
#
|
|
13
|
+
# Plans joined by an unfinished edge, in either direction, form a group,
|
|
14
|
+
# and a group works on one plan at a time (#95): the first in topological
|
|
15
|
+
# order that may start, so two plans that will meet in a common dependent
|
|
16
|
+
# are never built against each other's moving code. Separate groups run
|
|
17
|
+
# side by side. An edge to a finished plan joins nothing, so the
|
|
18
|
+
# dependents of a shipped plan part ways.
|
|
19
|
+
class Dependencies
|
|
20
|
+
# The states in which a dependency is done. Everything else, blocked and
|
|
21
|
+
# deferred included, keeps the dependent waiting.
|
|
22
|
+
DONE = %i[approved deployed].freeze
|
|
23
|
+
|
|
24
|
+
# The states before the build. Work in them is documents only, so a plan
|
|
25
|
+
# here gives up its group's turn to one whose code is already under way.
|
|
26
|
+
BEFORE_BUILD = %i[retroactive new researched ready_for_planning planned].freeze
|
|
27
|
+
|
|
28
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
29
|
+
# @param status_of [#call, nil] the state to judge a plan by, when it is
|
|
30
|
+
# not the folder's name. A dry run renames nothing, so without this a
|
|
31
|
+
# prerequisite whose contents already say ✅ would still hold its
|
|
32
|
+
# dependents back, while a real run would have reconciled it and let
|
|
33
|
+
# them start.
|
|
34
|
+
def initialize(tree, status_of: nil)
|
|
35
|
+
@tree = tree
|
|
36
|
+
@status_of = status_of || :status.to_proc
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
40
|
+
# @return [Array<String>, nil] the declared list, nil when the key is absent
|
|
41
|
+
def depends_on(ordinal) = declared[key(ordinal)]
|
|
42
|
+
|
|
43
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
44
|
+
# @return [Array<String>] every plan whose `depends_on` names this one
|
|
45
|
+
def blocks_of(ordinal) = reverse.fetch(key(ordinal), [])
|
|
46
|
+
|
|
47
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
48
|
+
# @return [Array<String>] declared entries that are not done, unknown ones included
|
|
49
|
+
def waiting_on(ordinal) = edges(ordinal).reject { done?(it) }
|
|
50
|
+
|
|
51
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
52
|
+
# @return [Array<String>] declared entries with no folder in the tree
|
|
53
|
+
def unknown(ordinal) = edges(ordinal).reject { states.key?(it) }
|
|
54
|
+
|
|
55
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
56
|
+
# @return [Array<String>] declared entries naming a done plan, which the gate ignores
|
|
57
|
+
def closed(ordinal) = edges(ordinal).select { done?(it) }
|
|
58
|
+
|
|
59
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
60
|
+
# @return [Array<String>, nil] a shortest cycle through this plan,
|
|
61
|
+
# starting and ending with it, or nil when it is on none
|
|
62
|
+
def cycle_of(ordinal)
|
|
63
|
+
wanted = key(ordinal)
|
|
64
|
+
return nil unless looped?(wanted)
|
|
65
|
+
|
|
66
|
+
(@rings ||= {})[wanted] ||= ring_from(wanted)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# One shortest cycle per tangle of plans that wait on each other, each
|
|
70
|
+
# starting and ending at the tangle's lowest ordinal. A tangle can hold
|
|
71
|
+
# more cycles than this; {#cycle_of} names one through any plan in it.
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<Array<String>>]
|
|
74
|
+
def cycles = @cycles ||= components.select { looped?(it.first) }.map { cycle_of(it.first) }.sort
|
|
75
|
+
|
|
76
|
+
# Unfinished plans joined by an unfinished edge, in either direction.
|
|
77
|
+
#
|
|
78
|
+
# @return [Array<Array<String>>] each group sorted, groups by first ordinal
|
|
79
|
+
def groups
|
|
80
|
+
@groups ||= begin
|
|
81
|
+
# Union-find, walked in a loop rather than recursively, so a long
|
|
82
|
+
# chain of plans cannot raise SystemStackError inside a tick.
|
|
83
|
+
root = unfinished.to_h { [it, it] }
|
|
84
|
+
find = lambda do |x|
|
|
85
|
+
x = root[x] = root[root[x]] until root[x] == x
|
|
86
|
+
x
|
|
87
|
+
end
|
|
88
|
+
unfinished.each do |from|
|
|
89
|
+
open_edges.fetch(from).each { |to| root[find.call(from)] = find.call(to) if root.key?(to) }
|
|
90
|
+
end
|
|
91
|
+
unfinished.group_by { find.call(it) }.values.map(&:sort).sort
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Each group in topological order, prerequisites first, ties by
|
|
96
|
+
# ordinal. Plans on a cycle cannot be ordered and come last.
|
|
97
|
+
#
|
|
98
|
+
# @return [Array<Array<String>>] parallel to {#groups}
|
|
99
|
+
def order = @order ||= groups.map { sorted(it) }
|
|
100
|
+
|
|
101
|
+
# The plan whose turn it is in this plan's group, or nil when no plan
|
|
102
|
+
# in it may start: the first in {#order} that may, preferring one whose
|
|
103
|
+
# build is already under way, so work already done is never stranded.
|
|
104
|
+
#
|
|
105
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
106
|
+
# @return [String, nil]
|
|
107
|
+
def head(ordinal)
|
|
108
|
+
wanted = key(ordinal)
|
|
109
|
+
line = order.find { it.include?(wanted) } or return nil
|
|
110
|
+
(@heads ||= {}).fetch(line.first) do
|
|
111
|
+
ready = line.select { startable?(it) }
|
|
112
|
+
@heads[line.first] = ready.find { !BEFORE_BUILD.include?(states[it]) } || ready.first
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Why the engine must not start this plan, or nil when it may. A cycle
|
|
117
|
+
# outranks a plain wait, since waiting on a cycle never ends. An entry
|
|
118
|
+
# naming no plan is said apart from a real wait: a typo such as
|
|
119
|
+
# "099.00" holds the plan forever, and "waiting on 099.00" in the held
|
|
120
|
+
# report read like a wait that would end on its own. A plan free of
|
|
121
|
+
# all three still waits while another plan holds its group's turn.
|
|
122
|
+
#
|
|
123
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
124
|
+
# @return [String, nil] e.g. "waiting on 034.00", "unknown plan 099.00",
|
|
125
|
+
# "cycle: 034.00 → 050.00 → 034.00" or "queued behind 034.00"
|
|
126
|
+
def reason(ordinal)
|
|
127
|
+
own = own_reason(ordinal) and return own
|
|
128
|
+
|
|
129
|
+
first = head(ordinal)
|
|
130
|
+
"queued behind #{first}" if first && first != key(ordinal)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# {#reason} without the group's turn: what this plan's own edges say.
|
|
136
|
+
#
|
|
137
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
138
|
+
# @return [String, nil]
|
|
139
|
+
def own_reason(ordinal)
|
|
140
|
+
cycle = cycle_of(ordinal)
|
|
141
|
+
return "cycle: #{cycle.join(" → ")}" if cycle
|
|
142
|
+
|
|
143
|
+
missing = unknown(ordinal)
|
|
144
|
+
open = waiting_on(ordinal) - missing
|
|
145
|
+
parts = []
|
|
146
|
+
parts << "unknown plan #{missing.join(", ")}" unless missing.empty?
|
|
147
|
+
parts << "waiting on #{open.join(", ")}" unless open.empty?
|
|
148
|
+
parts.join("; ") unless parts.empty?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [Boolean] whether this plan could take its group's turn:
|
|
152
|
+
# neither parked nor done, and free of its own reasons to wait
|
|
153
|
+
def startable?(ordinal) = !StateMachine::SETTLED.include?(states[ordinal]) && own_reason(ordinal).nil?
|
|
154
|
+
|
|
155
|
+
# @return [Array<String>] plans in the tree that are not done
|
|
156
|
+
def unfinished = @unfinished ||= declared.keys.reject { done?(it) }.sort
|
|
157
|
+
|
|
158
|
+
# Kahn's algorithm over the group's open edges, taking the lowest
|
|
159
|
+
# ordinal whenever several plans are free. The free list stays sorted
|
|
160
|
+
# by binary insertion, so a group of thousands sorts in a blink.
|
|
161
|
+
#
|
|
162
|
+
# @param group [Array<String>]
|
|
163
|
+
# @return [Array<String>]
|
|
164
|
+
def sorted(group)
|
|
165
|
+
members = group.to_set
|
|
166
|
+
pending = group.to_h { |plan| [plan, open_edges.fetch(plan).count { members.include?(it) }] }
|
|
167
|
+
free = pending.select { |_, n| n.zero? }.keys.sort
|
|
168
|
+
out = []
|
|
169
|
+
until free.empty?
|
|
170
|
+
plan = free.shift
|
|
171
|
+
out << plan
|
|
172
|
+
pending.delete(plan)
|
|
173
|
+
blocks_of(plan).each do |waiter|
|
|
174
|
+
next unless pending.key?(waiter) && (pending[waiter] -= 1).zero?
|
|
175
|
+
|
|
176
|
+
free.insert(free.bsearch_index { it >= waiter } || free.size, waiter)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
out + pending.keys.sort
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @return [String]
|
|
183
|
+
def key(ordinal) = (ordinal.is_a?(Ordinal) ? ordinal : Ordinal.parse(ordinal)).to_s
|
|
184
|
+
|
|
185
|
+
# @return [Array<String>]
|
|
186
|
+
def edges(ordinal) = depends_on(ordinal) || []
|
|
187
|
+
|
|
188
|
+
# @return [Boolean]
|
|
189
|
+
def done?(ordinal) = DONE.include?(states[ordinal])
|
|
190
|
+
|
|
191
|
+
# @return [Hash{String => Symbol}] ordinal => status key
|
|
192
|
+
def states = @states ||= @tree.subjects.to_h { [it.feature.ordinal.to_s, @status_of.call(it).key] }
|
|
193
|
+
|
|
194
|
+
# @return [Hash{String => Array<String>, nil}]
|
|
195
|
+
def declared
|
|
196
|
+
@declared ||= @tree.subjects.to_h { [it.feature.ordinal.to_s, Spec.for(it).depends_on] }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @return [Hash{String => Array<String>}]
|
|
200
|
+
def reverse
|
|
201
|
+
@reverse ||= declared.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(from, tos), out|
|
|
202
|
+
Array(tos).each { out[it] << from }
|
|
203
|
+
end.transform_values(&:sort)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The edges that can still hold anything up: entries not yet done that
|
|
207
|
+
# name a plan in the tree. A dependency on a ✅ plan is already met, so
|
|
208
|
+
# it closes no cycle; two plans that once named each other stop being
|
|
209
|
+
# deadlocked the moment one of them ships.
|
|
210
|
+
#
|
|
211
|
+
# @return [Hash{String => Array<String>}]
|
|
212
|
+
def open_edges = @open_edges ||= declared.keys.to_h { [it, waiting_on(it).select { declared.key?(it) }] }
|
|
213
|
+
|
|
214
|
+
# @return [Boolean] whether this plan sits on a cycle of open edges
|
|
215
|
+
def looped?(ordinal)
|
|
216
|
+
group = group_of[ordinal] or return false
|
|
217
|
+
group.size > 1 || open_edges.fetch(ordinal).include?(ordinal)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @return [Hash{String => Array<String>}] ordinal => its strongly connected component
|
|
221
|
+
def group_of = @group_of ||= components.each_with_object({}) { |g, out| g.each { out[it] = g } }
|
|
222
|
+
|
|
223
|
+
# Tarjan's strongly connected components over {#open_edges}, so each
|
|
224
|
+
# plan is visited once. Enumerating every path instead revisited shared
|
|
225
|
+
# subgraphs on each one: 28 plans each waiting on the two before took
|
|
226
|
+
# 2.6 seconds and two million visits, and the dispatcher asks on every
|
|
227
|
+
# tick. The walk keeps its own stack of frames rather than recursing,
|
|
228
|
+
# so a chain of a few thousand plans cannot raise SystemStackError
|
|
229
|
+
# inside a dispatcher tick.
|
|
230
|
+
#
|
|
231
|
+
# @return [Array<Array<String>>] each component sorted
|
|
232
|
+
def components
|
|
233
|
+
@components ||= begin
|
|
234
|
+
index = {}
|
|
235
|
+
low = {}
|
|
236
|
+
stack = []
|
|
237
|
+
on_stack = {}
|
|
238
|
+
out = []
|
|
239
|
+
open_edges.keys.sort.each do |root|
|
|
240
|
+
next if index.key?(root)
|
|
241
|
+
|
|
242
|
+
frames = [[root, 0]]
|
|
243
|
+
index[root] = low[root] = index.size
|
|
244
|
+
stack << root
|
|
245
|
+
on_stack[root] = true
|
|
246
|
+
until frames.empty?
|
|
247
|
+
v, i = frames.last
|
|
248
|
+
w = open_edges.fetch(v)[i]
|
|
249
|
+
if w
|
|
250
|
+
frames.last[1] += 1
|
|
251
|
+
if !index.key?(w)
|
|
252
|
+
index[w] = low[w] = index.size
|
|
253
|
+
stack << w
|
|
254
|
+
on_stack[w] = true
|
|
255
|
+
frames << [w, 0]
|
|
256
|
+
elsif on_stack[w]
|
|
257
|
+
low[v] = [low[v], index[w]].min
|
|
258
|
+
end
|
|
259
|
+
next
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
frames.pop
|
|
263
|
+
parent = frames.last&.first
|
|
264
|
+
low[parent] = [low[parent], low[v]].min if parent
|
|
265
|
+
next unless low[v] == index[v]
|
|
266
|
+
|
|
267
|
+
group = []
|
|
268
|
+
group << stack.pop.tap { on_stack.delete(it) } until group.last == v
|
|
269
|
+
out << group.sort
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
out
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Breadth-first from +start+ back to itself inside its component, so
|
|
277
|
+
# the cycle named is a shortest one and the walk is linear.
|
|
278
|
+
#
|
|
279
|
+
# @param start [String]
|
|
280
|
+
# @return [Array<String>]
|
|
281
|
+
def ring_from(start)
|
|
282
|
+
group = group_of.fetch(start)
|
|
283
|
+
parent = { start => nil }
|
|
284
|
+
queue = [start]
|
|
285
|
+
until queue.empty?
|
|
286
|
+
node = queue.shift
|
|
287
|
+
open_edges.fetch(node).sort.each do |nxt|
|
|
288
|
+
next unless group.include?(nxt)
|
|
289
|
+
|
|
290
|
+
if nxt == start
|
|
291
|
+
path = [node]
|
|
292
|
+
path.unshift(parent[path.first]) while parent[path.first]
|
|
293
|
+
return path + [start]
|
|
294
|
+
end
|
|
295
|
+
next if parent.key?(nxt)
|
|
296
|
+
|
|
297
|
+
parent[nxt] = node
|
|
298
|
+
queue << nxt
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
3
5
|
module OpenLanes
|
|
4
6
|
module Plans
|
|
5
7
|
# Lowercase in the middle of a title, capitalised at the front.
|
|
@@ -45,22 +47,36 @@ module OpenLanes
|
|
|
45
47
|
}.join(" ")
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
# The
|
|
50
|
+
# The heavy rule between the parts of a folder name. No space anywhere, so
|
|
51
|
+
# a name needs no quoting in a shell, and nothing an emoji can swallow.
|
|
52
|
+
RULE = "━━"
|
|
53
|
+
|
|
54
|
+
# What points from the state to the slug.
|
|
55
|
+
POINTER = "❯"
|
|
56
|
+
|
|
57
|
+
# The one place a plan folder's name is spelled out (decision N1):
|
|
58
|
+
# `NNN.MM━━<emoji>━━❯<slug>`, or `NNN.MM━━<emoji>━━❯[YYYY-MM-DD]━━<slug>`
|
|
59
|
+
# once the plan has been moved into an inactive subfolder (N3).
|
|
49
60
|
#
|
|
50
|
-
# The
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# `--` and single-dash spellings too, and `resync dirs` migrates them on
|
|
55
|
-
# contact.
|
|
61
|
+
# The earlier `NNN.MM-<emoji> → <slug>` put a space in every name, and the
|
|
62
|
+
# `--` before it let a two-cell emoji swallow the dash beside it.
|
|
63
|
+
# Everything that mints or renames a folder goes through here; {Feature.parse}
|
|
64
|
+
# still reads every older spelling, and `resync dirs` migrates them.
|
|
56
65
|
#
|
|
57
66
|
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
58
67
|
# @param status [OpenLanes::Plans::Status]
|
|
59
68
|
# @param slug [String]
|
|
69
|
+
# @param archived_on [Date, nil] the date the plan was moved aside
|
|
60
70
|
# @return [String]
|
|
61
|
-
def self.plan_dirname(ordinal, status, slug
|
|
71
|
+
def self.plan_dirname(ordinal, status, slug, archived_on: nil)
|
|
72
|
+
date = archived_on && "[#{archived_on.strftime("%F")}]#{RULE}"
|
|
73
|
+
"#{ordinal}#{RULE}#{status.emoji}#{RULE}#{POINTER}#{date}#{slug}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The current spelling, with or without the archive date.
|
|
77
|
+
CURRENT_FORM = /\A[\d.]+#{RULE}(?<emoji>[^━]+)#{RULE}#{POINTER}(?:\[(?<date>\d{4}-\d{2}-\d{2})\]#{RULE})?(?<slug>.+)\z/
|
|
62
78
|
|
|
63
|
-
# One
|
|
79
|
+
# One plan folder, decoded.
|
|
64
80
|
#
|
|
65
81
|
# @!attribute [r] ordinal
|
|
66
82
|
# @return [OpenLanes::Plans::Ordinal]
|
|
@@ -72,9 +88,14 @@ module OpenLanes
|
|
|
72
88
|
# @return [String] exactly as it is on disk
|
|
73
89
|
# @!attribute [r] path
|
|
74
90
|
# @return [String] absolute
|
|
75
|
-
|
|
91
|
+
# @!attribute [r] archived_on
|
|
92
|
+
# @return [Date, nil] the date in the name of a plan moved aside, else nil
|
|
93
|
+
Feature = Data.define(:ordinal, :status, :slug, :dirname, :path, :archived_on) do
|
|
76
94
|
include Comparable
|
|
77
95
|
|
|
96
|
+
# @param archived_on [Date, nil] optional, so callers that predate it still build one
|
|
97
|
+
def initialize(archived_on: nil, **) = super
|
|
98
|
+
|
|
78
99
|
# Decode a folder name, or return nil when it is not a plan folder.
|
|
79
100
|
#
|
|
80
101
|
# @param path [String] absolute path to a candidate directory
|
|
@@ -83,29 +104,67 @@ module OpenLanes
|
|
|
83
104
|
dirname = File.basename(path)
|
|
84
105
|
ordinal = Ordinal.from_dirname(dirname) or return nil
|
|
85
106
|
|
|
107
|
+
current(dirname, ordinal, path) || legacy(dirname, ordinal, path)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# `NNN.MM━━<emoji>━━❯<slug>`, dated or not.
|
|
111
|
+
#
|
|
112
|
+
# An emoji nothing answers to reads as ⚪ New rather than falling through
|
|
113
|
+
# to {.legacy}, which cannot split this spelling and took the whole name
|
|
114
|
+
# for the slug; the next rename then wrote
|
|
115
|
+
# `001.00━━⭕━━❯001.00━━🛑━━❯slug`. A name already nested that way is
|
|
116
|
+
# read down to its innermost slug, so `resync dirs` can undo it.
|
|
117
|
+
#
|
|
118
|
+
# @return [OpenLanes::Plans::Feature, nil]
|
|
119
|
+
def self.current(dirname, ordinal, path)
|
|
120
|
+
m = CURRENT_FORM.match(dirname) or return nil
|
|
121
|
+
status = OpenLanes::Plans.status_for_emoji(m[:emoji]) || STATUS_BY_KEY.fetch(:new)
|
|
122
|
+
archived_on = m[:date] && Date.iso8601(m[:date], exception: false)
|
|
123
|
+
|
|
124
|
+
new(ordinal:, status:, slug: innermost(m[:slug], ordinal), dirname:, path:, archived_on:)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param slug [String]
|
|
128
|
+
# @param ordinal [OpenLanes::Plans::Ordinal]
|
|
129
|
+
# @return [String] +slug+ without any plan prefix of the same ordinal
|
|
130
|
+
def self.innermost(slug, ordinal)
|
|
131
|
+
return slug unless Ordinal.from_dirname(slug) == ordinal
|
|
132
|
+
|
|
133
|
+
parse(slug)&.slug || slug
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# `NNN.MM-<emoji> → <slug>`, `NNN-<emoji>--<slug>` and `NNN-<emoji>-<slug>`.
|
|
137
|
+
#
|
|
138
|
+
# @return [OpenLanes::Plans::Feature]
|
|
139
|
+
def self.legacy(dirname, ordinal, path)
|
|
86
140
|
rest = dirname.sub(/\A[\d.]+[-_]/, "")
|
|
87
141
|
head, tail = rest.split(/\s*→\s*|[-_]/, 2)
|
|
88
142
|
|
|
89
143
|
# A leading segment with no ASCII word character is the status emoji.
|
|
90
|
-
# The slug strips any further separators: the
|
|
91
|
-
#
|
|
92
|
-
#
|
|
144
|
+
# The slug strips any further separators: the arrow spelling put a
|
|
145
|
+
# spaced arrow after the emoji, and folders from before that put two
|
|
146
|
+
# dashes, or one.
|
|
93
147
|
status = (OpenLanes::Plans.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
|
|
94
148
|
slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
|
|
95
149
|
|
|
96
150
|
new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
|
|
97
151
|
end
|
|
152
|
+
private_class_method :current, :legacy, :innermost
|
|
98
153
|
|
|
99
154
|
# @return [String] proper name, e.g. "Law as Data"
|
|
100
155
|
def title = OpenLanes::Plans.titleize(slug)
|
|
101
156
|
|
|
102
157
|
# The folder name this feature would carry in a given state — the slug
|
|
103
|
-
# never
|
|
104
|
-
# also what repairs a folder written
|
|
158
|
+
# and any archive date never move, and the number is always rendered
|
|
159
|
+
# canonically, so this is also what repairs a folder written
|
|
160
|
+
# `018-⚪️--foo` before the `NNN.MM` rule.
|
|
105
161
|
#
|
|
106
162
|
# @param status [OpenLanes::Plans::Status]
|
|
163
|
+
# @param archived_on [Date, nil] defaults to the date the name carries now
|
|
107
164
|
# @return [String]
|
|
108
|
-
def dirname_as(status
|
|
165
|
+
def dirname_as(status, archived_on: self.archived_on)
|
|
166
|
+
OpenLanes::Plans.plan_dirname(ordinal, status, slug, archived_on:)
|
|
167
|
+
end
|
|
109
168
|
|
|
110
169
|
# The number exactly as the folder writes it, which is not always the
|
|
111
170
|
# canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
|