openlanes 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +7 -0
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +84 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1175 -117
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +6 -6
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +12 -120
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +223 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +17 -13
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +60 -11
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +64 -5
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +39 -24
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +226 -8
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +167 -6
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +29 -10
- data/lib/openlanes/web.rb +6 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# Rewrites a question an agent wrote as prose into D3's shape (D9): a
|
|
6
|
+
# short context paragraph and lettered options, exactly one recommended.
|
|
7
|
+
# The page draws a button per option and a text box under them, so a
|
|
8
|
+
# person can answer with one click instead of composing a reply to a
|
|
9
|
+
# paragraph.
|
|
10
|
+
#
|
|
11
|
+
# One call to a small model on OpenRouter, through `inquirex-llm`'s
|
|
12
|
+
# `OpenRouterAdapter`, which asks for a JSON object and checks that both
|
|
13
|
+
# fields came back. What the model returns is not trusted to be D3: it is
|
|
14
|
+
# cleaned, written out with {Plans::Choices.write}, and read back with
|
|
15
|
+
# {Plans::Choices.parse}, and anything that does not survive the trip is
|
|
16
|
+
# {Invalid}.
|
|
17
|
+
#
|
|
18
|
+
# It neither reads nor writes `blocked.md`; {Engine::Conversions} does,
|
|
19
|
+
# so this stays a function of text, and a seam: the suite sets
|
|
20
|
+
# {Live.choice_converter} to nil, and examples about it hand it an
|
|
21
|
+
# adapter double, so nothing in the tests reaches OpenRouter.
|
|
22
|
+
class ChoiceConverter
|
|
23
|
+
# Small and quick, and listed by OpenRouter and by inquirex-llm's own
|
|
24
|
+
# catalogue (`Models::OPENROUTER_ALIASES[:claude_haiku]`).
|
|
25
|
+
# `questions.model` in `.openlanes.yml` replaces it.
|
|
26
|
+
DEFAULT_MODEL = "anthropic/claude-haiku-5.5"
|
|
27
|
+
|
|
28
|
+
# What the model reads with the question.
|
|
29
|
+
PROMPT = <<~PROMPT
|
|
30
|
+
A software agent stopped and wrote the question below for a person. The person answers it in a
|
|
31
|
+
web page that shows one button per option and a free-text box under them. Rewrite the question as
|
|
32
|
+
multiple choice:
|
|
33
|
+
|
|
34
|
+
- `context`: one short paragraph with what the person needs to know to choose. Keep every fact,
|
|
35
|
+
number and name that matters; drop repetition. Do not repeat the heading.
|
|
36
|
+
- `options`: an array of 2 to 5 objects `{"label": String, "recommended": Boolean}`, each a distinct
|
|
37
|
+
answer the question already implies, written as one short line in the agent's own terms.
|
|
38
|
+
Exactly one has `"recommended": true`: the one the question itself prefers, or, if it prefers
|
|
39
|
+
none, the safest one.
|
|
40
|
+
|
|
41
|
+
Never change what is being asked, never add facts the question does not state, never answer it,
|
|
42
|
+
and never add an option for "something else" or "type something instead": the page has a text box.
|
|
43
|
+
PROMPT
|
|
44
|
+
|
|
45
|
+
# What the model answered could not be made into a D3 question.
|
|
46
|
+
class Invalid < Error; end
|
|
47
|
+
|
|
48
|
+
# @!attribute [r] body
|
|
49
|
+
# @return [String] the question's new body, D3-shaped Markdown
|
|
50
|
+
# @!attribute [r] count
|
|
51
|
+
# @return [Integer] how many options it offers
|
|
52
|
+
# @!attribute [r] model
|
|
53
|
+
# @return [String] the model that wrote it
|
|
54
|
+
Result = Data.define(:body, :count, :model)
|
|
55
|
+
|
|
56
|
+
# A converter when OPENROUTER_API_KEY is set, nil otherwise.
|
|
57
|
+
#
|
|
58
|
+
# @param env [#[]]
|
|
59
|
+
# @param config [OpenLanes::Config]
|
|
60
|
+
# @return [OpenLanes::Live::ChoiceConverter, nil]
|
|
61
|
+
def self.from_env(env = ENV, config: Config.current)
|
|
62
|
+
key = env["OPENROUTER_API_KEY"].to_s.strip
|
|
63
|
+
key.empty? ? nil : new(api_key: key, model: config.questions_model || DEFAULT_MODEL)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The model's answer, cleaned and written in D3's shape.
|
|
67
|
+
#
|
|
68
|
+
# @param output [Hash] `context` and `options`, as the adapter parsed them
|
|
69
|
+
# @return [String] the new body
|
|
70
|
+
# @raise [Invalid] when it cannot be one
|
|
71
|
+
def self.render(output)
|
|
72
|
+
output = output.to_h.transform_keys(&:to_sym)
|
|
73
|
+
context = output[:context].to_s.strip
|
|
74
|
+
raise Invalid, "no context" if context.empty?
|
|
75
|
+
# A heading inside the body would end the question there, and a
|
|
76
|
+
# `Bn` or `An` line would start another one.
|
|
77
|
+
if [Plans::BlockedFile::HEADING, Plans::OPEN_BLOCK, Plans::ANSWER_BLOCK].any? { context.match?(it) }
|
|
78
|
+
raise Invalid, "the context holds a heading"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
options = Array(output[:options]).map { option(it) }
|
|
82
|
+
.reject { |label, _| label.match?(Plans::Choices::SOMETHING_ELSE) }
|
|
83
|
+
raise Invalid, "#{options.size} option#{"s" unless options.size == 1}, not 2 or more" if options.size < 2
|
|
84
|
+
|
|
85
|
+
recommended = options.each_index.select { options[it].last }
|
|
86
|
+
raise Invalid, "#{recommended.size} options recommended, not one" unless recommended.size == 1
|
|
87
|
+
|
|
88
|
+
body = Plans::Choices.write(context:, labels: options.map(&:first), recommended: recommended.first)
|
|
89
|
+
back = Plans::Choices.parse(body)
|
|
90
|
+
raise Invalid, "the rewrite does not read back as #{options.size} choices" unless back&.options&.size == options.size
|
|
91
|
+
|
|
92
|
+
body
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One option as a label on one line and whether it is recommended; a
|
|
96
|
+
# recommendation the model wrote into the label is taken out of it, since
|
|
97
|
+
# the flag already says so.
|
|
98
|
+
#
|
|
99
|
+
# @param raw [Hash, Object]
|
|
100
|
+
# @return [Array(String, Boolean)]
|
|
101
|
+
# @raise [Invalid]
|
|
102
|
+
def self.option(raw)
|
|
103
|
+
raise Invalid, "an option is not an object" unless raw.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
raw = raw.transform_keys(&:to_sym)
|
|
106
|
+
label = raw[:label].to_s.gsub(/\s+/, " ").sub(Plans::Choices::RECOMMENDED, " ").strip
|
|
107
|
+
raise Invalid, "an option has no label" if label.empty?
|
|
108
|
+
|
|
109
|
+
[label, [true, "true"].include?(raw[:recommended])]
|
|
110
|
+
end
|
|
111
|
+
private_class_method :option
|
|
112
|
+
|
|
113
|
+
# @param api_key [String, nil] OpenRouter's; only read to build the default adapter
|
|
114
|
+
# @param model [String] an OpenRouter model id
|
|
115
|
+
# @param adapter [#call, nil] answers `call(node, answers)` with a Hash
|
|
116
|
+
# holding `context` and `options`, as `Inquirex::LLM::OpenRouterAdapter`
|
|
117
|
+
# does; built on first use when nil, so a process that never converts
|
|
118
|
+
# never loads inquirex
|
|
119
|
+
def initialize(api_key: nil, model: DEFAULT_MODEL, adapter: nil)
|
|
120
|
+
@api_key = api_key
|
|
121
|
+
@model = model
|
|
122
|
+
@adapter = adapter
|
|
123
|
+
@mutex = Mutex.new
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [String]
|
|
127
|
+
attr_reader :model
|
|
128
|
+
|
|
129
|
+
# Blocks for as long as the request takes (the adapter gives up after
|
|
130
|
+
# 30 seconds without an answer), so the engine calls it on a thread.
|
|
131
|
+
#
|
|
132
|
+
# @param heading [String] the question's heading, for context; it is kept
|
|
133
|
+
# @param body [String] the question as the agent wrote it
|
|
134
|
+
# @return [OpenLanes::Live::ChoiceConverter::Result]
|
|
135
|
+
# @raise [Invalid] when the answer is not a D3 question
|
|
136
|
+
# @raise [StandardError] whatever the adapter raises: no network, a 4xx, bad JSON
|
|
137
|
+
def call(heading:, body:)
|
|
138
|
+
output = adapter.call(node, { heading: heading.to_s, question: body.to_s })
|
|
139
|
+
written = self.class.render(output)
|
|
140
|
+
Result.new(body: written, count: Plans::Choices.parse(written).options.size, model:)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def adapter
|
|
146
|
+
@mutex.synchronize do
|
|
147
|
+
@adapter ||= begin
|
|
148
|
+
require "inquirex-llm"
|
|
149
|
+
Inquirex::LLM::OpenRouterAdapter.new(api_key: @api_key, model:)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def node
|
|
155
|
+
@node ||= begin
|
|
156
|
+
require "inquirex-llm"
|
|
157
|
+
Inquirex::LLM::Node.new(id: :choices,
|
|
158
|
+
verb: :extract,
|
|
159
|
+
prompt: PROMPT,
|
|
160
|
+
schema: Inquirex::LLM::Schema.new(context: :string, options: :array),
|
|
161
|
+
from_steps: %i[heading question])
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# A second opinion on {Plans::Choices}: does this message ask its reader
|
|
8
|
+
# to choose one of the options it lists? Asked of Jev, TypeSafe's System
|
|
9
|
+
# One model, through `inquirex-llm`'s `JevAdapter`, as one boolean.
|
|
10
|
+
#
|
|
11
|
+
# The parser cannot tell "Done so far: 1. … 2. …" from a numbered choice;
|
|
12
|
+
# Jev can. Its answer only ever narrows: `false` takes the buttons away and
|
|
13
|
+
# leaves the text box, `true` keeps them, and `nil` (no answer, an error, a
|
|
14
|
+
# timeout, an answer too unsure to count) leaves the parser's verdict as it
|
|
15
|
+
# was. A missing key therefore costs nothing but the second opinion.
|
|
16
|
+
#
|
|
17
|
+
# The snapshot is rebuilt every time the board moves, so every answer is
|
|
18
|
+
# kept, in this process, by the SHA256 of the text: one request per
|
|
19
|
+
# distinct question, ever. A request still in flight when {#call} gives up
|
|
20
|
+
# waiting goes on in its thread, and the next snapshot finds its answer.
|
|
21
|
+
#
|
|
22
|
+
# A seam, like {Vcs::GitHub} and {Linear::API}: the suite sets
|
|
23
|
+
# {Live.choice_judge} to nil, and examples about the judge hand it an
|
|
24
|
+
# adapter double, so nothing in the tests reaches TypeSafe.
|
|
25
|
+
class ChoiceJudge
|
|
26
|
+
# What Jev reads with the message. Its boolean question is whether
|
|
27
|
+
# `asks_to_choose` is true according to the message.
|
|
28
|
+
PROMPT = <<~PROMPT
|
|
29
|
+
The message below was written by a software agent to a person, who answers it in a web page.
|
|
30
|
+
`asks_to_choose` is true when the message asks the person to pick one of the options it lists
|
|
31
|
+
(lettered or numbered alternatives, or a plain yes/no question), and false when the list is
|
|
32
|
+
something else: steps already done, findings, a plan, or a question that wants a written answer.
|
|
33
|
+
PROMPT
|
|
34
|
+
|
|
35
|
+
# How long a snapshot waits for an answer it does not have yet.
|
|
36
|
+
TIMEOUT = 3.0
|
|
37
|
+
|
|
38
|
+
# Below this Jev is guessing, and the parser's verdict stands.
|
|
39
|
+
MIN_CONFIDENCE = 0.6
|
|
40
|
+
|
|
41
|
+
# The schema field Jev answers.
|
|
42
|
+
FIELD = :asks_to_choose
|
|
43
|
+
|
|
44
|
+
# A judge when TYPESAFE_API_KEY is set, nil otherwise.
|
|
45
|
+
#
|
|
46
|
+
# @param env [#[]]
|
|
47
|
+
# @return [OpenLanes::Live::ChoiceJudge, nil]
|
|
48
|
+
def self.from_env(env = ENV)
|
|
49
|
+
key = env["TYPESAFE_API_KEY"].to_s.strip
|
|
50
|
+
key.empty? ? nil : new(api_key: key)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param api_key [String, nil] TypeSafe's; only read to build the default adapter
|
|
54
|
+
# @param adapter [#call, nil] answers `call(node, answers)` with a Hash
|
|
55
|
+
# holding {FIELD}, as `Inquirex::LLM::JevAdapter` does; built on first
|
|
56
|
+
# use when nil, so a process that never judges never loads inquirex
|
|
57
|
+
# @param timeout [Float] seconds {#call} waits for an answer it lacks
|
|
58
|
+
def initialize(api_key: nil, adapter: nil, timeout: TIMEOUT)
|
|
59
|
+
@api_key = api_key
|
|
60
|
+
@adapter = adapter
|
|
61
|
+
@timeout = timeout
|
|
62
|
+
@answers = {}
|
|
63
|
+
@mutex = Mutex.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param text [String] the question, as the person would read it
|
|
67
|
+
# @return [Boolean, nil] whether it asks to choose; nil when Jev has not
|
|
68
|
+
# said, or could not
|
|
69
|
+
def call(text)
|
|
70
|
+
key = Digest::SHA256.hexdigest(text.to_s)
|
|
71
|
+
asking = @mutex.synchronize { @answers[key] ||= ask(text.to_s) }
|
|
72
|
+
asking.join(@timeout) ? asking.value : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# @return [Thread] whose value is true, false or nil
|
|
78
|
+
def ask(text)
|
|
79
|
+
Thread.new do
|
|
80
|
+
Thread.current.report_on_exception = false
|
|
81
|
+
adapter.call(node, { message: text })[FIELD]
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
UI.log("choice judge: no verdict (#{e.class})")
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def adapter
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
@adapter ||= begin
|
|
91
|
+
require "inquirex-llm"
|
|
92
|
+
Inquirex::LLM::JevAdapter.new(api_key: @api_key, min_confidence: MIN_CONFIDENCE)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def node
|
|
98
|
+
@node ||= begin
|
|
99
|
+
require "inquirex-llm"
|
|
100
|
+
Inquirex::LLM::Node.new(id: :choice,
|
|
101
|
+
verb: :extract,
|
|
102
|
+
prompt: PROMPT,
|
|
103
|
+
schema: Inquirex::LLM::Schema.new(FIELD => :boolean),
|
|
104
|
+
from_steps: [:message])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -39,9 +39,17 @@ module OpenLanes
|
|
|
39
39
|
# Extend every claim this node holds. One that lapsed and was taken by
|
|
40
40
|
# another node in the meantime is dropped from {#held}, not fought for.
|
|
41
41
|
#
|
|
42
|
+
# One that is simply gone is taken back: a Redis that restarted empty
|
|
43
|
+
# (W32) has lost every claim, and reading that as "another machine
|
|
44
|
+
# took it" stopped this machine's own agents for plans nobody else
|
|
45
|
+
# wanted. `SET NX` still loses to a node that got there first.
|
|
46
|
+
#
|
|
42
47
|
# @return [Array<String>] the ordinals lost
|
|
43
48
|
def renew
|
|
44
|
-
lost = held.reject
|
|
49
|
+
lost = held.reject do |ordinal|
|
|
50
|
+
key = @keys.claim(ordinal)
|
|
51
|
+
@store.compare_and_expire(key, @keys.node, @ttl) || @store.set(key, @keys.node, ex: @ttl, nx: true)
|
|
52
|
+
end
|
|
45
53
|
@mutex.synchronize { @held -= lost }
|
|
46
54
|
lost
|
|
47
55
|
end
|
|
@@ -42,25 +42,28 @@ module OpenLanes
|
|
|
42
42
|
|
|
43
43
|
# Every kind the page may send.
|
|
44
44
|
KINDS = %w[answer hold_blocked release_hold resume discard message hold wrap_up compact stop stop_all
|
|
45
|
-
set_model switch_model add_plan create_plans
|
|
45
|
+
set_model switch_model add_plan create_plans respond pause_discuss resume_anyway
|
|
46
|
+
stop_plan restart_plan set_concurrency].freeze
|
|
46
47
|
|
|
47
48
|
# Kinds that act on one plan, and so must name it.
|
|
48
49
|
PLAN_KINDS = %w[answer hold_blocked release_hold resume discard message hold wrap_up compact stop
|
|
49
|
-
set_model switch_model].freeze
|
|
50
|
+
set_model switch_model respond pause_discuss resume_anyway stop_plan restart_plan].freeze
|
|
50
51
|
|
|
51
52
|
# Kinds that act on one agent of that plan, and so must name it too.
|
|
52
53
|
AGENT_KINDS = %w[hold wrap_up compact stop set_model switch_model].freeze
|
|
53
54
|
|
|
54
55
|
# Payload fields each kind cannot do without.
|
|
55
56
|
PAYLOAD = {
|
|
56
|
-
"answer"
|
|
57
|
-
"hold_blocked"
|
|
58
|
-
"release_hold"
|
|
59
|
-
"message"
|
|
60
|
-
"set_model"
|
|
61
|
-
"switch_model"
|
|
62
|
-
"add_plan"
|
|
63
|
-
"create_plans"
|
|
57
|
+
"answer" => %w[question text],
|
|
58
|
+
"hold_blocked" => %w[question],
|
|
59
|
+
"release_hold" => %w[question],
|
|
60
|
+
"message" => %w[text],
|
|
61
|
+
"set_model" => %w[model],
|
|
62
|
+
"switch_model" => %w[model],
|
|
63
|
+
"add_plan" => %w[ordinal],
|
|
64
|
+
"create_plans" => %w[idea],
|
|
65
|
+
"respond" => %w[text],
|
|
66
|
+
"set_concurrency" => %w[limit]
|
|
64
67
|
}.freeze
|
|
65
68
|
|
|
66
69
|
# Ids are ULIDs; this only keeps an absurd one out of a hash field.
|
|
@@ -31,26 +31,49 @@ module OpenLanes
|
|
|
31
31
|
|
|
32
32
|
# Command kind to the method that applies it.
|
|
33
33
|
HANDLERS = {
|
|
34
|
-
"answer"
|
|
35
|
-
"hold_blocked"
|
|
36
|
-
"release_hold"
|
|
37
|
-
"resume"
|
|
38
|
-
"discard"
|
|
39
|
-
"message"
|
|
40
|
-
"hold"
|
|
41
|
-
"wrap_up"
|
|
42
|
-
"compact"
|
|
43
|
-
"stop"
|
|
44
|
-
"stop_all"
|
|
45
|
-
"set_model"
|
|
46
|
-
"switch_model"
|
|
47
|
-
"add_plan"
|
|
48
|
-
"create_plans"
|
|
34
|
+
"answer" => :answer,
|
|
35
|
+
"hold_blocked" => :hold_blocked,
|
|
36
|
+
"release_hold" => :release_hold,
|
|
37
|
+
"resume" => :resume,
|
|
38
|
+
"discard" => :discard,
|
|
39
|
+
"message" => :message,
|
|
40
|
+
"hold" => :signal,
|
|
41
|
+
"wrap_up" => :signal,
|
|
42
|
+
"compact" => :compact,
|
|
43
|
+
"stop" => :signal,
|
|
44
|
+
"stop_all" => :stop_all,
|
|
45
|
+
"set_model" => :set_model,
|
|
46
|
+
"switch_model" => :switch_model,
|
|
47
|
+
"add_plan" => :add_plan,
|
|
48
|
+
"create_plans" => :create_plans,
|
|
49
|
+
"respond" => :respond,
|
|
50
|
+
"pause_discuss" => :pause_discuss,
|
|
51
|
+
"resume_anyway" => :resume_anyway,
|
|
52
|
+
"stop_plan" => :stop_plan,
|
|
53
|
+
"restart_plan" => :restart_plan,
|
|
54
|
+
"set_concurrency" => :set_concurrency
|
|
49
55
|
}.freeze
|
|
50
56
|
|
|
57
|
+
# How long "Resume anyway" lifts a 💣's halt.
|
|
58
|
+
RESUME_FOR = 24 * 3600
|
|
59
|
+
|
|
51
60
|
# Plans a person may not add to a run: there is nothing left to do.
|
|
52
61
|
FINISHED = Snapshot::FINISHED
|
|
53
62
|
|
|
63
|
+
# The copilot turn that answers a slash command: `"<id>.1"`, the turn
|
|
64
|
+
# the page's "reading" waits for. {Web::Demo} writes the same one, so
|
|
65
|
+
# the demo's copilot ends its wait as the engine's does.
|
|
66
|
+
#
|
|
67
|
+
# @param reply [OpenLanes::Live::Reply]
|
|
68
|
+
# @return [OpenLanes::Live::Copilot::Turn]
|
|
69
|
+
def self.echo_turn(reply)
|
|
70
|
+
Copilot::Turn.new(id: "#{reply.id}.1",
|
|
71
|
+
from: "copilot",
|
|
72
|
+
kind: "text",
|
|
73
|
+
text: [reply.status, reply.reason].compact.join(": "),
|
|
74
|
+
at: reply.at)
|
|
75
|
+
end
|
|
76
|
+
|
|
54
77
|
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
55
78
|
# @param keys [OpenLanes::Live::Keys]
|
|
56
79
|
# @param node [OpenLanes::Live::Node]
|
|
@@ -106,9 +129,19 @@ module OpenLanes
|
|
|
106
129
|
@ready = true
|
|
107
130
|
end
|
|
108
131
|
|
|
132
|
+
# A group that went with a Redis restarted empty is made again, and
|
|
133
|
+
# the read tried once more; that it vanished once is no reason to stop
|
|
134
|
+
# the engine, as it did (#59).
|
|
135
|
+
#
|
|
109
136
|
# @param id [String] "0" for this consumer's pending, ">" for new
|
|
110
137
|
# @return [Array<OpenLanes::Live::Store::Entry>]
|
|
111
|
-
def read(id)
|
|
138
|
+
def read(id)
|
|
139
|
+
@store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
|
|
140
|
+
rescue GroupMissing
|
|
141
|
+
@ready = false
|
|
142
|
+
prepare
|
|
143
|
+
@store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
|
|
144
|
+
end
|
|
112
145
|
|
|
113
146
|
# Answer the commands whose work finished since the last tick. A
|
|
114
147
|
# command still at work is neither answered nor acknowledged, so an
|
|
@@ -135,7 +168,7 @@ module OpenLanes
|
|
|
135
168
|
# the page is part of the run's story, beside what the agents did.
|
|
136
169
|
UI.log("from the page: #{command.kind.tr("_", " ")}", **{ plan: command.plan, agent: command.agent }.compact)
|
|
137
170
|
status, reason = apply(command, dispatcher)
|
|
138
|
-
return reply(entry, command.id, status, reason) unless status == :pending
|
|
171
|
+
return reply(entry, command.id, status, reason, echo: command.payload["source"] == "copilot") unless status == :pending
|
|
139
172
|
|
|
140
173
|
@pending[entry.id] = [entry, command.id, reason]
|
|
141
174
|
nil
|
|
@@ -157,25 +190,36 @@ module OpenLanes
|
|
|
157
190
|
["refused", "#{command.kind} failed: #{e.message}"]
|
|
158
191
|
end
|
|
159
192
|
|
|
193
|
+
# A command the copilot sent (a slash command, plan 020.00) is also
|
|
194
|
+
# answered in the conversation, as turn `"<id>.1"`: that is the turn the
|
|
195
|
+
# page's "reading" waits for, and the conversation keeps the outcome.
|
|
196
|
+
# It is written in the same `MULTI`, so a redelivery, which
|
|
197
|
+
# {#answer_again} handles, can never write it twice.
|
|
198
|
+
#
|
|
199
|
+
# @param echo [Boolean] whether to answer in the copilot stream too
|
|
160
200
|
# @return [OpenLanes::Live::Reply]
|
|
161
|
-
def reply(entry, id, status, reason)
|
|
201
|
+
def reply(entry, id, status, reason, echo: false)
|
|
162
202
|
reply = Reply.new(id:, status:, reason:, at: @clock.call)
|
|
163
203
|
json = reply.to_json
|
|
164
|
-
|
|
204
|
+
turn = Commands.echo_turn(reply) if echo
|
|
205
|
+
EventBus.for(@store).transaction do |tx|
|
|
165
206
|
tx.hset(@keys.done, id, json)
|
|
166
|
-
tx.
|
|
207
|
+
tx.append(@keys.replies, { json: }, maxlen: Keys::REPLIES_LENGTH)
|
|
208
|
+
tx.append(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH) if turn
|
|
167
209
|
tx.xack(@keys.commands, Keys::GROUP, entry.id)
|
|
168
210
|
@record&.write(tx)
|
|
169
211
|
end
|
|
212
|
+
News.publish(store: @store, keys: @keys, kind: "reply")
|
|
170
213
|
reply
|
|
171
214
|
end
|
|
172
215
|
|
|
173
216
|
# @return [OpenLanes::Live::Reply] the first reply, sent again
|
|
174
217
|
def answer_again(entry, first)
|
|
175
|
-
@store.
|
|
176
|
-
tx.
|
|
218
|
+
EventBus.for(@store).transaction do |tx|
|
|
219
|
+
tx.append(@keys.replies, { json: first }, maxlen: Keys::REPLIES_LENGTH)
|
|
177
220
|
tx.xack(@keys.commands, Keys::GROUP, entry.id)
|
|
178
221
|
end
|
|
222
|
+
News.publish(store: @store, keys: @keys, kind: "reply")
|
|
179
223
|
data = JSON.parse(first)
|
|
180
224
|
Reply.new(id: data["id"], status: data["status"], reason: data["reason"], at: Time.iso8601(data["at"]))
|
|
181
225
|
end
|
|
@@ -217,17 +261,25 @@ module OpenLanes
|
|
|
217
261
|
|
|
218
262
|
# `## A<n>` under the question, signed, for `lando-broker` to fold in
|
|
219
263
|
# (W10). The dispatcher hands the plan to the broker once every
|
|
220
|
-
# question has an answer, and the plan leaves
|
|
264
|
+
# question has an answer, and the plan leaves ⭕ when the file is
|
|
221
265
|
# drained. The answer carries the command's id in a comment, so a
|
|
222
266
|
# command applied again after a crash finds it and writes nothing.
|
|
223
267
|
#
|
|
224
268
|
# @return [String, nil]
|
|
225
269
|
def answer(command, dispatcher)
|
|
270
|
+
return extension(command, dispatcher) if command["question"].to_s.match?(/\AX\d+\z/i)
|
|
271
|
+
return quality(command, dispatcher) if command["question"].to_s.casecmp?(Engine::QualityQuestion::NAME)
|
|
272
|
+
|
|
226
273
|
subject, question = question!(command, dispatcher)
|
|
227
|
-
|
|
228
|
-
return nil if File.read(path).include?(marker(command))
|
|
274
|
+
return nil if File.read(File.join(subject.feature.path, "blocked.md")).include?(marker(command))
|
|
229
275
|
return "B#{question.number} already has an answer waiting" if question.answered
|
|
230
276
|
|
|
277
|
+
write_answer(subject, question, command, dispatcher)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @return [nil]
|
|
281
|
+
def write_answer(subject, question, command, dispatcher)
|
|
282
|
+
path = File.join(subject.feature.path, "blocked.md")
|
|
231
283
|
File.open(path, "a") do |file|
|
|
232
284
|
file.write("\n## A#{question.number}\n\n#{command["text"]}\n\n" \
|
|
233
285
|
"Answered by #{@node.user} on #{@clock.call.strftime("%Y-%m-%d")}\n#{marker(command)}\n")
|
|
@@ -237,6 +289,83 @@ module OpenLanes
|
|
|
237
289
|
nil
|
|
238
290
|
end
|
|
239
291
|
|
|
292
|
+
# An answer to the harness's request for more time or rounds (E1).
|
|
293
|
+
# The page sends the chosen option, "b) Extend Time by 10 minutes";
|
|
294
|
+
# only its letter counts, so there is nothing to parse but that.
|
|
295
|
+
#
|
|
296
|
+
# @return [String]
|
|
297
|
+
def extension(command, dispatcher)
|
|
298
|
+
subject = subject!(command, dispatcher)
|
|
299
|
+
number = command["question"][1..].to_i
|
|
300
|
+
letter = command["text"].to_s.strip[/\A([a-c])\)?(?:\s|\z)/i, 1] or
|
|
301
|
+
raise Refusal, "X#{number} takes a), b) or c), not #{command["text"].to_s.strip[0, 40].inspect}"
|
|
302
|
+
settled = dispatcher.answer_extension(subject, number, letter.downcase, by: @node.user) or
|
|
303
|
+
raise Refusal, "#{command.plan} has no open request X#{number}"
|
|
304
|
+
settled["grant"] == "live" ? "#{settled["agent"]} has #{settled["seconds"] / 60} more minutes" : "#{settled["agent"]} gets one more round"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# R4's answer: a) high or b) mvp, written into `.openlanes.yml` (U3).
|
|
308
|
+
# Only the letter counts, as for {#extension}.
|
|
309
|
+
#
|
|
310
|
+
# @return [String]
|
|
311
|
+
def quality(command, dispatcher)
|
|
312
|
+
subject = subject!(command, dispatcher)
|
|
313
|
+
name = Engine::QualityQuestion::NAME
|
|
314
|
+
letter = command["text"].to_s.strip[/\A([ab])\)?(?:\s|\z)/i, 1] or
|
|
315
|
+
raise Refusal, "#{name} takes a) or b), not #{command["text"].to_s.strip[0, 40].inspect}"
|
|
316
|
+
chosen = Engine::QualityQuestion.answer!(subject.feature.path, letter, by: @node.user, root: File.dirname(dispatcher.runner.tree.dir)) or
|
|
317
|
+
raise Refusal, "#{command.plan} has no open question #{name}"
|
|
318
|
+
dispatcher.wake!
|
|
319
|
+
"desired_codebase_quality: #{chosen} in #{Config::FILE}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# ---- bombs ------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
# The 💣 notice's answer box (plan 009): the first open question with
|
|
325
|
+
# no answer yet gets this one, in exactly the form {#answer} writes,
|
|
326
|
+
# so the drain that clears a ⭕ clears the 💣 too.
|
|
327
|
+
#
|
|
328
|
+
# @return [String, nil]
|
|
329
|
+
def respond(command, dispatcher)
|
|
330
|
+
subject = bomb!(command, dispatcher)
|
|
331
|
+
return nil if File.read(File.join(subject.feature.path, "blocked.md")).include?(marker(command))
|
|
332
|
+
|
|
333
|
+
question = Lifecycle::Unblocker.questions(subject).reject(&:answered).first or
|
|
334
|
+
raise Refusal, "every question #{command.plan} asks already has an answer waiting"
|
|
335
|
+
write_answer(subject, question, command, dispatcher)
|
|
336
|
+
"answered B#{question.number}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Pause and Discuss (N10): every agent on this machine holds, and the
|
|
340
|
+
# page opens the copilot on the plan. No file changes.
|
|
341
|
+
#
|
|
342
|
+
# @return [String]
|
|
343
|
+
def pause_discuss(command, dispatcher)
|
|
344
|
+
bomb!(command, dispatcher)
|
|
345
|
+
held = dispatcher.running.map(&:key).select { dispatcher.signal(it, Execution::Control::HOLD) }
|
|
346
|
+
held.each { @run_state.hold_car(it, held: true) }
|
|
347
|
+
"held #{held.size} agent#{"s" unless held.size == 1}; discuss #{command.plan} in the copilot"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Resume anyway for {RESUME_FOR}: the halt this 💣 causes lapses on its
|
|
351
|
+
# own, with no config edit to undo.
|
|
352
|
+
#
|
|
353
|
+
# @return [String]
|
|
354
|
+
def resume_anyway(command, dispatcher)
|
|
355
|
+
subject = bomb!(command, dispatcher)
|
|
356
|
+
till = Plans::PlanState.for(subject.feature.path).resume_anyway!(till: @clock.call + RESUME_FOR)
|
|
357
|
+
dispatcher.wake!
|
|
358
|
+
"#{command.plan} halts nothing until #{till.utc.iso8601}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @return [OpenLanes::Plans::Subject] the plan, refused unless it is 💣
|
|
362
|
+
def bomb!(command, dispatcher)
|
|
363
|
+
subject = subject!(command, dispatcher)
|
|
364
|
+
raise Refusal, "#{command.plan} is #{subject.status}, not a 💣" unless subject.status.key == :bomb
|
|
365
|
+
|
|
366
|
+
subject
|
|
367
|
+
end
|
|
368
|
+
|
|
240
369
|
# @return [nil]
|
|
241
370
|
def hold_blocked(command, dispatcher) = hold_question(command, dispatcher, held: true)
|
|
242
371
|
|
|
@@ -417,7 +546,7 @@ module OpenLanes
|
|
|
417
546
|
nil
|
|
418
547
|
end
|
|
419
548
|
|
|
420
|
-
# The idea becomes a
|
|
549
|
+
# The idea becomes a ⚪ plan whose spec.md opens with it, then joins the run.
|
|
421
550
|
#
|
|
422
551
|
# @return [String]
|
|
423
552
|
def create_plans(command, dispatcher)
|
|
@@ -432,10 +561,10 @@ module OpenLanes
|
|
|
432
561
|
->(problem) { raise Refusal, "could not create the plan: #{problem}" }
|
|
433
562
|
)
|
|
434
563
|
feature = Plans::Feature.parse(path)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
564
|
+
seed = [idea, decisions(command.payload["decisions"]), marker(command)].compact.join("\n\n")
|
|
565
|
+
brief = Lifecycle.brief(path:, title: feature.title, root: File.dirname(tree.dir), seed:)
|
|
566
|
+
brief.write_scaffold!
|
|
567
|
+
brief.stamp!(idea_frontmatter(command.payload["frontmatter"]))
|
|
439
568
|
created(feature, dispatcher)
|
|
440
569
|
end
|
|
441
570
|
|
|
@@ -462,6 +591,22 @@ module OpenLanes
|
|
|
462
591
|
end
|
|
463
592
|
end
|
|
464
593
|
|
|
594
|
+
# The interview's answers (M6) as a `## Decisions` section of the
|
|
595
|
+
# spec, so the agents downstream find them made instead of blocking to
|
|
596
|
+
# ask. A skipped interview sends none, and the spec is as it was.
|
|
597
|
+
#
|
|
598
|
+
# @param list [Array<Hash>, nil] `payload.decisions`: `question`, `answer`
|
|
599
|
+
# @return [String, nil]
|
|
600
|
+
def decisions(list)
|
|
601
|
+
rows = Array(list).filter_map do |entry|
|
|
602
|
+
next unless entry.is_a?(Hash)
|
|
603
|
+
|
|
604
|
+
question, answer = entry.transform_keys(&:to_s).values_at("question", "answer").map { it.to_s.gsub(/\s+/, " ").strip }
|
|
605
|
+
"- **#{question}** #{answer}" unless question.empty? || answer.empty?
|
|
606
|
+
end
|
|
607
|
+
"## Decisions\n\n#{rows.join("\n")}" unless rows.empty?
|
|
608
|
+
end
|
|
609
|
+
|
|
465
610
|
# @return [String]
|
|
466
611
|
def created(feature, dispatcher)
|
|
467
612
|
claim!(feature.ordinal)
|
|
@@ -491,6 +636,48 @@ module OpenLanes
|
|
|
491
636
|
json ? JSON.parse(json).values_at("user", "host").join("@") : "another machine"
|
|
492
637
|
end
|
|
493
638
|
|
|
639
|
+
# ---- lanes (plan 020.00) ----------------------------------------------
|
|
640
|
+
|
|
641
|
+
# `/stop NNN`: every agent on the plan stops, and the plan leaves the board.
|
|
642
|
+
#
|
|
643
|
+
# @return [String]
|
|
644
|
+
def stop_plan(command, dispatcher)
|
|
645
|
+
ordinal = ordinal!(command)
|
|
646
|
+
told = dispatcher.stop_plan(ordinal)
|
|
647
|
+
told.each { @run_state.hold_car(it, held: false) }
|
|
648
|
+
"stopped #{told.size} agent#{"s" unless told.size == 1} on #{ordinal} and took it off the board"
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# `/restart NNN`: a lane that lost its agent is dispatched again.
|
|
652
|
+
#
|
|
653
|
+
# @return [String]
|
|
654
|
+
def restart_plan(command, dispatcher)
|
|
655
|
+
ordinal = ordinal!(command)
|
|
656
|
+
dispatcher.restart_plan(ordinal)
|
|
657
|
+
"#{ordinal} restarts at the next tick"
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# `/concurrency N`. Lowering it stops nobody; the run waits for agents
|
|
661
|
+
# to finish until it is under the new limit.
|
|
662
|
+
#
|
|
663
|
+
# @return [String]
|
|
664
|
+
def set_concurrency(command, dispatcher)
|
|
665
|
+
limit = command.payload["limit"]
|
|
666
|
+
raise Refusal, "concurrency must be a whole number of at least 1, not #{limit.inspect}" unless limit.is_a?(Integer) && limit >= 1
|
|
667
|
+
|
|
668
|
+
before = dispatcher.runner.jobs
|
|
669
|
+
dispatcher.concurrency = limit
|
|
670
|
+
@run_state.concurrency = limit
|
|
671
|
+
over = dispatcher.running.size - limit
|
|
672
|
+
wait = over.positive? ? "; the run waits for #{over} agent#{"s" unless over == 1} to finish" : ""
|
|
673
|
+
"concurrency #{before} → #{limit}#{wait}"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# @return [String] the plan's ordinal, normalised
|
|
677
|
+
def ordinal!(command)
|
|
678
|
+
(Plans::Ordinal.parse(command.plan.to_s) or raise Refusal, "#{command.plan} is not a plan number").to_s
|
|
679
|
+
end
|
|
680
|
+
|
|
494
681
|
# ---- lookups ----------------------------------------------------------
|
|
495
682
|
|
|
496
683
|
# @return [OpenLanes::Plans::Subject]
|