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
data/lib/openlanes/live/store.rb
CHANGED
|
@@ -45,7 +45,7 @@ module OpenLanes
|
|
|
45
45
|
|
|
46
46
|
# The writes {#multi} accepts. Reads inside a transaction answer
|
|
47
47
|
# nothing until it commits, so none are offered.
|
|
48
|
-
WRITES = %i[set del expire hset hdel xadd xack].freeze
|
|
48
|
+
WRITES = %i[set del expire hset hdel xadd xack xtrim].freeze
|
|
49
49
|
|
|
50
50
|
# Records the writes of one {#multi} block, to be applied together.
|
|
51
51
|
class Transaction
|
|
@@ -144,6 +144,15 @@ module OpenLanes
|
|
|
144
144
|
# @return [Integer] entries in the stream
|
|
145
145
|
def xlen(key) = call("XLEN", key)
|
|
146
146
|
|
|
147
|
+
# Drops every entry older than +minid+, exactly rather than with `~`,
|
|
148
|
+
# so a message an hour old is gone when the hour is up, not when Redis
|
|
149
|
+
# next finds a whole node to free.
|
|
150
|
+
#
|
|
151
|
+
# @param key [String]
|
|
152
|
+
# @param minid [String] `<ms>-<seq>`, the oldest id kept
|
|
153
|
+
# @return [Integer] how many were removed
|
|
154
|
+
def xtrim(key, minid:) = call(*xtrim_command(key, minid:))
|
|
155
|
+
|
|
147
156
|
# Creates the group, and the stream with it. Asking twice is not an
|
|
148
157
|
# error: every engine start asks.
|
|
149
158
|
#
|
|
@@ -169,6 +178,10 @@ module OpenLanes
|
|
|
169
178
|
# @return [Array<Entry>]
|
|
170
179
|
def xreadgroup(key, group, consumer, id: ">", count: 100)
|
|
171
180
|
entries(call("XREADGROUP", "GROUP", group, consumer, "COUNT", count, "STREAMS", key, id)&.fetch(key, nil))
|
|
181
|
+
rescue RedisClient::CommandError => e
|
|
182
|
+
raise unless e.message.start_with?("NOGROUP")
|
|
183
|
+
|
|
184
|
+
raise GroupMissing, e.message.lines.first.to_s.strip
|
|
172
185
|
end
|
|
173
186
|
|
|
174
187
|
# @return [Integer] how many were acknowledged
|
|
@@ -215,6 +228,24 @@ module OpenLanes
|
|
|
215
228
|
found.uniq.sort
|
|
216
229
|
end
|
|
217
230
|
|
|
231
|
+
# ---- pub/sub ----------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
# @param channel [String]
|
|
234
|
+
# @param message [String]
|
|
235
|
+
# @return [Integer] how many subscriptions received it
|
|
236
|
+
def publish(channel, message) = call("PUBLISH", channel, message)
|
|
237
|
+
|
|
238
|
+
# A connection of its own, subscribed to +channels+ and +patterns+.
|
|
239
|
+
#
|
|
240
|
+
# @return [OpenLanes::Live::Subscriber]
|
|
241
|
+
def subscriber(channels: [], patterns: []) = Subscriber.new(url:, channels:, patterns:)
|
|
242
|
+
|
|
243
|
+
# The database in the URL. Pub/sub ignores databases, so whoever names
|
|
244
|
+
# a channel puts this in it (see {Keys#chat_room}).
|
|
245
|
+
#
|
|
246
|
+
# @return [Integer]
|
|
247
|
+
def db = @client.config.db.to_i
|
|
248
|
+
|
|
218
249
|
# ---- transactions -----------------------------------------------------
|
|
219
250
|
|
|
220
251
|
# The writes the block makes, applied atomically.
|
|
@@ -250,10 +281,14 @@ module OpenLanes
|
|
|
250
281
|
case name
|
|
251
282
|
when :set then set_command(*args, **kwargs)
|
|
252
283
|
when :xadd then xadd_command(*args, **kwargs)
|
|
284
|
+
when :xtrim then xtrim_command(*args, **kwargs)
|
|
253
285
|
else [name.to_s.upcase, *args]
|
|
254
286
|
end
|
|
255
287
|
end
|
|
256
288
|
|
|
289
|
+
# @return [Array]
|
|
290
|
+
def xtrim_command(key, minid:) = ["XTRIM", key, "MINID", minid]
|
|
291
|
+
|
|
257
292
|
# @return [Array]
|
|
258
293
|
def set_command(key, value, ex: nil, nx: false)
|
|
259
294
|
["SET", key, value, *("NX" if nx), *(["EX", ex] if ex)]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "redis-client"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# One Redis pub/sub connection, subscribed once to the channels and
|
|
8
|
+
# patterns it was made with (plan 006's chat). A subscribed connection
|
|
9
|
+
# can do nothing else, so it is its own connection, never the {Store}'s.
|
|
10
|
+
#
|
|
11
|
+
# Made by {Store#subscriber}; {MemorySubscriber} is the suite's.
|
|
12
|
+
class Subscriber
|
|
13
|
+
# Seconds the shortest wait lasts. redis-client reads a timeout of 0 as
|
|
14
|
+
# "wait for ever", which is the one thing a polling loop must not do.
|
|
15
|
+
SHORTEST = 0.01
|
|
16
|
+
|
|
17
|
+
# @param url [String]
|
|
18
|
+
# @param channels [Array<String>]
|
|
19
|
+
# @param patterns [Array<String>]
|
|
20
|
+
# @param timeout [Float] seconds to connect and to wait for each confirmation
|
|
21
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
22
|
+
def initialize(url:, channels: [], patterns: [], timeout: Store::TIMEOUT)
|
|
23
|
+
@url = url
|
|
24
|
+
guarded do
|
|
25
|
+
@pubsub = RedisClient.config(url:, timeout:).new_client.pubsub
|
|
26
|
+
@pubsub.call("SUBSCRIBE", *channels) unless channels.empty?
|
|
27
|
+
@pubsub.call("PSUBSCRIBE", *patterns) unless patterns.empty?
|
|
28
|
+
confirm(channels.size + patterns.size, timeout)
|
|
29
|
+
end
|
|
30
|
+
rescue Unavailable
|
|
31
|
+
# The caller never gets this object, so nobody else can close the
|
|
32
|
+
# connection, and a chat retrying a NOPERM every step would pile up
|
|
33
|
+
# one open socket per try until the GC got round to them.
|
|
34
|
+
close
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The next message, waiting up to +timeout+ seconds for one.
|
|
39
|
+
#
|
|
40
|
+
# @param timeout [Float]
|
|
41
|
+
# @return [Array(String, String), nil] its channel and its payload
|
|
42
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
43
|
+
def next_message(timeout)
|
|
44
|
+
loop do
|
|
45
|
+
event = read([timeout, SHORTEST].max) or return nil
|
|
46
|
+
case event.first
|
|
47
|
+
when "message" then return [event[1], event[2]]
|
|
48
|
+
when "pmessage" then return [event[2], event[3]]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [void]
|
|
54
|
+
def close = @pubsub&.close
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Waits until Redis has confirmed every subscription, so a message
|
|
59
|
+
# published the moment this returns is one this connection receives.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def confirm(expected, timeout)
|
|
63
|
+
confirmed = 0
|
|
64
|
+
while confirmed < expected
|
|
65
|
+
event = read(timeout) or raise Unavailable, "Redis at #{Live.mask(@url)} did not confirm a subscription"
|
|
66
|
+
confirmed += 1 if %w[subscribe psubscribe].include?(event.first)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The next event on the connection. redis-client hands a refusal back
|
|
71
|
+
# as an error object rather than raising it, so a `NOPERM` from an ACL
|
|
72
|
+
# that withholds `PSUBSCRIBE` would otherwise reach `event.first` as a
|
|
73
|
+
# NoMethodError and kill the hub's thread without a word.
|
|
74
|
+
#
|
|
75
|
+
# @param timeout [Float]
|
|
76
|
+
# @return [Array, nil]
|
|
77
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
78
|
+
def read(timeout)
|
|
79
|
+
event = guarded { @pubsub.next_event(timeout) }
|
|
80
|
+
guarded { raise event } if event.is_a?(RedisClient::Error)
|
|
81
|
+
event
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Object] the block's value
|
|
85
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
86
|
+
def guarded
|
|
87
|
+
yield
|
|
88
|
+
rescue RedisClient::Error => e
|
|
89
|
+
raise Unavailable, "Redis at #{Live.mask(@url)} is unavailable: #{e.message.sub(/\s*\(redis:.*\)\z/, "")}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/openlanes/live.rb
CHANGED
|
@@ -17,6 +17,11 @@ module OpenLanes
|
|
|
17
17
|
# Redis could not be reached, or stopped answering.
|
|
18
18
|
class Unavailable < Error; end
|
|
19
19
|
|
|
20
|
+
# A stream's consumer group is not there: a Redis that restarted empty
|
|
21
|
+
# (W32) took it with everything else. Whoever reads the group creates it
|
|
22
|
+
# again; nothing is wrong with the connection.
|
|
23
|
+
class GroupMissing < Error; end
|
|
24
|
+
|
|
20
25
|
module_function
|
|
21
26
|
|
|
22
27
|
# W16's order: `--redis-url`, then `.openlanes.yml`'s `redis_url:`, then
|
|
@@ -36,6 +41,16 @@ module OpenLanes
|
|
|
36
41
|
# @return [String]
|
|
37
42
|
def mask(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)([^:@/]*):[^@/]+@}i, '\1\2:***@')
|
|
38
43
|
|
|
44
|
+
# The address with all of its userinfo replaced, user name included, for
|
|
45
|
+
# a page anyone on the network can open. {.mask} keeps the user name
|
|
46
|
+
# because a log read by the person who ran the engine is better for it; a
|
|
47
|
+
# page shown on a shared screen is not.
|
|
48
|
+
#
|
|
49
|
+
# @param url [String]
|
|
50
|
+
# @return [String] `redis://:***@host:6379/0`, or the URL as it was when
|
|
51
|
+
# it carries no userinfo
|
|
52
|
+
def redact(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)[^@/]*@}i, '\1:***@')
|
|
53
|
+
|
|
39
54
|
# @param url [String]
|
|
40
55
|
# @return [OpenLanes::Live::Store]
|
|
41
56
|
def store(url) = Store.new(url:)
|
|
@@ -54,9 +69,64 @@ module OpenLanes
|
|
|
54
69
|
store
|
|
55
70
|
end
|
|
56
71
|
|
|
72
|
+
# The process's {ChoiceJudge}: one, so its answers are shared by every
|
|
73
|
+
# snapshot built here; nil without TYPESAFE_API_KEY, and nil in the suite.
|
|
74
|
+
#
|
|
75
|
+
# @return [OpenLanes::Live::ChoiceJudge, nil]
|
|
76
|
+
def choice_judge
|
|
77
|
+
return @choice_judge if defined?(@choice_judge)
|
|
78
|
+
|
|
79
|
+
@choice_judge = ChoiceJudge.from_env
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param judge [#call, nil] answers `call(text)` with true, false or nil
|
|
83
|
+
# @return [void]
|
|
84
|
+
def choice_judge=(judge)
|
|
85
|
+
@choice_judge = judge
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The process's {ChoiceConverter}: nil without OPENROUTER_API_KEY, and
|
|
89
|
+
# nil in the suite.
|
|
90
|
+
#
|
|
91
|
+
# @return [OpenLanes::Live::ChoiceConverter, nil]
|
|
92
|
+
def choice_converter
|
|
93
|
+
return @choice_converter if defined?(@choice_converter)
|
|
94
|
+
|
|
95
|
+
@choice_converter = ChoiceConverter.from_env
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param converter [#call, nil] answers `call(heading:, body:)` with a
|
|
99
|
+
# {ChoiceConverter::Result}
|
|
100
|
+
# @return [void]
|
|
101
|
+
def choice_converter=(converter)
|
|
102
|
+
@choice_converter = converter
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The process's {Interviewer} (M6): nil without OPENROUTER_API_KEY, and
|
|
106
|
+
# nil in the suite.
|
|
107
|
+
#
|
|
108
|
+
# @return [OpenLanes::Live::Interviewer, nil]
|
|
109
|
+
def interviewer
|
|
110
|
+
return @interviewer if defined?(@interviewer)
|
|
111
|
+
|
|
112
|
+
@interviewer = Interviewer.from_env
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param interviewer [#call, nil] answers `call(idea:)` with
|
|
116
|
+
# {Interviewer::Question}s
|
|
117
|
+
# @return [void]
|
|
118
|
+
def interviewer=(interviewer)
|
|
119
|
+
@interviewer = interviewer
|
|
120
|
+
end
|
|
121
|
+
|
|
57
122
|
# @return [OpenLanes::Live::Session]
|
|
58
123
|
def session(**) = Session.new(**)
|
|
59
124
|
|
|
125
|
+
# The conversation with the run's copilot (W17), on +store+ under +keys+.
|
|
126
|
+
#
|
|
127
|
+
# @return [OpenLanes::Live::Copilot]
|
|
128
|
+
def copilot(**) = Copilot.new(**)
|
|
129
|
+
|
|
60
130
|
# This checkout's keys, for the API server beside its engine.
|
|
61
131
|
#
|
|
62
132
|
# @param root [String]
|
|
@@ -90,7 +160,7 @@ module OpenLanes
|
|
|
90
160
|
# @raise [OpenLanes::Live::Command::Invalid] for the API server's 422
|
|
91
161
|
def enqueue(store:, keys:, input:)
|
|
92
162
|
command = Command.parse(input)
|
|
93
|
-
store.
|
|
163
|
+
EventBus.for(store).append(keys.commands, { json: JSON.generate(command.wire) })
|
|
94
164
|
Reply.new(id: command.id, status: "accepted")
|
|
95
165
|
end
|
|
96
166
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# The bodies of the questions in `blocked.md`: the lines under each
|
|
6
|
+
# `## Bn` heading, up to the next heading of any kind or the next `## An`;
|
|
7
|
+
# and the answers under `## An`, which run to the next `Bn` or `An`.
|
|
8
|
+
#
|
|
9
|
+
# The page reads a body to show it and the engine reads one to rewrite it
|
|
10
|
+
# into choices, so both find its edges here; two readers with two ideas
|
|
11
|
+
# of where a question ends would rewrite text the page never showed.
|
|
12
|
+
# Pure: text in, text out.
|
|
13
|
+
module BlockedFile
|
|
14
|
+
# Any Markdown heading, which ends the body of the question above it.
|
|
15
|
+
HEADING = /^[ \t]{0,3}\#{1,6}[ \t]/
|
|
16
|
+
|
|
17
|
+
# What sits between `A1` and the answer on its heading: a `.`, `:` or
|
|
18
|
+
# `)`, the close of a bold `**A1.**`, or a dash with a space after it.
|
|
19
|
+
# Only that, because the answer's own punctuation is part of it: `-1`
|
|
20
|
+
# and `--no-delete` are decisions, and `1` and `no-delete` the opposite.
|
|
21
|
+
SEPARATOR = /\A[ \t]*[.:)]?\**[ \t]*(?:[-–—][ \t]+)?/
|
|
22
|
+
|
|
23
|
+
# A question or an answer heading, either of which ends an answer.
|
|
24
|
+
BLOCK = Regexp.union(OPEN_BLOCK, ANSWER_BLOCK)
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param text [String] the whole of blocked.md
|
|
29
|
+
# @return [Hash{Integer => String}] each question's number to the
|
|
30
|
+
# lines under its heading; a number written twice keeps the last
|
|
31
|
+
def bodies(text)
|
|
32
|
+
lines = text.to_s.lines
|
|
33
|
+
spans(lines).transform_values { |(from, to)| lines[from...to].join }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The answers waiting under `## An` headings, by number, which is how
|
|
37
|
+
# the harness copies each question and its answer into the asking
|
|
38
|
+
# agent's journal (M3) before the broker drains them.
|
|
39
|
+
#
|
|
40
|
+
# An answer is often its heading: `## A2. Retain, do not delete`, with
|
|
41
|
+
# only who decided under it. So the words after the number are part of
|
|
42
|
+
# it. And it is a person's text, which the page writes verbatim, so a
|
|
43
|
+
# heading inside it (`### Database`) is part of it too; only the next
|
|
44
|
+
# question or answer ends it.
|
|
45
|
+
#
|
|
46
|
+
# @param text [String] the whole of blocked.md
|
|
47
|
+
# @return [Hash{Integer => String}] each answer's number to its heading's
|
|
48
|
+
# words, if any, and the lines under it
|
|
49
|
+
def answers(text)
|
|
50
|
+
lines = text.to_s.lines
|
|
51
|
+
spans(lines, ANSWER_BLOCK, ends: BLOCK).to_h do |number, (from, to)|
|
|
52
|
+
title = lines[from - 1].sub(ANSWER_BLOCK, "").sub(SEPARATOR, "").strip
|
|
53
|
+
[number, "#{"#{title}\n" unless title.empty?}#{lines[from...to].join}"]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +text+ with question +number+'s body replaced by +body+, its heading
|
|
58
|
+
# and everything else as they were.
|
|
59
|
+
#
|
|
60
|
+
# @param text [String] the whole of blocked.md
|
|
61
|
+
# @param number [Integer]
|
|
62
|
+
# @param body [String]
|
|
63
|
+
# @return [String, nil] nil when the file has no such question
|
|
64
|
+
def replace(text, number, body)
|
|
65
|
+
lines = text.to_s.lines
|
|
66
|
+
from, to = spans(lines)[number]
|
|
67
|
+
return nil unless from
|
|
68
|
+
|
|
69
|
+
lines[from - 1] = "#{lines[from - 1].chomp}\n"
|
|
70
|
+
section = ["\n", "#{body.strip}\n"]
|
|
71
|
+
section << "\n" if to < lines.size
|
|
72
|
+
(lines[0...from] + section + lines[to..]).join
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# +text+ with answer +number+ taken out and +note+ put at the top of its
|
|
76
|
+
# question's body, so the question is open again and says why.
|
|
77
|
+
#
|
|
78
|
+
# An answer the broker read and would not fold in otherwise sits under
|
|
79
|
+
# its question for good: the page counts the question answered and
|
|
80
|
+
# hides it, and the broker refuses it every time it is asked. Before
|
|
81
|
+
# the options, because the page reads a body's options off its end.
|
|
82
|
+
#
|
|
83
|
+
# @param text [String] the whole of blocked.md
|
|
84
|
+
# @param number [Integer]
|
|
85
|
+
# @param note [String]
|
|
86
|
+
# @return [String, nil] nil when the file has no such question or answer
|
|
87
|
+
def reject(text, number, note)
|
|
88
|
+
lines = text.to_s.lines
|
|
89
|
+
from, to = spans(lines, ANSWER_BLOCK, ends: BLOCK)[number]
|
|
90
|
+
return nil unless from && bodies(text).key?(number)
|
|
91
|
+
|
|
92
|
+
reopened = (lines[0...(from - 1)] + lines[to..]).join
|
|
93
|
+
replace(reopened, number, "#{note.strip}\n\n#{bodies(reopened)[number].strip}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param lines [Array<String>]
|
|
97
|
+
# @param pattern [Regexp] {OPEN_BLOCK} for questions, {ANSWER_BLOCK}
|
|
98
|
+
# for answers; either kind of heading ends the other's body
|
|
99
|
+
# @param ends [Regexp] what else ends a body: any heading, by default
|
|
100
|
+
# @return [Hash{Integer => Array(Integer, Integer)}] each block's
|
|
101
|
+
# number to the first line under its heading and the line that ends it
|
|
102
|
+
def spans(lines, pattern = OPEN_BLOCK, ends: HEADING)
|
|
103
|
+
found = {}
|
|
104
|
+
open = nil
|
|
105
|
+
lines.each_with_index do |line, i|
|
|
106
|
+
number = line[pattern, 1]
|
|
107
|
+
if number || line.match?(ends) || line.match?(OPEN_BLOCK) || line.match?(ANSWER_BLOCK)
|
|
108
|
+
found[open.first] = [open.last, i] if open
|
|
109
|
+
open = number && [number.to_i, i + 1]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
found[open.first] = [open.last, lines.size] if open
|
|
113
|
+
found
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -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
|