@opengsd/gsd-path 1.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.
- package/AGENTS.md +397 -0
- package/DOCS.md +341 -0
- package/FULL.md +475 -0
- package/GUIDE.md +23 -0
- package/HOOKS.md +273 -0
- package/LICENSE +21 -0
- package/MIGRATE.md +100 -0
- package/QUICK.md +146 -0
- package/README.md +280 -0
- package/RUNTIME.md +229 -0
- package/UPDATE.md +196 -0
- package/WORKFLOW.md +683 -0
- package/package.json +80 -0
- package/platforms/claude/dispatch.md +63 -0
- package/platforms/copilot/dispatch.md +61 -0
- package/platforms/cursor/agent.md +17 -0
- package/platforms/cursor/dispatch.md +63 -0
- package/platforms/grok/dispatch.md +61 -0
- package/platforms/kimi/dispatch.md +64 -0
- package/platforms/kiro/dispatch.md +58 -0
- package/platforms/opencode/dispatch.md +62 -0
- package/platforms/qwen/dispatch.md +60 -0
- package/platforms/shared-agents/dispatch.md +131 -0
- package/plugin.json +14 -0
- package/scripts/_common.py +180 -0
- package/scripts/archive_milestone.py +2265 -0
- package/scripts/bootstrap_repository.py +710 -0
- package/scripts/build_state.py +854 -0
- package/scripts/check_docs_audit.py +463 -0
- package/scripts/check_handoffs.py +1855 -0
- package/scripts/check_task_briefs.py +376 -0
- package/scripts/check_update.py +93 -0
- package/scripts/core_hook_gate.py +34 -0
- package/scripts/core_hook_settings.py +119 -0
- package/scripts/detect_project.py +1517 -0
- package/scripts/discussion_records.py +619 -0
- package/scripts/discussion_validate.py +1321 -0
- package/scripts/dispatch_driver.py +1812 -0
- package/scripts/git_guard.py +759 -0
- package/scripts/guard_hook.py +2124 -0
- package/scripts/install.mjs +3239 -0
- package/scripts/install.py +3008 -0
- package/scripts/integration.py +1433 -0
- package/scripts/isolation.py +3538 -0
- package/scripts/lean_verification.py +256 -0
- package/scripts/loop_run.py +837 -0
- package/scripts/migrate_core.py +169 -0
- package/scripts/pipeline_diagnose.py +715 -0
- package/scripts/pipeline_git.py +940 -0
- package/scripts/pipeline_state.py +2291 -0
- package/scripts/pipeline_undo.py +1039 -0
- package/scripts/promote_lookahead.py +415 -0
- package/scripts/review_findings.py +714 -0
- package/scripts/review_panel.py +553 -0
- package/scripts/skill-resources.json +340 -0
- package/scripts/state_checkpoint.py +1039 -0
- package/scripts/state_promote.py +743 -0
- package/scripts/status_runtime.py +122 -0
- package/scripts/sync_skill_resources.py +259 -0
- package/scripts/token_budget.py +146 -0
- package/scripts/wizard.mjs +179 -0
- package/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/BUILD.md +700 -0
- package/skills/gsd-path/DECIDE.md +115 -0
- package/skills/gsd-path/DEFINE.md +297 -0
- package/skills/gsd-path/DOCS-AUDIT.md +190 -0
- package/skills/gsd-path/INSPECT.md +167 -0
- package/skills/gsd-path/PLAN.md +411 -0
- package/skills/gsd-path/RESEARCH.md +159 -0
- package/skills/gsd-path/ROADMAP.md +215 -0
- package/skills/gsd-path/SHIP.md +412 -0
- package/skills/gsd-path/SKILL.md +471 -0
- package/skills/gsd-path/agents/openai.yaml +6 -0
- package/skills/gsd-path/references/codebase-mapper.md +52 -0
- package/skills/gsd-path/references/coder.md +90 -0
- package/skills/gsd-path/references/decider.md +36 -0
- package/skills/gsd-path/references/dispatch.md +131 -0
- package/skills/gsd-path/references/docs-auditor.md +60 -0
- package/skills/gsd-path/references/planner.md +104 -0
- package/skills/gsd-path/references/researcher.md +42 -0
- package/skills/gsd-path/references/reviewer.md +188 -0
- package/skills/gsd-path/references/roadmapper.md +48 -0
- package/skills/gsd-path/scripts/_common.py +180 -0
- package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
- package/skills/gsd-path/scripts/build_state.py +854 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path/scripts/check_update.py +93 -0
- package/skills/gsd-path/scripts/detect_project.py +1517 -0
- package/skills/gsd-path/scripts/discussion_records.py +619 -0
- package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path/scripts/integration.py +1433 -0
- package/skills/gsd-path/scripts/isolation.py +3538 -0
- package/skills/gsd-path/scripts/lean_verification.py +256 -0
- package/skills/gsd-path/scripts/loop_run.py +837 -0
- package/skills/gsd-path/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path/scripts/review_findings.py +714 -0
- package/skills/gsd-path/scripts/review_panel.py +553 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path/scripts/state_promote.py +743 -0
- package/skills/gsd-path/scripts/token_budget.py +146 -0
- package/skills/gsd-path/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/templates/answers.md +29 -0
- package/skills/gsd-path/templates/archive-manifest.md +37 -0
- package/skills/gsd-path/templates/charter.md +50 -0
- package/skills/gsd-path/templates/codebase.md +46 -0
- package/skills/gsd-path/templates/dialogue.md +21 -0
- package/skills/gsd-path/templates/docs-audit.md +69 -0
- package/skills/gsd-path/templates/evidence.md +24 -0
- package/skills/gsd-path/templates/final-review.md +27 -0
- package/skills/gsd-path/templates/gap-review.md +20 -0
- package/skills/gsd-path/templates/intent.md +98 -0
- package/skills/gsd-path/templates/loop.md +59 -0
- package/skills/gsd-path/templates/patch-findings.md +16 -0
- package/skills/gsd-path/templates/plan-panel.md +25 -0
- package/skills/gsd-path/templates/plan.md +89 -0
- package/skills/gsd-path/templates/repository.md +16 -0
- package/skills/gsd-path/templates/research-handoff.md +25 -0
- package/skills/gsd-path/templates/roadmap.md +38 -0
- package/skills/gsd-path/templates/skeptic.md +36 -0
- package/skills/gsd-path/templates/state.md +27 -0
- package/skills/gsd-path/templates/synthesis.md +36 -0
- package/skills/gsd-path/templates/task.md +74 -0
- package/skills/gsd-path/templates/wave-panel.md +25 -0
- package/skills/gsd-path/templates/wave-review.md +64 -0
- package/skills/gsd-path-build/SKILL.md +700 -0
- package/skills/gsd-path-build/agents/openai.yaml +6 -0
- package/skills/gsd-path-build/references/coder.md +90 -0
- package/skills/gsd-path-build/references/dispatch.md +131 -0
- package/skills/gsd-path-build/references/reviewer.md +188 -0
- package/skills/gsd-path-build/scripts/_common.py +180 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-build/scripts/build_state.py +854 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path-build/scripts/integration.py +1433 -0
- package/skills/gsd-path-build/scripts/isolation.py +3538 -0
- package/skills/gsd-path-build/scripts/loop_run.py +837 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-build/scripts/review_findings.py +714 -0
- package/skills/gsd-path-build/scripts/review_panel.py +553 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-build/scripts/state_promote.py +743 -0
- package/skills/gsd-path-build/scripts/token_budget.py +146 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-build/templates/skeptic.md +36 -0
- package/skills/gsd-path-build/templates/task.md +74 -0
- package/skills/gsd-path-build/templates/wave-panel.md +25 -0
- package/skills/gsd-path-build/templates/wave-review.md +64 -0
- package/skills/gsd-path-decide/SKILL.md +115 -0
- package/skills/gsd-path-decide/agents/openai.yaml +6 -0
- package/skills/gsd-path-decide/references/decider.md +36 -0
- package/skills/gsd-path-decide/references/dispatch.md +131 -0
- package/skills/gsd-path-decide/scripts/_common.py +180 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
- package/skills/gsd-path-decide/templates/synthesis.md +36 -0
- package/skills/gsd-path-define/SKILL.md +297 -0
- package/skills/gsd-path-define/agents/openai.yaml +6 -0
- package/skills/gsd-path-define/scripts/_common.py +180 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-define/scripts/isolation.py +3538 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-define/scripts/state_promote.py +743 -0
- package/skills/gsd-path-define/templates/charter.md +50 -0
- package/skills/gsd-path-define/templates/intent.md +98 -0
- package/skills/gsd-path-define/templates/state.md +27 -0
- package/skills/gsd-path-discuss/SKILL.md +162 -0
- package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
- package/skills/gsd-path-discuss/scripts/_common.py +180 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-discuss/templates/answers.md +29 -0
- package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
- package/skills/gsd-path-docs-audit/SKILL.md +190 -0
- package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
- package/skills/gsd-path-forensics/SKILL.md +39 -0
- package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
- package/skills/gsd-path-forensics/scripts/_common.py +180 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
- package/skills/gsd-path-inspect/SKILL.md +167 -0
- package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
- package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
- package/skills/gsd-path-inspect/references/dispatch.md +131 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
- package/skills/gsd-path-inspect/scripts/_common.py +180 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
- package/skills/gsd-path-inspect/templates/codebase.md +46 -0
- package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
- package/skills/gsd-path-inspect/templates/state.md +27 -0
- package/skills/gsd-path-loop/SKILL.md +98 -0
- package/skills/gsd-path-loop/agents/openai.yaml +6 -0
- package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
- package/skills/gsd-path-loop/scripts/_common.py +180 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
- package/skills/gsd-path-loop/templates/loop.md +59 -0
- package/skills/gsd-path-migrate/SKILL.md +134 -0
- package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
- package/skills/gsd-path-plan/SKILL.md +411 -0
- package/skills/gsd-path-plan/agents/openai.yaml +6 -0
- package/skills/gsd-path-plan/references/dispatch.md +131 -0
- package/skills/gsd-path-plan/references/planner.md +104 -0
- package/skills/gsd-path-plan/scripts/_common.py +180 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
- package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
- package/skills/gsd-path-plan/templates/plan.md +89 -0
- package/skills/gsd-path-plan/templates/task.md +74 -0
- package/skills/gsd-path-research/SKILL.md +159 -0
- package/skills/gsd-path-research/agents/openai.yaml +6 -0
- package/skills/gsd-path-research/references/dispatch.md +131 -0
- package/skills/gsd-path-research/references/researcher.md +42 -0
- package/skills/gsd-path-research/scripts/_common.py +180 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-research/scripts/isolation.py +3538 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-research/scripts/state_promote.py +743 -0
- package/skills/gsd-path-research/templates/evidence.md +24 -0
- package/skills/gsd-path-research/templates/research-handoff.md +25 -0
- package/skills/gsd-path-roadmap/SKILL.md +215 -0
- package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
- package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
- package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
- package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
- package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
- package/skills/gsd-path-ship/SKILL.md +412 -0
- package/skills/gsd-path-ship/agents/openai.yaml +6 -0
- package/skills/gsd-path-ship/references/dispatch.md +131 -0
- package/skills/gsd-path-ship/references/reviewer.md +188 -0
- package/skills/gsd-path-ship/scripts/_common.py +180 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-ship/scripts/build_state.py +854 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-ship/scripts/integration.py +1433 -0
- package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
- package/skills/gsd-path-ship/templates/final-review.md +27 -0
- package/skills/gsd-path-ship/templates/gap-review.md +20 -0
- package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
- package/skills/gsd-path-ship/templates/wave-review.md +64 -0
- package/skills/gsd-path-undo/SKILL.md +45 -0
- package/skills/gsd-path-undo/agents/openai.yaml +6 -0
- package/skills/gsd-path-undo/scripts/_common.py +180 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
- package/skills/path/BUILD.md +700 -0
- package/skills/path/DECIDE.md +115 -0
- package/skills/path/DEFINE.md +297 -0
- package/skills/path/DOCS-AUDIT.md +190 -0
- package/skills/path/INSPECT.md +167 -0
- package/skills/path/PLAN.md +411 -0
- package/skills/path/RESEARCH.md +159 -0
- package/skills/path/ROADMAP.md +215 -0
- package/skills/path/SHIP.md +412 -0
- package/skills/path/SKILL.md +471 -0
- package/skills/path/agents/openai.yaml +6 -0
- package/skills/path/references/codebase-mapper.md +52 -0
- package/skills/path/references/coder.md +90 -0
- package/skills/path/references/decider.md +36 -0
- package/skills/path/references/dispatch.md +131 -0
- package/skills/path/references/docs-auditor.md +60 -0
- package/skills/path/references/planner.md +104 -0
- package/skills/path/references/researcher.md +42 -0
- package/skills/path/references/reviewer.md +188 -0
- package/skills/path/references/roadmapper.md +48 -0
- package/skills/path/scripts/_common.py +180 -0
- package/skills/path/scripts/archive_milestone.py +2265 -0
- package/skills/path/scripts/bootstrap_repository.py +710 -0
- package/skills/path/scripts/build_state.py +854 -0
- package/skills/path/scripts/check_docs_audit.py +463 -0
- package/skills/path/scripts/check_handoffs.py +1855 -0
- package/skills/path/scripts/check_task_briefs.py +376 -0
- package/skills/path/scripts/check_update.py +93 -0
- package/skills/path/scripts/detect_project.py +1517 -0
- package/skills/path/scripts/discussion_records.py +619 -0
- package/skills/path/scripts/discussion_validate.py +1321 -0
- package/skills/path/scripts/dispatch_driver.py +1812 -0
- package/skills/path/scripts/integration.py +1433 -0
- package/skills/path/scripts/isolation.py +3538 -0
- package/skills/path/scripts/lean_verification.py +256 -0
- package/skills/path/scripts/loop_run.py +837 -0
- package/skills/path/scripts/pipeline_git.py +940 -0
- package/skills/path/scripts/pipeline_state.py +2291 -0
- package/skills/path/scripts/promote_lookahead.py +415 -0
- package/skills/path/scripts/review_findings.py +714 -0
- package/skills/path/scripts/review_panel.py +553 -0
- package/skills/path/scripts/state_checkpoint.py +1039 -0
- package/skills/path/scripts/state_promote.py +743 -0
- package/skills/path/scripts/token_budget.py +146 -0
- package/skills/path/scripts/workflow_run.py +117 -0
- package/skills/path/templates/answers.md +29 -0
- package/skills/path/templates/archive-manifest.md +37 -0
- package/skills/path/templates/charter.md +50 -0
- package/skills/path/templates/codebase.md +46 -0
- package/skills/path/templates/dialogue.md +21 -0
- package/skills/path/templates/docs-audit.md +69 -0
- package/skills/path/templates/evidence.md +24 -0
- package/skills/path/templates/final-review.md +27 -0
- package/skills/path/templates/gap-review.md +20 -0
- package/skills/path/templates/intent.md +98 -0
- package/skills/path/templates/loop.md +59 -0
- package/skills/path/templates/patch-findings.md +16 -0
- package/skills/path/templates/plan-panel.md +25 -0
- package/skills/path/templates/plan.md +89 -0
- package/skills/path/templates/repository.md +16 -0
- package/skills/path/templates/research-handoff.md +25 -0
- package/skills/path/templates/roadmap.md +38 -0
- package/skills/path/templates/skeptic.md +36 -0
- package/skills/path/templates/state.md +27 -0
- package/skills/path/templates/synthesis.md +36 -0
- package/skills/path/templates/task.md +74 -0
- package/skills/path/templates/wave-panel.md +25 -0
- package/skills/path/templates/wave-review.md +64 -0
|
@@ -0,0 +1,1812 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic dispatch driver for one build wave.
|
|
3
|
+
|
|
4
|
+
Composes the canonical helpers in the build contract's order: recover, ready,
|
|
5
|
+
brief lint, task isolation, child dispatch, Verify, landing, verify ledger,
|
|
6
|
+
retire. Every action re-derives its state from Git, the task files, and the
|
|
7
|
+
dispatch records under the Git common directory, so a call may be repeated
|
|
8
|
+
after a crash or a tool timeout. The driver never edits task frontmatter,
|
|
9
|
+
commits, or creates a checkout itself: isolation.py and build_state.py own
|
|
10
|
+
those. Children are spawned through an owner-supplied command that reads the
|
|
11
|
+
brief on stdin; the owner therefore owns the child's permission posture.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import errno
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
import signal
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import BinaryIO, Callable, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from scripts import (_common, archive_milestone, build_state, discussion_validate, isolation,
|
|
34
|
+
pipeline_state, review_findings, review_panel, workflow_run)
|
|
35
|
+
from scripts import check_handoffs as contracts
|
|
36
|
+
except ImportError: # bundled copy inside a skill's scripts directory
|
|
37
|
+
import _common
|
|
38
|
+
import archive_milestone
|
|
39
|
+
import build_state
|
|
40
|
+
import check_handoffs as contracts
|
|
41
|
+
import discussion_validate
|
|
42
|
+
import isolation
|
|
43
|
+
import pipeline_state
|
|
44
|
+
import review_findings
|
|
45
|
+
import review_panel
|
|
46
|
+
import workflow_run
|
|
47
|
+
|
|
48
|
+
if os.name == "nt":
|
|
49
|
+
import msvcrt
|
|
50
|
+
else:
|
|
51
|
+
import fcntl
|
|
52
|
+
|
|
53
|
+
RESULT_LINE = re.compile(r"(?im)^RESULT:\s*(?P<task>\S+)\s+(?P<verdict>ready|blocked)\s*$")
|
|
54
|
+
QUESTION_MARK = "NEEDS-ORCHESTRATOR:"
|
|
55
|
+
ANSWER_MARK = "Orchestrator answer:"
|
|
56
|
+
OUTPUT_TAIL_LINES = 20
|
|
57
|
+
POLL_SECONDS = 1 # ponytail: fixed poll cadence; make it an owner value if it ever matters
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ORPHANED = "child wrapper exited without recording a result"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DriverStop(RuntimeError):
|
|
64
|
+
"""A typed stop: the receipt carries the reason; the parent decides."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, reason: str, **details: object) -> None:
|
|
67
|
+
super().__init__(reason)
|
|
68
|
+
self.details = details
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
STOP_ERRORS = (DriverStop, isolation.IsolationError, build_state.BuildStateError,
|
|
72
|
+
contracts.HandoffError, review_findings.ReviewFindingsError,
|
|
73
|
+
pipeline_state.PipelineStateError, archive_milestone.ArchiveError)
|
|
74
|
+
RECOVERY_BLOCKED = "recovery blocked"
|
|
75
|
+
# The stops the build contract answers with build/blocked when the round raises them (steps 1 and 2);
|
|
76
|
+
# the same stops from review, panel, or skeptics stay with the parent.
|
|
77
|
+
BLOCKING_STOPS = {"dependency-deadlock": "dependency deadlock", RECOVERY_BLOCKED: "blocked recovery"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def now() -> str:
|
|
81
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tail(text: str) -> str:
|
|
85
|
+
return "\n".join(text.splitlines()[-OUTPUT_TAIL_LINES:])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- dispatch records ------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def milestone_slug(primary: Path) -> str:
|
|
92
|
+
"""The bound branch as a path segment; every record and ledger is scoped to one milestone."""
|
|
93
|
+
return isolation.require_bound(primary).replace("/", "-")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def records_root(primary: Path) -> Path:
|
|
97
|
+
return isolation.common_git_dir(primary) / "gsd-path" / "dispatch" / milestone_slug(primary)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def budget_ledger(primary: Path) -> Path:
|
|
101
|
+
return isolation.common_git_dir(primary) / "gsd-path" / "budget" / f"{milestone_slug(primary)}.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def attempts_used(root: Path, task_id: str) -> int:
|
|
105
|
+
"""Dispatches of this task in this milestone, excluding question redispatches."""
|
|
106
|
+
return sum(1 for path in (root / task_id).glob("attempt-*/state.json")
|
|
107
|
+
if load_state(path).get("origin") == "dispatch")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def acquire_lock(primary: Path) -> BinaryIO:
|
|
111
|
+
root = records_root(primary)
|
|
112
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
handle = open(root / "driver.lock", "a+b")
|
|
114
|
+
try:
|
|
115
|
+
if os.name == "nt":
|
|
116
|
+
handle.seek(0)
|
|
117
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
118
|
+
else:
|
|
119
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
120
|
+
except OSError as error:
|
|
121
|
+
handle.close()
|
|
122
|
+
if error.errno in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
123
|
+
raise DriverStop("another dispatch_driver invocation holds the lock") from error
|
|
124
|
+
raise
|
|
125
|
+
return handle
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def attempt_number(path: Path) -> int:
|
|
129
|
+
return int(path.name.split("-")[1])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_state(path: Path) -> Dict[str, object]:
|
|
133
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_state(path: Path, state: Dict[str, object]) -> None:
|
|
137
|
+
_common.atomic_write(path, json.dumps(state, indent=2, sort_keys=True) + "\n")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def update_state(state: Dict[str, object], **changes: object) -> None:
|
|
141
|
+
path = Path(str(state["_path"]))
|
|
142
|
+
save_state(path, dict(load_state(path), **changes))
|
|
143
|
+
state.update(changes)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def latest_states(root: Path) -> List[Dict[str, object]]:
|
|
147
|
+
"""The newest attempt record of every task, in task-id order."""
|
|
148
|
+
states = []
|
|
149
|
+
if root.is_dir():
|
|
150
|
+
for task_dir in sorted(root.iterdir()):
|
|
151
|
+
attempts = sorted(task_dir.glob("attempt-*/state.json"),
|
|
152
|
+
key=lambda path: attempt_number(path.parent))
|
|
153
|
+
if attempts:
|
|
154
|
+
states.append(read_attempt(attempts[-1]))
|
|
155
|
+
return states
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def read_attempt(path: Path) -> Dict[str, object]:
|
|
159
|
+
"""One attempt's state.json merged with the exit receipt its child wrapper writes."""
|
|
160
|
+
state = dict(load_state(path), _path=str(path))
|
|
161
|
+
exit_path = path.with_name("exit.json")
|
|
162
|
+
if exit_path.is_file():
|
|
163
|
+
state.update(load_state(exit_path))
|
|
164
|
+
return state
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def child_running(state: Dict[str, object]) -> bool:
|
|
168
|
+
return state.get("finished_at") is None and bool(state.get("pid")) and process_alive(int(state["pid"]))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def process_alive(pid: int) -> bool:
|
|
172
|
+
try:
|
|
173
|
+
os.kill(pid, 0)
|
|
174
|
+
except ProcessLookupError:
|
|
175
|
+
return False
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- child process ---------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def final_message(stdout: str) -> str:
|
|
183
|
+
"""The child's final text from Claude JSON, Codex JSONL, or plain text."""
|
|
184
|
+
try:
|
|
185
|
+
document = json.loads(stdout)
|
|
186
|
+
if isinstance(document, dict):
|
|
187
|
+
return str(document.get("result", ""))
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
pass
|
|
190
|
+
text = None
|
|
191
|
+
for line in stdout.splitlines():
|
|
192
|
+
try:
|
|
193
|
+
event = json.loads(line)
|
|
194
|
+
except json.JSONDecodeError:
|
|
195
|
+
continue
|
|
196
|
+
item = event.get("item") if isinstance(event, dict) else None
|
|
197
|
+
if isinstance(item, dict) and event.get("type") == "item.completed" \
|
|
198
|
+
and item.get("type") == "agent_message":
|
|
199
|
+
text = str(item.get("text", ""))
|
|
200
|
+
return stdout if text is None else text
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def child_main(state_path: Path) -> int:
|
|
204
|
+
"""Run the owner's child command under the isolate; record its exit in a separate receipt."""
|
|
205
|
+
state = load_state(state_path)
|
|
206
|
+
attempt_dir = state_path.parent
|
|
207
|
+
with open(attempt_dir / "brief.md", "rb") as brief, \
|
|
208
|
+
open(attempt_dir / "stdout", "wb") as out, open(attempt_dir / "stderr", "wb") as err:
|
|
209
|
+
process = subprocess.Popen(state["command"], cwd=state["worktree"], stdin=brief,
|
|
210
|
+
stdout=out, stderr=err, start_new_session=True)
|
|
211
|
+
try:
|
|
212
|
+
exit_code: Optional[int] = process.wait(timeout=state.get("child_timeout"))
|
|
213
|
+
timed_out = False
|
|
214
|
+
except subprocess.TimeoutExpired:
|
|
215
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
216
|
+
process.wait()
|
|
217
|
+
exit_code, timed_out = None, True
|
|
218
|
+
save_state(attempt_dir / "exit.json",
|
|
219
|
+
dict(finished_at=now(), exit_code=exit_code, timed_out=timed_out))
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def brief_text(state: Dict[str, object], role_brief: Path, task_template: Path) -> str:
|
|
224
|
+
worktree = Path(str(state["worktree"]))
|
|
225
|
+
task_id = state["task_id"]
|
|
226
|
+
agents = worktree / "AGENTS.md"
|
|
227
|
+
workflow = worktree / "WORKFLOW.md"
|
|
228
|
+
lines = [
|
|
229
|
+
f"You are the coder for GSD Path task {task_id}.",
|
|
230
|
+
f"Read the coder role brief first and follow it exactly, including its terminal "
|
|
231
|
+
f"`RESULT: {task_id} ready|blocked` line: {role_brief}",
|
|
232
|
+
f"Work only in this worktree root: {worktree}",
|
|
233
|
+
f"Task file: {worktree / str(state['task_file'])}",
|
|
234
|
+
f"Task template: {task_template}",
|
|
235
|
+
f"INTENT.md: {worktree / '.project/intent/INTENT.md'}",
|
|
236
|
+
f"AGENTS.md: {agents if agents.exists() else 'absent'}",
|
|
237
|
+
f"WORKFLOW.md: {workflow if workflow.exists() else 'absent'}",
|
|
238
|
+
f"Recorded base: {state['base']}",
|
|
239
|
+
f"Dispatch mode: {state['mode']} ("
|
|
240
|
+
+ ("the primary worktree on the bound branch" if state["mode"] == "serial"
|
|
241
|
+
else "an isolated task worktree; never touch the primary") + ").",
|
|
242
|
+
"Next consumer: the build orchestrator reruns Verify in an isolated checkout and lands "
|
|
243
|
+
"the task. Gate: Verify passes and only the task's declared files plus its Log changed.",
|
|
244
|
+
"Never delegate to another agent. Never stage, commit, or otherwise mutate Git.",
|
|
245
|
+
]
|
|
246
|
+
if state.get("answered"):
|
|
247
|
+
lines.append(f"The task Log now records an `{ANSWER_MARK}` to your earlier question; "
|
|
248
|
+
"continue from it.")
|
|
249
|
+
return "\n".join(lines) + "\n"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def spawn(root: Path, state: Dict[str, object], options: argparse.Namespace,
|
|
253
|
+
brief: Callable[[Dict[str, object]], str], command: Optional[str] = None) -> Dict[str, object]:
|
|
254
|
+
"""Create the next attempt record, write the brief the callback builds, and start the wrapper."""
|
|
255
|
+
task_dir = root / str(state["task_id"])
|
|
256
|
+
attempt = max((attempt_number(path) for path in task_dir.glob("attempt-*")), default=0) + 1
|
|
257
|
+
attempt_dir = task_dir / f"attempt-{attempt}"
|
|
258
|
+
attempt_dir.mkdir(parents=True)
|
|
259
|
+
stale = ("pid", "finished_at", "exit_code", "timed_out", "reason", "question", "commit", "answer",
|
|
260
|
+
"usage_recorded")
|
|
261
|
+
state = {key: value for key, value in state.items()
|
|
262
|
+
if not key.startswith("_") and key not in stale}
|
|
263
|
+
state.update({"attempt": attempt, "command": shlex.split(command or options.child_command),
|
|
264
|
+
"child_timeout": options.child_timeout, "outcome": None,
|
|
265
|
+
"dispatched_at": now(), "origin": "question" if state.get("answered") else "dispatch"})
|
|
266
|
+
state_path = attempt_dir / "state.json"
|
|
267
|
+
(attempt_dir / "brief.md").write_text(brief(state), encoding="utf-8")
|
|
268
|
+
save_state(state_path, state)
|
|
269
|
+
wrapper = subprocess.Popen(
|
|
270
|
+
[sys.executable, "-B", str(Path(__file__).resolve()), "_child", "--state", str(state_path)],
|
|
271
|
+
cwd=state["worktree"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
272
|
+
stderr=subprocess.DEVNULL, start_new_session=True,
|
|
273
|
+
)
|
|
274
|
+
state["_path"] = str(state_path)
|
|
275
|
+
update_state(state, pid=wrapper.pid)
|
|
276
|
+
return state
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --- finishing a task ------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def log_delta(primary: Path, state: Dict[str, object]) -> str:
|
|
283
|
+
base_text = isolation.git_text(primary, "show", f"{state['base']}:{state['task_file']}")
|
|
284
|
+
text = (Path(str(state["worktree"])) / str(state["task_file"])).read_text(encoding="utf-8")
|
|
285
|
+
return isolation.task_log_delta(base_text, text)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def append_log(task_path: Path, line: str) -> None:
|
|
289
|
+
text = task_path.read_text(encoding="utf-8")
|
|
290
|
+
if not text.endswith("\n"):
|
|
291
|
+
text += "\n"
|
|
292
|
+
_common.atomic_write(task_path, text + line + "\n")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def snapshot_tree(primary: Path) -> str:
|
|
296
|
+
"""Tree of the primary's complete working tree via a temporary index; the real index is untouched."""
|
|
297
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
298
|
+
env = dict(os.environ, GIT_INDEX_FILE=str(Path(temporary) / "index"))
|
|
299
|
+
for arguments in (("read-tree", "HEAD"), ("add", "-A"), ("write-tree",)):
|
|
300
|
+
completed = subprocess.run(("git", "-C", str(primary), *arguments), env=env,
|
|
301
|
+
text=True, capture_output=True)
|
|
302
|
+
if completed.returncode:
|
|
303
|
+
raise DriverStop(f"git {' '.join(arguments)} failed: {completed.stderr.strip()}")
|
|
304
|
+
return completed.stdout.strip()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def run_verify(command: str, cwd: Path) -> Dict[str, object]:
|
|
308
|
+
completed = _common.run_command("bash", "-c", command, cwd=cwd)
|
|
309
|
+
return {"exit_code": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def state_from_task(primary: Path, task_id: str) -> Dict[str, object]:
|
|
313
|
+
"""A finish-able state derived from the task frontmatter alone, for coders dispatched by hand."""
|
|
314
|
+
tasks_dir = primary / ".project/tasks"
|
|
315
|
+
for task_path in sorted(tasks_dir.glob("*.md")):
|
|
316
|
+
fields, _ = isolation.task_frontmatter(task_path.read_text(encoding="utf-8"))
|
|
317
|
+
if fields and str(fields.get("id")) == task_id:
|
|
318
|
+
break
|
|
319
|
+
else:
|
|
320
|
+
raise DriverStop(f"task {task_id} has no task file")
|
|
321
|
+
if fields.get("status") != "in-progress" or not fields.get("base"):
|
|
322
|
+
raise DriverStop(f"task {task_id} is not an in-progress task with a recorded base")
|
|
323
|
+
def value(key: str) -> Optional[str]:
|
|
324
|
+
raw = fields.get(key)
|
|
325
|
+
return None if raw in (None, "", "null") else str(raw)
|
|
326
|
+
|
|
327
|
+
task_branch = value("task_branch")
|
|
328
|
+
name = f"task-{task_id.lower()}-verify"
|
|
329
|
+
sidecar = None if task_branch else {
|
|
330
|
+
"worktree": str(isolation.sidecar_root(primary, "verify", name)),
|
|
331
|
+
"branch": isolation.verify_branch_name(name)}
|
|
332
|
+
return {"task_id": task_id, "title": str(fields.get("title")),
|
|
333
|
+
"task_file": task_path.relative_to(primary).as_posix(), "files": list(fields.get("files") or []),
|
|
334
|
+
"base": str(fields.get("base")), "worktree": value("worktree") or str(primary),
|
|
335
|
+
"task_branch": task_branch, "mode": "parallel" if task_branch else "serial",
|
|
336
|
+
"sidecar": sidecar}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def finish_task(primary: Path, state: Dict[str, object]) -> Dict[str, object]:
|
|
340
|
+
"""Verify in the isolate, land, record the ledger, retire. Returns the landing receipt."""
|
|
341
|
+
worktree = Path(str(state["worktree"]))
|
|
342
|
+
task_id = str(state["task_id"])
|
|
343
|
+
task_file = str(state["task_file"])
|
|
344
|
+
base = str(state["base"])
|
|
345
|
+
task_path = worktree / task_file
|
|
346
|
+
command = _common.task_verify_command(task_path.read_text(encoding="utf-8"))
|
|
347
|
+
if not command:
|
|
348
|
+
raise DriverStop("task has no Verify command", task=task_id)
|
|
349
|
+
if state["mode"] == "serial":
|
|
350
|
+
# ponytail: sidecar reproduction is git plumbing that belongs in isolation.py as the
|
|
351
|
+
# inverse of clean_verify; lift it there when a second caller appears.
|
|
352
|
+
sidecar = state["sidecar"]
|
|
353
|
+
sidecar_path = Path(str(sidecar["worktree"]))
|
|
354
|
+
tree = snapshot_tree(primary)
|
|
355
|
+
isolation.git_output(sidecar_path, "read-tree", "-u", "--reset", tree)
|
|
356
|
+
if isolation.git_output(sidecar_path, "write-tree") != tree:
|
|
357
|
+
raise DriverStop("sidecar reproduction differs from the primary task diff", task=task_id)
|
|
358
|
+
execution = run_verify(command, sidecar_path)
|
|
359
|
+
location = f"sidecar {sidecar['branch']}"
|
|
360
|
+
isolation.clean_verify(primary, sidecar_path, base, str(sidecar["branch"]))
|
|
361
|
+
isolation.retire(primary, sidecar_path, str(sidecar["branch"]), False)
|
|
362
|
+
else:
|
|
363
|
+
execution = run_verify(command, worktree)
|
|
364
|
+
location = f"isolate {state['task_branch']}"
|
|
365
|
+
passed = execution["exit_code"] == 0
|
|
366
|
+
execution.update({"worktree": str(worktree), "location": location})
|
|
367
|
+
evidence_dir = (Path(str(state["_path"])).parent if state.get("_path")
|
|
368
|
+
else records_root(primary) / task_id / f"manual-{now().replace(':', '')}")
|
|
369
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
_common.atomic_write(evidence_dir / "verify.json",
|
|
371
|
+
json.dumps(execution, indent=2, sort_keys=True) + "\n")
|
|
372
|
+
execution["evidence"] = str(evidence_dir / "verify.json")
|
|
373
|
+
output = "\n".join(f" {line}" for line in tail(execution["stdout"] + execution["stderr"]).splitlines())
|
|
374
|
+
append_log(task_path, f"- {now()[:10]} — orchestrator Verify ({location}): "
|
|
375
|
+
f"{'pass' if passed else 'fail'}, exit {execution['exit_code']}"
|
|
376
|
+
+ (f"; output tail:\n ```\n{output}\n ```" if output else ""))
|
|
377
|
+
if not passed:
|
|
378
|
+
if state["mode"] == "parallel":
|
|
379
|
+
isolation.deactivate_task(worktree, task_id, str(state["task_branch"]))
|
|
380
|
+
raise DriverStop("Verify failed in the isolate", task=task_id, execution=execution)
|
|
381
|
+
landed = isolation.land(primary, worktree, base, task_id, str(state["title"]), task_file,
|
|
382
|
+
list(state["files"]))
|
|
383
|
+
commit = str(landed["commit"])
|
|
384
|
+
# Only a landing whose parent is the recorded base proves the verified tree.
|
|
385
|
+
ledger = isolation.git_output(primary, "rev-parse", f"{commit}^") == base
|
|
386
|
+
if ledger:
|
|
387
|
+
build_state.verify_record(str(primary), command, commit, "pass", execution=execution)
|
|
388
|
+
isolation.retire(primary, worktree, state.get("task_branch") or None, False)
|
|
389
|
+
return {"task": task_id, "commit": commit, "mode": landed["mode"], "verify": execution,
|
|
390
|
+
"ledger": ledger}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# --- shared by round and review --------------------------------------------
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def summary(state: Dict[str, object]) -> Dict[str, object]:
|
|
397
|
+
return {key: state.get(key) for key in
|
|
398
|
+
("task_id", "attempt", "mode", "worktree", "wave", "cycle", "verify_heavy", "dispatched_at")}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def recover_report(primary: Path, project_dir: str, receipt: Dict[str, object]) -> Dict[str, object]:
|
|
402
|
+
report = isolation.recover(primary, Path(project_dir) / "tasks")
|
|
403
|
+
receipt["steps"].append({"script": "isolation.py recover", "result": report})
|
|
404
|
+
if report["verdict"] == "block":
|
|
405
|
+
raise DriverStop(RECOVERY_BLOCKED, recover=report)
|
|
406
|
+
return report
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def checkpoint_project(primary: Path, receipt: Dict[str, object], subject: str, body: str) -> Optional[str]:
|
|
410
|
+
"""Commit .project bookkeeping through the canonical checkpoint; None when nothing is dirty."""
|
|
411
|
+
if not any(path.startswith(".project/") for path in isolation.uncommitted_paths(primary)):
|
|
412
|
+
return None
|
|
413
|
+
result = isolation.checkpoint(primary, isolation.current_sha(primary), subject, body, [".project"])
|
|
414
|
+
receipt["steps"].append({"script": "isolation.py checkpoint", "result": result})
|
|
415
|
+
return str(result.get("commit"))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def transition(primary: Path, project_dir: str, receipt: Dict[str, object], state: pipeline_state.PipelineState,
|
|
419
|
+
changes: Dict[str, str], event: str) -> None:
|
|
420
|
+
"""Move STATE.md through pipeline_state, expecting exactly the state the caller inspected."""
|
|
421
|
+
result = pipeline_state.transition_state(primary, state.json(), changes, event, project_dir)
|
|
422
|
+
receipt["steps"].append({"script": "pipeline_state.py transition", "result": result})
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def run_step(primary: Path, project_dir: str, receipt: Dict[str, object], action: str, head: str,
|
|
426
|
+
task_id: Optional[str] = None, round_size: Optional[int] = None) -> List[Dict[str, object]]:
|
|
427
|
+
"""One workflow_run action; its steps join the receipt and a blocked result is a stop."""
|
|
428
|
+
result = workflow_run.run_workflow(primary, action, project_dir, head, task_id, round_size)
|
|
429
|
+
receipt["steps"].extend(result["steps"])
|
|
430
|
+
if result["status"] != "complete":
|
|
431
|
+
raise DriverStop(f"{action} {task_id or ''}: {result['reason']}".rstrip())
|
|
432
|
+
return result["steps"]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def wave_tasks(primary: Path, project_dir: str, wave: int, receipt: Dict[str, object]) -> List[Dict[str, object]]:
|
|
436
|
+
"""The wave's tasks with proven landings; an unproven task is a stop."""
|
|
437
|
+
tasks = []
|
|
438
|
+
for task in recover_report(primary, project_dir, receipt)["tasks"]:
|
|
439
|
+
text = (primary / str(task["task"])).read_text(encoding="utf-8")
|
|
440
|
+
if contracts._task_wave(text, str(task.get("task_id"))) != wave:
|
|
441
|
+
continue
|
|
442
|
+
if task["verdict"] not in ("recovered", "attested"):
|
|
443
|
+
raise DriverStop(f"task {task.get('task_id')} has no proven landing: {task['verdict']}", recover=task)
|
|
444
|
+
tasks.append(task)
|
|
445
|
+
if not tasks:
|
|
446
|
+
raise DriverStop(f"wave {wave} has no tasks")
|
|
447
|
+
return tasks
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def stop(receipt: Dict[str, object], error: BaseException) -> Dict[str, object]:
|
|
451
|
+
receipt["status"] = "blocked"
|
|
452
|
+
receipt["blocked"].append({"reason": str(error), "code": getattr(error, "code", None),
|
|
453
|
+
**getattr(error, "details", {})})
|
|
454
|
+
receipt["next"] = "$gsd-path-forensics"
|
|
455
|
+
return receipt
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# --- the round -------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class Round:
|
|
462
|
+
def __init__(self, primary: Path, options: argparse.Namespace) -> None:
|
|
463
|
+
self.primary = primary
|
|
464
|
+
self.options = options
|
|
465
|
+
self.project_dir = options.project_dir
|
|
466
|
+
self.root = records_root(primary)
|
|
467
|
+
self.proven: Dict[str, str] = {} # task id -> landing commit proven by recover
|
|
468
|
+
self.budgeted = (getattr(options, "task_limit", None) is not None
|
|
469
|
+
or budget_ledger(primary).is_file())
|
|
470
|
+
self.receipt: Dict[str, object] = {
|
|
471
|
+
"status": None, "wave": getattr(options, "wave", None), "landed": [],
|
|
472
|
+
"dispatched": [], "in_flight": [], "questions": [], "blocked": [], "steps": [],
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
# state transitions ----------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def enter(self) -> None:
|
|
478
|
+
"""The contract's checkpointed entry from plan/done, or recovery from build/blocked."""
|
|
479
|
+
state, _, _ = pipeline_state.load_state(self.primary, self.project_dir)
|
|
480
|
+
if (state.phase, state.status) not in (("plan", "done"), ("build", "blocked")):
|
|
481
|
+
return
|
|
482
|
+
transition(self.primary, self.project_dir, self.receipt, state, {"phase": "build", "status": "active"},
|
|
483
|
+
"build started")
|
|
484
|
+
checkpoint_project(self.primary, self.receipt, "build: start milestone",
|
|
485
|
+
f"Why: enter build from {state.phase}/{state.status} with the initial .project artifacts")
|
|
486
|
+
|
|
487
|
+
def block(self) -> None:
|
|
488
|
+
"""Set build/blocked when the stop just recorded is one the contract names, and checkpoint it."""
|
|
489
|
+
last = self.receipt["blocked"][-1]
|
|
490
|
+
cause = BLOCKING_STOPS.get(last.get("code")) or BLOCKING_STOPS.get(last["reason"])
|
|
491
|
+
if cause is None:
|
|
492
|
+
return
|
|
493
|
+
try:
|
|
494
|
+
state, _, _ = pipeline_state.load_state(self.primary, self.project_dir)
|
|
495
|
+
transition(self.primary, self.project_dir, self.receipt, state, {"status": "blocked"},
|
|
496
|
+
f"build blocked: {cause}")
|
|
497
|
+
self.receipt["checkpoint"] = checkpoint_project(
|
|
498
|
+
self.primary, self.receipt, f"build: record {cause}", f"Why: {last['reason']}")
|
|
499
|
+
except STOP_ERRORS as inner:
|
|
500
|
+
self.receipt["blocked"].append({"reason": str(inner), "helper": "pipeline_state.py transition"})
|
|
501
|
+
|
|
502
|
+
# token budget ---------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
def budget(self, action: str, *arguments: str) -> Dict[str, object]:
|
|
505
|
+
"""Run the bundled token_budget.py against this milestone's ledger; blocked is a stop."""
|
|
506
|
+
command = [sys.executable, "-B", str(Path(__file__).resolve().parent / "token_budget.py"),
|
|
507
|
+
action, "--ledger", str(budget_ledger(self.primary)), *arguments]
|
|
508
|
+
completed = subprocess.run(command, cwd=self.primary, capture_output=True, text=True)
|
|
509
|
+
result = json.loads(completed.stdout) if completed.stdout.strip() else {}
|
|
510
|
+
self.receipt["steps"].append({"script": f"token_budget.py {action}", "command": command,
|
|
511
|
+
"exit_code": completed.returncode, "result": result,
|
|
512
|
+
"stderr": completed.stderr})
|
|
513
|
+
if completed.returncode:
|
|
514
|
+
raise DriverStop(f"token budget {action}: {result.get('reason', completed.stderr.strip())}",
|
|
515
|
+
budget=result)
|
|
516
|
+
return result
|
|
517
|
+
|
|
518
|
+
def admit(self, task_id: str) -> None:
|
|
519
|
+
if self.budgeted:
|
|
520
|
+
self.budget("admit", "--task", task_id)
|
|
521
|
+
|
|
522
|
+
def configure_budget(self) -> None:
|
|
523
|
+
if getattr(self.options, "task_limit", None) is not None:
|
|
524
|
+
self.budget("configure", "--task-limit", str(getattr(self.options, "task_limit", None)),
|
|
525
|
+
"--session-limit", str(getattr(self.options, "session_limit", None)),
|
|
526
|
+
"--authority", getattr(self.options, "budget_authority", None))
|
|
527
|
+
|
|
528
|
+
# recovery -------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def recover(self) -> None:
|
|
531
|
+
report = recover_report(self.primary, self.project_dir, self.receipt)
|
|
532
|
+
for task in report["tasks"]:
|
|
533
|
+
worktree = task.get("worktree")
|
|
534
|
+
verdict = task["verdict"]
|
|
535
|
+
if verdict in ("recovered", "attested"):
|
|
536
|
+
self.proven[str(task.get("task_id"))] = str(task.get("commit"))
|
|
537
|
+
if verdict == "recovered" and worktree and worktree["clean"] and task.get("task_branch"):
|
|
538
|
+
isolation.retire(self.primary, Path(str(worktree["path"])),
|
|
539
|
+
str(task["task_branch"]), False)
|
|
540
|
+
elif (verdict == "recovered" and worktree is None and task.get("task_branch")) or (
|
|
541
|
+
verdict == "resume" and (task.get("landing_retry") or task.get("dispatch_retry"))):
|
|
542
|
+
# ponytail: interrupted retirements and retries stay with the parent's documented
|
|
543
|
+
# procedure; failed/blocked reconciliation surfaces through ready's typed error.
|
|
544
|
+
raise DriverStop(f"task {task.get('task_id')} needs recovery: {verdict}", recover=task)
|
|
545
|
+
|
|
546
|
+
# settle exited children ---------------------------------------------
|
|
547
|
+
|
|
548
|
+
def isolate_live(self, state: Dict[str, object]) -> bool:
|
|
549
|
+
"""True while the isolate still holds the task as in-progress; the parent's recovery clears it."""
|
|
550
|
+
task_path = Path(str(state["worktree"])) / str(state["task_file"])
|
|
551
|
+
if not task_path.is_file():
|
|
552
|
+
return False
|
|
553
|
+
fields, _ = isolation.task_frontmatter(task_path.read_text(encoding="utf-8"))
|
|
554
|
+
return fields is not None and fields.get("status") == "in-progress"
|
|
555
|
+
|
|
556
|
+
def settle(self) -> bool:
|
|
557
|
+
"""Classify every exited child. Returns True when a landing or redispatch changed the round."""
|
|
558
|
+
changed = False
|
|
559
|
+
for state in latest_states(self.root):
|
|
560
|
+
if state.get("wave") != self.receipt["wave"]:
|
|
561
|
+
continue
|
|
562
|
+
outcome = state.get("outcome")
|
|
563
|
+
if outcome == "question":
|
|
564
|
+
if state.get("answered"):
|
|
565
|
+
self.admit(str(state["task_id"]))
|
|
566
|
+
self.launch(dict(state, answered=True))
|
|
567
|
+
update_state(state, outcome="redispatched")
|
|
568
|
+
changed = True
|
|
569
|
+
else:
|
|
570
|
+
self.receipt["questions"].append({**self.summary(state),
|
|
571
|
+
"question": state.get("question")})
|
|
572
|
+
elif outcome == "blocked":
|
|
573
|
+
if self.isolate_live(state):
|
|
574
|
+
self.receipt["blocked"].append({**self.summary(state),
|
|
575
|
+
"reason": state.get("reason"), "reported": True})
|
|
576
|
+
else:
|
|
577
|
+
update_state(state, outcome="resolved")
|
|
578
|
+
elif outcome is None:
|
|
579
|
+
if str(state["task_id"]) in self.proven: # landed, then interrupted before recording
|
|
580
|
+
update_state(state, outcome="landed", commit=self.proven[str(state["task_id"])])
|
|
581
|
+
elif state.get("finished_at") is None:
|
|
582
|
+
if child_running(state):
|
|
583
|
+
self.receipt["in_flight"].append(self.summary(state))
|
|
584
|
+
else:
|
|
585
|
+
self.fail(state, ORPHANED)
|
|
586
|
+
else:
|
|
587
|
+
changed = self.classify(state) or changed
|
|
588
|
+
return changed
|
|
589
|
+
|
|
590
|
+
summary = staticmethod(summary)
|
|
591
|
+
|
|
592
|
+
def fail(self, state: Dict[str, object], reason: str, **details: object) -> None:
|
|
593
|
+
update_state(state, outcome="blocked", reason=reason)
|
|
594
|
+
self.receipt["blocked"].append({**self.summary(state), "reason": reason, **details})
|
|
595
|
+
|
|
596
|
+
def classify(self, state: Dict[str, object]) -> bool:
|
|
597
|
+
attempt_dir = Path(str(state["_path"])).parent
|
|
598
|
+
if self.budgeted and not state.get("usage_recorded"):
|
|
599
|
+
self.budget("record", "--task", str(state["task_id"]), "--events", str(attempt_dir / "stdout"))
|
|
600
|
+
update_state(state, usage_recorded=True)
|
|
601
|
+
message = final_message((attempt_dir / "stdout").read_text(encoding="utf-8", errors="replace"))
|
|
602
|
+
stderr = (attempt_dir / "stderr").read_text(encoding="utf-8", errors="replace")
|
|
603
|
+
try:
|
|
604
|
+
delta = log_delta(self.primary, state)
|
|
605
|
+
except (isolation.IsolationError, FileNotFoundError) as error:
|
|
606
|
+
self.fail(state, str(error), stdout_tail=tail(message), stderr_tail=tail(stderr))
|
|
607
|
+
return False
|
|
608
|
+
entries = [line for line in delta.splitlines() if line.strip()]
|
|
609
|
+
answered_at = max((index for index, line in enumerate(entries) if ANSWER_MARK in line), default=-1)
|
|
610
|
+
unanswered = entries[answered_at + 1:]
|
|
611
|
+
matches = list(RESULT_LINE.finditer(message))
|
|
612
|
+
verdict = matches[-1].group("verdict").lower() if matches else None
|
|
613
|
+
named = matches[-1].group("task").lower() if matches else None
|
|
614
|
+
if state.get("timed_out"): # a failed child is a failure even when it wrote a question
|
|
615
|
+
self.fail(state, "child exceeded the configured timeout", log_delta=delta.strip())
|
|
616
|
+
elif state.get("exit_code") != 0:
|
|
617
|
+
self.fail(state, f"child exited {state.get('exit_code')}", stderr_tail=tail(stderr),
|
|
618
|
+
stdout_tail=tail(message), log_delta=delta.strip())
|
|
619
|
+
elif unanswered and QUESTION_MARK in unanswered[0]:
|
|
620
|
+
update_state(state, outcome="question", question=unanswered[0], answered=False)
|
|
621
|
+
self.receipt["questions"].append({**self.summary(state), "question": unanswered[0],
|
|
622
|
+
"log_delta": delta.strip()})
|
|
623
|
+
elif verdict != "ready" or named != str(state["task_id"]).lower():
|
|
624
|
+
self.fail(state, "child returned blocked" if verdict == "blocked"
|
|
625
|
+
else "child returned no RESULT line", stdout_tail=tail(message),
|
|
626
|
+
log_delta=delta.strip())
|
|
627
|
+
else:
|
|
628
|
+
return self.finish(state)
|
|
629
|
+
return False
|
|
630
|
+
|
|
631
|
+
def finish(self, state: Dict[str, object]) -> bool:
|
|
632
|
+
try:
|
|
633
|
+
landing = finish_task(self.primary, state)
|
|
634
|
+
except STOP_ERRORS as error:
|
|
635
|
+
details = getattr(error, "details", {})
|
|
636
|
+
self.fail(state, str(error), **{key: value for key, value in details.items()
|
|
637
|
+
if key != "task"})
|
|
638
|
+
return False
|
|
639
|
+
update_state(state, outcome="landed", commit=landing["commit"])
|
|
640
|
+
self.receipt["landed"].append(landing)
|
|
641
|
+
return True
|
|
642
|
+
|
|
643
|
+
def launch(self, state: Dict[str, object]) -> None:
|
|
644
|
+
if state.get("wave") != self.receipt["wave"]:
|
|
645
|
+
raise DriverStop("task is outside the requested wave", task=state["task_id"],
|
|
646
|
+
wave=state.get("wave"), requested_wave=self.receipt["wave"])
|
|
647
|
+
fresh = spawn(self.root, state, self.options,
|
|
648
|
+
lambda fresh: brief_text(fresh, self.options.role_brief, self.options.task_template))
|
|
649
|
+
self.receipt["dispatched"].append(self.summary(fresh))
|
|
650
|
+
self.receipt["in_flight"].append(self.summary(fresh))
|
|
651
|
+
|
|
652
|
+
# dispatch -------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
def in_flight_states(self) -> List[Dict[str, object]]:
|
|
655
|
+
return [state for state in latest_states(self.root)
|
|
656
|
+
if state.get("outcome") is None and state.get("wave") == self.receipt["wave"]]
|
|
657
|
+
|
|
658
|
+
def checkpoint_bookkeeping(self) -> None:
|
|
659
|
+
open_ids = {state["task_id"] for state in latest_states(self.root)
|
|
660
|
+
if state.get("outcome") in (None, "question", "blocked")}
|
|
661
|
+
orphaned = []
|
|
662
|
+
for task_path in sorted((self.primary / ".project/tasks").glob("*.md")):
|
|
663
|
+
fields, _ = isolation.task_frontmatter(task_path.read_text(encoding="utf-8"))
|
|
664
|
+
if fields and fields.get("status") == "in-progress" and fields.get("id") not in open_ids:
|
|
665
|
+
orphaned.append(str(fields.get("id")))
|
|
666
|
+
if orphaned:
|
|
667
|
+
raise DriverStop("in-progress tasks have no open dispatch record", tasks=orphaned)
|
|
668
|
+
checkpoint_project(self.primary, self.receipt, "build: record dispatch bookkeeping",
|
|
669
|
+
"Why: commit the verify ledger and task state before the next dispatch round\n"
|
|
670
|
+
f"Base: {isolation.current_sha(self.primary)}")
|
|
671
|
+
|
|
672
|
+
def dispatch_ready(self) -> bool:
|
|
673
|
+
"""Dispatch every selectable task. Returns True when the wave is complete."""
|
|
674
|
+
in_flight = self.in_flight_states()
|
|
675
|
+
if any(state["mode"] == "serial" for state in in_flight):
|
|
676
|
+
return False
|
|
677
|
+
ready = build_state.ready(str(self.primary), self.project_dir)
|
|
678
|
+
self.receipt["steps"].append({"script": "build_state.py ready", "result": ready})
|
|
679
|
+
wave = ready["current_wave"]
|
|
680
|
+
if wave is not None and wave < self.receipt["wave"]:
|
|
681
|
+
raise DriverStop(f"wave {wave} is still unfinished; run round --wave {wave} first")
|
|
682
|
+
self.checkpoint_bookkeeping()
|
|
683
|
+
if wave is None or wave > self.receipt["wave"]:
|
|
684
|
+
return not in_flight
|
|
685
|
+
active_ids = {state["task_id"] for state in in_flight}
|
|
686
|
+
# ponytail: the one-heavy-Verify-at-a-time rule lives here; move it into
|
|
687
|
+
# build_state.ready if the hand-dispatch path ever needs it too.
|
|
688
|
+
heavy_busy = any(state.get("verify_heavy") for state in in_flight)
|
|
689
|
+
capacity = self.options.capacity
|
|
690
|
+
selected = []
|
|
691
|
+
for task in ready["ready"]:
|
|
692
|
+
if capacity is not None and len(in_flight) + len(selected) >= capacity:
|
|
693
|
+
break
|
|
694
|
+
if task["wave"] != self.receipt["wave"]:
|
|
695
|
+
continue
|
|
696
|
+
if task["id"] in active_ids or (task["verify_heavy"] and heavy_busy):
|
|
697
|
+
continue
|
|
698
|
+
heavy_busy = heavy_busy or task["verify_heavy"]
|
|
699
|
+
selected.append(task)
|
|
700
|
+
if not selected:
|
|
701
|
+
return False
|
|
702
|
+
for task in selected:
|
|
703
|
+
used = attempts_used(self.root, task["id"])
|
|
704
|
+
if used >= self.options.max_attempts:
|
|
705
|
+
raise DriverStop(f"task {task['id']} reached the attempt limit ({self.options.max_attempts}) "
|
|
706
|
+
"for this milestone; a person must rule", task=task["id"], attempts=used)
|
|
707
|
+
self.admit(task["id"])
|
|
708
|
+
head = isolation.current_sha(self.primary)
|
|
709
|
+
self.runner("lint-round", head)
|
|
710
|
+
round_size = len(selected) + len(in_flight)
|
|
711
|
+
for task in selected:
|
|
712
|
+
prepared = self.runner("prepare-task", head, task["id"], round_size)
|
|
713
|
+
isolate = prepared[0]["result"]
|
|
714
|
+
sidecar = prepared[1]["result"] if len(prepared) > 1 else None
|
|
715
|
+
worktree = Path(str(isolate["worktree"]))
|
|
716
|
+
isolation.activate_task(worktree, head, task["id"], f"build_{task['id'].lower()}",
|
|
717
|
+
task["task_file"], isolate["task_branch"])
|
|
718
|
+
self.launch({"task_id": task["id"], "title": task["title"], "task_file": task["task_file"],
|
|
719
|
+
"files": task["files"], "wave": task["wave"],
|
|
720
|
+
"verify_heavy": task["verify_heavy"], "base": head, "worktree": str(worktree),
|
|
721
|
+
"task_branch": isolate["task_branch"], "mode": isolate["mode"],
|
|
722
|
+
"sidecar": sidecar, "answered": False})
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
def runner(self, action: str, head: str, task_id: Optional[str] = None,
|
|
726
|
+
round_size: Optional[int] = None) -> List[Dict[str, object]]:
|
|
727
|
+
return run_step(self.primary, self.project_dir, self.receipt, action, head, task_id, round_size)
|
|
728
|
+
|
|
729
|
+
def run(self) -> Dict[str, object]:
|
|
730
|
+
deadline = time.monotonic() + self.options.wait if self.options.wait else None
|
|
731
|
+
try:
|
|
732
|
+
self.enter()
|
|
733
|
+
self.configure_budget()
|
|
734
|
+
self.recover()
|
|
735
|
+
changed = True
|
|
736
|
+
while True:
|
|
737
|
+
self.receipt["in_flight"] = []
|
|
738
|
+
changed = self.settle() or changed
|
|
739
|
+
if self.receipt["questions"] or self.receipt["blocked"]:
|
|
740
|
+
self.receipt["status"] = "question" if self.receipt["questions"] else "blocked"
|
|
741
|
+
return self.receipt
|
|
742
|
+
# ready is expensive and its inputs only move when a landing or redispatch happened
|
|
743
|
+
if changed and self.dispatch_ready():
|
|
744
|
+
self.receipt["status"] = "done"
|
|
745
|
+
return self.receipt
|
|
746
|
+
changed = False
|
|
747
|
+
live = self.in_flight_states()
|
|
748
|
+
if deadline is None or time.monotonic() >= deadline or not live:
|
|
749
|
+
self.receipt["status"] = "in-flight" if live else "done"
|
|
750
|
+
return self.receipt
|
|
751
|
+
time.sleep(POLL_SECONDS)
|
|
752
|
+
except STOP_ERRORS as error:
|
|
753
|
+
stop(self.receipt, error)
|
|
754
|
+
self.block()
|
|
755
|
+
return self.receipt
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# --- the wave review -------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def advance(state: Dict[str, object], collect: Callable[[Dict[str, object]], None]) -> bool:
|
|
762
|
+
"""Move one sidecar child toward a terminal outcome, in place. False while it still runs."""
|
|
763
|
+
if state.get("outcome") == "collected" and (
|
|
764
|
+
state.get("cleanup_pending") or not state.get("cleanup_complete")):
|
|
765
|
+
collect(state)
|
|
766
|
+
if state.get("outcome") is None:
|
|
767
|
+
if state.get("finished_at") is None:
|
|
768
|
+
if child_running(state):
|
|
769
|
+
return False
|
|
770
|
+
state.update(read_attempt(Path(str(state["_path"])))) # the wrapper may have just recorded its result
|
|
771
|
+
if state.get("finished_at") is not None:
|
|
772
|
+
collect(state)
|
|
773
|
+
else:
|
|
774
|
+
update_state(state, outcome="blocked", reason=ORPHANED)
|
|
775
|
+
return True
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def retire_sidecar(primary: Path, state: Dict[str, object]) -> None:
|
|
779
|
+
"""Clean and retire a collected child's sidecar; a failure stays pending for the next run."""
|
|
780
|
+
sidecar = Path(str(state["worktree"]))
|
|
781
|
+
try:
|
|
782
|
+
if sidecar.exists():
|
|
783
|
+
isolation.clean_verify(primary, sidecar, str(state["base"]), str(state["branch"]))
|
|
784
|
+
isolation.retire(primary, sidecar, str(state["branch"]), False)
|
|
785
|
+
except isolation.IsolationError as error:
|
|
786
|
+
update_state(state, cleanup_pending=True, cleanup_error=str(error))
|
|
787
|
+
return
|
|
788
|
+
update_state(state, cleanup_complete=True, cleanup_pending=False, cleanup_error=None)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def review_base(records: Path, wave: int, cycle: int) -> str:
|
|
792
|
+
"""The clean base the cycle's review recorded; a stop when no review ran."""
|
|
793
|
+
base = next((state["base"] for state in latest_states(records / "reviews")
|
|
794
|
+
if state.get("wave") == wave and state.get("cycle") == cycle), None)
|
|
795
|
+
if base is None:
|
|
796
|
+
raise DriverStop("review base is not recorded; run review first")
|
|
797
|
+
return str(base)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def spawn_in_sidecar(root: Path, primary: Path, receipt: Dict[str, object], options: argparse.Namespace,
|
|
801
|
+
name: str, state: Dict[str, object], brief: Callable[[Dict[str, object]], str],
|
|
802
|
+
command: Optional[str] = None) -> None:
|
|
803
|
+
"""Cut a verify sidecar at the state's base and start one child in it."""
|
|
804
|
+
sidecar = isolation.isolate_verify(primary, str(state["base"]), name)
|
|
805
|
+
receipt["steps"].append({"script": "isolation.py isolate-verify", "result": sidecar})
|
|
806
|
+
spawn(root, dict(state, worktree=str(sidecar["worktree"]), branch=str(sidecar["branch"])), options, brief,
|
|
807
|
+
command=command)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def review_brief(state: Dict[str, object], options: argparse.Namespace, primary: Path,
|
|
811
|
+
tasks: List[Dict[str, object]], final_scope: bool, previous: Optional[Path]) -> str:
|
|
812
|
+
wave, cycle, lens = state["wave"], state["cycle"], state.get("lens")
|
|
813
|
+
agents = primary / "AGENTS.md"
|
|
814
|
+
lines = [
|
|
815
|
+
f"You are the reviewer for GSD Path wave {wave}, cycle {cycle}; logical task name "
|
|
816
|
+
f"{state['task_id']}. Mode: wave" + (f"; lens: {lens}." if lens else "."),
|
|
817
|
+
f"Read the reviewer role brief first and follow it exactly: {options.role_brief}",
|
|
818
|
+
f"Template (use only this): {options.template}",
|
|
819
|
+
f"AGENTS.md: {agents if agents.exists() else 'absent'}",
|
|
820
|
+
f"Repository root, read-only: {primary}",
|
|
821
|
+
f"Your verify sidecar root, the only place you may write or apply patches: {state['worktree']} "
|
|
822
|
+
f"on branch {state['branch']} at the recorded review base {state['base']}.",
|
|
823
|
+
f"Write exactly this output file: {Path(str(state['worktree'])) / str(state['relative'])}",
|
|
824
|
+
f"Canonical path after the orchestrator collects it: {state['relative']}",
|
|
825
|
+
f"INTENT.md: {primary / '.project/intent/INTENT.md'}",
|
|
826
|
+
f"PLAN.md: {primary / '.project/plan/PLAN.md'}",
|
|
827
|
+
"Tasks in this wave, each with its recorded base and proven landing commit; the "
|
|
828
|
+
"orchestrator's recorded isolated Verify result is the `orchestrator Verify` entry in each Log:",
|
|
829
|
+
]
|
|
830
|
+
for task in tasks:
|
|
831
|
+
lines.append(f"- {task['task_id']}: {primary / str(task['task'])} — base {task['base']}, "
|
|
832
|
+
f"landing commit {task['commit']}")
|
|
833
|
+
if previous is not None:
|
|
834
|
+
lines.append(f"Previous cycle review to re-check: {previous}")
|
|
835
|
+
if options.repair_evidence:
|
|
836
|
+
lines.append(f"Repair evidence receipt (re-review after a proven repair): {options.repair_evidence}")
|
|
837
|
+
if final_scope:
|
|
838
|
+
lines.append(f"Final scope applies: record `Review scope: final` and `Reviewed HEAD: {state['base']}`, "
|
|
839
|
+
"check every INTENT success criterion and PLAN.md's Surface contract, and add "
|
|
840
|
+
"Surface, Check, and Observed fields to each surface criterion block.")
|
|
841
|
+
lines += [
|
|
842
|
+
"Do not re-run task Verify or PLAN.md's project Verify. Never edit the primary worktree. "
|
|
843
|
+
"Never delegate to another agent. Never stage or commit.",
|
|
844
|
+
"Terminal result: the `Wave verdict:` line of the file you wrote is your result; the "
|
|
845
|
+
"orchestrator validates the file and reads it from there. Name that verdict in your final message.",
|
|
846
|
+
]
|
|
847
|
+
return "\n".join(lines) + "\n"
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
class Review:
|
|
851
|
+
"""Step 6 of the build contract at `full` or `deep` depth: one reviewer per lens in its own
|
|
852
|
+
verify sidecar, validated, collected, retired; findings grouped on blocked."""
|
|
853
|
+
|
|
854
|
+
def __init__(self, primary: Path, options: argparse.Namespace) -> None:
|
|
855
|
+
self.primary = primary
|
|
856
|
+
self.options = options
|
|
857
|
+
self.project_dir = options.project_dir
|
|
858
|
+
self.wave, self.cycle = options.wave, options.cycle
|
|
859
|
+
self.root = records_root(primary) / "reviews"
|
|
860
|
+
self.receipt: Dict[str, object] = {"status": None, "wave": self.wave, "cycle": self.cycle,
|
|
861
|
+
"lenses": {}, "in_flight": [], "blocked": [], "steps": []}
|
|
862
|
+
|
|
863
|
+
def lens_name(self, key: str) -> str:
|
|
864
|
+
return f"review_wave_{self.wave}_cycle_{self.cycle}" + ("" if key == "canonical" else f"_{key}")
|
|
865
|
+
|
|
866
|
+
def current_states(self, keys: List[str]) -> Dict[str, Dict[str, object]]:
|
|
867
|
+
names = {self.lens_name(key): key for key in keys}
|
|
868
|
+
return {names[str(state["task_id"])]: state for state in latest_states(self.root)
|
|
869
|
+
if str(state["task_id"]) in names}
|
|
870
|
+
|
|
871
|
+
def wave_tasks(self) -> List[Dict[str, object]]:
|
|
872
|
+
return wave_tasks(self.primary, self.project_dir, self.wave, self.receipt)
|
|
873
|
+
|
|
874
|
+
def dispatch(self, lenses: Dict[str, str], depth: str, base: Optional[str] = None) -> None:
|
|
875
|
+
dirty = sorted(isolation.uncommitted_paths(self.primary))
|
|
876
|
+
if dirty: # the review base must include every bookkeeping change
|
|
877
|
+
raise DriverStop("review collection base requires a clean primary", paths=dirty)
|
|
878
|
+
tasks = self.wave_tasks()
|
|
879
|
+
plan_text = (self.primary / self.project_dir / "plan/PLAN.md").read_text(encoding="utf-8")
|
|
880
|
+
intent = (self.primary / self.project_dir / "intent/INTENT.md").read_text(encoding="utf-8")
|
|
881
|
+
try:
|
|
882
|
+
lane = contracts._line_value(intent, "Lane:")
|
|
883
|
+
except contracts.HandoffError:
|
|
884
|
+
lane = None # an INTENT without a lane line is never quick
|
|
885
|
+
final_scope = depth == "full" and lane == "quick" and contracts._plan_waves(plan_text)[1] == [1]
|
|
886
|
+
earlier = (review_findings.review_paths(self.primary / self.project_dir, self.wave, self.cycle - 1, depth)
|
|
887
|
+
if self.cycle > 1 else {})
|
|
888
|
+
for previous in earlier.values():
|
|
889
|
+
if not previous.is_file():
|
|
890
|
+
raise DriverStop(f"previous cycle review is missing: {previous}")
|
|
891
|
+
base = base or isolation.current_sha(self.primary)
|
|
892
|
+
self.receipt["base"] = base
|
|
893
|
+
for key, relative in lenses.items():
|
|
894
|
+
name = f"wave-{self.wave}-cycle-{self.cycle}" + ("" if key == "canonical" else f"-{key}")
|
|
895
|
+
previous = earlier.get(key)
|
|
896
|
+
state = {"task_id": self.lens_name(key), "lens": None if key == "canonical" else key,
|
|
897
|
+
"wave": self.wave, "cycle": self.cycle, "base": base, "relative": relative}
|
|
898
|
+
spawn_in_sidecar(self.root, self.primary, self.receipt, self.options, name, state,
|
|
899
|
+
lambda fresh, previous=previous: review_brief(fresh, self.options, self.primary, tasks,
|
|
900
|
+
final_scope, previous))
|
|
901
|
+
|
|
902
|
+
def collect(self, state: Dict[str, object]) -> None:
|
|
903
|
+
"""Validate in the sidecar, then copy to the canonical path, then retire: the contract's order."""
|
|
904
|
+
sidecar = Path(str(state["worktree"]))
|
|
905
|
+
relative = str(state["relative"])
|
|
906
|
+
stderr = (Path(str(state["_path"])).parent / "stderr").read_text(encoding="utf-8", errors="replace")
|
|
907
|
+
if state.get("timed_out") or state.get("exit_code") != 0:
|
|
908
|
+
update_state(state, outcome="blocked", reason=f"reviewer child exited {state.get('exit_code')}",
|
|
909
|
+
stderr_tail=tail(stderr))
|
|
910
|
+
return
|
|
911
|
+
staged = sidecar / relative
|
|
912
|
+
canonical = self.primary / relative
|
|
913
|
+
if state.get("outcome") != "collected":
|
|
914
|
+
if not staged.is_file():
|
|
915
|
+
if (state.get("validated") and canonical.is_file()
|
|
916
|
+
and hashlib.sha256(canonical.read_bytes()).hexdigest() == state.get("validated_sha256")):
|
|
917
|
+
update_state(state, outcome="collected")
|
|
918
|
+
else:
|
|
919
|
+
update_state(state, outcome="blocked", reason="reviewer wrote no review file", stderr_tail=tail(stderr))
|
|
920
|
+
return
|
|
921
|
+
else:
|
|
922
|
+
try:
|
|
923
|
+
validated = contracts.validate_wave_evidence(sidecar, self.project_dir, relative)
|
|
924
|
+
except contracts.HandoffError as error:
|
|
925
|
+
update_state(state, outcome="blocked", reason=str(error), helper="check_handoffs.py wave")
|
|
926
|
+
return
|
|
927
|
+
update_state(state, verdict=validated["verdict"], owned=validated.get("owned"), validated=True,
|
|
928
|
+
validated_sha256=hashlib.sha256(staged.read_bytes()).hexdigest())
|
|
929
|
+
try:
|
|
930
|
+
collected = isolation.collect_artifact(self.primary, sidecar, str(state["base"]),
|
|
931
|
+
str(state["branch"]), relative, relative, None)
|
|
932
|
+
except isolation.IsolationError as error:
|
|
933
|
+
update_state(state, outcome="blocked", reason=str(error), helper="isolation.py")
|
|
934
|
+
return
|
|
935
|
+
update_state(state, outcome="collected")
|
|
936
|
+
self.receipt["steps"].append({"script": "isolation.py collect-artifact", "result": collected})
|
|
937
|
+
retire_sidecar(self.primary, state)
|
|
938
|
+
|
|
939
|
+
def input_failure(self, base: str) -> Optional[Dict[str, str]]:
|
|
940
|
+
paths = review_findings.review_paths(self.primary / self.project_dir, self.wave,
|
|
941
|
+
self.cycle, self.receipt["depth"])
|
|
942
|
+
allowed = {path.relative_to(self.primary).as_posix() for path in paths.values()}
|
|
943
|
+
head = isolation.current_sha(self.primary)
|
|
944
|
+
committed = set(filter(None, isolation.git_text(
|
|
945
|
+
self.primary, "diff", "--no-renames", "--name-only", "-z", base, head, "--").split("\0")))
|
|
946
|
+
changed = committed | isolation.uncommitted_paths(self.primary)
|
|
947
|
+
checkpoint = f"build: record wave {self.wave} cycle {self.cycle} review"
|
|
948
|
+
history_matches = head == base or (
|
|
949
|
+
isolation.git_output(self.primary, "show", "-s", "--format=%P", head) == base
|
|
950
|
+
and isolation.git_output(self.primary, "show", "-s", "--format=%s", head) == checkpoint)
|
|
951
|
+
if changed - allowed or not history_matches:
|
|
952
|
+
return {"reason": "inputs changed after the review base; run a new cycle",
|
|
953
|
+
"helper": "dispatch_driver.py review"}
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
def settle(self, states: Dict[str, Dict[str, object]]) -> None:
|
|
957
|
+
"""Advance every lens and render the receipt from the records; settle is their only writer."""
|
|
958
|
+
self.receipt["in_flight"], self.receipt["blocked"], self.receipt["lenses"] = [], [], {}
|
|
959
|
+
for key, state in states.items():
|
|
960
|
+
if not advance(state, self.collect):
|
|
961
|
+
self.receipt["in_flight"].append(summary(state))
|
|
962
|
+
continue
|
|
963
|
+
self.receipt["base"] = state.get("base")
|
|
964
|
+
if state.get("outcome") == "collected":
|
|
965
|
+
failure = self.input_failure(str(state["base"]))
|
|
966
|
+
if failure:
|
|
967
|
+
self.receipt["blocked"].append({**summary(state), **failure})
|
|
968
|
+
continue
|
|
969
|
+
canonical = self.primary / str(state["relative"])
|
|
970
|
+
if (not canonical.is_file()
|
|
971
|
+
or hashlib.sha256(canonical.read_bytes()).hexdigest() != state.get("validated_sha256")):
|
|
972
|
+
self.receipt["blocked"].append({**summary(state),
|
|
973
|
+
"reason": "canonical review artifact changed after collection",
|
|
974
|
+
"helper": "isolation.py collect-artifact"})
|
|
975
|
+
continue
|
|
976
|
+
if state.get("cleanup_pending"):
|
|
977
|
+
self.receipt["blocked"].append({**summary(state), "reason": state.get("cleanup_error"),
|
|
978
|
+
"helper": "isolation.py retire"})
|
|
979
|
+
self.receipt["lenses"][key] = {"verdict": state.get("verdict"), "owned": state.get("owned"),
|
|
980
|
+
"path": state.get("relative")}
|
|
981
|
+
elif state.get("outcome") == "blocked":
|
|
982
|
+
self.receipt["blocked"].append({**summary(state), "reason": state.get("reason"),
|
|
983
|
+
"helper": state.get("helper"), "stderr_tail": state.get("stderr_tail")})
|
|
984
|
+
|
|
985
|
+
def conclude(self) -> None:
|
|
986
|
+
lenses: Dict[str, Dict[str, object]] = self.receipt["lenses"]
|
|
987
|
+
passing = not self.receipt["blocked"] and all(entry.get("verdict") == "pass" for entry in lenses.values())
|
|
988
|
+
failure = self.input_failure(str(self.receipt["base"])) if passing else None
|
|
989
|
+
if passing and failure is None:
|
|
990
|
+
if not self.receipt["panel_required"]: # with a panel, the parent's single checkpoint follows it
|
|
991
|
+
self.receipt["checkpoint"] = checkpoint_project(
|
|
992
|
+
self.primary, self.receipt, f"build: record wave {self.wave} cycle {self.cycle} review",
|
|
993
|
+
f"Why: persist the passing wave review verdict\nWave: {self.wave}")
|
|
994
|
+
self.receipt["status"] = "pass"
|
|
995
|
+
return
|
|
996
|
+
helper_failures = [f"{item['helper']}: {item['reason']}" for item in self.receipt["blocked"]
|
|
997
|
+
if item.get("helper")]
|
|
998
|
+
if failure:
|
|
999
|
+
self.receipt.update(failure)
|
|
1000
|
+
helper_failures.append(f"{failure['helper']}: {failure['reason']}")
|
|
1001
|
+
try:
|
|
1002
|
+
self.receipt["findings"] = review_findings.compute(self.primary, self.project_dir, self.wave,
|
|
1003
|
+
self.cycle, helper_failures)
|
|
1004
|
+
except review_findings.ReviewFindingsError as error:
|
|
1005
|
+
self.receipt["findings"] = {"status": "error", "error": str(error)}
|
|
1006
|
+
self.receipt["status"] = "blocked"
|
|
1007
|
+
|
|
1008
|
+
def run(self) -> Dict[str, object]:
|
|
1009
|
+
deadline = time.monotonic() + self.options.wait if self.options.wait else None
|
|
1010
|
+
try:
|
|
1011
|
+
project = self.primary / self.project_dir
|
|
1012
|
+
plan_text = (project / "plan/PLAN.md").read_text(encoding="utf-8")
|
|
1013
|
+
depth = review_findings.wave_depth(plan_text, self.wave)
|
|
1014
|
+
self.receipt["depth"] = depth
|
|
1015
|
+
if depth == "verify-only":
|
|
1016
|
+
# ponytail: verify-only files are orchestrator-written judgment; the parent owns them.
|
|
1017
|
+
self.receipt.update(status="not-applicable",
|
|
1018
|
+
reason="verify-only reviews are orchestrator-written; the driver does not run them")
|
|
1019
|
+
return self.receipt
|
|
1020
|
+
self.receipt["panel_required"] = review_panel.plan_review_panel_enabled(plan_text)
|
|
1021
|
+
lenses = {key: path.relative_to(self.primary).as_posix() for key, path in
|
|
1022
|
+
review_findings.review_paths(project, self.wave, self.cycle, depth).items()}
|
|
1023
|
+
states = self.current_states(list(lenses))
|
|
1024
|
+
missing = {key: relative for key, relative in lenses.items() if key not in states}
|
|
1025
|
+
if missing:
|
|
1026
|
+
base = None
|
|
1027
|
+
if states:
|
|
1028
|
+
bases = {state["base"] for state in states.values()}
|
|
1029
|
+
head = isolation.current_sha(self.primary)
|
|
1030
|
+
if bases != {head}:
|
|
1031
|
+
raise DriverStop(f"missing review lenses {', '.join(missing)}: HEAD moved to {head}; "
|
|
1032
|
+
f"recorded bases: {', '.join(sorted(bases))}")
|
|
1033
|
+
base = head
|
|
1034
|
+
self.dispatch(missing, depth, base)
|
|
1035
|
+
states = self.current_states(list(lenses))
|
|
1036
|
+
while True:
|
|
1037
|
+
self.settle(states)
|
|
1038
|
+
if set(states) == set(lenses) and all(
|
|
1039
|
+
state.get("outcome") in ("collected", "blocked") for state in states.values()):
|
|
1040
|
+
self.conclude()
|
|
1041
|
+
return self.receipt
|
|
1042
|
+
if deadline is None or time.monotonic() >= deadline:
|
|
1043
|
+
self.receipt["status"] = "in-flight"
|
|
1044
|
+
return self.receipt
|
|
1045
|
+
time.sleep(POLL_SECONDS)
|
|
1046
|
+
states = self.current_states(list(lenses))
|
|
1047
|
+
except STOP_ERRORS as error:
|
|
1048
|
+
return stop(self.receipt, error)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
# --- the review panel and fix tasks ----------------------------------------
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def helper_json(receipt: Dict[str, object], script: str, *arguments: str, cwd: Path) -> Dict[str, object]:
|
|
1055
|
+
"""Run a bundled helper and return its JSON stdout; a non-zero exit is a typed stop."""
|
|
1056
|
+
command = [sys.executable, "-B", str(Path(__file__).resolve().parent / script), *arguments]
|
|
1057
|
+
completed = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
|
|
1058
|
+
step = {"script": script, "command": command, "exit_code": completed.returncode,
|
|
1059
|
+
"stdout": completed.stdout, "stderr": completed.stderr}
|
|
1060
|
+
receipt["steps"].append(step)
|
|
1061
|
+
try:
|
|
1062
|
+
result = json.loads(completed.stdout) if completed.stdout.strip() else {}
|
|
1063
|
+
except json.JSONDecodeError:
|
|
1064
|
+
result = {}
|
|
1065
|
+
if completed.returncode:
|
|
1066
|
+
raise DriverStop(f"{script} failed: {result.get('error') or completed.stderr.strip()}", result=result)
|
|
1067
|
+
result["_stdout"] = completed.stdout
|
|
1068
|
+
return result
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
class Children:
|
|
1072
|
+
"""One sidecar child per key of a wave cycle, each polled to a terminal outcome and its file
|
|
1073
|
+
collected; Panel and Skeptics differ only in what they dispatch and record."""
|
|
1074
|
+
|
|
1075
|
+
records = "" # dispatch records subdirectory
|
|
1076
|
+
field = "" # the state key naming a child
|
|
1077
|
+
results = "" # the receipt key holding collected entries
|
|
1078
|
+
|
|
1079
|
+
def __init__(self, primary: Path, options: argparse.Namespace) -> None:
|
|
1080
|
+
self.primary, self.options = primary, options
|
|
1081
|
+
self.project_dir, self.wave, self.cycle = options.project_dir, options.wave, options.cycle
|
|
1082
|
+
self.root = records_root(primary) / self.records
|
|
1083
|
+
self.receipt: Dict[str, object] = {"status": None, "wave": self.wave, "cycle": self.cycle,
|
|
1084
|
+
self.results: {}, "in_flight": [], "blocked": [], "steps": []}
|
|
1085
|
+
|
|
1086
|
+
def states(self) -> Dict[str, Dict[str, object]]:
|
|
1087
|
+
return {str(state[self.field]): state for state in latest_states(self.root)
|
|
1088
|
+
if state.get("wave") == self.wave and state.get("cycle") == self.cycle}
|
|
1089
|
+
|
|
1090
|
+
def validate(self, staged: Path) -> Dict[str, object]:
|
|
1091
|
+
"""State fields read from the staged file; raises review_findings.ReviewFindingsError."""
|
|
1092
|
+
return {}
|
|
1093
|
+
|
|
1094
|
+
def entry(self, state: Dict[str, object]) -> Dict[str, object]:
|
|
1095
|
+
raise NotImplementedError
|
|
1096
|
+
|
|
1097
|
+
def collect(self, state: Dict[str, object]) -> None:
|
|
1098
|
+
collect_file(self.primary, self.receipt, state, self.validate)
|
|
1099
|
+
|
|
1100
|
+
def poll(self, states: Dict[str, Dict[str, object]], deadline: Optional[float]) -> bool:
|
|
1101
|
+
"""Advance every child; True once all are terminal, False when the deadline passes first."""
|
|
1102
|
+
while True:
|
|
1103
|
+
self.receipt["in_flight"], self.receipt["blocked"], self.receipt[self.results] = [], [], {}
|
|
1104
|
+
for key, state in states.items():
|
|
1105
|
+
if not advance(state, self.collect):
|
|
1106
|
+
self.receipt["in_flight"].append(summary(state))
|
|
1107
|
+
continue
|
|
1108
|
+
if state.get("cleanup_pending"):
|
|
1109
|
+
self.receipt["blocked"].append({**summary(state), "reason": state.get("cleanup_error"),
|
|
1110
|
+
"helper": "isolation.py retire"})
|
|
1111
|
+
if state.get("outcome") == "collected":
|
|
1112
|
+
self.receipt[self.results][key] = self.entry(state)
|
|
1113
|
+
elif state.get("outcome") == "blocked":
|
|
1114
|
+
self.receipt["blocked"].append({**summary(state), "reason": state.get("reason"),
|
|
1115
|
+
"helper": state.get("helper")})
|
|
1116
|
+
if all(state.get("outcome") in ("collected", "blocked") for state in states.values()):
|
|
1117
|
+
return True
|
|
1118
|
+
if deadline is None or time.monotonic() >= deadline:
|
|
1119
|
+
self.receipt["status"] = "in-flight"
|
|
1120
|
+
return False
|
|
1121
|
+
time.sleep(POLL_SECONDS)
|
|
1122
|
+
states.update(self.states()) # an unreaped wrapper still looks alive; its exit receipt does not
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def collect_file(primary: Path, receipt: Dict[str, object], state: Dict[str, object],
|
|
1126
|
+
validate: Callable[[Path], Dict[str, object]]) -> None:
|
|
1127
|
+
"""Validate a child's staged file in its sidecar, copy it to the canonical path, then retire."""
|
|
1128
|
+
sidecar, relative = Path(str(state["worktree"])), str(state["relative"])
|
|
1129
|
+
if state.get("outcome") != "collected":
|
|
1130
|
+
if state.get("timed_out") or state.get("exit_code") != 0:
|
|
1131
|
+
update_state(state, outcome="blocked", reason=f"child exited {state.get('exit_code')}")
|
|
1132
|
+
return
|
|
1133
|
+
staged = sidecar / relative
|
|
1134
|
+
if staged.is_file():
|
|
1135
|
+
try:
|
|
1136
|
+
fields = validate(staged)
|
|
1137
|
+
except review_findings.ReviewFindingsError as error:
|
|
1138
|
+
update_state(state, outcome="blocked", reason=str(error), helper="review_findings.py")
|
|
1139
|
+
return
|
|
1140
|
+
update_state(state, validated=True,
|
|
1141
|
+
validated_sha256=hashlib.sha256(staged.read_bytes()).hexdigest(), **fields)
|
|
1142
|
+
else:
|
|
1143
|
+
canonical = primary / relative
|
|
1144
|
+
if (not state.get("validated") or not canonical.is_file()
|
|
1145
|
+
or hashlib.sha256(canonical.read_bytes()).hexdigest() != state.get("validated_sha256")):
|
|
1146
|
+
update_state(state, outcome="blocked", reason="child wrote no file or collected file changed")
|
|
1147
|
+
return
|
|
1148
|
+
try:
|
|
1149
|
+
collected = isolation.collect_artifact(primary, sidecar, str(state["base"]), str(state["branch"]),
|
|
1150
|
+
relative, relative, None)
|
|
1151
|
+
except isolation.IsolationError as error:
|
|
1152
|
+
update_state(state, outcome="blocked", reason=str(error), helper="isolation.py")
|
|
1153
|
+
return
|
|
1154
|
+
receipt["steps"].append({"script": "isolation.py collect-artifact", "result": collected})
|
|
1155
|
+
update_state(state, outcome="collected")
|
|
1156
|
+
retire_sidecar(primary, state)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
class Panel(Children):
|
|
1160
|
+
"""Step 6's optional review panel: resolve, one panelist per family in its own sidecar,
|
|
1161
|
+
collect, merge, then the single on-pass checkpoint with the canonical review."""
|
|
1162
|
+
|
|
1163
|
+
records, field, results = "panels", "family", "families"
|
|
1164
|
+
|
|
1165
|
+
def family_name(self, family: str) -> str:
|
|
1166
|
+
return f"review_wave_{self.wave}_cycle_{self.cycle}_panel_{family}"
|
|
1167
|
+
|
|
1168
|
+
def entry(self, state: Dict[str, object]) -> Dict[str, object]:
|
|
1169
|
+
return {"slug": state.get("slug"), "path": state.get("relative")}
|
|
1170
|
+
|
|
1171
|
+
def relative(self, family: str) -> str:
|
|
1172
|
+
return f"{self.project_dir}/review/wave-{self.wave}.cycle{self.cycle}.panel.{family}.md"
|
|
1173
|
+
|
|
1174
|
+
def brief(self, state: Dict[str, object], tasks: List[Dict[str, object]], depth: str, review: str) -> str:
|
|
1175
|
+
agents = self.primary / "AGENTS.md"
|
|
1176
|
+
lines = [
|
|
1177
|
+
f"You are one review-panel child for GSD Path wave {self.wave}, cycle {self.cycle}; logical task "
|
|
1178
|
+
f"name {state['task_id']}. Mode: wave panel. Family: {state['family']}. Model: {state['slug']}.",
|
|
1179
|
+
f"Read the reviewer role brief first and follow its Wave panel mode exactly: {self.options.role_brief}",
|
|
1180
|
+
f"Template (use only this): {self.options.template}",
|
|
1181
|
+
f"AGENTS.md: {agents if agents.exists() else 'absent'}",
|
|
1182
|
+
f"Depth for this brief: {'adversarial' if depth == 'deep' else 'full'}.",
|
|
1183
|
+
f"Repository root, read-only: {self.primary}",
|
|
1184
|
+
f"Canonical wave review, read-only: {self.primary / review}",
|
|
1185
|
+
f"Your verify sidecar root, the only place you may write or apply patches: {state['worktree']} "
|
|
1186
|
+
f"on branch {state['branch']} at the recorded review base {state['base']}.",
|
|
1187
|
+
f"Write exactly this output file: {Path(str(state['worktree'])) / str(state['relative'])}",
|
|
1188
|
+
f"INTENT.md: {self.primary / '.project/intent/INTENT.md'}",
|
|
1189
|
+
"Tasks in this wave, each with its recorded base and proven landing commit:",
|
|
1190
|
+
]
|
|
1191
|
+
for task in tasks:
|
|
1192
|
+
lines.append(f"- {task['task_id']}: {self.primary / str(task['task'])} — base {task['base']}, "
|
|
1193
|
+
f"landing commit {task['commit']}")
|
|
1194
|
+
lines += ["Do not write a Wave verdict or edit the canonical review. Never edit the primary. "
|
|
1195
|
+
"Never delegate. Never stage or commit.",
|
|
1196
|
+
"Terminal result: the file you wrote is your result; name its finding count in your final message."]
|
|
1197
|
+
return "\n".join(lines) + "\n"
|
|
1198
|
+
|
|
1199
|
+
def run(self) -> Dict[str, object]:
|
|
1200
|
+
deadline = time.monotonic() + self.options.wait if self.options.wait else None
|
|
1201
|
+
try:
|
|
1202
|
+
project = self.primary / self.project_dir
|
|
1203
|
+
plan_text = (project / "plan/PLAN.md").read_text(encoding="utf-8")
|
|
1204
|
+
depth = review_findings.wave_depth(plan_text, self.wave)
|
|
1205
|
+
self.receipt["depth"] = depth
|
|
1206
|
+
if depth == "verify-only":
|
|
1207
|
+
raise DriverStop("never spawn a review panel at verify-only")
|
|
1208
|
+
paths = review_findings.review_paths(project, self.wave, self.cycle, depth)
|
|
1209
|
+
review = next(iter(paths.values())).relative_to(self.primary).as_posix()
|
|
1210
|
+
if not all(path.is_file() for path in paths.values()):
|
|
1211
|
+
raise DriverStop("the canonical wave review must be collected before the panel")
|
|
1212
|
+
output = project / "review" / f"wave-{self.wave}.cycle{self.cycle}.panel.md"
|
|
1213
|
+
skipped = project / "review" / f"wave-{self.wave}.cycle{self.cycle}.panel.skipped.json"
|
|
1214
|
+
states = self.states()
|
|
1215
|
+
roster_path = self.root / f"wave-{self.wave}-cycle-{self.cycle}" / "roster.json"
|
|
1216
|
+
if roster_path.is_file():
|
|
1217
|
+
roster = load_state(roster_path)
|
|
1218
|
+
else:
|
|
1219
|
+
if states:
|
|
1220
|
+
raise DriverStop("panel records exist without a persisted roster")
|
|
1221
|
+
arguments = ["resolve", "--plan", str(project / "plan/PLAN.md"),
|
|
1222
|
+
"--intent", str(project / "intent/INTENT.md"),
|
|
1223
|
+
"--advertised", self.options.advertised]
|
|
1224
|
+
if self.options.parent_slug:
|
|
1225
|
+
arguments += ["--parent-slug", self.options.parent_slug]
|
|
1226
|
+
if (project / "CHARTER.md").exists():
|
|
1227
|
+
arguments += ["--charter", str(project / "CHARTER.md")]
|
|
1228
|
+
resolved = helper_json(self.receipt, "review_panel.py", *arguments, cwd=self.primary)
|
|
1229
|
+
self.receipt["resolve"] = resolved
|
|
1230
|
+
if resolved["status"] == "off":
|
|
1231
|
+
self.receipt["status"] = "off"
|
|
1232
|
+
return self.receipt
|
|
1233
|
+
if resolved["status"] == "skipped":
|
|
1234
|
+
base = review_base(self.root.parent, self.wave, self.cycle)
|
|
1235
|
+
failure = self.input_failure(base, paths)
|
|
1236
|
+
if failure:
|
|
1237
|
+
raise DriverStop(failure)
|
|
1238
|
+
if skipped.exists(): # an interrupted checkpoint left a valid receipt: reuse it
|
|
1239
|
+
try:
|
|
1240
|
+
saved = json.loads(skipped.read_text(encoding="utf-8"))
|
|
1241
|
+
except (OSError, json.JSONDecodeError):
|
|
1242
|
+
saved = {}
|
|
1243
|
+
if not isinstance(saved, dict) or saved.get("status") != "skipped":
|
|
1244
|
+
raise DriverStop("panel skipped receipt exists but is not a skipped receipt",
|
|
1245
|
+
path=str(skipped))
|
|
1246
|
+
else:
|
|
1247
|
+
_common.atomic_write(skipped, str(resolved.pop("_stdout")))
|
|
1248
|
+
self.receipt["skipped_receipt"] = str(skipped)
|
|
1249
|
+
failure = self.input_failure(base, paths)
|
|
1250
|
+
if failure:
|
|
1251
|
+
raise DriverStop(failure)
|
|
1252
|
+
self.checkpoint(paths, "persist the wave review and the skipped panel receipt")
|
|
1253
|
+
self.receipt["status"] = "skipped"
|
|
1254
|
+
return self.receipt
|
|
1255
|
+
if skipped.exists():
|
|
1256
|
+
raise DriverStop("a skipped-panel receipt exists for this cycle", path=str(skipped))
|
|
1257
|
+
roster = {"families": [item["family"] for item in resolved["selected"]],
|
|
1258
|
+
"slugs": {item["family"]: item["slug"] for item in resolved["selected"]},
|
|
1259
|
+
"mode": resolved["mode"], "base": isolation.current_sha(self.primary)}
|
|
1260
|
+
save_state(roster_path, roster)
|
|
1261
|
+
self.receipt["mode"] = roster["mode"]
|
|
1262
|
+
if set(states) - set(roster["families"]) or any(
|
|
1263
|
+
state["base"] != roster["base"] for state in states.values()):
|
|
1264
|
+
raise DriverStop("panel records disagree with the persisted roster")
|
|
1265
|
+
missing = [family for family in roster["families"] if family not in states]
|
|
1266
|
+
if missing:
|
|
1267
|
+
base = roster["base"]
|
|
1268
|
+
if isolation.current_sha(self.primary) != base:
|
|
1269
|
+
raise DriverStop("cannot resume missing panel families: HEAD moved from the recorded base")
|
|
1270
|
+
tasks = wave_tasks(self.primary, self.project_dir, self.wave, self.receipt)
|
|
1271
|
+
for family in missing:
|
|
1272
|
+
slug = roster["slugs"][family]
|
|
1273
|
+
state = {"task_id": self.family_name(family), "family": family, "slug": slug,
|
|
1274
|
+
"wave": self.wave, "cycle": self.cycle, "base": base, "mode": roster["mode"],
|
|
1275
|
+
"relative": self.relative(family)}
|
|
1276
|
+
spawn_in_sidecar(self.root, self.primary, self.receipt, self.options,
|
|
1277
|
+
f"wave-{self.wave}-cycle-{self.cycle}-panel-{family}", state,
|
|
1278
|
+
lambda fresh: self.brief(fresh, tasks, depth, review),
|
|
1279
|
+
command=self.options.child_command.replace("{model}", slug))
|
|
1280
|
+
states = self.states()
|
|
1281
|
+
if not self.poll(states, deadline):
|
|
1282
|
+
return self.receipt
|
|
1283
|
+
if self.receipt["blocked"]:
|
|
1284
|
+
self.receipt["status"] = "blocked"
|
|
1285
|
+
return self.receipt
|
|
1286
|
+
if set(states) != set(roster["families"]) or not all(
|
|
1287
|
+
state.get("outcome") == "collected" for state in states.values()):
|
|
1288
|
+
raise DriverStop("the complete panel roster must be collected before merge")
|
|
1289
|
+
mode = roster["mode"]
|
|
1290
|
+
inputs = ",".join(str(self.primary / entry["path"]) for entry in self.receipt["families"].values())
|
|
1291
|
+
failure = self.input_failure(str(roster["base"]), paths)
|
|
1292
|
+
if failure:
|
|
1293
|
+
raise DriverStop(failure)
|
|
1294
|
+
merged = helper_json(self.receipt, "review_panel.py", "merge", "--kind", "wave", "--wave", str(self.wave),
|
|
1295
|
+
"--cycle", str(self.cycle), "--inputs", inputs, "--output", str(output),
|
|
1296
|
+
"--mode", str(mode), cwd=self.primary)
|
|
1297
|
+
self.receipt["merge"] = merged
|
|
1298
|
+
self.receipt["panel"] = output.relative_to(self.primary).as_posix()
|
|
1299
|
+
failure = self.input_failure(str(roster["base"]), paths)
|
|
1300
|
+
if failure:
|
|
1301
|
+
raise DriverStop(failure)
|
|
1302
|
+
self.checkpoint(paths, "persist the wave review and its review panel")
|
|
1303
|
+
self.receipt["status"] = "pass"
|
|
1304
|
+
return self.receipt
|
|
1305
|
+
except STOP_ERRORS as error:
|
|
1306
|
+
return stop(self.receipt, error)
|
|
1307
|
+
|
|
1308
|
+
def input_failure(self, base: str, paths: Dict[str, Path]) -> Optional[str]:
|
|
1309
|
+
"""Only this cycle's review and panel artifacts may differ from the recorded base."""
|
|
1310
|
+
review_dir = self.primary / self.project_dir / "review"
|
|
1311
|
+
allowed = {path.relative_to(self.primary).as_posix() for path in paths.values()}
|
|
1312
|
+
allowed |= {(review_dir / name).relative_to(self.primary).as_posix() for name in (
|
|
1313
|
+
f"wave-{self.wave}.cycle{self.cycle}.panel.md", f"wave-{self.wave}.cycle{self.cycle}.panel.skipped.json")}
|
|
1314
|
+
allowed |= {relative for relative in (state.get("relative") for state in self.states().values()) if relative}
|
|
1315
|
+
head = isolation.current_sha(self.primary)
|
|
1316
|
+
committed = set(filter(None, isolation.git_text(
|
|
1317
|
+
self.primary, "diff", "--no-renames", "--name-only", "-z", base, head, "--").split("\0")))
|
|
1318
|
+
if head != base or (committed | isolation.uncommitted_paths(self.primary)) - allowed:
|
|
1319
|
+
return "inputs changed after the review base; run a new cycle"
|
|
1320
|
+
return None
|
|
1321
|
+
|
|
1322
|
+
def checkpoint(self, paths: Dict[str, Path], reason: str) -> None:
|
|
1323
|
+
verdicts = [contracts.validate_wave_evidence(
|
|
1324
|
+
self.primary, self.project_dir, path.relative_to(self.primary).as_posix())["verdict"]
|
|
1325
|
+
for path in paths.values()]
|
|
1326
|
+
self.receipt["review_verdict"] = "pass" if all(verdict == "pass" for verdict in verdicts) else "blocked"
|
|
1327
|
+
self.receipt["checkpoint"] = None
|
|
1328
|
+
if self.receipt["review_verdict"] == "pass":
|
|
1329
|
+
self.receipt["checkpoint"] = checkpoint_project(
|
|
1330
|
+
self.primary, self.receipt, f"build: record wave {self.wave} cycle {self.cycle} review",
|
|
1331
|
+
f"Why: {reason}\nWave: {self.wave}")
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
class Skeptics(Children):
|
|
1335
|
+
"""Step 7's skeptic branch: one read-only skeptic per `needs-skeptic` locator in its own sidecar at
|
|
1336
|
+
the review base, validated, collected, retired, then the findings helper rerun. The skeptic files
|
|
1337
|
+
stay uncommitted for the fix-task or ruling checkpoint."""
|
|
1338
|
+
|
|
1339
|
+
records, field, results = "skeptics", "locator", "skeptics"
|
|
1340
|
+
|
|
1341
|
+
def compute(self) -> Dict[str, object]:
|
|
1342
|
+
try:
|
|
1343
|
+
return review_findings.compute(self.primary, self.project_dir, self.wave, self.cycle)
|
|
1344
|
+
except review_findings.ReviewFindingsError as error:
|
|
1345
|
+
raise DriverStop(str(error), helper="review_findings.py collect")
|
|
1346
|
+
|
|
1347
|
+
def validate(self, staged: Path) -> Dict[str, object]:
|
|
1348
|
+
group = next(group for group in self.compute()["groups"]
|
|
1349
|
+
if staged.name == f"wave-{self.wave}.cycle{self.cycle}.skeptic-{group['locator']}.md")
|
|
1350
|
+
return {"verdict": review_findings.parse_skeptic(
|
|
1351
|
+
staged, self.wave, [item["text"] for item in group["observations"]])["verdict"]}
|
|
1352
|
+
|
|
1353
|
+
def entry(self, state: Dict[str, object]) -> Dict[str, object]:
|
|
1354
|
+
return {"verdict": state.get("verdict"), "path": state.get("relative")}
|
|
1355
|
+
|
|
1356
|
+
def brief(self, state: Dict[str, object], group: Dict[str, object], tasks: List[Dict[str, object]]) -> str:
|
|
1357
|
+
worktree = Path(str(state["worktree"]))
|
|
1358
|
+
cited = [task for task in tasks if task["task_id"] in group["tasks"]]
|
|
1359
|
+
lines = [
|
|
1360
|
+
f"You are one finding-skeptic child for GSD Path wave {self.wave}, cycle {self.cycle}; logical task "
|
|
1361
|
+
f"name {state['task_id']}. Mode: skeptic.",
|
|
1362
|
+
f"Read the reviewer role brief first and follow its Skeptic mode exactly: {self.options.role_brief}",
|
|
1363
|
+
f"Template (use only this): {self.options.template}",
|
|
1364
|
+
f"Criterion locator: {group['locator']}",
|
|
1365
|
+
f"Criterion: {group['criterion']}",
|
|
1366
|
+
f"Lenses: {', '.join(group['lenses'])}",
|
|
1367
|
+
"Observations to refute, verbatim, one per line:",
|
|
1368
|
+
*[f"- [{item['lens']}] {item['text']}" for item in group["observations"]],
|
|
1369
|
+
f"Repository root, read-only: {self.primary}",
|
|
1370
|
+
f"Your verify sidecar root, the only place you may write or apply patches: {worktree} "
|
|
1371
|
+
f"on branch {state['branch']} at the recorded review base {state['base']}.",
|
|
1372
|
+
f"Write exactly this output file: {worktree / str(state['relative'])}",
|
|
1373
|
+
f"INTENT.md: {self.primary / self.project_dir / 'intent/INTENT.md'}",
|
|
1374
|
+
"Tasks the observations cite, each with its recorded base and proven landing commit:",
|
|
1375
|
+
*[f"- {task['task_id']}: {self.primary / str(task['task'])} — base {task['base']}, "
|
|
1376
|
+
f"landing commit {task['commit']}" for task in cited],
|
|
1377
|
+
"Do not write a Wave verdict. Never edit the primary. Never delegate. Never stage or commit.",
|
|
1378
|
+
"Terminal result: the `## Verdict` of the file you wrote is your result; name it in your final message.",
|
|
1379
|
+
]
|
|
1380
|
+
return "\n".join(lines) + "\n"
|
|
1381
|
+
|
|
1382
|
+
def run(self) -> Dict[str, object]:
|
|
1383
|
+
deadline = time.monotonic() + self.options.wait if self.options.wait else None
|
|
1384
|
+
try:
|
|
1385
|
+
findings = self.compute()
|
|
1386
|
+
self.receipt["findings"] = findings
|
|
1387
|
+
escalation = [key for key in ("structural_blockers", "cap_reached") if findings.get(key)]
|
|
1388
|
+
if escalation:
|
|
1389
|
+
self.receipt.update(status="escalate", escalation=escalation)
|
|
1390
|
+
return self.receipt
|
|
1391
|
+
states = self.states()
|
|
1392
|
+
missing = [locator for locator in findings["skeptic_groups"] if locator not in states]
|
|
1393
|
+
if not states and not missing:
|
|
1394
|
+
self.receipt.update(status="none", reason="no skeptic groups")
|
|
1395
|
+
return self.receipt
|
|
1396
|
+
if missing:
|
|
1397
|
+
base = review_base(self.root.parent, self.wave, self.cycle)
|
|
1398
|
+
if isolation.current_sha(self.primary) != base:
|
|
1399
|
+
raise DriverStop("cannot dispatch skeptics: HEAD moved from the recorded review base")
|
|
1400
|
+
groups = {group["locator"]: group for group in findings["groups"]}
|
|
1401
|
+
tasks = wave_tasks(self.primary, self.project_dir, self.wave, self.receipt)
|
|
1402
|
+
for locator in missing:
|
|
1403
|
+
state = {"task_id": f"review_wave_{self.wave}_cycle_{self.cycle}_skeptic_{locator}",
|
|
1404
|
+
"locator": locator, "wave": self.wave, "cycle": self.cycle, "base": base,
|
|
1405
|
+
"relative": f"{self.project_dir}/review/wave-{self.wave}.cycle{self.cycle}.skeptic-{locator}.md"}
|
|
1406
|
+
spawn_in_sidecar(self.root, self.primary, self.receipt, self.options,
|
|
1407
|
+
f"wave-{self.wave}-cycle-{self.cycle}-skeptic-{locator}", state,
|
|
1408
|
+
lambda fresh, group=groups[locator]: self.brief(fresh, group, tasks))
|
|
1409
|
+
states = self.states()
|
|
1410
|
+
if not self.poll(states, deadline):
|
|
1411
|
+
return self.receipt
|
|
1412
|
+
if self.receipt["blocked"]:
|
|
1413
|
+
self.receipt["status"] = "blocked"
|
|
1414
|
+
return self.receipt
|
|
1415
|
+
self.receipt["findings"] = self.compute() # the helper routes refutations and fix groups
|
|
1416
|
+
self.receipt["status"] = "done"
|
|
1417
|
+
return self.receipt
|
|
1418
|
+
except STOP_ERRORS as error:
|
|
1419
|
+
return stop(self.receipt, error)
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def next_task_id(tasks_dir: Path) -> str:
|
|
1423
|
+
numbers = []
|
|
1424
|
+
for path in tasks_dir.glob("*.md"):
|
|
1425
|
+
fields, _ = isolation.task_frontmatter(path.read_text(encoding="utf-8"))
|
|
1426
|
+
match = re.fullmatch(r"T(\d+)", str((fields or {}).get("id", "")))
|
|
1427
|
+
if match:
|
|
1428
|
+
numbers.append(int(match.group(1)))
|
|
1429
|
+
return f"T{max(numbers, default=0) + 1:03d}"
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def fix_tasks(primary: Path, options: argparse.Namespace) -> Dict[str, object]:
|
|
1433
|
+
"""Step 7: one fix task per findings batch, carrying failed criteria and observations verbatim."""
|
|
1434
|
+
receipt: Dict[str, object] = {"status": None, "wave": options.wave, "cycle": options.cycle,
|
|
1435
|
+
"created": [], "existing": [], "carried": [], "blocked": [], "steps": []}
|
|
1436
|
+
try:
|
|
1437
|
+
project = primary / options.project_dir
|
|
1438
|
+
tasks_dir = project / "tasks"
|
|
1439
|
+
repairs = []
|
|
1440
|
+
for path in sorted(tasks_dir.glob("*-fix-wave-*-cycle-*.md")):
|
|
1441
|
+
text = path.read_text(encoding="utf-8")
|
|
1442
|
+
fields, _ = isolation.task_frontmatter(text)
|
|
1443
|
+
repairs.append({"task": fields["id"], "path": path.relative_to(primary).as_posix(),
|
|
1444
|
+
"wave": int(fields["wave"]), "locators": re.findall(r"(?m)^### (.+)$", _common.section_body(text, "Review findings") or "")})
|
|
1445
|
+
suffix = f"-fix-wave-{options.wave}-cycle-{options.cycle}.md"
|
|
1446
|
+
existing = [item for item in repairs if item["path"].endswith(suffix)]
|
|
1447
|
+
carried = {locator for item in repairs if item not in existing for locator in item["locators"]}
|
|
1448
|
+
findings = review_findings.compute(primary, options.project_dir, options.wave, options.cycle)
|
|
1449
|
+
receipt["findings"] = findings
|
|
1450
|
+
escalation = [key for key in ("structural_blockers", "skeptic_groups", "cap_reached", "all_refuted")
|
|
1451
|
+
if findings.get(key)]
|
|
1452
|
+
if escalation: # step 7's branch table for these stays with the parent and the user
|
|
1453
|
+
receipt.update(status="escalate", escalation=escalation)
|
|
1454
|
+
return receipt
|
|
1455
|
+
if not findings.get("fix_batches"):
|
|
1456
|
+
receipt.update(status="none", reason="no fix batches")
|
|
1457
|
+
return receipt
|
|
1458
|
+
texts = contracts._task_texts(primary, options.project_dir)
|
|
1459
|
+
groups = {group["locator"]: group for group in findings["groups"]}
|
|
1460
|
+
today = now()[:10]
|
|
1461
|
+
# Same-wave file overlap is forbidden, so repairs land in a newly appended wave.
|
|
1462
|
+
plan_path = project / "plan/PLAN.md"
|
|
1463
|
+
plan_text = plan_path.read_text(encoding="utf-8")
|
|
1464
|
+
depths, waves = contracts._plan_waves(plan_text)
|
|
1465
|
+
repair_waves = {item["wave"] for item in existing}
|
|
1466
|
+
if len(repair_waves) > 1:
|
|
1467
|
+
raise DriverStop("repair tasks for this cycle disagree on their wave")
|
|
1468
|
+
repair_heading = re.search(
|
|
1469
|
+
rf"(?m)^## Wave (\d+) — fix wave {options.wave} cycle {options.cycle} review findings$", plan_text)
|
|
1470
|
+
if repair_waves:
|
|
1471
|
+
fix_wave = next(iter(repair_waves))
|
|
1472
|
+
elif repair_heading:
|
|
1473
|
+
fix_wave = int(repair_heading.group(1))
|
|
1474
|
+
else:
|
|
1475
|
+
fix_wave = max(waves) + 1
|
|
1476
|
+
if repair_heading and int(repair_heading.group(1)) != fix_wave:
|
|
1477
|
+
raise DriverStop("repair tasks disagree with the PLAN repair wave")
|
|
1478
|
+
receipt["existing"] = existing
|
|
1479
|
+
for batch in findings["fix_batches"]:
|
|
1480
|
+
if any(set(batch["locators"]) <= set(item["locators"]) for item in existing):
|
|
1481
|
+
continue
|
|
1482
|
+
if all(locator in carried for locator in batch["locators"]):
|
|
1483
|
+
receipt["carried"].append(batch)
|
|
1484
|
+
continue
|
|
1485
|
+
batch_groups = [groups[locator] for locator in batch["locators"]]
|
|
1486
|
+
sources = sorted({task for group in batch_groups for task in group["tasks"]})
|
|
1487
|
+
for source in sources:
|
|
1488
|
+
if source not in texts:
|
|
1489
|
+
raise DriverStop(f"fix batch names unknown task {source}")
|
|
1490
|
+
task_id = next_task_id(tasks_dir)
|
|
1491
|
+
verify = "set -e\n" + "\n".join( # each source Verify in its own subshell
|
|
1492
|
+
f"(\n{command}\n)" for command in dict.fromkeys(_common.task_verify_command(texts[source]) for source in sources))
|
|
1493
|
+
heavy = any(re.search(r"(?m)^Heavy:\s*yes", texts[source]) for source in sources)
|
|
1494
|
+
criteria = "\n".join(f"{index}. {criterion}" for index, criterion in
|
|
1495
|
+
enumerate(dict.fromkeys(group["criterion"] for group in batch_groups), 1))
|
|
1496
|
+
blocks = "\n\n".join(
|
|
1497
|
+
f"### {group['locator']}\nCriterion: {group['criterion']}\n"
|
|
1498
|
+
+ "\n".join(f"- {item['text']}" for item in group["observations"]) for group in batch_groups)
|
|
1499
|
+
files = "\n".join(f" - {path}" for path in batch["files"])
|
|
1500
|
+
title = f"Fix wave {options.wave} cycle {options.cycle} review findings in {', '.join(sources)}"
|
|
1501
|
+
text = f"""---
|
|
1502
|
+
id: {task_id}
|
|
1503
|
+
title: {title}
|
|
1504
|
+
wave: {fix_wave}
|
|
1505
|
+
deps: [{', '.join(sources)}]
|
|
1506
|
+
status: pending
|
|
1507
|
+
agent: null
|
|
1508
|
+
base: null
|
|
1509
|
+
worktree: null
|
|
1510
|
+
task_branch: null
|
|
1511
|
+
files:
|
|
1512
|
+
{files}
|
|
1513
|
+
---
|
|
1514
|
+
|
|
1515
|
+
# {task_id} — {title}
|
|
1516
|
+
|
|
1517
|
+
## Context
|
|
1518
|
+
|
|
1519
|
+
Repair task created from the wave {options.wave} cycle {options.cycle} review of {', '.join(sources)}.
|
|
1520
|
+
The failed criteria and every reviewer observation are recorded verbatim under Review findings.
|
|
1521
|
+
The source task contracts are unchanged; read them and .project/intent/INTENT.md before editing.
|
|
1522
|
+
|
|
1523
|
+
## Approach
|
|
1524
|
+
|
|
1525
|
+
- Fix only what the Review findings name, inside the listed files; do not change the source
|
|
1526
|
+
tasks' acceptance criteria or interface contracts.
|
|
1527
|
+
- Re-run the source tasks' Verify commands after the fix and record the result in the Log.
|
|
1528
|
+
|
|
1529
|
+
## Interface contract
|
|
1530
|
+
|
|
1531
|
+
- None
|
|
1532
|
+
|
|
1533
|
+
## Intent coverage
|
|
1534
|
+
|
|
1535
|
+
- None
|
|
1536
|
+
|
|
1537
|
+
## Acceptance criteria
|
|
1538
|
+
|
|
1539
|
+
{criteria}
|
|
1540
|
+
|
|
1541
|
+
## Verify
|
|
1542
|
+
|
|
1543
|
+
```bash
|
|
1544
|
+
{verify}
|
|
1545
|
+
```
|
|
1546
|
+
|
|
1547
|
+
Heavy: {'yes' if heavy else 'no'}
|
|
1548
|
+
|
|
1549
|
+
## Review findings
|
|
1550
|
+
|
|
1551
|
+
{blocks}
|
|
1552
|
+
|
|
1553
|
+
## Log
|
|
1554
|
+
|
|
1555
|
+
- {today} — created by dispatch_driver.py fix-tasks from wave {options.wave} cycle {options.cycle}
|
|
1556
|
+
"""
|
|
1557
|
+
path = tasks_dir / f"{task_id}-fix-wave-{options.wave}-cycle-{options.cycle}.md"
|
|
1558
|
+
_common.atomic_write(path, text)
|
|
1559
|
+
receipt["created"].append({"task": task_id, "path": path.relative_to(primary).as_posix(),
|
|
1560
|
+
"wave": fix_wave, "deps": sources, "files": batch["files"],
|
|
1561
|
+
"locators": batch["locators"]})
|
|
1562
|
+
rows = []
|
|
1563
|
+
for item in [*existing, *receipt["created"]]:
|
|
1564
|
+
fields, _ = isolation.task_frontmatter((primary / item["path"]).read_text(encoding="utf-8"))
|
|
1565
|
+
rows.append(f"| {item['task']} | {fields['title']} | {', '.join(fields['deps'])} | "
|
|
1566
|
+
f"{', '.join(fields['files'])} |")
|
|
1567
|
+
if not rows:
|
|
1568
|
+
receipt.update(status="none", reason="all fix batches already carried")
|
|
1569
|
+
return receipt
|
|
1570
|
+
block = (f"## Wave {fix_wave} — fix wave {options.wave} cycle {options.cycle} review findings\n\n"
|
|
1571
|
+
f"Goal: Repair the blocking findings of the wave {options.wave} cycle {options.cycle} review "
|
|
1572
|
+
"without changing source task contracts.\n"
|
|
1573
|
+
f"Review depth: {depths[options.wave]}\n\n| Task | Title | Deps | Files |\n|------|-------|------|-------|\n"
|
|
1574
|
+
+ "\n".join(rows) + "\n\n")
|
|
1575
|
+
if repair_heading:
|
|
1576
|
+
start = repair_heading.start()
|
|
1577
|
+
following = re.search(r"(?m)^## ", plan_text[repair_heading.end():])
|
|
1578
|
+
end = repair_heading.end() + following.start() if following else len(plan_text)
|
|
1579
|
+
current = plan_text[start:end]
|
|
1580
|
+
present = set(re.findall(r"(?m)^\|\s*(T\d+)\s*\|", current))
|
|
1581
|
+
missing_rows = [row for row in rows if row.split("|")[1].strip() not in present]
|
|
1582
|
+
updated = plan_text
|
|
1583
|
+
if missing_rows:
|
|
1584
|
+
updated = plan_text[:end].rstrip() + "\n" + "\n".join(missing_rows) + "\n\n" + plan_text[end:]
|
|
1585
|
+
else:
|
|
1586
|
+
marker = "## Intent coverage"
|
|
1587
|
+
index = plan_text.index(marker) if marker in plan_text else len(plan_text)
|
|
1588
|
+
updated = plan_text[:index] + block + plan_text[index:]
|
|
1589
|
+
plan_changed = updated != plan_text
|
|
1590
|
+
if plan_changed:
|
|
1591
|
+
_common.atomic_write(plan_path, updated)
|
|
1592
|
+
receipt["plan_wave"] = fix_wave
|
|
1593
|
+
lint = subprocess.run([sys.executable, "-B", str(Path(__file__).resolve().parent / "check_task_briefs.py"),
|
|
1594
|
+
"--repo", str(primary), "--base", isolation.current_sha(primary),
|
|
1595
|
+
"--tasks-dir", f"{options.project_dir}/tasks"], capture_output=True, text=True)
|
|
1596
|
+
receipt["steps"].append({"script": "check_task_briefs.py", "exit_code": lint.returncode,
|
|
1597
|
+
"stdout": lint.stdout, "stderr": lint.stderr})
|
|
1598
|
+
if lint.returncode:
|
|
1599
|
+
raise DriverStop("fix task briefs failed lint", stderr=lint.stderr.strip())
|
|
1600
|
+
# The blocked review and its fix tasks land in one bookkeeping checkpoint, as step 7 requires.
|
|
1601
|
+
dirty = any(path.startswith(".project/") for path in isolation.uncommitted_paths(primary))
|
|
1602
|
+
receipt["checkpoint"] = checkpoint_project(
|
|
1603
|
+
primary, receipt, f"build: record wave {options.wave} cycle {options.cycle} review and fix tasks",
|
|
1604
|
+
f"Why: persist the blocked review verdict with the fix tasks it batched\nWave: {options.wave}\n"
|
|
1605
|
+
f"Tasks: {', '.join(item['task'] for item in [*existing, *receipt['created']])}")
|
|
1606
|
+
receipt["status"] = "created" if receipt["created"] or plan_changed or dirty else "exists"
|
|
1607
|
+
return receipt
|
|
1608
|
+
except STOP_ERRORS as error:
|
|
1609
|
+
return stop(receipt, error)
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def complete(primary: Path, options: argparse.Namespace) -> Dict[str, object]:
|
|
1613
|
+
"""Completion: every wave landed and its last review cycle passed; landing proof; ship/active; checkpoint."""
|
|
1614
|
+
receipt: Dict[str, object] = {"status": None, "blocked": [], "steps": []}
|
|
1615
|
+
try:
|
|
1616
|
+
dirty = sorted(isolation.uncommitted_paths(primary))
|
|
1617
|
+
if dirty:
|
|
1618
|
+
raise DriverStop("completion requires a clean primary", paths=dirty)
|
|
1619
|
+
state, _, _ = pipeline_state.load_state(primary, options.project_dir)
|
|
1620
|
+
if (state.phase, state.status) not in (("build", "active"), ("build", "done")):
|
|
1621
|
+
raise DriverStop(f"completion requires build/active, not {state.phase}/{state.status}")
|
|
1622
|
+
ready = build_state.ready(str(primary), options.project_dir, allow_done=True)
|
|
1623
|
+
receipt["steps"].append({"script": "build_state.py ready", "result": ready})
|
|
1624
|
+
if ready["current_wave"] is not None:
|
|
1625
|
+
raise DriverStop(f"wave {ready['current_wave']} is still unfinished")
|
|
1626
|
+
# The archive-time check is the one definition of "every wave passed": contiguous cycles,
|
|
1627
|
+
# passing last-cycle wave and task verdicts, and the panel and skeptic artifacts each cycle owes.
|
|
1628
|
+
receipt["review_cycles"] = list(discussion_validate.review_cycle_counts(primary / options.project_dir))
|
|
1629
|
+
head = isolation.current_sha(primary)
|
|
1630
|
+
run_step(primary, options.project_dir, receipt, "build-evidence", head)
|
|
1631
|
+
transition(primary, options.project_dir, receipt, state, {"phase": "ship", "status": "active"},
|
|
1632
|
+
"build done; final review pending")
|
|
1633
|
+
receipt["checkpoint"] = checkpoint_project(
|
|
1634
|
+
primary, receipt, "build: complete milestone",
|
|
1635
|
+
f"Why: every wave landed and its latest review passed; landing proof recorded\nBase: {head}")
|
|
1636
|
+
receipt["status"] = "done"
|
|
1637
|
+
return receipt
|
|
1638
|
+
except STOP_ERRORS as error:
|
|
1639
|
+
return stop(receipt, error)
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
# --- other actions ---------------------------------------------------------
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def answer(primary: Path, task_id: str, text: str) -> Dict[str, object]:
|
|
1646
|
+
states = [state for state in latest_states(records_root(primary)) if state["task_id"] == task_id]
|
|
1647
|
+
if not states or states[0].get("outcome") != "question":
|
|
1648
|
+
raise DriverStop(f"task {task_id} has no open question")
|
|
1649
|
+
state = states[0]
|
|
1650
|
+
task_path = Path(str(state["worktree"])) / str(state["task_file"])
|
|
1651
|
+
append_log(task_path, f"- {now()[:10]} — {ANSWER_MARK} {text}")
|
|
1652
|
+
update_state(state, answered=True, answer=text)
|
|
1653
|
+
return {"status": "answered", "task": task_id, "task_file": str(task_path),
|
|
1654
|
+
"next": "run `round` to redispatch the retained isolate"}
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def require_files(parser: argparse.ArgumentParser, arguments: argparse.Namespace, *names: str) -> None:
|
|
1658
|
+
for name in names:
|
|
1659
|
+
value = getattr(arguments, name)
|
|
1660
|
+
if value is None or not Path(value).is_file():
|
|
1661
|
+
parser.error(f"--{name.replace('_', '-')} must name an existing file")
|
|
1662
|
+
setattr(arguments, name, Path(value).resolve())
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def positive_int(value: str) -> int:
|
|
1666
|
+
number = int(value)
|
|
1667
|
+
if number < 1:
|
|
1668
|
+
raise argparse.ArgumentTypeError("value must be at least 1")
|
|
1669
|
+
return number
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def default_resource(name: str) -> Optional[Path]:
|
|
1673
|
+
candidate = Path(__file__).resolve().parent.parent / name
|
|
1674
|
+
return candidate if candidate.is_file() else None
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def main(argv=None) -> int:
|
|
1678
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1679
|
+
commands = parser.add_subparsers(dest="action", required=True)
|
|
1680
|
+
round_parser = commands.add_parser("round", help="settle, land, and dispatch the current wave")
|
|
1681
|
+
round_parser.add_argument("--repo", type=Path, required=True)
|
|
1682
|
+
round_parser.add_argument("--project-dir", default=".project")
|
|
1683
|
+
round_parser.add_argument("--child-command", required=True,
|
|
1684
|
+
help="owner-supplied command that reads the brief on stdin")
|
|
1685
|
+
round_parser.add_argument("--wait", type=float, help="seconds to keep polling before returning")
|
|
1686
|
+
round_parser.add_argument("--wave", type=int, required=True, help="wave selected by the parent")
|
|
1687
|
+
round_parser.add_argument("--child-timeout", type=float, help="per-child wall clock in seconds")
|
|
1688
|
+
round_parser.add_argument("--capacity", type=positive_int, help="owner's concurrent-child limit")
|
|
1689
|
+
round_parser.add_argument("--max-attempts", type=positive_int, default=2,
|
|
1690
|
+
help="dispatches per task per milestone before a person must rule; "
|
|
1691
|
+
"default 2 is the build contract's one logged redispatch")
|
|
1692
|
+
round_parser.add_argument("--task-limit", type=positive_int, help="owner output-token limit per task")
|
|
1693
|
+
round_parser.add_argument("--session-limit", type=positive_int,
|
|
1694
|
+
help="owner output-token limit for the milestone")
|
|
1695
|
+
round_parser.add_argument("--budget-authority", help="quoted owner policy for the limits")
|
|
1696
|
+
round_parser.add_argument("--role-brief", type=Path, default=default_resource("references/coder.md"))
|
|
1697
|
+
round_parser.add_argument("--task-template", type=Path, default=default_resource("templates/task.md"))
|
|
1698
|
+
cycle_child = argparse.ArgumentParser(add_help=False) # shared by the review-cycle child runners
|
|
1699
|
+
cycle_child.add_argument("--repo", type=Path, required=True)
|
|
1700
|
+
cycle_child.add_argument("--project-dir", default=".project")
|
|
1701
|
+
cycle_child.add_argument("--wave", type=int, required=True)
|
|
1702
|
+
cycle_child.add_argument("--cycle", type=positive_int, required=True)
|
|
1703
|
+
cycle_child.add_argument("--child-command", required=True,
|
|
1704
|
+
help="owner-supplied command that reads the brief on stdin; "
|
|
1705
|
+
"for panel, a literal {model} is replaced by the family slug")
|
|
1706
|
+
cycle_child.add_argument("--wait", type=float, help="seconds to keep polling before returning")
|
|
1707
|
+
cycle_child.add_argument("--child-timeout", type=float, help="per-child wall clock in seconds")
|
|
1708
|
+
cycle_child.add_argument("--role-brief", type=Path, default=default_resource("references/reviewer.md"))
|
|
1709
|
+
review_parser = commands.add_parser("review", parents=[cycle_child],
|
|
1710
|
+
help="run one wave review cycle at full or deep depth")
|
|
1711
|
+
review_parser.add_argument("--repair-evidence", type=Path,
|
|
1712
|
+
help="review_findings.py repair-evidence receipt for a re-review")
|
|
1713
|
+
review_parser.add_argument("--template", type=Path, default=default_resource("templates/wave-review.md"))
|
|
1714
|
+
panel_parser = commands.add_parser("panel", parents=[cycle_child],
|
|
1715
|
+
help="run the optional review panel for a collected wave review")
|
|
1716
|
+
panel_parser.add_argument("--advertised", required=True, help="comma-separated model slugs the host advertises")
|
|
1717
|
+
panel_parser.add_argument("--parent-slug", help="the parent's model slug when known")
|
|
1718
|
+
panel_parser.add_argument("--template", type=Path, default=default_resource("templates/wave-panel.md"))
|
|
1719
|
+
skeptic_parser = commands.add_parser("skeptics", parents=[cycle_child],
|
|
1720
|
+
help="run one finding skeptic per needs-skeptic locator")
|
|
1721
|
+
skeptic_parser.add_argument("--template", type=Path, default=default_resource("templates/skeptic.md"))
|
|
1722
|
+
fix_parser = commands.add_parser("fix-tasks", help="write one fix task per findings batch of a blocked cycle")
|
|
1723
|
+
fix_parser.add_argument("--repo", type=Path, required=True)
|
|
1724
|
+
fix_parser.add_argument("--project-dir", default=".project")
|
|
1725
|
+
fix_parser.add_argument("--wave", type=int, required=True)
|
|
1726
|
+
fix_parser.add_argument("--cycle", type=positive_int, required=True)
|
|
1727
|
+
complete_parser = commands.add_parser(
|
|
1728
|
+
"complete", help="prove every wave landed and passed review, record landing evidence, enter ship")
|
|
1729
|
+
complete_parser.add_argument("--repo", type=Path, required=True)
|
|
1730
|
+
complete_parser.add_argument("--project-dir", default=".project")
|
|
1731
|
+
finish_parser = commands.add_parser(
|
|
1732
|
+
"finish", help="verify, land, record, and retire one in-progress task whose coder has returned")
|
|
1733
|
+
finish_parser.add_argument("--repo", type=Path, required=True)
|
|
1734
|
+
finish_parser.add_argument("--task-id", required=True)
|
|
1735
|
+
finish_parser.add_argument("--project-dir", default=".project")
|
|
1736
|
+
answer_parser = commands.add_parser("answer", help="record an orchestrator answer for a question")
|
|
1737
|
+
answer_parser.add_argument("--repo", type=Path, required=True)
|
|
1738
|
+
answer_parser.add_argument("--task-id", required=True)
|
|
1739
|
+
answer_parser.add_argument("--answer", required=True)
|
|
1740
|
+
status_parser = commands.add_parser("status", help="list dispatch records")
|
|
1741
|
+
status_parser.add_argument("--repo", type=Path, required=True)
|
|
1742
|
+
child_parser = commands.add_parser("_child", help=argparse.SUPPRESS)
|
|
1743
|
+
child_parser.add_argument("--state", type=Path, required=True)
|
|
1744
|
+
arguments = parser.parse_args(argv)
|
|
1745
|
+
|
|
1746
|
+
if arguments.action == "_child":
|
|
1747
|
+
return child_main(arguments.state)
|
|
1748
|
+
primary = arguments.repo.resolve()
|
|
1749
|
+
lock = None
|
|
1750
|
+
try:
|
|
1751
|
+
if arguments.action != "status":
|
|
1752
|
+
lock = acquire_lock(primary)
|
|
1753
|
+
if arguments.action == "panel":
|
|
1754
|
+
require_files(parser, arguments, "role_brief", "template")
|
|
1755
|
+
result = Panel(primary, arguments).run()
|
|
1756
|
+
elif arguments.action == "skeptics":
|
|
1757
|
+
require_files(parser, arguments, "role_brief", "template")
|
|
1758
|
+
result = Skeptics(primary, arguments).run()
|
|
1759
|
+
elif arguments.action == "fix-tasks":
|
|
1760
|
+
result = fix_tasks(primary, arguments)
|
|
1761
|
+
elif arguments.action == "complete":
|
|
1762
|
+
result = complete(primary, arguments)
|
|
1763
|
+
elif arguments.action == "review":
|
|
1764
|
+
require_files(parser, arguments, "role_brief", "template",
|
|
1765
|
+
*(["repair_evidence"] if arguments.repair_evidence is not None else []))
|
|
1766
|
+
result = Review(primary, arguments).run()
|
|
1767
|
+
elif arguments.action == "round":
|
|
1768
|
+
budget_values = (arguments.task_limit, arguments.session_limit, arguments.budget_authority)
|
|
1769
|
+
if any(value is not None for value in budget_values) and None in budget_values:
|
|
1770
|
+
parser.error("--task-limit, --session-limit, and --budget-authority go together")
|
|
1771
|
+
require_files(parser, arguments, "role_brief", "task_template")
|
|
1772
|
+
result = Round(primary, arguments).run()
|
|
1773
|
+
elif arguments.action == "finish":
|
|
1774
|
+
current = Round(primary, arguments)
|
|
1775
|
+
current.recover()
|
|
1776
|
+
records = [state for state in latest_states(current.root)
|
|
1777
|
+
if state["task_id"] == arguments.task_id]
|
|
1778
|
+
if arguments.task_id in current.proven:
|
|
1779
|
+
commit = current.proven[arguments.task_id]
|
|
1780
|
+
if records and records[0].get("outcome") is None:
|
|
1781
|
+
update_state(records[0], outcome="landed", commit=commit)
|
|
1782
|
+
current.receipt["landed"].append(
|
|
1783
|
+
{"task": arguments.task_id, "commit": commit, "recovered": True})
|
|
1784
|
+
elif records:
|
|
1785
|
+
record = records[0]
|
|
1786
|
+
if record.get("outcome") is not None:
|
|
1787
|
+
raise DriverStop(f"task {arguments.task_id} dispatch record is {record['outcome']}; "
|
|
1788
|
+
"use round")
|
|
1789
|
+
if record.get("finished_at") is None:
|
|
1790
|
+
raise DriverStop(f"task {arguments.task_id} still has a running child")
|
|
1791
|
+
current.classify(record)
|
|
1792
|
+
else: # a coder dispatched by hand: derive the isolate from the task frontmatter
|
|
1793
|
+
current.receipt["landed"].append(
|
|
1794
|
+
finish_task(primary, state_from_task(primary, arguments.task_id)))
|
|
1795
|
+
result = {**current.receipt, "status": "landed" if current.receipt["landed"] else "blocked"}
|
|
1796
|
+
elif arguments.action == "answer":
|
|
1797
|
+
result = answer(primary, arguments.task_id, arguments.answer)
|
|
1798
|
+
else:
|
|
1799
|
+
result = {"status": "ok", "dispatches": [
|
|
1800
|
+
{key: value for key, value in state.items() if key not in ("_path", "command")}
|
|
1801
|
+
for state in latest_states(records_root(primary))]}
|
|
1802
|
+
except STOP_ERRORS as error:
|
|
1803
|
+
result = {"status": "blocked", "reason": str(error), **getattr(error, "details", {})}
|
|
1804
|
+
finally:
|
|
1805
|
+
if lock is not None:
|
|
1806
|
+
lock.close()
|
|
1807
|
+
print(json.dumps(result, sort_keys=True, default=str))
|
|
1808
|
+
return 0 if result["status"] != "blocked" else 1
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
if __name__ == "__main__":
|
|
1812
|
+
raise SystemExit(main())
|