@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,1039 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Helper-owned undo for unpublished GSD Path pipeline commits and worktrees.
|
|
4
|
+
|
|
5
|
+
Preview is read-only. Apply mutates only after the caller supplies the exact
|
|
6
|
+
HEAD SHA returned by preview. The helper never force-pushes, never rewrites a
|
|
7
|
+
commit that is an ancestor of origin/main, and never invents a git reset the
|
|
8
|
+
model did not ask this command to perform.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Optional, Sequence
|
|
23
|
+
|
|
24
|
+
def _load_pipeline_modules():
|
|
25
|
+
try:
|
|
26
|
+
import archive_milestone
|
|
27
|
+
import isolation
|
|
28
|
+
import pipeline_git
|
|
29
|
+
import pipeline_state
|
|
30
|
+
return archive_milestone, isolation, pipeline_git, pipeline_state
|
|
31
|
+
except ModuleNotFoundError as error:
|
|
32
|
+
if error.name not in {
|
|
33
|
+
"archive_milestone",
|
|
34
|
+
"isolation",
|
|
35
|
+
"pipeline_git",
|
|
36
|
+
"pipeline_state",
|
|
37
|
+
}:
|
|
38
|
+
raise
|
|
39
|
+
try:
|
|
40
|
+
from scripts import archive_milestone, isolation, pipeline_git, pipeline_state
|
|
41
|
+
return archive_milestone, isolation, pipeline_git, pipeline_state
|
|
42
|
+
except ModuleNotFoundError as error:
|
|
43
|
+
if error.name not in {
|
|
44
|
+
"scripts",
|
|
45
|
+
"scripts.archive_milestone",
|
|
46
|
+
"scripts.isolation",
|
|
47
|
+
"scripts.pipeline_git",
|
|
48
|
+
"scripts.pipeline_state",
|
|
49
|
+
}:
|
|
50
|
+
raise
|
|
51
|
+
shared = Path(__file__).resolve().parents[2] / "gsd-path" / "scripts"
|
|
52
|
+
sys.path.insert(0, str(shared))
|
|
53
|
+
import archive_milestone
|
|
54
|
+
import isolation
|
|
55
|
+
import pipeline_git
|
|
56
|
+
import pipeline_state
|
|
57
|
+
return archive_milestone, isolation, pipeline_git, pipeline_state
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
archive_milestone, isolation, pipeline_git, pipeline_state = _load_pipeline_modules()
|
|
61
|
+
ArchiveError = archive_milestone.ArchiveError
|
|
62
|
+
IsolationError = isolation.IsolationError
|
|
63
|
+
PLAN_APPROVAL_SUBJECT = pipeline_state.PLAN_APPROVAL_SUBJECT
|
|
64
|
+
PipelineStateError = pipeline_state.PipelineStateError
|
|
65
|
+
_activate_roadmap_milestone = pipeline_state._activate_roadmap_milestone
|
|
66
|
+
_approval_details = pipeline_state._approval_details
|
|
67
|
+
_commit_subject_body = pipeline_state._commit_subject_body
|
|
68
|
+
_optional_rev = pipeline_state._optional_rev
|
|
69
|
+
_render_transition = pipeline_state._render_transition
|
|
70
|
+
_shipment_roadmap = pipeline_state._shipment_roadmap
|
|
71
|
+
_repo_root = pipeline_state._repo_root
|
|
72
|
+
_run_git = pipeline_state._run_git
|
|
73
|
+
_state_from_text = pipeline_state._state_from_text
|
|
74
|
+
_git_path = pipeline_state._git_path
|
|
75
|
+
_read_json = pipeline_state._read_json
|
|
76
|
+
_write_json = pipeline_state._write_json
|
|
77
|
+
_worktree_changes = pipeline_state._worktree_changes
|
|
78
|
+
undo_transaction = pipeline_state.undo_transaction
|
|
79
|
+
load_state = pipeline_state.load_state
|
|
80
|
+
status_state = pipeline_state.status_state
|
|
81
|
+
is_bound_branch = pipeline_git.is_bound_branch
|
|
82
|
+
is_ship_subject = pipeline_git.is_ship_subject
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
UNDO_SCHEMA = "gsd-path/undo/v1"
|
|
86
|
+
UNDO_TRANSACTION_SCHEMA = pipeline_state.UNDO_TRANSACTION_SCHEMA
|
|
87
|
+
UNDO_TRANSACTION_NAME = pipeline_state.UNDO_TRANSACTION_NAME
|
|
88
|
+
ROADMAP_APPROVAL_SUBJECT = "roadmap: program roadmap approved"
|
|
89
|
+
TASK_SUBJECT_RE = re.compile(r"^(T\d{3,}): .+")
|
|
90
|
+
KINDS = ("checkpoint", "task", "uncommitted-archive", "lookahead")
|
|
91
|
+
PROMOTION_PREFIX = "router: promote lookahead milestone "
|
|
92
|
+
ABANDON_PREFIX = "build: abandon milestone "
|
|
93
|
+
INTEGRATE_PREFIX = "integrate: "
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UndoError(RuntimeError):
|
|
97
|
+
"""Raised when undo cannot be proven or applied safely."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_archive(value: str) -> str:
|
|
101
|
+
return value.rstrip("/")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _archive_in_head(repo: Path, archive: str) -> bool:
|
|
105
|
+
relative = _normalize_archive(archive)
|
|
106
|
+
listed = _run_git(
|
|
107
|
+
repo,
|
|
108
|
+
"ls-tree",
|
|
109
|
+
"-r",
|
|
110
|
+
"--name-only",
|
|
111
|
+
"HEAD",
|
|
112
|
+
"--",
|
|
113
|
+
relative,
|
|
114
|
+
f"{relative}/",
|
|
115
|
+
check=False,
|
|
116
|
+
)
|
|
117
|
+
return listed.returncode == 0 and bool(listed.stdout.strip())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parent_sha(repo: Path, head: str) -> Optional[str]:
|
|
121
|
+
result = _run_git(repo, "rev-parse", "--verify", f"{head}^", check=False)
|
|
122
|
+
sha = result.stdout.strip()
|
|
123
|
+
return sha if result.returncode == 0 and sha else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _product_dirty(dirty: Sequence[str]) -> list[str]:
|
|
127
|
+
return [path for path in dirty if not path.startswith(".project/")]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _active_journals(journals: dict[str, Optional[str]]) -> dict[str, str]:
|
|
131
|
+
return {key: value for key, value in journals.items() if value}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _blocked(reasons: Sequence[str]) -> dict[str, object]:
|
|
135
|
+
return {
|
|
136
|
+
"kind": None,
|
|
137
|
+
"head": None,
|
|
138
|
+
"parent": None,
|
|
139
|
+
"subject": None,
|
|
140
|
+
"effects": [],
|
|
141
|
+
"blocked": list(reasons),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _git_text(repo: Path, revision: str, relative: str) -> str:
|
|
146
|
+
result = _run_git(repo, "show", f"{revision}:{relative}", check=False)
|
|
147
|
+
if result.returncode != 0:
|
|
148
|
+
raise UndoError(f"missing {relative} in {revision}")
|
|
149
|
+
return result.stdout
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _checkpoint_proof_error(
|
|
153
|
+
repo: Path, head: str, parent: str, subject: str
|
|
154
|
+
) -> Optional[str]:
|
|
155
|
+
changed = set(
|
|
156
|
+
_run_git(
|
|
157
|
+
repo,
|
|
158
|
+
"diff-tree",
|
|
159
|
+
"--no-commit-id",
|
|
160
|
+
"--name-only",
|
|
161
|
+
"-r",
|
|
162
|
+
head,
|
|
163
|
+
).stdout.splitlines()
|
|
164
|
+
)
|
|
165
|
+
if not changed or any(not path.startswith(".project/") for path in changed):
|
|
166
|
+
return "checkpoint changes paths outside .project"
|
|
167
|
+
state_paths = [
|
|
168
|
+
path
|
|
169
|
+
for path in (".project/STATE.md", ".project/next/STATE.md")
|
|
170
|
+
if path in changed
|
|
171
|
+
]
|
|
172
|
+
if len(state_paths) != 1:
|
|
173
|
+
return "checkpoint must change exactly one owned STATE.md"
|
|
174
|
+
state_path = state_paths[0]
|
|
175
|
+
project_dir = str(PurePosixPath(state_path).parent)
|
|
176
|
+
try:
|
|
177
|
+
before_text = _git_text(repo, parent, state_path)
|
|
178
|
+
after_text = _git_text(repo, head, state_path)
|
|
179
|
+
before = _state_from_text(before_text, f"{parent}:{state_path}")
|
|
180
|
+
after = _state_from_text(after_text, f"{head}:{state_path}")
|
|
181
|
+
body = _commit_subject_body(repo, head)[1].strip()
|
|
182
|
+
if subject == PLAN_APPROVAL_SUBJECT:
|
|
183
|
+
kind = "plan"
|
|
184
|
+
selected = None
|
|
185
|
+
elif subject == ROADMAP_APPROVAL_SUBJECT:
|
|
186
|
+
kind = "roadmap"
|
|
187
|
+
selected = after.milestone
|
|
188
|
+
else:
|
|
189
|
+
return "checkpoint subject is not canonical"
|
|
190
|
+
changes, event, expected_subject, expected_body = _approval_details(
|
|
191
|
+
kind, before, selected
|
|
192
|
+
)
|
|
193
|
+
if subject != expected_subject or body != expected_body:
|
|
194
|
+
return "checkpoint message does not match its state transition"
|
|
195
|
+
added_dates = re.findall(
|
|
196
|
+
rf"^- (\d{{4}}-\d{{2}}-\d{{2}}) — {kind} — {re.escape(event)}$",
|
|
197
|
+
after_text,
|
|
198
|
+
re.MULTILINE,
|
|
199
|
+
)
|
|
200
|
+
if not added_dates:
|
|
201
|
+
return "checkpoint state lacks its canonical event"
|
|
202
|
+
expected = {
|
|
203
|
+
"phase": before.phase,
|
|
204
|
+
"status": before.status,
|
|
205
|
+
"milestone": before.milestone,
|
|
206
|
+
"branch": before.branch,
|
|
207
|
+
"archive": before.archive,
|
|
208
|
+
}
|
|
209
|
+
_, expected_state, rendered = _render_transition(
|
|
210
|
+
before,
|
|
211
|
+
before_text,
|
|
212
|
+
expected,
|
|
213
|
+
changes,
|
|
214
|
+
event,
|
|
215
|
+
project_dir,
|
|
216
|
+
added_dates[-1],
|
|
217
|
+
kind,
|
|
218
|
+
)
|
|
219
|
+
if rendered != after_text or expected_state != after:
|
|
220
|
+
return "checkpoint STATE.md is not the canonical approval result"
|
|
221
|
+
if kind == "roadmap":
|
|
222
|
+
roadmap_before = _git_text(repo, parent, ".project/ROADMAP.md")
|
|
223
|
+
roadmap_after = _git_text(repo, head, ".project/ROADMAP.md")
|
|
224
|
+
if roadmap_after != _activate_roadmap_milestone(
|
|
225
|
+
roadmap_before, str(selected)
|
|
226
|
+
):
|
|
227
|
+
return "checkpoint ROADMAP.md is not the canonical approval result"
|
|
228
|
+
except (PipelineStateError, UndoError) as error:
|
|
229
|
+
return str(error)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _task_proof_error(repo: Path, head: str) -> Optional[str]:
|
|
234
|
+
try:
|
|
235
|
+
recovered = isolation.recover(repo, Path(".project/tasks"))
|
|
236
|
+
except IsolationError as error:
|
|
237
|
+
return str(error)
|
|
238
|
+
matches = [
|
|
239
|
+
task
|
|
240
|
+
for task in recovered.get("tasks", [])
|
|
241
|
+
if task.get("verdict") == "recovered" and task.get("commit") == head
|
|
242
|
+
]
|
|
243
|
+
if recovered.get("verdict") != "ok" or len(matches) != 1:
|
|
244
|
+
return "HEAD is not the uniquely proven last task landing"
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _discussion_extension(repo: Path, archive: str) -> Optional[dict[str, bytes]]:
|
|
249
|
+
active = repo / ".project" / "discuss"
|
|
250
|
+
if not active.exists() and not active.is_symlink():
|
|
251
|
+
return None
|
|
252
|
+
archived = repo / archive / "discuss"
|
|
253
|
+
try:
|
|
254
|
+
archive_milestone.require_append_only_discussion(
|
|
255
|
+
active,
|
|
256
|
+
archived,
|
|
257
|
+
require_dispositions=False,
|
|
258
|
+
)
|
|
259
|
+
except (ArchiveError, OSError) as error:
|
|
260
|
+
raise UndoError(f"discussion records cannot be preserved: {error}") from error
|
|
261
|
+
return {
|
|
262
|
+
name: (active / name).read_bytes()
|
|
263
|
+
for name in archive_milestone.DISCUSSION_FILES
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _restore_discussion(repo: Path, files: dict[str, bytes]) -> None:
|
|
268
|
+
discussion = repo / ".project" / "discuss"
|
|
269
|
+
if discussion.is_symlink() or (discussion.exists() and not discussion.is_dir()):
|
|
270
|
+
raise UndoError("discussion restore destination is unsafe")
|
|
271
|
+
discussion.mkdir(exist_ok=True)
|
|
272
|
+
for name, data in files.items():
|
|
273
|
+
temporary_name: Optional[str] = None
|
|
274
|
+
try:
|
|
275
|
+
with tempfile.NamedTemporaryFile(dir=discussion, delete=False) as handle:
|
|
276
|
+
handle.write(data)
|
|
277
|
+
handle.flush()
|
|
278
|
+
os.fsync(handle.fileno())
|
|
279
|
+
temporary_name = handle.name
|
|
280
|
+
os.replace(temporary_name, discussion / name)
|
|
281
|
+
temporary_name = None
|
|
282
|
+
finally:
|
|
283
|
+
if temporary_name:
|
|
284
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
285
|
+
try:
|
|
286
|
+
archive_milestone.validate_discussion_directory(
|
|
287
|
+
discussion,
|
|
288
|
+
require_dispositions=False,
|
|
289
|
+
)
|
|
290
|
+
except ArchiveError as error:
|
|
291
|
+
raise UndoError(f"restored discussion records are invalid: {error}") from error
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _commit_discussion(repo: Path, revision: str) -> Optional[dict[str, bytes]]:
|
|
295
|
+
files: dict[str, bytes] = {}
|
|
296
|
+
for name in archive_milestone.DISCUSSION_FILES:
|
|
297
|
+
result = _run_git(
|
|
298
|
+
repo,
|
|
299
|
+
"show",
|
|
300
|
+
f"{revision}:.project/discuss/{name}",
|
|
301
|
+
check=False,
|
|
302
|
+
)
|
|
303
|
+
if result.returncode == 0:
|
|
304
|
+
files[name] = result.stdout.encode()
|
|
305
|
+
if not files:
|
|
306
|
+
return None
|
|
307
|
+
if set(files) != set(archive_milestone.DISCUSSION_FILES):
|
|
308
|
+
raise UndoError(f"commit {revision} has incomplete discussion records")
|
|
309
|
+
return files
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _checkpoint_discussion(
|
|
313
|
+
repo: Path, head: str, parent: str
|
|
314
|
+
) -> Optional[dict[str, bytes]]:
|
|
315
|
+
current = _commit_discussion(repo, head)
|
|
316
|
+
if current is None:
|
|
317
|
+
return None
|
|
318
|
+
previous = _commit_discussion(repo, parent)
|
|
319
|
+
if previous is not None and any(
|
|
320
|
+
not current[name].startswith(previous[name]) for name in current
|
|
321
|
+
):
|
|
322
|
+
raise UndoError("checkpoint discussion records are not append-only")
|
|
323
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
324
|
+
directory = Path(temporary)
|
|
325
|
+
for name, data in current.items():
|
|
326
|
+
(directory / name).write_bytes(data)
|
|
327
|
+
try:
|
|
328
|
+
archive_milestone.validate_discussion_directory(
|
|
329
|
+
directory,
|
|
330
|
+
require_dispositions=False,
|
|
331
|
+
)
|
|
332
|
+
except ArchiveError as error:
|
|
333
|
+
raise UndoError(f"checkpoint discussion records are invalid: {error}") from error
|
|
334
|
+
return current
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _encode_discussion(
|
|
338
|
+
files: Optional[dict[str, bytes]],
|
|
339
|
+
) -> Optional[dict[str, str]]:
|
|
340
|
+
if files is None:
|
|
341
|
+
return None
|
|
342
|
+
return {name: data.decode("utf-8") for name, data in files.items()}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _decode_discussion(value: object) -> Optional[dict[str, bytes]]:
|
|
346
|
+
if value is None:
|
|
347
|
+
return None
|
|
348
|
+
expected = set(archive_milestone.DISCUSSION_FILES)
|
|
349
|
+
if not isinstance(value, dict) or set(value) != expected:
|
|
350
|
+
raise UndoError("undo transaction has invalid discussion records")
|
|
351
|
+
if any(not isinstance(content, str) for content in value.values()):
|
|
352
|
+
raise UndoError("undo transaction discussion records must be text")
|
|
353
|
+
return {str(name): content.encode("utf-8") for name, content in value.items()}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def pending_transaction(repo: Path) -> Optional[dict[str, object]]:
|
|
357
|
+
try:
|
|
358
|
+
return undo_transaction(_repo_root(repo))
|
|
359
|
+
except PipelineStateError as error:
|
|
360
|
+
raise UndoError(str(error)) from error
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _archive_metadata_error(
|
|
364
|
+
repo: Path,
|
|
365
|
+
state_fields: dict[str, object],
|
|
366
|
+
configured: str,
|
|
367
|
+
dirty: Sequence[str],
|
|
368
|
+
) -> Optional[str]:
|
|
369
|
+
archive = _normalize_archive(configured)
|
|
370
|
+
tracked = _run_git(
|
|
371
|
+
repo,
|
|
372
|
+
"ls-tree",
|
|
373
|
+
"-r",
|
|
374
|
+
"--name-only",
|
|
375
|
+
"HEAD",
|
|
376
|
+
"--",
|
|
377
|
+
*(f".project/{name}" for name in archive_milestone.TRANSACTION_DIRECTORIES),
|
|
378
|
+
).stdout.splitlines()
|
|
379
|
+
allowed = {
|
|
380
|
+
".project/STATE.md",
|
|
381
|
+
*tracked,
|
|
382
|
+
*(
|
|
383
|
+
f".project/discuss/{name}"
|
|
384
|
+
for name in archive_milestone.DISCUSSION_FILES
|
|
385
|
+
),
|
|
386
|
+
}
|
|
387
|
+
if state_fields.get("phase") == "shipped":
|
|
388
|
+
allowed.add(".project/ROADMAP.md")
|
|
389
|
+
unowned = sorted(
|
|
390
|
+
path
|
|
391
|
+
for path in dirty
|
|
392
|
+
if path not in allowed
|
|
393
|
+
and path != archive
|
|
394
|
+
and not path.startswith(f"{archive}/")
|
|
395
|
+
)
|
|
396
|
+
if unowned:
|
|
397
|
+
return "archive undo found unowned .project changes: " + ", ".join(unowned)
|
|
398
|
+
|
|
399
|
+
archive_path = repo / archive
|
|
400
|
+
expected_inventory = {path.removeprefix(".project/") for path in tracked}
|
|
401
|
+
try:
|
|
402
|
+
actual_inventory = list(archive_milestone.archive_file_inventory(archive_path))
|
|
403
|
+
final_review = archive_path / "review" / "FINAL.md"
|
|
404
|
+
gap_reviews = sorted((archive_path / "review").glob("final-gap-*.md"))
|
|
405
|
+
if final_review.exists() or gap_reviews:
|
|
406
|
+
reviewed_head, _ = archive_milestone.parse_final_review(archive_path)
|
|
407
|
+
archive_milestone.validate_gap_reviews(archive_path, reviewed_head)
|
|
408
|
+
if reviewed_head != _optional_rev(repo, "HEAD"):
|
|
409
|
+
return "archive undo final review does not match current HEAD"
|
|
410
|
+
expected_inventory.add("review/FINAL.md")
|
|
411
|
+
expected_inventory.update(
|
|
412
|
+
path.relative_to(archive_path).as_posix() for path in gap_reviews
|
|
413
|
+
)
|
|
414
|
+
actual_directories = sorted(
|
|
415
|
+
path.relative_to(archive_path).as_posix()
|
|
416
|
+
for path in archive_path.rglob("*")
|
|
417
|
+
if path.is_dir()
|
|
418
|
+
)
|
|
419
|
+
except (ArchiveError, OSError) as error:
|
|
420
|
+
return f"archive undo cannot prove archive ownership: {error}"
|
|
421
|
+
expected_inventory = sorted(expected_inventory)
|
|
422
|
+
expected_directories = sorted(
|
|
423
|
+
{
|
|
424
|
+
parent.as_posix()
|
|
425
|
+
for relative in expected_inventory
|
|
426
|
+
for parent in PurePosixPath(relative).parents
|
|
427
|
+
if parent != PurePosixPath(".")
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
if (
|
|
431
|
+
actual_inventory != expected_inventory
|
|
432
|
+
or actual_directories != expected_directories
|
|
433
|
+
):
|
|
434
|
+
missing = sorted(set(expected_inventory) - set(actual_inventory))
|
|
435
|
+
extra = sorted(set(actual_inventory) - set(expected_inventory))
|
|
436
|
+
extra_directories = sorted(
|
|
437
|
+
set(actual_directories) - set(expected_directories)
|
|
438
|
+
)
|
|
439
|
+
return (
|
|
440
|
+
"archive undo inventory differs from helper-owned files: "
|
|
441
|
+
f"missing={missing}, extra={extra}, extra_directories={extra_directories}"
|
|
442
|
+
)
|
|
443
|
+
manifest_paths = sorted(
|
|
444
|
+
path.relative_to(archive_path).as_posix()
|
|
445
|
+
for path in archive_path.rglob("MANIFEST.md")
|
|
446
|
+
)
|
|
447
|
+
if manifest_paths not in ([], ["MANIFEST.md"]):
|
|
448
|
+
return "archive undo found unowned MANIFEST.md paths: " + ", ".join(
|
|
449
|
+
manifest_paths
|
|
450
|
+
)
|
|
451
|
+
manifest = archive_path / "MANIFEST.md"
|
|
452
|
+
if manifest.exists() or manifest.is_symlink():
|
|
453
|
+
try:
|
|
454
|
+
archive_milestone.validate_manifest(repo, archive_path, load_state(repo)[0])
|
|
455
|
+
except (ArchiveError, OSError) as error:
|
|
456
|
+
return f"archive undo cannot prove MANIFEST.md ownership: {error}"
|
|
457
|
+
|
|
458
|
+
head_state = _git_text(repo, "HEAD", ".project/STATE.md")
|
|
459
|
+
prepared_state = archive_milestone.set_frontmatter_value(
|
|
460
|
+
head_state,
|
|
461
|
+
"archive",
|
|
462
|
+
configured,
|
|
463
|
+
)
|
|
464
|
+
current_state = (repo / ".project" / "STATE.md").read_text(encoding="utf-8")
|
|
465
|
+
phase = state_fields.get("phase")
|
|
466
|
+
status = state_fields.get("status")
|
|
467
|
+
if (phase, status) == ("ship", "active"):
|
|
468
|
+
if current_state != prepared_state:
|
|
469
|
+
return "archive undo found unowned STATE.md changes"
|
|
470
|
+
if ".project/ROADMAP.md" in dirty:
|
|
471
|
+
return "archive undo found unowned ROADMAP.md changes"
|
|
472
|
+
return None
|
|
473
|
+
if (phase, status) != ("shipped", "done"):
|
|
474
|
+
return "archive undo requires ship/active or shipped/done"
|
|
475
|
+
|
|
476
|
+
prepared = _state_from_text(prepared_state, "prepared archive STATE.md")
|
|
477
|
+
events = (
|
|
478
|
+
"archive preflight passed; shipment recorded",
|
|
479
|
+
"archive preflight passed; shipment recorded; program complete",
|
|
480
|
+
)
|
|
481
|
+
rendered_states = []
|
|
482
|
+
for event in events:
|
|
483
|
+
try:
|
|
484
|
+
_, _, rendered = _render_transition(
|
|
485
|
+
prepared,
|
|
486
|
+
prepared_state,
|
|
487
|
+
{
|
|
488
|
+
"phase": "ship",
|
|
489
|
+
"status": "active",
|
|
490
|
+
"branch": prepared.branch,
|
|
491
|
+
"archive": configured,
|
|
492
|
+
},
|
|
493
|
+
{"phase": "shipped", "status": "done"},
|
|
494
|
+
event,
|
|
495
|
+
".project",
|
|
496
|
+
)
|
|
497
|
+
except PipelineStateError:
|
|
498
|
+
continue
|
|
499
|
+
rendered_states.append(rendered)
|
|
500
|
+
if current_state not in rendered_states:
|
|
501
|
+
return "archive undo found unowned shipment STATE.md changes"
|
|
502
|
+
if ".project/ROADMAP.md" in dirty:
|
|
503
|
+
try:
|
|
504
|
+
head_roadmap = _git_text(repo, "HEAD", ".project/ROADMAP.md")
|
|
505
|
+
expected_roadmap = _shipment_roadmap(head_roadmap, prepared, configured)
|
|
506
|
+
except (PipelineStateError, UndoError):
|
|
507
|
+
return "archive undo cannot prove ROADMAP.md shipment ownership"
|
|
508
|
+
current_roadmap = (repo / ".project" / "ROADMAP.md").read_text(
|
|
509
|
+
encoding="utf-8"
|
|
510
|
+
)
|
|
511
|
+
if current_roadmap != expected_roadmap:
|
|
512
|
+
return "archive undo found unowned ROADMAP.md changes"
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _prepare_transaction(
|
|
517
|
+
repo: Path,
|
|
518
|
+
kind: str,
|
|
519
|
+
expected_head: str,
|
|
520
|
+
parent: str,
|
|
521
|
+
branch: str,
|
|
522
|
+
archive: Optional[str],
|
|
523
|
+
discussion: Optional[dict[str, bytes]],
|
|
524
|
+
) -> dict[str, object]:
|
|
525
|
+
transaction = {
|
|
526
|
+
"schema": UNDO_TRANSACTION_SCHEMA,
|
|
527
|
+
"repo": str(repo),
|
|
528
|
+
"kind": kind,
|
|
529
|
+
"expected_head": expected_head,
|
|
530
|
+
"parent": parent,
|
|
531
|
+
"branch": branch,
|
|
532
|
+
"worktree_fingerprint": _worktree_fingerprint(repo),
|
|
533
|
+
"archive": archive,
|
|
534
|
+
"archive_fingerprint": (
|
|
535
|
+
_tree_fingerprint(repo / _normalize_archive(archive))
|
|
536
|
+
if archive is not None
|
|
537
|
+
else None
|
|
538
|
+
),
|
|
539
|
+
"discussion": _encode_discussion(discussion),
|
|
540
|
+
}
|
|
541
|
+
_write_json(_git_path(repo, UNDO_TRANSACTION_NAME), transaction)
|
|
542
|
+
return transaction
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _tree_fingerprint(root: Path) -> str:
|
|
546
|
+
if root.is_symlink() or not root.is_dir():
|
|
547
|
+
raise UndoError(f"undo-owned tree must be a real directory: {root}")
|
|
548
|
+
digest = hashlib.sha256()
|
|
549
|
+
for path in sorted(root.rglob("*")):
|
|
550
|
+
relative = path.relative_to(root).as_posix()
|
|
551
|
+
if path.is_symlink():
|
|
552
|
+
raise UndoError(f"undo-owned tree contains a symlink: {path}")
|
|
553
|
+
if path.is_dir():
|
|
554
|
+
digest.update(f"d\0{relative}\0".encode())
|
|
555
|
+
elif path.is_file():
|
|
556
|
+
digest.update(f"f\0{relative}\0{path.stat().st_mode & 0o777}\0".encode())
|
|
557
|
+
digest.update(path.read_bytes())
|
|
558
|
+
else:
|
|
559
|
+
raise UndoError(f"undo-owned tree contains a special file: {path}")
|
|
560
|
+
return digest.hexdigest()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _worktree_fingerprint(repo: Path) -> str:
|
|
564
|
+
digest = hashlib.sha256()
|
|
565
|
+
for arguments in (("diff", "--binary"), ("diff", "--cached", "--binary")):
|
|
566
|
+
output = _run_git(repo, *arguments).stdout
|
|
567
|
+
digest.update("\0".join(arguments).encode())
|
|
568
|
+
digest.update(output.encode())
|
|
569
|
+
untracked = sorted(
|
|
570
|
+
path
|
|
571
|
+
for path in _run_git(
|
|
572
|
+
repo,
|
|
573
|
+
"ls-files",
|
|
574
|
+
"--others",
|
|
575
|
+
"--exclude-standard",
|
|
576
|
+
"-z",
|
|
577
|
+
).stdout.split("\0")
|
|
578
|
+
if path
|
|
579
|
+
)
|
|
580
|
+
for relative in untracked:
|
|
581
|
+
path = repo / relative
|
|
582
|
+
digest.update(relative.encode())
|
|
583
|
+
if path.is_symlink():
|
|
584
|
+
digest.update(b"l\0")
|
|
585
|
+
digest.update(os.readlink(path).encode())
|
|
586
|
+
elif path.is_file():
|
|
587
|
+
digest.update(f"f\0{path.stat().st_mode & 0o777}\0".encode())
|
|
588
|
+
digest.update(path.read_bytes())
|
|
589
|
+
else:
|
|
590
|
+
digest.update(b"special\0")
|
|
591
|
+
return digest.hexdigest()
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _post_reset_recovery_error(
|
|
595
|
+
repo: Path,
|
|
596
|
+
transaction: dict[str, object],
|
|
597
|
+
) -> Optional[str]:
|
|
598
|
+
dirty = set(_worktree_changes(repo))
|
|
599
|
+
allowed = set()
|
|
600
|
+
discussion = transaction.get("discussion")
|
|
601
|
+
if isinstance(discussion, dict):
|
|
602
|
+
for name, content in discussion.items():
|
|
603
|
+
relative = f".project/discuss/{name}"
|
|
604
|
+
allowed.add(relative)
|
|
605
|
+
path = repo / relative
|
|
606
|
+
if relative in dirty and (
|
|
607
|
+
not path.is_file()
|
|
608
|
+
or path.is_symlink()
|
|
609
|
+
or path.read_text(encoding="utf-8") != content
|
|
610
|
+
):
|
|
611
|
+
return f"undo recovery discussion drifted: {relative}"
|
|
612
|
+
archive = transaction.get("archive")
|
|
613
|
+
if isinstance(archive, str):
|
|
614
|
+
archive_path = repo / _normalize_archive(archive)
|
|
615
|
+
if archive_path.exists() or archive_path.is_symlink():
|
|
616
|
+
expected = transaction.get("archive_fingerprint")
|
|
617
|
+
if _tree_fingerprint(archive_path) != expected:
|
|
618
|
+
return "undo recovery archive inventory drifted"
|
|
619
|
+
allowed.update(
|
|
620
|
+
path.relative_to(repo).as_posix()
|
|
621
|
+
for path in archive_path.rglob("*")
|
|
622
|
+
if path.is_file()
|
|
623
|
+
)
|
|
624
|
+
unexpected = sorted(dirty - allowed)
|
|
625
|
+
if unexpected:
|
|
626
|
+
return "undo recovery found unowned worktree changes: " + ", ".join(unexpected)
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _transaction_recovery_stage(
|
|
631
|
+
repo: Path,
|
|
632
|
+
transaction: dict[str, object],
|
|
633
|
+
) -> str:
|
|
634
|
+
expected_head = str(transaction["expected_head"])
|
|
635
|
+
parent = str(transaction["parent"])
|
|
636
|
+
current = _optional_rev(repo, "HEAD")
|
|
637
|
+
if transaction["kind"] == "checkpoint":
|
|
638
|
+
allowed = {expected_head, parent}
|
|
639
|
+
else:
|
|
640
|
+
allowed = {expected_head}
|
|
641
|
+
if current not in allowed:
|
|
642
|
+
raise UndoError("HEAD moved during undo recovery")
|
|
643
|
+
current_branch = status_state(repo)["git"].get("branch")
|
|
644
|
+
if current_branch != transaction["branch"]:
|
|
645
|
+
raise UndoError(
|
|
646
|
+
f"undo recovery is on {current_branch or '<detached>'}, expected {transaction['branch']}"
|
|
647
|
+
)
|
|
648
|
+
if current == expected_head and _worktree_fingerprint(repo) == transaction.get(
|
|
649
|
+
"worktree_fingerprint"
|
|
650
|
+
):
|
|
651
|
+
return "prepared"
|
|
652
|
+
recovery_error = _post_reset_recovery_error(repo, transaction)
|
|
653
|
+
if recovery_error:
|
|
654
|
+
raise UndoError(recovery_error)
|
|
655
|
+
return "reset"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _finish_transaction(repo: Path, transaction: dict[str, object]) -> None:
|
|
659
|
+
stage = _transaction_recovery_stage(repo, transaction)
|
|
660
|
+
if stage == "prepared":
|
|
661
|
+
_require_unpublished_reset(repo, str(transaction["branch"]))
|
|
662
|
+
_reset_to(repo, str(transaction["parent"]))
|
|
663
|
+
discussion = _decode_discussion(transaction.get("discussion"))
|
|
664
|
+
if discussion is not None:
|
|
665
|
+
_restore_discussion(repo, discussion)
|
|
666
|
+
archive = transaction.get("archive")
|
|
667
|
+
if isinstance(archive, str) and not _archive_in_head(repo, archive):
|
|
668
|
+
_remove_untracked_tree(repo, archive)
|
|
669
|
+
path = _git_path(repo, UNDO_TRANSACTION_NAME)
|
|
670
|
+
path.unlink()
|
|
671
|
+
descriptor = os.open(path.parent, os.O_RDONLY)
|
|
672
|
+
try:
|
|
673
|
+
os.fsync(descriptor)
|
|
674
|
+
finally:
|
|
675
|
+
os.close(descriptor)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def classify_undo(repo: Path) -> dict[str, object]:
|
|
679
|
+
resolved = _repo_root(repo)
|
|
680
|
+
status = status_state(resolved)
|
|
681
|
+
state_fields = status["state"]
|
|
682
|
+
git = status["git"]
|
|
683
|
+
head = git.get("head")
|
|
684
|
+
branch = git.get("branch")
|
|
685
|
+
dirty = list(git.get("dirty") or [])
|
|
686
|
+
subject = str(git.get("subject") or "")
|
|
687
|
+
journals = _active_journals(status["journals"])
|
|
688
|
+
recorded_branch = state_fields.get("branch")
|
|
689
|
+
archive = state_fields.get("archive")
|
|
690
|
+
|
|
691
|
+
if not head:
|
|
692
|
+
return _blocked(["HEAD has no commit"])
|
|
693
|
+
if recorded_branch is None or not is_bound_branch(str(recorded_branch)):
|
|
694
|
+
return _blocked(["undo requires STATE.branch to be a bound gsd-path/M00N branch"])
|
|
695
|
+
if branch != recorded_branch:
|
|
696
|
+
return _blocked(
|
|
697
|
+
[f"current branch {branch or '<detached>'} != STATE.branch {recorded_branch}"]
|
|
698
|
+
)
|
|
699
|
+
if journals:
|
|
700
|
+
names = ", ".join(sorted(journals))
|
|
701
|
+
return _blocked(
|
|
702
|
+
[f"an in-progress helper journal exists ({names}); resume that transaction first"]
|
|
703
|
+
)
|
|
704
|
+
if git.get("ancestor_of_origin_main"):
|
|
705
|
+
return _blocked(["HEAD is an ancestor of origin/main; undo would rewrite shipped history"])
|
|
706
|
+
|
|
707
|
+
product = _product_dirty(dirty)
|
|
708
|
+
if product:
|
|
709
|
+
return _blocked(
|
|
710
|
+
["worktree has changes outside .project/: " + ", ".join(product)]
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
if archive and state_fields.get("phase") in {"ship", "shipped"}:
|
|
714
|
+
archive_path = _normalize_archive(str(archive))
|
|
715
|
+
if not _archive_in_head(resolved, archive_path):
|
|
716
|
+
try:
|
|
717
|
+
_discussion_extension(resolved, archive_path)
|
|
718
|
+
except UndoError as error:
|
|
719
|
+
return _blocked([str(error)])
|
|
720
|
+
metadata_error = _archive_metadata_error(
|
|
721
|
+
resolved,
|
|
722
|
+
state_fields,
|
|
723
|
+
str(archive),
|
|
724
|
+
dirty,
|
|
725
|
+
)
|
|
726
|
+
if metadata_error:
|
|
727
|
+
return _blocked([metadata_error])
|
|
728
|
+
return {
|
|
729
|
+
"kind": "uncommitted-archive",
|
|
730
|
+
"head": head,
|
|
731
|
+
"parent": head,
|
|
732
|
+
"subject": subject,
|
|
733
|
+
"archive": archive_path,
|
|
734
|
+
"branch": recorded_branch,
|
|
735
|
+
"effects": [
|
|
736
|
+
"restore tracked .project/ files to HEAD",
|
|
737
|
+
f"delete untracked {archive_path}",
|
|
738
|
+
],
|
|
739
|
+
"blocked": [],
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
next_dir = resolved / ".project" / "next"
|
|
743
|
+
next_tracked = _run_git(
|
|
744
|
+
resolved,
|
|
745
|
+
"ls-files",
|
|
746
|
+
"--",
|
|
747
|
+
".project/next",
|
|
748
|
+
check=False,
|
|
749
|
+
).stdout.splitlines()
|
|
750
|
+
if (next_dir.exists() or next_dir.is_symlink()) and not any(
|
|
751
|
+
line.strip() for line in next_tracked
|
|
752
|
+
):
|
|
753
|
+
if next_dir.is_symlink() or not next_dir.is_dir():
|
|
754
|
+
return _blocked(["lookahead discard requires a real .project/next directory"])
|
|
755
|
+
try:
|
|
756
|
+
next_state, _, _ = load_state(resolved, ".project/next")
|
|
757
|
+
except (PipelineStateError, OSError) as error:
|
|
758
|
+
return _blocked([f"lookahead ownership is unproven: {error}"])
|
|
759
|
+
if next_state.project != state_fields.get("project"):
|
|
760
|
+
return _blocked(["lookahead ownership is unproven: project differs"])
|
|
761
|
+
extra = [
|
|
762
|
+
path
|
|
763
|
+
for path in dirty
|
|
764
|
+
if path != ".project/next" and not path.startswith(".project/next/")
|
|
765
|
+
]
|
|
766
|
+
if extra:
|
|
767
|
+
return _blocked(
|
|
768
|
+
["lookahead discard requires a clean worktree except .project/next/"]
|
|
769
|
+
)
|
|
770
|
+
return {
|
|
771
|
+
"kind": "lookahead",
|
|
772
|
+
"head": head,
|
|
773
|
+
"parent": head,
|
|
774
|
+
"subject": subject,
|
|
775
|
+
"branch": recorded_branch,
|
|
776
|
+
"effects": ["delete untracked .project/next/"],
|
|
777
|
+
"blocked": [],
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if dirty:
|
|
781
|
+
return _blocked(["worktree is dirty; restore or commit before undoing HEAD"])
|
|
782
|
+
|
|
783
|
+
_, body = _commit_subject_body(resolved, head)
|
|
784
|
+
parent = _parent_sha(resolved, head)
|
|
785
|
+
if parent is None:
|
|
786
|
+
return _blocked(["HEAD has no parent; undo refuses to delete the only commit"])
|
|
787
|
+
if git.get("published"):
|
|
788
|
+
return _blocked(
|
|
789
|
+
[
|
|
790
|
+
f"HEAD is published on origin/{recorded_branch}; undo never force-pushes"
|
|
791
|
+
]
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
if subject in {PLAN_APPROVAL_SUBJECT, ROADMAP_APPROVAL_SUBJECT}:
|
|
795
|
+
kind = "plan" if subject == PLAN_APPROVAL_SUBJECT else "roadmap"
|
|
796
|
+
proof_error = _checkpoint_proof_error(resolved, head, parent, subject)
|
|
797
|
+
if proof_error:
|
|
798
|
+
return _blocked([f"checkpoint ownership is unproven: {proof_error}"])
|
|
799
|
+
return {
|
|
800
|
+
"kind": "checkpoint",
|
|
801
|
+
"checkpoint": kind,
|
|
802
|
+
"branch": recorded_branch,
|
|
803
|
+
"head": head,
|
|
804
|
+
"parent": parent,
|
|
805
|
+
"subject": subject,
|
|
806
|
+
"effects": [
|
|
807
|
+
f"reset {recorded_branch} to {parent}",
|
|
808
|
+
f"STATE returns to {kind}/active",
|
|
809
|
+
],
|
|
810
|
+
"blocked": [],
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if TASK_SUBJECT_RE.fullmatch(subject) and body.lstrip().startswith("Task:"):
|
|
814
|
+
proof_error = _task_proof_error(resolved, head)
|
|
815
|
+
if proof_error:
|
|
816
|
+
return _blocked([f"task landing ownership is unproven: {proof_error}"])
|
|
817
|
+
return {
|
|
818
|
+
"kind": "task",
|
|
819
|
+
"branch": recorded_branch,
|
|
820
|
+
"head": head,
|
|
821
|
+
"parent": parent,
|
|
822
|
+
"subject": subject,
|
|
823
|
+
"effects": [
|
|
824
|
+
f"reset {recorded_branch} to {parent}",
|
|
825
|
+
"the last task landing commit is dropped",
|
|
826
|
+
],
|
|
827
|
+
"blocked": [],
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
slug = state_fields.get("milestone") or ""
|
|
831
|
+
archive_name = ""
|
|
832
|
+
if archive:
|
|
833
|
+
archive_name = Path(_normalize_archive(str(archive))).name
|
|
834
|
+
if archive_name and is_ship_subject(subject, archive_name):
|
|
835
|
+
return _blocked(
|
|
836
|
+
["HEAD is a ship commit; finish integration with $gsd-path-ship"]
|
|
837
|
+
)
|
|
838
|
+
if subject.startswith(PROMOTION_PREFIX):
|
|
839
|
+
return _blocked(["HEAD is a lookahead promotion commit; undo refuses it"])
|
|
840
|
+
if subject.startswith(ABANDON_PREFIX):
|
|
841
|
+
return _blocked(["HEAD is a milestone-abandon commit; abandoned entries are immutable"])
|
|
842
|
+
if subject.startswith(INTEGRATE_PREFIX):
|
|
843
|
+
return _blocked(["HEAD is an integration merge; undo never rewrites main"])
|
|
844
|
+
return _blocked([f"HEAD is not an undoable pipeline commit: {subject or '<empty>'}"])
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def preview(repo: Path) -> dict[str, object]:
|
|
848
|
+
resolved = _repo_root(repo)
|
|
849
|
+
transaction = pending_transaction(resolved)
|
|
850
|
+
if transaction is None:
|
|
851
|
+
target = classify_undo(resolved)
|
|
852
|
+
else:
|
|
853
|
+
try:
|
|
854
|
+
stage = _transaction_recovery_stage(resolved, transaction)
|
|
855
|
+
if stage == "prepared":
|
|
856
|
+
_require_unpublished_reset(resolved, str(transaction["branch"]))
|
|
857
|
+
except UndoError as error:
|
|
858
|
+
target = _blocked([str(error)])
|
|
859
|
+
else:
|
|
860
|
+
target = {
|
|
861
|
+
"kind": transaction["kind"],
|
|
862
|
+
"head": transaction["expected_head"],
|
|
863
|
+
"parent": transaction["parent"],
|
|
864
|
+
"subject": "interrupted undo transaction",
|
|
865
|
+
"branch": transaction["branch"],
|
|
866
|
+
"archive": transaction["archive"],
|
|
867
|
+
"effects": ["resume the validated interrupted undo transaction"],
|
|
868
|
+
"blocked": [],
|
|
869
|
+
}
|
|
870
|
+
state, _, path = load_state(resolved)
|
|
871
|
+
return {
|
|
872
|
+
"schema": UNDO_SCHEMA,
|
|
873
|
+
"status": "preview",
|
|
874
|
+
"path": str(path),
|
|
875
|
+
"state": state.json(),
|
|
876
|
+
"target": target,
|
|
877
|
+
"apply": None
|
|
878
|
+
if target.get("kind") is None
|
|
879
|
+
else {
|
|
880
|
+
"command": "apply",
|
|
881
|
+
"kind": target["kind"],
|
|
882
|
+
"expected_head": target["head"],
|
|
883
|
+
},
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _reset_to(repo: Path, revision: str) -> None:
|
|
888
|
+
result = _run_git(repo, "reset", "--hard", revision, check=False)
|
|
889
|
+
if result.returncode != 0:
|
|
890
|
+
detail = result.stderr.strip() or result.stdout.strip() or "git reset failed"
|
|
891
|
+
raise UndoError(f"git reset --hard {revision} failed: {detail}")
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _require_unpublished_reset(repo: Path, expected_branch: str) -> None:
|
|
895
|
+
git = status_state(repo)["git"]
|
|
896
|
+
if git.get("branch") != expected_branch:
|
|
897
|
+
raise UndoError(
|
|
898
|
+
f"reset is on {git.get('branch') or '<detached>'}, expected {expected_branch}"
|
|
899
|
+
)
|
|
900
|
+
if git.get("published"):
|
|
901
|
+
raise UndoError("HEAD became published on its recorded remote branch")
|
|
902
|
+
if git.get("ancestor_of_origin_main"):
|
|
903
|
+
raise UndoError("HEAD became published through origin/main")
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _remove_untracked_tree(repo: Path, relative: str) -> None:
|
|
907
|
+
normalized = PurePosixPath(relative)
|
|
908
|
+
if normalized.is_absolute() or ".." in normalized.parts or not normalized.parts:
|
|
909
|
+
raise UndoError(f"refusing to delete an invalid path: {relative}")
|
|
910
|
+
current = repo
|
|
911
|
+
for part in normalized.parts[:-1]:
|
|
912
|
+
current /= part
|
|
913
|
+
if current.is_symlink():
|
|
914
|
+
raise UndoError(f"refusing to delete through a symlink parent: {relative}")
|
|
915
|
+
path = repo.joinpath(*normalized.parts)
|
|
916
|
+
if path.is_symlink():
|
|
917
|
+
path.unlink()
|
|
918
|
+
return
|
|
919
|
+
if path.is_dir():
|
|
920
|
+
shutil.rmtree(path)
|
|
921
|
+
return
|
|
922
|
+
if path.exists():
|
|
923
|
+
path.unlink()
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def apply_undo(repo: Path, kind: str, expected_head: str) -> dict[str, object]:
|
|
927
|
+
if kind not in KINDS:
|
|
928
|
+
raise UndoError(f"unsupported undo kind: {kind}")
|
|
929
|
+
resolved = _repo_root(repo)
|
|
930
|
+
transaction = pending_transaction(resolved)
|
|
931
|
+
if transaction is not None:
|
|
932
|
+
if transaction["kind"] != kind or transaction["expected_head"] != expected_head:
|
|
933
|
+
raise UndoError("undo transaction differs from this apply request")
|
|
934
|
+
_finish_transaction(resolved, transaction)
|
|
935
|
+
after = preview(resolved)
|
|
936
|
+
return {
|
|
937
|
+
"schema": UNDO_SCHEMA,
|
|
938
|
+
"status": "applied",
|
|
939
|
+
"kind": kind,
|
|
940
|
+
"head_before": expected_head,
|
|
941
|
+
"head_after": _optional_rev(resolved, "HEAD"),
|
|
942
|
+
"state": after["state"],
|
|
943
|
+
"remaining": after["target"],
|
|
944
|
+
}
|
|
945
|
+
previewed = preview(resolved)
|
|
946
|
+
target = previewed["target"]
|
|
947
|
+
if target.get("kind") is None:
|
|
948
|
+
raise UndoError("; ".join(target.get("blocked") or ["undo is blocked"]))
|
|
949
|
+
if target["kind"] != kind:
|
|
950
|
+
raise UndoError(
|
|
951
|
+
f"preview kind is {target['kind']}, not {kind}; rerun preview"
|
|
952
|
+
)
|
|
953
|
+
if target["head"] != expected_head:
|
|
954
|
+
raise UndoError("expected-head does not match current HEAD from preview")
|
|
955
|
+
head = _optional_rev(resolved, "HEAD")
|
|
956
|
+
if head != expected_head:
|
|
957
|
+
raise UndoError("HEAD moved after preview; rerun preview")
|
|
958
|
+
|
|
959
|
+
if kind == "checkpoint":
|
|
960
|
+
parent = target["parent"]
|
|
961
|
+
if not parent:
|
|
962
|
+
raise UndoError("parent revision is not a safe reset target")
|
|
963
|
+
discussion = _checkpoint_discussion(resolved, expected_head, str(parent))
|
|
964
|
+
transaction = _prepare_transaction(
|
|
965
|
+
resolved,
|
|
966
|
+
kind,
|
|
967
|
+
expected_head,
|
|
968
|
+
str(parent),
|
|
969
|
+
str(target["branch"]),
|
|
970
|
+
None,
|
|
971
|
+
discussion,
|
|
972
|
+
)
|
|
973
|
+
_finish_transaction(resolved, transaction)
|
|
974
|
+
elif kind == "task":
|
|
975
|
+
parent = target["parent"]
|
|
976
|
+
if not parent:
|
|
977
|
+
raise UndoError("parent revision is not a safe reset target")
|
|
978
|
+
if _worktree_changes(resolved):
|
|
979
|
+
raise UndoError("worktree changed after task undo preview")
|
|
980
|
+
_require_unpublished_reset(resolved, str(target["branch"]))
|
|
981
|
+
_reset_to(resolved, str(parent))
|
|
982
|
+
elif kind == "uncommitted-archive":
|
|
983
|
+
archive = str(target["archive"])
|
|
984
|
+
discussion = _discussion_extension(resolved, archive)
|
|
985
|
+
transaction = _prepare_transaction(
|
|
986
|
+
resolved,
|
|
987
|
+
kind,
|
|
988
|
+
expected_head,
|
|
989
|
+
expected_head,
|
|
990
|
+
str(target["branch"]),
|
|
991
|
+
archive,
|
|
992
|
+
discussion,
|
|
993
|
+
)
|
|
994
|
+
_finish_transaction(resolved, transaction)
|
|
995
|
+
else:
|
|
996
|
+
_remove_untracked_tree(resolved, ".project/next")
|
|
997
|
+
|
|
998
|
+
after = preview(resolved)
|
|
999
|
+
return {
|
|
1000
|
+
"schema": UNDO_SCHEMA,
|
|
1001
|
+
"status": "applied",
|
|
1002
|
+
"kind": kind,
|
|
1003
|
+
"head_before": expected_head,
|
|
1004
|
+
"head_after": after["target"].get("head") or _optional_rev(resolved, "HEAD"),
|
|
1005
|
+
"state": after["state"],
|
|
1006
|
+
"remaining": after["target"],
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
1011
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1012
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1013
|
+
preview_parser = subparsers.add_parser("preview")
|
|
1014
|
+
preview_parser.add_argument("--repo", required=True, type=Path)
|
|
1015
|
+
apply_parser = subparsers.add_parser("apply")
|
|
1016
|
+
apply_parser.add_argument("--repo", required=True, type=Path)
|
|
1017
|
+
apply_parser.add_argument("--kind", required=True, choices=KINDS)
|
|
1018
|
+
apply_parser.add_argument("--expected-head", required=True)
|
|
1019
|
+
return parser.parse_args(argv)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
1023
|
+
args = parse_args(argv)
|
|
1024
|
+
try:
|
|
1025
|
+
if args.command == "preview":
|
|
1026
|
+
result = preview(args.repo)
|
|
1027
|
+
elif args.command == "apply":
|
|
1028
|
+
result = apply_undo(args.repo, args.kind, args.expected_head)
|
|
1029
|
+
else: # pragma: no cover - argparse rejects unknown commands
|
|
1030
|
+
raise UndoError(f"unknown command: {args.command}")
|
|
1031
|
+
except (UndoError, PipelineStateError) as error:
|
|
1032
|
+
print(f"error: {error}", file=sys.stderr)
|
|
1033
|
+
return 1
|
|
1034
|
+
print(json.dumps(result, sort_keys=True))
|
|
1035
|
+
return 0
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
if __name__ == "__main__":
|
|
1039
|
+
raise SystemExit(main())
|