openlanes 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -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 +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- 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 +26 -2
- 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 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- 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 +94 -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 +1229 -116
- 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 +107 -0
- 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 +38 -105
- 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 +236 -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 +39 -11
- 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 +665 -0
- 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 +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- 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 +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- 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 +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -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 +66 -7
- 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 +110 -21
- 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 +287 -7
- 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 +210 -14
- 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 +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- 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 +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -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/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- 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/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /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,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
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Plans
|
|
7
|
+
# An agent's journal (M2): what it decided, did, left to do, asked and
|
|
8
|
+
# must not forget, kept from one round to the next.
|
|
9
|
+
#
|
|
10
|
+
# The agent writes no file and runs no command. It prints each entry as
|
|
11
|
+
# one line of JSON in its own output, which the harness reads anyway, and
|
|
12
|
+
# the dispatcher keeps it in `state.json` under `memory.<agent>`. One
|
|
13
|
+
# writer, under the lock {PlanState} already takes, is what lets a pair
|
|
14
|
+
# journal at once without either losing the other's line; an agent that
|
|
15
|
+
# wrote the file itself would be a second writer racing the first.
|
|
16
|
+
#
|
|
17
|
+
# {"openlanes_memory": {"kind": "decision", "text": "Money is integer cents"}}
|
|
18
|
+
# {"openlanes_memory": {"kind": "override", "text": "BigDecimal after all", "overrides": 3}}
|
|
19
|
+
#
|
|
20
|
+
# The single key {KEY} is what tells an entry from any other JSON an
|
|
21
|
+
# agent prints: a status object, a `package.json` it quotes, a `jq`
|
|
22
|
+
# result. Nothing else an agent has reason to print starts with that key.
|
|
23
|
+
#
|
|
24
|
+
# There is no timestamp. Entries are numbered in order across the
|
|
25
|
+
# agent's rounds on the plan, and a later one wins over an earlier one;
|
|
26
|
+
# an `override` names the number it overturns.
|
|
27
|
+
#
|
|
28
|
+
# Pure: text in, entries out. The dispatcher decides where they go.
|
|
29
|
+
module Journal
|
|
30
|
+
# The one key an entry line carries.
|
|
31
|
+
KEY = "openlanes_memory"
|
|
32
|
+
|
|
33
|
+
# What an entry may be. `question` is the agent stopping to ask; the
|
|
34
|
+
# harness adds the answer beside it once one arrives (M3).
|
|
35
|
+
KINDS = %w[todo done decision question override note].freeze
|
|
36
|
+
|
|
37
|
+
# Longest text kept, in characters. Every round's prompt carries the
|
|
38
|
+
# journal, so an entry is a line to remember, not a document; one that
|
|
39
|
+
# runs on is cut rather than refused, since what it says first is what
|
|
40
|
+
# it most needed to keep.
|
|
41
|
+
TEXT_LIMIT = 500
|
|
42
|
+
|
|
43
|
+
# A line that may be an entry: the key first, after an optional
|
|
44
|
+
# backtick, since agents quote JSON in Markdown as often as not.
|
|
45
|
+
CANDIDATE = /\A`*\{\s*"#{KEY}"\s*:/
|
|
46
|
+
|
|
47
|
+
# One entry, as the agent printed it or the harness recorded it.
|
|
48
|
+
#
|
|
49
|
+
# @!attribute [r] kind
|
|
50
|
+
# @return [String] one of {KINDS}
|
|
51
|
+
# @!attribute [r] text
|
|
52
|
+
# @return [String]
|
|
53
|
+
# @!attribute [r] overrides
|
|
54
|
+
# @return [Integer, nil] the number of the entry an `override` overturns
|
|
55
|
+
# @!attribute [r] ref
|
|
56
|
+
# @return [String, nil] `B<n>`, for a question the harness recorded
|
|
57
|
+
# from `blocked.md`
|
|
58
|
+
# @!attribute [r] answer
|
|
59
|
+
# @return [String, nil] a person's answer to that question
|
|
60
|
+
Entry = Data.define(:kind, :text, :overrides, :ref, :answer) do
|
|
61
|
+
def initialize(kind:, text:, overrides: nil, ref: nil, answer: nil) = super
|
|
62
|
+
|
|
63
|
+
# @param hash [Hash] as `state.json` keeps it
|
|
64
|
+
# @return [OpenLanes::Plans::Journal::Entry]
|
|
65
|
+
def self.from_h(hash)
|
|
66
|
+
new(kind: hash["kind"].to_s,
|
|
67
|
+
text: hash["text"].to_s,
|
|
68
|
+
overrides: hash["overrides"],
|
|
69
|
+
ref: hash["ref"],
|
|
70
|
+
answer: hash["answer"])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Hash] string keys, nils left out, as the schema wants
|
|
74
|
+
def to_json_h = to_h.transform_keys(&:to_s).compact
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A line that announced itself as an entry and was not one, kept so the
|
|
78
|
+
# dispatcher can say so: an agent whose journal is silently dropped
|
|
79
|
+
# believes it is remembering.
|
|
80
|
+
#
|
|
81
|
+
# @!attribute [r] line
|
|
82
|
+
# @return [String]
|
|
83
|
+
# @!attribute [r] reason
|
|
84
|
+
# @return [String]
|
|
85
|
+
Rejection = Data.define(:line, :reason)
|
|
86
|
+
|
|
87
|
+
# One round's worth of one agent's journal.
|
|
88
|
+
#
|
|
89
|
+
# @!attribute [r] number
|
|
90
|
+
# @return [Integer] the agent's journal round on this plan, 1 up,
|
|
91
|
+
# counted across runs and states. Not the dispatcher's round, which
|
|
92
|
+
# restarts at 1 in every run and every state and would land a later
|
|
93
|
+
# run's entries on an earlier run's.
|
|
94
|
+
# @!attribute [r] round
|
|
95
|
+
# @return [Integer, nil] the dispatcher's round it was printed in
|
|
96
|
+
# @!attribute [r] state
|
|
97
|
+
# @return [String, nil] the state the plan was in when it started
|
|
98
|
+
# @!attribute [r] entries
|
|
99
|
+
# @return [Array<Entry>]
|
|
100
|
+
# @!attribute [r] first
|
|
101
|
+
# @return [Integer] the number of its first entry in the whole journal
|
|
102
|
+
Round = Data.define(:number, :round, :state, :entries, :first)
|
|
103
|
+
|
|
104
|
+
module_function
|
|
105
|
+
|
|
106
|
+
# Every entry line in one piece of the agent's text, in order, each
|
|
107
|
+
# read into an {Entry} or a {Rejection}. Lines that do not start with
|
|
108
|
+
# {KEY} are none of this method's business and are skipped.
|
|
109
|
+
#
|
|
110
|
+
# @param text [String, nil] what the agent said
|
|
111
|
+
# @return [Array<Entry, Rejection>]
|
|
112
|
+
def scan(text)
|
|
113
|
+
text.to_s.each_line.filter_map do |raw|
|
|
114
|
+
line = raw.strip
|
|
115
|
+
read(line) if line.match?(CANDIDATE)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param line [String] one candidate line
|
|
120
|
+
# @return [Entry, Rejection]
|
|
121
|
+
def read(line)
|
|
122
|
+
json = line.delete_prefix("`" * line[/\A`*/].size).delete_suffix("`" * line[/`*\z/].size)
|
|
123
|
+
value = JSON.parse(json)[KEY]
|
|
124
|
+
problem = problem_with(value)
|
|
125
|
+
return Rejection.new(line:, reason: problem) if problem
|
|
126
|
+
|
|
127
|
+
Entry.new(kind: value["kind"], text: clip(value["text"]), overrides: value["overrides"])
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
Rejection.new(line:, reason: "not JSON: #{e.message.lines.first.to_s.strip}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Only the fields an agent may set are read; `ref` and `answer` are the
|
|
133
|
+
# harness's to write, and an agent claiming a person answered something
|
|
134
|
+
# is exactly the line that must not be trusted.
|
|
135
|
+
#
|
|
136
|
+
# @param value [Object]
|
|
137
|
+
# @return [String, nil] why it is not an entry
|
|
138
|
+
def problem_with(value)
|
|
139
|
+
return "#{KEY} is not an object" unless value.is_a?(Hash)
|
|
140
|
+
return "kind #{value["kind"].inspect} is not one of #{KINDS.join(", ")}" unless KINDS.include?(value["kind"])
|
|
141
|
+
return "text is missing" unless value["text"].is_a?(String) && !value["text"].strip.empty?
|
|
142
|
+
|
|
143
|
+
overrides = value["overrides"]
|
|
144
|
+
return nil if overrides.nil?
|
|
145
|
+
return "overrides must be an entry number" unless overrides.is_a?(Integer) && overrides.positive?
|
|
146
|
+
|
|
147
|
+
"only an override may name an entry it overrides" unless value["kind"] == "override"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param text [String]
|
|
151
|
+
# @return [String] stripped, and cut to {TEXT_LIMIT}
|
|
152
|
+
def clip(text)
|
|
153
|
+
text = text.to_s.strip
|
|
154
|
+
text.length > TEXT_LIMIT ? "#{text[0, TEXT_LIMIT - 1]}…" : text
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The line an agent prints, for the prompt's examples, so the prompt and
|
|
158
|
+
# the parser cannot describe two shapes.
|
|
159
|
+
#
|
|
160
|
+
# @param kind [String]
|
|
161
|
+
# @param text [String]
|
|
162
|
+
# @param overrides [Integer, nil]
|
|
163
|
+
# @return [String]
|
|
164
|
+
def line(kind, text, overrides: nil)
|
|
165
|
+
JSON.generate(KEY => { "kind" => kind, "text" => text, "overrides" => overrides }.compact)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|