openlanes 3.0.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 +7 -0
- data/agents/hansolo-reviewer.md +59 -0
- data/agents/lando-broker.md +70 -0
- data/agents/leah-researcher.md +60 -0
- data/agents/luke-backend.md +73 -0
- data/agents/palpatine-planner.md +61 -0
- data/agents/r2d2-mechanic.md +57 -0
- data/agents/rey-frontend.md +68 -0
- data/agents/yoda-writer.md +67 -0
- data/bin/factory-e2e +157 -0
- data/bin/plan-number +164 -0
- data/bin/setup +26 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/repo/lib/cart.rb +9 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/repo/lib/cart.rb +9 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/review-medium.yml +60 -0
- data/evals/cases/lando-broker/unblock-fast/fail/plan/blocked.md +9 -0
- data/evals/cases/lando-broker/unblock-fast/fail/plan/spec.md +16 -0
- data/evals/cases/lando-broker/unblock-fast/fail/run.json +1 -0
- data/evals/cases/lando-broker/unblock-fast/pass/plan/spec.md +18 -0
- data/evals/cases/lando-broker/unblock-fast/pass/run.json +1 -0
- data/evals/cases/lando-broker/unblock-fast.yml +44 -0
- data/evals/cases/leah-researcher/research-deep/fail/plan/spec.md +26 -0
- data/evals/cases/leah-researcher/research-deep/fail/run.json +1 -0
- data/evals/cases/leah-researcher/research-deep/pass/plan/spec.md +39 -0
- data/evals/cases/leah-researcher/research-deep/pass/run.json +1 -0
- data/evals/cases/leah-researcher/research-deep.yml +51 -0
- data/evals/cases/leah-researcher/research-fast/fail/plan/spec.md +27 -0
- data/evals/cases/leah-researcher/research-fast/fail/run.json +1 -0
- data/evals/cases/leah-researcher/research-fast/pass/plan/spec.md +23 -0
- data/evals/cases/leah-researcher/research-fast/pass/run.json +1 -0
- data/evals/cases/leah-researcher/research-fast.yml +55 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan-backend.md +7 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan-frontend.md +1 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan.md +3 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/pull-requests.md +8 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/spec.md +8 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/Gemfile +2 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/lib/cart.rb +11 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/luke-backend/build-medium/fail/run.json +1 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan-backend.md +7 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan.md +3 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/pull-requests.md +8 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/spec.md +8 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/Gemfile +2 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/lib/cart.rb +11 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/spec/cart_spec.rb +11 -0
- data/evals/cases/luke-backend/build-medium/pass/run.json +1 -0
- data/evals/cases/luke-backend/build-medium.yml +68 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan-backend.md +6 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/spec.md +14 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-backend.md +32 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan.md +28 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/spec.md +14 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-deep.yml +50 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-backend.md +48 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/spec.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-backend.md +8 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/spec.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-fast.yml +47 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/README.md +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter/formatter.rb +12 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter.rb +15 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/spec/greeter_spec.rb +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/README.md +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/lib/greeter.rb +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/spec/greeter_spec.rb +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-fast.yml +59 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/Gemfile +2 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/lib/greeter.rb +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/spec/greeter_spec.rb +11 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/Gemfile +2 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/lib/greeter.rb +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/spec/greeter_spec.rb +11 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-medium.yml +58 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/plan-frontend.md +7 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/plan.md +4 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/pull-requests.md +8 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/spec.md +5 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/lib/cart.rb +5 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.js +4 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.test.js +3 -0
- data/evals/cases/rey-frontend/ui-medium/fail/run.json +1 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/plan-frontend.md +7 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/plan.md +4 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/pull-requests.md +8 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/spec.md +5 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/lib/cart.rb +3 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.js +4 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.test.js +3 -0
- data/evals/cases/rey-frontend/ui-medium/pass/run.json +1 -0
- data/evals/cases/rey-frontend/ui-medium.yml +55 -0
- data/evals/cases/yoda-writer/spec-medium/fail/plan/plan.md +5 -0
- data/evals/cases/yoda-writer/spec-medium/fail/plan/spec.md +45 -0
- data/evals/cases/yoda-writer/spec-medium/fail/run.json +1 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/plan.md +0 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +63 -0
- data/evals/cases/yoda-writer/spec-medium/pass/run.json +1 -0
- data/evals/cases/yoda-writer/spec-medium.yml +40 -0
- data/exe/depot +1 -0
- data/exe/lanes +1 -0
- data/exe/openlanes +32 -0
- data/lib/openlanes/adapters/base.rb +69 -0
- data/lib/openlanes/adapters/claude.rb +124 -0
- data/lib/openlanes/adapters/codex.rb +33 -0
- data/lib/openlanes/adapters/model.rb +37 -0
- data/lib/openlanes/adapters/pi.rb +22 -0
- data/lib/openlanes/adapters/stream.rb +196 -0
- data/lib/openlanes/adapters.rb +92 -0
- data/lib/openlanes/agents/agent.rb +103 -0
- data/lib/openlanes/agents/profile.rb +57 -0
- data/lib/openlanes/agents/registry.rb +151 -0
- data/lib/openlanes/agents/roster.rb +103 -0
- data/lib/openlanes/agents/routing.rb +88 -0
- data/lib/openlanes/agents.rb +20 -0
- data/lib/openlanes/cli/agents/subcommands/describe.rb +66 -0
- data/lib/openlanes/cli/agents/subcommands/list.rb +20 -0
- data/lib/openlanes/cli/base.rb +263 -0
- data/lib/openlanes/cli/create/create.rb +354 -0
- data/lib/openlanes/cli/docs/docs.rb +40 -0
- data/lib/openlanes/cli/eval/eval.rb +110 -0
- data/lib/openlanes/cli/index/index.rb +40 -0
- data/lib/openlanes/cli/init/init.rb +145 -0
- data/lib/openlanes/cli/linear/linear.rb +36 -0
- data/lib/openlanes/cli/linear/subcommands/import.rb +174 -0
- data/lib/openlanes/cli/linear/subcommands/projects.rb +55 -0
- data/lib/openlanes/cli/list_plans/list_plans.rb +21 -0
- data/lib/openlanes/cli/mail/mail.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/read.rb +43 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +43 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +48 -0
- data/lib/openlanes/cli/resync/subcommands/prs.rb +111 -0
- data/lib/openlanes/cli/run/run.rb +544 -0
- data/lib/openlanes/cli/skills/subcommands/install.rb +92 -0
- data/lib/openlanes/cli/start/start.rb +110 -0
- data/lib/openlanes/cli/state/state.rb +26 -0
- data/lib/openlanes/cli/state/subcommands/show.rb +30 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +48 -0
- data/lib/openlanes/cli/states/states.rb +15 -0
- data/lib/openlanes/cli/unblock/unblock.rb +235 -0
- data/lib/openlanes/cli/version/version.rb +13 -0
- data/lib/openlanes/cli/web/web.rb +100 -0
- data/lib/openlanes/cli/worktree/worktree.rb +85 -0
- data/lib/openlanes/cli.rb +162 -0
- data/lib/openlanes/engine/board.rb +104 -0
- data/lib/openlanes/engine/dispatcher.rb +1297 -0
- data/lib/openlanes/engine/progress_log.rb +125 -0
- data/lib/openlanes/engine/runner.rb +224 -0
- data/lib/openlanes/engine/state_file.rb +190 -0
- data/lib/openlanes/engine/tally.rb +177 -0
- data/lib/openlanes/engine.rb +19 -0
- data/lib/openlanes/evals/case.rb +191 -0
- data/lib/openlanes/evals/changes.rb +67 -0
- data/lib/openlanes/evals/check.rb +224 -0
- data/lib/openlanes/evals/evidence.rb +71 -0
- data/lib/openlanes/evals/live.rb +140 -0
- data/lib/openlanes/evals/recording.rb +38 -0
- data/lib/openlanes/evals/run.rb +34 -0
- data/lib/openlanes/evals/score.rb +24 -0
- data/lib/openlanes/evals/scorer.rb +42 -0
- data/lib/openlanes/evals/verdict.rb +18 -0
- data/lib/openlanes/evals.rb +56 -0
- data/lib/openlanes/execution/checks.rb +73 -0
- data/lib/openlanes/execution/child.rb +228 -0
- data/lib/openlanes/execution/clock.rb +121 -0
- data/lib/openlanes/execution/control.rb +194 -0
- data/lib/openlanes/execution/executor.rb +849 -0
- data/lib/openlanes/execution/prompt.rb +455 -0
- data/lib/openlanes/execution/transcript.rb +531 -0
- data/lib/openlanes/execution.rb +19 -0
- data/lib/openlanes/launcher.rb +69 -0
- data/lib/openlanes/lifecycle/adoption.rb +197 -0
- data/lib/openlanes/lifecycle/brief.rb +288 -0
- data/lib/openlanes/lifecycle/completion.rb +58 -0
- data/lib/openlanes/lifecycle/creator.rb +126 -0
- data/lib/openlanes/lifecycle/lanes.rb +74 -0
- data/lib/openlanes/lifecycle/resync.rb +428 -0
- data/lib/openlanes/lifecycle/unblocker.rb +236 -0
- data/lib/openlanes/lifecycle.rb +28 -0
- data/lib/openlanes/linear/api.rb +224 -0
- data/lib/openlanes/linear/attribution.rb +185 -0
- data/lib/openlanes/linear/fuzzy.rb +68 -0
- data/lib/openlanes/linear/import.rb +314 -0
- data/lib/openlanes/linear/issue.rb +187 -0
- data/lib/openlanes/linear/mapping.rb +116 -0
- data/lib/openlanes/linear/push.rb +197 -0
- data/lib/openlanes/linear/survey.rb +174 -0
- data/lib/openlanes/linear/unit.rb +274 -0
- data/lib/openlanes/linear.rb +41 -0
- data/lib/openlanes/live/claims.rb +71 -0
- data/lib/openlanes/live/command.rb +122 -0
- data/lib/openlanes/live/commands.rb +505 -0
- data/lib/openlanes/live/keys.rb +93 -0
- data/lib/openlanes/live/memory_store.rb +265 -0
- data/lib/openlanes/live/meter.rb +51 -0
- data/lib/openlanes/live/node.rb +88 -0
- data/lib/openlanes/live/publisher.rb +179 -0
- data/lib/openlanes/live/repo.rb +60 -0
- data/lib/openlanes/live/run_record.rb +133 -0
- data/lib/openlanes/live/run_state.rb +101 -0
- data/lib/openlanes/live/session.rb +107 -0
- data/lib/openlanes/live/snapshot.rb +322 -0
- data/lib/openlanes/live/stations.rb +67 -0
- data/lib/openlanes/live/store.rb +277 -0
- data/lib/openlanes/live.rb +109 -0
- data/lib/openlanes/plans/dev_work.rb +56 -0
- data/lib/openlanes/plans/feature.rb +131 -0
- data/lib/openlanes/plans/index.rb +208 -0
- data/lib/openlanes/plans/ledger.rb +255 -0
- data/lib/openlanes/plans/mailbox.rb +164 -0
- data/lib/openlanes/plans/ordinal.rb +92 -0
- data/lib/openlanes/plans/plan_state.rb +291 -0
- data/lib/openlanes/plans/pull_request.rb +29 -0
- data/lib/openlanes/plans/pull_requests.rb +229 -0
- data/lib/openlanes/plans/spec.rb +213 -0
- data/lib/openlanes/plans/state_machine.rb +433 -0
- data/lib/openlanes/plans/status.rb +365 -0
- data/lib/openlanes/plans/subject.rb +133 -0
- data/lib/openlanes/plans/tree.rb +79 -0
- data/lib/openlanes/plans.rb +39 -0
- data/lib/openlanes/presentation/diagram.rb +149 -0
- data/lib/openlanes/presentation/documentation.rb +438 -0
- data/lib/openlanes/presentation/reporter.rb +181 -0
- data/lib/openlanes/presentation/viewer.rb +62 -0
- data/lib/openlanes/presentation.rb +23 -0
- data/lib/openlanes/skills/installer.rb +68 -0
- data/lib/openlanes/skills/skill.rb +60 -0
- data/lib/openlanes/skills.rb +25 -0
- data/lib/openlanes/support/config.rb +217 -0
- data/lib/openlanes/support/frontmatter.rb +38 -0
- data/lib/openlanes/support/markdown.rb +56 -0
- data/lib/openlanes/support/project.rb +79 -0
- data/lib/openlanes/support/ui.rb +671 -0
- data/lib/openlanes/vcs/github.rb +171 -0
- data/lib/openlanes/vcs/publisher.rb +221 -0
- data/lib/openlanes/vcs/worktree.rb +248 -0
- data/lib/openlanes/vcs.rb +19 -0
- data/lib/openlanes/version.rb +10 -0
- data/lib/openlanes/web/app.rb +176 -0
- data/lib/openlanes/web/context.rb +66 -0
- data/lib/openlanes/web/demo.rb +169 -0
- data/lib/openlanes/web/demo_board.rb +286 -0
- data/lib/openlanes/web/events.rb +104 -0
- data/lib/openlanes/web/launcher.rb +143 -0
- data/lib/openlanes/web/server.rb +77 -0
- data/lib/openlanes/web/stores.rb +36 -0
- data/lib/openlanes/web.rb +92 -0
- data/lib/openlanes.rb +151 -0
- data/openlanes.gemspec +87 -0
- data/schemas/plan-state.schema.json +101 -0
- data/skills/openlanes/SKILL.md +48 -0
- data/skills/openlanes-documents/SKILL.md +61 -0
- data/skills/openlanes-documents/references/blocked-template.md +49 -0
- data/skills/openlanes-documents/references/closing-templates.md +59 -0
- data/skills/openlanes-documents/references/plan-template.md +61 -0
- data/skills/openlanes-documents/references/retroactive-spec.md +43 -0
- data/skills/openlanes-documents/references/spec-template.md +45 -0
- data/skills/openlanes-run/SKILL.md +90 -0
- data/skills/openlanes-setup/SKILL.md +64 -0
- data/skills/openlanes-spec/SKILL.md +90 -0
- data/skills/openlanes-status/SKILL.md +32 -0
- data/skills/openlanes-sync/SKILL.md +22 -0
- data/web/dist/assets/apl-B4CMkyY2.js +2 -0
- data/web/dist/assets/asciiarmor-Df11BRmG.js +2 -0
- data/web/dist/assets/asn1-EdZsLKOL.js +2 -0
- data/web/dist/assets/asterisk-B-8jnY81.js +2 -0
- data/web/dist/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- data/web/dist/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- data/web/dist/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- data/web/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
- data/web/dist/assets/clike-B9uivgTg.js +2 -0
- data/web/dist/assets/clojure-BMjYHr_A.js +2 -0
- data/web/dist/assets/cmake-BQqOBYOt.js +2 -0
- data/web/dist/assets/cobol-CWcv1MsR.js +2 -0
- data/web/dist/assets/coffeescript-S37ZYGWr.js +2 -0
- data/web/dist/assets/commonlisp-DBKNyK5s.js +2 -0
- data/web/dist/assets/crystal-SjHAIU92.js +2 -0
- data/web/dist/assets/css-BnMrqG3P.js +2 -0
- data/web/dist/assets/cypher-C_CwsFkJ.js +2 -0
- data/web/dist/assets/d-pRatUO7H.js +2 -0
- data/web/dist/assets/diff-DbItnlRl.js +2 -0
- data/web/dist/assets/dockerfile-BKs6k2Af.js +2 -0
- data/web/dist/assets/dtd-DF_7sFjM.js +2 -0
- data/web/dist/assets/dylan-DwRh75JA.js +2 -0
- data/web/dist/assets/ebnf-CDyGwa7X.js +2 -0
- data/web/dist/assets/ecl-Cabwm37j.js +2 -0
- data/web/dist/assets/eiffel-CnydiIhH.js +2 -0
- data/web/dist/assets/elm-vLlmbW-K.js +2 -0
- data/web/dist/assets/erlang-BNw1qcRV.js +2 -0
- data/web/dist/assets/factor-kuTfRLto.js +2 -0
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/fcl-Kvtd6kyn.js +2 -0
- data/web/dist/assets/forth-Ffai-XNe.js +2 -0
- data/web/dist/assets/fortran-DYz_wnZ1.js +2 -0
- data/web/dist/assets/gas-Bneqetm1.js +2 -0
- data/web/dist/assets/gherkin-heZmZLOM.js +2 -0
- data/web/dist/assets/groovy-D9Dt4D0W.js +2 -0
- data/web/dist/assets/haskell-Cw1EW3IL.js +2 -0
- data/web/dist/assets/haxe-H-WmDvRZ.js +2 -0
- data/web/dist/assets/http-DBlCnlav.js +2 -0
- data/web/dist/assets/idl-BEugSyMb.js +2 -0
- data/web/dist/assets/index-2xPC-eHt.js +2 -0
- data/web/dist/assets/index-8dNhRmiO.js +8 -0
- data/web/dist/assets/index-B7-O9ujQ.js +2 -0
- data/web/dist/assets/index-BdR34cXC.js +2 -0
- data/web/dist/assets/index-Bp7W-ZTq.js +2 -0
- data/web/dist/assets/index-Byyxbh_z.js +4 -0
- data/web/dist/assets/index-C0VmkrO-.js +2 -0
- data/web/dist/assets/index-CI74IkWP.js +198 -0
- data/web/dist/assets/index-CYxcbpRM.js +3 -0
- data/web/dist/assets/index-D0rmJN3h.js +2 -0
- data/web/dist/assets/index-DB4QwuYn.js +2 -0
- data/web/dist/assets/index-DDI7zoAS.js +2 -0
- data/web/dist/assets/index-DGLUuEXi.js +2 -0
- data/web/dist/assets/index-OR-odsl9.js +2 -0
- data/web/dist/assets/index-P3vSkiU-.js +2 -0
- data/web/dist/assets/index-a4EHR-u4.css +1 -0
- data/web/dist/assets/index-mRV4XhJZ.js +2 -0
- data/web/dist/assets/index-popk9wbW.js +2 -0
- data/web/dist/assets/index-uM88Njdj.js +2 -0
- data/web/dist/assets/javascript-iXu5QeM3.js +2 -0
- data/web/dist/assets/julia-DuME0IfC.js +2 -0
- data/web/dist/assets/livescript-BwQOo05w.js +2 -0
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- data/web/dist/assets/lua-VAEuO923.js +2 -0
- data/web/dist/assets/mathematica-DTrFuWx2.js +2 -0
- data/web/dist/assets/mbox-CNhZ1qSd.js +2 -0
- data/web/dist/assets/mirc-CjQqDB4T.js +2 -0
- data/web/dist/assets/mllike-CXdrOF99.js +2 -0
- data/web/dist/assets/modelica-Dc1JOy9r.js +2 -0
- data/web/dist/assets/mscgen-BA5vi2Kp.js +2 -0
- data/web/dist/assets/mumps-BT43cFF4.js +2 -0
- data/web/dist/assets/nginx-DdIZxoE0.js +2 -0
- data/web/dist/assets/nsis-LY0VT_u5.js +2 -0
- data/web/dist/assets/ntriples-BfvgReVJ.js +2 -0
- data/web/dist/assets/octave-Ck1zUtKM.js +2 -0
- data/web/dist/assets/overpass-cyrillic-400-normal-CZsFKZCk.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-400-normal-tXDxWGRy.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-600-normal-B-hc8bdn.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-600-normal-CXB2Mrig.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-800-normal-CP6RWG8X.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-800-normal-PJW4A9xW.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-400-normal-AKUz2ZI5.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-400-normal-C2ZjKngx.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-600-normal-CW4Fknsm.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-600-normal-Zx-mtRSD.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-800-normal-CIth-cA7.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-800-normal-hx3cL3ZN.woff +0 -0
- data/web/dist/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- data/web/dist/assets/overpass-latin-400-normal-c_C8zulu.woff +0 -0
- data/web/dist/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- data/web/dist/assets/overpass-latin-600-normal-DhYQrzaY.woff +0 -0
- data/web/dist/assets/overpass-latin-800-normal-Bzx_XP5l.woff2 +0 -0
- data/web/dist/assets/overpass-latin-800-normal-D6-q4f0l.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-400-normal-BIkXRwl6.woff2 +0 -0
- data/web/dist/assets/overpass-latin-ext-400-normal-DUq_h-_z.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-600-normal-Cyj4Nio0.woff2 +0 -0
- data/web/dist/assets/overpass-latin-ext-600-normal-DQKKMGzp.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-800-normal-D6WKWGNs.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-800-normal-DZ60AB77.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-400-normal-B144qALP.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-400-normal-XK9_jwT8.woff +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-Dgejvo4A.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-DgxwtUZy.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-400-normal-DqAOLzCO.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- data/web/dist/assets/overpass-mono-latin-ext-400-normal-5YIw-E8g.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-ext-400-normal-DBdJzBs_.woff2 +0 -0
- data/web/dist/assets/overpass-mono-vietnamese-400-normal-DrLp_cHj.woff +0 -0
- data/web/dist/assets/overpass-mono-vietnamese-400-normal-jmOeL0rj.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-400-normal-BKRCEiXy.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-400-normal-CCWAWVG7.woff +0 -0
- data/web/dist/assets/overpass-vietnamese-600-normal-CjS-4vQf.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-600-normal-DlMMlPfM.woff +0 -0
- data/web/dist/assets/overpass-vietnamese-800-normal-BB8K9-Vo.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-800-normal-DZUygvj_.woff +0 -0
- data/web/dist/assets/oz-BzwKVEFT.js +2 -0
- data/web/dist/assets/pascal--L3eBynH.js +2 -0
- data/web/dist/assets/perl-CdXCOZ3F.js +2 -0
- data/web/dist/assets/pig-CevX1Tat.js +2 -0
- data/web/dist/assets/powershell-CFHJl5sT.js +2 -0
- data/web/dist/assets/properties-C78fOPTZ.js +2 -0
- data/web/dist/assets/protobuf-ChK-085T.js +2 -0
- data/web/dist/assets/pug-DeIclll2.js +2 -0
- data/web/dist/assets/puppet-DMA9R1ak.js +2 -0
- data/web/dist/assets/python-BuPzkPfP.js +2 -0
- data/web/dist/assets/q-pXgVlZs6.js +2 -0
- data/web/dist/assets/r-B6wPVr8A.js +2 -0
- data/web/dist/assets/rpm-CTu-6PCP.js +2 -0
- data/web/dist/assets/ruby-BK5aLJev.js +2 -0
- data/web/dist/assets/sas-B4kiWyti.js +2 -0
- data/web/dist/assets/scheme-C41bIUwD.js +2 -0
- data/web/dist/assets/shell-CjFT_Tl9.js +2 -0
- data/web/dist/assets/sieve-C3Gn_uJK.js +2 -0
- data/web/dist/assets/simple-mode-GW_nhZxv.js +2 -0
- data/web/dist/assets/smalltalk-CnHTOXQT.js +2 -0
- data/web/dist/assets/solr-DehyRSwq.js +2 -0
- data/web/dist/assets/sparql-DkYu6x3z.js +2 -0
- data/web/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
- data/web/dist/assets/sql-D0XecflT.js +2 -0
- data/web/dist/assets/stex-C3f8Ysf7.js +2 -0
- data/web/dist/assets/stylus-B533Al4x.js +2 -0
- data/web/dist/assets/swift-BzpIVaGY.js +2 -0
- data/web/dist/assets/tcl-DVfN8rqt.js +2 -0
- data/web/dist/assets/textile-CnDTJFAw.js +2 -0
- data/web/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
- data/web/dist/assets/tiki-DGYXhP31.js +2 -0
- data/web/dist/assets/toml-Bm5Em-hy.js +2 -0
- data/web/dist/assets/troff-wAsdV37c.js +2 -0
- data/web/dist/assets/ttcn-CfJYG6tj.js +2 -0
- data/web/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
- data/web/dist/assets/turtle-B1tBg_DP.js +2 -0
- data/web/dist/assets/vb-CmGdzxic.js +2 -0
- data/web/dist/assets/vbscript-BuJXcnF6.js +2 -0
- data/web/dist/assets/velocity-D8B20fx6.js +2 -0
- data/web/dist/assets/verilog-C6RDOZhf.js +2 -0
- data/web/dist/assets/vhdl-lSbBsy5d.js +2 -0
- data/web/dist/assets/webidl-ZXfAyPTL.js +2 -0
- data/web/dist/assets/xquery-DzFWVndE.js +2 -0
- data/web/dist/assets/yacas-BJ4BC0dw.js +2 -0
- data/web/dist/assets/z80-Hz9HOZM7.js +2 -0
- data/web/dist/index.html +14 -0
- data/web/dist/vite.svg +1 -0
- data/web/src/api/fixtures/board.json +512 -0
- metadata +907 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# A plan's number, and therefore its identity. Set once when the folder is
|
|
6
|
+
# created and never changed: branch names, pull request titles and every
|
|
7
|
+
# `pull-requests.md` join on it, and renumbering breaks all of them silently.
|
|
8
|
+
#
|
|
9
|
+
# Always rendered `NNN.MM`. `002.00` is an ordinary plan, specified before it
|
|
10
|
+
# was built. `002.01` is retroactive — work that shipped between 002 and 003
|
|
11
|
+
# and was documented afterwards. The decimal marks a **sibling**, not
|
|
12
|
+
# containment: 002.01 is not part of 002.
|
|
13
|
+
#
|
|
14
|
+
# `000` is where the sequence starts, so the first plan of a project is
|
|
15
|
+
# `000.00`. It also absorbs the older meaning — work that predates the plan
|
|
16
|
+
# discipline — since either way it sorts first, which is where it belongs.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] major
|
|
19
|
+
# @return [Integer] 0..999
|
|
20
|
+
# @!attribute [r] minor
|
|
21
|
+
# @return [Integer] 0..99; zero for an ordinary plan
|
|
22
|
+
class Ordinal < Data.define(:major, :minor)
|
|
23
|
+
include Comparable
|
|
24
|
+
|
|
25
|
+
# The canonical form, plus the bare `NNN` that trees written before this
|
|
26
|
+
# rule still use and must remain readable.
|
|
27
|
+
PATTERN = /\A(\d{1,3})(?:\.(\d{1,2}))?\z/
|
|
28
|
+
|
|
29
|
+
# The most retroactive slots a single gap can hold.
|
|
30
|
+
MAX_MINOR = 99
|
|
31
|
+
|
|
32
|
+
# @param text [String, nil] e.g. "3", "003", "003.00", "018.01"
|
|
33
|
+
# @return [OpenLanes::Plans::Ordinal, nil] nil when it is not a plan number
|
|
34
|
+
def self.parse(text)
|
|
35
|
+
m = PATTERN.match(text.to_s.strip)
|
|
36
|
+
m && new(major: m[1].to_i, minor: m[2].to_i)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Pull the number off the front of a folder name.
|
|
40
|
+
#
|
|
41
|
+
# @param dirname [String] e.g. "018.01-✅--verify-against-filed-returns"
|
|
42
|
+
# @return [OpenLanes::Plans::Ordinal, nil]
|
|
43
|
+
def self.from_dirname(dirname) = parse(dirname.to_s[/\A[\d.]+/])
|
|
44
|
+
|
|
45
|
+
# The next ordinary plan after everything in +existing+.
|
|
46
|
+
#
|
|
47
|
+
# The first plan in an empty tree is `000.00`, not `001.00`: the sequence
|
|
48
|
+
# counts from zero so that the very first specification — usually the
|
|
49
|
+
# project's own — sorts above everything and needs no gap reserved for it.
|
|
50
|
+
#
|
|
51
|
+
# @param existing [Array<OpenLanes::Plans::Ordinal>]
|
|
52
|
+
# @return [OpenLanes::Plans::Ordinal]
|
|
53
|
+
def self.next_major(existing)
|
|
54
|
+
return new(major: 0, minor: 0) if existing.empty?
|
|
55
|
+
|
|
56
|
+
new(major: existing.map(&:major).max + 1, minor: 0)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The next retroactive slot in the gap after +major+.
|
|
60
|
+
#
|
|
61
|
+
# @param existing [Array<OpenLanes::Plans::Ordinal>]
|
|
62
|
+
# @param major [Integer] the plan the work landed after
|
|
63
|
+
# @return [OpenLanes::Plans::Ordinal]
|
|
64
|
+
# @raise [OpenLanes::Error] when the gap is full
|
|
65
|
+
def self.next_minor(existing, major:)
|
|
66
|
+
taken = existing.select { |o| o.major == major }.map(&:minor).max || 0
|
|
67
|
+
if taken >= MAX_MINOR
|
|
68
|
+
raise Error, "all #{MAX_MINOR} retroactive slots after #{format("%03d", major)} are taken"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
new(major:, minor: taken + 1)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] whether this plan was documented after the fact
|
|
75
|
+
def retroactive? = minor.positive?
|
|
76
|
+
|
|
77
|
+
# @return [String] the canonical `NNN.MM`
|
|
78
|
+
def to_s = format("%03d.%02d", major, minor)
|
|
79
|
+
|
|
80
|
+
# @return [String] what a pull request title carries
|
|
81
|
+
def to_prefix = "[#{self}]"
|
|
82
|
+
|
|
83
|
+
# @param other [Object]
|
|
84
|
+
# @return [Integer, nil]
|
|
85
|
+
def <=>(other)
|
|
86
|
+
return nil unless other.is_a?(self.class)
|
|
87
|
+
|
|
88
|
+
[major, minor] <=> [other.major, other.minor]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "time"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
|
|
10
|
+
module OpenLanes
|
|
11
|
+
module Plans
|
|
12
|
+
# `state.json` in a plan folder: what has happened to this plan, in a form
|
|
13
|
+
# a program can read without parsing prose.
|
|
14
|
+
#
|
|
15
|
+
# Three kinds of record live here, written by three kinds of writer:
|
|
16
|
+
#
|
|
17
|
+
# stages the harness: one per agent per round, Started when it is
|
|
18
|
+
# dispatched and settled when it returns, with the adapter,
|
|
19
|
+
# model, effort, tokens and seconds it ran on
|
|
20
|
+
# signatures the agents, through `openlanes state sign`: the ledger
|
|
21
|
+
# lines they used to append to plan.md and spec.md
|
|
22
|
+
# messages the agents, through `openlanes mail send`: what a pair
|
|
23
|
+
# says to each other, and the RESUME notes an interrupted
|
|
24
|
+
# agent leaves for its successor
|
|
25
|
+
#
|
|
26
|
+
# A stage still Started whose harness process is gone is a crash, which is
|
|
27
|
+
# how a restarted run finds what it has to resume.
|
|
28
|
+
#
|
|
29
|
+
# The folder name stays the source of truth for the plan's state; `state`
|
|
30
|
+
# here is the last state the harness moved the folder to, kept so the file
|
|
31
|
+
# is readable on its own.
|
|
32
|
+
#
|
|
33
|
+
# Every write takes a lock, re-reads, changes, and renames a temporary file
|
|
34
|
+
# over the old one, so two agents signing in the same second cannot lose
|
|
35
|
+
# each other's line, and a reader never sees half a file. The lock lives in
|
|
36
|
+
# the system temp dir, keyed by the plan's number, because the folder it
|
|
37
|
+
# protects is renamed under it as the plan moves.
|
|
38
|
+
class PlanState
|
|
39
|
+
FILENAME = "state.json"
|
|
40
|
+
|
|
41
|
+
# Bumped when a field changes meaning. Readers accept older versions.
|
|
42
|
+
VERSION = 1
|
|
43
|
+
|
|
44
|
+
# Where the schema is published, so an editor can validate the file.
|
|
45
|
+
SCHEMA_ID = "https://github.com/kigster/openlanes/blob/main/schemas/plan-state.schema.json"
|
|
46
|
+
|
|
47
|
+
# The schema in this checkout.
|
|
48
|
+
SCHEMA_PATH = File.expand_path("../../../schemas/plan-state.schema.json", __dir__)
|
|
49
|
+
|
|
50
|
+
# @param dir [String] a plan folder
|
|
51
|
+
# @return [OpenLanes::Plans::PlanState]
|
|
52
|
+
def self.for(dir) = new(dir:)
|
|
53
|
+
|
|
54
|
+
# @param dir [String] a plan folder
|
|
55
|
+
def initialize(dir:)
|
|
56
|
+
@dir = dir
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String]
|
|
60
|
+
attr_reader :dir
|
|
61
|
+
|
|
62
|
+
# @return [String]
|
|
63
|
+
def path = File.join(dir, FILENAME)
|
|
64
|
+
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def exist? = File.file?(path)
|
|
67
|
+
|
|
68
|
+
# The whole document, or a fresh one when there is none yet.
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash]
|
|
71
|
+
def read
|
|
72
|
+
return skeleton unless exist?
|
|
73
|
+
|
|
74
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
75
|
+
data.is_a?(Hash) ? skeleton.merge(data) : skeleton
|
|
76
|
+
rescue JSON::ParserError => e
|
|
77
|
+
raise Error, "#{path} is not valid JSON: #{e.message.lines.first.strip}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Change the document under the lock and write it back atomically.
|
|
81
|
+
#
|
|
82
|
+
# @yieldparam data [Hash] mutate in place
|
|
83
|
+
# @return [Hash] what was written
|
|
84
|
+
def update
|
|
85
|
+
locked do
|
|
86
|
+
data = read
|
|
87
|
+
yield data
|
|
88
|
+
data["plan"] = identity
|
|
89
|
+
data["updated_at"] = Time.now.iso8601
|
|
90
|
+
write(data)
|
|
91
|
+
data
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Array<Hash>]
|
|
96
|
+
def stages = read["stages"]
|
|
97
|
+
|
|
98
|
+
# @return [Array<Hash>]
|
|
99
|
+
def signatures = read["signatures"]
|
|
100
|
+
|
|
101
|
+
# @return [Array<Hash>]
|
|
102
|
+
def messages = read["messages"]
|
|
103
|
+
|
|
104
|
+
# @return [String, nil]
|
|
105
|
+
def state = read["state"]
|
|
106
|
+
|
|
107
|
+
# ---- the harness --------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
# Record or update one agent's round. A second call for the same agent
|
|
110
|
+
# and round updates the first, so a round is one record from dispatch
|
|
111
|
+
# to settlement.
|
|
112
|
+
#
|
|
113
|
+
# @param agent [String]
|
|
114
|
+
# @param round [Integer]
|
|
115
|
+
# @param fields [Hash] status, started_at, finished_at, adapter, model,
|
|
116
|
+
# effort, up, down, seconds, note, to, run
|
|
117
|
+
# @return [Hash] the stage as written
|
|
118
|
+
def stage!(agent:, round:, **fields)
|
|
119
|
+
stage = nil
|
|
120
|
+
update do |data|
|
|
121
|
+
stage = data["stages"].find { |s| s["agent"] == agent && s["round"] == round }
|
|
122
|
+
unless stage
|
|
123
|
+
stage = { "agent" => agent, "round" => round }
|
|
124
|
+
data["stages"] << stage
|
|
125
|
+
end
|
|
126
|
+
stage.merge!(fields.transform_keys(&:to_s).compact)
|
|
127
|
+
end
|
|
128
|
+
stage
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param key [Symbol, String] the state the folder was just moved to
|
|
132
|
+
# @return [void]
|
|
133
|
+
def state!(key)
|
|
134
|
+
update { |data| data["state"] = key.to_s }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Stages a dead harness left Started. Nobody is working them, and nobody
|
|
138
|
+
# will finish them unless a run picks them up.
|
|
139
|
+
#
|
|
140
|
+
# @param alive [Proc] pid → Boolean
|
|
141
|
+
# @param pid [Integer] this run, which is never its own crash
|
|
142
|
+
# @return [Array<Hash>]
|
|
143
|
+
def stranded(alive: method(:alive?), pid: Process.pid)
|
|
144
|
+
stages.select do |s|
|
|
145
|
+
run = s["run"] || {}
|
|
146
|
+
s["status"] == "Started" && run["pid"] != pid && run["host"] == Socket.gethostname && !alive.call(run["pid"])
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ---- the agents -------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
# One ledger line, in JSON. What `openlanes state sign` writes.
|
|
153
|
+
#
|
|
154
|
+
# @param agent [String]
|
|
155
|
+
# @param round [Integer]
|
|
156
|
+
# @param status [String] one of {Ledger::STATUSES}
|
|
157
|
+
# @param note [String, nil]
|
|
158
|
+
# @param next_agent [String, nil] only after Completed
|
|
159
|
+
# @param at [Time]
|
|
160
|
+
# @return [Hash] the signature as written
|
|
161
|
+
# @raise [OpenLanes::Error] for a status outside the ledger's vocabulary,
|
|
162
|
+
# or a handoff after anything but Completed
|
|
163
|
+
def sign!(agent:, round:, status:, note: nil, next_agent: nil, at: Time.now)
|
|
164
|
+
raise Error, "status must be one of #{Ledger::STATUSES.join(", ")}" unless Ledger::STATUSES.include?(status)
|
|
165
|
+
raise Error, "only a Completed signature may name who is next" if next_agent && status != "Completed"
|
|
166
|
+
raise Error, "a signature needs an agent name" unless agent.to_s.match?(/\A[\w-]+\z/)
|
|
167
|
+
|
|
168
|
+
signature = { "at" => at.iso8601, "agent" => agent, "status" => status, "round" => Integer(round),
|
|
169
|
+
"note" => note, "next" => next_agent }.compact
|
|
170
|
+
update { |data| data["signatures"] << signature }
|
|
171
|
+
signature
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Append one message and give it the next number.
|
|
175
|
+
#
|
|
176
|
+
# @param from [String]
|
|
177
|
+
# @param to [String]
|
|
178
|
+
# @param body [String]
|
|
179
|
+
# @param at [Time]
|
|
180
|
+
# @param after [Integer] numbers already taken elsewhere, so a plan
|
|
181
|
+
# whose older messages are in mailbox.md keeps counting from there
|
|
182
|
+
# @param command [String, nil] the id of the page's command that sent
|
|
183
|
+
# it. A command is applied at least once (W13), so a second append
|
|
184
|
+
# under the same id, to the same reader, returns the first and adds
|
|
185
|
+
# nothing: an engine that died between the append and recording the
|
|
186
|
+
# reply does not say the same thing twice.
|
|
187
|
+
# @return [Hash]
|
|
188
|
+
def message!(from:, to:, body:, at: Time.now, after: 0, command: nil)
|
|
189
|
+
message = nil
|
|
190
|
+
update do |data|
|
|
191
|
+
message = command && data["messages"].find { |m| m["command"] == command && m["to"] == to }
|
|
192
|
+
next if message
|
|
193
|
+
|
|
194
|
+
number = [data["messages"].map { |m| m["number"] }.max.to_i, after].max + 1
|
|
195
|
+
message = { "number" => number, "at" => at.iso8601, "from" => from, "to" => to, "body" => body }
|
|
196
|
+
message["command"] = command if command
|
|
197
|
+
data["messages"] << message
|
|
198
|
+
end
|
|
199
|
+
message
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The signatures as ledger entries, so the dispatcher reads them exactly
|
|
203
|
+
# as it reads a line in plan.md. Each signature takes two line numbers,
|
|
204
|
+
# the entry and its handoff, so {Ledger.handoff_after} pairs them.
|
|
205
|
+
#
|
|
206
|
+
# @return [OpenLanes::Plans::Ledger::Reading]
|
|
207
|
+
def ledger
|
|
208
|
+
entries = []
|
|
209
|
+
handoffs = []
|
|
210
|
+
signatures.each_with_index do |sig, index|
|
|
211
|
+
at = parse_time(sig["at"])
|
|
212
|
+
line = (index * 2) + 1
|
|
213
|
+
entries << Ledger::Entry.new(at:,
|
|
214
|
+
agent: sig["agent"],
|
|
215
|
+
status: sig["status"],
|
|
216
|
+
round: sig["round"].to_i,
|
|
217
|
+
note: sig["note"],
|
|
218
|
+
file: FILENAME,
|
|
219
|
+
line:)
|
|
220
|
+
handoffs << Ledger::Handoff.new(at:, next: sig["next"], file: FILENAME, line: line + 1) if sig["next"]
|
|
221
|
+
end
|
|
222
|
+
Ledger::Reading.new(entries:, handoffs:, problems: [])
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# @return [Hash]
|
|
228
|
+
def skeleton
|
|
229
|
+
{ "$schema" => SCHEMA_ID, "version" => VERSION, "plan" => identity, "state" => nil,
|
|
230
|
+
"updated_at" => nil, "stages" => [], "signatures" => [], "messages" => [] }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The plan's number and slug, from the folder name it has right now.
|
|
234
|
+
#
|
|
235
|
+
# @return [Hash]
|
|
236
|
+
def identity
|
|
237
|
+
feature = Feature.parse(dir)
|
|
238
|
+
feature ? { "ordinal" => feature.ordinal.to_s, "slug" => feature.slug } : { "ordinal" => nil, "slug" => File.basename(dir) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @param data [Hash]
|
|
242
|
+
# @return [void]
|
|
243
|
+
def write(data)
|
|
244
|
+
tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
245
|
+
File.write(tmp, "#{JSON.pretty_generate(data)}\n")
|
|
246
|
+
File.rename(tmp, path)
|
|
247
|
+
ensure
|
|
248
|
+
FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @return [Object] the block's value
|
|
252
|
+
def locked
|
|
253
|
+
lock = File.join(Dir.tmpdir, "openlanes-plan-#{lock_key}.lock")
|
|
254
|
+
File.open(lock, File::RDWR | File::CREAT, 0o644) do |f|
|
|
255
|
+
f.flock(File::LOCK_EX)
|
|
256
|
+
yield
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The plans directory and the plan's number: stable while the folder's
|
|
261
|
+
# name changes underneath.
|
|
262
|
+
#
|
|
263
|
+
# @return [String]
|
|
264
|
+
def lock_key
|
|
265
|
+
plans = File.expand_path(File.dirname(dir))
|
|
266
|
+
Digest::SHA256.hexdigest("#{plans}\0#{identity["ordinal"] || File.basename(dir)}")[0, 16]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @param text [String, nil]
|
|
270
|
+
# @return [Time]
|
|
271
|
+
def parse_time(text)
|
|
272
|
+
Time.iso8601(text.to_s)
|
|
273
|
+
rescue ArgumentError
|
|
274
|
+
Time.at(0)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# @param pid [Integer, nil]
|
|
278
|
+
# @return [Boolean]
|
|
279
|
+
def alive?(pid)
|
|
280
|
+
return false unless pid.is_a?(Integer) && pid.positive?
|
|
281
|
+
|
|
282
|
+
Process.kill(0, pid)
|
|
283
|
+
true
|
|
284
|
+
rescue Errno::ESRCH
|
|
285
|
+
false
|
|
286
|
+
rescue Errno::EPERM
|
|
287
|
+
true
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# One row of a plan's `pull-requests.md`.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] number
|
|
8
|
+
# @return [String, nil] as written, without the `#`
|
|
9
|
+
# @!attribute [r] title
|
|
10
|
+
# @return [String]
|
|
11
|
+
# @!attribute [r] url
|
|
12
|
+
# @return [String, nil]
|
|
13
|
+
# @!attribute [r] state
|
|
14
|
+
# @return [String] normalised words plus emoji, e.g. "Open 🟡"
|
|
15
|
+
PullRequest = Data.define(:number, :title, :url, :state) do
|
|
16
|
+
# Still awaiting a decision. A closed-unmerged pull request is finished
|
|
17
|
+
# business, so it is neither open nor merged.
|
|
18
|
+
#
|
|
19
|
+
# @return [Boolean]
|
|
20
|
+
def open? = state.match?(/\b(?:open|wip|draft)\b/i)
|
|
21
|
+
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def merged? = state.match?(/\bmerged\b/i) && !state.match?(/\bunmerged\b/i)
|
|
24
|
+
|
|
25
|
+
# @return [String] "#92 — Send mail through Resend"
|
|
26
|
+
def label = number ? "##{number} — #{title}" : title
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# Extracts the pull request roll-up from a plan folder.
|
|
6
|
+
class PullRequests
|
|
7
|
+
# A markdown link whose text may contain escaped brackets, because every
|
|
8
|
+
# title this tool writes now opens with `\[NNN.MM\]`. Matching `[^\]]+`
|
|
9
|
+
# stops at the escaped `]` and loses the URL with it.
|
|
10
|
+
LINK = /\[((?:\\.|[^\]\\])+)\]\((https?:[^)\s]+)\)/
|
|
11
|
+
|
|
12
|
+
# Canonical wording for the states we recognise, most specific first.
|
|
13
|
+
STATES = [
|
|
14
|
+
[/\bclosed\b|\bunmerged\b|\babandoned\b/i, "Closed 🔴"],
|
|
15
|
+
[/\bmerged\b/i, "Merged 🟣"],
|
|
16
|
+
[/\bdraft\b|\bwip\b/i, "WIP 🟡"],
|
|
17
|
+
[/\bopen\b/i, "Open 🟡"]
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Filenames that may hold the table.
|
|
21
|
+
CANDIDATES = %w[pull-requests.md pull_requests.md prs.md].freeze
|
|
22
|
+
|
|
23
|
+
# The file this class writes, and the first one it looks for.
|
|
24
|
+
FILENAME = CANDIDATES.first
|
|
25
|
+
|
|
26
|
+
# Render `pull-requests.md` for a set of pull requests fetched from GitHub.
|
|
27
|
+
#
|
|
28
|
+
# The table is what {#parse} reads back and what the state machine judges
|
|
29
|
+
# a folder by. The descriptions below it are for a reader — human or
|
|
30
|
+
# agent — synthesizing a specification from work that already shipped:
|
|
31
|
+
# the table says *which* pull requests, the bodies say *what they did*,
|
|
32
|
+
# and a retroactive `spec.md` cannot be written from numbers alone.
|
|
33
|
+
#
|
|
34
|
+
# Prose after the table is ignored by the parser, so the two can coexist
|
|
35
|
+
# in one file rather than needing a scratch file the folder rules forbid.
|
|
36
|
+
#
|
|
37
|
+
# @param prs [Array<Hash>] `{number:, title:, url:, state:, body:}`
|
|
38
|
+
# @return [String]
|
|
39
|
+
def self.render(prs)
|
|
40
|
+
rows = prs.map { |pr|
|
|
41
|
+
"| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
<<~MARKDOWN
|
|
45
|
+
# Pull Requests
|
|
46
|
+
|
|
47
|
+
| Pull Request Number | Pull Request Name | Status |
|
|
48
|
+
| ------------------: | :---------------- | -----: |
|
|
49
|
+
#{rows.join("\n")}
|
|
50
|
+
|
|
51
|
+
## What these pull requests did
|
|
52
|
+
|
|
53
|
+
#{prs.map { |pr| describe(pr) }.join("\n")}
|
|
54
|
+
MARKDOWN
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The table alone, for splicing into a file that already says other
|
|
58
|
+
# things: the ledger blocks luke and rey write before a pull request
|
|
59
|
+
# exists. Rewriting the whole file deleted their signatures.
|
|
60
|
+
#
|
|
61
|
+
# @param prs [Array<Hash>]
|
|
62
|
+
# @return [String] header, rule and rows, newline-terminated
|
|
63
|
+
def self.table(prs)
|
|
64
|
+
rows = prs.map { |pr| "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |" }
|
|
65
|
+
["| Pull Request Number | Pull Request Name | Status |",
|
|
66
|
+
"| ------------------: | :---------------- | -----: |", *rows].join("\n") + "\n"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Write the table into +path+ without touching anything else there.
|
|
70
|
+
#
|
|
71
|
+
# @param path [String]
|
|
72
|
+
# @param prs [Array<Hash>]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def self.upsert(path, prs)
|
|
75
|
+
return File.write(path, render(prs)) unless File.file?(path)
|
|
76
|
+
|
|
77
|
+
lines = File.read(path, encoding: "UTF-8").lines
|
|
78
|
+
start = lines.index { |l| l.strip.start_with?("|") && lines[lines.index(l) + 1].to_s.strip.start_with?("|") }
|
|
79
|
+
if start
|
|
80
|
+
stop = start
|
|
81
|
+
stop += 1 while lines[stop + 1]&.strip&.start_with?("|")
|
|
82
|
+
lines[start..stop] = [table(prs)]
|
|
83
|
+
else
|
|
84
|
+
at = lines.index { |l| l.start_with?("# ") }
|
|
85
|
+
insertion = ["\n", table(prs)]
|
|
86
|
+
at ? lines.insert(at + 1, *insertion) : lines.unshift("# Pull Requests\n", *insertion, "\n")
|
|
87
|
+
end
|
|
88
|
+
File.write(path, lines.join)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param pr [Hash]
|
|
92
|
+
# @return [String]
|
|
93
|
+
def self.describe(pr)
|
|
94
|
+
body = pr[:body].to_s.strip
|
|
95
|
+
body = "_No description was written on the pull request._" if body.empty?
|
|
96
|
+
|
|
97
|
+
<<~MARKDOWN
|
|
98
|
+
### ##{pr[:number]} — #{pr[:title]}
|
|
99
|
+
|
|
100
|
+
#{pr[:url]}
|
|
101
|
+
|
|
102
|
+
#{body}
|
|
103
|
+
MARKDOWN
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Three characters have to survive a round trip through a table cell.
|
|
107
|
+
#
|
|
108
|
+
# A `|` ends the cell, and a title containing one is not unusual —
|
|
109
|
+
# "fix: guard against a || b".
|
|
110
|
+
#
|
|
111
|
+
# `[` and `]` are newer and cost more. Every title this tool writes now
|
|
112
|
+
# opens with a plan number, so the row reads `[[013.00] Ship it](url)` —
|
|
113
|
+
# and a markdown link whose text starts with `[` does not parse. The
|
|
114
|
+
# title came back with the URL glued to it and `url` came back nil, which
|
|
115
|
+
# is not a cosmetic loss: it is every pull request link on the page, and
|
|
116
|
+
# the attachment on every Linear issue.
|
|
117
|
+
#
|
|
118
|
+
# @param text [String]
|
|
119
|
+
# @return [String]
|
|
120
|
+
def self.escape(text)
|
|
121
|
+
text.to_s.gsub(/([\[\]|])/) { "\\#{$1}" }.gsub(/\s+/, " ").strip
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @param dir [String] absolute path to the plan folder
|
|
125
|
+
def initialize(dir:)
|
|
126
|
+
@dir = dir
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Array<OpenLanes::Plans::PullRequest>] possibly empty
|
|
130
|
+
def all = @all ||= parse
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# @return [String]
|
|
135
|
+
attr_reader :dir
|
|
136
|
+
|
|
137
|
+
# @return [Array<OpenLanes::Plans::PullRequest>]
|
|
138
|
+
def parse
|
|
139
|
+
path = CANDIDATES.map { |f| File.join(dir, f) }.find { |p| File.file?(p) }
|
|
140
|
+
return [] unless path
|
|
141
|
+
|
|
142
|
+
text = File.read(path, encoding: "UTF-8")
|
|
143
|
+
table = pick_table(Markdown.tables(text))
|
|
144
|
+
return scrape(text) unless table
|
|
145
|
+
|
|
146
|
+
rows = table[:rows].filter_map { |cells| row_to_pr(cells, columns(table[:header])) }
|
|
147
|
+
rows.empty? ? scrape(text) : rows
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param tables [Array<Hash>]
|
|
151
|
+
# @return [Hash, nil]
|
|
152
|
+
def pick_table(tables)
|
|
153
|
+
tables.find { |t|
|
|
154
|
+
t[:header].any? { |h| h.match?(/pull\s*request|\bpr\b|\A#\z|\Anumber\z/i) } &&
|
|
155
|
+
t[:rows].any? { |r| r.any? { |c| c.match?(%r{/(?:pull|merge_requests)/\d+}) } }
|
|
156
|
+
} || tables.find { |t| t[:rows].any? { |r| r.any? { |c| c.match?(%r{/pull/\d+}) } } }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param header [Array<String>]
|
|
160
|
+
# @return [Hash{Symbol => Integer, nil}]
|
|
161
|
+
def columns(header)
|
|
162
|
+
{
|
|
163
|
+
number: header.index { |h| h.match?(/number|\A#\z|\Apr\z|\Apr\s*#/i) },
|
|
164
|
+
title: header.index { |h| h.match?(/name|title|summary|description|scope/i) },
|
|
165
|
+
state: header.index { |h| h.match?(/status|state/i) }
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param cells [Array<String>]
|
|
170
|
+
# @param idx [Hash{Symbol => Integer, nil}]
|
|
171
|
+
# @return [OpenLanes::Plans::PullRequest, nil]
|
|
172
|
+
def row_to_pr(cells, idx)
|
|
173
|
+
title_cell = cells[idx[:title] || 1].to_s
|
|
174
|
+
|
|
175
|
+
# The title comes from the title column and the URL from wherever it is.
|
|
176
|
+
# Scanning every cell for the first link and taking both from it reads
|
|
177
|
+
# `| [#24](...) | Split verified into signed_off |` as an issue called
|
|
178
|
+
# "#24", because the number column is a link too and comes first.
|
|
179
|
+
titled = title_cell.match(LINK)
|
|
180
|
+
link = titled || cells.filter_map { |c| c.match(LINK) }.first
|
|
181
|
+
|
|
182
|
+
url = link && link[2]
|
|
183
|
+
title = titled ? titled[1] : unformat(title_cell)
|
|
184
|
+
title = title.gsub(/\\([\[\]|\\])/, '\1') # undo the escaping {.render} applies
|
|
185
|
+
number = cells[idx[:number] || 0].to_s[/\d+/] || url&.[](%r{/(?:pull|merge_requests)/(\d+)}, 1)
|
|
186
|
+
return nil if title.empty? && url.nil?
|
|
187
|
+
|
|
188
|
+
PullRequest.new(number:,
|
|
189
|
+
url:,
|
|
190
|
+
title: title.empty? ? "Pull request" : title,
|
|
191
|
+
state: normalize(cells[idx[:state] || -1].to_s))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# When there is no parsable table, scrape bare links so a folder that
|
|
195
|
+
# plainly has pull requests does not report none.
|
|
196
|
+
#
|
|
197
|
+
# @param text [String]
|
|
198
|
+
# @return [Array<OpenLanes::Plans::PullRequest>]
|
|
199
|
+
def scrape(text)
|
|
200
|
+
text.scan(%r{https?://\S+?/(?:pull|merge_requests)/(\d+)}).flatten.uniq.map do |num|
|
|
201
|
+
PullRequest.new(number: num,
|
|
202
|
+
title: "Pull request ##{num}",
|
|
203
|
+
state: "Unknown",
|
|
204
|
+
url: text[%r{https?://\S+?/(?:pull|merge_requests)/#{num}\b}])
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Strip the markdown a title was wrapped in without eating the title.
|
|
209
|
+
#
|
|
210
|
+
# An underscore between letters is not emphasis, it is an identifier —
|
|
211
|
+
# `signed_off`, `as_of`, `pull_requests`. Removing every underscore turned
|
|
212
|
+
# "Split verified into signed_off" into "signedoff" on the way to Linear.
|
|
213
|
+
#
|
|
214
|
+
# @param cell [String]
|
|
215
|
+
# @return [String]
|
|
216
|
+
def unformat(cell)
|
|
217
|
+
cell.to_s.gsub(/[*`]/, "").sub(/\A_+/, "").sub(/_+\z/, "").strip
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @param cell [String]
|
|
221
|
+
# @return [String]
|
|
222
|
+
def normalize(cell)
|
|
223
|
+
STATES.each { |pattern, label| return label if cell.match?(pattern) }
|
|
224
|
+
stripped = cell.gsub(/[*_`]/, "").strip
|
|
225
|
+
stripped.empty? ? "Unknown" : stripped
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|