@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,3538 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Named checkouts for task isolation, verify sidecars, and task landing.
|
|
4
|
+
|
|
5
|
+
The orchestrator calls this helper instead of inventing git worktree, commit,
|
|
6
|
+
or cherry-pick commands. Every checkout this module creates is a named branch;
|
|
7
|
+
it never detaches HEAD.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
|
+
from typing import Dict, Optional, Sequence, Set
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from pipeline_git import (
|
|
27
|
+
attest_commit_body,
|
|
28
|
+
attest_commit_subject,
|
|
29
|
+
is_bound_branch,
|
|
30
|
+
ship_commit_body,
|
|
31
|
+
ship_subject,
|
|
32
|
+
task_commit_body,
|
|
33
|
+
task_commit_subject,
|
|
34
|
+
)
|
|
35
|
+
import _common
|
|
36
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
37
|
+
from scripts.pipeline_git import (
|
|
38
|
+
attest_commit_body,
|
|
39
|
+
attest_commit_subject,
|
|
40
|
+
is_bound_branch,
|
|
41
|
+
ship_commit_body,
|
|
42
|
+
ship_subject,
|
|
43
|
+
task_commit_body,
|
|
44
|
+
task_commit_subject,
|
|
45
|
+
)
|
|
46
|
+
from scripts import _common
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
TASK_BRANCH_PREFIX = "gsd-path-task/"
|
|
50
|
+
TASK_AUTHORIZATION_PREFIX = "refs/gsd-path/task-authorizations/"
|
|
51
|
+
VERIFY_BRANCH_PREFIX = "gsd-path-verify/"
|
|
52
|
+
DISCUSSION_PATHS = frozenset(
|
|
53
|
+
{".project/discuss/DIALOGUE.md", ".project/discuss/ANSWERS.md"}
|
|
54
|
+
)
|
|
55
|
+
VERIFY_LEDGER_PATH = _common.VERIFY_LEDGER_PATH
|
|
56
|
+
# recover verdicts that count as a landed task
|
|
57
|
+
LANDED_VERDICTS = frozenset({"recovered", "attested"})
|
|
58
|
+
# Orchestrator bookkeeping that may stay uncommitted in the primary while a
|
|
59
|
+
# parallel task lands; the next `.project` checkpoint commits it.
|
|
60
|
+
BOOKKEEPING_PATHS = DISCUSSION_PATHS | {VERIFY_LEDGER_PATH}
|
|
61
|
+
# Bookkeeping path prefixes: pipeline records plus the guard's own install
|
|
62
|
+
# artifacts. git_guard and the post-adoption proof share this one definition.
|
|
63
|
+
BOOKKEEPING_PREFIXES = (
|
|
64
|
+
".project/",
|
|
65
|
+
".gsd-path/",
|
|
66
|
+
".claude/settings.json",
|
|
67
|
+
".codex/hooks.json",
|
|
68
|
+
".cursor/hooks.json",
|
|
69
|
+
)
|
|
70
|
+
TASK_ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
|
|
71
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
72
|
+
FIELD_PATTERN = _common.FIELD_PATTERN
|
|
73
|
+
INLINE_LIST_PATTERN = _common.INLINE_LIST_PATTERN
|
|
74
|
+
LIST_ITEM_PATTERN = _common.LIST_ITEM_PATTERN
|
|
75
|
+
COLLECT_JOURNAL_SCHEMA = "gsd-path/collect-artifact/v1"
|
|
76
|
+
NULL_SHA = "0" * 40
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class IsolationError(RuntimeError):
|
|
80
|
+
"""Raised when isolation, landing, or retirement cannot proceed."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
run_git = _common.run_git
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def git_output(repo: Path, *arguments: str) -> str:
|
|
87
|
+
result = run_git(repo, *arguments)
|
|
88
|
+
if result.returncode != 0:
|
|
89
|
+
detail = (result.stderr or result.stdout).strip() or "git command failed"
|
|
90
|
+
raise IsolationError(detail)
|
|
91
|
+
return result.stdout.strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def require_directory(path: Path, label: str) -> Path:
|
|
95
|
+
resolved = path.resolve()
|
|
96
|
+
if not resolved.is_dir():
|
|
97
|
+
raise IsolationError(f"{label} is not a directory: {resolved}")
|
|
98
|
+
return resolved
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def require_attached(repo: Path) -> str:
|
|
102
|
+
branch = git_output(repo, "branch", "--show-current")
|
|
103
|
+
if not branch:
|
|
104
|
+
raise IsolationError(f"HEAD is detached in {repo}; expected a named branch")
|
|
105
|
+
return branch
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def require_bound(repo: Path) -> str:
|
|
109
|
+
branch = require_attached(repo)
|
|
110
|
+
if not is_bound_branch(branch):
|
|
111
|
+
raise IsolationError(
|
|
112
|
+
f"primary branch must be canonical gsd-path/M00N, found {branch}"
|
|
113
|
+
)
|
|
114
|
+
return branch
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def require_commit(repo: Path, value: str) -> str:
|
|
118
|
+
result = run_git(repo, "rev-parse", "--verify", "--quiet", f"{value}^{{commit}}")
|
|
119
|
+
if result.returncode != 0:
|
|
120
|
+
raise IsolationError(f"does not resolve to a commit: {value}")
|
|
121
|
+
return result.stdout.strip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def require_full_sha(value: str) -> str:
|
|
125
|
+
if not re.fullmatch(r"[0-9a-f]{40}", value):
|
|
126
|
+
raise IsolationError(f"base must be a full 40-character SHA: {value}")
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def current_sha(repo: Path) -> str:
|
|
131
|
+
return git_output(repo, "rev-parse", "HEAD")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def worktree_root(repo: Path) -> Path:
|
|
135
|
+
return Path(git_output(repo, "rev-parse", "--show-toplevel")).resolve()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def common_git_dir(repo: Path) -> Path:
|
|
139
|
+
raw = Path(git_output(repo, "rev-parse", "--git-common-dir"))
|
|
140
|
+
if raw.is_absolute():
|
|
141
|
+
return raw.resolve()
|
|
142
|
+
return (repo / raw).resolve()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def sidecar_root(primary: Path, kind: str, name: str) -> Path:
|
|
146
|
+
return primary.parent / f"{primary.name}.gsd-path" / kind / name
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_task_id(task_id: str) -> str:
|
|
150
|
+
if not TASK_ID_RE.fullmatch(task_id):
|
|
151
|
+
raise IsolationError(f"invalid task id: {task_id}")
|
|
152
|
+
return task_id
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_name(name: str, label: str) -> str:
|
|
156
|
+
if not NAME_RE.fullmatch(name):
|
|
157
|
+
raise IsolationError(f"invalid {label}: {name}")
|
|
158
|
+
return name
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def task_branch_name(task_id: str) -> str:
|
|
162
|
+
return f"{TASK_BRANCH_PREFIX}{validate_task_id(task_id)}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def task_authorization_ref(task_id: str) -> str:
|
|
166
|
+
return f"{TASK_AUTHORIZATION_PREFIX}{validate_task_id(task_id)}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _delete_task_authorization(primary: Path, branch: str) -> None:
|
|
170
|
+
if branch.startswith(TASK_BRANCH_PREFIX):
|
|
171
|
+
task_id = validate_task_id(branch.removeprefix(TASK_BRANCH_PREFIX))
|
|
172
|
+
deleted = run_git(
|
|
173
|
+
primary, "update-ref", "-d", task_authorization_ref(task_id)
|
|
174
|
+
)
|
|
175
|
+
if deleted.returncode != 0:
|
|
176
|
+
detail = deleted.stderr.strip() or deleted.stdout.strip()
|
|
177
|
+
raise IsolationError(
|
|
178
|
+
detail or f"could not clear task authorization for {task_id}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def authorized_task_worktree(worktree: Path, bound_branch: str) -> bool:
|
|
183
|
+
try:
|
|
184
|
+
worktree = require_directory(worktree, "task worktree")
|
|
185
|
+
branch = require_attached(worktree)
|
|
186
|
+
if not branch.startswith(TASK_BRANCH_PREFIX):
|
|
187
|
+
return False
|
|
188
|
+
task_id = validate_task_id(branch.removeprefix(TASK_BRANCH_PREFIX))
|
|
189
|
+
records = _registered_worktrees(worktree)
|
|
190
|
+
primary_matches = [
|
|
191
|
+
path
|
|
192
|
+
for path, record_branch in records.items()
|
|
193
|
+
if record_branch == f"refs/heads/{bound_branch}"
|
|
194
|
+
]
|
|
195
|
+
if len(primary_matches) != 1:
|
|
196
|
+
return False
|
|
197
|
+
primary = primary_matches[0]
|
|
198
|
+
if (
|
|
199
|
+
worktree != sidecar_root(primary, "task", task_id).resolve()
|
|
200
|
+
or common_git_dir(worktree) != common_git_dir(primary)
|
|
201
|
+
):
|
|
202
|
+
return False
|
|
203
|
+
head = current_sha(worktree)
|
|
204
|
+
authorization = run_git(
|
|
205
|
+
worktree, "rev-parse", "--verify", task_authorization_ref(task_id)
|
|
206
|
+
)
|
|
207
|
+
if authorization.returncode != 0 or authorization.stdout.strip() != head:
|
|
208
|
+
return False
|
|
209
|
+
matches = []
|
|
210
|
+
tasks_dir = worktree / ".project" / "tasks"
|
|
211
|
+
if tasks_dir.is_symlink() or tasks_dir.resolve() != tasks_dir:
|
|
212
|
+
return False
|
|
213
|
+
for task_path in tasks_dir.glob("*.md") if tasks_dir.is_dir() else ():
|
|
214
|
+
if task_path.is_symlink() or not task_path.is_file():
|
|
215
|
+
return False
|
|
216
|
+
fields, error = task_frontmatter(task_path.read_text(encoding="utf-8"))
|
|
217
|
+
if error is None and fields is not None and fields.get("id") == task_id:
|
|
218
|
+
matches.append(fields)
|
|
219
|
+
if len(matches) != 1:
|
|
220
|
+
return False
|
|
221
|
+
fields = matches[0]
|
|
222
|
+
agent = fields.get("agent")
|
|
223
|
+
return (
|
|
224
|
+
fields.get("status") == "in-progress"
|
|
225
|
+
and fields.get("base") == head
|
|
226
|
+
and fields.get("task_branch") == branch
|
|
227
|
+
and Path(str(fields.get("worktree", ""))).resolve() == worktree
|
|
228
|
+
and isinstance(agent, str)
|
|
229
|
+
and agent not in {"", "null"}
|
|
230
|
+
)
|
|
231
|
+
except (IsolationError, OSError, UnicodeError):
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def verify_branch_name(name: str) -> str:
|
|
236
|
+
return f"{VERIFY_BRANCH_PREFIX}{validate_name(name, 'verify name')}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def relative_posix(path: str) -> str:
|
|
240
|
+
parsed = PurePosixPath(path)
|
|
241
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
242
|
+
raise IsolationError(f"path must be relative without '..': {path}")
|
|
243
|
+
return parsed.as_posix()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _real_file(root: Path, relative: str, label: str) -> Path:
|
|
247
|
+
normalized = relative_posix(relative)
|
|
248
|
+
if normalized in {"", "."}:
|
|
249
|
+
raise IsolationError(f"{label} must name a file")
|
|
250
|
+
path = root / normalized
|
|
251
|
+
if not path.is_file() or path.is_symlink():
|
|
252
|
+
raise IsolationError(f"{label} is not a real file: {normalized}")
|
|
253
|
+
try:
|
|
254
|
+
path.resolve().relative_to(root)
|
|
255
|
+
except ValueError as error:
|
|
256
|
+
raise IsolationError(f"{label} escapes its worktree: {normalized}") from error
|
|
257
|
+
return path
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _safe_destination(root: Path, relative: str) -> Path:
|
|
261
|
+
normalized = relative_posix(relative)
|
|
262
|
+
if normalized in {"", "."} or not normalized.startswith(".project/"):
|
|
263
|
+
raise IsolationError("artifact destination must be a file under .project/")
|
|
264
|
+
current = root
|
|
265
|
+
parts = PurePosixPath(normalized).parts
|
|
266
|
+
for part in parts[:-1]:
|
|
267
|
+
current = current / part
|
|
268
|
+
if current.is_symlink():
|
|
269
|
+
raise IsolationError(f"artifact destination has a symlink parent: {normalized}")
|
|
270
|
+
destination = root / normalized
|
|
271
|
+
if destination.is_symlink() or (destination.exists() and not destination.is_file()):
|
|
272
|
+
raise IsolationError(f"artifact destination is not a real file: {normalized}")
|
|
273
|
+
return destination
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _sha256(data: bytes) -> str:
|
|
277
|
+
return hashlib.sha256(data).hexdigest()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _collect_journal_path(primary: Path, request: Dict[str, object]) -> Path:
|
|
281
|
+
key = hashlib.sha256(
|
|
282
|
+
json.dumps(request, sort_keys=True, separators=(",", ":")).encode()
|
|
283
|
+
).hexdigest()
|
|
284
|
+
return common_git_dir(primary) / "gsd-path" / "collect-artifact" / f"{key}.json"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _write_collect_journal(path: Path, payload: Dict[str, object]) -> None:
|
|
288
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
temporary_name: Optional[str] = None
|
|
290
|
+
try:
|
|
291
|
+
with tempfile.NamedTemporaryFile(
|
|
292
|
+
mode="w",
|
|
293
|
+
encoding="utf-8",
|
|
294
|
+
dir=path.parent,
|
|
295
|
+
prefix=".collect-artifact-",
|
|
296
|
+
delete=False,
|
|
297
|
+
) as handle:
|
|
298
|
+
json.dump(payload, handle, sort_keys=True)
|
|
299
|
+
handle.write("\n")
|
|
300
|
+
handle.flush()
|
|
301
|
+
os.fsync(handle.fileno())
|
|
302
|
+
temporary_name = handle.name
|
|
303
|
+
os.replace(temporary_name, path)
|
|
304
|
+
temporary_name = None
|
|
305
|
+
finally:
|
|
306
|
+
if temporary_name:
|
|
307
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _read_collect_journal(path: Path) -> Dict[str, object]:
|
|
311
|
+
if path.is_symlink() or not path.is_file():
|
|
312
|
+
raise IsolationError(f"artifact collection journal is not a real file: {path}")
|
|
313
|
+
try:
|
|
314
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
315
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
316
|
+
raise IsolationError(f"artifact collection journal is unreadable: {path}") from error
|
|
317
|
+
if not isinstance(payload, dict):
|
|
318
|
+
raise IsolationError("artifact collection journal must contain an object")
|
|
319
|
+
return payload
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _validated_collect_journal(
|
|
323
|
+
primary: Path,
|
|
324
|
+
path: Path,
|
|
325
|
+
*,
|
|
326
|
+
allow_complete_from_other_worktree: bool = False,
|
|
327
|
+
) -> Dict[str, object]:
|
|
328
|
+
journal = _read_collect_journal(path)
|
|
329
|
+
request_fields = {
|
|
330
|
+
"schema",
|
|
331
|
+
"primary_worktree",
|
|
332
|
+
"source_worktree",
|
|
333
|
+
"base",
|
|
334
|
+
"branch",
|
|
335
|
+
"source",
|
|
336
|
+
"destination",
|
|
337
|
+
"expected_destination",
|
|
338
|
+
}
|
|
339
|
+
expected_fields = request_fields | {
|
|
340
|
+
"bytes", "previous_sha256", "replaced", "sha256", "stage",
|
|
341
|
+
}
|
|
342
|
+
if set(journal) != expected_fields:
|
|
343
|
+
raise IsolationError("artifact collection journal has invalid fields")
|
|
344
|
+
if journal.get("schema") != COLLECT_JOURNAL_SCHEMA:
|
|
345
|
+
raise IsolationError("artifact collection journal has invalid schema")
|
|
346
|
+
string_fields = request_fields - {"schema", "expected_destination"}
|
|
347
|
+
if any(not isinstance(journal.get(field), str) for field in string_fields):
|
|
348
|
+
raise IsolationError("artifact collection journal has invalid request fields")
|
|
349
|
+
try:
|
|
350
|
+
require_full_sha(str(journal["base"]))
|
|
351
|
+
relative_posix(str(journal["source"]))
|
|
352
|
+
relative_posix(str(journal["destination"]))
|
|
353
|
+
except IsolationError as error:
|
|
354
|
+
raise IsolationError(
|
|
355
|
+
f"artifact collection journal has an invalid request: {error}"
|
|
356
|
+
) from error
|
|
357
|
+
if not Path(str(journal["primary_worktree"])).is_absolute():
|
|
358
|
+
raise IsolationError("artifact collection primary worktree must be absolute")
|
|
359
|
+
source_worktree = Path(str(journal["source_worktree"]))
|
|
360
|
+
if not source_worktree.is_absolute():
|
|
361
|
+
raise IsolationError("artifact collection source worktree must be absolute")
|
|
362
|
+
if not str(journal["branch"]).startswith(VERIFY_BRANCH_PREFIX):
|
|
363
|
+
raise IsolationError("artifact collection journal has an invalid verify branch")
|
|
364
|
+
if journal.get("stage") not in {"prepared", "collected", "complete"}:
|
|
365
|
+
raise IsolationError("artifact collection journal has an invalid stage")
|
|
366
|
+
count = journal.get("bytes")
|
|
367
|
+
if not isinstance(count, int) or isinstance(count, bool):
|
|
368
|
+
raise IsolationError("artifact collection journal has invalid byte count")
|
|
369
|
+
if not isinstance(journal.get("replaced"), bool):
|
|
370
|
+
raise IsolationError("artifact collection journal has invalid replacement state")
|
|
371
|
+
if not re.fullmatch(r"[0-9a-f]{64}", str(journal.get("sha256", ""))):
|
|
372
|
+
raise IsolationError("artifact collection journal has invalid content hash")
|
|
373
|
+
previous = journal.get("previous_sha256")
|
|
374
|
+
if previous is not None and not re.fullmatch(r"[0-9a-f]{64}", str(previous)):
|
|
375
|
+
raise IsolationError("artifact collection journal has invalid previous hash")
|
|
376
|
+
expected = journal.get("expected_destination")
|
|
377
|
+
if expected is not None and expected != "base" and not re.fullmatch(
|
|
378
|
+
r"[0-9a-f]{64}", str(expected)
|
|
379
|
+
):
|
|
380
|
+
raise IsolationError("artifact collection journal has invalid expected destination")
|
|
381
|
+
if journal.get("primary_worktree") != str(primary) and not (
|
|
382
|
+
allow_complete_from_other_worktree and journal["stage"] == "complete"
|
|
383
|
+
):
|
|
384
|
+
raise IsolationError("artifact collection journal belongs to another worktree")
|
|
385
|
+
return journal
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def collect_artifact_recoveries(primary: Path) -> list[Dict[str, object]]:
|
|
389
|
+
"""Return validated incomplete artifact collection transactions."""
|
|
390
|
+
primary = require_directory(primary, "primary worktree")
|
|
391
|
+
root = common_git_dir(primary) / "gsd-path" / "collect-artifact"
|
|
392
|
+
if not root.exists() and not root.is_symlink():
|
|
393
|
+
return []
|
|
394
|
+
if root.is_symlink() or not root.is_dir():
|
|
395
|
+
raise IsolationError(f"artifact collection journal root is unsafe: {root}")
|
|
396
|
+
recoveries = []
|
|
397
|
+
for path in sorted(root.iterdir()):
|
|
398
|
+
if path.suffix != ".json" or path.is_symlink() or not path.is_file():
|
|
399
|
+
raise IsolationError(f"artifact collection journal entry is unsafe: {path}")
|
|
400
|
+
journal = _validated_collect_journal(
|
|
401
|
+
primary, path, allow_complete_from_other_worktree=True
|
|
402
|
+
)
|
|
403
|
+
if journal["stage"] != "complete":
|
|
404
|
+
recoveries.append({**journal, "journal": str(path)})
|
|
405
|
+
return recoveries
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _git_file(repo: Path, revision: str, path: str) -> bytes:
|
|
409
|
+
result = subprocess.run(
|
|
410
|
+
("git", "-C", str(repo), "show", f"{revision}:{path}"),
|
|
411
|
+
capture_output=True,
|
|
412
|
+
check=False,
|
|
413
|
+
)
|
|
414
|
+
if result.returncode != 0:
|
|
415
|
+
raise IsolationError(f"file is missing at {revision}: {path}")
|
|
416
|
+
return result.stdout
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _registered_worktrees(primary: Path) -> Dict[Path, Optional[str]]:
|
|
420
|
+
result = run_git(primary, "worktree", "list", "--porcelain")
|
|
421
|
+
if result.returncode != 0:
|
|
422
|
+
raise IsolationError(
|
|
423
|
+
(result.stderr or result.stdout).strip() or "could not list worktrees"
|
|
424
|
+
)
|
|
425
|
+
records: Dict[Path, Optional[str]] = {}
|
|
426
|
+
for block in result.stdout.strip().split("\n\n"):
|
|
427
|
+
fields = block.splitlines()
|
|
428
|
+
if not fields or not fields[0].startswith("worktree "):
|
|
429
|
+
continue
|
|
430
|
+
path = Path(fields[0].removeprefix("worktree ")).resolve()
|
|
431
|
+
branch = next(
|
|
432
|
+
(
|
|
433
|
+
line.removeprefix("branch ")
|
|
434
|
+
for line in fields[1:]
|
|
435
|
+
if line.startswith("branch ")
|
|
436
|
+
),
|
|
437
|
+
None,
|
|
438
|
+
)
|
|
439
|
+
records[path] = branch
|
|
440
|
+
return records
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _same_directory(path: Path, expected: os.stat_result) -> bool:
|
|
444
|
+
try:
|
|
445
|
+
current = path.lstat()
|
|
446
|
+
except OSError:
|
|
447
|
+
return False
|
|
448
|
+
return stat.S_ISDIR(current.st_mode) and os.path.samestat(current, expected)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _cleanup_reserved_worktree(
|
|
452
|
+
primary: Path,
|
|
453
|
+
branch: str,
|
|
454
|
+
destination: Path,
|
|
455
|
+
base: str,
|
|
456
|
+
reserved_directory: os.stat_result,
|
|
457
|
+
) -> None:
|
|
458
|
+
ref = f"refs/heads/{branch}"
|
|
459
|
+
records = _registered_worktrees(primary)
|
|
460
|
+
registered = records.get(destination.resolve())
|
|
461
|
+
if registered == ref and _same_directory(destination, reserved_directory):
|
|
462
|
+
run_git(primary, "worktree", "remove", "--force", str(destination))
|
|
463
|
+
records = _registered_worktrees(primary)
|
|
464
|
+
elif registered is None and _same_directory(destination, reserved_directory):
|
|
465
|
+
try:
|
|
466
|
+
destination.rmdir()
|
|
467
|
+
except OSError:
|
|
468
|
+
pass
|
|
469
|
+
if ref not in records.values():
|
|
470
|
+
run_git(primary, "update-ref", "-d", ref, base)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def create_named_worktree(primary: Path, branch: str, destination: Path, base: str) -> None:
|
|
474
|
+
bound = require_bound(primary)
|
|
475
|
+
if branch == bound:
|
|
476
|
+
raise IsolationError(
|
|
477
|
+
f"refusing to check out the bound branch {bound} in a second worktree"
|
|
478
|
+
)
|
|
479
|
+
if os.path.lexists(destination):
|
|
480
|
+
raise IsolationError(f"worktree path already exists: {destination}")
|
|
481
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
ref = f"refs/heads/{branch}"
|
|
483
|
+
reserved = run_git(primary, "update-ref", ref, base, NULL_SHA)
|
|
484
|
+
if reserved.returncode != 0:
|
|
485
|
+
raise IsolationError(f"branch already exists: {branch}")
|
|
486
|
+
try:
|
|
487
|
+
destination.mkdir()
|
|
488
|
+
except FileExistsError as error:
|
|
489
|
+
run_git(primary, "update-ref", "-d", ref, base)
|
|
490
|
+
raise IsolationError(f"worktree path already exists: {destination}") from error
|
|
491
|
+
except OSError as error:
|
|
492
|
+
run_git(primary, "update-ref", "-d", ref, base)
|
|
493
|
+
raise IsolationError(f"cannot reserve worktree path {destination}: {error}") from error
|
|
494
|
+
reserved_directory = destination.lstat()
|
|
495
|
+
result = run_git(
|
|
496
|
+
primary,
|
|
497
|
+
"worktree",
|
|
498
|
+
"add",
|
|
499
|
+
str(destination),
|
|
500
|
+
branch,
|
|
501
|
+
)
|
|
502
|
+
if result.returncode != 0:
|
|
503
|
+
_cleanup_reserved_worktree(
|
|
504
|
+
primary, branch, destination, base, reserved_directory
|
|
505
|
+
)
|
|
506
|
+
detail = (result.stderr or result.stdout).strip() or "git worktree add failed"
|
|
507
|
+
raise IsolationError(detail)
|
|
508
|
+
current = git_output(destination, "branch", "--show-current")
|
|
509
|
+
if current != branch:
|
|
510
|
+
_cleanup_reserved_worktree(
|
|
511
|
+
primary, branch, destination, base, reserved_directory
|
|
512
|
+
)
|
|
513
|
+
raise IsolationError(
|
|
514
|
+
f"worktree HEAD is not {branch} (got {current or 'detached'})"
|
|
515
|
+
)
|
|
516
|
+
if git_output(destination, "rev-parse", "HEAD") != base:
|
|
517
|
+
_cleanup_reserved_worktree(
|
|
518
|
+
primary, branch, destination, base, reserved_directory
|
|
519
|
+
)
|
|
520
|
+
raise IsolationError("new worktree is not at the recorded base")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def isolate_task(
|
|
524
|
+
primary: Path,
|
|
525
|
+
base: str,
|
|
526
|
+
task_id: str,
|
|
527
|
+
round_size: int,
|
|
528
|
+
) -> Dict[str, object]:
|
|
529
|
+
primary = require_directory(primary, "primary worktree")
|
|
530
|
+
if worktree_root(primary) != primary:
|
|
531
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
532
|
+
bound = require_bound(primary)
|
|
533
|
+
resolved_base = require_commit(primary, require_full_sha(base))
|
|
534
|
+
validate_task_id(task_id)
|
|
535
|
+
if round_size < 1:
|
|
536
|
+
raise IsolationError("round-size must be >= 1")
|
|
537
|
+
if current_sha(primary) != resolved_base:
|
|
538
|
+
raise IsolationError("task isolation requires primary HEAD to equal the recorded base")
|
|
539
|
+
dirty = uncommitted_paths(primary)
|
|
540
|
+
if dirty:
|
|
541
|
+
raise IsolationError(
|
|
542
|
+
"task isolation requires a clean primary worktree: "
|
|
543
|
+
+ ", ".join(sorted(dirty))
|
|
544
|
+
)
|
|
545
|
+
if round_size == 1:
|
|
546
|
+
return {
|
|
547
|
+
"base": resolved_base,
|
|
548
|
+
"bound_branch": bound,
|
|
549
|
+
"detached": False,
|
|
550
|
+
"kind": "task",
|
|
551
|
+
"mode": "serial",
|
|
552
|
+
"task_branch": None,
|
|
553
|
+
"worktree": str(primary),
|
|
554
|
+
}
|
|
555
|
+
branch = task_branch_name(task_id)
|
|
556
|
+
destination = sidecar_root(primary, "task", task_id)
|
|
557
|
+
create_named_worktree(primary, branch, destination, resolved_base)
|
|
558
|
+
return {
|
|
559
|
+
"base": resolved_base,
|
|
560
|
+
"bound_branch": bound,
|
|
561
|
+
"detached": False,
|
|
562
|
+
"kind": "task",
|
|
563
|
+
"mode": "parallel",
|
|
564
|
+
"task_branch": branch,
|
|
565
|
+
"worktree": str(destination),
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def isolate_verify(primary: Path, base: str, name: str, historical_task: Optional[str] = None) -> Dict[str, object]:
|
|
570
|
+
primary = require_directory(primary, "primary worktree")
|
|
571
|
+
if worktree_root(primary) != primary:
|
|
572
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
573
|
+
bound = require_bound(primary)
|
|
574
|
+
resolved_base = require_commit(primary, require_full_sha(base))
|
|
575
|
+
if historical_task is not None:
|
|
576
|
+
reports = recover(primary, Path(".project/tasks"))["tasks"]
|
|
577
|
+
matches = [report for report in reports if report.get("task_id") == historical_task]
|
|
578
|
+
if (len(matches) != 1 or matches[0].get("verdict") != "recovered"
|
|
579
|
+
or matches[0].get("commit") != resolved_base):
|
|
580
|
+
raise IsolationError("historical verification requires this task's recovery-proven landing")
|
|
581
|
+
elif current_sha(primary) != resolved_base:
|
|
582
|
+
raise IsolationError("verify isolation requires primary HEAD to equal the recorded base")
|
|
583
|
+
product_dirt = sorted(
|
|
584
|
+
path for path in uncommitted_paths(primary) if not path.startswith(".project/")
|
|
585
|
+
)
|
|
586
|
+
if product_dirt:
|
|
587
|
+
raise IsolationError(
|
|
588
|
+
"verify isolation refuses non-.project primary changes: "
|
|
589
|
+
+ ", ".join(product_dirt)
|
|
590
|
+
)
|
|
591
|
+
branch = verify_branch_name(name)
|
|
592
|
+
destination = sidecar_root(primary, "verify", name)
|
|
593
|
+
create_named_worktree(primary, branch, destination, resolved_base)
|
|
594
|
+
return {
|
|
595
|
+
"base": resolved_base,
|
|
596
|
+
"bound_branch": bound,
|
|
597
|
+
"branch": branch,
|
|
598
|
+
"detached": False,
|
|
599
|
+
"kind": "verify",
|
|
600
|
+
"mode": "sidecar",
|
|
601
|
+
"worktree": str(destination),
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def clean_verify(primary: Path, worktree: Path, base: str, branch: str) -> Dict[str, object]:
|
|
606
|
+
"""Discard command-created files only in the named verification sidecar."""
|
|
607
|
+
primary = require_directory(primary, "primary worktree")
|
|
608
|
+
worktree = require_directory(worktree, "verification worktree")
|
|
609
|
+
if worktree == primary or not branch.startswith(VERIFY_BRANCH_PREFIX):
|
|
610
|
+
raise IsolationError("verification cleanup requires a sidecar branch")
|
|
611
|
+
if worktree != _sidecar_path_for_branch(primary, branch):
|
|
612
|
+
raise IsolationError("verification cleanup path differs from its branch")
|
|
613
|
+
if worktree_root(worktree) != worktree or common_git_dir(worktree) != common_git_dir(primary):
|
|
614
|
+
raise IsolationError("verification cleanup requires the primary repository's sidecar")
|
|
615
|
+
if require_attached(worktree) != branch:
|
|
616
|
+
raise IsolationError("verification cleanup branch changed")
|
|
617
|
+
expected = require_commit(primary, require_full_sha(base))
|
|
618
|
+
if current_sha(primary) != expected or current_sha(worktree) != expected:
|
|
619
|
+
raise IsolationError("verification cleanup HEAD changed")
|
|
620
|
+
git_output(worktree, "restore", "--source=HEAD", "--staged", "--worktree", "--", ".")
|
|
621
|
+
removed = git_output(worktree, "clean", "-fdx", "--", ".")
|
|
622
|
+
return {"worktree": str(worktree), "branch": branch, "base": expected, "removed": removed}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _split_paths(block: str) -> Set[str]:
|
|
626
|
+
return {line.strip() for line in block.splitlines() if line.strip()}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# Top-level .project entries the pipeline owns; ship verification rejects anything else.
|
|
630
|
+
PROJECT_ENTRIES = frozenset({
|
|
631
|
+
"STATE.md", "LESSONS.md", "REPOSITORY.md", "CHARTER.md", "ROADMAP.md", "SYNTHESIS.md",
|
|
632
|
+
"intent", "research", "plan", "tasks", "review", "build", "discuss", "archive", "next",
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def uncommitted_paths(repo: Path) -> Set[str]:
|
|
637
|
+
tracked = git_output(
|
|
638
|
+
repo, "diff", "--no-renames", "--name-only", "--relative", "HEAD"
|
|
639
|
+
)
|
|
640
|
+
cached = git_output(
|
|
641
|
+
repo, "diff", "--no-renames", "--name-only", "--cached", "--relative"
|
|
642
|
+
)
|
|
643
|
+
untracked = git_output(repo, "ls-files", "--others", "--exclude-standard")
|
|
644
|
+
return _split_paths(tracked) | _split_paths(cached) | _split_paths(untracked)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def committed_paths_since(repo: Path, base: str) -> Set[str]:
|
|
648
|
+
if current_sha(repo) == base:
|
|
649
|
+
return set()
|
|
650
|
+
return _split_paths(
|
|
651
|
+
git_output(
|
|
652
|
+
repo, "diff", "--no-renames", "--name-only", "--relative", base, "HEAD"
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _commit_pending(repo: Path, pending: Set[str], subject: str, body: str) -> str:
|
|
658
|
+
reset = run_git(repo, "reset", "-q", "HEAD")
|
|
659
|
+
if reset.returncode != 0:
|
|
660
|
+
raise IsolationError("could not clear the index before commit")
|
|
661
|
+
for path in sorted(pending):
|
|
662
|
+
added = run_git(repo, "add", "-A", "--", path)
|
|
663
|
+
if added.returncode != 0:
|
|
664
|
+
raise IsolationError(f"could not stage {path}")
|
|
665
|
+
staged = _split_paths(
|
|
666
|
+
git_output(
|
|
667
|
+
repo,
|
|
668
|
+
"diff",
|
|
669
|
+
"--no-renames",
|
|
670
|
+
"--cached",
|
|
671
|
+
"--name-only",
|
|
672
|
+
"--relative",
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
if staged != pending:
|
|
676
|
+
raise IsolationError("staged paths do not match the uncommitted change set")
|
|
677
|
+
committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
|
|
678
|
+
if committed.returncode != 0:
|
|
679
|
+
raise IsolationError((committed.stderr or committed.stdout).strip() or "commit failed")
|
|
680
|
+
return current_sha(repo)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def validate_allowed_changes(
|
|
684
|
+
repo: Path, base: str, pending: Set[str], allowed: Set[str]
|
|
685
|
+
) -> None:
|
|
686
|
+
if not pending:
|
|
687
|
+
raise IsolationError("no changes to land")
|
|
688
|
+
since_base = committed_paths_since(repo, base) | pending
|
|
689
|
+
unexpected = sorted(path for path in since_base if path not in allowed)
|
|
690
|
+
if unexpected:
|
|
691
|
+
raise IsolationError("unexpected paths: " + ", ".join(unexpected))
|
|
692
|
+
def _clean_blob_oid(repo: Path, path: str, text: str, *, write: bool) -> str:
|
|
693
|
+
arguments = ["hash-object"]
|
|
694
|
+
if write:
|
|
695
|
+
arguments.append("-w")
|
|
696
|
+
arguments.extend((f"--path={path}", "--stdin"))
|
|
697
|
+
result = run_git(repo, *arguments, input=text)
|
|
698
|
+
oid = result.stdout.strip()
|
|
699
|
+
if result.returncode != 0 or not re.fullmatch(r"[0-9a-f]{40,64}", oid):
|
|
700
|
+
detail = (result.stderr or result.stdout).strip() or "clean filter failed"
|
|
701
|
+
raise IsolationError(detail)
|
|
702
|
+
return oid
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def commit_allowed_changes(
|
|
706
|
+
repo: Path,
|
|
707
|
+
base: str,
|
|
708
|
+
subject: str,
|
|
709
|
+
allowed: Set[str],
|
|
710
|
+
body: str,
|
|
711
|
+
) -> tuple[str, str, Dict[str, Optional[tuple[str, str]]]]:
|
|
712
|
+
require_attached(repo)
|
|
713
|
+
pending = uncommitted_paths(repo)
|
|
714
|
+
validate_allowed_changes(repo, base, pending, allowed)
|
|
715
|
+
reset = run_git(repo, "reset", "-q", "HEAD")
|
|
716
|
+
if reset.returncode != 0:
|
|
717
|
+
raise IsolationError("could not clear the index before landing")
|
|
718
|
+
for path in sorted(pending):
|
|
719
|
+
added = run_git(repo, "add", "-A", "--", path)
|
|
720
|
+
if added.returncode != 0:
|
|
721
|
+
raise IsolationError(f"could not stage {path}")
|
|
722
|
+
staged = _split_paths(
|
|
723
|
+
git_output(repo, "diff", "--cached", "--name-only", "--relative")
|
|
724
|
+
)
|
|
725
|
+
if staged != pending:
|
|
726
|
+
raise IsolationError("staged paths do not match the uncommitted change set")
|
|
727
|
+
verified_tree = git_output(repo, "write-tree")
|
|
728
|
+
verified_delta = _tree_delta(repo, base, verified_tree)
|
|
729
|
+
committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
|
|
730
|
+
if committed.returncode != 0:
|
|
731
|
+
raise IsolationError(
|
|
732
|
+
(committed.stderr or committed.stdout).strip() or "commit failed"
|
|
733
|
+
)
|
|
734
|
+
return current_sha(repo), verified_tree, verified_delta
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def split_frontmatter(text: str) -> tuple[list[str], str]:
|
|
738
|
+
"""Return (frontmatter lines without the --- fences, body text)."""
|
|
739
|
+
lines = text.splitlines(keepends=True)
|
|
740
|
+
if not lines or lines[0].rstrip("\r\n") != "---":
|
|
741
|
+
raise IsolationError("task file has no frontmatter")
|
|
742
|
+
for index in range(1, len(lines)):
|
|
743
|
+
if lines[index].rstrip("\r\n") == "---":
|
|
744
|
+
return [line.rstrip("\r\n") for line in lines[1:index]], "".join(lines[index + 1 :])
|
|
745
|
+
raise IsolationError("task file frontmatter is not closed")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
_strip_yaml_comment = _common.strip_yaml_comment
|
|
749
|
+
_unquote = _common.unquote
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def task_frontmatter(text: str) -> tuple[Optional[Dict[str, object]], Optional[str]]:
|
|
753
|
+
try:
|
|
754
|
+
lines, _ = split_frontmatter(text)
|
|
755
|
+
except IsolationError as error:
|
|
756
|
+
return None, str(error)
|
|
757
|
+
fields: Dict[str, object] = {}
|
|
758
|
+
index = 0
|
|
759
|
+
while index < len(lines):
|
|
760
|
+
match = FIELD_PATTERN.match(lines[index])
|
|
761
|
+
if match is None:
|
|
762
|
+
index += 1
|
|
763
|
+
continue
|
|
764
|
+
key = match.group("key")
|
|
765
|
+
value = _strip_yaml_comment(match.group("value"))
|
|
766
|
+
inline = INLINE_LIST_PATTERN.fullmatch(value)
|
|
767
|
+
if inline is not None:
|
|
768
|
+
fields[key] = [
|
|
769
|
+
_unquote(item)
|
|
770
|
+
for item in inline.group("body").split(",")
|
|
771
|
+
if item.strip()
|
|
772
|
+
]
|
|
773
|
+
index += 1
|
|
774
|
+
continue
|
|
775
|
+
if value:
|
|
776
|
+
fields[key] = _unquote(value)
|
|
777
|
+
index += 1
|
|
778
|
+
continue
|
|
779
|
+
items = []
|
|
780
|
+
cursor = index + 1
|
|
781
|
+
while cursor < len(lines):
|
|
782
|
+
item = LIST_ITEM_PATTERN.match(lines[cursor])
|
|
783
|
+
if item is None:
|
|
784
|
+
break
|
|
785
|
+
items.append(_unquote(item.group("value")))
|
|
786
|
+
cursor += 1
|
|
787
|
+
fields[key] = items
|
|
788
|
+
index = cursor
|
|
789
|
+
return fields, None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
LANDED_FIELDS = {"status": "done", "worktree": "null", "task_branch": "null"}
|
|
793
|
+
LANDING_MUTABLE_FIELDS = ("status", "agent", "base", "worktree", "task_branch")
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _activated_task_text(
|
|
797
|
+
text: str,
|
|
798
|
+
task_id: str,
|
|
799
|
+
agent: str,
|
|
800
|
+
base: str,
|
|
801
|
+
worktree: Path,
|
|
802
|
+
task_branch: Optional[str],
|
|
803
|
+
) -> str:
|
|
804
|
+
head, body = split_frontmatter(text)
|
|
805
|
+
fields, error = task_frontmatter(text)
|
|
806
|
+
if error is not None or fields is None:
|
|
807
|
+
raise IsolationError(error or "task frontmatter is unreadable")
|
|
808
|
+
pending = {
|
|
809
|
+
"id": task_id,
|
|
810
|
+
"status": "pending",
|
|
811
|
+
"agent": "null",
|
|
812
|
+
"base": "null",
|
|
813
|
+
"worktree": "null",
|
|
814
|
+
"task_branch": "null",
|
|
815
|
+
}
|
|
816
|
+
values = {
|
|
817
|
+
"status": "in-progress",
|
|
818
|
+
"agent": agent,
|
|
819
|
+
"base": base,
|
|
820
|
+
"worktree": str(worktree),
|
|
821
|
+
"task_branch": task_branch or "null",
|
|
822
|
+
}
|
|
823
|
+
active = {"id": task_id, **values}
|
|
824
|
+
if all(fields.get(key) == value for key, value in active.items()):
|
|
825
|
+
return text
|
|
826
|
+
for key, value in pending.items():
|
|
827
|
+
if fields.get(key) != value:
|
|
828
|
+
raise IsolationError(f"task activation requires {key}: {value}")
|
|
829
|
+
found = set()
|
|
830
|
+
lines = []
|
|
831
|
+
for line in head:
|
|
832
|
+
key = _frontmatter_key(line)
|
|
833
|
+
if key in values:
|
|
834
|
+
if key in found:
|
|
835
|
+
raise IsolationError(f"task frontmatter repeats {key}")
|
|
836
|
+
found.add(key)
|
|
837
|
+
lines.append(f"{key}: {values[key]}")
|
|
838
|
+
else:
|
|
839
|
+
lines.append(line)
|
|
840
|
+
missing = sorted(values.keys() - found)
|
|
841
|
+
if missing:
|
|
842
|
+
raise IsolationError(
|
|
843
|
+
"task frontmatter missing activation fields: " + ", ".join(missing)
|
|
844
|
+
)
|
|
845
|
+
return "---\n" + "\n".join(lines) + "\n---\n" + body
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def activate_task(
|
|
849
|
+
worktree: Path,
|
|
850
|
+
base: str,
|
|
851
|
+
task_id: str,
|
|
852
|
+
agent: str,
|
|
853
|
+
task_file: str,
|
|
854
|
+
task_branch: Optional[str],
|
|
855
|
+
) -> Dict[str, object]:
|
|
856
|
+
worktree = require_directory(worktree, "task worktree")
|
|
857
|
+
if worktree_root(worktree) != worktree:
|
|
858
|
+
raise IsolationError(f"task worktree is not its Git root: {worktree}")
|
|
859
|
+
resolved_base = require_commit(worktree, require_full_sha(base))
|
|
860
|
+
if current_sha(worktree) != resolved_base:
|
|
861
|
+
raise IsolationError(
|
|
862
|
+
"task activation requires worktree HEAD at the recorded base"
|
|
863
|
+
)
|
|
864
|
+
task_id = validate_task_id(task_id)
|
|
865
|
+
if not agent or agent == "null" or "\n" in agent:
|
|
866
|
+
raise IsolationError("task activation requires an assigned agent")
|
|
867
|
+
branch = require_attached(worktree)
|
|
868
|
+
if task_branch is None:
|
|
869
|
+
if branch.startswith(TASK_BRANCH_PREFIX):
|
|
870
|
+
raise IsolationError("parallel task activation requires --task-branch")
|
|
871
|
+
elif task_branch != task_branch_name(task_id) or branch != task_branch:
|
|
872
|
+
raise IsolationError("task activation branch does not match the task worktree")
|
|
873
|
+
normalized_task = relative_posix(task_file)
|
|
874
|
+
if not normalized_task.startswith(".project/tasks/"):
|
|
875
|
+
raise IsolationError("task activation file must stay under .project/tasks/")
|
|
876
|
+
task_path = _real_file(worktree, normalized_task, "task activation file")
|
|
877
|
+
text, mode = _read_task_text(task_path)
|
|
878
|
+
activated = _activated_task_text(
|
|
879
|
+
text, task_id, agent, resolved_base, worktree, task_branch
|
|
880
|
+
)
|
|
881
|
+
_replace_regular_file(task_path, activated.encode("utf-8"), mode)
|
|
882
|
+
if task_branch is not None:
|
|
883
|
+
authorization_ref = task_authorization_ref(task_id)
|
|
884
|
+
authorized = run_git(
|
|
885
|
+
worktree,
|
|
886
|
+
"update-ref",
|
|
887
|
+
authorization_ref,
|
|
888
|
+
resolved_base,
|
|
889
|
+
"0" * len(resolved_base),
|
|
890
|
+
)
|
|
891
|
+
if authorized.returncode != 0:
|
|
892
|
+
existing = run_git(
|
|
893
|
+
worktree,
|
|
894
|
+
"rev-parse",
|
|
895
|
+
"--verify",
|
|
896
|
+
authorization_ref,
|
|
897
|
+
)
|
|
898
|
+
if existing.returncode != 0 or existing.stdout.strip() != resolved_base:
|
|
899
|
+
_replace_regular_file(task_path, text.encode("utf-8"), mode)
|
|
900
|
+
raise IsolationError("could not record task worktree authorization")
|
|
901
|
+
return {
|
|
902
|
+
"agent": agent,
|
|
903
|
+
"base": resolved_base,
|
|
904
|
+
"status": "in-progress",
|
|
905
|
+
"task_branch": task_branch,
|
|
906
|
+
"task_file": normalized_task,
|
|
907
|
+
"worktree": str(worktree),
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def deactivate_task(
|
|
912
|
+
worktree: Path, task_id: str, task_branch: str
|
|
913
|
+
) -> Dict[str, object]:
|
|
914
|
+
worktree = require_directory(worktree, "task worktree")
|
|
915
|
+
if worktree_root(worktree) != worktree:
|
|
916
|
+
raise IsolationError(f"task worktree is not its Git root: {worktree}")
|
|
917
|
+
task_id = validate_task_id(task_id)
|
|
918
|
+
expected_branch = task_branch_name(task_id)
|
|
919
|
+
if (
|
|
920
|
+
task_branch != expected_branch
|
|
921
|
+
or require_attached(worktree) != expected_branch
|
|
922
|
+
):
|
|
923
|
+
raise IsolationError("task deactivation branch does not match the task worktree")
|
|
924
|
+
_delete_task_authorization(worktree, task_branch)
|
|
925
|
+
return {
|
|
926
|
+
"status": "deactivated",
|
|
927
|
+
"task_branch": task_branch,
|
|
928
|
+
"task_id": task_id,
|
|
929
|
+
"worktree": str(worktree),
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _landed_task_text(text: str, base: str) -> str:
|
|
934
|
+
head, body = split_frontmatter(text)
|
|
935
|
+
values = {**LANDED_FIELDS, "base": base}
|
|
936
|
+
found = set()
|
|
937
|
+
lines = []
|
|
938
|
+
for line in head:
|
|
939
|
+
key = line.split(":", 1)[0]
|
|
940
|
+
if key in values:
|
|
941
|
+
if key in found:
|
|
942
|
+
raise IsolationError(f"task frontmatter repeats {key}")
|
|
943
|
+
if key == "base":
|
|
944
|
+
recorded_base = _unquote(line.split(":", 1)[1])
|
|
945
|
+
if recorded_base not in {"null", base}:
|
|
946
|
+
raise IsolationError(
|
|
947
|
+
"task frontmatter base must be null or match landing base"
|
|
948
|
+
)
|
|
949
|
+
found.add(key)
|
|
950
|
+
lines.append(f"{key}: {values[key]}")
|
|
951
|
+
else:
|
|
952
|
+
lines.append(line)
|
|
953
|
+
missing = sorted(values.keys() - found)
|
|
954
|
+
if missing:
|
|
955
|
+
raise IsolationError("task frontmatter missing landing fields: " + ", ".join(missing))
|
|
956
|
+
return "---\n" + "\n".join(lines) + "\n---\n" + body
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _read_regular_file(path: Path) -> tuple[bytes, int]:
|
|
960
|
+
descriptor = -1
|
|
961
|
+
try:
|
|
962
|
+
initial = path.lstat()
|
|
963
|
+
if not stat.S_ISREG(initial.st_mode):
|
|
964
|
+
raise IsolationError(f"task path is not a regular file: {path}")
|
|
965
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
966
|
+
metadata = os.fstat(descriptor)
|
|
967
|
+
current = path.lstat()
|
|
968
|
+
initial_identity = (initial.st_dev, initial.st_ino)
|
|
969
|
+
opened_identity = (metadata.st_dev, metadata.st_ino)
|
|
970
|
+
current_identity = (current.st_dev, current.st_ino)
|
|
971
|
+
if (
|
|
972
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
973
|
+
or not stat.S_ISREG(current.st_mode)
|
|
974
|
+
or initial_identity != opened_identity
|
|
975
|
+
or current_identity != opened_identity
|
|
976
|
+
):
|
|
977
|
+
raise IsolationError(f"task path is not a regular file: {path}")
|
|
978
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
979
|
+
descriptor = -1
|
|
980
|
+
return stream.read(), stat.S_IMODE(metadata.st_mode)
|
|
981
|
+
except OSError as error:
|
|
982
|
+
raise IsolationError(f"task path is not a regular file: {path}") from error
|
|
983
|
+
finally:
|
|
984
|
+
if descriptor >= 0:
|
|
985
|
+
os.close(descriptor)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _read_task_text(path: Path) -> tuple[str, int]:
|
|
989
|
+
data, mode = _read_regular_file(path)
|
|
990
|
+
try:
|
|
991
|
+
return data.decode("utf-8"), mode
|
|
992
|
+
except UnicodeDecodeError as error:
|
|
993
|
+
raise IsolationError(f"task file is not UTF-8: {path}") from error
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _replace_regular_file(path: Path, data: bytes, mode: int) -> None:
|
|
997
|
+
descriptor = -1
|
|
998
|
+
temporary_path: Optional[Path] = None
|
|
999
|
+
try:
|
|
1000
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
1001
|
+
dir=path.parent, prefix=f".{path.name}."
|
|
1002
|
+
)
|
|
1003
|
+
temporary_path = Path(temporary_name)
|
|
1004
|
+
os.chmod(temporary_path, mode)
|
|
1005
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
1006
|
+
descriptor = -1
|
|
1007
|
+
stream.write(data)
|
|
1008
|
+
os.replace(temporary_path, path)
|
|
1009
|
+
temporary_path = None
|
|
1010
|
+
except OSError as error:
|
|
1011
|
+
raise IsolationError(f"could not replace task file {path}: {error}") from error
|
|
1012
|
+
finally:
|
|
1013
|
+
if descriptor >= 0:
|
|
1014
|
+
os.close(descriptor)
|
|
1015
|
+
if temporary_path is not None:
|
|
1016
|
+
temporary_path.unlink(missing_ok=True)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def stamp_task_landed(task_path: Path, base: str) -> None:
|
|
1020
|
+
"""Write the orchestrator-owned landing state into the task frontmatter."""
|
|
1021
|
+
text, mode = _read_task_text(task_path)
|
|
1022
|
+
_replace_regular_file(
|
|
1023
|
+
task_path, _landed_task_text(text, base).encode("utf-8"), mode
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _restore_landing_state(
|
|
1028
|
+
worktree: Path, task_path: Path, task_bytes: bytes, task_mode: int, index_tree: str
|
|
1029
|
+
) -> None:
|
|
1030
|
+
errors = []
|
|
1031
|
+
try:
|
|
1032
|
+
_replace_regular_file(task_path, task_bytes, task_mode)
|
|
1033
|
+
except IsolationError as error:
|
|
1034
|
+
errors.append(str(error))
|
|
1035
|
+
restored = run_git(worktree, "read-tree", index_tree)
|
|
1036
|
+
if restored.returncode != 0:
|
|
1037
|
+
errors.append((restored.stderr or restored.stdout).strip() or "index restore failed")
|
|
1038
|
+
if errors:
|
|
1039
|
+
raise IsolationError("could not restore landing state: " + "; ".join(errors))
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _restore_rejected_commit(
|
|
1043
|
+
repo: Path, commit: str, parent: str, worktree_tree: str
|
|
1044
|
+
) -> None:
|
|
1045
|
+
if current_sha(repo) != commit:
|
|
1046
|
+
raise IsolationError("HEAD moved after the rejected landing commit")
|
|
1047
|
+
moved = run_git(repo, "update-ref", "HEAD", parent, commit)
|
|
1048
|
+
if moved.returncode != 0:
|
|
1049
|
+
detail = (moved.stderr or moved.stdout).strip() or "could not restore HEAD"
|
|
1050
|
+
raise IsolationError(detail)
|
|
1051
|
+
restored = run_git(repo, "read-tree", "--reset", "-u", worktree_tree)
|
|
1052
|
+
if restored.returncode != 0:
|
|
1053
|
+
detail = (
|
|
1054
|
+
(restored.stderr or restored.stdout).strip()
|
|
1055
|
+
or "could not restore the verified tree"
|
|
1056
|
+
)
|
|
1057
|
+
raise IsolationError(detail)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _stamp_and_commit(
|
|
1061
|
+
worktree: Path, base: str, subject: str, task_file: str, allowed: Set[str]
|
|
1062
|
+
) -> tuple[str, Dict[str, Optional[tuple[str, str]]]]:
|
|
1063
|
+
task_path = worktree / task_file
|
|
1064
|
+
task_text, task_mode = _read_task_text(task_path)
|
|
1065
|
+
task_bytes = task_text.encode("utf-8")
|
|
1066
|
+
stamped_text = _landed_task_text(task_text, base)
|
|
1067
|
+
stamped = stamped_text.encode("utf-8")
|
|
1068
|
+
_, landing_error = _landing_state(
|
|
1069
|
+
worktree,
|
|
1070
|
+
base,
|
|
1071
|
+
task_file,
|
|
1072
|
+
stamped_text,
|
|
1073
|
+
subject,
|
|
1074
|
+
allowed,
|
|
1075
|
+
clean_after=True,
|
|
1076
|
+
)
|
|
1077
|
+
if landing_error:
|
|
1078
|
+
raise IsolationError(landing_error)
|
|
1079
|
+
pending = uncommitted_paths(worktree)
|
|
1080
|
+
if stamped != task_bytes:
|
|
1081
|
+
pending.add(task_file)
|
|
1082
|
+
validate_allowed_changes(worktree, base, pending, allowed)
|
|
1083
|
+
original_head = current_sha(worktree)
|
|
1084
|
+
index_tree = git_output(worktree, "write-tree")
|
|
1085
|
+
try:
|
|
1086
|
+
_replace_regular_file(task_path, stamped, task_mode)
|
|
1087
|
+
body = task_commit_body(task_file, uncommitted_paths(worktree), base)
|
|
1088
|
+
commit, verified_tree, verified_delta = commit_allowed_changes(
|
|
1089
|
+
worktree, base, subject, allowed, body
|
|
1090
|
+
)
|
|
1091
|
+
proof_error = _landing_commit_proof_error(
|
|
1092
|
+
worktree,
|
|
1093
|
+
commit,
|
|
1094
|
+
original_head,
|
|
1095
|
+
task_file,
|
|
1096
|
+
base,
|
|
1097
|
+
allowed,
|
|
1098
|
+
verified_delta,
|
|
1099
|
+
)
|
|
1100
|
+
if proof_error:
|
|
1101
|
+
try:
|
|
1102
|
+
_restore_rejected_commit(
|
|
1103
|
+
worktree, commit, original_head, verified_tree
|
|
1104
|
+
)
|
|
1105
|
+
except IsolationError as restore_error:
|
|
1106
|
+
raise IsolationError(
|
|
1107
|
+
f"landing commit proof failed: {proof_error}; {restore_error}"
|
|
1108
|
+
) from restore_error
|
|
1109
|
+
raise IsolationError(f"landing commit proof failed: {proof_error}")
|
|
1110
|
+
return commit, verified_delta
|
|
1111
|
+
except BaseException as error:
|
|
1112
|
+
if current_sha(worktree) != original_head:
|
|
1113
|
+
raise IsolationError(
|
|
1114
|
+
f"landing failed after HEAD changed; refusing rollback: {error}"
|
|
1115
|
+
) from error
|
|
1116
|
+
try:
|
|
1117
|
+
_restore_landing_state(
|
|
1118
|
+
worktree, task_path, task_bytes, task_mode, index_tree
|
|
1119
|
+
)
|
|
1120
|
+
except IsolationError as restore_error:
|
|
1121
|
+
raise IsolationError(f"{error}; {restore_error}") from error
|
|
1122
|
+
raise
|
|
1123
|
+
def land(
|
|
1124
|
+
primary: Path,
|
|
1125
|
+
source: Path,
|
|
1126
|
+
base: str,
|
|
1127
|
+
task_id: str,
|
|
1128
|
+
title: str,
|
|
1129
|
+
task_file: str,
|
|
1130
|
+
allow_paths: Sequence[str],
|
|
1131
|
+
) -> Dict[str, object]:
|
|
1132
|
+
primary = require_directory(primary, "primary worktree")
|
|
1133
|
+
source = require_directory(source, "source worktree")
|
|
1134
|
+
if worktree_root(primary) != primary:
|
|
1135
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
1136
|
+
if worktree_root(source) != source:
|
|
1137
|
+
raise IsolationError(f"source is not its Git root: {source}")
|
|
1138
|
+
if common_git_dir(primary) != common_git_dir(source):
|
|
1139
|
+
raise IsolationError("source worktree belongs to another repository")
|
|
1140
|
+
bound = require_bound(primary)
|
|
1141
|
+
source_branch = require_attached(source)
|
|
1142
|
+
resolved_base = require_commit(primary, require_full_sha(base))
|
|
1143
|
+
validate_task_id(task_id)
|
|
1144
|
+
try:
|
|
1145
|
+
subject = task_commit_subject(task_id, title)
|
|
1146
|
+
except ValueError as error:
|
|
1147
|
+
raise IsolationError(str(error)) from error
|
|
1148
|
+
allowed = {relative_posix(task_file)}
|
|
1149
|
+
for path in allow_paths:
|
|
1150
|
+
allowed.add(relative_posix(path))
|
|
1151
|
+
serial = source == primary
|
|
1152
|
+
if serial:
|
|
1153
|
+
if source_branch != bound:
|
|
1154
|
+
raise IsolationError("serial landing requires the bound branch")
|
|
1155
|
+
if current_sha(primary) != resolved_base:
|
|
1156
|
+
raise IsolationError("serial landing requires HEAD to equal the recorded base")
|
|
1157
|
+
commit, _ = _stamp_and_commit(
|
|
1158
|
+
primary,
|
|
1159
|
+
resolved_base,
|
|
1160
|
+
subject,
|
|
1161
|
+
relative_posix(task_file),
|
|
1162
|
+
allowed,
|
|
1163
|
+
)
|
|
1164
|
+
return {
|
|
1165
|
+
"bound_branch": bound,
|
|
1166
|
+
"commit": commit,
|
|
1167
|
+
"mode": "serial",
|
|
1168
|
+
"source_commit": commit,
|
|
1169
|
+
"subject": subject,
|
|
1170
|
+
}
|
|
1171
|
+
expected_branch = task_branch_name(task_id)
|
|
1172
|
+
if source_branch != expected_branch:
|
|
1173
|
+
raise IsolationError(
|
|
1174
|
+
f"source branch is {source_branch}, expected {expected_branch}"
|
|
1175
|
+
)
|
|
1176
|
+
if require_attached(primary) != bound:
|
|
1177
|
+
raise IsolationError("primary left the bound branch")
|
|
1178
|
+
dirty = sorted(uncommitted_paths(primary) - BOOKKEEPING_PATHS)
|
|
1179
|
+
if dirty:
|
|
1180
|
+
raise IsolationError(
|
|
1181
|
+
"primary worktree is dirty; refusing to cherry-pick: "
|
|
1182
|
+
+ ", ".join(dirty)
|
|
1183
|
+
+ "; commit or remove those paths in the primary worktree first"
|
|
1184
|
+
)
|
|
1185
|
+
source_dirty = bool(uncommitted_paths(source))
|
|
1186
|
+
if source_dirty:
|
|
1187
|
+
if current_sha(source) != resolved_base:
|
|
1188
|
+
raise IsolationError("dirty parallel source HEAD must equal the recorded base")
|
|
1189
|
+
source_commit, source_delta = _stamp_and_commit(
|
|
1190
|
+
source, resolved_base, subject, relative_posix(task_file), allowed
|
|
1191
|
+
)
|
|
1192
|
+
else:
|
|
1193
|
+
source_commit = current_sha(source)
|
|
1194
|
+
if source_commit == resolved_base:
|
|
1195
|
+
raise IsolationError("no changes to land")
|
|
1196
|
+
source_delta = _tree_delta(source, resolved_base, source_commit)
|
|
1197
|
+
source_parent, parent_error = _single_parent(source, source_commit)
|
|
1198
|
+
if parent_error or source_parent != resolved_base:
|
|
1199
|
+
raise IsolationError(
|
|
1200
|
+
parent_error or "clean parallel source commit parent must equal the recorded base"
|
|
1201
|
+
)
|
|
1202
|
+
if uncommitted_paths(source):
|
|
1203
|
+
raise IsolationError("source worktree is dirty after creating the task commit")
|
|
1204
|
+
source_body = git_output(source, "log", "-1", "--format=%b", source_commit)
|
|
1205
|
+
_, proof_error = _prove_task_commit(
|
|
1206
|
+
source,
|
|
1207
|
+
source_commit,
|
|
1208
|
+
source_body,
|
|
1209
|
+
relative_posix(task_file),
|
|
1210
|
+
resolved_base,
|
|
1211
|
+
None,
|
|
1212
|
+
allowed,
|
|
1213
|
+
)
|
|
1214
|
+
if proof_error:
|
|
1215
|
+
raise IsolationError(f"source commit body/task proof failed: {proof_error}")
|
|
1216
|
+
landing_parent = current_sha(primary)
|
|
1217
|
+
picked = run_git(primary, "cherry-pick", source_commit)
|
|
1218
|
+
if picked.returncode != 0:
|
|
1219
|
+
run_git(primary, "cherry-pick", "--abort")
|
|
1220
|
+
if git_output(primary, "status", "--porcelain"):
|
|
1221
|
+
raise IsolationError("cherry-pick failed and primary is not clean after abort")
|
|
1222
|
+
if require_attached(primary) != bound:
|
|
1223
|
+
raise IsolationError("cherry-pick failed and primary is no longer on the bound branch")
|
|
1224
|
+
detail = (picked.stderr or picked.stdout).strip() or "cherry-pick conflict"
|
|
1225
|
+
raise IsolationError(f"conflict: {detail}")
|
|
1226
|
+
commit = current_sha(primary)
|
|
1227
|
+
proof_error = _landing_commit_proof_error(
|
|
1228
|
+
primary,
|
|
1229
|
+
commit,
|
|
1230
|
+
landing_parent,
|
|
1231
|
+
relative_posix(task_file),
|
|
1232
|
+
resolved_base,
|
|
1233
|
+
allowed,
|
|
1234
|
+
source_delta,
|
|
1235
|
+
)
|
|
1236
|
+
if proof_error:
|
|
1237
|
+
try:
|
|
1238
|
+
_restore_rejected_commit(
|
|
1239
|
+
primary, commit, landing_parent, landing_parent
|
|
1240
|
+
)
|
|
1241
|
+
except IsolationError as restore_error:
|
|
1242
|
+
raise IsolationError(
|
|
1243
|
+
f"landing commit proof failed: {proof_error}; {restore_error}"
|
|
1244
|
+
) from restore_error
|
|
1245
|
+
raise IsolationError(f"landing commit proof failed: {proof_error}")
|
|
1246
|
+
return {
|
|
1247
|
+
"bound_branch": bound,
|
|
1248
|
+
"commit": commit,
|
|
1249
|
+
"mode": "parallel",
|
|
1250
|
+
"source_commit": source_commit,
|
|
1251
|
+
"subject": subject,
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def git_text(repo: Path, *arguments: str) -> str:
|
|
1256
|
+
result = run_git(repo, *arguments)
|
|
1257
|
+
if result.returncode != 0:
|
|
1258
|
+
detail = (result.stderr or result.stdout).strip() or "git command failed"
|
|
1259
|
+
raise IsolationError(detail)
|
|
1260
|
+
return result.stdout
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _tree_delta(
|
|
1264
|
+
repo: Path, before: str, after: str
|
|
1265
|
+
) -> Dict[str, Optional[tuple[str, str]]]:
|
|
1266
|
+
raw = git_text(
|
|
1267
|
+
repo,
|
|
1268
|
+
"diff",
|
|
1269
|
+
"--raw",
|
|
1270
|
+
"--no-abbrev",
|
|
1271
|
+
"--no-renames",
|
|
1272
|
+
"-z",
|
|
1273
|
+
before,
|
|
1274
|
+
after,
|
|
1275
|
+
"--",
|
|
1276
|
+
)
|
|
1277
|
+
records = raw.split("\0")
|
|
1278
|
+
if records and records[-1] == "":
|
|
1279
|
+
records.pop()
|
|
1280
|
+
if len(records) % 2:
|
|
1281
|
+
raise IsolationError("could not parse tree delta")
|
|
1282
|
+
delta: Dict[str, Optional[tuple[str, str]]] = {}
|
|
1283
|
+
for metadata, path in zip(records[0::2], records[1::2]):
|
|
1284
|
+
fields = metadata.split()
|
|
1285
|
+
if (
|
|
1286
|
+
len(fields) != 5
|
|
1287
|
+
or not fields[0].startswith(":")
|
|
1288
|
+
or not path
|
|
1289
|
+
or path in delta
|
|
1290
|
+
):
|
|
1291
|
+
raise IsolationError("could not parse tree delta")
|
|
1292
|
+
mode = fields[1]
|
|
1293
|
+
oid = fields[3]
|
|
1294
|
+
delta[path] = None if mode == "000000" else (mode, oid)
|
|
1295
|
+
return delta
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _frontmatter_key(line: str) -> Optional[str]:
|
|
1299
|
+
match = FIELD_PATTERN.match(line)
|
|
1300
|
+
return match.group("key") if match is not None else None
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _single_parent(repo: Path, sha: str) -> tuple[Optional[str], Optional[str]]:
|
|
1304
|
+
parents = git_output(repo, "show", "-s", "--format=%P", sha).split()
|
|
1305
|
+
if len(parents) != 1:
|
|
1306
|
+
return None, "task commit must have exactly one parent"
|
|
1307
|
+
return parents[0], None
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _immutable_frontmatter(lines: Sequence[str]) -> list[str]:
|
|
1311
|
+
return [line for line in lines if _frontmatter_key(line) not in LANDING_MUTABLE_FIELDS]
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _landing_transition_error(
|
|
1315
|
+
before_head: Sequence[str],
|
|
1316
|
+
after_head: Sequence[str],
|
|
1317
|
+
before_fields: Dict[str, object],
|
|
1318
|
+
after_fields: Dict[str, object],
|
|
1319
|
+
base: str,
|
|
1320
|
+
) -> Optional[str]:
|
|
1321
|
+
for key in LANDING_MUTABLE_FIELDS:
|
|
1322
|
+
if sum(_frontmatter_key(line) == key for line in before_head) != 1:
|
|
1323
|
+
return f"base task frontmatter must contain one {key} field"
|
|
1324
|
+
if sum(_frontmatter_key(line) == key for line in after_head) != 1:
|
|
1325
|
+
return f"landed task frontmatter must contain one {key} field"
|
|
1326
|
+
if _immutable_frontmatter(after_head) != _immutable_frontmatter(before_head):
|
|
1327
|
+
return "task frontmatter changes fields outside landing metadata"
|
|
1328
|
+
if before_fields.get("status") == "done":
|
|
1329
|
+
return "base task is already done"
|
|
1330
|
+
expected = {**LANDED_FIELDS, "base": base}
|
|
1331
|
+
for key, value in expected.items():
|
|
1332
|
+
if after_fields.get(key) != value:
|
|
1333
|
+
return f"landed task frontmatter has invalid {key}"
|
|
1334
|
+
agent = after_fields.get("agent")
|
|
1335
|
+
if not isinstance(agent, str) or agent in {"", "null"}:
|
|
1336
|
+
return "landed task frontmatter has no assigned agent"
|
|
1337
|
+
return None
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _declared_task_paths(
|
|
1341
|
+
fields: Dict[str, object],
|
|
1342
|
+
) -> tuple[Optional[Set[str]], Optional[str]]:
|
|
1343
|
+
raw_files = fields.get("files")
|
|
1344
|
+
if not isinstance(raw_files, list) or not all(
|
|
1345
|
+
isinstance(item, str) for item in raw_files
|
|
1346
|
+
):
|
|
1347
|
+
return None, "base task frontmatter has invalid files"
|
|
1348
|
+
try:
|
|
1349
|
+
return {relative_posix(item) for item in raw_files}, None
|
|
1350
|
+
except IsolationError as error:
|
|
1351
|
+
return None, str(error)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _regular_blob_oid(repo: Path, revision: str, path: str) -> str:
|
|
1355
|
+
entries = git_output(repo, "ls-tree", revision, "--", path).splitlines()
|
|
1356
|
+
if len(entries) != 1:
|
|
1357
|
+
raise IsolationError(f"task path is not a regular file in {revision}: {path}")
|
|
1358
|
+
parts = entries[0].split(maxsplit=3)
|
|
1359
|
+
if len(parts) != 4 or parts[0] not in {"100644", "100755"} or parts[1] != "blob":
|
|
1360
|
+
raise IsolationError(f"task path is not a regular file in {revision}: {path}")
|
|
1361
|
+
return parts[2]
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _base_task_contract(
|
|
1365
|
+
repo: Path, base: str, task_file: str
|
|
1366
|
+
) -> tuple[list[str], str, Dict[str, object], Set[str]]:
|
|
1367
|
+
_regular_blob_oid(repo, base, task_file)
|
|
1368
|
+
text = git_text(repo, "show", f"{base}:{task_file}")
|
|
1369
|
+
head, body = split_frontmatter(text)
|
|
1370
|
+
fields, error = task_frontmatter(text)
|
|
1371
|
+
if error or fields is None:
|
|
1372
|
+
raise IsolationError(error or "base task frontmatter is unreadable")
|
|
1373
|
+
files, files_error = _declared_task_paths(fields)
|
|
1374
|
+
if files_error or files is None:
|
|
1375
|
+
raise IsolationError(files_error or "base task files are unreadable")
|
|
1376
|
+
return head, body, fields, {task_file, *files}
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _clean_task_text(repo: Path, path: str, text: str) -> str:
|
|
1380
|
+
oid = _clean_blob_oid(repo, path, text, write=True)
|
|
1381
|
+
return git_text(repo, "cat-file", "blob", oid)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _normalize_newlines(text: str) -> str:
|
|
1385
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _landing_state(
|
|
1389
|
+
repo: Path,
|
|
1390
|
+
base: str,
|
|
1391
|
+
task_file: str,
|
|
1392
|
+
after_text: str,
|
|
1393
|
+
subject: str,
|
|
1394
|
+
allowed: Optional[Set[str]],
|
|
1395
|
+
*,
|
|
1396
|
+
clean_after: bool = False,
|
|
1397
|
+
) -> tuple[Optional[Set[str]], Optional[str]]:
|
|
1398
|
+
try:
|
|
1399
|
+
before_head, before_body, before_fields, contract_paths = _base_task_contract(
|
|
1400
|
+
repo, base, task_file
|
|
1401
|
+
)
|
|
1402
|
+
if clean_after:
|
|
1403
|
+
after_text = _clean_task_text(repo, task_file, after_text)
|
|
1404
|
+
after_head, after_body = split_frontmatter(after_text)
|
|
1405
|
+
except IsolationError as error:
|
|
1406
|
+
return None, str(error)
|
|
1407
|
+
after_fields, after_error = task_frontmatter(after_text)
|
|
1408
|
+
if after_error or after_fields is None:
|
|
1409
|
+
return contract_paths, after_error or "landed task frontmatter is unreadable"
|
|
1410
|
+
try:
|
|
1411
|
+
expected_subject = task_commit_subject(
|
|
1412
|
+
str(before_fields.get("id", "")), str(before_fields.get("title", ""))
|
|
1413
|
+
)
|
|
1414
|
+
except ValueError as error:
|
|
1415
|
+
return contract_paths, str(error)
|
|
1416
|
+
if subject != expected_subject:
|
|
1417
|
+
return (
|
|
1418
|
+
contract_paths,
|
|
1419
|
+
f"source commit subject is {subject!r}, expected {expected_subject!r}",
|
|
1420
|
+
)
|
|
1421
|
+
if allowed is not None and allowed != contract_paths:
|
|
1422
|
+
missing = sorted(contract_paths - allowed)
|
|
1423
|
+
extra = sorted(allowed - contract_paths)
|
|
1424
|
+
details = []
|
|
1425
|
+
if missing:
|
|
1426
|
+
details.append("missing " + ", ".join(missing))
|
|
1427
|
+
if extra:
|
|
1428
|
+
details.append("extra " + ", ".join(extra))
|
|
1429
|
+
return (
|
|
1430
|
+
contract_paths,
|
|
1431
|
+
"landing allow-list differs from the base task: " + "; ".join(details),
|
|
1432
|
+
)
|
|
1433
|
+
transition_error = _landing_transition_error(
|
|
1434
|
+
before_head, after_head, before_fields, after_fields, base
|
|
1435
|
+
)
|
|
1436
|
+
if transition_error:
|
|
1437
|
+
return contract_paths, transition_error
|
|
1438
|
+
if not _normalize_newlines(after_body).startswith(
|
|
1439
|
+
_normalize_newlines(before_body)
|
|
1440
|
+
):
|
|
1441
|
+
return contract_paths, "task file body is not append-only"
|
|
1442
|
+
return contract_paths, None
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def _landing_retry_error(
|
|
1446
|
+
repo: Path, base: str, task_file: str, task_text: str, subject: str
|
|
1447
|
+
) -> Optional[str]:
|
|
1448
|
+
contract_paths, error = _landing_state(
|
|
1449
|
+
repo, base, task_file, task_text, subject, None
|
|
1450
|
+
)
|
|
1451
|
+
if error or contract_paths is None:
|
|
1452
|
+
return error or "base task contract is unreadable"
|
|
1453
|
+
pending = uncommitted_paths(repo)
|
|
1454
|
+
if task_file not in pending:
|
|
1455
|
+
return "interrupted landing does not retain the task change"
|
|
1456
|
+
try:
|
|
1457
|
+
validate_allowed_changes(repo, base, pending, contract_paths)
|
|
1458
|
+
except IsolationError as validation_error:
|
|
1459
|
+
return str(validation_error)
|
|
1460
|
+
return None
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _prove_task_commit(
|
|
1464
|
+
repo: Path,
|
|
1465
|
+
sha: str,
|
|
1466
|
+
body: str,
|
|
1467
|
+
task_file: str,
|
|
1468
|
+
expected_base: str,
|
|
1469
|
+
current_task_text: Optional[str],
|
|
1470
|
+
allowed: Optional[Set[str]] = None,
|
|
1471
|
+
compare_head: bool = True,
|
|
1472
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
1473
|
+
"""Return (base, None) when `sha` is this task's landing commit, else (None, reason)."""
|
|
1474
|
+
base = next((line[6:].strip() for line in body.splitlines() if line.startswith("Base: ")), None)
|
|
1475
|
+
if not base:
|
|
1476
|
+
return None, "body has no Base: field"
|
|
1477
|
+
if base != expected_base:
|
|
1478
|
+
return None, "body Base: differs from the task's recorded base"
|
|
1479
|
+
parent, parent_error = _single_parent(repo, sha)
|
|
1480
|
+
if parent_error or parent is None:
|
|
1481
|
+
return None, parent_error or "task commit parent is unreadable"
|
|
1482
|
+
if run_git(repo, "merge-base", "--is-ancestor", base, parent).returncode != 0:
|
|
1483
|
+
return None, "commit does not descend from its Base:"
|
|
1484
|
+
try:
|
|
1485
|
+
after_oid = _regular_blob_oid(repo, sha, task_file)
|
|
1486
|
+
after_text = git_text(repo, "show", f"{sha}:{task_file}")
|
|
1487
|
+
except IsolationError as error:
|
|
1488
|
+
return None, str(error)
|
|
1489
|
+
subject = git_output(repo, "log", "-1", "--format=%s", sha)
|
|
1490
|
+
contract_paths, landing_error = _landing_state(
|
|
1491
|
+
repo, base, task_file, after_text, subject, allowed
|
|
1492
|
+
)
|
|
1493
|
+
if contract_paths is None:
|
|
1494
|
+
return None, landing_error or "base task contract is unreadable"
|
|
1495
|
+
changed = set(
|
|
1496
|
+
git_output(
|
|
1497
|
+
repo, "diff-tree", "--no-commit-id", "--name-only", "-r", sha
|
|
1498
|
+
).splitlines()
|
|
1499
|
+
)
|
|
1500
|
+
if task_file not in changed:
|
|
1501
|
+
return None, "commit does not touch the task file"
|
|
1502
|
+
stray = sorted(changed - contract_paths)
|
|
1503
|
+
if stray:
|
|
1504
|
+
return None, f"commit touches undeclared paths: {', '.join(stray)}"
|
|
1505
|
+
if landing_error:
|
|
1506
|
+
return None, landing_error
|
|
1507
|
+
if body != task_commit_body(task_file, changed, base).strip():
|
|
1508
|
+
return None, "body Task:/Files: fields do not match the changed paths"
|
|
1509
|
+
if current_task_text is not None:
|
|
1510
|
+
try:
|
|
1511
|
+
current_oid = _clean_blob_oid(
|
|
1512
|
+
repo, task_file, current_task_text, write=False
|
|
1513
|
+
)
|
|
1514
|
+
current_head_oid = (
|
|
1515
|
+
_regular_blob_oid(repo, "HEAD", task_file)
|
|
1516
|
+
if compare_head
|
|
1517
|
+
else after_oid
|
|
1518
|
+
)
|
|
1519
|
+
except IsolationError as error:
|
|
1520
|
+
return None, str(error)
|
|
1521
|
+
if after_oid != current_head_oid or after_oid != current_oid:
|
|
1522
|
+
return None, "current done task differs from its landing commit"
|
|
1523
|
+
return base, None
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _landing_commit_proof_error(
|
|
1527
|
+
repo: Path,
|
|
1528
|
+
commit: str,
|
|
1529
|
+
expected_parent: str,
|
|
1530
|
+
task_file: str,
|
|
1531
|
+
expected_base: str,
|
|
1532
|
+
allowed: Set[str],
|
|
1533
|
+
expected_delta: Dict[str, Optional[tuple[str, str]]],
|
|
1534
|
+
) -> Optional[str]:
|
|
1535
|
+
parent, parent_error = _single_parent(repo, commit)
|
|
1536
|
+
if parent_error or parent != expected_parent:
|
|
1537
|
+
return parent_error or "landing commit parent differs from the pre-landing HEAD"
|
|
1538
|
+
try:
|
|
1539
|
+
current_task_text, _ = _read_task_text(repo / task_file)
|
|
1540
|
+
body = git_output(repo, "log", "-1", "--format=%b", commit)
|
|
1541
|
+
except IsolationError as error:
|
|
1542
|
+
return str(error)
|
|
1543
|
+
_, proof_error = _prove_task_commit(
|
|
1544
|
+
repo,
|
|
1545
|
+
commit,
|
|
1546
|
+
body,
|
|
1547
|
+
task_file,
|
|
1548
|
+
expected_base,
|
|
1549
|
+
current_task_text,
|
|
1550
|
+
allowed,
|
|
1551
|
+
)
|
|
1552
|
+
if proof_error:
|
|
1553
|
+
return proof_error
|
|
1554
|
+
try:
|
|
1555
|
+
actual_delta = _tree_delta(repo, expected_parent, commit)
|
|
1556
|
+
except IsolationError as error:
|
|
1557
|
+
return str(error)
|
|
1558
|
+
if actual_delta != expected_delta:
|
|
1559
|
+
return "landing commit tree differs from the verified changes"
|
|
1560
|
+
pending = uncommitted_paths(repo) - BOOKKEEPING_PATHS
|
|
1561
|
+
if pending:
|
|
1562
|
+
return "landing left uncommitted paths: " + ", ".join(sorted(pending))
|
|
1563
|
+
return None
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _task_branch_proof_error(
|
|
1567
|
+
repo: Path, branch: str, base: str, landed_commit: str
|
|
1568
|
+
) -> Optional[str]:
|
|
1569
|
+
branch_commit = require_commit(repo, branch)
|
|
1570
|
+
branch_parent, branch_parent_error = _single_parent(repo, branch_commit)
|
|
1571
|
+
if branch_parent_error or branch_parent != base:
|
|
1572
|
+
return branch_parent_error or "retained task branch parent differs from Base:"
|
|
1573
|
+
landed_parent, landed_parent_error = _single_parent(repo, landed_commit)
|
|
1574
|
+
if landed_parent_error or landed_parent is None:
|
|
1575
|
+
return landed_parent_error or "landing commit parent is unreadable"
|
|
1576
|
+
if run_git(repo, "merge-base", "--is-ancestor", base, landed_parent).returncode != 0:
|
|
1577
|
+
return "landing commit does not descend from Base:"
|
|
1578
|
+
branch_subject = git_output(repo, "log", "-1", "--format=%s", branch_commit)
|
|
1579
|
+
landed_subject = git_output(repo, "log", "-1", "--format=%s", landed_commit)
|
|
1580
|
+
branch_body = git_output(repo, "log", "-1", "--format=%b", branch_commit)
|
|
1581
|
+
landed_body = git_output(repo, "log", "-1", "--format=%b", landed_commit)
|
|
1582
|
+
if branch_subject != landed_subject or branch_body != landed_body:
|
|
1583
|
+
return "retained task branch message differs from the landing commit"
|
|
1584
|
+
try:
|
|
1585
|
+
branch_delta = _tree_delta(repo, base, branch_commit)
|
|
1586
|
+
landed_delta = _tree_delta(repo, landed_parent, landed_commit)
|
|
1587
|
+
except IsolationError as error:
|
|
1588
|
+
return str(error)
|
|
1589
|
+
if branch_delta != landed_delta:
|
|
1590
|
+
return "retained task branch differs from the landing commit"
|
|
1591
|
+
return None
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _worktree_report(
|
|
1595
|
+
primary: Path, path: Path, expected_branch: str
|
|
1596
|
+
) -> tuple[Optional[Dict[str, object]], Optional[str]]:
|
|
1597
|
+
if not os.path.lexists(path):
|
|
1598
|
+
return None, None
|
|
1599
|
+
if path.is_symlink() or not path.is_dir() or path.resolve() != path:
|
|
1600
|
+
return None, f"sidecar path is not an owned worktree: {path}"
|
|
1601
|
+
try:
|
|
1602
|
+
if worktree_root(path) != path:
|
|
1603
|
+
return None, f"sidecar path is not its Git root: {path}"
|
|
1604
|
+
if common_git_dir(path) != common_git_dir(primary):
|
|
1605
|
+
return None, f"sidecar belongs to another repository: {path}"
|
|
1606
|
+
branch = require_attached(path)
|
|
1607
|
+
if branch != expected_branch:
|
|
1608
|
+
return None, f"sidecar is on {branch}, expected {expected_branch}"
|
|
1609
|
+
return {
|
|
1610
|
+
"path": str(path),
|
|
1611
|
+
"branch": branch,
|
|
1612
|
+
"clean": not git_output(
|
|
1613
|
+
path, "status", "--porcelain", "--untracked-files=all"
|
|
1614
|
+
),
|
|
1615
|
+
}, None
|
|
1616
|
+
except IsolationError as error:
|
|
1617
|
+
return None, f"invalid sidecar worktree {path}: {error}"
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def _retained_task_contract_error(
|
|
1621
|
+
current_text: str, isolate_text: str
|
|
1622
|
+
) -> Optional[str]:
|
|
1623
|
+
try:
|
|
1624
|
+
current_head, current_body = split_frontmatter(current_text)
|
|
1625
|
+
isolate_head, isolate_body = split_frontmatter(isolate_text)
|
|
1626
|
+
except IsolationError as error:
|
|
1627
|
+
return str(error)
|
|
1628
|
+
if _immutable_frontmatter(isolate_head) != _immutable_frontmatter(current_head):
|
|
1629
|
+
return "retained task contract differs from the current task"
|
|
1630
|
+
if not _normalize_newlines(isolate_body).startswith(
|
|
1631
|
+
_normalize_newlines(current_body)
|
|
1632
|
+
):
|
|
1633
|
+
return "retained task body does not extend the current task"
|
|
1634
|
+
return None
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _resume_task_error(
|
|
1638
|
+
current_text: str,
|
|
1639
|
+
isolate_text: str,
|
|
1640
|
+
task_id: str,
|
|
1641
|
+
title: str,
|
|
1642
|
+
base: str,
|
|
1643
|
+
worktree: str,
|
|
1644
|
+
task_branch: str,
|
|
1645
|
+
) -> Optional[str]:
|
|
1646
|
+
isolate_head, _ = split_frontmatter(isolate_text)
|
|
1647
|
+
isolate_fields, fields_error = task_frontmatter(isolate_text)
|
|
1648
|
+
if fields_error or isolate_fields is None:
|
|
1649
|
+
return fields_error or "retained task frontmatter is unreadable"
|
|
1650
|
+
expected = {
|
|
1651
|
+
"id": task_id,
|
|
1652
|
+
"title": title,
|
|
1653
|
+
"status": "in-progress",
|
|
1654
|
+
"base": base,
|
|
1655
|
+
"task_branch": task_branch,
|
|
1656
|
+
}
|
|
1657
|
+
for key, value in expected.items():
|
|
1658
|
+
if (
|
|
1659
|
+
sum(_frontmatter_key(line) == key for line in isolate_head) != 1
|
|
1660
|
+
or isolate_fields.get(key) != value
|
|
1661
|
+
):
|
|
1662
|
+
return f"retained task has invalid {key}"
|
|
1663
|
+
recorded_worktree = isolate_fields.get("worktree")
|
|
1664
|
+
if (
|
|
1665
|
+
sum(_frontmatter_key(line) == "worktree" for line in isolate_head) != 1
|
|
1666
|
+
or not isinstance(recorded_worktree, str)
|
|
1667
|
+
or Path(recorded_worktree).resolve() != Path(worktree).resolve()
|
|
1668
|
+
):
|
|
1669
|
+
return "retained task has invalid worktree"
|
|
1670
|
+
agent = isolate_fields.get("agent")
|
|
1671
|
+
if (
|
|
1672
|
+
sum(_frontmatter_key(line) == "agent" for line in isolate_head) != 1
|
|
1673
|
+
or not isinstance(agent, str)
|
|
1674
|
+
or agent in {"", "null"}
|
|
1675
|
+
):
|
|
1676
|
+
return "retained task has no assigned agent"
|
|
1677
|
+
return _retained_task_contract_error(current_text, isolate_text)
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _recover_task(
|
|
1681
|
+
primary: Path,
|
|
1682
|
+
path: Path,
|
|
1683
|
+
history: Dict[str, list[tuple[str, str]]],
|
|
1684
|
+
branches: Set[str],
|
|
1685
|
+
head: str,
|
|
1686
|
+
bound: str,
|
|
1687
|
+
canonical_task_file: Optional[str] = None,
|
|
1688
|
+
) -> Dict[str, object]:
|
|
1689
|
+
task_file = canonical_task_file or relative_posix(str(path.relative_to(primary)))
|
|
1690
|
+
report: Dict[str, object] = {"task": task_file}
|
|
1691
|
+
|
|
1692
|
+
def result(verdict: str, **extra: object) -> Dict[str, object]:
|
|
1693
|
+
return {**report, "verdict": verdict, **extra}
|
|
1694
|
+
|
|
1695
|
+
try:
|
|
1696
|
+
task_text, _ = _read_task_text(path)
|
|
1697
|
+
except IsolationError as error:
|
|
1698
|
+
return result("block", reason=str(error))
|
|
1699
|
+
fields, error = task_frontmatter(task_text)
|
|
1700
|
+
if error or fields is None:
|
|
1701
|
+
return result("block", reason=error or "unreadable frontmatter")
|
|
1702
|
+
task_id, status, title = (str(fields.get(key, "")) for key in ("id", "status", "title"))
|
|
1703
|
+
report.update({"task_id": task_id, "status": status})
|
|
1704
|
+
try:
|
|
1705
|
+
validate_task_id(task_id)
|
|
1706
|
+
subject = task_commit_subject(task_id, title)
|
|
1707
|
+
except ValueError as error:
|
|
1708
|
+
return result("block", reason=str(error))
|
|
1709
|
+
except IsolationError as exc:
|
|
1710
|
+
return result("block", reason=str(exc))
|
|
1711
|
+
task_branch = task_branch_name(task_id)
|
|
1712
|
+
branch_exists = task_branch in branches
|
|
1713
|
+
worktree, worktree_error = _worktree_report(
|
|
1714
|
+
primary, sidecar_root(primary, "task", task_id), task_branch
|
|
1715
|
+
)
|
|
1716
|
+
report["worktree"] = worktree
|
|
1717
|
+
report["task_branch"] = task_branch if branch_exists else None
|
|
1718
|
+
report["rejected"] = []
|
|
1719
|
+
|
|
1720
|
+
if worktree_error:
|
|
1721
|
+
return result("block", reason=worktree_error)
|
|
1722
|
+
|
|
1723
|
+
valid_statuses = {"pending", "in-progress", "done", "failed", "blocked"}
|
|
1724
|
+
if status not in valid_statuses:
|
|
1725
|
+
return result("block", reason=f"unknown task status: {status!r}")
|
|
1726
|
+
|
|
1727
|
+
retained = branch_exists or report["worktree"] is not None
|
|
1728
|
+
|
|
1729
|
+
if status in {"pending", "in-progress"}:
|
|
1730
|
+
if status == "pending" and not retained:
|
|
1731
|
+
return result("none", reason="pending task has no retained isolate")
|
|
1732
|
+
if retained:
|
|
1733
|
+
if not branch_exists:
|
|
1734
|
+
return result("block", reason="sidecar worktree has no retained branch")
|
|
1735
|
+
try:
|
|
1736
|
+
base = git_output(primary, "merge-base", head, task_branch)
|
|
1737
|
+
branch_head = require_commit(primary, task_branch)
|
|
1738
|
+
except IsolationError as error:
|
|
1739
|
+
return result("block", reason=f"{status} task has invalid base: {error}")
|
|
1740
|
+
if worktree is None:
|
|
1741
|
+
if branch_head != base:
|
|
1742
|
+
return result("block", reason="retained task branch advanced past its base")
|
|
1743
|
+
return result("resume", base=base)
|
|
1744
|
+
sidecar = Path(str(worktree["path"]))
|
|
1745
|
+
isolate_path = sidecar / task_file
|
|
1746
|
+
try:
|
|
1747
|
+
isolate_text, _ = _read_task_text(isolate_path)
|
|
1748
|
+
except IsolationError as error:
|
|
1749
|
+
return result("block", reason=str(error))
|
|
1750
|
+
isolate_fields, isolate_error = task_frontmatter(isolate_text)
|
|
1751
|
+
if isolate_error or isolate_fields is None:
|
|
1752
|
+
return result(
|
|
1753
|
+
"block",
|
|
1754
|
+
reason=isolate_error or "retained task frontmatter is unreadable",
|
|
1755
|
+
)
|
|
1756
|
+
if status == "pending" and branch_head == base and bool(worktree["clean"]):
|
|
1757
|
+
try:
|
|
1758
|
+
base_oid = _regular_blob_oid(primary, base, task_file)
|
|
1759
|
+
current_oid = _clean_blob_oid(
|
|
1760
|
+
primary, task_file, task_text, write=False
|
|
1761
|
+
)
|
|
1762
|
+
isolate_oid = _clean_blob_oid(
|
|
1763
|
+
sidecar, task_file, isolate_text, write=False
|
|
1764
|
+
)
|
|
1765
|
+
except IsolationError as error:
|
|
1766
|
+
return result("block", reason=str(error))
|
|
1767
|
+
if current_oid == base_oid and isolate_oid == base_oid:
|
|
1768
|
+
return result(
|
|
1769
|
+
"resume",
|
|
1770
|
+
reason="task isolation was created; retry dispatch",
|
|
1771
|
+
dispatch_retry=True,
|
|
1772
|
+
base=base,
|
|
1773
|
+
)
|
|
1774
|
+
if isolate_fields.get("status") == "done":
|
|
1775
|
+
contract_error = _retained_task_contract_error(
|
|
1776
|
+
task_text, isolate_text
|
|
1777
|
+
)
|
|
1778
|
+
if contract_error:
|
|
1779
|
+
return result("block", reason=contract_error)
|
|
1780
|
+
if branch_head == base:
|
|
1781
|
+
retry_error = _landing_retry_error(
|
|
1782
|
+
sidecar,
|
|
1783
|
+
base,
|
|
1784
|
+
task_file,
|
|
1785
|
+
isolate_text,
|
|
1786
|
+
subject,
|
|
1787
|
+
)
|
|
1788
|
+
source_commit = None
|
|
1789
|
+
else:
|
|
1790
|
+
source_commit = branch_head
|
|
1791
|
+
try:
|
|
1792
|
+
parent, parent_error = _single_parent(primary, branch_head)
|
|
1793
|
+
if parent_error or parent != base:
|
|
1794
|
+
retry_error = parent_error or (
|
|
1795
|
+
"retained task commit parent differs from its base"
|
|
1796
|
+
)
|
|
1797
|
+
elif not bool(worktree["clean"]):
|
|
1798
|
+
retry_error = (
|
|
1799
|
+
"retained task commit has uncommitted changes"
|
|
1800
|
+
)
|
|
1801
|
+
else:
|
|
1802
|
+
body = git_output(
|
|
1803
|
+
primary, "log", "-1", "--format=%b", branch_head
|
|
1804
|
+
)
|
|
1805
|
+
_, retry_error = _prove_task_commit(
|
|
1806
|
+
primary,
|
|
1807
|
+
branch_head,
|
|
1808
|
+
body,
|
|
1809
|
+
task_file,
|
|
1810
|
+
base,
|
|
1811
|
+
None,
|
|
1812
|
+
)
|
|
1813
|
+
if retry_error is None:
|
|
1814
|
+
committed_oid = _regular_blob_oid(
|
|
1815
|
+
primary, branch_head, task_file
|
|
1816
|
+
)
|
|
1817
|
+
checkout_oid = _clean_blob_oid(
|
|
1818
|
+
sidecar,
|
|
1819
|
+
task_file,
|
|
1820
|
+
isolate_text,
|
|
1821
|
+
write=False,
|
|
1822
|
+
)
|
|
1823
|
+
if committed_oid != checkout_oid:
|
|
1824
|
+
retry_error = (
|
|
1825
|
+
"retained task differs from its commit"
|
|
1826
|
+
)
|
|
1827
|
+
except IsolationError as error:
|
|
1828
|
+
retry_error = str(error)
|
|
1829
|
+
if retry_error:
|
|
1830
|
+
return result("block", reason=retry_error)
|
|
1831
|
+
return result(
|
|
1832
|
+
"resume",
|
|
1833
|
+
reason="landing was interrupted; retry land without redispatch",
|
|
1834
|
+
landing_retry=True,
|
|
1835
|
+
source_commit=source_commit,
|
|
1836
|
+
base=base,
|
|
1837
|
+
)
|
|
1838
|
+
if branch_head != base:
|
|
1839
|
+
return result("block", reason="retained task branch advanced past its base")
|
|
1840
|
+
resume_error = _resume_task_error(
|
|
1841
|
+
task_text,
|
|
1842
|
+
isolate_text,
|
|
1843
|
+
task_id,
|
|
1844
|
+
title,
|
|
1845
|
+
base,
|
|
1846
|
+
str(sidecar),
|
|
1847
|
+
task_branch,
|
|
1848
|
+
)
|
|
1849
|
+
else:
|
|
1850
|
+
try:
|
|
1851
|
+
base = require_commit(
|
|
1852
|
+
primary, require_full_sha(str(fields.get("base", "")))
|
|
1853
|
+
)
|
|
1854
|
+
except IsolationError as error:
|
|
1855
|
+
return result("block", reason=f"{status} task has invalid base: {error}")
|
|
1856
|
+
if head != base:
|
|
1857
|
+
return result("block", reason=f"{status} serial task advanced past its base")
|
|
1858
|
+
primary_worktree, primary_error = _worktree_report(primary, primary, bound)
|
|
1859
|
+
if primary_error:
|
|
1860
|
+
return result("block", reason=primary_error)
|
|
1861
|
+
report["worktree"] = primary_worktree
|
|
1862
|
+
resume_error = _resume_task_error(
|
|
1863
|
+
task_text,
|
|
1864
|
+
task_text,
|
|
1865
|
+
task_id,
|
|
1866
|
+
title,
|
|
1867
|
+
base,
|
|
1868
|
+
str(primary),
|
|
1869
|
+
"null",
|
|
1870
|
+
)
|
|
1871
|
+
if resume_error:
|
|
1872
|
+
return result("block", reason=resume_error)
|
|
1873
|
+
return result("resume", base=base)
|
|
1874
|
+
|
|
1875
|
+
if status in {"failed", "blocked"}:
|
|
1876
|
+
recorded_worktree = str(fields.get("worktree", ""))
|
|
1877
|
+
serial_retained = not retained and recorded_worktree == str(primary)
|
|
1878
|
+
if not retained and not serial_retained:
|
|
1879
|
+
return result("none", reason=f"{status} task has no retained isolate")
|
|
1880
|
+
try:
|
|
1881
|
+
base = require_commit(
|
|
1882
|
+
primary, require_full_sha(str(fields.get("base", "")))
|
|
1883
|
+
)
|
|
1884
|
+
except IsolationError as error:
|
|
1885
|
+
return result("block", reason=f"{status} task has invalid base: {error}")
|
|
1886
|
+
if retained:
|
|
1887
|
+
expected_worktree = str(sidecar_root(primary, "task", task_id))
|
|
1888
|
+
if str(fields.get("worktree", "")) != expected_worktree:
|
|
1889
|
+
return result("block", reason=f"{status} task records another worktree")
|
|
1890
|
+
if str(fields.get("task_branch", "")) != task_branch:
|
|
1891
|
+
return result("block", reason=f"{status} task records another branch")
|
|
1892
|
+
if branch_exists and git_output(primary, "merge-base", head, task_branch) != base:
|
|
1893
|
+
return result(
|
|
1894
|
+
"block", reason=f"{status} task branch differs from its recorded base"
|
|
1895
|
+
)
|
|
1896
|
+
if serial_retained:
|
|
1897
|
+
if str(fields.get("task_branch", "")) != "null":
|
|
1898
|
+
return result("block", reason=f"{status} serial task records a task branch")
|
|
1899
|
+
if run_git(primary, "merge-base", "--is-ancestor", base, head).returncode != 0:
|
|
1900
|
+
return result("block", reason=f"{status} serial task base is not an ancestor")
|
|
1901
|
+
primary_worktree, primary_error = _worktree_report(primary, primary, bound)
|
|
1902
|
+
if primary_error:
|
|
1903
|
+
return result("block", reason=primary_error)
|
|
1904
|
+
report["worktree"] = primary_worktree
|
|
1905
|
+
return result("reconcile", base=base)
|
|
1906
|
+
|
|
1907
|
+
recorded_base = str(fields.get("base", ""))
|
|
1908
|
+
try:
|
|
1909
|
+
recorded_base = require_commit(primary, require_full_sha(recorded_base))
|
|
1910
|
+
except IsolationError as error:
|
|
1911
|
+
return result("block", reason=f"done task has invalid base: {error}")
|
|
1912
|
+
|
|
1913
|
+
proven, rejected = [], []
|
|
1914
|
+
candidates = history.get(subject, [])
|
|
1915
|
+
for sha, body in candidates:
|
|
1916
|
+
base, why = _prove_task_commit(
|
|
1917
|
+
primary,
|
|
1918
|
+
sha,
|
|
1919
|
+
body,
|
|
1920
|
+
task_file,
|
|
1921
|
+
recorded_base,
|
|
1922
|
+
task_text,
|
|
1923
|
+
compare_head=canonical_task_file is None,
|
|
1924
|
+
)
|
|
1925
|
+
if why is None:
|
|
1926
|
+
proven.append((sha, base))
|
|
1927
|
+
else:
|
|
1928
|
+
rejected.append({"commit": sha, "reason": why})
|
|
1929
|
+
report["rejected"] = rejected
|
|
1930
|
+
if len(proven) > 1:
|
|
1931
|
+
return result("block", reason="multiple proven landing commits: " + ", ".join(sha for sha, _ in proven))
|
|
1932
|
+
attested = []
|
|
1933
|
+
for sha, body in history.get(attest_commit_subject(task_id, title), []):
|
|
1934
|
+
base, why = _prove_attest_commit(
|
|
1935
|
+
primary, sha, body, task_file, task_text, compare_head=canonical_task_file is None
|
|
1936
|
+
)
|
|
1937
|
+
if why is None:
|
|
1938
|
+
attested.append((sha, base))
|
|
1939
|
+
else:
|
|
1940
|
+
rejected.append({"commit": sha, "reason": why})
|
|
1941
|
+
if proven and attested:
|
|
1942
|
+
return result("block", reason="task has both a landing commit and an attestation")
|
|
1943
|
+
if len(attested) > 1:
|
|
1944
|
+
return result("block", reason="multiple attestations: " + ", ".join(sha for sha, _ in attested))
|
|
1945
|
+
if attested:
|
|
1946
|
+
commit, base = attested[0]
|
|
1947
|
+
if worktree is not None and not bool(worktree["clean"]):
|
|
1948
|
+
return result("block", reason="attested task retains a dirty worktree", commit=commit, base=base)
|
|
1949
|
+
return result("attested", commit=commit, base=base)
|
|
1950
|
+
if proven:
|
|
1951
|
+
commit, base = proven[0]
|
|
1952
|
+
if branch_exists:
|
|
1953
|
+
branch_error = _task_branch_proof_error(primary, task_branch, base, commit)
|
|
1954
|
+
if branch_error:
|
|
1955
|
+
return result("block", reason=branch_error)
|
|
1956
|
+
if worktree is not None and not bool(worktree["clean"]):
|
|
1957
|
+
return result(
|
|
1958
|
+
"block",
|
|
1959
|
+
reason="proven task retains a dirty worktree",
|
|
1960
|
+
commit=commit,
|
|
1961
|
+
base=base,
|
|
1962
|
+
)
|
|
1963
|
+
return result("recovered", commit=commit, base=base)
|
|
1964
|
+
if not retained and len(candidates) == 1:
|
|
1965
|
+
landing = candidates[0][0]
|
|
1966
|
+
project_root = path.parent.parent # .project or .project/archive/<n>
|
|
1967
|
+
try:
|
|
1968
|
+
rebased_base = _prove_rebase_adoption(
|
|
1969
|
+
primary, project_root, task_file, head, landing, task_text
|
|
1970
|
+
)
|
|
1971
|
+
except IsolationError as error:
|
|
1972
|
+
return result("block", reason=f"rebase adoption receipt is invalid: {error}")
|
|
1973
|
+
if rebased_base is not None:
|
|
1974
|
+
return result(
|
|
1975
|
+
"attested",
|
|
1976
|
+
commit=landing,
|
|
1977
|
+
base=rebased_base,
|
|
1978
|
+
provenance="owner-authorized-rebase",
|
|
1979
|
+
)
|
|
1980
|
+
if not retained and head == recorded_base:
|
|
1981
|
+
retry_error = _landing_retry_error(
|
|
1982
|
+
primary, recorded_base, task_file, task_text, subject
|
|
1983
|
+
)
|
|
1984
|
+
if retry_error is None:
|
|
1985
|
+
primary_worktree, primary_error = _worktree_report(
|
|
1986
|
+
primary, primary, bound
|
|
1987
|
+
)
|
|
1988
|
+
if primary_error:
|
|
1989
|
+
return result("block", reason=primary_error)
|
|
1990
|
+
report["worktree"] = primary_worktree
|
|
1991
|
+
return result(
|
|
1992
|
+
"resume",
|
|
1993
|
+
reason="landing was interrupted; retry land without redispatch",
|
|
1994
|
+
landing_retry=True,
|
|
1995
|
+
source_commit=None,
|
|
1996
|
+
base=recorded_base,
|
|
1997
|
+
)
|
|
1998
|
+
return result("block", reason="done but no landing commit proves it")
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _recovery_task_inventory(
|
|
2002
|
+
primary: Path, tasks_dir: Path, head: str
|
|
2003
|
+
) -> tuple[list[Path], Optional[str]]:
|
|
2004
|
+
requested = tasks_dir if tasks_dir.is_absolute() else primary / tasks_dir
|
|
2005
|
+
if os.path.lexists(requested) and not requested.is_dir():
|
|
2006
|
+
raise IsolationError(f"tasks directory is not a directory: {requested}")
|
|
2007
|
+
resolved = requested.resolve()
|
|
2008
|
+
if resolved != requested.absolute():
|
|
2009
|
+
raise IsolationError(f"tasks directory must not use symlinks: {requested}")
|
|
2010
|
+
try:
|
|
2011
|
+
relative_dir = resolved.relative_to(primary)
|
|
2012
|
+
except ValueError as error:
|
|
2013
|
+
raise IsolationError(
|
|
2014
|
+
f"tasks directory is outside the primary worktree: {resolved}"
|
|
2015
|
+
) from error
|
|
2016
|
+
relative_posix_dir = PurePosixPath(relative_dir.as_posix())
|
|
2017
|
+
revisions = [head]
|
|
2018
|
+
parents = git_output(primary, "show", "-s", "--format=%P", head).split()
|
|
2019
|
+
if parents:
|
|
2020
|
+
revisions.append(parents[0])
|
|
2021
|
+
tracked = set()
|
|
2022
|
+
for revision in revisions:
|
|
2023
|
+
tracked.update(
|
|
2024
|
+
path
|
|
2025
|
+
for path in git_text(
|
|
2026
|
+
primary,
|
|
2027
|
+
"ls-tree",
|
|
2028
|
+
"-r",
|
|
2029
|
+
"--name-only",
|
|
2030
|
+
"-z",
|
|
2031
|
+
revision,
|
|
2032
|
+
"--",
|
|
2033
|
+
relative_posix_dir.as_posix(),
|
|
2034
|
+
).split("\0")
|
|
2035
|
+
if path
|
|
2036
|
+
and PurePosixPath(path).parent == relative_posix_dir
|
|
2037
|
+
and PurePosixPath(path).suffix == ".md"
|
|
2038
|
+
)
|
|
2039
|
+
task_paths = sorted(resolved.glob("*.md")) if resolved.is_dir() else []
|
|
2040
|
+
live = {path.relative_to(primary).as_posix() for path in task_paths}
|
|
2041
|
+
details = []
|
|
2042
|
+
missing = sorted(tracked - live)
|
|
2043
|
+
unexpected = sorted(live - tracked)
|
|
2044
|
+
if missing:
|
|
2045
|
+
details.append("missing " + ", ".join(missing))
|
|
2046
|
+
if unexpected:
|
|
2047
|
+
details.append("unexpected " + ", ".join(unexpected))
|
|
2048
|
+
return task_paths, "; ".join(details) or None
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
def _first_parent_records(repo: Path, revision: str) -> list[tuple[str, str, str]]:
|
|
2052
|
+
"""First-parent commits of `revision` as (sha, subject, stripped body) in log order."""
|
|
2053
|
+
log = git_output(repo, "log", "--first-parent", "--format=%x1e%H%x00%s%x00%b", revision)
|
|
2054
|
+
records = []
|
|
2055
|
+
for record in filter(None, log.split("\x1e")):
|
|
2056
|
+
sha, subject, body = record.split("\x00", 2)
|
|
2057
|
+
records.append((sha, subject, body.strip()))
|
|
2058
|
+
return records
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
def _first_parent_history(repo: Path, revision: str) -> Dict[str, list[tuple[str, str]]]:
|
|
2062
|
+
"""Map each first-parent commit subject to its [(sha, body)] in log order."""
|
|
2063
|
+
history: Dict[str, list[tuple[str, str]]] = {}
|
|
2064
|
+
for sha, subject, body in _first_parent_records(repo, revision):
|
|
2065
|
+
history.setdefault(subject, []).append((sha, body))
|
|
2066
|
+
return history
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def recover(primary: Path, tasks_dir: Path) -> Dict[str, object]:
|
|
2070
|
+
"""Prove each task's landing state from git. Read-only.
|
|
2071
|
+
|
|
2072
|
+
Verdicts: `recovered` (exactly one proven landing commit; `commit` and
|
|
2073
|
+
`base` returned), `resume` (pending, in progress, or an exact interrupted
|
|
2074
|
+
landing), `reconcile` (a failed or blocked task retains isolation), `block`
|
|
2075
|
+
(missing or conflicting proof), and `none` (nothing to recover). Retained
|
|
2076
|
+
`task_branch` and worktree state are returned. Verify is not rerun: a proven
|
|
2077
|
+
landing commit is its own evidence.
|
|
2078
|
+
"""
|
|
2079
|
+
primary = require_directory(primary, "primary worktree")
|
|
2080
|
+
if worktree_root(primary) != primary:
|
|
2081
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
2082
|
+
bound = require_attached(primary)
|
|
2083
|
+
head = current_sha(primary)
|
|
2084
|
+
task_paths, inventory_error = _recovery_task_inventory(primary, tasks_dir, head)
|
|
2085
|
+
if inventory_error:
|
|
2086
|
+
return {
|
|
2087
|
+
"bound_branch": bound,
|
|
2088
|
+
"tasks": [],
|
|
2089
|
+
"verdict": "block",
|
|
2090
|
+
"reason": "task inventory differs from trusted Git state: "
|
|
2091
|
+
+ inventory_error,
|
|
2092
|
+
}
|
|
2093
|
+
history = _first_parent_history(primary, bound)
|
|
2094
|
+
branches = set(
|
|
2095
|
+
git_output(primary, "for-each-ref", "--format=%(refname:short)", f"refs/heads/{TASK_BRANCH_PREFIX}").splitlines()
|
|
2096
|
+
)
|
|
2097
|
+
tasks = [
|
|
2098
|
+
_recover_task(primary, path, history, branches, head, bound)
|
|
2099
|
+
for path in task_paths
|
|
2100
|
+
]
|
|
2101
|
+
return {
|
|
2102
|
+
"bound_branch": bound,
|
|
2103
|
+
"tasks": tasks,
|
|
2104
|
+
"verdict": "block" if any(t["verdict"] == "block" for t in tasks) else "ok",
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
def verify_landed_task_files(
|
|
2109
|
+
primary: Path,
|
|
2110
|
+
task_paths: Sequence[Path],
|
|
2111
|
+
canonical_tasks_dir: str,
|
|
2112
|
+
head: str,
|
|
2113
|
+
) -> Dict[str, object]:
|
|
2114
|
+
"""Prove archived task files against their canonical landing commits."""
|
|
2115
|
+
|
|
2116
|
+
primary = require_directory(primary, "primary worktree")
|
|
2117
|
+
if worktree_root(primary) != primary:
|
|
2118
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
2119
|
+
bound = require_attached(primary)
|
|
2120
|
+
resolved_head = require_commit(primary, require_full_sha(head))
|
|
2121
|
+
canonical_dir = relative_posix(canonical_tasks_dir)
|
|
2122
|
+
history = _first_parent_history(primary, resolved_head)
|
|
2123
|
+
branches = set(
|
|
2124
|
+
git_output(
|
|
2125
|
+
primary,
|
|
2126
|
+
"for-each-ref",
|
|
2127
|
+
"--format=%(refname:short)",
|
|
2128
|
+
f"refs/heads/{TASK_BRANCH_PREFIX}",
|
|
2129
|
+
).splitlines()
|
|
2130
|
+
)
|
|
2131
|
+
tasks = []
|
|
2132
|
+
for path in task_paths:
|
|
2133
|
+
resolved = path.resolve()
|
|
2134
|
+
if path.is_symlink() or not path.is_file():
|
|
2135
|
+
raise IsolationError(f"task artifact is missing or unsafe: {path}")
|
|
2136
|
+
try:
|
|
2137
|
+
resolved.relative_to(primary)
|
|
2138
|
+
except ValueError as error:
|
|
2139
|
+
raise IsolationError(f"task artifact is outside the primary: {path}") from error
|
|
2140
|
+
canonical = relative_posix(f"{canonical_dir}/{path.name}")
|
|
2141
|
+
task_text, _ = _read_task_text(resolved)
|
|
2142
|
+
fields, error = task_frontmatter(task_text)
|
|
2143
|
+
if error or fields is None:
|
|
2144
|
+
raise IsolationError(error or f"unreadable task frontmatter: {path.name}")
|
|
2145
|
+
if fields.get("status") != "done":
|
|
2146
|
+
raise IsolationError(
|
|
2147
|
+
f"{fields.get('id', path.name)}: task must have status: done"
|
|
2148
|
+
)
|
|
2149
|
+
tasks.append(
|
|
2150
|
+
_recover_task(
|
|
2151
|
+
primary,
|
|
2152
|
+
resolved,
|
|
2153
|
+
history,
|
|
2154
|
+
branches,
|
|
2155
|
+
resolved_head,
|
|
2156
|
+
bound,
|
|
2157
|
+
canonical,
|
|
2158
|
+
)
|
|
2159
|
+
)
|
|
2160
|
+
blocked = [task for task in tasks if task["verdict"] not in LANDED_VERDICTS]
|
|
2161
|
+
if blocked:
|
|
2162
|
+
def failure_reason(task: Dict[str, object]) -> str:
|
|
2163
|
+
rejected = task.get("rejected")
|
|
2164
|
+
if isinstance(rejected, list) and rejected:
|
|
2165
|
+
details = ", ".join(
|
|
2166
|
+
str(item.get("reason", "rejected"))
|
|
2167
|
+
for item in rejected
|
|
2168
|
+
if isinstance(item, dict)
|
|
2169
|
+
)
|
|
2170
|
+
if details:
|
|
2171
|
+
return details
|
|
2172
|
+
return str(task.get("reason", task["verdict"]))
|
|
2173
|
+
|
|
2174
|
+
reasons = "; ".join(
|
|
2175
|
+
f"{task.get('task_id', task['task'])}: {failure_reason(task)}"
|
|
2176
|
+
for task in blocked
|
|
2177
|
+
)
|
|
2178
|
+
raise IsolationError(reasons)
|
|
2179
|
+
return {"bound_branch": bound, "head": resolved_head, "tasks": tasks}
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def _sidecar_path_for_branch(primary: Path, branch: str) -> Path:
|
|
2183
|
+
if branch.startswith(TASK_BRANCH_PREFIX):
|
|
2184
|
+
task_id = branch.removeprefix(TASK_BRANCH_PREFIX)
|
|
2185
|
+
validate_task_id(task_id)
|
|
2186
|
+
return sidecar_root(primary, "task", task_id)
|
|
2187
|
+
if branch.startswith(VERIFY_BRANCH_PREFIX):
|
|
2188
|
+
name = branch.removeprefix(VERIFY_BRANCH_PREFIX)
|
|
2189
|
+
validate_name(name, "verify name")
|
|
2190
|
+
return sidecar_root(primary, "verify", name)
|
|
2191
|
+
raise IsolationError(f"refusing to retire unrecognized branch {branch}")
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
def _landing_base(repo: Path, commit: str) -> str:
|
|
2195
|
+
body = git_output(repo, "log", "-1", "--format=%b", commit)
|
|
2196
|
+
values = [
|
|
2197
|
+
line.removeprefix("Base: ").strip()
|
|
2198
|
+
for line in body.splitlines()
|
|
2199
|
+
if line.startswith("Base: ")
|
|
2200
|
+
]
|
|
2201
|
+
if len(values) != 1:
|
|
2202
|
+
raise IsolationError("landing commit body must contain one Base: field")
|
|
2203
|
+
return require_commit(repo, require_full_sha(values[0]))
|
|
2204
|
+
|
|
2205
|
+
|
|
2206
|
+
def _require_first_parent_commit(repo: Path, bound: str, commit: str) -> None:
|
|
2207
|
+
history = git_output(repo, "rev-list", "--first-parent", bound).splitlines()
|
|
2208
|
+
if commit not in history:
|
|
2209
|
+
raise IsolationError("landing commit is not on the bound first-parent history")
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def _failed_task_branch_proof_error(
|
|
2213
|
+
repo: Path, branch: str, task_file: str
|
|
2214
|
+
) -> Optional[str]:
|
|
2215
|
+
task_file = relative_posix(task_file)
|
|
2216
|
+
task_path = repo / task_file
|
|
2217
|
+
try:
|
|
2218
|
+
task_text, _ = _read_task_text(task_path)
|
|
2219
|
+
except IsolationError as error:
|
|
2220
|
+
return str(error)
|
|
2221
|
+
fields, fields_error = task_frontmatter(task_text)
|
|
2222
|
+
if fields_error or fields is None:
|
|
2223
|
+
return fields_error or "failed task frontmatter is unreadable"
|
|
2224
|
+
task_id = str(fields.get("id", ""))
|
|
2225
|
+
try:
|
|
2226
|
+
if task_branch_name(task_id) != branch:
|
|
2227
|
+
return "failed task id does not own the retained branch"
|
|
2228
|
+
except IsolationError as error:
|
|
2229
|
+
return str(error)
|
|
2230
|
+
if fields.get("status") not in {"failed", "blocked"}:
|
|
2231
|
+
return "forced rejected-branch retirement requires failed or blocked task state"
|
|
2232
|
+
if fields.get("task_branch") != branch:
|
|
2233
|
+
return "failed task does not record the retained branch"
|
|
2234
|
+
if fields.get("worktree") != str(sidecar_root(repo, "task", task_id)):
|
|
2235
|
+
return "failed task does not record the retained worktree"
|
|
2236
|
+
try:
|
|
2237
|
+
base = require_commit(repo, require_full_sha(str(fields.get("base", ""))))
|
|
2238
|
+
branch_commit = require_commit(repo, branch)
|
|
2239
|
+
except IsolationError as error:
|
|
2240
|
+
return str(error)
|
|
2241
|
+
parent, parent_error = _single_parent(repo, branch_commit)
|
|
2242
|
+
if parent_error or parent != base:
|
|
2243
|
+
return parent_error or "retained rejected branch parent differs from its recorded base"
|
|
2244
|
+
body = git_output(repo, "log", "-1", "--format=%b", branch_commit)
|
|
2245
|
+
_, proof_error = _prove_task_commit(
|
|
2246
|
+
repo, branch_commit, body, task_file, base, None
|
|
2247
|
+
)
|
|
2248
|
+
return proof_error
|
|
2249
|
+
def collect_artifact(
|
|
2250
|
+
primary: Path,
|
|
2251
|
+
source: Path,
|
|
2252
|
+
base: str,
|
|
2253
|
+
branch: str,
|
|
2254
|
+
source_path: str,
|
|
2255
|
+
destination_path: str,
|
|
2256
|
+
expected_destination: Optional[str] = None,
|
|
2257
|
+
) -> Dict[str, object]:
|
|
2258
|
+
"""Atomically collect one artifact and retain an idempotent receipt."""
|
|
2259
|
+
|
|
2260
|
+
primary = require_directory(primary, "primary worktree")
|
|
2261
|
+
source = require_directory(source, "source worktree")
|
|
2262
|
+
if worktree_root(primary) != primary or worktree_root(source) != source:
|
|
2263
|
+
raise IsolationError("primary and source must each be their Git root")
|
|
2264
|
+
if common_git_dir(primary) != common_git_dir(source):
|
|
2265
|
+
raise IsolationError("source worktree belongs to another repository")
|
|
2266
|
+
resolved_base = require_commit(primary, require_full_sha(base))
|
|
2267
|
+
if current_sha(primary) != resolved_base:
|
|
2268
|
+
raise IsolationError("primary worktree HEAD differs from the recorded base")
|
|
2269
|
+
if current_sha(source) != resolved_base:
|
|
2270
|
+
raise IsolationError("source worktree HEAD differs from the recorded base")
|
|
2271
|
+
if not branch.startswith(VERIFY_BRANCH_PREFIX) or require_attached(source) != branch:
|
|
2272
|
+
raise IsolationError("source worktree is not on the expected verify branch")
|
|
2273
|
+
normalized_source = relative_posix(source_path)
|
|
2274
|
+
normalized_destination = relative_posix(destination_path)
|
|
2275
|
+
destination = _safe_destination(primary, normalized_destination)
|
|
2276
|
+
request: Dict[str, object] = {
|
|
2277
|
+
"schema": COLLECT_JOURNAL_SCHEMA,
|
|
2278
|
+
"primary_worktree": str(primary),
|
|
2279
|
+
"source_worktree": str(source),
|
|
2280
|
+
"base": resolved_base,
|
|
2281
|
+
"branch": branch,
|
|
2282
|
+
"source": normalized_source,
|
|
2283
|
+
"destination": normalized_destination,
|
|
2284
|
+
"expected_destination": expected_destination,
|
|
2285
|
+
}
|
|
2286
|
+
journal_path = _collect_journal_path(primary, request)
|
|
2287
|
+
changed = uncommitted_paths(source)
|
|
2288
|
+
journal: Optional[Dict[str, object]] = None
|
|
2289
|
+
if journal_path.exists() or journal_path.is_symlink():
|
|
2290
|
+
journal = _validated_collect_journal(primary, journal_path)
|
|
2291
|
+
if any(
|
|
2292
|
+
journal.get(key) != value for key, value in request.items()
|
|
2293
|
+
):
|
|
2294
|
+
raise IsolationError("artifact collection journal does not match request")
|
|
2295
|
+
if journal["stage"] == "complete" and changed == {normalized_source}:
|
|
2296
|
+
journal = None # a later sidecar run reuses the same branch and paths
|
|
2297
|
+
|
|
2298
|
+
if journal is None:
|
|
2299
|
+
if changed != {normalized_source}:
|
|
2300
|
+
unexpected = sorted(changed - {normalized_source})
|
|
2301
|
+
detail = ", ".join(unexpected) if unexpected else "expected artifact is unchanged"
|
|
2302
|
+
raise IsolationError(f"unexpected sidecar paths: {detail}")
|
|
2303
|
+
source_file = _real_file(source, normalized_source, "artifact source")
|
|
2304
|
+
data = source_file.read_bytes()
|
|
2305
|
+
previous_data = destination.read_bytes() if destination.exists() else None
|
|
2306
|
+
if previous_data is not None and previous_data != data:
|
|
2307
|
+
actual_hash = _sha256(previous_data)
|
|
2308
|
+
if expected_destination == "base":
|
|
2309
|
+
expected_hash = _sha256(
|
|
2310
|
+
_git_file(primary, resolved_base, normalized_destination)
|
|
2311
|
+
)
|
|
2312
|
+
elif expected_destination and re.fullmatch(
|
|
2313
|
+
r"[0-9a-f]{64}", expected_destination
|
|
2314
|
+
):
|
|
2315
|
+
expected_hash = expected_destination
|
|
2316
|
+
else:
|
|
2317
|
+
raise IsolationError(
|
|
2318
|
+
"artifact destination has different content without a proven expected version"
|
|
2319
|
+
)
|
|
2320
|
+
if actual_hash != expected_hash:
|
|
2321
|
+
raise IsolationError(
|
|
2322
|
+
"artifact destination changed after its expected version was recorded"
|
|
2323
|
+
)
|
|
2324
|
+
journal = {
|
|
2325
|
+
**request,
|
|
2326
|
+
"bytes": len(data),
|
|
2327
|
+
"previous_sha256": _sha256(previous_data) if previous_data is not None else None,
|
|
2328
|
+
"replaced": previous_data is not None and previous_data != data,
|
|
2329
|
+
"sha256": _sha256(data),
|
|
2330
|
+
"stage": "prepared",
|
|
2331
|
+
}
|
|
2332
|
+
_write_collect_journal(journal_path, journal)
|
|
2333
|
+
else:
|
|
2334
|
+
current_hash = _sha256(destination.read_bytes()) if destination.exists() else None
|
|
2335
|
+
if not changed:
|
|
2336
|
+
if current_hash != journal["sha256"]:
|
|
2337
|
+
raise IsolationError(
|
|
2338
|
+
"artifact receipt exists but the collected destination changed"
|
|
2339
|
+
)
|
|
2340
|
+
journal["stage"] = "complete"
|
|
2341
|
+
_write_collect_journal(journal_path, journal)
|
|
2342
|
+
return {
|
|
2343
|
+
key: journal[key]
|
|
2344
|
+
for key in (
|
|
2345
|
+
"base",
|
|
2346
|
+
"branch",
|
|
2347
|
+
"bytes",
|
|
2348
|
+
"destination",
|
|
2349
|
+
"previous_sha256",
|
|
2350
|
+
"replaced",
|
|
2351
|
+
"sha256",
|
|
2352
|
+
"source",
|
|
2353
|
+
)
|
|
2354
|
+
}
|
|
2355
|
+
if changed != {normalized_source}:
|
|
2356
|
+
unexpected = sorted(changed - {normalized_source})
|
|
2357
|
+
raise IsolationError(
|
|
2358
|
+
"unexpected sidecar paths: " + ", ".join(unexpected)
|
|
2359
|
+
)
|
|
2360
|
+
source_file = _real_file(source, normalized_source, "artifact source")
|
|
2361
|
+
data = source_file.read_bytes()
|
|
2362
|
+
if _sha256(data) != journal["sha256"]:
|
|
2363
|
+
raise IsolationError("artifact source changed after collection was prepared")
|
|
2364
|
+
if current_hash not in {journal["previous_sha256"], journal["sha256"]}:
|
|
2365
|
+
raise IsolationError(
|
|
2366
|
+
"artifact destination changed after collection was prepared"
|
|
2367
|
+
)
|
|
2368
|
+
|
|
2369
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
2370
|
+
if not destination.exists() or _sha256(destination.read_bytes()) != journal["sha256"]:
|
|
2371
|
+
temporary_name: Optional[str] = None
|
|
2372
|
+
try:
|
|
2373
|
+
with tempfile.NamedTemporaryFile(
|
|
2374
|
+
dir=destination.parent,
|
|
2375
|
+
prefix=".gsd-path-artifact-",
|
|
2376
|
+
delete=False,
|
|
2377
|
+
) as handle:
|
|
2378
|
+
handle.write(data)
|
|
2379
|
+
handle.flush()
|
|
2380
|
+
os.fsync(handle.fileno())
|
|
2381
|
+
temporary_name = handle.name
|
|
2382
|
+
os.replace(temporary_name, destination)
|
|
2383
|
+
temporary_name = None
|
|
2384
|
+
finally:
|
|
2385
|
+
if temporary_name:
|
|
2386
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
2387
|
+
if _sha256(destination.read_bytes()) != journal["sha256"]:
|
|
2388
|
+
raise IsolationError("artifact destination does not match the prepared receipt")
|
|
2389
|
+
journal["stage"] = "collected"
|
|
2390
|
+
_write_collect_journal(journal_path, journal)
|
|
2391
|
+
|
|
2392
|
+
reset = run_git(source, "reset", "-q", "HEAD", "--", normalized_source)
|
|
2393
|
+
if reset.returncode != 0:
|
|
2394
|
+
raise IsolationError("artifact was collected but its staged state could not be cleared")
|
|
2395
|
+
tracked = run_git(source, "ls-files", "--error-unmatch", "--", normalized_source)
|
|
2396
|
+
if tracked.returncode == 0:
|
|
2397
|
+
restored = run_git(source, "restore", "--worktree", "--", normalized_source)
|
|
2398
|
+
if restored.returncode != 0:
|
|
2399
|
+
raise IsolationError("artifact was collected but the source could not be restored")
|
|
2400
|
+
else:
|
|
2401
|
+
source_file.unlink()
|
|
2402
|
+
if uncommitted_paths(source):
|
|
2403
|
+
raise IsolationError("artifact was collected but the source worktree is still dirty")
|
|
2404
|
+
journal["stage"] = "complete"
|
|
2405
|
+
_write_collect_journal(journal_path, journal)
|
|
2406
|
+
return {
|
|
2407
|
+
key: journal[key]
|
|
2408
|
+
for key in (
|
|
2409
|
+
"base",
|
|
2410
|
+
"branch",
|
|
2411
|
+
"bytes",
|
|
2412
|
+
"destination",
|
|
2413
|
+
"previous_sha256",
|
|
2414
|
+
"replaced",
|
|
2415
|
+
"sha256",
|
|
2416
|
+
"source",
|
|
2417
|
+
)
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
def _validate_checkpoint_message(subject: str, body: str) -> None:
|
|
2422
|
+
lines = [line.strip() for line in body.splitlines() if line.strip()]
|
|
2423
|
+
if subject == "roadmap: program roadmap approved":
|
|
2424
|
+
if lines != ["Why: approved roadmap checkpoint"]:
|
|
2425
|
+
raise IsolationError("roadmap checkpoint body is not canonical")
|
|
2426
|
+
return
|
|
2427
|
+
if subject == "plan: build plan approved":
|
|
2428
|
+
if (
|
|
2429
|
+
len(lines) != 2
|
|
2430
|
+
or lines[0] != "Why: approved plan checkpoint"
|
|
2431
|
+
or not re.fullmatch(r"Milestone: \S+", lines[1])
|
|
2432
|
+
):
|
|
2433
|
+
raise IsolationError("plan checkpoint body is not canonical")
|
|
2434
|
+
return
|
|
2435
|
+
if re.fullmatch(r"build: abandon milestone [a-z0-9][a-z0-9-]*", subject):
|
|
2436
|
+
if len(lines) != 1 or not lines[0].startswith("Why: "):
|
|
2437
|
+
raise IsolationError("abandon checkpoint body must contain one Why line")
|
|
2438
|
+
reason = lines[0][len("Why: ") :]
|
|
2439
|
+
if not reason or reason != " ".join(reason.split()):
|
|
2440
|
+
raise IsolationError("abandon checkpoint reason must be normalized")
|
|
2441
|
+
return
|
|
2442
|
+
if re.fullmatch(r"build: \S.*", subject):
|
|
2443
|
+
if not lines or not re.fullmatch(r"Why: \S.*", lines[0]):
|
|
2444
|
+
raise IsolationError("build checkpoint body must start with Why")
|
|
2445
|
+
seen = set()
|
|
2446
|
+
for line in lines[1:]:
|
|
2447
|
+
match = re.fullmatch(r"(Wave|Tasks|Base): \S.*", line)
|
|
2448
|
+
if not match or match.group(1) in seen:
|
|
2449
|
+
raise IsolationError("build checkpoint body has an invalid field")
|
|
2450
|
+
seen.add(match.group(1))
|
|
2451
|
+
return
|
|
2452
|
+
raise IsolationError("unsupported pipeline checkpoint subject")
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
task_verify_command = _common.task_verify_command
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
def _ledger_pass(entries: Sequence[Dict[str, object]], command: str, commit: str) -> bool:
|
|
2459
|
+
entry = _common.latest_verify_entry(entries, command, commit)
|
|
2460
|
+
return entry is not None and entry["result"] == "pass"
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
def _verify_ledger_pass(repo: Path, command: str, commit: str) -> bool:
|
|
2464
|
+
"""True when the worktree verify ledger's latest run of command at commit passed."""
|
|
2465
|
+
path = repo.joinpath(*PurePosixPath(VERIFY_LEDGER_PATH).parts)
|
|
2466
|
+
if path.is_symlink():
|
|
2467
|
+
raise IsolationError(f"{VERIFY_LEDGER_PATH} must be a regular file")
|
|
2468
|
+
try:
|
|
2469
|
+
return _ledger_pass(_common.verify_ledger_entries(path), command, commit)
|
|
2470
|
+
except ValueError as error:
|
|
2471
|
+
raise IsolationError(str(error)) from error
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def _task_contract_error(base_text: str, text: str, label: str) -> Optional[str]:
|
|
2475
|
+
"""Only landing metadata and an appended log may differ from the base task."""
|
|
2476
|
+
try:
|
|
2477
|
+
base_head, base_body = split_frontmatter(base_text)
|
|
2478
|
+
head, body = split_frontmatter(text)
|
|
2479
|
+
except IsolationError as error:
|
|
2480
|
+
return str(error)
|
|
2481
|
+
base_fields, _ = task_frontmatter(base_text)
|
|
2482
|
+
if base_fields is not None and base_fields.get("status") == "done":
|
|
2483
|
+
return "base task is already done"
|
|
2484
|
+
if _immutable_frontmatter(base_head) != _immutable_frontmatter(head) or not (
|
|
2485
|
+
_normalize_newlines(body).startswith(_normalize_newlines(base_body))
|
|
2486
|
+
):
|
|
2487
|
+
return f"{label} changes the task contract recorded at its base"
|
|
2488
|
+
return None
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
def task_log_delta(base_text: str, text: str) -> str:
|
|
2492
|
+
"""The task body appended since its base; raises unless the task is an append-only extension."""
|
|
2493
|
+
error = _task_contract_error(base_text, text, "task")
|
|
2494
|
+
if error:
|
|
2495
|
+
raise IsolationError(error)
|
|
2496
|
+
base_body = _normalize_newlines(split_frontmatter(base_text)[1])
|
|
2497
|
+
return _normalize_newlines(split_frontmatter(text)[1])[len(base_body):]
|
|
2498
|
+
|
|
2499
|
+
|
|
2500
|
+
def _attest_body_fields(body: str) -> tuple[Dict[str, str], list[str]]:
|
|
2501
|
+
fields: Dict[str, str] = {}
|
|
2502
|
+
files: list[str] = []
|
|
2503
|
+
for line in body.splitlines():
|
|
2504
|
+
if line.startswith("- "):
|
|
2505
|
+
files.append(line[2:].strip())
|
|
2506
|
+
else:
|
|
2507
|
+
key, separator, value = line.partition(":")
|
|
2508
|
+
if separator and key in {"Task", "Base", "Head", "Verify", "Ruling", "Attestation", "Verify-JSON"}:
|
|
2509
|
+
fields[key] = value.strip()
|
|
2510
|
+
return fields, files
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
def _attested_changes(repo: Path, base: str, head: str, declared: Set[str]) -> list[str]:
|
|
2514
|
+
"""Declared paths that changed between base and head, matched literally like land."""
|
|
2515
|
+
changed = set(git_output(repo, "diff", "--name-only", base, head).splitlines())
|
|
2516
|
+
return sorted(changed & declared)
|
|
2517
|
+
|
|
2518
|
+
|
|
2519
|
+
def _prove_attest_commit(
|
|
2520
|
+
repo: Path,
|
|
2521
|
+
sha: str,
|
|
2522
|
+
body: str,
|
|
2523
|
+
task_file: str,
|
|
2524
|
+
task_text: str,
|
|
2525
|
+
compare_head: bool = True,
|
|
2526
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
2527
|
+
"""Return (base, None) when `sha` attests this task, else (None, reason)."""
|
|
2528
|
+
fields, files = _attest_body_fields(body)
|
|
2529
|
+
base, head = fields.get("Base", ""), fields.get("Head", "")
|
|
2530
|
+
if fields.get("Task") != task_file:
|
|
2531
|
+
return None, "body Task: differs from the task file"
|
|
2532
|
+
if not fields.get("Ruling"):
|
|
2533
|
+
return None, "attestation has no Ruling:"
|
|
2534
|
+
try:
|
|
2535
|
+
base = require_commit(repo, require_full_sha(base))
|
|
2536
|
+
head = require_commit(repo, require_full_sha(head))
|
|
2537
|
+
except IsolationError as error:
|
|
2538
|
+
return None, f"attestation Base:/Head: are invalid: {error}"
|
|
2539
|
+
parent, parent_error = _single_parent(repo, sha)
|
|
2540
|
+
if parent_error or parent != head:
|
|
2541
|
+
return None, parent_error or "attestation parent differs from its Head:"
|
|
2542
|
+
if run_git(repo, "merge-base", "--is-ancestor", base, head).returncode != 0:
|
|
2543
|
+
return None, "attestation Head: does not descend from its Base:"
|
|
2544
|
+
changed = set(git_output(repo, "diff-tree", "--no-commit-id", "--name-only", "-r", sha).splitlines())
|
|
2545
|
+
if task_file not in changed or not changed <= {task_file, VERIFY_LEDGER_PATH}:
|
|
2546
|
+
return None, "attestation commit must change only the task file and the verify ledger"
|
|
2547
|
+
try:
|
|
2548
|
+
before_text = git_text(repo, "show", f"{head}:{task_file}")
|
|
2549
|
+
after_text = git_text(repo, "show", f"{sha}:{task_file}")
|
|
2550
|
+
before_head, before_body = split_frontmatter(before_text)
|
|
2551
|
+
after_head, after_body = split_frontmatter(after_text)
|
|
2552
|
+
except IsolationError as error:
|
|
2553
|
+
return None, str(error)
|
|
2554
|
+
after_fields, after_error = task_frontmatter(after_text)
|
|
2555
|
+
if after_error or after_fields is None:
|
|
2556
|
+
return None, after_error or "attested task frontmatter is unreadable"
|
|
2557
|
+
if _immutable_frontmatter(before_head) != _immutable_frontmatter(after_head) or not after_body.startswith(before_body):
|
|
2558
|
+
return None, "attestation changes the task outside landing metadata and an appended log"
|
|
2559
|
+
for key, value in {**LANDED_FIELDS, "base": base}.items():
|
|
2560
|
+
if after_fields.get(key) != value:
|
|
2561
|
+
return None, f"attested task frontmatter has invalid {key}"
|
|
2562
|
+
agent = after_fields.get("agent")
|
|
2563
|
+
if not isinstance(agent, str) or agent in {"", "null"}:
|
|
2564
|
+
return None, "attested task frontmatter has no assigned agent"
|
|
2565
|
+
try:
|
|
2566
|
+
contract_error = _task_contract_error(
|
|
2567
|
+
git_text(repo, "show", f"{base}:{task_file}"), after_text, "attestation"
|
|
2568
|
+
)
|
|
2569
|
+
except IsolationError as error:
|
|
2570
|
+
return None, str(error)
|
|
2571
|
+
if contract_error:
|
|
2572
|
+
return None, contract_error
|
|
2573
|
+
verify = task_verify_command(after_text)
|
|
2574
|
+
legacy = "Attestation" not in fields
|
|
2575
|
+
if legacy:
|
|
2576
|
+
verify = " ".join(verify.split())
|
|
2577
|
+
recorded_verify = fields.get("Verify")
|
|
2578
|
+
else:
|
|
2579
|
+
if fields["Attestation"] != "gsd-path/attestation/v2":
|
|
2580
|
+
return None, "unknown attestation version"
|
|
2581
|
+
try:
|
|
2582
|
+
recorded_verify = json.loads(fields.get("Verify-JSON", ""))
|
|
2583
|
+
except json.JSONDecodeError:
|
|
2584
|
+
return None, "attestation Verify-JSON is invalid"
|
|
2585
|
+
if not verify.strip() or recorded_verify != verify:
|
|
2586
|
+
return None, "body Verify: differs from the task's ## Verify command"
|
|
2587
|
+
ledger = run_git(repo, "show", f"{sha}:{VERIFY_LEDGER_PATH}")
|
|
2588
|
+
if ledger.returncode != 0:
|
|
2589
|
+
return None, "attestation commit carries no verify ledger"
|
|
2590
|
+
try:
|
|
2591
|
+
entries = _common.parse_verify_ledger(ledger.stdout)
|
|
2592
|
+
if legacy:
|
|
2593
|
+
matches = [e for e in entries if e.get("schema") is None and e["command"] == verify and e["commit"] == head]
|
|
2594
|
+
passed = bool(matches) and matches[-1]["result"] == "pass"
|
|
2595
|
+
else:
|
|
2596
|
+
passed = _ledger_pass(entries, verify, head)
|
|
2597
|
+
if not passed:
|
|
2598
|
+
return None, "verify ledger in the attestation has no passing run at Head:"
|
|
2599
|
+
except ValueError as error:
|
|
2600
|
+
return None, str(error)
|
|
2601
|
+
declared, declared_error = _declared_task_paths(after_fields)
|
|
2602
|
+
if declared_error or declared is None:
|
|
2603
|
+
return None, declared_error or "attested task files are unreadable"
|
|
2604
|
+
expected_files = _attested_changes(repo, base, head, declared)
|
|
2605
|
+
if not expected_files:
|
|
2606
|
+
return None, "no declared file changed between Base: and Head:"
|
|
2607
|
+
if files != expected_files:
|
|
2608
|
+
return None, "body Files: does not match the declared changes between Base: and Head:"
|
|
2609
|
+
expected_body = attest_commit_body(
|
|
2610
|
+
task_file, base, head, expected_files, verify, fields["Ruling"], legacy=legacy
|
|
2611
|
+
)
|
|
2612
|
+
if body.strip() != expected_body.strip():
|
|
2613
|
+
return None, "attestation body is not canonical"
|
|
2614
|
+
try:
|
|
2615
|
+
current_oid = _clean_blob_oid(repo, task_file, task_text, write=False)
|
|
2616
|
+
after_oid = _regular_blob_oid(repo, sha, task_file)
|
|
2617
|
+
head_oid = _regular_blob_oid(repo, "HEAD", task_file) if compare_head else after_oid
|
|
2618
|
+
except IsolationError as error:
|
|
2619
|
+
return None, str(error)
|
|
2620
|
+
if current_oid != after_oid or head_oid != after_oid:
|
|
2621
|
+
return None, "current done task differs from its attestation"
|
|
2622
|
+
return base, None
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
REBASE_ADOPTION_SCHEMA = "gsd-path/rebase-adoption/v1"
|
|
2626
|
+
REBASE_ADOPTION_RECEIPT = "build/rebase-adoption.json"
|
|
2627
|
+
REBASE_ADOPTION_PATH = ".project/" + REBASE_ADOPTION_RECEIPT
|
|
2628
|
+
|
|
2629
|
+
|
|
2630
|
+
def _one_landing(history: Dict[str, list[tuple[str, str]]], subject: str) -> tuple[str, str]:
|
|
2631
|
+
"""The single (sha, body) landing for `subject`; adoption needs exactly one."""
|
|
2632
|
+
candidates = history.get(subject, [])
|
|
2633
|
+
if len(candidates) != 1:
|
|
2634
|
+
raise IsolationError("rebase adoption requires one landing: " + subject)
|
|
2635
|
+
return candidates[0]
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def _committed_task_paths(repo: Path, revision: str) -> list[str]:
|
|
2639
|
+
return git_text(repo, "ls-tree", "-r", "--name-only", revision, "--", ".project/tasks").splitlines()
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
def _ordered_edits(repo: Path, sha: str) -> list[bytes]:
|
|
2643
|
+
"""Every changed byte, path, mode and binary payload of `sha`; hunk
|
|
2644
|
+
coordinates and content-addressed index headers excluded."""
|
|
2645
|
+
result = subprocess.run(
|
|
2646
|
+
("git", "-C", str(repo), "diff", "--binary", "--full-index", "--no-renames",
|
|
2647
|
+
"--unified=0", sha + "^", sha),
|
|
2648
|
+
capture_output=True,
|
|
2649
|
+
check=False,
|
|
2650
|
+
)
|
|
2651
|
+
if result.returncode != 0:
|
|
2652
|
+
detail = (result.stderr or result.stdout).decode("utf-8", errors="replace").strip()
|
|
2653
|
+
raise IsolationError(detail or "git command failed")
|
|
2654
|
+
return [line for line in io.BytesIO(result.stdout)
|
|
2655
|
+
if not line.startswith((b"index ", b"@@ "))]
|
|
2656
|
+
|
|
2657
|
+
|
|
2658
|
+
def _task_contract_section(text: str) -> str:
|
|
2659
|
+
"""Everything before the task's `## Log`: the part adoption keeps immutable."""
|
|
2660
|
+
before, separator, _ = text.partition("\n## Log\n")
|
|
2661
|
+
if not separator:
|
|
2662
|
+
raise IsolationError("task is missing its Log boundary")
|
|
2663
|
+
return before
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
def _task_proof(
|
|
2667
|
+
repo: Path,
|
|
2668
|
+
path: str,
|
|
2669
|
+
original: tuple[str, str],
|
|
2670
|
+
rebased: tuple[str, str],
|
|
2671
|
+
adopted: str,
|
|
2672
|
+
current_text: str,
|
|
2673
|
+
) -> Dict[str, object]:
|
|
2674
|
+
"""One receipt row: `rebased` replays the strictly proven `original` landing."""
|
|
2675
|
+
(original_sha, original_body), (rebased_sha, rebased_body) = original, rebased
|
|
2676
|
+
original_text = git_text(repo, "show", f"{original_sha}:{path}")
|
|
2677
|
+
fields, error = task_frontmatter(original_text)
|
|
2678
|
+
if error or fields is None:
|
|
2679
|
+
raise IsolationError(error or "invalid original task")
|
|
2680
|
+
base = str(fields.get("base", ""))
|
|
2681
|
+
_, error = _prove_task_commit(repo, original_sha, original_body, path, base, None)
|
|
2682
|
+
if error:
|
|
2683
|
+
raise IsolationError("original landing is invalid: " + error)
|
|
2684
|
+
parent, error = _single_parent(repo, rebased_sha)
|
|
2685
|
+
if error or parent is None:
|
|
2686
|
+
raise IsolationError(error or "invalid rebased parent")
|
|
2687
|
+
# Subjects already match: both landings were looked up under the same subject.
|
|
2688
|
+
if original_body != rebased_body:
|
|
2689
|
+
raise IsolationError("rebased landing message differs")
|
|
2690
|
+
if _ordered_edits(repo, original_sha) != _ordered_edits(repo, rebased_sha):
|
|
2691
|
+
raise IsolationError("rebased landing changes different bytes, paths or modes")
|
|
2692
|
+
if _regular_blob_oid(repo, original_sha, path) != _regular_blob_oid(repo, rebased_sha, path):
|
|
2693
|
+
raise IsolationError("rebased task differs from original landing")
|
|
2694
|
+
_regular_blob_oid(repo, adopted, path) # rejects a symlinked task at the adopted head
|
|
2695
|
+
if _task_contract_section(current_text) != _task_contract_section(original_text):
|
|
2696
|
+
raise IsolationError("adopted task contract differs from original landing")
|
|
2697
|
+
return {"path": path, "original_landing": original_sha, "rebased_landing": rebased_sha,
|
|
2698
|
+
"original_base": base, "original_task": original_text,
|
|
2699
|
+
"adopted_task": current_text,
|
|
2700
|
+
"context_changed": git_text(repo, "diff", "--binary", original_sha + "^", original_sha)
|
|
2701
|
+
!= git_text(repo, "diff", "--binary", rebased_sha + "^", rebased_sha)}
|
|
2702
|
+
|
|
2703
|
+
|
|
2704
|
+
def adopt_rebase(primary: Path, original_head: str, head: str, ruling: str) -> Dict[str, object]:
|
|
2705
|
+
"""Compute the owner-ruled receipt adopting every done task's rebased landing.
|
|
2706
|
+
|
|
2707
|
+
Adoption attests a pinned rebased revision, not behavioral equivalence or
|
|
2708
|
+
final acceptance: each done task needs its original canonical landing on
|
|
2709
|
+
`original_head`, one landing with identical message and ordered edits on
|
|
2710
|
+
`head`, and an unchanged task contract. Pending tasks are skipped; tasks in
|
|
2711
|
+
flight cannot be adopted.
|
|
2712
|
+
"""
|
|
2713
|
+
if not ruling.strip():
|
|
2714
|
+
raise IsolationError("an explicit owner ruling is required")
|
|
2715
|
+
for sha in (original_head, head):
|
|
2716
|
+
require_commit(primary, require_full_sha(sha))
|
|
2717
|
+
if original_head == head:
|
|
2718
|
+
raise IsolationError("original and adopted heads must differ")
|
|
2719
|
+
old, new = _first_parent_history(primary, original_head), _first_parent_history(primary, head)
|
|
2720
|
+
paths = _committed_task_paths(primary, head)
|
|
2721
|
+
if not paths:
|
|
2722
|
+
raise IsolationError("no committed task inventory")
|
|
2723
|
+
tasks = []
|
|
2724
|
+
for path in paths:
|
|
2725
|
+
text = git_text(primary, "show", f"{head}:{path}")
|
|
2726
|
+
fields, error = task_frontmatter(text)
|
|
2727
|
+
if error or fields is None:
|
|
2728
|
+
raise IsolationError(f"{path}: {error or 'unreadable task frontmatter'}")
|
|
2729
|
+
status = str(fields.get("status", ""))
|
|
2730
|
+
if status == "pending":
|
|
2731
|
+
continue
|
|
2732
|
+
if status != "done":
|
|
2733
|
+
raise IsolationError(f"rebasing a {status} task is unsupported: {path}")
|
|
2734
|
+
subject = task_commit_subject(str(fields.get("id", "")), str(fields.get("title", "")))
|
|
2735
|
+
tasks.append(_task_proof(primary, path, _one_landing(old, subject), _one_landing(new, subject), head, text))
|
|
2736
|
+
return {"schema": REBASE_ADOPTION_SCHEMA, "original_head": original_head, "head": head,
|
|
2737
|
+
"ruling": ruling.strip(), "tasks": tasks}
|
|
2738
|
+
|
|
2739
|
+
|
|
2740
|
+
# Proof outcomes per process, including failures: git objects are immutable, so
|
|
2741
|
+
# a receipt or range that failed once fails for every task that asks again.
|
|
2742
|
+
_ADOPTION_MEMO: Dict[tuple, tuple[bool, object]] = {}
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
def _memoized(key: tuple, compute):
|
|
2746
|
+
if key not in _ADOPTION_MEMO:
|
|
2747
|
+
try:
|
|
2748
|
+
_ADOPTION_MEMO[key] = (True, compute())
|
|
2749
|
+
except IsolationError as error:
|
|
2750
|
+
_ADOPTION_MEMO[key] = (False, error)
|
|
2751
|
+
ok, value = _ADOPTION_MEMO[key]
|
|
2752
|
+
if not ok:
|
|
2753
|
+
raise value
|
|
2754
|
+
return value
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def _require_landings_after(repo: Path, adopted: str, head: str) -> None:
|
|
2758
|
+
"""After adoption every product change must be a strictly proven landing."""
|
|
2759
|
+
_memoized(("landings", str(repo), adopted, head), lambda: _check_landings_after(repo, adopted, head))
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
def _check_landings_after(repo: Path, adopted: str, head: str) -> None:
|
|
2763
|
+
if run_git(repo, "merge-base", "--is-ancestor", adopted, head).returncode:
|
|
2764
|
+
raise IsolationError("adopted revision is not an ancestor")
|
|
2765
|
+
inventory = set(_committed_task_paths(repo, head))
|
|
2766
|
+
archived_paths = git_text(
|
|
2767
|
+
repo, "ls-tree", "-r", "--name-only", head, "--", ".project/archive"
|
|
2768
|
+
).splitlines()
|
|
2769
|
+
for path in archived_paths:
|
|
2770
|
+
match = re.fullmatch(r"\.project/archive/[^/]+/tasks/([^/]+\.md)", path)
|
|
2771
|
+
if match:
|
|
2772
|
+
inventory.add(".project/tasks/" + match.group(1))
|
|
2773
|
+
for sha, _, body in _first_parent_records(repo, f"{adopted}..{head}"):
|
|
2774
|
+
changed = git_output(repo, "diff", "--name-only", sha + "^", sha).splitlines()
|
|
2775
|
+
# Bookkeeping, including real attestations, cannot change product paths.
|
|
2776
|
+
if all(path.startswith(BOOKKEEPING_PREFIXES) for path in changed):
|
|
2777
|
+
continue
|
|
2778
|
+
fields, _ = _attest_body_fields(body)
|
|
2779
|
+
task_file, base = fields.get("Task", ""), fields.get("Base", "")
|
|
2780
|
+
if task_file in inventory and re.fullmatch(r"[0-9a-f]{40}", base):
|
|
2781
|
+
_, error = _prove_task_commit(repo, sha, body, task_file, base, None)
|
|
2782
|
+
if error is None:
|
|
2783
|
+
continue
|
|
2784
|
+
raise IsolationError("product changed outside a proven landing after the adopted revision")
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def _receipt_path(project_root: Path) -> Path:
|
|
2788
|
+
"""The adoption receipt beside a live (.project/) or archived project root."""
|
|
2789
|
+
path = project_root / REBASE_ADOPTION_RECEIPT
|
|
2790
|
+
try:
|
|
2791
|
+
resolved = path.resolve()
|
|
2792
|
+
except (RuntimeError, OSError) as error:
|
|
2793
|
+
raise IsolationError(f"unsafe rebase adoption receipt path: {error}") from error
|
|
2794
|
+
if resolved != path.absolute():
|
|
2795
|
+
raise IsolationError("unsafe rebase adoption receipt path")
|
|
2796
|
+
return path
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
def _receipt_bytes(project_root: Path) -> Optional[bytes]:
|
|
2800
|
+
"""The receipt's bytes, or None when the project root has no receipt."""
|
|
2801
|
+
path = _receipt_path(project_root)
|
|
2802
|
+
if not os.path.lexists(path):
|
|
2803
|
+
return None
|
|
2804
|
+
if not path.is_file():
|
|
2805
|
+
raise IsolationError("rebase adoption receipt is not a regular file")
|
|
2806
|
+
try:
|
|
2807
|
+
return path.read_bytes()
|
|
2808
|
+
except OSError as error:
|
|
2809
|
+
raise IsolationError(str(error)) from error
|
|
2810
|
+
|
|
2811
|
+
|
|
2812
|
+
def _validated_receipt(repo: Path, raw: bytes) -> Dict[str, object]:
|
|
2813
|
+
"""Parse and recompute a receipt once per process; git objects are immutable."""
|
|
2814
|
+
key = ("receipt", str(repo), hashlib.sha256(raw).hexdigest())
|
|
2815
|
+
return _memoized(key, lambda: _validate_receipt(repo, raw))
|
|
2816
|
+
|
|
2817
|
+
|
|
2818
|
+
def _validate_receipt(repo: Path, raw: bytes) -> Dict[str, object]:
|
|
2819
|
+
try:
|
|
2820
|
+
receipt = json.loads(raw)
|
|
2821
|
+
if not isinstance(receipt, dict) or receipt.get("schema") != REBASE_ADOPTION_SCHEMA:
|
|
2822
|
+
raise IsolationError("unknown schema")
|
|
2823
|
+
for field in ("original_head", "head"):
|
|
2824
|
+
if not isinstance(receipt.get(field), str):
|
|
2825
|
+
raise IsolationError(f"{field} must be a full lowercase SHA")
|
|
2826
|
+
require_full_sha(receipt[field])
|
|
2827
|
+
if not isinstance(receipt.get("ruling"), str) or not receipt["ruling"].strip():
|
|
2828
|
+
raise IsolationError("ruling must be a non-empty string")
|
|
2829
|
+
if not isinstance(receipt.get("tasks"), list) or any(
|
|
2830
|
+
not isinstance(row, dict) for row in receipt["tasks"]
|
|
2831
|
+
):
|
|
2832
|
+
raise IsolationError("tasks must be a list of objects")
|
|
2833
|
+
expected = adopt_rebase(repo, receipt["original_head"], receipt["head"], receipt["ruling"])
|
|
2834
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
2835
|
+
raise IsolationError(str(error)) from error
|
|
2836
|
+
if receipt != expected:
|
|
2837
|
+
raise IsolationError("receipt does not match recomputed Git evidence")
|
|
2838
|
+
return receipt
|
|
2839
|
+
|
|
2840
|
+
|
|
2841
|
+
def _prove_rebase_adoption(
|
|
2842
|
+
repo: Path, project_root: Path, task_file: str, head: str, landing: str, current_text: str
|
|
2843
|
+
) -> Optional[str]:
|
|
2844
|
+
"""Return the original base the receipt binds to `landing`; None without a receipt.
|
|
2845
|
+
|
|
2846
|
+
`project_root` is the task artifact's own root: `.project` for live tasks,
|
|
2847
|
+
`.project/archive/<n>` for archived ones.
|
|
2848
|
+
"""
|
|
2849
|
+
raw = _receipt_bytes(project_root)
|
|
2850
|
+
if raw is None:
|
|
2851
|
+
return None
|
|
2852
|
+
receipt = _validated_receipt(repo, raw)
|
|
2853
|
+
if receipt["head"] != head:
|
|
2854
|
+
_require_landings_after(repo, receipt["head"], head)
|
|
2855
|
+
rows = [row for row in receipt["tasks"] if row["path"] == task_file]
|
|
2856
|
+
if len(rows) != 1 or rows[0]["rebased_landing"] != landing:
|
|
2857
|
+
raise IsolationError("receipt does not bind this landing")
|
|
2858
|
+
if rows[0]["adopted_task"] != current_text:
|
|
2859
|
+
raise IsolationError("current task differs from adopted task")
|
|
2860
|
+
return str(rows[0]["original_base"])
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
def record_rebase_adoption(
|
|
2864
|
+
primary: Path, original_head: str, head: str, ruling: str
|
|
2865
|
+
) -> Dict[str, object]:
|
|
2866
|
+
"""Write the adoption receipt for the bound milestone worktree at `head`."""
|
|
2867
|
+
primary = require_directory(primary, "primary worktree")
|
|
2868
|
+
if worktree_root(primary) != primary:
|
|
2869
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
2870
|
+
bound = require_bound(primary)
|
|
2871
|
+
if current_sha(primary) != head:
|
|
2872
|
+
raise IsolationError("HEAD differs from approved adopted revision")
|
|
2873
|
+
receipt = adopt_rebase(primary, original_head, head, ruling)
|
|
2874
|
+
existing = _receipt_bytes(primary / ".project")
|
|
2875
|
+
try:
|
|
2876
|
+
if existing is not None and json.loads(existing) != receipt:
|
|
2877
|
+
raise IsolationError("another adoption receipt already exists")
|
|
2878
|
+
except ValueError as error:
|
|
2879
|
+
raise IsolationError(f"unreadable existing receipt: {error}") from error
|
|
2880
|
+
target = _receipt_path(primary / ".project")
|
|
2881
|
+
_common.atomic_write(target, json.dumps(receipt, indent=2) + "\n")
|
|
2882
|
+
return {"bound_branch": bound, "head": head, "path": str(target),
|
|
2883
|
+
"tasks": len(receipt["tasks"]), "verdict": "adopted"}
|
|
2884
|
+
|
|
2885
|
+
|
|
2886
|
+
def publication_base(repo: Path, branch: str, ship_commit: str) -> Optional[str]:
|
|
2887
|
+
"""The adopted head a shipped, owner-adopted milestone may advance from.
|
|
2888
|
+
|
|
2889
|
+
Returns None when the milestone is not an adopted one; raises when the
|
|
2890
|
+
archived receipt, ship commit, or archived tasks fail proof.
|
|
2891
|
+
"""
|
|
2892
|
+
try:
|
|
2893
|
+
from pipeline_state import load_state
|
|
2894
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
2895
|
+
from scripts.pipeline_state import load_state
|
|
2896
|
+
repo = require_directory(repo, "repository")
|
|
2897
|
+
state, state_text, _ = load_state(repo)
|
|
2898
|
+
if (state.phase, state.status, state.integration, state.branch) != (
|
|
2899
|
+
"shipped", "done", "direct", branch
|
|
2900
|
+
) or state.archive is None:
|
|
2901
|
+
return None
|
|
2902
|
+
raw = _receipt_bytes(repo / state.archive)
|
|
2903
|
+
if raw is None:
|
|
2904
|
+
return None
|
|
2905
|
+
if current_sha(repo) != ship_commit or uncommitted_paths(repo):
|
|
2906
|
+
raise IsolationError("publication requires the clean ship commit")
|
|
2907
|
+
if git_text(repo, "show", f"{ship_commit}:.project/STATE.md") != state_text:
|
|
2908
|
+
raise IsolationError("ship state differs from committed state")
|
|
2909
|
+
receipt = _validated_receipt(repo, raw)
|
|
2910
|
+
parent, error = _single_parent(repo, ship_commit)
|
|
2911
|
+
if error or parent != receipt["head"]:
|
|
2912
|
+
raise IsolationError("ship parent is not the adopted revision")
|
|
2913
|
+
changed = git_output(repo, "diff", "--name-only", parent, ship_commit).splitlines()
|
|
2914
|
+
if any(not name.startswith(".project/") for name in changed):
|
|
2915
|
+
raise IsolationError("ship commit changes product files")
|
|
2916
|
+
expected = (ship_subject(Path(state.archive).name) + "\n\n"
|
|
2917
|
+
+ ship_commit_body(state.archive, parent)).strip()
|
|
2918
|
+
if git_text(repo, "show", "-s", "--format=%B", ship_commit).strip() != expected:
|
|
2919
|
+
raise IsolationError("ship commit message is not canonical")
|
|
2920
|
+
for row in receipt["tasks"]:
|
|
2921
|
+
name = Path(str(row["path"])).name
|
|
2922
|
+
task = _real_file(repo, f"{state.archive}/tasks/{name}", "archived task")
|
|
2923
|
+
if _read_task_text(task)[0] != row["adopted_task"]:
|
|
2924
|
+
raise IsolationError("archived task differs from adopted record")
|
|
2925
|
+
return parent
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
def attest(
|
|
2929
|
+
primary: Path, task_file: str, ruling: str, base: Optional[str] = None
|
|
2930
|
+
) -> Dict[str, object]:
|
|
2931
|
+
"""Record an owner ruling that a done task's work is already on the branch.
|
|
2932
|
+
|
|
2933
|
+
The attestation is one .project/-only commit that stamps the task's landed
|
|
2934
|
+
state (and carries an uncommitted verify-ledger line) and names the base,
|
|
2935
|
+
the attested HEAD, the declared files that changed between them, the task
|
|
2936
|
+
Verify recorded as passing at that HEAD, and the ruling verbatim. `recover`
|
|
2937
|
+
reports it as `attested`, never `recovered`.
|
|
2938
|
+
"""
|
|
2939
|
+
primary = require_directory(primary, "primary worktree")
|
|
2940
|
+
if worktree_root(primary) != primary:
|
|
2941
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
2942
|
+
bound = require_bound(primary)
|
|
2943
|
+
task_file = relative_posix(task_file)
|
|
2944
|
+
if not task_file.startswith(".project/tasks/"):
|
|
2945
|
+
raise IsolationError("task file must live under .project/tasks/")
|
|
2946
|
+
ruling_text = " ".join(ruling.split())
|
|
2947
|
+
if not ruling_text:
|
|
2948
|
+
raise IsolationError("--ruling must not be empty")
|
|
2949
|
+
pending = uncommitted_paths(primary)
|
|
2950
|
+
dirty = sorted(pending - BOOKKEEPING_PATHS)
|
|
2951
|
+
if dirty:
|
|
2952
|
+
raise IsolationError("primary worktree is dirty: " + ", ".join(dirty))
|
|
2953
|
+
head = current_sha(primary)
|
|
2954
|
+
task_path = primary / task_file
|
|
2955
|
+
task_text, task_mode = _read_task_text(task_path)
|
|
2956
|
+
fields, error = task_frontmatter(task_text)
|
|
2957
|
+
if error or fields is None:
|
|
2958
|
+
raise IsolationError(error or "unreadable task frontmatter")
|
|
2959
|
+
task_id, title = str(fields.get("id", "")), str(fields.get("title", ""))
|
|
2960
|
+
validate_task_id(task_id)
|
|
2961
|
+
if fields.get("status") != "done":
|
|
2962
|
+
raise IsolationError("only a done task can be attested")
|
|
2963
|
+
try:
|
|
2964
|
+
subject = attest_commit_subject(task_id, title)
|
|
2965
|
+
except ValueError as exc:
|
|
2966
|
+
raise IsolationError(str(exc)) from exc
|
|
2967
|
+
report = recover(primary, Path(task_file).parent)
|
|
2968
|
+
mine = next((task for task in report["tasks"] if task.get("task_id") == task_id), None)
|
|
2969
|
+
if mine is None:
|
|
2970
|
+
raise IsolationError(f"task recovery did not report {task_id}: {report.get('reason', '')}")
|
|
2971
|
+
if mine["verdict"] in LANDED_VERDICTS:
|
|
2972
|
+
raise IsolationError(f"task is already {mine['verdict']} at {mine['commit']}")
|
|
2973
|
+
reason = str(mine.get("reason", ""))
|
|
2974
|
+
if mine["verdict"] != "block" or not reason.startswith(
|
|
2975
|
+
("done but no landing commit proves it", "done task has invalid base")
|
|
2976
|
+
):
|
|
2977
|
+
raise IsolationError(f"task recovery is not a missing landing: {reason or mine['verdict']}")
|
|
2978
|
+
if mine.get("rejected"):
|
|
2979
|
+
raise IsolationError(
|
|
2980
|
+
"task has rejected landing evidence on the branch; resolve it before attesting: "
|
|
2981
|
+
+ "; ".join(str(item.get("reason", "")) for item in mine["rejected"])
|
|
2982
|
+
)
|
|
2983
|
+
if mine.get("worktree") is not None or mine.get("task_branch") is not None:
|
|
2984
|
+
raise IsolationError("task retains isolation; retire it before attesting")
|
|
2985
|
+
recorded = str(fields.get("base", ""))
|
|
2986
|
+
resolved_base = require_commit(primary, base or recorded)
|
|
2987
|
+
if base and recorded not in {"", "null"} and not resolved_base.startswith(recorded):
|
|
2988
|
+
raise IsolationError("--base may only repair the recorded base, not replace it")
|
|
2989
|
+
if run_git(primary, "merge-base", "--is-ancestor", resolved_base, head).returncode != 0:
|
|
2990
|
+
raise IsolationError("base is not an ancestor of HEAD")
|
|
2991
|
+
_regular_blob_oid(primary, resolved_base, task_file)
|
|
2992
|
+
contract_error = _task_contract_error(
|
|
2993
|
+
git_text(primary, "show", f"{resolved_base}:{task_file}"), task_text, "task"
|
|
2994
|
+
)
|
|
2995
|
+
if contract_error:
|
|
2996
|
+
raise IsolationError(contract_error + "; only landing metadata and an appended log may change")
|
|
2997
|
+
declared, declared_error = _declared_task_paths(fields)
|
|
2998
|
+
if declared_error or declared is None:
|
|
2999
|
+
raise IsolationError(declared_error or "task files are unreadable")
|
|
3000
|
+
changed = _attested_changes(primary, resolved_base, head, declared)
|
|
3001
|
+
if not changed:
|
|
3002
|
+
raise IsolationError("no declared file changed between the base and HEAD; nothing to attest")
|
|
3003
|
+
verify = task_verify_command(task_text)
|
|
3004
|
+
if not verify:
|
|
3005
|
+
raise IsolationError("task has no ## Verify bash command")
|
|
3006
|
+
if not _verify_ledger_pass(primary, verify, head):
|
|
3007
|
+
raise IsolationError(
|
|
3008
|
+
f"verify ledger has no passing run of the task Verify at {head}; "
|
|
3009
|
+
"run it and record it with build_state.py verify-record first"
|
|
3010
|
+
)
|
|
3011
|
+
frontmatter, body = split_frontmatter(task_text)
|
|
3012
|
+
frontmatter = [
|
|
3013
|
+
f"base: {resolved_base}" if _frontmatter_key(line) == "base" else line
|
|
3014
|
+
for line in frontmatter
|
|
3015
|
+
]
|
|
3016
|
+
normalized = "---\n" + "\n".join(frontmatter) + "\n---\n" + body
|
|
3017
|
+
stamped = _landed_task_text(normalized, resolved_base)
|
|
3018
|
+
if not stamped.endswith("\n"):
|
|
3019
|
+
stamped += "\n"
|
|
3020
|
+
stamped += f"- attested: {ruling_text}\n"
|
|
3021
|
+
task_bytes = task_text.encode("utf-8")
|
|
3022
|
+
index_tree = git_output(primary, "write-tree")
|
|
3023
|
+
commit_body = attest_commit_body(task_file, resolved_base, head, changed, verify, ruling_text)
|
|
3024
|
+
try:
|
|
3025
|
+
_replace_regular_file(task_path, stamped.encode("utf-8"), task_mode)
|
|
3026
|
+
# The ledger line proving Verify at HEAD travels inside the attestation.
|
|
3027
|
+
commit = _commit_pending(
|
|
3028
|
+
primary, {task_file, *(pending & {VERIFY_LEDGER_PATH})}, subject, commit_body
|
|
3029
|
+
)
|
|
3030
|
+
except BaseException as error:
|
|
3031
|
+
try:
|
|
3032
|
+
_restore_landing_state(primary, task_path, task_bytes, task_mode, index_tree)
|
|
3033
|
+
except IsolationError as restore_error:
|
|
3034
|
+
raise IsolationError(f"{error}; {restore_error}") from error
|
|
3035
|
+
raise
|
|
3036
|
+
_, proof_error = _prove_attest_commit(primary, commit, commit_body.strip(), task_file, stamped)
|
|
3037
|
+
if proof_error:
|
|
3038
|
+
# Move HEAD back and restore the index and task file; leave every other
|
|
3039
|
+
# worktree path (ledger, discussion records) exactly as it was.
|
|
3040
|
+
if current_sha(primary) != commit:
|
|
3041
|
+
raise IsolationError("HEAD moved after the rejected attestation commit")
|
|
3042
|
+
moved = run_git(primary, "update-ref", "HEAD", head, commit)
|
|
3043
|
+
if moved.returncode != 0:
|
|
3044
|
+
raise IsolationError((moved.stderr or moved.stdout).strip() or "could not restore HEAD")
|
|
3045
|
+
_restore_landing_state(primary, task_path, task_bytes, task_mode, index_tree)
|
|
3046
|
+
raise IsolationError(f"attestation proof failed: {proof_error}")
|
|
3047
|
+
return {
|
|
3048
|
+
"bound_branch": bound,
|
|
3049
|
+
"commit": commit,
|
|
3050
|
+
"base": resolved_base,
|
|
3051
|
+
"head": head,
|
|
3052
|
+
"files": changed,
|
|
3053
|
+
"subject": subject,
|
|
3054
|
+
"verdict": "attested",
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
|
|
3058
|
+
def checkpoint(
|
|
3059
|
+
repo: Path,
|
|
3060
|
+
expected_head: str,
|
|
3061
|
+
subject: str,
|
|
3062
|
+
body: str,
|
|
3063
|
+
allow_paths: Sequence[str],
|
|
3064
|
+
) -> Dict[str, object]:
|
|
3065
|
+
"""Create one allowlisted pipeline checkpoint with a canonical message."""
|
|
3066
|
+
|
|
3067
|
+
repo = require_directory(repo, "primary worktree")
|
|
3068
|
+
if worktree_root(repo) != repo:
|
|
3069
|
+
raise IsolationError(f"primary is not its Git root: {repo}")
|
|
3070
|
+
require_bound(repo)
|
|
3071
|
+
expected = require_commit(repo, require_full_sha(expected_head))
|
|
3072
|
+
_validate_checkpoint_message(subject.strip(), body)
|
|
3073
|
+
allowed = [relative_posix(path).rstrip("/") for path in allow_paths]
|
|
3074
|
+
if not allowed or any(
|
|
3075
|
+
path in {"", "."}
|
|
3076
|
+
or not (path == ".project" or path.startswith(".project/"))
|
|
3077
|
+
for path in allowed
|
|
3078
|
+
):
|
|
3079
|
+
raise IsolationError("checkpoint allow paths must stay under .project/")
|
|
3080
|
+
current = current_sha(repo)
|
|
3081
|
+
if current != expected:
|
|
3082
|
+
parents = git_output(repo, "show", "-s", "--format=%P", current).split()
|
|
3083
|
+
actual_subject = git_output(repo, "show", "-s", "--format=%s", current)
|
|
3084
|
+
actual_body = git_output(repo, "show", "-s", "--format=%b", current)
|
|
3085
|
+
paths = committed_paths_since(repo, expected) if parents == [expected] else set()
|
|
3086
|
+
paths_allowed = paths and all(
|
|
3087
|
+
any(
|
|
3088
|
+
path == allowed_path or path.startswith(allowed_path + "/")
|
|
3089
|
+
for allowed_path in allowed
|
|
3090
|
+
)
|
|
3091
|
+
for path in paths
|
|
3092
|
+
)
|
|
3093
|
+
if (
|
|
3094
|
+
parents == [expected]
|
|
3095
|
+
and actual_subject == subject.strip()
|
|
3096
|
+
and actual_body == body.strip()
|
|
3097
|
+
and paths_allowed
|
|
3098
|
+
and not uncommitted_paths(repo)
|
|
3099
|
+
):
|
|
3100
|
+
return {
|
|
3101
|
+
"commit": current,
|
|
3102
|
+
"paths": sorted(paths),
|
|
3103
|
+
"status": "already-complete",
|
|
3104
|
+
"subject": subject.strip(),
|
|
3105
|
+
}
|
|
3106
|
+
raise IsolationError("primary HEAD differs from --expected-head")
|
|
3107
|
+
pending = uncommitted_paths(repo)
|
|
3108
|
+
if not pending:
|
|
3109
|
+
raise IsolationError("no changes to checkpoint")
|
|
3110
|
+
unexpected = sorted(
|
|
3111
|
+
path
|
|
3112
|
+
for path in pending
|
|
3113
|
+
if not any(
|
|
3114
|
+
path == allowed_path or path.startswith(allowed_path + "/")
|
|
3115
|
+
for allowed_path in allowed
|
|
3116
|
+
)
|
|
3117
|
+
)
|
|
3118
|
+
if unexpected:
|
|
3119
|
+
raise IsolationError("unexpected paths: " + ", ".join(unexpected))
|
|
3120
|
+
unsupported = sorted({
|
|
3121
|
+
path.split("/")[1]
|
|
3122
|
+
for path in pending
|
|
3123
|
+
if path.startswith(".project/") and path.split("/")[1] not in PROJECT_ENTRIES
|
|
3124
|
+
})
|
|
3125
|
+
if unsupported:
|
|
3126
|
+
raise IsolationError(
|
|
3127
|
+
"unsupported .project artifacts: " + ", ".join(unsupported)
|
|
3128
|
+
+ "; remove them before the checkpoint"
|
|
3129
|
+
)
|
|
3130
|
+
normalized_subject = subject.strip()
|
|
3131
|
+
if normalized_subject.startswith("build:"):
|
|
3132
|
+
# Build owns fix-task additions and brief repairs, never the task
|
|
3133
|
+
# inventory: a deleted or renamed brief is a replan. Abandon moves
|
|
3134
|
+
# briefs into .project/archive/ inside the same checkpoint.
|
|
3135
|
+
abandon = re.fullmatch(
|
|
3136
|
+
r"build: abandon milestone [a-z0-9][a-z0-9-]*", normalized_subject
|
|
3137
|
+
)
|
|
3138
|
+
archived = set()
|
|
3139
|
+
if abandon:
|
|
3140
|
+
for path in pending:
|
|
3141
|
+
match = re.fullmatch(
|
|
3142
|
+
r"\.project/archive/[^/]+/tasks/([^/]+\.md)", path
|
|
3143
|
+
)
|
|
3144
|
+
if match and (repo / path).is_file():
|
|
3145
|
+
archived.add(match.group(1))
|
|
3146
|
+
removed = sorted(
|
|
3147
|
+
path
|
|
3148
|
+
for path in pending
|
|
3149
|
+
if path.startswith(".project/tasks/")
|
|
3150
|
+
and path.endswith(".md")
|
|
3151
|
+
and "/" not in path[len(".project/tasks/") :]
|
|
3152
|
+
and not (repo / path).is_file()
|
|
3153
|
+
and path.rsplit("/", 1)[1] not in archived
|
|
3154
|
+
)
|
|
3155
|
+
if removed:
|
|
3156
|
+
raise IsolationError(
|
|
3157
|
+
"build checkpoints never delete or rename task briefs: "
|
|
3158
|
+
+ ", ".join(removed)
|
|
3159
|
+
)
|
|
3160
|
+
return {
|
|
3161
|
+
"commit": _commit_pending(repo, pending, normalized_subject, body),
|
|
3162
|
+
"paths": sorted(pending),
|
|
3163
|
+
"status": "committed",
|
|
3164
|
+
"subject": normalized_subject,
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
|
|
3168
|
+
def _unlanded_task_commits(repo: Path, bound: str, branch: str) -> list[str]:
|
|
3169
|
+
"""Return branch commits whose patch is not on the bound branch."""
|
|
3170
|
+
return [
|
|
3171
|
+
line[2:]
|
|
3172
|
+
for line in git_output(repo, "cherry", bound, branch).splitlines()
|
|
3173
|
+
if line.startswith("+ ")
|
|
3174
|
+
]
|
|
3175
|
+
|
|
3176
|
+
|
|
3177
|
+
def _require_landed_task_branch(
|
|
3178
|
+
primary: Path,
|
|
3179
|
+
bound: str,
|
|
3180
|
+
branch: str,
|
|
3181
|
+
landed_commit: Optional[str],
|
|
3182
|
+
task_file: Optional[str],
|
|
3183
|
+
) -> None:
|
|
3184
|
+
"""Refuse to delete a task branch while it still holds unlanded commits."""
|
|
3185
|
+
unlanded = _unlanded_task_commits(primary, bound, branch)
|
|
3186
|
+
if not unlanded:
|
|
3187
|
+
return
|
|
3188
|
+
if landed_commit:
|
|
3189
|
+
landed = require_commit(primary, require_full_sha(landed_commit))
|
|
3190
|
+
_require_first_parent_commit(primary, bound, landed)
|
|
3191
|
+
base = _landing_base(primary, landed)
|
|
3192
|
+
proof_error = _task_branch_proof_error(primary, branch, base, landed)
|
|
3193
|
+
elif task_file:
|
|
3194
|
+
proof_error = _failed_task_branch_proof_error(primary, branch, task_file)
|
|
3195
|
+
else:
|
|
3196
|
+
proof_error = (
|
|
3197
|
+
"pass --landed-commit <landing commit> or --task-file <failed task file>"
|
|
3198
|
+
)
|
|
3199
|
+
if proof_error:
|
|
3200
|
+
raise IsolationError(
|
|
3201
|
+
f"refusing to retire {branch}: {len(unlanded)} commit(s) are not landed "
|
|
3202
|
+
f"on {bound}: {', '.join(unlanded)}; {proof_error}; run: "
|
|
3203
|
+
f"python3 {Path(__file__).resolve()} recover --repo {primary}"
|
|
3204
|
+
)
|
|
3205
|
+
|
|
3206
|
+
|
|
3207
|
+
def retire(
|
|
3208
|
+
primary: Path,
|
|
3209
|
+
worktree: Optional[Path],
|
|
3210
|
+
branch: Optional[str],
|
|
3211
|
+
force: bool,
|
|
3212
|
+
landed_commit: Optional[str] = None,
|
|
3213
|
+
task_file: Optional[str] = None,
|
|
3214
|
+
) -> Dict[str, object]:
|
|
3215
|
+
primary = require_directory(primary, "primary worktree")
|
|
3216
|
+
bound = require_attached(primary)
|
|
3217
|
+
if branch == bound:
|
|
3218
|
+
raise IsolationError("refusing to retire the bound branch")
|
|
3219
|
+
expected_worktree = (
|
|
3220
|
+
_sidecar_path_for_branch(primary, branch) if branch else None
|
|
3221
|
+
)
|
|
3222
|
+
if worktree is None:
|
|
3223
|
+
if expected_worktree is None:
|
|
3224
|
+
raise IsolationError("retire requires --worktree or --branch")
|
|
3225
|
+
resolved_worktree = expected_worktree
|
|
3226
|
+
else:
|
|
3227
|
+
resolved_worktree = worktree.resolve()
|
|
3228
|
+
if (
|
|
3229
|
+
branch
|
|
3230
|
+
and not resolved_worktree.exists()
|
|
3231
|
+
and expected_worktree is not None
|
|
3232
|
+
and resolved_worktree != expected_worktree
|
|
3233
|
+
):
|
|
3234
|
+
raise IsolationError("absent worktree path does not match the sidecar branch")
|
|
3235
|
+
if resolved_worktree == primary:
|
|
3236
|
+
if branch:
|
|
3237
|
+
raise IsolationError("refusing to retire a sidecar branch through the primary")
|
|
3238
|
+
return {
|
|
3239
|
+
"bound_branch": bound,
|
|
3240
|
+
"branch": None,
|
|
3241
|
+
"retired": False,
|
|
3242
|
+
"reason": "serial",
|
|
3243
|
+
"worktree": str(primary),
|
|
3244
|
+
}
|
|
3245
|
+
if not resolved_worktree.exists():
|
|
3246
|
+
branch_retired = False
|
|
3247
|
+
if branch:
|
|
3248
|
+
if branch == bound:
|
|
3249
|
+
raise IsolationError("refusing to delete the bound branch")
|
|
3250
|
+
if not (
|
|
3251
|
+
branch.startswith(TASK_BRANCH_PREFIX)
|
|
3252
|
+
or branch.startswith(VERIFY_BRANCH_PREFIX)
|
|
3253
|
+
):
|
|
3254
|
+
raise IsolationError(f"refusing to retire unrecognized branch {branch}")
|
|
3255
|
+
_delete_task_authorization(primary, branch)
|
|
3256
|
+
existing = run_git(
|
|
3257
|
+
primary, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"
|
|
3258
|
+
)
|
|
3259
|
+
if existing.returncode == 0:
|
|
3260
|
+
deleted = run_git(primary, "branch", "-d", branch)
|
|
3261
|
+
if deleted.returncode != 0 and not force:
|
|
3262
|
+
raise IsolationError(
|
|
3263
|
+
(deleted.stderr or deleted.stdout).strip()
|
|
3264
|
+
or f"could not delete {branch}"
|
|
3265
|
+
)
|
|
3266
|
+
if deleted.returncode != 0:
|
|
3267
|
+
if not branch.startswith(TASK_BRANCH_PREFIX):
|
|
3268
|
+
raise IsolationError(
|
|
3269
|
+
"forced branch-only retirement requires a task branch"
|
|
3270
|
+
)
|
|
3271
|
+
if landed_commit:
|
|
3272
|
+
landed = require_commit(primary, require_full_sha(landed_commit))
|
|
3273
|
+
_require_first_parent_commit(primary, bound, landed)
|
|
3274
|
+
base = _landing_base(primary, landed)
|
|
3275
|
+
proof_error = _task_branch_proof_error(
|
|
3276
|
+
primary, branch, base, landed
|
|
3277
|
+
)
|
|
3278
|
+
elif task_file:
|
|
3279
|
+
proof_error = _failed_task_branch_proof_error(
|
|
3280
|
+
primary, branch, task_file
|
|
3281
|
+
)
|
|
3282
|
+
else:
|
|
3283
|
+
raise IsolationError(
|
|
3284
|
+
"forced branch-only task retirement requires --landed-commit or --task-file"
|
|
3285
|
+
)
|
|
3286
|
+
if proof_error:
|
|
3287
|
+
raise IsolationError(proof_error)
|
|
3288
|
+
deleted = run_git(primary, "branch", "-D", branch)
|
|
3289
|
+
if deleted.returncode != 0:
|
|
3290
|
+
raise IsolationError(
|
|
3291
|
+
(deleted.stderr or deleted.stdout).strip()
|
|
3292
|
+
or f"could not delete {branch}"
|
|
3293
|
+
)
|
|
3294
|
+
branch_retired = True
|
|
3295
|
+
return {
|
|
3296
|
+
"bound_branch": bound,
|
|
3297
|
+
"branch": branch,
|
|
3298
|
+
"retired": True,
|
|
3299
|
+
"reason": "branch-only" if branch_retired else "already-absent",
|
|
3300
|
+
"worktree": str(resolved_worktree),
|
|
3301
|
+
}
|
|
3302
|
+
if worktree_root(resolved_worktree) != resolved_worktree:
|
|
3303
|
+
raise IsolationError(f"worktree is not its Git root: {resolved_worktree}")
|
|
3304
|
+
if common_git_dir(primary) != common_git_dir(resolved_worktree):
|
|
3305
|
+
raise IsolationError("worktree belongs to another repository")
|
|
3306
|
+
current = require_attached(resolved_worktree)
|
|
3307
|
+
if current == bound:
|
|
3308
|
+
raise IsolationError("refusing to retire a worktree on the bound branch")
|
|
3309
|
+
if branch and current != branch:
|
|
3310
|
+
raise IsolationError(f"worktree is on {current}, expected {branch}")
|
|
3311
|
+
if current.startswith(TASK_BRANCH_PREFIX) or current.startswith(VERIFY_BRANCH_PREFIX):
|
|
3312
|
+
retire_branch = current
|
|
3313
|
+
else:
|
|
3314
|
+
raise IsolationError(f"refusing to retire unrecognized branch {current}")
|
|
3315
|
+
if retire_branch.startswith(TASK_BRANCH_PREFIX):
|
|
3316
|
+
_require_landed_task_branch(
|
|
3317
|
+
primary, bound, retire_branch, landed_commit, task_file
|
|
3318
|
+
)
|
|
3319
|
+
_delete_task_authorization(primary, retire_branch)
|
|
3320
|
+
if not force:
|
|
3321
|
+
dirty = git_output(
|
|
3322
|
+
resolved_worktree, "status", "--porcelain", "--untracked-files=all"
|
|
3323
|
+
)
|
|
3324
|
+
if dirty:
|
|
3325
|
+
raise IsolationError(
|
|
3326
|
+
"worktree is dirty; pass --force only from the retry-retirement path"
|
|
3327
|
+
)
|
|
3328
|
+
remove = run_git(
|
|
3329
|
+
primary,
|
|
3330
|
+
"worktree",
|
|
3331
|
+
"remove",
|
|
3332
|
+
*(("--force",) if force else ()),
|
|
3333
|
+
str(resolved_worktree),
|
|
3334
|
+
)
|
|
3335
|
+
if remove.returncode != 0:
|
|
3336
|
+
raise IsolationError(
|
|
3337
|
+
(remove.stderr or remove.stdout).strip() or "git worktree remove failed"
|
|
3338
|
+
)
|
|
3339
|
+
deleted = run_git(primary, "branch", "-D", retire_branch)
|
|
3340
|
+
if deleted.returncode != 0:
|
|
3341
|
+
raise IsolationError(
|
|
3342
|
+
(deleted.stderr or deleted.stdout).strip() or f"could not delete {retire_branch}"
|
|
3343
|
+
)
|
|
3344
|
+
return {
|
|
3345
|
+
"bound_branch": bound,
|
|
3346
|
+
"branch": retire_branch,
|
|
3347
|
+
"retired": True,
|
|
3348
|
+
"reason": "removed",
|
|
3349
|
+
"worktree": str(resolved_worktree),
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
|
|
3353
|
+
def parser() -> argparse.ArgumentParser:
|
|
3354
|
+
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
3355
|
+
subparsers = argument_parser.add_subparsers(dest="command", required=True)
|
|
3356
|
+
|
|
3357
|
+
isolate_task_parser = subparsers.add_parser(
|
|
3358
|
+
"isolate-task", help="create or reuse a named task checkout"
|
|
3359
|
+
)
|
|
3360
|
+
isolate_task_parser.add_argument("--repo", type=Path, required=True)
|
|
3361
|
+
isolate_task_parser.add_argument("--base", required=True)
|
|
3362
|
+
isolate_task_parser.add_argument("--task-id", required=True)
|
|
3363
|
+
isolate_task_parser.add_argument("--round-size", type=int, required=True)
|
|
3364
|
+
|
|
3365
|
+
activate_task_parser = subparsers.add_parser(
|
|
3366
|
+
"activate-task", help="record one helper-owned task dispatch"
|
|
3367
|
+
)
|
|
3368
|
+
activate_task_parser.add_argument("--repo", type=Path, required=True)
|
|
3369
|
+
activate_task_parser.add_argument("--base", required=True)
|
|
3370
|
+
activate_task_parser.add_argument("--task-id", required=True)
|
|
3371
|
+
activate_task_parser.add_argument("--agent", required=True)
|
|
3372
|
+
activate_task_parser.add_argument("--task-file", required=True)
|
|
3373
|
+
activate_task_parser.add_argument("--task-branch")
|
|
3374
|
+
|
|
3375
|
+
deactivate_task_parser = subparsers.add_parser(
|
|
3376
|
+
"deactivate-task", help="revoke one helper-owned task dispatch"
|
|
3377
|
+
)
|
|
3378
|
+
deactivate_task_parser.add_argument("--repo", type=Path, required=True)
|
|
3379
|
+
deactivate_task_parser.add_argument("--task-id", required=True)
|
|
3380
|
+
deactivate_task_parser.add_argument("--task-branch", required=True)
|
|
3381
|
+
|
|
3382
|
+
isolate_verify_parser = subparsers.add_parser(
|
|
3383
|
+
"isolate-verify", help="create a named verify sidecar"
|
|
3384
|
+
)
|
|
3385
|
+
isolate_verify_parser.add_argument("--repo", type=Path, required=True)
|
|
3386
|
+
isolate_verify_parser.add_argument("--base", required=True)
|
|
3387
|
+
isolate_verify_parser.add_argument("--name", required=True)
|
|
3388
|
+
isolate_verify_parser.add_argument("--historical-task")
|
|
3389
|
+
|
|
3390
|
+
land_parser = subparsers.add_parser("land", help="commit a task onto the bound branch")
|
|
3391
|
+
land_parser.add_argument("--repo", type=Path, required=True)
|
|
3392
|
+
land_parser.add_argument("--source", type=Path, required=True)
|
|
3393
|
+
land_parser.add_argument("--base", required=True)
|
|
3394
|
+
land_parser.add_argument("--task-id", required=True)
|
|
3395
|
+
land_parser.add_argument("--title", required=True)
|
|
3396
|
+
land_parser.add_argument("--task-file", required=True)
|
|
3397
|
+
land_parser.add_argument("--allow-path", action="append", default=[], dest="allow_paths")
|
|
3398
|
+
|
|
3399
|
+
attest_parser = subparsers.add_parser(
|
|
3400
|
+
"attest", help="record an owner ruling that a done task's work is already on the branch"
|
|
3401
|
+
)
|
|
3402
|
+
attest_parser.add_argument("--repo", type=Path, required=True)
|
|
3403
|
+
attest_parser.add_argument("--task-file", required=True)
|
|
3404
|
+
attest_parser.add_argument("--ruling", required=True)
|
|
3405
|
+
attest_parser.add_argument("--base", help="full or abbreviated base SHA; defaults to the task's recorded base")
|
|
3406
|
+
|
|
3407
|
+
adopt_parser = subparsers.add_parser(
|
|
3408
|
+
"adopt-rebase", help="record an owner ruling adopting every done task's rebased landing"
|
|
3409
|
+
)
|
|
3410
|
+
adopt_parser.add_argument("--repo", type=Path, required=True)
|
|
3411
|
+
adopt_parser.add_argument("--original-head", required=True, help="preserved original tip, full SHA")
|
|
3412
|
+
adopt_parser.add_argument("--head", required=True, help="rebased HEAD being adopted, full SHA")
|
|
3413
|
+
adopt_parser.add_argument("--ruling", required=True)
|
|
3414
|
+
|
|
3415
|
+
recover_parser = subparsers.add_parser(
|
|
3416
|
+
"recover", help="prove landed task commits from git; read-only"
|
|
3417
|
+
)
|
|
3418
|
+
recover_parser.add_argument("--repo", type=Path, required=True)
|
|
3419
|
+
recover_parser.add_argument("--tasks-dir", type=Path, default=Path(".project/tasks"))
|
|
3420
|
+
|
|
3421
|
+
collect_parser = subparsers.add_parser(
|
|
3422
|
+
"collect-artifact", help="atomically collect one verified sidecar artifact"
|
|
3423
|
+
)
|
|
3424
|
+
collect_parser.add_argument("--repo", type=Path, required=True)
|
|
3425
|
+
collect_parser.add_argument("--source", type=Path, required=True)
|
|
3426
|
+
collect_parser.add_argument("--base", required=True)
|
|
3427
|
+
collect_parser.add_argument("--branch", required=True)
|
|
3428
|
+
collect_parser.add_argument("--source-path", required=True)
|
|
3429
|
+
collect_parser.add_argument("--destination-path", required=True)
|
|
3430
|
+
collect_parser.add_argument(
|
|
3431
|
+
"--expected-destination",
|
|
3432
|
+
help="base or the recorded SHA-256 of an existing destination",
|
|
3433
|
+
)
|
|
3434
|
+
|
|
3435
|
+
checkpoint_parser = subparsers.add_parser(
|
|
3436
|
+
"checkpoint", help="commit allowlisted pipeline bookkeeping"
|
|
3437
|
+
)
|
|
3438
|
+
checkpoint_parser.add_argument("--repo", type=Path, required=True)
|
|
3439
|
+
checkpoint_parser.add_argument("--expected-head", required=True)
|
|
3440
|
+
checkpoint_parser.add_argument("--subject", required=True)
|
|
3441
|
+
checkpoint_parser.add_argument("--body", required=True)
|
|
3442
|
+
checkpoint_parser.add_argument(
|
|
3443
|
+
"--allow-path", action="append", required=True, dest="allow_paths"
|
|
3444
|
+
)
|
|
3445
|
+
|
|
3446
|
+
retire_parser = subparsers.add_parser("retire", help="remove a named sidecar checkout")
|
|
3447
|
+
retire_parser.add_argument("--repo", type=Path, required=True)
|
|
3448
|
+
retire_parser.add_argument("--worktree", type=Path)
|
|
3449
|
+
retire_parser.add_argument("--branch")
|
|
3450
|
+
retire_parser.add_argument("--force", action="store_true")
|
|
3451
|
+
retire_parser.add_argument("--landed-commit")
|
|
3452
|
+
retire_parser.add_argument("--task-file")
|
|
3453
|
+
return argument_parser
|
|
3454
|
+
|
|
3455
|
+
|
|
3456
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
3457
|
+
arguments = parser().parse_args(argv)
|
|
3458
|
+
try:
|
|
3459
|
+
if arguments.command == "isolate-task":
|
|
3460
|
+
result = isolate_task(
|
|
3461
|
+
arguments.repo,
|
|
3462
|
+
arguments.base,
|
|
3463
|
+
arguments.task_id,
|
|
3464
|
+
arguments.round_size,
|
|
3465
|
+
)
|
|
3466
|
+
elif arguments.command == "activate-task":
|
|
3467
|
+
result = activate_task(
|
|
3468
|
+
arguments.repo,
|
|
3469
|
+
arguments.base,
|
|
3470
|
+
arguments.task_id,
|
|
3471
|
+
arguments.agent,
|
|
3472
|
+
arguments.task_file,
|
|
3473
|
+
arguments.task_branch,
|
|
3474
|
+
)
|
|
3475
|
+
elif arguments.command == "deactivate-task":
|
|
3476
|
+
result = deactivate_task(
|
|
3477
|
+
arguments.repo,
|
|
3478
|
+
arguments.task_id,
|
|
3479
|
+
arguments.task_branch,
|
|
3480
|
+
)
|
|
3481
|
+
elif arguments.command == "isolate-verify":
|
|
3482
|
+
result = isolate_verify(arguments.repo, arguments.base, arguments.name, arguments.historical_task)
|
|
3483
|
+
elif arguments.command == "land":
|
|
3484
|
+
result = land(
|
|
3485
|
+
arguments.repo,
|
|
3486
|
+
arguments.source,
|
|
3487
|
+
arguments.base,
|
|
3488
|
+
arguments.task_id,
|
|
3489
|
+
arguments.title,
|
|
3490
|
+
arguments.task_file,
|
|
3491
|
+
arguments.allow_paths,
|
|
3492
|
+
)
|
|
3493
|
+
elif arguments.command == "attest":
|
|
3494
|
+
result = attest(
|
|
3495
|
+
arguments.repo, arguments.task_file, arguments.ruling, arguments.base
|
|
3496
|
+
)
|
|
3497
|
+
elif arguments.command == "adopt-rebase":
|
|
3498
|
+
result = record_rebase_adoption(
|
|
3499
|
+
arguments.repo, arguments.original_head, arguments.head, arguments.ruling
|
|
3500
|
+
)
|
|
3501
|
+
elif arguments.command == "recover":
|
|
3502
|
+
result = recover(arguments.repo, arguments.tasks_dir)
|
|
3503
|
+
elif arguments.command == "collect-artifact":
|
|
3504
|
+
result = collect_artifact(
|
|
3505
|
+
arguments.repo,
|
|
3506
|
+
arguments.source,
|
|
3507
|
+
arguments.base,
|
|
3508
|
+
arguments.branch,
|
|
3509
|
+
arguments.source_path,
|
|
3510
|
+
arguments.destination_path,
|
|
3511
|
+
arguments.expected_destination,
|
|
3512
|
+
)
|
|
3513
|
+
elif arguments.command == "checkpoint":
|
|
3514
|
+
result = checkpoint(
|
|
3515
|
+
arguments.repo,
|
|
3516
|
+
arguments.expected_head,
|
|
3517
|
+
arguments.subject,
|
|
3518
|
+
arguments.body,
|
|
3519
|
+
arguments.allow_paths,
|
|
3520
|
+
)
|
|
3521
|
+
else:
|
|
3522
|
+
result = retire(
|
|
3523
|
+
arguments.repo,
|
|
3524
|
+
arguments.worktree,
|
|
3525
|
+
arguments.branch,
|
|
3526
|
+
arguments.force,
|
|
3527
|
+
arguments.landed_commit,
|
|
3528
|
+
arguments.task_file,
|
|
3529
|
+
)
|
|
3530
|
+
except IsolationError as error:
|
|
3531
|
+
print(str(error), file=sys.stderr)
|
|
3532
|
+
return 1
|
|
3533
|
+
print(json.dumps(result, sort_keys=True))
|
|
3534
|
+
return 0
|
|
3535
|
+
|
|
3536
|
+
|
|
3537
|
+
if __name__ == "__main__":
|
|
3538
|
+
raise SystemExit(main())
|