@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,2291 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Validate, route, and change GSD Path state without model-side parsing.
|
|
4
|
+
|
|
5
|
+
The helper owns the fixed-format STATE.md interface. It checkpoints plan and
|
|
6
|
+
roadmap approvals, promotes a lookahead track as a resumable transaction, and
|
|
7
|
+
classifies plan drift before the promoted milestone can enter build.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import contextlib
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import asdict, dataclass
|
|
23
|
+
from datetime import date
|
|
24
|
+
from pathlib import Path, PurePosixPath
|
|
25
|
+
from typing import Iterator, Mapping, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from isolation import (
|
|
29
|
+
IsolationError,
|
|
30
|
+
authorized_task_worktree,
|
|
31
|
+
checkpoint as isolation_checkpoint,
|
|
32
|
+
collect_artifact_recoveries,
|
|
33
|
+
)
|
|
34
|
+
import _common
|
|
35
|
+
except ModuleNotFoundError as error: # pragma: no cover - package imports used by tests
|
|
36
|
+
if error.name not in {"isolation", "_common"}:
|
|
37
|
+
raise
|
|
38
|
+
from scripts.isolation import (
|
|
39
|
+
IsolationError,
|
|
40
|
+
authorized_task_worktree,
|
|
41
|
+
checkpoint as isolation_checkpoint,
|
|
42
|
+
collect_artifact_recoveries,
|
|
43
|
+
)
|
|
44
|
+
from scripts import _common
|
|
45
|
+
|
|
46
|
+
try: # pragma: no cover - exercised only on Windows
|
|
47
|
+
import fcntl
|
|
48
|
+
except ImportError: # pragma: no cover - exercised only on Windows
|
|
49
|
+
fcntl = None # type: ignore[assignment]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
PIPELINE_MARKER = _common.PIPELINE_MARKER
|
|
53
|
+
PHASES = (
|
|
54
|
+
"inspect",
|
|
55
|
+
"define",
|
|
56
|
+
"research",
|
|
57
|
+
"decide",
|
|
58
|
+
"roadmap",
|
|
59
|
+
"plan",
|
|
60
|
+
"build",
|
|
61
|
+
"ship",
|
|
62
|
+
"shipped",
|
|
63
|
+
)
|
|
64
|
+
STATUSES = ("active", "done", "blocked")
|
|
65
|
+
STATE_FIELDS = (
|
|
66
|
+
"pipeline",
|
|
67
|
+
"project",
|
|
68
|
+
"milestone",
|
|
69
|
+
"phase",
|
|
70
|
+
"status",
|
|
71
|
+
"branch",
|
|
72
|
+
"archive",
|
|
73
|
+
"integration_default",
|
|
74
|
+
"integration",
|
|
75
|
+
"integration_source",
|
|
76
|
+
)
|
|
77
|
+
LEGACY_STATE_FIELDS = STATE_FIELDS[:7]
|
|
78
|
+
INTEGRATION_MODES = ("direct", "pull-request")
|
|
79
|
+
INTEGRATION_SOURCES = ("default", "milestone")
|
|
80
|
+
NULL = "null"
|
|
81
|
+
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
82
|
+
BOUND_BRANCH_RE = _common.BOUND_BRANCH_RE
|
|
83
|
+
ARCHIVE_RE = re.compile(r"^\.project/archive/(\d{3,})-([a-z0-9][a-z0-9-]*)/?$")
|
|
84
|
+
FRONTMATTER_RE = re.compile(r"^([a-z_]+):\s*([^#]*?)(?:\s+#.*)?$")
|
|
85
|
+
ROADMAP_HEADING_RE = re.compile(r"^### (M\d{3,}) — ([a-z0-9][a-z0-9-]*)\s*$")
|
|
86
|
+
TASK_ID_RE = re.compile(r"^T\d{3,}$")
|
|
87
|
+
TASK_FILE_RE = re.compile(r"^(T\d{3,})-[a-z0-9][a-z0-9-]*\.md$")
|
|
88
|
+
PLAN_APPROVAL_SUBJECT = "plan: build plan approved"
|
|
89
|
+
PROMOTION_SCHEMA = "gsd-path/promote-next/v1"
|
|
90
|
+
STATE_SCHEMA = "gsd-path/state/v1"
|
|
91
|
+
ROUTE_SCHEMA = "gsd-path/route/v1"
|
|
92
|
+
STATUS_SCHEMA = "gsd-path/status/v1"
|
|
93
|
+
LEGACY_BIND_NEXT_JOURNAL_SCHEMA = "gsd-path/bind-next-journal/v1"
|
|
94
|
+
BIND_NEXT_JOURNAL_SCHEMA = "gsd-path/bind-next-journal/v2"
|
|
95
|
+
BIND_NEXT_JOURNAL_DIR = "gsd-path-bind-next"
|
|
96
|
+
PROMOTION_TRACKS = ("intent", "research", "plan", "tasks", "review")
|
|
97
|
+
LOOKAHEAD_PHASES = ("inspect", "define", "research", "decide", "plan")
|
|
98
|
+
LOOKAHEAD_FIXED_ARTIFACTS = {
|
|
99
|
+
"STATE.md": "inspect",
|
|
100
|
+
"research/evidence-codebase.md": "inspect",
|
|
101
|
+
"research/DOCS-AUDIT.md": "inspect",
|
|
102
|
+
"intent/INTENT.md": "define",
|
|
103
|
+
"research/RESEARCH.md": "research",
|
|
104
|
+
"research/SYNTHESIS.md": "decide",
|
|
105
|
+
"plan/PLAN.md": "plan",
|
|
106
|
+
"review/PLAN-PANEL.md": "plan",
|
|
107
|
+
"review/PLAN-PANEL.skipped.json": "plan",
|
|
108
|
+
}
|
|
109
|
+
LOOKAHEAD_EVIDENCE_RE = re.compile(r"^research/evidence-[a-z0-9][a-z0-9-]*\.md$")
|
|
110
|
+
PROMOTION_RESIDUAL = ".gsd-path-promote-next-residual"
|
|
111
|
+
CHECKPOINT_SCHEMA = "gsd-path/state-checkpoint/v1"
|
|
112
|
+
CHECKPOINT_JOURNAL_NAME = "gsd-path-state-checkpoint.json"
|
|
113
|
+
CHECKPOINT_KINDS = ("plan", "roadmap")
|
|
114
|
+
SHIPMENT_SCHEMA = "gsd-path/shipment/v1"
|
|
115
|
+
SHIPMENT_JOURNAL_NAME = "gsd-path-shipment.json"
|
|
116
|
+
UNDO_TRANSACTION_SCHEMA = "gsd-path/undo-transaction/v2"
|
|
117
|
+
UNDO_TRANSACTION_NAME = "gsd-path-undo.json"
|
|
118
|
+
|
|
119
|
+
CROSS_PHASE_TRANSITIONS = {
|
|
120
|
+
("inspect", "done", "define", "active"),
|
|
121
|
+
("define", "done", "research", "active"),
|
|
122
|
+
("define", "done", "plan", "active"),
|
|
123
|
+
("research", "done", "decide", "active"),
|
|
124
|
+
("decide", "done", "roadmap", "active"),
|
|
125
|
+
("define", "active", "roadmap", "active"),
|
|
126
|
+
("decide", "done", "plan", "active"),
|
|
127
|
+
("roadmap", "done", "define", "active"),
|
|
128
|
+
("plan", "done", "build", "active"),
|
|
129
|
+
("build", "active", "ship", "active"),
|
|
130
|
+
("build", "done", "ship", "active"),
|
|
131
|
+
("ship", "active", "shipped", "done"),
|
|
132
|
+
("build", "active", "roadmap", "active"),
|
|
133
|
+
("build", "blocked", "roadmap", "active"),
|
|
134
|
+
("ship", "blocked", "plan", "active"),
|
|
135
|
+
("shipped", "done", "define", "active"),
|
|
136
|
+
("shipped", "done", "inspect", "active"),
|
|
137
|
+
("roadmap", "active", "inspect", "active"),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class PipelineStateError(RuntimeError):
|
|
142
|
+
"""Raised when pipeline state cannot be proven or changed safely."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class PipelineState:
|
|
147
|
+
pipeline: str
|
|
148
|
+
project: str
|
|
149
|
+
milestone: Optional[str]
|
|
150
|
+
phase: str
|
|
151
|
+
status: str
|
|
152
|
+
branch: Optional[str]
|
|
153
|
+
archive: Optional[str]
|
|
154
|
+
integration_default: str
|
|
155
|
+
integration: str
|
|
156
|
+
integration_source: str
|
|
157
|
+
|
|
158
|
+
def json(self) -> dict[str, Optional[str]]:
|
|
159
|
+
return asdict(self)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class ApprovedTaskContract:
|
|
164
|
+
path: str
|
|
165
|
+
text: str
|
|
166
|
+
files: tuple[str, ...]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# state_checkpoint and state_promote call back into this module through a
|
|
170
|
+
# module reference and import the constants and classes above, so this import
|
|
171
|
+
# must stay below those definitions.
|
|
172
|
+
if __package__: # imported as scripts.pipeline_state
|
|
173
|
+
from .state_checkpoint import (
|
|
174
|
+
_sha256,
|
|
175
|
+
_tree_digest,
|
|
176
|
+
_checkpoint_artifact_digest,
|
|
177
|
+
_roadmap_status,
|
|
178
|
+
_activate_roadmap_milestone,
|
|
179
|
+
_approval_details,
|
|
180
|
+
_checkpoint_request_fields,
|
|
181
|
+
_checkpoint_file,
|
|
182
|
+
_approval_journal,
|
|
183
|
+
_remove_checkpoint_temporary,
|
|
184
|
+
_converge_checkpoint_file,
|
|
185
|
+
_unlink_checkpoint_journal,
|
|
186
|
+
_resume_checkpoint_locked,
|
|
187
|
+
checkpoint_approval,
|
|
188
|
+
_checkpoint_deferral_error,
|
|
189
|
+
defer_approval,
|
|
190
|
+
resume_checkpoint,
|
|
191
|
+
_task_files,
|
|
192
|
+
_approval_checkpoint,
|
|
193
|
+
_validate_approval_track,
|
|
194
|
+
_safe_declared_paths,
|
|
195
|
+
_git_text_at,
|
|
196
|
+
_approved_task_contracts,
|
|
197
|
+
_task_contracts_at,
|
|
198
|
+
_classify_plan_drift,
|
|
199
|
+
)
|
|
200
|
+
from .state_promote import (
|
|
201
|
+
_render_roadmap,
|
|
202
|
+
_promotion_event,
|
|
203
|
+
_promotion_state,
|
|
204
|
+
_head_with_exact_message,
|
|
205
|
+
_tree_entries,
|
|
206
|
+
_promotion_tree_mapping,
|
|
207
|
+
_completed_promotion,
|
|
208
|
+
_prepare_promotion,
|
|
209
|
+
_require_journal_request,
|
|
210
|
+
_resume_track_moves,
|
|
211
|
+
_resume_metadata,
|
|
212
|
+
_resume_next_removal,
|
|
213
|
+
_commit_promotion,
|
|
214
|
+
promote_next,
|
|
215
|
+
)
|
|
216
|
+
else: # standalone script or sibling import
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
sys.modules.setdefault("pipeline_state", sys.modules[__name__])
|
|
219
|
+
from state_checkpoint import (
|
|
220
|
+
_sha256,
|
|
221
|
+
_tree_digest,
|
|
222
|
+
_checkpoint_artifact_digest,
|
|
223
|
+
_roadmap_status,
|
|
224
|
+
_activate_roadmap_milestone,
|
|
225
|
+
_approval_details,
|
|
226
|
+
_checkpoint_request_fields,
|
|
227
|
+
_checkpoint_file,
|
|
228
|
+
_approval_journal,
|
|
229
|
+
_remove_checkpoint_temporary,
|
|
230
|
+
_converge_checkpoint_file,
|
|
231
|
+
_unlink_checkpoint_journal,
|
|
232
|
+
_resume_checkpoint_locked,
|
|
233
|
+
checkpoint_approval,
|
|
234
|
+
_checkpoint_deferral_error,
|
|
235
|
+
defer_approval,
|
|
236
|
+
resume_checkpoint,
|
|
237
|
+
_task_files,
|
|
238
|
+
_approval_checkpoint,
|
|
239
|
+
_validate_approval_track,
|
|
240
|
+
_safe_declared_paths,
|
|
241
|
+
_git_text_at,
|
|
242
|
+
_approved_task_contracts,
|
|
243
|
+
_task_contracts_at,
|
|
244
|
+
_classify_plan_drift,
|
|
245
|
+
)
|
|
246
|
+
from state_promote import (
|
|
247
|
+
_render_roadmap,
|
|
248
|
+
_promotion_event,
|
|
249
|
+
_promotion_state,
|
|
250
|
+
_head_with_exact_message,
|
|
251
|
+
_tree_entries,
|
|
252
|
+
_promotion_tree_mapping,
|
|
253
|
+
_completed_promotion,
|
|
254
|
+
_prepare_promotion,
|
|
255
|
+
_require_journal_request,
|
|
256
|
+
_resume_track_moves,
|
|
257
|
+
_resume_metadata,
|
|
258
|
+
_resume_next_removal,
|
|
259
|
+
_commit_promotion,
|
|
260
|
+
promote_next,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _run_git(
|
|
264
|
+
repo: Path,
|
|
265
|
+
*arguments: str,
|
|
266
|
+
check: bool = True,
|
|
267
|
+
) -> subprocess.CompletedProcess[str]:
|
|
268
|
+
result = subprocess.run(
|
|
269
|
+
["git", "-C", str(repo), *arguments],
|
|
270
|
+
capture_output=True,
|
|
271
|
+
text=True,
|
|
272
|
+
)
|
|
273
|
+
if check and result.returncode != 0:
|
|
274
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
275
|
+
raise PipelineStateError(f"git {' '.join(arguments)} failed: {detail}")
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _repo_root(repo: Path) -> Path:
|
|
280
|
+
if repo.is_symlink() or not repo.is_dir():
|
|
281
|
+
raise PipelineStateError(f"repo must be a real directory: {repo}")
|
|
282
|
+
resolved = repo.resolve()
|
|
283
|
+
top = _run_git(resolved, "rev-parse", "--show-toplevel", check=False)
|
|
284
|
+
if top.returncode == 0 and Path(top.stdout.strip()).resolve() != resolved:
|
|
285
|
+
raise PipelineStateError(f"repo must be the worktree root: {resolved}")
|
|
286
|
+
return resolved
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _track_root(repo: Path, project_dir: str) -> Path:
|
|
290
|
+
repo = repo.resolve()
|
|
291
|
+
relative = PurePosixPath(project_dir)
|
|
292
|
+
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
|
293
|
+
raise PipelineStateError(f"project-dir must stay inside repo: {project_dir}")
|
|
294
|
+
root = repo.joinpath(*relative.parts)
|
|
295
|
+
if root.is_symlink() or not root.is_dir():
|
|
296
|
+
raise PipelineStateError(f"project directory must be real: {root}")
|
|
297
|
+
if repo not in root.resolve().parents:
|
|
298
|
+
raise PipelineStateError(f"project directory escapes repo: {root}")
|
|
299
|
+
return root
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _read_real_file(path: Path, label: str) -> str:
|
|
303
|
+
if path.is_symlink() or not path.is_file():
|
|
304
|
+
raise PipelineStateError(f"{label} must be a real file: {path}")
|
|
305
|
+
return path.read_text(encoding="utf-8")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _optional_real_file(path: Path, label: str) -> Optional[str]:
|
|
309
|
+
if not path.exists() and not path.is_symlink():
|
|
310
|
+
return None
|
|
311
|
+
return _read_real_file(path, label)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _parse_frontmatter(text: str, label: str) -> dict[str, str]:
|
|
315
|
+
lines = text.splitlines()
|
|
316
|
+
if not lines or lines[0] != "---":
|
|
317
|
+
raise PipelineStateError(f"{label} is missing YAML frontmatter")
|
|
318
|
+
values: dict[str, str] = {}
|
|
319
|
+
for line in lines[1:]:
|
|
320
|
+
if line == "---":
|
|
321
|
+
return values
|
|
322
|
+
stripped = line.strip()
|
|
323
|
+
if not stripped or stripped.startswith("#"):
|
|
324
|
+
continue
|
|
325
|
+
match = FRONTMATTER_RE.fullmatch(line)
|
|
326
|
+
if match is None:
|
|
327
|
+
raise PipelineStateError(f"{label} has unsupported frontmatter: {line}")
|
|
328
|
+
key, raw_value = match.groups()
|
|
329
|
+
if key in values:
|
|
330
|
+
raise PipelineStateError(f"{label} repeats frontmatter field: {key}")
|
|
331
|
+
values[key] = raw_value.strip().strip("\"'")
|
|
332
|
+
raise PipelineStateError(f"{label} frontmatter is not closed")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _nullable(value: str) -> Optional[str]:
|
|
336
|
+
return None if value == NULL else value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _bound_branch_number(value: str) -> Optional[int]:
|
|
340
|
+
match = BOUND_BRANCH_RE.fullmatch(value)
|
|
341
|
+
if match is None:
|
|
342
|
+
return None
|
|
343
|
+
number = int(match.group(1))
|
|
344
|
+
return number if number >= 1 else None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _state_from_text(text: str, label: str = "STATE.md") -> PipelineState:
|
|
348
|
+
values = _parse_frontmatter(text, label)
|
|
349
|
+
missing = [field for field in LEGACY_STATE_FIELDS if field not in values]
|
|
350
|
+
extra = sorted(set(values) - set(STATE_FIELDS))
|
|
351
|
+
if missing:
|
|
352
|
+
raise PipelineStateError(f"{label} is missing fields: {', '.join(missing)}")
|
|
353
|
+
if extra:
|
|
354
|
+
raise PipelineStateError(f"{label} has unknown fields: {', '.join(extra)}")
|
|
355
|
+
integration_pair = {"integration_default", "integration"}
|
|
356
|
+
integration_fields = integration_pair | {"integration_source"}
|
|
357
|
+
present_integration_fields = integration_fields & set(values)
|
|
358
|
+
if present_integration_fields not in (set(), integration_pair, integration_fields):
|
|
359
|
+
raise PipelineStateError(
|
|
360
|
+
f"{label} integration_default and integration must appear together"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
integration_default = values.get("integration_default", "direct")
|
|
364
|
+
integration = values.get("integration", "direct")
|
|
365
|
+
integration_source = values.get("integration_source")
|
|
366
|
+
if integration_source is None:
|
|
367
|
+
integration_source = (
|
|
368
|
+
"default" if integration == integration_default else "milestone"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
state = PipelineState(
|
|
372
|
+
pipeline=values["pipeline"],
|
|
373
|
+
project=values["project"],
|
|
374
|
+
milestone=_nullable(values["milestone"]),
|
|
375
|
+
phase=values["phase"],
|
|
376
|
+
status=values["status"],
|
|
377
|
+
branch=_nullable(values["branch"]),
|
|
378
|
+
archive=_nullable(values["archive"]),
|
|
379
|
+
integration_default=integration_default,
|
|
380
|
+
integration=integration,
|
|
381
|
+
integration_source=integration_source,
|
|
382
|
+
)
|
|
383
|
+
_validate_state_values(state, label)
|
|
384
|
+
return state
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _validate_state_values(state: PipelineState, label: str) -> None:
|
|
388
|
+
if state.pipeline != PIPELINE_MARKER:
|
|
389
|
+
raise PipelineStateError(f"{label} pipeline must be {PIPELINE_MARKER}")
|
|
390
|
+
if state.project == NULL or not SLUG_RE.fullmatch(state.project):
|
|
391
|
+
raise PipelineStateError(f"{label} project must be a slug")
|
|
392
|
+
if state.milestone is not None and not SLUG_RE.fullmatch(state.milestone):
|
|
393
|
+
raise PipelineStateError(f"{label} milestone must be null or a slug")
|
|
394
|
+
if state.phase not in PHASES:
|
|
395
|
+
raise PipelineStateError(f"{label} has invalid phase: {state.phase}")
|
|
396
|
+
if state.status not in STATUSES:
|
|
397
|
+
raise PipelineStateError(f"{label} has invalid status: {state.status}")
|
|
398
|
+
if state.integration_default not in INTEGRATION_MODES:
|
|
399
|
+
raise PipelineStateError(
|
|
400
|
+
f"{label} has invalid integration_default: {state.integration_default}"
|
|
401
|
+
)
|
|
402
|
+
if state.integration not in INTEGRATION_MODES:
|
|
403
|
+
raise PipelineStateError(f"{label} has invalid integration: {state.integration}")
|
|
404
|
+
if state.integration_source not in INTEGRATION_SOURCES:
|
|
405
|
+
raise PipelineStateError(
|
|
406
|
+
f"{label} has invalid integration_source: {state.integration_source}"
|
|
407
|
+
)
|
|
408
|
+
if (
|
|
409
|
+
state.integration_source == "default"
|
|
410
|
+
and state.integration != state.integration_default
|
|
411
|
+
):
|
|
412
|
+
raise PipelineStateError(
|
|
413
|
+
f"{label} default-sourced integration must match integration_default"
|
|
414
|
+
)
|
|
415
|
+
if state.branch is not None and _bound_branch_number(state.branch) is None:
|
|
416
|
+
raise PipelineStateError(f"{label} has invalid branch: {state.branch}")
|
|
417
|
+
archive_match = ARCHIVE_RE.fullmatch(state.archive or "")
|
|
418
|
+
if state.archive is not None and (
|
|
419
|
+
archive_match is None or int(archive_match.group(1)) < 1
|
|
420
|
+
):
|
|
421
|
+
raise PipelineStateError(f"{label} has invalid archive: {state.archive}")
|
|
422
|
+
if state.phase == "shipped" and state.status != "done":
|
|
423
|
+
raise PipelineStateError(f"{label} shipped phase requires status done")
|
|
424
|
+
if state.phase == "shipped" and state.archive is None:
|
|
425
|
+
raise PipelineStateError(f"{label} shipped phase requires an archive")
|
|
426
|
+
if state.archive is not None and state.phase not in {"build", "ship", "shipped"}:
|
|
427
|
+
raise PipelineStateError(
|
|
428
|
+
f"{label} archive is only legal during build, ship, or shipped"
|
|
429
|
+
)
|
|
430
|
+
if state.phase in {"ship", "shipped"} and state.branch is None:
|
|
431
|
+
raise PipelineStateError(f"{label} {state.phase} phase requires a branch")
|
|
432
|
+
if archive_match is not None:
|
|
433
|
+
if state.milestone != archive_match.group(2):
|
|
434
|
+
raise PipelineStateError(f"{label} archive does not match milestone")
|
|
435
|
+
branch_number = _bound_branch_number(state.branch or "")
|
|
436
|
+
if branch_number is None or branch_number != int(archive_match.group(1)):
|
|
437
|
+
raise PipelineStateError(f"{label} archive does not match branch")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _is_lookahead(project_dir: str) -> bool:
|
|
441
|
+
return PurePosixPath(project_dir) == PurePosixPath(".project/next")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _validate_state_context(
|
|
445
|
+
state: PipelineState,
|
|
446
|
+
project_dir: str,
|
|
447
|
+
label: str,
|
|
448
|
+
) -> None:
|
|
449
|
+
if not _is_lookahead(project_dir):
|
|
450
|
+
return
|
|
451
|
+
if state.branch is not None or state.archive is not None:
|
|
452
|
+
raise PipelineStateError(f"{label} lookahead branch and archive must be null")
|
|
453
|
+
if state.phase not in LOOKAHEAD_PHASES:
|
|
454
|
+
raise PipelineStateError(f"{label} lookahead cannot enter {state.phase}")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def load_state(repo: Path, project_dir: str = ".project") -> tuple[PipelineState, str, Path]:
|
|
458
|
+
root = _track_root(repo, project_dir)
|
|
459
|
+
path = root / "STATE.md"
|
|
460
|
+
text = _read_real_file(path, "STATE.md")
|
|
461
|
+
state = _state_from_text(text)
|
|
462
|
+
_validate_state_context(state, project_dir, "STATE.md")
|
|
463
|
+
if _is_lookahead(project_dir):
|
|
464
|
+
_validate_next_layout(root, state)
|
|
465
|
+
return state, text, path
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def validate_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
|
|
469
|
+
resolved = _repo_root(repo)
|
|
470
|
+
state, _, path = load_state(resolved, project_dir)
|
|
471
|
+
return {
|
|
472
|
+
"schema": STATE_SCHEMA,
|
|
473
|
+
"status": "valid",
|
|
474
|
+
"path": str(path),
|
|
475
|
+
"state": state.json(),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def undo_transaction(repo: Path) -> Optional[dict[str, object]]:
|
|
480
|
+
resolved = _repo_root(repo)
|
|
481
|
+
path = _git_path(resolved, UNDO_TRANSACTION_NAME)
|
|
482
|
+
if not path.exists() and not path.is_symlink():
|
|
483
|
+
return None
|
|
484
|
+
value = _read_json(path)
|
|
485
|
+
expected = {
|
|
486
|
+
"schema",
|
|
487
|
+
"repo",
|
|
488
|
+
"kind",
|
|
489
|
+
"expected_head",
|
|
490
|
+
"parent",
|
|
491
|
+
"branch",
|
|
492
|
+
"worktree_fingerprint",
|
|
493
|
+
"archive",
|
|
494
|
+
"archive_fingerprint",
|
|
495
|
+
"discussion",
|
|
496
|
+
}
|
|
497
|
+
if set(value) != expected or value.get("schema") != UNDO_TRANSACTION_SCHEMA:
|
|
498
|
+
raise PipelineStateError("undo transaction has invalid fields")
|
|
499
|
+
if value.get("repo") != str(resolved):
|
|
500
|
+
raise PipelineStateError("undo transaction belongs to another worktree")
|
|
501
|
+
kind = value.get("kind")
|
|
502
|
+
if kind not in {"checkpoint", "uncommitted-archive"}:
|
|
503
|
+
raise PipelineStateError("undo transaction has invalid kind")
|
|
504
|
+
for field in ("expected_head", "parent"):
|
|
505
|
+
field_value = value.get(field)
|
|
506
|
+
if not isinstance(field_value, str) or not re.fullmatch(
|
|
507
|
+
r"[0-9a-f]{40,64}", field_value
|
|
508
|
+
):
|
|
509
|
+
raise PipelineStateError(f"undo transaction has invalid {field}")
|
|
510
|
+
branch = value.get("branch")
|
|
511
|
+
if not isinstance(branch, str) or BOUND_BRANCH_RE.fullmatch(branch) is None:
|
|
512
|
+
raise PipelineStateError("undo transaction has invalid branch")
|
|
513
|
+
fingerprint = value.get("worktree_fingerprint")
|
|
514
|
+
if not isinstance(fingerprint, str) or not re.fullmatch(
|
|
515
|
+
r"[0-9a-f]{64}", fingerprint
|
|
516
|
+
):
|
|
517
|
+
raise PipelineStateError("undo transaction has invalid worktree fingerprint")
|
|
518
|
+
archive = value.get("archive")
|
|
519
|
+
archive_fingerprint = value.get("archive_fingerprint")
|
|
520
|
+
if kind == "checkpoint" and (
|
|
521
|
+
archive is not None or archive_fingerprint is not None
|
|
522
|
+
):
|
|
523
|
+
raise PipelineStateError("checkpoint undo transaction cannot name an archive")
|
|
524
|
+
if kind == "uncommitted-archive" and (
|
|
525
|
+
not isinstance(archive, str) or ARCHIVE_RE.fullmatch(archive) is None
|
|
526
|
+
):
|
|
527
|
+
raise PipelineStateError("archive undo transaction has invalid archive")
|
|
528
|
+
if kind == "uncommitted-archive" and (
|
|
529
|
+
not isinstance(archive_fingerprint, str)
|
|
530
|
+
or not re.fullmatch(r"[0-9a-f]{64}", archive_fingerprint)
|
|
531
|
+
):
|
|
532
|
+
raise PipelineStateError(
|
|
533
|
+
"archive undo transaction has invalid archive fingerprint"
|
|
534
|
+
)
|
|
535
|
+
discussion = value.get("discussion")
|
|
536
|
+
if discussion is not None and (
|
|
537
|
+
not isinstance(discussion, dict)
|
|
538
|
+
or set(discussion) != {"DIALOGUE.md", "ANSWERS.md"}
|
|
539
|
+
or any(not isinstance(content, str) for content in discussion.values())
|
|
540
|
+
):
|
|
541
|
+
raise PipelineStateError("undo transaction has invalid discussion records")
|
|
542
|
+
return value
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def transaction_journals(repo: Path) -> dict[str, Optional[str]]:
|
|
546
|
+
"""Return paths of any in-progress helper journals. Read-only."""
|
|
547
|
+
resolved = _repo_root(repo)
|
|
548
|
+
found: dict[str, Optional[str]] = {}
|
|
549
|
+
named = {
|
|
550
|
+
"checkpoint": CHECKPOINT_JOURNAL_NAME,
|
|
551
|
+
"shipment": SHIPMENT_JOURNAL_NAME,
|
|
552
|
+
"promotion": "gsd-path-promote-next.json",
|
|
553
|
+
"abandon": "gsd-path-abandon.json",
|
|
554
|
+
}
|
|
555
|
+
for key, name in named.items():
|
|
556
|
+
path = _git_path(resolved, name)
|
|
557
|
+
found[key] = str(path) if path.exists() or path.is_symlink() else None
|
|
558
|
+
state, _, _ = load_state(resolved)
|
|
559
|
+
bind_recovery = _bind_next_recovery(resolved, state)
|
|
560
|
+
found["bind_next"] = (
|
|
561
|
+
str(bind_recovery["route"]["journal"])
|
|
562
|
+
if bind_recovery is not None
|
|
563
|
+
else None
|
|
564
|
+
)
|
|
565
|
+
transaction = undo_transaction(resolved)
|
|
566
|
+
found["undo"] = (
|
|
567
|
+
str(_git_path(resolved, UNDO_TRANSACTION_NAME))
|
|
568
|
+
if transaction is not None
|
|
569
|
+
else None
|
|
570
|
+
)
|
|
571
|
+
try:
|
|
572
|
+
collect = collect_artifact_recoveries(resolved)
|
|
573
|
+
except IsolationError as error:
|
|
574
|
+
raise PipelineStateError(str(error)) from error
|
|
575
|
+
found["collect_artifact"] = str(collect[0]["journal"]) if collect else None
|
|
576
|
+
return found
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _optional_rev(repo: Path, spec: str) -> Optional[str]:
|
|
580
|
+
result = _run_git(repo, "rev-parse", "--verify", "--quiet", spec, check=False)
|
|
581
|
+
sha = result.stdout.strip()
|
|
582
|
+
return sha if result.returncode == 0 and sha else None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
|
|
586
|
+
result = _run_git(
|
|
587
|
+
repo,
|
|
588
|
+
"merge-base",
|
|
589
|
+
"--is-ancestor",
|
|
590
|
+
ancestor,
|
|
591
|
+
descendant,
|
|
592
|
+
check=False,
|
|
593
|
+
)
|
|
594
|
+
if result.returncode == 0:
|
|
595
|
+
return True
|
|
596
|
+
if result.returncode == 1:
|
|
597
|
+
return False
|
|
598
|
+
detail = result.stderr.strip() or result.stdout.strip() or "git ancestry probe failed"
|
|
599
|
+
raise PipelineStateError(detail)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _live_annotated_tag(repo: Path, tag_ref: str) -> tuple[str, str]:
|
|
603
|
+
result = _run_git(
|
|
604
|
+
repo,
|
|
605
|
+
"ls-remote",
|
|
606
|
+
"--exit-code",
|
|
607
|
+
"origin",
|
|
608
|
+
tag_ref,
|
|
609
|
+
f"{tag_ref}^{{}}",
|
|
610
|
+
check=False,
|
|
611
|
+
)
|
|
612
|
+
if result.returncode == 2:
|
|
613
|
+
raise PipelineStateError("published milestone tag is missing on origin")
|
|
614
|
+
if result.returncode != 0:
|
|
615
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
616
|
+
raise PipelineStateError(
|
|
617
|
+
f"could not inspect published milestone tag on origin: {detail}"
|
|
618
|
+
)
|
|
619
|
+
refs: dict[str, str] = {}
|
|
620
|
+
for line in result.stdout.splitlines():
|
|
621
|
+
fields = line.split("\t", 1)
|
|
622
|
+
if (
|
|
623
|
+
len(fields) != 2
|
|
624
|
+
or fields[1] in refs
|
|
625
|
+
or re.fullmatch(r"[0-9a-f]{40}", fields[0]) is None
|
|
626
|
+
):
|
|
627
|
+
raise PipelineStateError("origin returned invalid milestone tag data")
|
|
628
|
+
refs[fields[1]] = fields[0]
|
|
629
|
+
peeled_ref = f"{tag_ref}^{{}}"
|
|
630
|
+
if set(refs) != {tag_ref, peeled_ref}:
|
|
631
|
+
raise PipelineStateError("origin milestone tag is missing or not annotated")
|
|
632
|
+
return refs[tag_ref], refs[peeled_ref]
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _commit_subject_body(repo: Path, revision: str = "HEAD") -> tuple[str, str]:
|
|
636
|
+
raw = _run_git(repo, "cat-file", "commit", revision).stdout
|
|
637
|
+
try:
|
|
638
|
+
message = raw.split("\n\n", 1)[1]
|
|
639
|
+
except IndexError as error:
|
|
640
|
+
raise PipelineStateError(f"commit {revision} has no message body") from error
|
|
641
|
+
subject, _, rest = message.partition("\n")
|
|
642
|
+
return subject.strip(), rest.lstrip("\n")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _pending_answers(repo: Path) -> tuple[list[dict[str, str]], Optional[str]]:
|
|
646
|
+
answers = repo / ".project" / "discuss" / "ANSWERS.md"
|
|
647
|
+
if not answers.exists() and not answers.is_symlink():
|
|
648
|
+
return [], None
|
|
649
|
+
try:
|
|
650
|
+
try:
|
|
651
|
+
from discussion_records import DiscussionError, pending_records
|
|
652
|
+
except ModuleNotFoundError as error: # pragma: no cover - package imports used by tests
|
|
653
|
+
if error.name != "discussion_records":
|
|
654
|
+
raise
|
|
655
|
+
from scripts.discussion_records import DiscussionError, pending_records
|
|
656
|
+
except ModuleNotFoundError as error:
|
|
657
|
+
if error.name not in {"scripts", "scripts.discussion_records"}:
|
|
658
|
+
raise
|
|
659
|
+
return [], "discussion_records helper is unavailable"
|
|
660
|
+
try:
|
|
661
|
+
records = pending_records(answers.parent)
|
|
662
|
+
except (DiscussionError, OSError, UnicodeDecodeError, ValueError) as error:
|
|
663
|
+
return [], str(error)
|
|
664
|
+
pending: list[dict[str, str]] = []
|
|
665
|
+
for record in records:
|
|
666
|
+
pending.append(
|
|
667
|
+
{
|
|
668
|
+
"answer": str(record.get("answer") or ""),
|
|
669
|
+
"owner": str(record.get("owner") or ""),
|
|
670
|
+
"target": str(record.get("target") or ""),
|
|
671
|
+
"status": str(record.get("status") or ""),
|
|
672
|
+
}
|
|
673
|
+
)
|
|
674
|
+
return pending, None
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _next_skill(route: Mapping[str, object]) -> Optional[str]:
|
|
678
|
+
action = route.get("action")
|
|
679
|
+
phase = route.get("phase")
|
|
680
|
+
if action == "run-phase" and isinstance(phase, str):
|
|
681
|
+
return f"gsd-path-{phase}"
|
|
682
|
+
if action in {
|
|
683
|
+
"bind-initial",
|
|
684
|
+
"resume-checkpoint",
|
|
685
|
+
"resume-shipment",
|
|
686
|
+
"resume-promotion",
|
|
687
|
+
"resume-next-handoff",
|
|
688
|
+
"resume-undo",
|
|
689
|
+
"validate-integrated",
|
|
690
|
+
"block",
|
|
691
|
+
}:
|
|
692
|
+
return "gsd-path-undo" if action == "resume-undo" else "gsd-path"
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def status_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
|
|
697
|
+
"""Report owned state, route, and git facts without advancing a phase."""
|
|
698
|
+
resolved = _repo_root(repo)
|
|
699
|
+
routed = route_state(resolved, project_dir)
|
|
700
|
+
state = routed["state"]
|
|
701
|
+
route = routed["route"]
|
|
702
|
+
has_git = (
|
|
703
|
+
_run_git(resolved, "rev-parse", "--git-dir", check=False).returncode == 0
|
|
704
|
+
)
|
|
705
|
+
head = _optional_rev(resolved, "HEAD") if has_git else None
|
|
706
|
+
branch = _current_branch(resolved) if has_git else None
|
|
707
|
+
origin_branch = (
|
|
708
|
+
_optional_rev(resolved, f"refs/remotes/origin/{branch}")
|
|
709
|
+
if branch
|
|
710
|
+
else None
|
|
711
|
+
)
|
|
712
|
+
origin_main = (
|
|
713
|
+
_optional_rev(resolved, "refs/remotes/origin/main") if has_git else None
|
|
714
|
+
)
|
|
715
|
+
pending, pending_error = _pending_answers(resolved)
|
|
716
|
+
lookahead = resolved / ".project" / "next"
|
|
717
|
+
subject = ""
|
|
718
|
+
if head is not None:
|
|
719
|
+
subject, _ = _commit_subject_body(resolved, head)
|
|
720
|
+
journals = transaction_journals(resolved) if has_git else {
|
|
721
|
+
"checkpoint": None,
|
|
722
|
+
"shipment": None,
|
|
723
|
+
"promotion": None,
|
|
724
|
+
"abandon": None,
|
|
725
|
+
"bind_next": None,
|
|
726
|
+
"undo": None,
|
|
727
|
+
"collect_artifact": None,
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
"schema": STATUS_SCHEMA,
|
|
731
|
+
"advance": False,
|
|
732
|
+
"state": state,
|
|
733
|
+
"route": route,
|
|
734
|
+
"path": str(_track_root(resolved, project_dir) / "STATE.md"),
|
|
735
|
+
"git": {
|
|
736
|
+
"branch": branch,
|
|
737
|
+
"head": head,
|
|
738
|
+
"subject": subject,
|
|
739
|
+
"dirty": _worktree_changes(resolved) if has_git else [],
|
|
740
|
+
"origin_branch": origin_branch,
|
|
741
|
+
"origin_main": origin_main,
|
|
742
|
+
"published": bool(
|
|
743
|
+
head
|
|
744
|
+
and origin_branch
|
|
745
|
+
and _is_ancestor(resolved, head, origin_branch)
|
|
746
|
+
),
|
|
747
|
+
"ancestor_of_origin_main": bool(
|
|
748
|
+
head and origin_main and _is_ancestor(resolved, head, origin_main)
|
|
749
|
+
),
|
|
750
|
+
},
|
|
751
|
+
"pending_answers": pending,
|
|
752
|
+
"pending_error": pending_error,
|
|
753
|
+
"lookahead": lookahead.exists() or lookahead.is_symlink(),
|
|
754
|
+
"journals": journals,
|
|
755
|
+
"next_skill": _next_skill(route if isinstance(route, dict) else {}),
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _current_branch(repo: Path) -> Optional[str]:
|
|
760
|
+
result = _run_git(
|
|
761
|
+
repo,
|
|
762
|
+
"symbolic-ref",
|
|
763
|
+
"--quiet",
|
|
764
|
+
"--short",
|
|
765
|
+
"HEAD",
|
|
766
|
+
check=False,
|
|
767
|
+
)
|
|
768
|
+
if result.returncode == 1:
|
|
769
|
+
return None
|
|
770
|
+
if result.returncode != 0:
|
|
771
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
772
|
+
raise PipelineStateError(f"could not inspect current branch: {detail}")
|
|
773
|
+
return result.stdout.strip()
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _intent_lane(track: Path) -> Optional[str]:
|
|
777
|
+
text = _optional_real_file(track / "intent" / "INTENT.md", "INTENT.md")
|
|
778
|
+
if text is None:
|
|
779
|
+
return None
|
|
780
|
+
match = re.search(r"(?m)^Lane:\s*(standard|quick|milestone)\b", text)
|
|
781
|
+
if match is None:
|
|
782
|
+
return "standard"
|
|
783
|
+
return match.group(1)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _active_roadmap_has_questions(project: Path, milestone: Optional[str] = None) -> bool:
|
|
787
|
+
"""Report open questions for the active entry, or for ``milestone`` when given."""
|
|
788
|
+
text = _read_real_file(project / "ROADMAP.md", "ROADMAP.md")
|
|
789
|
+
lines = text.splitlines()
|
|
790
|
+
active_section: Optional[list[str]] = None
|
|
791
|
+
for index, line in enumerate(lines):
|
|
792
|
+
heading = ROADMAP_HEADING_RE.fullmatch(line)
|
|
793
|
+
if heading is None:
|
|
794
|
+
continue
|
|
795
|
+
if int(heading.group(1).removeprefix("M")) < 1:
|
|
796
|
+
raise PipelineStateError(
|
|
797
|
+
f"ROADMAP.md has invalid milestone id: {heading.group(1)}"
|
|
798
|
+
)
|
|
799
|
+
end = next(
|
|
800
|
+
(
|
|
801
|
+
cursor
|
|
802
|
+
for cursor in range(index + 1, len(lines))
|
|
803
|
+
if ROADMAP_HEADING_RE.fullmatch(lines[cursor]) is not None
|
|
804
|
+
),
|
|
805
|
+
len(lines),
|
|
806
|
+
)
|
|
807
|
+
section = lines[index:end]
|
|
808
|
+
if milestone is not None:
|
|
809
|
+
if heading.group(2) == milestone:
|
|
810
|
+
active_section = section
|
|
811
|
+
continue
|
|
812
|
+
if any(re.fullmatch(r"Status:\s*active(?:\s+#.*)?", item) for item in section):
|
|
813
|
+
if active_section is not None:
|
|
814
|
+
raise PipelineStateError("ROADMAP.md has more than one active milestone")
|
|
815
|
+
active_section = section
|
|
816
|
+
if active_section is None:
|
|
817
|
+
if milestone is not None:
|
|
818
|
+
raise PipelineStateError(f"ROADMAP.md is missing lookahead milestone: {milestone}")
|
|
819
|
+
raise PipelineStateError("ROADMAP.md has no active milestone")
|
|
820
|
+
try:
|
|
821
|
+
start = active_section.index("Open questions") + 1
|
|
822
|
+
except ValueError as error:
|
|
823
|
+
raise PipelineStateError("active roadmap milestone has no Open questions") from error
|
|
824
|
+
questions = [
|
|
825
|
+
line[2:].strip()
|
|
826
|
+
for line in active_section[start:]
|
|
827
|
+
if line.startswith("- ")
|
|
828
|
+
]
|
|
829
|
+
return any(question.lower() != "none" for question in questions)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _valid_patch_findings(repo: Path) -> bool:
|
|
833
|
+
validator = Path(__file__).resolve().with_name("check_handoffs.py")
|
|
834
|
+
if validator.is_symlink() or not validator.is_file():
|
|
835
|
+
raise PipelineStateError(f"patch validator must be a real file: {validator}")
|
|
836
|
+
result = subprocess.run(
|
|
837
|
+
[
|
|
838
|
+
sys.executable,
|
|
839
|
+
"-B",
|
|
840
|
+
str(validator),
|
|
841
|
+
"patch",
|
|
842
|
+
"--repo",
|
|
843
|
+
str(repo),
|
|
844
|
+
],
|
|
845
|
+
capture_output=True,
|
|
846
|
+
text=True,
|
|
847
|
+
)
|
|
848
|
+
return result.returncode == 0
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _selected_initial_branch(project: Path, state: PipelineState) -> str:
|
|
852
|
+
roadmap = project / "ROADMAP.md"
|
|
853
|
+
if roadmap.exists() or roadmap.is_symlink():
|
|
854
|
+
text = _read_real_file(roadmap, "ROADMAP.md")
|
|
855
|
+
lines = text.splitlines(keepends=True)
|
|
856
|
+
active: list[tuple[str, str]] = []
|
|
857
|
+
for slug, (milestone_id, start, end) in _roadmap_sections(text).items():
|
|
858
|
+
statuses = [
|
|
859
|
+
match.group(1)
|
|
860
|
+
for line in lines[start:end]
|
|
861
|
+
if (match := re.match(r"^Status:\s*(\S+)", line)) is not None
|
|
862
|
+
]
|
|
863
|
+
if statuses == ["active"]:
|
|
864
|
+
active.append((milestone_id, slug))
|
|
865
|
+
if len(active) != 1:
|
|
866
|
+
raise PipelineStateError("ROADMAP.md must have exactly one active milestone")
|
|
867
|
+
milestone_id, slug = active[0]
|
|
868
|
+
if state.milestone is not None and state.milestone != slug:
|
|
869
|
+
raise PipelineStateError("STATE.milestone does not match active roadmap entry")
|
|
870
|
+
return f"gsd-path/{milestone_id}"
|
|
871
|
+
|
|
872
|
+
archive = project / "archive"
|
|
873
|
+
numbers: list[int] = []
|
|
874
|
+
if archive.exists() or archive.is_symlink():
|
|
875
|
+
if archive.is_symlink() or not archive.is_dir():
|
|
876
|
+
raise PipelineStateError(f"archive root must be a real directory: {archive}")
|
|
877
|
+
for child in archive.iterdir():
|
|
878
|
+
match = re.fullmatch(r"(\d{3,})-[a-z0-9][a-z0-9-]*", child.name)
|
|
879
|
+
if (
|
|
880
|
+
child.is_symlink()
|
|
881
|
+
or not child.is_dir()
|
|
882
|
+
or match is None
|
|
883
|
+
or int(match.group(1)) < 1
|
|
884
|
+
):
|
|
885
|
+
raise PipelineStateError(f"archive root has invalid entry: {child}")
|
|
886
|
+
numbers.append(int(match.group(1)))
|
|
887
|
+
number = max(numbers) + 1 if numbers else 1
|
|
888
|
+
return f"gsd-path/M{number:03d}"
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _route_result(
|
|
892
|
+
state: PipelineState,
|
|
893
|
+
action: str,
|
|
894
|
+
*,
|
|
895
|
+
phase: Optional[str] = None,
|
|
896
|
+
mode: Optional[str] = None,
|
|
897
|
+
reason: str,
|
|
898
|
+
) -> dict[str, object]:
|
|
899
|
+
route = {"action": action, "reason": reason}
|
|
900
|
+
if phase is not None:
|
|
901
|
+
route["phase"] = phase
|
|
902
|
+
if mode is not None:
|
|
903
|
+
route["mode"] = mode
|
|
904
|
+
return {"schema": ROUTE_SCHEMA, "state": state.json(), "route": route}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _legacy_bind_next_landing(repo: Path, state: PipelineState) -> str:
|
|
908
|
+
if ARCHIVE_RE.fullmatch(state.archive or "") is None:
|
|
909
|
+
raise PipelineStateError("legacy bind-next journal requires a shipped archive")
|
|
910
|
+
archive_name = PurePosixPath(state.archive or "").name
|
|
911
|
+
tag_ref = f"refs/tags/milestone/{archive_name}"
|
|
912
|
+
published_ref = f"refs/remotes/origin/tags/milestone/{archive_name}"
|
|
913
|
+
for label, ref in (("local", tag_ref), ("published", published_ref)):
|
|
914
|
+
tag_type = _run_git(repo, "cat-file", "-t", ref, check=False)
|
|
915
|
+
if tag_type.returncode != 0 or tag_type.stdout.strip() != "tag":
|
|
916
|
+
raise PipelineStateError(
|
|
917
|
+
f"legacy bind-next journal {label} milestone tag is missing or not annotated"
|
|
918
|
+
)
|
|
919
|
+
local_object = _run_git(repo, "rev-parse", "--verify", tag_ref).stdout.strip()
|
|
920
|
+
published_object = _run_git(
|
|
921
|
+
repo,
|
|
922
|
+
"rev-parse",
|
|
923
|
+
"--verify",
|
|
924
|
+
published_ref,
|
|
925
|
+
).stdout.strip()
|
|
926
|
+
if local_object != published_object:
|
|
927
|
+
raise PipelineStateError(
|
|
928
|
+
"legacy bind-next journal milestone tags do not match"
|
|
929
|
+
)
|
|
930
|
+
landing = _run_git(
|
|
931
|
+
repo,
|
|
932
|
+
"rev-parse",
|
|
933
|
+
"--verify",
|
|
934
|
+
f"{tag_ref}^{{commit}}",
|
|
935
|
+
).stdout.strip()
|
|
936
|
+
published_landing = _run_git(
|
|
937
|
+
repo,
|
|
938
|
+
"rev-parse",
|
|
939
|
+
"--verify",
|
|
940
|
+
f"{published_ref}^{{commit}}",
|
|
941
|
+
).stdout.strip()
|
|
942
|
+
if landing != published_landing:
|
|
943
|
+
raise PipelineStateError(
|
|
944
|
+
"legacy bind-next journal milestone tag landings do not match"
|
|
945
|
+
)
|
|
946
|
+
return landing
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _bind_next_recovery(
|
|
950
|
+
repo: Path,
|
|
951
|
+
state: PipelineState,
|
|
952
|
+
) -> Optional[dict[str, object]]:
|
|
953
|
+
journal_root = _git_path(repo, BIND_NEXT_JOURNAL_DIR)
|
|
954
|
+
if not journal_root.exists() and not journal_root.is_symlink():
|
|
955
|
+
return None
|
|
956
|
+
if journal_root.is_symlink() or not journal_root.is_dir():
|
|
957
|
+
raise PipelineStateError(f"bind-next journal root must be a real directory: {journal_root}")
|
|
958
|
+
|
|
959
|
+
matches: list[tuple[Path, dict[str, object]]] = []
|
|
960
|
+
for path in sorted(journal_root.glob("*.json")):
|
|
961
|
+
transaction = _read_json(path)
|
|
962
|
+
if transaction.get("schema") not in {
|
|
963
|
+
LEGACY_BIND_NEXT_JOURNAL_SCHEMA,
|
|
964
|
+
BIND_NEXT_JOURNAL_SCHEMA,
|
|
965
|
+
}:
|
|
966
|
+
raise PipelineStateError(f"bind-next journal has invalid schema: {path}")
|
|
967
|
+
if transaction.get("repo") != str(repo):
|
|
968
|
+
raise PipelineStateError(f"bind-next journal belongs to another worktree: {path}")
|
|
969
|
+
if transaction.get("previous_branch") == state.branch:
|
|
970
|
+
matches.append((path, transaction))
|
|
971
|
+
if not matches:
|
|
972
|
+
return None
|
|
973
|
+
if len(matches) != 1:
|
|
974
|
+
raise PipelineStateError("multiple bind-next journals claim the current STATE branch")
|
|
975
|
+
if state.phase != "shipped" or state.status != "done":
|
|
976
|
+
raise PipelineStateError("bind-next journal requires STATE shipped/done")
|
|
977
|
+
|
|
978
|
+
path, transaction = matches[0]
|
|
979
|
+
required = (
|
|
980
|
+
"branch",
|
|
981
|
+
"previous_branch",
|
|
982
|
+
"ship",
|
|
983
|
+
"remote_default",
|
|
984
|
+
"base",
|
|
985
|
+
"stage",
|
|
986
|
+
)
|
|
987
|
+
expected_fields = {"schema", "repo", *required}
|
|
988
|
+
optional_fields = {"allow_remote_absent"}
|
|
989
|
+
if transaction["schema"] == BIND_NEXT_JOURNAL_SCHEMA:
|
|
990
|
+
expected_fields.add("landing")
|
|
991
|
+
else:
|
|
992
|
+
optional_fields.add("landing")
|
|
993
|
+
if (
|
|
994
|
+
not expected_fields <= set(transaction)
|
|
995
|
+
or set(transaction) - expected_fields - optional_fields
|
|
996
|
+
):
|
|
997
|
+
raise PipelineStateError("bind-next journal has unsupported fields")
|
|
998
|
+
if any(not isinstance(transaction.get(key), str) for key in required):
|
|
999
|
+
raise PipelineStateError("bind-next journal is missing request fields")
|
|
1000
|
+
landing_value = transaction.get("landing")
|
|
1001
|
+
if transaction["schema"] == BIND_NEXT_JOURNAL_SCHEMA and not isinstance(
|
|
1002
|
+
landing_value, str
|
|
1003
|
+
):
|
|
1004
|
+
raise PipelineStateError("bind-next journal is missing request fields")
|
|
1005
|
+
if landing_value is not None and not isinstance(landing_value, str):
|
|
1006
|
+
raise PipelineStateError("bind-next journal has invalid landing")
|
|
1007
|
+
allow_remote_absent = transaction.get("allow_remote_absent", False)
|
|
1008
|
+
if not isinstance(allow_remote_absent, bool):
|
|
1009
|
+
raise PipelineStateError("bind-next journal has invalid allow_remote_absent")
|
|
1010
|
+
branch = str(transaction["branch"])
|
|
1011
|
+
previous = str(transaction["previous_branch"])
|
|
1012
|
+
ship = str(transaction["ship"])
|
|
1013
|
+
base = str(transaction["base"])
|
|
1014
|
+
stage = str(transaction["stage"])
|
|
1015
|
+
branch_number = _bound_branch_number(branch)
|
|
1016
|
+
previous_number = _bound_branch_number(previous)
|
|
1017
|
+
if branch_number is None or previous_number is None:
|
|
1018
|
+
raise PipelineStateError("bind-next journal has invalid branch fields")
|
|
1019
|
+
if branch_number <= previous_number:
|
|
1020
|
+
raise PipelineStateError("bind-next journal target does not follow previous branch")
|
|
1021
|
+
if path.name != f"M{branch_number:03d}.json":
|
|
1022
|
+
raise PipelineStateError("bind-next journal path does not match target branch")
|
|
1023
|
+
if any(not re.fullmatch(r"[0-9a-f]{40}", value) for value in (ship, base)):
|
|
1024
|
+
raise PipelineStateError("bind-next journal has invalid ship or base SHA")
|
|
1025
|
+
for label, value in (("ship", ship), ("base", base)):
|
|
1026
|
+
resolved = _run_git(
|
|
1027
|
+
repo,
|
|
1028
|
+
"rev-parse",
|
|
1029
|
+
"--verify",
|
|
1030
|
+
f"{value}^{{commit}}",
|
|
1031
|
+
check=False,
|
|
1032
|
+
)
|
|
1033
|
+
if resolved.returncode != 0 or resolved.stdout.strip() != value:
|
|
1034
|
+
raise PipelineStateError(f"bind-next journal {label} is not an existing full SHA")
|
|
1035
|
+
landing = (
|
|
1036
|
+
landing_value
|
|
1037
|
+
if isinstance(landing_value, str)
|
|
1038
|
+
else _legacy_bind_next_landing(repo, state)
|
|
1039
|
+
)
|
|
1040
|
+
if re.fullmatch(r"[0-9a-f]{40}", landing) is None:
|
|
1041
|
+
raise PipelineStateError("bind-next journal has invalid landing SHA")
|
|
1042
|
+
resolved_landing = _run_git(
|
|
1043
|
+
repo,
|
|
1044
|
+
"rev-parse",
|
|
1045
|
+
"--verify",
|
|
1046
|
+
f"{landing}^{{commit}}",
|
|
1047
|
+
check=False,
|
|
1048
|
+
)
|
|
1049
|
+
if resolved_landing.returncode != 0 or resolved_landing.stdout.strip() != landing:
|
|
1050
|
+
raise PipelineStateError("bind-next journal landing is not an existing full SHA")
|
|
1051
|
+
if not _is_ancestor(repo, ship, landing):
|
|
1052
|
+
raise PipelineStateError("bind-next journal landing does not contain ship")
|
|
1053
|
+
if not _is_ancestor(repo, landing, base):
|
|
1054
|
+
raise PipelineStateError("bind-next journal landing is not an ancestor of base")
|
|
1055
|
+
if transaction["remote_default"] != "origin/main":
|
|
1056
|
+
raise PipelineStateError("bind-next journal has invalid remote default")
|
|
1057
|
+
if stage not in {"prepared", "switched", "retired"}:
|
|
1058
|
+
raise PipelineStateError("bind-next journal has invalid stage")
|
|
1059
|
+
|
|
1060
|
+
current = _current_branch(repo)
|
|
1061
|
+
if current not in {previous, branch}:
|
|
1062
|
+
raise PipelineStateError("bind-next journal does not own the current branch")
|
|
1063
|
+
if current == previous and stage != "prepared":
|
|
1064
|
+
raise PipelineStateError("bind-next journal stage conflicts with previous branch")
|
|
1065
|
+
head = _run_git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
1066
|
+
if current == previous:
|
|
1067
|
+
if head != ship:
|
|
1068
|
+
raise PipelineStateError("bind-next previous branch is not at journal ship")
|
|
1069
|
+
if _run_git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
|
|
1070
|
+
raise PipelineStateError("bind-next prepared journal collides with target branch")
|
|
1071
|
+
else:
|
|
1072
|
+
if head != base:
|
|
1073
|
+
raise PipelineStateError("bind-next target branch is not at journal base")
|
|
1074
|
+
result = _route_result(
|
|
1075
|
+
state,
|
|
1076
|
+
"resume-next-handoff",
|
|
1077
|
+
reason="a matching bind-next ownership journal is incomplete",
|
|
1078
|
+
)
|
|
1079
|
+
result["route"].update(
|
|
1080
|
+
{
|
|
1081
|
+
"previous_branch": previous,
|
|
1082
|
+
"branch": branch,
|
|
1083
|
+
"ship": ship,
|
|
1084
|
+
"remote_default": transaction["remote_default"],
|
|
1085
|
+
"base": base,
|
|
1086
|
+
"landing": landing,
|
|
1087
|
+
"allow_remote_absent": allow_remote_absent,
|
|
1088
|
+
"journal": str(path),
|
|
1089
|
+
"dirty": _worktree_changes(repo),
|
|
1090
|
+
}
|
|
1091
|
+
)
|
|
1092
|
+
return result
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _pending_discussion_block(repo: Path, phase: Optional[str]) -> Optional[str]:
|
|
1096
|
+
pending, error = _pending_answers(repo)
|
|
1097
|
+
if error:
|
|
1098
|
+
return f"pending discussion cannot be validated: {error}"
|
|
1099
|
+
if not pending or (phase and all(record["owner"] == f"gsd-path-{phase}" for record in pending)):
|
|
1100
|
+
return None
|
|
1101
|
+
owners = ", ".join(f"{record['answer']} ({record['owner']})" for record in pending)
|
|
1102
|
+
return f"pending discussion requires its owner disposition: {owners}; see {repo / '.project/discuss/ANSWERS.md'}"
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def route_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
|
|
1106
|
+
"""Route ordinary phase work only when its pending discussion owner can enter."""
|
|
1107
|
+
result = _route_state(repo, project_dir)
|
|
1108
|
+
route = result["route"]
|
|
1109
|
+
if route["action"] == "run-phase" and result["state"]["archive"] is None:
|
|
1110
|
+
reason = _pending_discussion_block(_repo_root(repo), route.get("phase"))
|
|
1111
|
+
if reason:
|
|
1112
|
+
result["route"] = {"action": "block", "reason": reason}
|
|
1113
|
+
return result
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _route_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
|
|
1117
|
+
"""Return the next deterministic pipeline action for one state track."""
|
|
1118
|
+
resolved = _repo_root(repo)
|
|
1119
|
+
state, _, _ = load_state(resolved, project_dir)
|
|
1120
|
+
track = _track_root(resolved, project_dir)
|
|
1121
|
+
project = _track_root(resolved, ".project")
|
|
1122
|
+
lookahead = PurePosixPath(project_dir) == PurePosixPath(".project/next")
|
|
1123
|
+
|
|
1124
|
+
if not lookahead:
|
|
1125
|
+
git_dir = _run_git(resolved, "rev-parse", "--git-dir", check=False)
|
|
1126
|
+
if git_dir.returncode == 0:
|
|
1127
|
+
undo = undo_transaction(resolved)
|
|
1128
|
+
if undo is not None:
|
|
1129
|
+
result = _route_result(
|
|
1130
|
+
state,
|
|
1131
|
+
"resume-undo",
|
|
1132
|
+
reason="a journaled undo transaction is incomplete",
|
|
1133
|
+
)
|
|
1134
|
+
result["route"].update(
|
|
1135
|
+
{
|
|
1136
|
+
"kind": undo["kind"],
|
|
1137
|
+
"expected_head": undo["expected_head"],
|
|
1138
|
+
"journal": str(_git_path(resolved, UNDO_TRANSACTION_NAME)),
|
|
1139
|
+
}
|
|
1140
|
+
)
|
|
1141
|
+
return result
|
|
1142
|
+
shipment_journal = _git_path(resolved, SHIPMENT_JOURNAL_NAME)
|
|
1143
|
+
if shipment_journal.exists() or shipment_journal.is_symlink():
|
|
1144
|
+
transaction = _read_json(shipment_journal)
|
|
1145
|
+
expected_keys = {
|
|
1146
|
+
"schema", "repo", "archive", "event", "roadmap_before",
|
|
1147
|
+
"roadmap_after", "state_before", "state_after",
|
|
1148
|
+
}
|
|
1149
|
+
if set(transaction) != expected_keys or transaction.get("schema") != SHIPMENT_SCHEMA or transaction.get("repo") != str(resolved):
|
|
1150
|
+
raise PipelineStateError("shipment journal is invalid")
|
|
1151
|
+
result = _route_result(
|
|
1152
|
+
state,
|
|
1153
|
+
"resume-shipment",
|
|
1154
|
+
reason="a journaled shipment metadata transaction is incomplete",
|
|
1155
|
+
)
|
|
1156
|
+
result["route"].update(
|
|
1157
|
+
{"archive": transaction["archive"], "event": transaction["event"]}
|
|
1158
|
+
)
|
|
1159
|
+
return result
|
|
1160
|
+
checkpoint_journal = _git_path(resolved, CHECKPOINT_JOURNAL_NAME)
|
|
1161
|
+
if checkpoint_journal.exists() or checkpoint_journal.is_symlink():
|
|
1162
|
+
transaction = _approval_journal(
|
|
1163
|
+
resolved,
|
|
1164
|
+
_read_json(checkpoint_journal),
|
|
1165
|
+
)
|
|
1166
|
+
result = _route_result(
|
|
1167
|
+
state,
|
|
1168
|
+
"resume-checkpoint",
|
|
1169
|
+
reason="a journaled plan or roadmap approval is incomplete",
|
|
1170
|
+
)
|
|
1171
|
+
result["route"].update(
|
|
1172
|
+
{
|
|
1173
|
+
"kind": transaction["kind"],
|
|
1174
|
+
"project_dir": transaction["project_dir"],
|
|
1175
|
+
"journal": str(checkpoint_journal),
|
|
1176
|
+
}
|
|
1177
|
+
)
|
|
1178
|
+
return result
|
|
1179
|
+
promotion_journal = _git_path(
|
|
1180
|
+
resolved,
|
|
1181
|
+
"gsd-path-promote-next.json",
|
|
1182
|
+
)
|
|
1183
|
+
if promotion_journal.exists() or promotion_journal.is_symlink():
|
|
1184
|
+
transaction = _read_json(promotion_journal)
|
|
1185
|
+
required = {
|
|
1186
|
+
"schema": PROMOTION_SCHEMA,
|
|
1187
|
+
"repo": str(resolved),
|
|
1188
|
+
}
|
|
1189
|
+
if any(transaction.get(key) != value for key, value in required.items()):
|
|
1190
|
+
raise PipelineStateError(
|
|
1191
|
+
"promotion journal does not belong to this pipeline worktree"
|
|
1192
|
+
)
|
|
1193
|
+
fields = {
|
|
1194
|
+
key: transaction.get(key)
|
|
1195
|
+
for key in ("milestone", "branch", "integrate")
|
|
1196
|
+
}
|
|
1197
|
+
if not all(isinstance(value, str) for value in fields.values()):
|
|
1198
|
+
raise PipelineStateError("promotion journal is missing request fields")
|
|
1199
|
+
landing = transaction.get("landing", fields["integrate"])
|
|
1200
|
+
base = transaction.get("base", fields["integrate"])
|
|
1201
|
+
if not isinstance(landing, str) or not isinstance(base, str):
|
|
1202
|
+
raise PipelineStateError("promotion journal has invalid landing or base")
|
|
1203
|
+
result = _route_result(
|
|
1204
|
+
state,
|
|
1205
|
+
"resume-promotion",
|
|
1206
|
+
reason="a journaled lookahead promotion is incomplete",
|
|
1207
|
+
)
|
|
1208
|
+
fields["landing"] = landing
|
|
1209
|
+
fields["base"] = base
|
|
1210
|
+
result["route"].update(fields)
|
|
1211
|
+
result["route"]["journal"] = str(promotion_journal)
|
|
1212
|
+
return result
|
|
1213
|
+
bind_recovery = _bind_next_recovery(resolved, state)
|
|
1214
|
+
if bind_recovery is not None:
|
|
1215
|
+
return bind_recovery
|
|
1216
|
+
|
|
1217
|
+
if not lookahead and state.branch is None:
|
|
1218
|
+
result = _route_result(
|
|
1219
|
+
state,
|
|
1220
|
+
"bind-initial",
|
|
1221
|
+
reason="active state has no router-owned branch binding",
|
|
1222
|
+
)
|
|
1223
|
+
result["route"]["branch"] = _selected_initial_branch(project, state)
|
|
1224
|
+
return result
|
|
1225
|
+
|
|
1226
|
+
if state.branch is not None:
|
|
1227
|
+
current = _current_branch(resolved)
|
|
1228
|
+
if current != state.branch:
|
|
1229
|
+
isolated_build = (
|
|
1230
|
+
state.phase == "build"
|
|
1231
|
+
and authorized_task_worktree(resolved, state.branch)
|
|
1232
|
+
)
|
|
1233
|
+
if not isolated_build:
|
|
1234
|
+
return _route_result(
|
|
1235
|
+
state,
|
|
1236
|
+
"block",
|
|
1237
|
+
reason=(
|
|
1238
|
+
f"current branch {current or '<detached>'} != "
|
|
1239
|
+
f"STATE.branch {state.branch}"
|
|
1240
|
+
),
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
if state.archive is not None:
|
|
1244
|
+
if state.phase == "build":
|
|
1245
|
+
return _route_result(
|
|
1246
|
+
state,
|
|
1247
|
+
"run-phase",
|
|
1248
|
+
phase="build",
|
|
1249
|
+
mode="abandon-recovery",
|
|
1250
|
+
reason="STATE.archive records an interrupted abandon transaction",
|
|
1251
|
+
)
|
|
1252
|
+
if state.phase in {"ship", "shipped"}:
|
|
1253
|
+
return _route_result(
|
|
1254
|
+
state,
|
|
1255
|
+
"run-phase",
|
|
1256
|
+
phase="ship",
|
|
1257
|
+
mode="archive-recovery" if state.phase == "ship" else "validate-integrated",
|
|
1258
|
+
reason="STATE.archive records a ship transaction",
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
phase, status = state.phase, state.status
|
|
1262
|
+
if phase == "inspect":
|
|
1263
|
+
return _route_result(
|
|
1264
|
+
state,
|
|
1265
|
+
"run-phase",
|
|
1266
|
+
phase="define" if status == "done" else "inspect",
|
|
1267
|
+
mode="brownfield" if status == "done" else "normal",
|
|
1268
|
+
reason=f"state is {phase}/{status}",
|
|
1269
|
+
)
|
|
1270
|
+
if phase == "define" and status != "done":
|
|
1271
|
+
return _route_result(
|
|
1272
|
+
state,
|
|
1273
|
+
"run-phase",
|
|
1274
|
+
phase="define",
|
|
1275
|
+
reason=f"state is {phase}/{status}",
|
|
1276
|
+
)
|
|
1277
|
+
if phase == "define":
|
|
1278
|
+
lane = _intent_lane(track)
|
|
1279
|
+
if lane == "quick":
|
|
1280
|
+
return _route_result(
|
|
1281
|
+
state,
|
|
1282
|
+
"run-phase",
|
|
1283
|
+
phase="plan",
|
|
1284
|
+
mode="quick",
|
|
1285
|
+
reason="INTENT lane is quick",
|
|
1286
|
+
)
|
|
1287
|
+
if lane == "milestone":
|
|
1288
|
+
questions = _active_roadmap_has_questions(
|
|
1289
|
+
project,
|
|
1290
|
+
state.milestone if lookahead else None,
|
|
1291
|
+
)
|
|
1292
|
+
entry = "lookahead" if lookahead else "active"
|
|
1293
|
+
return _route_result(
|
|
1294
|
+
state,
|
|
1295
|
+
"run-phase",
|
|
1296
|
+
phase="research" if questions else "plan",
|
|
1297
|
+
mode="milestone",
|
|
1298
|
+
reason=(
|
|
1299
|
+
f"{entry} roadmap milestone has open questions"
|
|
1300
|
+
if questions
|
|
1301
|
+
else f"{entry} roadmap milestone has no open questions"
|
|
1302
|
+
),
|
|
1303
|
+
)
|
|
1304
|
+
if lane is None:
|
|
1305
|
+
charter = _optional_real_file(project / "CHARTER.md", "CHARTER.md")
|
|
1306
|
+
if charter is None:
|
|
1307
|
+
return _route_result(
|
|
1308
|
+
state,
|
|
1309
|
+
"block",
|
|
1310
|
+
reason="define/done has neither INTENT.md nor CHARTER.md",
|
|
1311
|
+
)
|
|
1312
|
+
mode = "program"
|
|
1313
|
+
else:
|
|
1314
|
+
mode = "standard"
|
|
1315
|
+
return _route_result(
|
|
1316
|
+
state,
|
|
1317
|
+
"run-phase",
|
|
1318
|
+
phase="research",
|
|
1319
|
+
mode=mode,
|
|
1320
|
+
reason="define is done",
|
|
1321
|
+
)
|
|
1322
|
+
if phase == "research":
|
|
1323
|
+
return _route_result(
|
|
1324
|
+
state,
|
|
1325
|
+
"run-phase",
|
|
1326
|
+
phase="decide" if status == "done" else "research",
|
|
1327
|
+
reason=f"state is {phase}/{status}",
|
|
1328
|
+
)
|
|
1329
|
+
if phase == "decide" and status != "done":
|
|
1330
|
+
return _route_result(
|
|
1331
|
+
state,
|
|
1332
|
+
"run-phase",
|
|
1333
|
+
phase="decide",
|
|
1334
|
+
reason=f"state is {phase}/{status}",
|
|
1335
|
+
)
|
|
1336
|
+
if phase == "decide":
|
|
1337
|
+
charter = project / "CHARTER.md"
|
|
1338
|
+
roadmap = project / "ROADMAP.md"
|
|
1339
|
+
next_phase = "roadmap" if charter.is_file() and not roadmap.exists() else "plan"
|
|
1340
|
+
return _route_result(state, "run-phase", phase=next_phase, reason="decide is done")
|
|
1341
|
+
if phase == "roadmap":
|
|
1342
|
+
return _route_result(
|
|
1343
|
+
state,
|
|
1344
|
+
"run-phase",
|
|
1345
|
+
phase="define" if status == "done" else "roadmap",
|
|
1346
|
+
mode="milestone" if status == "done" else "normal",
|
|
1347
|
+
reason=f"state is {phase}/{status}",
|
|
1348
|
+
)
|
|
1349
|
+
if phase == "plan" and status != "done":
|
|
1350
|
+
return _route_result(state, "run-phase", phase="plan", reason=f"state is {phase}/{status}")
|
|
1351
|
+
if phase == "plan":
|
|
1352
|
+
if lookahead:
|
|
1353
|
+
return _route_result(
|
|
1354
|
+
state,
|
|
1355
|
+
"wait",
|
|
1356
|
+
mode="lookahead-ready",
|
|
1357
|
+
reason="lookahead plan is approved and waits for milestone promotion",
|
|
1358
|
+
)
|
|
1359
|
+
return _route_result(
|
|
1360
|
+
state,
|
|
1361
|
+
"run-phase",
|
|
1362
|
+
phase="build",
|
|
1363
|
+
reason="approved plan and branch binding are ready",
|
|
1364
|
+
)
|
|
1365
|
+
if phase == "build":
|
|
1366
|
+
return _route_result(
|
|
1367
|
+
state,
|
|
1368
|
+
"run-phase",
|
|
1369
|
+
phase="build",
|
|
1370
|
+
mode="transition-recovery" if status == "done" else "recovery",
|
|
1371
|
+
reason=f"state is {phase}/{status}",
|
|
1372
|
+
)
|
|
1373
|
+
if phase == "ship":
|
|
1374
|
+
if status == "blocked":
|
|
1375
|
+
findings = project / "review" / "PATCH-FINDINGS.md"
|
|
1376
|
+
if (
|
|
1377
|
+
findings.is_file()
|
|
1378
|
+
and not findings.is_symlink()
|
|
1379
|
+
and _valid_patch_findings(resolved)
|
|
1380
|
+
):
|
|
1381
|
+
return _route_result(
|
|
1382
|
+
state,
|
|
1383
|
+
"run-phase",
|
|
1384
|
+
phase="plan",
|
|
1385
|
+
mode="patch",
|
|
1386
|
+
reason="validated patch findings exist",
|
|
1387
|
+
)
|
|
1388
|
+
return _route_result(
|
|
1389
|
+
state,
|
|
1390
|
+
"block",
|
|
1391
|
+
reason="ship is blocked without valid PATCH-FINDINGS.md",
|
|
1392
|
+
)
|
|
1393
|
+
return _route_result(
|
|
1394
|
+
state,
|
|
1395
|
+
"run-phase",
|
|
1396
|
+
phase="ship",
|
|
1397
|
+
mode="final",
|
|
1398
|
+
reason=f"state is {phase}/{status}",
|
|
1399
|
+
)
|
|
1400
|
+
return _route_result(state, "validate-integrated", reason="state is shipped/done")
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _set_frontmatter(text: str, changes: Mapping[str, str]) -> str:
|
|
1404
|
+
lines = text.splitlines(keepends=True)
|
|
1405
|
+
closing: Optional[int] = None
|
|
1406
|
+
seen: set[str] = set()
|
|
1407
|
+
for index, line in enumerate(lines[1:], start=1):
|
|
1408
|
+
if line.rstrip("\r\n") == "---":
|
|
1409
|
+
closing = index
|
|
1410
|
+
break
|
|
1411
|
+
match = re.match(r"^([a-z_]+):", line)
|
|
1412
|
+
if match and match.group(1) in changes:
|
|
1413
|
+
key = match.group(1)
|
|
1414
|
+
lines[index] = f"{key}: {changes[key]}\n"
|
|
1415
|
+
seen.add(key)
|
|
1416
|
+
if closing is None:
|
|
1417
|
+
raise PipelineStateError("STATE.md frontmatter is not closed")
|
|
1418
|
+
missing = set(changes) - seen
|
|
1419
|
+
integration_fields = (
|
|
1420
|
+
"integration_default",
|
|
1421
|
+
"integration",
|
|
1422
|
+
"integration_source",
|
|
1423
|
+
)
|
|
1424
|
+
unsupported_missing = missing - set(integration_fields)
|
|
1425
|
+
if unsupported_missing:
|
|
1426
|
+
raise PipelineStateError(f"STATE.md is missing fields: {', '.join(sorted(missing))}")
|
|
1427
|
+
for key in integration_fields:
|
|
1428
|
+
if key in missing:
|
|
1429
|
+
lines.insert(closing, f"{key}: {changes[key]}\n")
|
|
1430
|
+
closing += 1
|
|
1431
|
+
return "".join(lines)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _append_event(
|
|
1435
|
+
text: str,
|
|
1436
|
+
phase: str,
|
|
1437
|
+
event: str,
|
|
1438
|
+
event_date: Optional[str] = None,
|
|
1439
|
+
) -> str:
|
|
1440
|
+
cleaned = " ".join(event.split())
|
|
1441
|
+
if not cleaned or "\n" in event or "\r" in event:
|
|
1442
|
+
raise PipelineStateError("state event must be one non-empty line")
|
|
1443
|
+
if "## Log" not in text:
|
|
1444
|
+
raise PipelineStateError("STATE.md is missing ## Log")
|
|
1445
|
+
suffix = "" if text.endswith("\n") else "\n"
|
|
1446
|
+
timestamp = event_date or date.today().isoformat()
|
|
1447
|
+
try:
|
|
1448
|
+
date.fromisoformat(timestamp)
|
|
1449
|
+
except ValueError as error:
|
|
1450
|
+
raise PipelineStateError(f"state event date is invalid: {timestamp}") from error
|
|
1451
|
+
return f"{text}{suffix}- {timestamp} — {phase} — {cleaned}\n"
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
@contextlib.contextmanager
|
|
1455
|
+
def _state_lock(project: Path) -> Iterator[None]:
|
|
1456
|
+
if fcntl is None: # pragma: no cover - Windows CI is not used
|
|
1457
|
+
raise PipelineStateError("state locking is unavailable on this platform")
|
|
1458
|
+
descriptor = os.open(project, os.O_RDONLY)
|
|
1459
|
+
try:
|
|
1460
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
1461
|
+
yield
|
|
1462
|
+
finally:
|
|
1463
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
1464
|
+
os.close(descriptor)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
1468
|
+
temporary = path.parent / f".{path.name}.gsd-path-tmp"
|
|
1469
|
+
try:
|
|
1470
|
+
path_status = path.lstat()
|
|
1471
|
+
temporary_status = temporary.lstat()
|
|
1472
|
+
except FileNotFoundError:
|
|
1473
|
+
pass
|
|
1474
|
+
else:
|
|
1475
|
+
if (
|
|
1476
|
+
stat.S_ISREG(path_status.st_mode)
|
|
1477
|
+
and stat.S_ISREG(temporary_status.st_mode)
|
|
1478
|
+
and path_status.st_nlink == 2
|
|
1479
|
+
and temporary_status.st_nlink == 2
|
|
1480
|
+
and os.path.samestat(path_status, temporary_status)
|
|
1481
|
+
):
|
|
1482
|
+
temporary.unlink()
|
|
1483
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
1484
|
+
try:
|
|
1485
|
+
os.fsync(directory)
|
|
1486
|
+
finally:
|
|
1487
|
+
os.close(directory)
|
|
1488
|
+
if temporary.exists() or temporary.is_symlink():
|
|
1489
|
+
raise PipelineStateError(f"atomic-write temporary path already exists: {temporary}")
|
|
1490
|
+
descriptor: Optional[int] = None
|
|
1491
|
+
try:
|
|
1492
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
|
|
1493
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
1494
|
+
descriptor = None
|
|
1495
|
+
handle.write(content)
|
|
1496
|
+
handle.flush()
|
|
1497
|
+
os.fsync(handle.fileno())
|
|
1498
|
+
os.replace(temporary, path)
|
|
1499
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
1500
|
+
try:
|
|
1501
|
+
os.fsync(directory)
|
|
1502
|
+
finally:
|
|
1503
|
+
os.close(directory)
|
|
1504
|
+
finally:
|
|
1505
|
+
if descriptor is not None:
|
|
1506
|
+
os.close(descriptor)
|
|
1507
|
+
if temporary.exists() or temporary.is_symlink():
|
|
1508
|
+
temporary.unlink()
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _validate_transition(
|
|
1512
|
+
before: PipelineState,
|
|
1513
|
+
after: PipelineState,
|
|
1514
|
+
event: str,
|
|
1515
|
+
project_dir: str,
|
|
1516
|
+
approval_kind: Optional[str] = None,
|
|
1517
|
+
allow_integration_configuration: bool = False,
|
|
1518
|
+
) -> None:
|
|
1519
|
+
before_position = (before.phase, before.status)
|
|
1520
|
+
after_position = (after.phase, after.status)
|
|
1521
|
+
approval_edge = (
|
|
1522
|
+
before.phase in CHECKPOINT_KINDS
|
|
1523
|
+
and before_position == (before.phase, "active")
|
|
1524
|
+
and after_position == (before.phase, "done")
|
|
1525
|
+
)
|
|
1526
|
+
if approval_edge and approval_kind != before.phase:
|
|
1527
|
+
raise PipelineStateError(
|
|
1528
|
+
f"{before.phase} approval requires pipeline_state.py approve "
|
|
1529
|
+
f"--kind {before.phase}"
|
|
1530
|
+
)
|
|
1531
|
+
if before.phase == after.phase:
|
|
1532
|
+
same_phase_statuses = {
|
|
1533
|
+
"active": {"active", "blocked", "done"},
|
|
1534
|
+
"blocked": {"blocked", "active"},
|
|
1535
|
+
"done": {"done"},
|
|
1536
|
+
}
|
|
1537
|
+
patch_reopen = (
|
|
1538
|
+
before_position == ("plan", "done")
|
|
1539
|
+
and after_position == ("plan", "active")
|
|
1540
|
+
and event == "patch plan reopened"
|
|
1541
|
+
)
|
|
1542
|
+
if after.status not in same_phase_statuses[before.status] and not patch_reopen:
|
|
1543
|
+
raise PipelineStateError(
|
|
1544
|
+
f"illegal state transition: {before_position} -> {after_position}"
|
|
1545
|
+
)
|
|
1546
|
+
elif (*before_position, *after_position) not in CROSS_PHASE_TRANSITIONS:
|
|
1547
|
+
raise PipelineStateError(
|
|
1548
|
+
f"illegal state transition: {before_position} -> {after_position}"
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
if before.pipeline != after.pipeline or before.project != after.project:
|
|
1552
|
+
raise PipelineStateError("pipeline and project are immutable")
|
|
1553
|
+
if (
|
|
1554
|
+
(*before_position, *after_position)
|
|
1555
|
+
== ("define", "active", "roadmap", "active")
|
|
1556
|
+
and PurePosixPath(project_dir) != PurePosixPath(".project")
|
|
1557
|
+
):
|
|
1558
|
+
raise PipelineStateError("roadmap re-slice is legal only on the active track")
|
|
1559
|
+
|
|
1560
|
+
branch_changed = before.branch != after.branch
|
|
1561
|
+
initial_binding = before.branch is None and after.branch is not None
|
|
1562
|
+
next_binding = (
|
|
1563
|
+
before.phase == "shipped"
|
|
1564
|
+
and before.status == "done"
|
|
1565
|
+
and after.phase in {"define", "inspect"}
|
|
1566
|
+
and after.status == "active"
|
|
1567
|
+
and before.branch is not None
|
|
1568
|
+
and after.branch is not None
|
|
1569
|
+
)
|
|
1570
|
+
abandon = (
|
|
1571
|
+
before.phase == "build"
|
|
1572
|
+
and after.phase == "roadmap"
|
|
1573
|
+
and after.status == "active"
|
|
1574
|
+
and before.archive is not None
|
|
1575
|
+
and after.archive is None
|
|
1576
|
+
)
|
|
1577
|
+
integration_changed = (
|
|
1578
|
+
before.integration_default != after.integration_default
|
|
1579
|
+
or before.integration != after.integration
|
|
1580
|
+
or before.integration_source != after.integration_source
|
|
1581
|
+
)
|
|
1582
|
+
if integration_changed and not (next_binding or abandon):
|
|
1583
|
+
if before.phase in {"build", "ship", "shipped"}:
|
|
1584
|
+
raise PipelineStateError("integration mode is locked when build starts")
|
|
1585
|
+
if not allow_integration_configuration:
|
|
1586
|
+
raise PipelineStateError(
|
|
1587
|
+
"integration mode changes require configure-integration"
|
|
1588
|
+
)
|
|
1589
|
+
if next_binding:
|
|
1590
|
+
before_number = _bound_branch_number(before.branch or "")
|
|
1591
|
+
after_number = _bound_branch_number(after.branch or "")
|
|
1592
|
+
if (
|
|
1593
|
+
before_number is None
|
|
1594
|
+
or after_number is None
|
|
1595
|
+
or after_number <= before_number
|
|
1596
|
+
):
|
|
1597
|
+
raise PipelineStateError(
|
|
1598
|
+
"next milestone requires a higher router-bound branch"
|
|
1599
|
+
)
|
|
1600
|
+
if after.integration_default != before.integration_default:
|
|
1601
|
+
raise PipelineStateError("next milestone must preserve integration_default")
|
|
1602
|
+
if after.integration != before.integration_default:
|
|
1603
|
+
raise PipelineStateError("next milestone must reset its integration override")
|
|
1604
|
+
if after.integration_source != "default":
|
|
1605
|
+
raise PipelineStateError("next milestone integration must use the project default")
|
|
1606
|
+
if abandon:
|
|
1607
|
+
if after.integration_default != before.integration_default:
|
|
1608
|
+
raise PipelineStateError("milestone abandon must preserve integration_default")
|
|
1609
|
+
if after.integration != before.integration_default:
|
|
1610
|
+
raise PipelineStateError("milestone abandon must reset its integration override")
|
|
1611
|
+
if after.integration_source != "default":
|
|
1612
|
+
raise PipelineStateError("milestone abandon integration must use the project default")
|
|
1613
|
+
if branch_changed and not (initial_binding or next_binding):
|
|
1614
|
+
raise PipelineStateError("illegal STATE.branch transition")
|
|
1615
|
+
if initial_binding:
|
|
1616
|
+
if before_position != after_position:
|
|
1617
|
+
raise PipelineStateError("initial branch binding cannot change phase or status")
|
|
1618
|
+
if event != "router bound initial milestone":
|
|
1619
|
+
raise PipelineStateError(
|
|
1620
|
+
"initial branch binding requires event: router bound initial milestone"
|
|
1621
|
+
)
|
|
1622
|
+
|
|
1623
|
+
archive_changed = before.archive != after.archive
|
|
1624
|
+
next_milestone = (
|
|
1625
|
+
before.phase == "shipped"
|
|
1626
|
+
and before.status == "done"
|
|
1627
|
+
and after.phase in {"define", "inspect"}
|
|
1628
|
+
and after.status == "active"
|
|
1629
|
+
and before.archive is not None
|
|
1630
|
+
and after.archive is None
|
|
1631
|
+
)
|
|
1632
|
+
if archive_changed and not (abandon or next_milestone):
|
|
1633
|
+
raise PipelineStateError("illegal STATE.archive transition")
|
|
1634
|
+
|
|
1635
|
+
milestone_changed = before.milestone != after.milestone
|
|
1636
|
+
define_approval = (
|
|
1637
|
+
before_position == ("define", "active")
|
|
1638
|
+
and after_position == ("define", "done")
|
|
1639
|
+
and after.milestone is not None
|
|
1640
|
+
and event == "milestone intent approved"
|
|
1641
|
+
)
|
|
1642
|
+
roadmap_selection = (
|
|
1643
|
+
before_position == ("roadmap", "active")
|
|
1644
|
+
and after_position == ("roadmap", "done")
|
|
1645
|
+
and after.milestone is not None
|
|
1646
|
+
and event == "program roadmap approved"
|
|
1647
|
+
)
|
|
1648
|
+
post_abandon_selection = (
|
|
1649
|
+
before_position == ("roadmap", "active")
|
|
1650
|
+
and after_position == ("inspect", "active")
|
|
1651
|
+
and before.milestone is None
|
|
1652
|
+
and after.milestone is not None
|
|
1653
|
+
)
|
|
1654
|
+
if milestone_changed and not (
|
|
1655
|
+
define_approval
|
|
1656
|
+
or roadmap_selection
|
|
1657
|
+
or next_milestone
|
|
1658
|
+
or abandon
|
|
1659
|
+
or post_abandon_selection
|
|
1660
|
+
):
|
|
1661
|
+
raise PipelineStateError("illegal STATE.milestone transition")
|
|
1662
|
+
if (
|
|
1663
|
+
before_position == ("plan", "blocked")
|
|
1664
|
+
and after_position == ("plan", "active")
|
|
1665
|
+
and event != "planning resumed"
|
|
1666
|
+
):
|
|
1667
|
+
raise PipelineStateError("plan recovery requires event: planning resumed")
|
|
1668
|
+
if abandon:
|
|
1669
|
+
expected_event = (
|
|
1670
|
+
f"milestone abandoned: {before.milestone}; archive: {before.archive}; "
|
|
1671
|
+
)
|
|
1672
|
+
if (
|
|
1673
|
+
before.milestone is None
|
|
1674
|
+
or after.milestone is not None
|
|
1675
|
+
or not event.startswith(expected_event)
|
|
1676
|
+
or not event.removeprefix(expected_event).startswith("ruling: ")
|
|
1677
|
+
or event == f"{expected_event}ruling:"
|
|
1678
|
+
):
|
|
1679
|
+
raise PipelineStateError(
|
|
1680
|
+
"milestone abandon event must name its milestone, archive, and ruling"
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
edge_events = {
|
|
1684
|
+
("plan", "done", "build", "active"): {"build started"},
|
|
1685
|
+
("build", "active", "ship", "active"): {
|
|
1686
|
+
"build done; final review pending"
|
|
1687
|
+
},
|
|
1688
|
+
("build", "done", "ship", "active"): {
|
|
1689
|
+
"build done; final review pending"
|
|
1690
|
+
},
|
|
1691
|
+
("decide", "done", "roadmap", "active"): {"roadmap started"},
|
|
1692
|
+
("define", "active", "roadmap", "active"): {
|
|
1693
|
+
"roadmap re-slice started"
|
|
1694
|
+
},
|
|
1695
|
+
("define", "done", "plan", "active"): {"planning started"},
|
|
1696
|
+
("decide", "done", "plan", "active"): {"planning started"},
|
|
1697
|
+
("ship", "blocked", "plan", "active"): {"patch plan reopened"},
|
|
1698
|
+
("ship", "active", "shipped", "done"): {
|
|
1699
|
+
"archive preflight passed; shipment recorded",
|
|
1700
|
+
"archive preflight passed; shipment recorded; program complete",
|
|
1701
|
+
},
|
|
1702
|
+
}.get((*before_position, *after_position))
|
|
1703
|
+
if edge_events is not None and event not in edge_events:
|
|
1704
|
+
expected = " or ".join(sorted(edge_events))
|
|
1705
|
+
raise PipelineStateError(f"state transition requires event: {expected}")
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def transition_state(
|
|
1709
|
+
repo: Path,
|
|
1710
|
+
expected: Mapping[str, Optional[str]],
|
|
1711
|
+
changes: Mapping[str, Optional[str]],
|
|
1712
|
+
event: str,
|
|
1713
|
+
project_dir: str = ".project",
|
|
1714
|
+
) -> dict[str, object]:
|
|
1715
|
+
"""Atomically apply a state change only when expected fields still match."""
|
|
1716
|
+
resolved = _repo_root(repo)
|
|
1717
|
+
project = _track_root(resolved, project_dir)
|
|
1718
|
+
unsupported = (set(expected) | set(changes)) - set(STATE_FIELDS)
|
|
1719
|
+
if unsupported:
|
|
1720
|
+
raise PipelineStateError(f"unsupported state fields: {', '.join(sorted(unsupported))}")
|
|
1721
|
+
if not changes:
|
|
1722
|
+
raise PipelineStateError("transition requires at least one changed field")
|
|
1723
|
+
required_expected = {"phase", "status", "branch", "archive"} | set(changes)
|
|
1724
|
+
missing_expected = sorted(required_expected - set(expected))
|
|
1725
|
+
if missing_expected:
|
|
1726
|
+
raise PipelineStateError(
|
|
1727
|
+
"transition is missing expected fields: " + ", ".join(missing_expected)
|
|
1728
|
+
)
|
|
1729
|
+
with _state_lock(project):
|
|
1730
|
+
state, text, path = load_state(resolved, project_dir)
|
|
1731
|
+
before, after, rendered = _render_transition(
|
|
1732
|
+
state,
|
|
1733
|
+
text,
|
|
1734
|
+
expected,
|
|
1735
|
+
changes,
|
|
1736
|
+
event,
|
|
1737
|
+
project_dir,
|
|
1738
|
+
)
|
|
1739
|
+
if after.status != "blocked":
|
|
1740
|
+
reason = _pending_discussion_block(
|
|
1741
|
+
resolved, after.phase if after.status == "active" else None
|
|
1742
|
+
)
|
|
1743
|
+
if reason:
|
|
1744
|
+
raise PipelineStateError(reason)
|
|
1745
|
+
if state.phase in {"research", "decide"} and (
|
|
1746
|
+
after.phase != state.phase or after.status == "done"
|
|
1747
|
+
):
|
|
1748
|
+
try:
|
|
1749
|
+
import check_handoffs
|
|
1750
|
+
except ImportError: # pragma: no cover - package imports used by tests
|
|
1751
|
+
from scripts import check_handoffs
|
|
1752
|
+
validator = {
|
|
1753
|
+
"research": check_handoffs.validate_research_artifacts,
|
|
1754
|
+
"decide": check_handoffs.validate_decide_artifacts,
|
|
1755
|
+
}[state.phase]
|
|
1756
|
+
try:
|
|
1757
|
+
validator(resolved, project_dir)
|
|
1758
|
+
except check_handoffs.HandoffError as error:
|
|
1759
|
+
raise PipelineStateError(f"{state.phase} handoff failed: {error}") from error
|
|
1760
|
+
_atomic_write(path, rendered)
|
|
1761
|
+
return {
|
|
1762
|
+
"schema": STATE_SCHEMA,
|
|
1763
|
+
"status": "transitioned",
|
|
1764
|
+
"path": str(path),
|
|
1765
|
+
"previous": before,
|
|
1766
|
+
"state": after.json(),
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def configure_integration(
|
|
1771
|
+
repo: Path,
|
|
1772
|
+
scope: str,
|
|
1773
|
+
mode: str,
|
|
1774
|
+
project_dir: str = ".project",
|
|
1775
|
+
) -> dict[str, object]:
|
|
1776
|
+
"""Set the project default or current milestone integration before build."""
|
|
1777
|
+
if scope not in {"default", "milestone"}:
|
|
1778
|
+
raise PipelineStateError(f"invalid integration scope: {scope}")
|
|
1779
|
+
if mode not in INTEGRATION_MODES:
|
|
1780
|
+
raise PipelineStateError(f"invalid integration mode: {mode}")
|
|
1781
|
+
if scope == "default" and _is_lookahead(project_dir):
|
|
1782
|
+
raise PipelineStateError(
|
|
1783
|
+
"project integration default must be configured on the active track"
|
|
1784
|
+
)
|
|
1785
|
+
resolved = _repo_root(repo)
|
|
1786
|
+
project = _track_root(resolved, project_dir)
|
|
1787
|
+
with _state_lock(project):
|
|
1788
|
+
state, text, path = load_state(resolved, project_dir)
|
|
1789
|
+
if state.phase in {"build", "ship", "shipped"}:
|
|
1790
|
+
raise PipelineStateError("integration mode is locked when build starts")
|
|
1791
|
+
if scope == "default":
|
|
1792
|
+
integration = (
|
|
1793
|
+
mode if state.integration_source == "default" else state.integration
|
|
1794
|
+
)
|
|
1795
|
+
changes = {
|
|
1796
|
+
"integration_default": mode,
|
|
1797
|
+
"integration": integration,
|
|
1798
|
+
"integration_source": state.integration_source,
|
|
1799
|
+
}
|
|
1800
|
+
else:
|
|
1801
|
+
changes = {
|
|
1802
|
+
"integration_default": state.integration_default,
|
|
1803
|
+
"integration": mode,
|
|
1804
|
+
"integration_source": "milestone",
|
|
1805
|
+
}
|
|
1806
|
+
rendered = _set_frontmatter(text, changes)
|
|
1807
|
+
rendered = _append_event(
|
|
1808
|
+
rendered,
|
|
1809
|
+
state.phase,
|
|
1810
|
+
f"integration {scope} set to {mode}",
|
|
1811
|
+
)
|
|
1812
|
+
after = _state_from_text(rendered)
|
|
1813
|
+
_validate_state_context(after, project_dir, "STATE.md")
|
|
1814
|
+
_validate_transition(
|
|
1815
|
+
state,
|
|
1816
|
+
after,
|
|
1817
|
+
f"integration {scope} set to {mode}",
|
|
1818
|
+
project_dir,
|
|
1819
|
+
allow_integration_configuration=True,
|
|
1820
|
+
)
|
|
1821
|
+
_atomic_write(path, rendered)
|
|
1822
|
+
return {
|
|
1823
|
+
"schema": STATE_SCHEMA,
|
|
1824
|
+
"status": "configured",
|
|
1825
|
+
"path": str(path),
|
|
1826
|
+
"previous": state.json(),
|
|
1827
|
+
"state": after.json(),
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def _shipment_roadmap(text: str, state: PipelineState, archive: str) -> str:
|
|
1832
|
+
if state.milestone is None or state.branch is None:
|
|
1833
|
+
raise PipelineStateError("shipment state must name milestone and branch")
|
|
1834
|
+
sections = _roadmap_sections(text)
|
|
1835
|
+
if state.milestone not in sections:
|
|
1836
|
+
raise PipelineStateError("ROADMAP.md is missing the shipped milestone")
|
|
1837
|
+
milestone_id, start, end = sections[state.milestone]
|
|
1838
|
+
number = _bound_branch_number(state.branch)
|
|
1839
|
+
match = ARCHIVE_RE.fullmatch(archive)
|
|
1840
|
+
if match is None or number != int(match.group(1)) or milestone_id != f"M{number:03d}":
|
|
1841
|
+
raise PipelineStateError("shipment branch, archive, and roadmap milestone differ")
|
|
1842
|
+
lines = text.splitlines(keepends=True)
|
|
1843
|
+
_replace_roadmap_field(lines, start, end, "Status", {"active", "shipped"}, "shipped")
|
|
1844
|
+
_replace_roadmap_field(lines, start, end, "Archive", {NULL, archive}, archive)
|
|
1845
|
+
return "".join(lines)
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def record_shipment(repo: Path, archive: str, event: str) -> dict[str, object]:
|
|
1849
|
+
"""Journal and atomically replace ROADMAP and STATE shipment metadata."""
|
|
1850
|
+
resolved = _repo_root(repo)
|
|
1851
|
+
project = _track_root(resolved, ".project")
|
|
1852
|
+
journal_path = _git_path(resolved, SHIPMENT_JOURNAL_NAME)
|
|
1853
|
+
with _state_lock(project):
|
|
1854
|
+
state, state_text, state_path = load_state(resolved)
|
|
1855
|
+
roadmap_path = project / "ROADMAP.md"
|
|
1856
|
+
charter_path = project / "CHARTER.md"
|
|
1857
|
+
if charter_path.is_symlink() or (
|
|
1858
|
+
charter_path.exists() and not charter_path.is_file()
|
|
1859
|
+
):
|
|
1860
|
+
raise PipelineStateError("CHARTER.md must be a real file")
|
|
1861
|
+
if roadmap_path.is_symlink() or (
|
|
1862
|
+
roadmap_path.exists() and not roadmap_path.is_file()
|
|
1863
|
+
):
|
|
1864
|
+
raise PipelineStateError("ROADMAP.md must be a real file")
|
|
1865
|
+
program = charter_path.is_file()
|
|
1866
|
+
if program and not roadmap_path.is_file():
|
|
1867
|
+
raise PipelineStateError("program shipment requires ROADMAP.md")
|
|
1868
|
+
if program or roadmap_path.is_file():
|
|
1869
|
+
roadmap_text: Optional[str] = _read_real_file(roadmap_path, "ROADMAP.md")
|
|
1870
|
+
target_roadmap: Optional[str] = _shipment_roadmap(roadmap_text, state, archive)
|
|
1871
|
+
else:
|
|
1872
|
+
roadmap_text = target_roadmap = None
|
|
1873
|
+
journal: Optional[dict[str, object]] = None
|
|
1874
|
+
event_date: Optional[str] = None
|
|
1875
|
+
if journal_path.exists() or journal_path.is_symlink():
|
|
1876
|
+
journal = _read_json(journal_path)
|
|
1877
|
+
# Reuse the journaled event date so a next-day resume still matches.
|
|
1878
|
+
recorded = re.match(
|
|
1879
|
+
r"- (\S+) — ",
|
|
1880
|
+
str(journal.get("state_after", "")).rstrip("\n").rsplit("\n", 1)[-1],
|
|
1881
|
+
)
|
|
1882
|
+
event_date = recorded.group(1) if recorded else None
|
|
1883
|
+
if (state.phase, state.status) == ("ship", "active"):
|
|
1884
|
+
_, after, target_state = _render_transition(
|
|
1885
|
+
state,
|
|
1886
|
+
state_text,
|
|
1887
|
+
{
|
|
1888
|
+
"phase": "ship",
|
|
1889
|
+
"status": "active",
|
|
1890
|
+
"branch": state.branch,
|
|
1891
|
+
"archive": archive,
|
|
1892
|
+
},
|
|
1893
|
+
{"phase": "shipped", "status": "done"},
|
|
1894
|
+
event,
|
|
1895
|
+
".project",
|
|
1896
|
+
event_date,
|
|
1897
|
+
)
|
|
1898
|
+
elif (state.phase, state.status) == ("shipped", "done"):
|
|
1899
|
+
if " ".join(event.split()) not in {
|
|
1900
|
+
"archive preflight passed; shipment recorded",
|
|
1901
|
+
"archive preflight passed; shipment recorded; program complete",
|
|
1902
|
+
}:
|
|
1903
|
+
raise PipelineStateError("shipment recovery has an invalid event")
|
|
1904
|
+
after, target_state = state, state_text
|
|
1905
|
+
else:
|
|
1906
|
+
raise PipelineStateError("shipment requires ship/active or shipped/done")
|
|
1907
|
+
request = {
|
|
1908
|
+
"schema": SHIPMENT_SCHEMA,
|
|
1909
|
+
"repo": str(resolved),
|
|
1910
|
+
"archive": archive,
|
|
1911
|
+
"event": " ".join(event.split()),
|
|
1912
|
+
}
|
|
1913
|
+
if journal is not None:
|
|
1914
|
+
expected_keys = {
|
|
1915
|
+
*request,
|
|
1916
|
+
"roadmap_before",
|
|
1917
|
+
"roadmap_after",
|
|
1918
|
+
"state_before",
|
|
1919
|
+
"state_after",
|
|
1920
|
+
}
|
|
1921
|
+
if set(journal) != expected_keys or any(
|
|
1922
|
+
journal.get(key) != value for key, value in request.items()
|
|
1923
|
+
):
|
|
1924
|
+
raise PipelineStateError("shipment journal does not match request")
|
|
1925
|
+
state_after = journal.get("state_after")
|
|
1926
|
+
roadmap_after = journal.get("roadmap_after")
|
|
1927
|
+
if (
|
|
1928
|
+
not isinstance(state_after, str)
|
|
1929
|
+
or state_after != target_state
|
|
1930
|
+
or roadmap_after != target_roadmap
|
|
1931
|
+
):
|
|
1932
|
+
raise PipelineStateError("shipment journal target does not match derived metadata")
|
|
1933
|
+
else:
|
|
1934
|
+
journal = {
|
|
1935
|
+
**request,
|
|
1936
|
+
"roadmap_before": roadmap_text,
|
|
1937
|
+
"roadmap_after": target_roadmap,
|
|
1938
|
+
"state_before": state_text,
|
|
1939
|
+
"state_after": target_state,
|
|
1940
|
+
}
|
|
1941
|
+
_write_json(journal_path, journal)
|
|
1942
|
+
targets = [(state_path, "state_before", "state_after")]
|
|
1943
|
+
if target_roadmap is not None:
|
|
1944
|
+
targets.insert(0, (roadmap_path, "roadmap_before", "roadmap_after"))
|
|
1945
|
+
for path, before_key, after_key in targets:
|
|
1946
|
+
current = _read_real_file(path, path.name)
|
|
1947
|
+
desired = journal.get(after_key)
|
|
1948
|
+
if not isinstance(desired, str) or current not in {journal.get(before_key), desired}:
|
|
1949
|
+
raise PipelineStateError(f"shipment metadata drifted: {path.name}")
|
|
1950
|
+
if current != desired:
|
|
1951
|
+
_atomic_write(path, desired)
|
|
1952
|
+
journal_path.unlink()
|
|
1953
|
+
return {"schema": SHIPMENT_SCHEMA, "status": "recorded", "archive": archive, "state": after.json()}
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _render_transition(
|
|
1957
|
+
state: PipelineState,
|
|
1958
|
+
text: str,
|
|
1959
|
+
expected: Mapping[str, Optional[str]],
|
|
1960
|
+
changes: Mapping[str, Optional[str]],
|
|
1961
|
+
event: str,
|
|
1962
|
+
project_dir: str,
|
|
1963
|
+
event_date: Optional[str] = None,
|
|
1964
|
+
approval_kind: Optional[str] = None,
|
|
1965
|
+
) -> tuple[dict[str, Optional[str]], PipelineState, str]:
|
|
1966
|
+
before = state.json()
|
|
1967
|
+
mismatches = {
|
|
1968
|
+
key: {"expected": value, "actual": before[key]}
|
|
1969
|
+
for key, value in expected.items()
|
|
1970
|
+
if before[key] != value
|
|
1971
|
+
}
|
|
1972
|
+
if mismatches:
|
|
1973
|
+
raise PipelineStateError(
|
|
1974
|
+
f"expected state does not match: {json.dumps(mismatches, sort_keys=True)}"
|
|
1975
|
+
)
|
|
1976
|
+
effective_changes = dict(changes)
|
|
1977
|
+
next_binding = (
|
|
1978
|
+
state.phase == "shipped"
|
|
1979
|
+
and state.status == "done"
|
|
1980
|
+
and effective_changes.get("phase", state.phase) in {"define", "inspect"}
|
|
1981
|
+
and effective_changes.get("status", state.status) == "active"
|
|
1982
|
+
and state.branch is not None
|
|
1983
|
+
and effective_changes.get("branch", state.branch) is not None
|
|
1984
|
+
)
|
|
1985
|
+
if next_binding:
|
|
1986
|
+
effective_changes["integration_default"] = state.integration_default
|
|
1987
|
+
effective_changes["integration"] = state.integration_default
|
|
1988
|
+
effective_changes["integration_source"] = "default"
|
|
1989
|
+
abandon = (
|
|
1990
|
+
state.phase == "build"
|
|
1991
|
+
and effective_changes.get("phase", state.phase) == "roadmap"
|
|
1992
|
+
and effective_changes.get("status", state.status) == "active"
|
|
1993
|
+
and state.archive is not None
|
|
1994
|
+
and effective_changes.get("archive", state.archive) is None
|
|
1995
|
+
)
|
|
1996
|
+
if abandon:
|
|
1997
|
+
effective_changes["integration_default"] = state.integration_default
|
|
1998
|
+
effective_changes["integration"] = state.integration_default
|
|
1999
|
+
effective_changes["integration_source"] = "default"
|
|
2000
|
+
rendered = _set_frontmatter(
|
|
2001
|
+
text,
|
|
2002
|
+
{
|
|
2003
|
+
key: NULL if value is None else value
|
|
2004
|
+
for key, value in effective_changes.items()
|
|
2005
|
+
},
|
|
2006
|
+
)
|
|
2007
|
+
next_phase = effective_changes.get("phase", state.phase)
|
|
2008
|
+
if not isinstance(next_phase, str):
|
|
2009
|
+
raise PipelineStateError("phase cannot be null")
|
|
2010
|
+
normalized_event = " ".join(event.split())
|
|
2011
|
+
rendered = _append_event(rendered, next_phase, normalized_event, event_date)
|
|
2012
|
+
after = _state_from_text(rendered)
|
|
2013
|
+
_validate_state_context(after, project_dir, "STATE.md")
|
|
2014
|
+
_validate_transition(
|
|
2015
|
+
state,
|
|
2016
|
+
after,
|
|
2017
|
+
normalized_event,
|
|
2018
|
+
project_dir,
|
|
2019
|
+
approval_kind,
|
|
2020
|
+
)
|
|
2021
|
+
return before, after, rendered
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def _roadmap_sections(text: str) -> dict[str, tuple[str, int, int]]:
|
|
2025
|
+
lines = text.splitlines(keepends=True)
|
|
2026
|
+
headings: list[tuple[str, str, int]] = []
|
|
2027
|
+
for index, line in enumerate(lines):
|
|
2028
|
+
match = ROADMAP_HEADING_RE.fullmatch(line.rstrip("\r\n"))
|
|
2029
|
+
if match:
|
|
2030
|
+
if int(match.group(1).removeprefix("M")) < 1:
|
|
2031
|
+
raise PipelineStateError(f"ROADMAP.md has invalid milestone id: {match.group(1)}")
|
|
2032
|
+
headings.append((match.group(1), match.group(2), index))
|
|
2033
|
+
sections: dict[str, tuple[str, int, int]] = {}
|
|
2034
|
+
for position, (milestone_id, slug, start) in enumerate(headings):
|
|
2035
|
+
end = headings[position + 1][2] if position + 1 < len(headings) else len(lines)
|
|
2036
|
+
if slug in sections:
|
|
2037
|
+
raise PipelineStateError(f"ROADMAP.md repeats milestone slug: {slug}")
|
|
2038
|
+
sections[slug] = (milestone_id, start, end)
|
|
2039
|
+
return sections
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
def _replace_roadmap_field(
|
|
2043
|
+
lines: list[str],
|
|
2044
|
+
start: int,
|
|
2045
|
+
end: int,
|
|
2046
|
+
field: str,
|
|
2047
|
+
expected: set[str],
|
|
2048
|
+
value: str,
|
|
2049
|
+
) -> None:
|
|
2050
|
+
found: Optional[int] = None
|
|
2051
|
+
for index in range(start, end):
|
|
2052
|
+
raw = lines[index].rstrip("\r\n")
|
|
2053
|
+
ending = lines[index][len(raw) :]
|
|
2054
|
+
match = re.match(rf"^({re.escape(field)}:\s*)(\S+)(.*)$", raw)
|
|
2055
|
+
if match is None:
|
|
2056
|
+
continue
|
|
2057
|
+
if found is not None:
|
|
2058
|
+
raise PipelineStateError(f"ROADMAP.md repeats {field} in one milestone")
|
|
2059
|
+
if match.group(2) not in expected:
|
|
2060
|
+
raise PipelineStateError(
|
|
2061
|
+
f"ROADMAP.md {field} is {match.group(2)}, expected {sorted(expected)}"
|
|
2062
|
+
)
|
|
2063
|
+
lines[index] = f"{match.group(1)}{value}{match.group(3)}{ending}"
|
|
2064
|
+
found = index
|
|
2065
|
+
if found is None:
|
|
2066
|
+
raise PipelineStateError(f"ROADMAP.md milestone is missing {field}")
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _git_path(repo: Path, name: str) -> Path:
|
|
2070
|
+
value = _run_git(repo, "rev-parse", "--git-path", name).stdout.strip()
|
|
2071
|
+
path = Path(value)
|
|
2072
|
+
if not path.is_absolute():
|
|
2073
|
+
path = repo / path
|
|
2074
|
+
return path.resolve()
|
|
2075
|
+
|
|
2076
|
+
|
|
2077
|
+
def _read_json(path: Path) -> dict[str, object]:
|
|
2078
|
+
if path.is_symlink() or not path.is_file():
|
|
2079
|
+
raise PipelineStateError(f"transaction journal must be a real file: {path}")
|
|
2080
|
+
try:
|
|
2081
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
2082
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
2083
|
+
raise PipelineStateError(f"transaction journal is unreadable: {path}: {error}") from error
|
|
2084
|
+
if not isinstance(value, dict):
|
|
2085
|
+
raise PipelineStateError(f"transaction journal must contain an object: {path}")
|
|
2086
|
+
return value
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
def _write_json(path: Path, value: Mapping[str, object]) -> None:
|
|
2090
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2091
|
+
_atomic_write(path, json.dumps(value, indent=2, sort_keys=True) + "\n")
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def _commit_message(subject: str, body_fields: Mapping[str, str]) -> str:
|
|
2095
|
+
body = "\n".join(f"{key}: {value}" for key, value in body_fields.items())
|
|
2096
|
+
return f"{subject}\n\n{body}\n"
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _validate_next_layout(next_root: Path, state: PipelineState) -> None:
|
|
2100
|
+
phase_index = LOOKAHEAD_PHASES.index(state.phase)
|
|
2101
|
+
for path in next_root.rglob("*"):
|
|
2102
|
+
relative = path.relative_to(next_root)
|
|
2103
|
+
if path.is_symlink():
|
|
2104
|
+
raise PipelineStateError(f"lookahead artifacts must not be symlinks: {path}")
|
|
2105
|
+
if path.is_dir():
|
|
2106
|
+
if (
|
|
2107
|
+
relative.parent != PurePosixPath(".")
|
|
2108
|
+
or path.name not in PROMOTION_TRACKS
|
|
2109
|
+
):
|
|
2110
|
+
raise PipelineStateError(f"lookahead has unowned directory: {path}")
|
|
2111
|
+
continue
|
|
2112
|
+
if not path.is_file():
|
|
2113
|
+
raise PipelineStateError(f"lookahead has unsafe artifact: {path}")
|
|
2114
|
+
artifact = relative.as_posix()
|
|
2115
|
+
required_phase = LOOKAHEAD_FIXED_ARTIFACTS.get(artifact)
|
|
2116
|
+
if required_phase is None and LOOKAHEAD_EVIDENCE_RE.fullmatch(artifact):
|
|
2117
|
+
required_phase = "research"
|
|
2118
|
+
if (
|
|
2119
|
+
required_phase is None
|
|
2120
|
+
and relative.parent == PurePosixPath("tasks")
|
|
2121
|
+
and TASK_FILE_RE.fullmatch(path.name)
|
|
2122
|
+
):
|
|
2123
|
+
required_phase = "plan"
|
|
2124
|
+
if (
|
|
2125
|
+
required_phase is None
|
|
2126
|
+
or LOOKAHEAD_PHASES.index(required_phase) > phase_index
|
|
2127
|
+
):
|
|
2128
|
+
raise PipelineStateError(
|
|
2129
|
+
f"lookahead has artifact not owned by {state.phase}: {path}"
|
|
2130
|
+
)
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
def _worktree_changes(repo: Path) -> list[str]:
|
|
2134
|
+
commands = (
|
|
2135
|
+
("diff", "--name-only", "-z"),
|
|
2136
|
+
("diff", "--cached", "--name-only", "-z"),
|
|
2137
|
+
("ls-files", "--others", "--exclude-standard", "-z"),
|
|
2138
|
+
)
|
|
2139
|
+
return sorted(
|
|
2140
|
+
{
|
|
2141
|
+
path
|
|
2142
|
+
for arguments in commands
|
|
2143
|
+
for path in _run_git(repo, *arguments).stdout.split("\0")
|
|
2144
|
+
if path
|
|
2145
|
+
}
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
def _field_arguments(parser: argparse.ArgumentParser, prefix: str) -> None:
|
|
2150
|
+
for field in STATE_FIELDS:
|
|
2151
|
+
parser.add_argument(f"--{prefix}-{field}", dest=f"{prefix}_{field}")
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
def _specified_fields(arguments: argparse.Namespace, prefix: str) -> dict[str, Optional[str]]:
|
|
2155
|
+
values: dict[str, Optional[str]] = {}
|
|
2156
|
+
for field in STATE_FIELDS:
|
|
2157
|
+
value = getattr(arguments, f"{prefix}_{field}")
|
|
2158
|
+
if value is not None:
|
|
2159
|
+
values[field] = None if value == NULL else value
|
|
2160
|
+
return values
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
2164
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
2165
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
2166
|
+
for command in ("validate", "route", "status"):
|
|
2167
|
+
subparser = subparsers.add_parser(command)
|
|
2168
|
+
subparser.add_argument("--repo", required=True, type=Path)
|
|
2169
|
+
subparser.add_argument("--project-dir", default=".project")
|
|
2170
|
+
transition = subparsers.add_parser("transition")
|
|
2171
|
+
transition.add_argument("--repo", required=True, type=Path)
|
|
2172
|
+
transition.add_argument("--project-dir", default=".project")
|
|
2173
|
+
transition.add_argument("--event", required=True)
|
|
2174
|
+
_field_arguments(transition, "expect")
|
|
2175
|
+
_field_arguments(transition, "set")
|
|
2176
|
+
approve = subparsers.add_parser("approve")
|
|
2177
|
+
approve.add_argument("--repo", required=True, type=Path)
|
|
2178
|
+
approve.add_argument("--kind", required=True, choices=CHECKPOINT_KINDS)
|
|
2179
|
+
approve.add_argument("--expected-head")
|
|
2180
|
+
approve.add_argument("--project-dir", default=".project")
|
|
2181
|
+
approve.add_argument("--milestone")
|
|
2182
|
+
approve.add_argument("--defer-checkpoint", action="store_true")
|
|
2183
|
+
approve.add_argument("--patch", action="store_true")
|
|
2184
|
+
resume = subparsers.add_parser("resume-checkpoint")
|
|
2185
|
+
resume.add_argument("--repo", required=True, type=Path)
|
|
2186
|
+
promote = subparsers.add_parser("promote-next")
|
|
2187
|
+
promote.add_argument("--repo", required=True, type=Path)
|
|
2188
|
+
promote.add_argument("--milestone", required=True)
|
|
2189
|
+
promote.add_argument("--branch", required=True)
|
|
2190
|
+
promote.add_argument("--integrate")
|
|
2191
|
+
promote.add_argument("--base")
|
|
2192
|
+
promote.add_argument("--landing")
|
|
2193
|
+
configure = subparsers.add_parser("configure-integration")
|
|
2194
|
+
configure.add_argument("--repo", required=True, type=Path)
|
|
2195
|
+
configure.add_argument("--project-dir", default=".project")
|
|
2196
|
+
configure.add_argument("--scope", required=True, choices=("default", "milestone"))
|
|
2197
|
+
configure.add_argument("--mode", required=True, choices=INTEGRATION_MODES)
|
|
2198
|
+
shipment = subparsers.add_parser("record-shipment")
|
|
2199
|
+
shipment.add_argument("--repo", required=True, type=Path)
|
|
2200
|
+
shipment.add_argument("--archive", required=True)
|
|
2201
|
+
shipment.add_argument("--event", required=True)
|
|
2202
|
+
return parser.parse_args(argv)
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
2206
|
+
args = parse_args(argv)
|
|
2207
|
+
try:
|
|
2208
|
+
if args.command == "validate":
|
|
2209
|
+
result = validate_state(args.repo, args.project_dir)
|
|
2210
|
+
elif args.command == "route":
|
|
2211
|
+
result = route_state(args.repo, args.project_dir)
|
|
2212
|
+
elif args.command == "status":
|
|
2213
|
+
result = status_state(args.repo, args.project_dir)
|
|
2214
|
+
elif args.command == "transition":
|
|
2215
|
+
result = transition_state(
|
|
2216
|
+
args.repo,
|
|
2217
|
+
_specified_fields(args, "expect"),
|
|
2218
|
+
_specified_fields(args, "set"),
|
|
2219
|
+
args.event,
|
|
2220
|
+
args.project_dir,
|
|
2221
|
+
)
|
|
2222
|
+
elif args.command == "approve" and (args.defer_checkpoint or args.patch):
|
|
2223
|
+
if args.expected_head is not None:
|
|
2224
|
+
raise PipelineStateError(
|
|
2225
|
+
"--expected-head is not accepted with --defer-checkpoint or --patch"
|
|
2226
|
+
)
|
|
2227
|
+
result = defer_approval(
|
|
2228
|
+
args.repo,
|
|
2229
|
+
args.kind,
|
|
2230
|
+
args.project_dir,
|
|
2231
|
+
args.milestone,
|
|
2232
|
+
args.patch,
|
|
2233
|
+
)
|
|
2234
|
+
elif args.command == "approve":
|
|
2235
|
+
if args.expected_head is None:
|
|
2236
|
+
raise PipelineStateError(
|
|
2237
|
+
"approve requires --expected-head unless --defer-checkpoint or --patch"
|
|
2238
|
+
)
|
|
2239
|
+
result = checkpoint_approval(
|
|
2240
|
+
args.repo,
|
|
2241
|
+
args.kind,
|
|
2242
|
+
args.expected_head,
|
|
2243
|
+
args.project_dir,
|
|
2244
|
+
args.milestone,
|
|
2245
|
+
)
|
|
2246
|
+
elif args.command == "resume-checkpoint":
|
|
2247
|
+
result = resume_checkpoint(args.repo)
|
|
2248
|
+
elif args.command == "promote-next":
|
|
2249
|
+
if args.integrate is not None and (
|
|
2250
|
+
args.base is not None or args.landing is not None
|
|
2251
|
+
):
|
|
2252
|
+
raise PipelineStateError(
|
|
2253
|
+
"promote-next cannot combine --integrate with --base or --landing"
|
|
2254
|
+
)
|
|
2255
|
+
base = args.integrate if args.integrate is not None else args.base
|
|
2256
|
+
landing = (
|
|
2257
|
+
args.integrate
|
|
2258
|
+
if args.integrate is not None
|
|
2259
|
+
else args.landing or base
|
|
2260
|
+
)
|
|
2261
|
+
if base is None:
|
|
2262
|
+
raise PipelineStateError(
|
|
2263
|
+
"promote-next requires --base or legacy --integrate"
|
|
2264
|
+
)
|
|
2265
|
+
result = promote_next(
|
|
2266
|
+
args.repo,
|
|
2267
|
+
args.milestone,
|
|
2268
|
+
args.branch,
|
|
2269
|
+
base,
|
|
2270
|
+
landing,
|
|
2271
|
+
)
|
|
2272
|
+
elif args.command == "record-shipment":
|
|
2273
|
+
result = record_shipment(args.repo, args.archive, args.event)
|
|
2274
|
+
elif args.command == "configure-integration":
|
|
2275
|
+
result = configure_integration(
|
|
2276
|
+
args.repo,
|
|
2277
|
+
args.scope,
|
|
2278
|
+
args.mode,
|
|
2279
|
+
args.project_dir,
|
|
2280
|
+
)
|
|
2281
|
+
else: # pragma: no cover - argparse rejects unknown commands
|
|
2282
|
+
raise PipelineStateError(f"unknown command: {args.command}")
|
|
2283
|
+
except PipelineStateError as error:
|
|
2284
|
+
print(f"error: {error}", file=sys.stderr)
|
|
2285
|
+
return 1
|
|
2286
|
+
print(json.dumps(result, sort_keys=True))
|
|
2287
|
+
return 0
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
if __name__ == "__main__":
|
|
2291
|
+
raise SystemExit(main())
|