@opengsd/gsd-path 1.0.0 → 1.1.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 +28 -11
- package/DOCS.md +6 -5
- package/FULL.md +9 -13
- package/QUICK.md +3 -3
- package/README.md +151 -14
- package/RUNTIME.md +7 -1
- package/WORKFLOW.md +30 -14
- package/package.json +15 -1
- package/platforms/shared-agents/dispatch.md +6 -0
- package/scripts/archive_milestone.py +10 -118
- package/scripts/bootstrap_repository.py +25 -3
- package/scripts/build_recovery.py +287 -0
- package/scripts/check_docs_audit.py +5 -3
- package/scripts/check_handoffs.py +1 -1
- package/scripts/detect_project.py +11 -2
- package/scripts/dispatch_driver.py +4 -2
- package/scripts/git_guard.py +2 -1
- package/scripts/install.mjs +2 -0
- package/scripts/install.py +2 -0
- package/scripts/integration.py +28 -22
- package/scripts/isolation.py +9 -1
- package/scripts/lean_verification.py +1 -1
- package/scripts/pipeline_diagnose.py +12 -3
- package/scripts/pipeline_state.py +114 -186
- package/scripts/pipeline_undo.py +11 -7
- package/scripts/promote_lookahead.py +30 -162
- package/scripts/roadmap.py +321 -0
- package/scripts/skill-resources.json +79 -1
- package/scripts/state_checkpoint.py +109 -72
- package/scripts/state_promote.py +42 -44
- package/scripts/task_context.py +73 -0
- package/scripts/token_budget.py +40 -1
- package/scripts/workflow_run.py +116 -7
- package/skills/gsd-path/BUILD.md +47 -575
- package/skills/gsd-path/DECIDE.md +2 -6
- package/skills/gsd-path/DEFINE.md +10 -14
- package/skills/gsd-path/INSPECT.md +30 -8
- package/skills/gsd-path/PLAN.md +25 -17
- package/skills/gsd-path/RESEARCH.md +3 -6
- package/skills/gsd-path/ROADMAP.md +22 -58
- package/skills/gsd-path/SHIP.md +5 -4
- package/skills/gsd-path/SKILL.md +24 -14
- package/skills/gsd-path/references/build-abandon.md +33 -0
- package/skills/gsd-path/references/build-native.md +494 -0
- package/skills/gsd-path/references/build-recovery.md +78 -0
- package/skills/gsd-path/references/coder.md +8 -3
- package/skills/gsd-path/references/dispatch.md +6 -0
- package/skills/gsd-path/references/docs-auditor.md +14 -0
- package/skills/gsd-path/references/reviewer-final.md +21 -0
- package/skills/gsd-path/references/reviewer-panel.md +17 -0
- package/skills/gsd-path/references/reviewer-skeptic.md +20 -0
- package/skills/gsd-path/references/reviewer-wave.md +89 -0
- package/skills/gsd-path/references/reviewer.md +12 -150
- package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/build_state.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/check_task_briefs.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/discussion_records.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/integration.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/loop_run.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/review_findings.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/task_context.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/workflow_run.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/archive_milestone.py +10 -118
- package/skills/gsd-path/scripts/bootstrap_repository.py +25 -3
- package/skills/gsd-path/scripts/build_recovery.py +287 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +5 -3
- package/skills/gsd-path/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path/scripts/detect_project.py +11 -2
- package/skills/gsd-path/scripts/dispatch_driver.py +4 -2
- package/skills/gsd-path/scripts/integration.py +28 -22
- package/skills/gsd-path/scripts/isolation.py +9 -1
- package/skills/gsd-path/scripts/lean_verification.py +1 -1
- package/skills/gsd-path/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path/scripts/promote_lookahead.py +30 -162
- package/skills/gsd-path/scripts/roadmap.py +321 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path/scripts/state_promote.py +42 -44
- package/skills/gsd-path/scripts/task_context.py +73 -0
- package/skills/gsd-path/scripts/token_budget.py +40 -1
- package/skills/gsd-path/scripts/workflow_run.py +116 -7
- package/skills/gsd-path/templates/wave-review.md +1 -1
- package/skills/gsd-path-build/SKILL.md +47 -575
- package/skills/gsd-path-build/references/build-abandon.md +33 -0
- package/skills/gsd-path-build/references/build-native.md +494 -0
- package/skills/gsd-path-build/references/build-recovery.md +78 -0
- package/skills/gsd-path-build/references/coder.md +8 -3
- package/skills/gsd-path-build/references/dispatch.md +6 -0
- package/skills/gsd-path-build/references/reviewer-final.md +21 -0
- package/skills/gsd-path-build/references/reviewer-panel.md +17 -0
- package/skills/gsd-path-build/references/reviewer-skeptic.md +20 -0
- package/skills/gsd-path-build/references/reviewer-wave.md +89 -0
- package/skills/gsd-path-build/references/reviewer.md +12 -150
- package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/build_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/check_task_briefs.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/loop_run.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/review_findings.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/task_context.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/workflow_run.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +10 -118
- package/skills/gsd-path-build/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-build/scripts/detect_project.py +1526 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +4 -2
- package/skills/gsd-path-build/scripts/integration.py +28 -22
- package/skills/gsd-path-build/scripts/isolation.py +9 -1
- package/skills/gsd-path-build/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-build/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-build/scripts/roadmap.py +321 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-build/scripts/state_promote.py +42 -44
- package/skills/gsd-path-build/scripts/task_context.py +73 -0
- package/skills/gsd-path-build/scripts/token_budget.py +40 -1
- package/skills/gsd-path-build/scripts/workflow_run.py +116 -7
- package/skills/gsd-path-build/templates/wave-review.md +1 -1
- package/skills/gsd-path-decide/SKILL.md +2 -6
- package/skills/gsd-path-decide/references/dispatch.md +6 -0
- package/skills/gsd-path-decide/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-decide/scripts/archive_milestone.py +2157 -0
- package/skills/gsd-path-decide/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-decide/scripts/detect_project.py +1526 -0
- package/skills/gsd-path-decide/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-decide/scripts/integration.py +1439 -0
- package/skills/gsd-path-decide/scripts/isolation.py +9 -1
- package/skills/gsd-path-decide/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-decide/scripts/review_panel.py +553 -0
- package/skills/gsd-path-decide/scripts/roadmap.py +321 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-decide/scripts/state_promote.py +42 -44
- package/skills/gsd-path-define/SKILL.md +10 -14
- package/skills/gsd-path-define/references/build-recovery.md +78 -0
- package/skills/gsd-path-define/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-define/scripts/archive_milestone.py +2157 -0
- package/skills/gsd-path-define/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-define/scripts/detect_project.py +11 -2
- package/skills/gsd-path-define/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-define/scripts/integration.py +1439 -0
- package/skills/gsd-path-define/scripts/isolation.py +9 -1
- package/skills/gsd-path-define/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-define/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-define/scripts/review_panel.py +553 -0
- package/skills/gsd-path-define/scripts/roadmap.py +321 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-define/scripts/state_promote.py +42 -44
- package/skills/gsd-path-docs-audit/references/dispatch.md +6 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +14 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +2157 -0
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +5 -3
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +1526 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-docs-audit/scripts/integration.py +1439 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-docs-audit/scripts/review_panel.py +553 -0
- package/skills/gsd-path-docs-audit/scripts/roadmap.py +321 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +42 -44
- package/skills/gsd-path-forensics/scripts/__pycache__/pipeline_undo.cpython-312.pyc +0 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +12 -3
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +11 -7
- package/skills/gsd-path-inspect/SKILL.md +30 -8
- package/skills/gsd-path-inspect/references/dispatch.md +6 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +14 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +2157 -0
- package/skills/gsd-path-inspect/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +5 -3
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-inspect/scripts/detect_project.py +11 -2
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-inspect/scripts/integration.py +1439 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +9 -1
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-inspect/scripts/review_panel.py +553 -0
- package/skills/gsd-path-inspect/scripts/roadmap.py +321 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-inspect/scripts/state_promote.py +42 -44
- package/skills/gsd-path-inspect/scripts/workflow_run.py +226 -0
- package/skills/gsd-path-plan/SKILL.md +25 -17
- package/skills/gsd-path-plan/references/build-recovery.md +78 -0
- package/skills/gsd-path-plan/references/dispatch.md +6 -0
- package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/archive_milestone.py +2157 -0
- package/skills/gsd-path-plan/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-plan/scripts/detect_project.py +1526 -0
- package/skills/gsd-path-plan/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-plan/scripts/integration.py +1439 -0
- package/skills/gsd-path-plan/scripts/isolation.py +9 -1
- package/skills/gsd-path-plan/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-plan/scripts/roadmap.py +321 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-plan/scripts/state_promote.py +42 -44
- package/skills/gsd-path-plan/scripts/workflow_run.py +116 -7
- package/skills/gsd-path-research/SKILL.md +3 -6
- package/skills/gsd-path-research/references/dispatch.md +6 -0
- package/skills/gsd-path-research/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-research/scripts/archive_milestone.py +2157 -0
- package/skills/gsd-path-research/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-research/scripts/detect_project.py +1526 -0
- package/skills/gsd-path-research/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-research/scripts/integration.py +1439 -0
- package/skills/gsd-path-research/scripts/isolation.py +9 -1
- package/skills/gsd-path-research/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-research/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-research/scripts/review_panel.py +553 -0
- package/skills/gsd-path-research/scripts/roadmap.py +321 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-research/scripts/state_promote.py +42 -44
- package/skills/gsd-path-roadmap/SKILL.md +22 -58
- package/skills/gsd-path-roadmap/references/dispatch.md +6 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +10 -118
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-roadmap/scripts/detect_project.py +11 -2
- package/skills/gsd-path-roadmap/scripts/integration.py +28 -22
- package/skills/gsd-path-roadmap/scripts/isolation.py +9 -1
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +30 -162
- package/skills/gsd-path-roadmap/scripts/roadmap.py +321 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-roadmap/scripts/state_promote.py +42 -44
- package/skills/gsd-path-ship/SKILL.md +5 -4
- package/skills/gsd-path-ship/references/dispatch.md +6 -0
- package/skills/gsd-path-ship/references/reviewer-final.md +21 -0
- package/skills/gsd-path-ship/references/reviewer-panel.md +17 -0
- package/skills/gsd-path-ship/references/reviewer-skeptic.md +20 -0
- package/skills/gsd-path-ship/references/reviewer-wave.md +89 -0
- package/skills/gsd-path-ship/references/reviewer.md +12 -150
- package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/build_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/check_task_briefs.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +10 -118
- package/skills/gsd-path-ship/scripts/build_recovery.py +287 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1 -1
- package/skills/gsd-path-ship/scripts/detect_project.py +1526 -0
- package/skills/gsd-path-ship/scripts/integration.py +28 -22
- package/skills/gsd-path-ship/scripts/isolation.py +9 -1
- package/skills/gsd-path-ship/scripts/lean_verification.py +1 -1
- package/skills/gsd-path-ship/scripts/pipeline_state.py +114 -186
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +283 -0
- package/skills/gsd-path-ship/scripts/roadmap.py +321 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +109 -72
- package/skills/gsd-path-ship/scripts/state_promote.py +42 -44
- package/skills/gsd-path-ship/scripts/workflow_run.py +116 -7
- package/skills/gsd-path-ship/templates/wave-review.md +1 -1
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +11 -7
- package/skills/path/BUILD.md +47 -575
- package/skills/path/DECIDE.md +2 -6
- package/skills/path/DEFINE.md +10 -14
- package/skills/path/INSPECT.md +30 -8
- package/skills/path/PLAN.md +25 -17
- package/skills/path/RESEARCH.md +3 -6
- package/skills/path/ROADMAP.md +22 -58
- package/skills/path/SHIP.md +5 -4
- package/skills/path/SKILL.md +24 -14
- package/skills/path/references/build-abandon.md +33 -0
- package/skills/path/references/build-native.md +494 -0
- package/skills/path/references/build-recovery.md +78 -0
- package/skills/path/references/coder.md +8 -3
- package/skills/path/references/dispatch.md +6 -0
- package/skills/path/references/docs-auditor.md +14 -0
- package/skills/path/references/reviewer-final.md +21 -0
- package/skills/path/references/reviewer-panel.md +17 -0
- package/skills/path/references/reviewer-skeptic.md +20 -0
- package/skills/path/references/reviewer-wave.md +89 -0
- package/skills/path/references/reviewer.md +12 -150
- package/skills/path/scripts/archive_milestone.py +10 -118
- package/skills/path/scripts/bootstrap_repository.py +25 -3
- package/skills/path/scripts/build_recovery.py +287 -0
- package/skills/path/scripts/check_docs_audit.py +5 -3
- package/skills/path/scripts/check_handoffs.py +1 -1
- package/skills/path/scripts/detect_project.py +11 -2
- package/skills/path/scripts/dispatch_driver.py +4 -2
- package/skills/path/scripts/integration.py +28 -22
- package/skills/path/scripts/isolation.py +9 -1
- package/skills/path/scripts/lean_verification.py +1 -1
- package/skills/path/scripts/pipeline_state.py +114 -186
- package/skills/path/scripts/promote_lookahead.py +30 -162
- package/skills/path/scripts/roadmap.py +321 -0
- package/skills/path/scripts/state_checkpoint.py +109 -72
- package/skills/path/scripts/state_promote.py +42 -44
- package/skills/path/scripts/task_context.py +73 -0
- package/skills/path/scripts/token_budget.py +40 -1
- package/skills/path/scripts/workflow_run.py +116 -7
- package/skills/path/templates/wave-review.md +1 -1
|
@@ -0,0 +1,2157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Prepare, render, validate, integrate, and abandon GSD Path milestone archives.
|
|
4
|
+
|
|
5
|
+
Also validates milestone integration: the read-only validate-integrated
|
|
6
|
+
command checks the shipped transaction, the selected mode's two-parent merge
|
|
7
|
+
on the remote default branch, and the milestone tag. Pull-request validation
|
|
8
|
+
requires network access to verify live origin publication.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import filecmp
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from datetime import date
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Iterator, NamedTuple, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from isolation import (
|
|
26
|
+
IsolationError,
|
|
27
|
+
checkpoint as isolation_checkpoint,
|
|
28
|
+
verify_landed_task_files,
|
|
29
|
+
)
|
|
30
|
+
from pipeline_git import (
|
|
31
|
+
bound_branch_name,
|
|
32
|
+
default_branch_name,
|
|
33
|
+
integrate_commit_body,
|
|
34
|
+
integrate_subject,
|
|
35
|
+
is_bound_branch,
|
|
36
|
+
is_integrate_subject,
|
|
37
|
+
is_ship_subject,
|
|
38
|
+
milestone_id,
|
|
39
|
+
milestone_number,
|
|
40
|
+
ship_commit_body,
|
|
41
|
+
ship_subject,
|
|
42
|
+
)
|
|
43
|
+
from review_panel import KNOWN_FAMILIES, ReviewPanelError, parse_sources
|
|
44
|
+
from pipeline_state import (
|
|
45
|
+
PipelineState,
|
|
46
|
+
PipelineStateError,
|
|
47
|
+
load_state,
|
|
48
|
+
transition_state,
|
|
49
|
+
)
|
|
50
|
+
import _common
|
|
51
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
52
|
+
from scripts.isolation import (
|
|
53
|
+
IsolationError,
|
|
54
|
+
checkpoint as isolation_checkpoint,
|
|
55
|
+
verify_landed_task_files,
|
|
56
|
+
)
|
|
57
|
+
from scripts.pipeline_git import (
|
|
58
|
+
bound_branch_name,
|
|
59
|
+
default_branch_name,
|
|
60
|
+
integrate_commit_body,
|
|
61
|
+
integrate_subject,
|
|
62
|
+
is_bound_branch,
|
|
63
|
+
is_integrate_subject,
|
|
64
|
+
is_ship_subject,
|
|
65
|
+
milestone_id,
|
|
66
|
+
milestone_number,
|
|
67
|
+
ship_commit_body,
|
|
68
|
+
ship_subject,
|
|
69
|
+
)
|
|
70
|
+
from scripts.review_panel import KNOWN_FAMILIES, ReviewPanelError, parse_sources
|
|
71
|
+
from scripts.pipeline_state import (
|
|
72
|
+
PipelineState,
|
|
73
|
+
PipelineStateError,
|
|
74
|
+
load_state,
|
|
75
|
+
transition_state,
|
|
76
|
+
)
|
|
77
|
+
from scripts import _common
|
|
78
|
+
|
|
79
|
+
if sys.platform == "win32":
|
|
80
|
+
import msvcrt
|
|
81
|
+
else:
|
|
82
|
+
import fcntl
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
ARCHIVE_PATTERN = re.compile(r"^(\d{3,})-([a-z0-9][a-z0-9-]*)$")
|
|
86
|
+
TASK_FILE_PATTERN = re.compile(r"^T\d{3}-[a-z0-9][a-z0-9-]*\.md$")
|
|
87
|
+
WAVE_FILE_PATTERN = re.compile(
|
|
88
|
+
r"^wave-([1-9]\d*)\.cycle([1-9]\d*)(\.(?:contract|adversarial|panel))?\.md$"
|
|
89
|
+
)
|
|
90
|
+
WAVE_PANEL_SKIP_PATTERN = re.compile(
|
|
91
|
+
r"^wave-([1-9]\d*)\.cycle([1-9]\d*)\.panel\.skipped\.json$"
|
|
92
|
+
)
|
|
93
|
+
WAVE_SKEPTIC_PATTERN = re.compile(
|
|
94
|
+
r"^wave-([1-9]\d*)\.cycle([1-9]\d*)\.skeptic-"
|
|
95
|
+
r"([a-z0-9]+(?:_[a-z0-9]+)*)\.md$"
|
|
96
|
+
)
|
|
97
|
+
WAVE_REPAIR_PATTERN = re.compile(
|
|
98
|
+
r"^wave-([1-9]\d*)\.cycle([1-9]\d*)\.repair-(T\d{3})\.json$"
|
|
99
|
+
)
|
|
100
|
+
# A review file claims to be wave-cycle evidence when its name starts with a
|
|
101
|
+
# wave number followed by "cycle" (a missing dot is a typo, not a note). Other
|
|
102
|
+
# wave-* names are free-form review notes like any non-canonical review file.
|
|
103
|
+
WAVE_CYCLE_CLAIM_PATTERN = re.compile(r"^wave-\d+\.?cycle")
|
|
104
|
+
FINAL_CRITERION_PATTERN = re.compile(r"^### SC([1-9]\d*) — (.+)$")
|
|
105
|
+
INTENT_CRITERION_PATTERN = re.compile(r"^([1-9]\d*)\.\s+(\S.*)$")
|
|
106
|
+
HTML_COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
107
|
+
FINAL_GAP_FILE_PATTERN = re.compile(r"^final-gap-([1-9]\d*)\.md$")
|
|
108
|
+
FINAL_GAP_HEADING_PATTERN = re.compile(r"^# Gap Review — ([1-9]\d*): (.+)$")
|
|
109
|
+
WAVE_TASK_HEADING_PATTERN = re.compile(r"^## (T\d{3}) — (.+): (pass|fail)$")
|
|
110
|
+
WAVE_SC_HEADING_PATTERN = re.compile(
|
|
111
|
+
r"^### (SC[1-9]\d*) — (.+): (pass|fail)$"
|
|
112
|
+
)
|
|
113
|
+
PLAN_COVERAGE_ROW_PATTERN = re.compile(
|
|
114
|
+
r"^\|\s*(SC[1-9]\d*)\s*\|\s*(T\d{3})\s*\|\s*([^|]+?)\s*\|$"
|
|
115
|
+
)
|
|
116
|
+
DIALOGUE_HEADING_PATTERN = re.compile(
|
|
117
|
+
r"^### D(\d{3}) — (\d{4}-\d{2}-\d{2}) — "
|
|
118
|
+
r"([a-z-]+)/(active|blocked|done) — (.+)$"
|
|
119
|
+
)
|
|
120
|
+
ANSWER_HEADING_PATTERN = re.compile(
|
|
121
|
+
r"^## Answer A(\d{3}) — (\d{4}-\d{2}-\d{2}) — .+$"
|
|
122
|
+
)
|
|
123
|
+
DISPOSITION_HEADING_PATTERN = re.compile(
|
|
124
|
+
r"^## Disposition X(\d{3}) — (\d{4}-\d{2}-\d{2})$"
|
|
125
|
+
)
|
|
126
|
+
DISCUSSION_PHASES = {
|
|
127
|
+
"inspect",
|
|
128
|
+
"define",
|
|
129
|
+
"research",
|
|
130
|
+
"decide",
|
|
131
|
+
"roadmap",
|
|
132
|
+
"plan",
|
|
133
|
+
"build",
|
|
134
|
+
"ship",
|
|
135
|
+
}
|
|
136
|
+
DISCUSSION_FILES = ("DIALOGUE.md", "ANSWERS.md")
|
|
137
|
+
EMPTY_DISCUSSION_FILES = {
|
|
138
|
+
"DIALOGUE.md": """# GSD Path Discussion — Dialogue
|
|
139
|
+
|
|
140
|
+
<!-- Written by $gsd-path-discuss. Append turns; never rewrite prior turns. -->
|
|
141
|
+
|
|
142
|
+
## Turns
|
|
143
|
+
""",
|
|
144
|
+
"ANSWERS.md": """# GSD Path Discussion — Answers
|
|
145
|
+
|
|
146
|
+
<!-- Written by $gsd-path-discuss. Append one record per turn; never rewrite prior answers. -->
|
|
147
|
+
""",
|
|
148
|
+
}
|
|
149
|
+
DISCUSSION_TRANSACTION_NAME = ".discussion-archive-transaction.json"
|
|
150
|
+
DIRECTORIES_TO_ARCHIVE = ("intent", "research", "plan", "tasks", "review")
|
|
151
|
+
# build/ holds the verify ledger; it archives with the milestone when present.
|
|
152
|
+
OPTIONAL_DIRECTORIES_TO_ARCHIVE = ("discuss", "build")
|
|
153
|
+
TRANSACTION_DIRECTORIES = (*DIRECTORIES_TO_ARCHIVE, *OPTIONAL_DIRECTORIES_TO_ARCHIVE)
|
|
154
|
+
MANIFEST_FIELDS = (
|
|
155
|
+
"Milestone:",
|
|
156
|
+
"Shipped:",
|
|
157
|
+
"Final verdict:",
|
|
158
|
+
"Waves:",
|
|
159
|
+
"Carried forward:",
|
|
160
|
+
)
|
|
161
|
+
MANIFEST_HEADINGS = ("## Success criteria at ship", "## Contents", "## Notes")
|
|
162
|
+
CARRY_TEMP_NAME = ".DOCS-AUDIT.md.gsd-path-tmp"
|
|
163
|
+
STATE_TEMP_NAME = ".STATE.md.gsd-path-tmp"
|
|
164
|
+
MANIFEST_TEMP_NAME = ".MANIFEST.md.gsd-path-tmp"
|
|
165
|
+
ABANDON_JOURNAL_SCHEMA = "gsd-path/abandon/v1"
|
|
166
|
+
ABANDON_JOURNAL_NAME = "gsd-path-abandon.json"
|
|
167
|
+
ROADMAP_HEADING_PATTERN = re.compile(
|
|
168
|
+
r"^### (M\d{3,}) — ([a-z0-9][a-z0-9-]*)\s*$"
|
|
169
|
+
)
|
|
170
|
+
# Evidence files are per-dispatched-dimension: the research phase gate owns
|
|
171
|
+
# their existence, so the archive requires only the always-produced core.
|
|
172
|
+
REQUIRED_ARCHIVE_FILES = (
|
|
173
|
+
"intent/INTENT.md",
|
|
174
|
+
"research/SYNTHESIS.md",
|
|
175
|
+
"plan/PLAN.md",
|
|
176
|
+
"review/FINAL.md",
|
|
177
|
+
)
|
|
178
|
+
# Abandon runs from build: review/ may hold only wave files, but an approved
|
|
179
|
+
# plan implies these artifacts existed.
|
|
180
|
+
ABANDON_REQUIRED_ARCHIVE_FILES = (
|
|
181
|
+
"intent/INTENT.md",
|
|
182
|
+
"research/SYNTHESIS.md",
|
|
183
|
+
"plan/PLAN.md",
|
|
184
|
+
)
|
|
185
|
+
ABANDON_REQUIRED_DIRECTORIES = ("intent", "research", "plan", "tasks")
|
|
186
|
+
|
|
187
|
+
# Template placeholders look like <name> or <one line>. Comparison text such
|
|
188
|
+
# as "120ms < 200ms" or "a -> b" is legitimate evidence, not a placeholder.
|
|
189
|
+
PLACEHOLDER_PATTERN = re.compile(r"(?<!\w)<[a-zA-Z][^<>\n]*>")
|
|
190
|
+
CODE_SPAN_PATTERN = re.compile(r"(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def contains_placeholder(value: str) -> bool:
|
|
194
|
+
# A wholly unfilled value is a placeholder even when quoted.
|
|
195
|
+
cleaned = value.strip().strip("`")
|
|
196
|
+
if PLACEHOLDER_PATTERN.fullmatch(cleaned):
|
|
197
|
+
return True
|
|
198
|
+
# Scan with code spans blanked out: quoted code such as a TypeScript
|
|
199
|
+
# generic is not an unfilled placeholder.
|
|
200
|
+
return PLACEHOLDER_PATTERN.search(CODE_SPAN_PATTERN.sub(" ", value)) is not None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def plan_review_panel_config(plan_text: str) -> dict:
|
|
204
|
+
"""Parse PLAN review-panel policy with the canonical fail-closed parser."""
|
|
205
|
+
try:
|
|
206
|
+
return parse_sources(plan_text, None)
|
|
207
|
+
except ReviewPanelError as error:
|
|
208
|
+
raise ArchiveError(f"PLAN.md review_panel config is invalid: {error}") from error
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def split_manifest_row(line: str) -> Sequence[str]:
|
|
212
|
+
"""Split a manifest table row, honoring \\| as an escaped literal pipe."""
|
|
213
|
+
text = line.strip()
|
|
214
|
+
if text.startswith("|"):
|
|
215
|
+
text = text[1:]
|
|
216
|
+
if text.endswith("|") and not text.endswith("\\|"):
|
|
217
|
+
text = text[:-1]
|
|
218
|
+
cells = []
|
|
219
|
+
current = []
|
|
220
|
+
index = 0
|
|
221
|
+
while index < len(text):
|
|
222
|
+
char = text[index]
|
|
223
|
+
if char == "\\" and index + 1 < len(text) and text[index + 1] == "|":
|
|
224
|
+
current.append("|")
|
|
225
|
+
index += 2
|
|
226
|
+
continue
|
|
227
|
+
if char == "|":
|
|
228
|
+
cells.append("".join(current).strip())
|
|
229
|
+
current = []
|
|
230
|
+
else:
|
|
231
|
+
current.append(char)
|
|
232
|
+
index += 1
|
|
233
|
+
cells.append("".join(current).strip())
|
|
234
|
+
return cells
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ArchiveError(RuntimeError):
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
PIPELINE_MARKER = _common.PIPELINE_MARKER
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# discussion_validate calls back into this module and imports its constants
|
|
245
|
+
# and ArchiveError, so this import must stay below those definitions.
|
|
246
|
+
if __package__: # imported as scripts.archive_milestone
|
|
247
|
+
from .discussion_validate import (
|
|
248
|
+
record_sections,
|
|
249
|
+
required_dialogue_block,
|
|
250
|
+
require_contiguous_ids,
|
|
251
|
+
require_iso_date,
|
|
252
|
+
require_bullet_fields,
|
|
253
|
+
validate_discussion_directory,
|
|
254
|
+
require_append_only_discussion,
|
|
255
|
+
reconcile_append_only_discussion,
|
|
256
|
+
require_safe_discussion_destination,
|
|
257
|
+
finish_discussion_reconciliation,
|
|
258
|
+
remove_interrupted_discussion_copies,
|
|
259
|
+
remove_interrupted_carry_copy,
|
|
260
|
+
require_transaction_context,
|
|
261
|
+
completed_field,
|
|
262
|
+
completed_bullet_field,
|
|
263
|
+
section_lines,
|
|
264
|
+
archived_intent_criteria,
|
|
265
|
+
parse_final_review,
|
|
266
|
+
validate_gap_reviews,
|
|
267
|
+
meaningful_review_evidence,
|
|
268
|
+
archived_wave_tasks,
|
|
269
|
+
plan_wave_criteria,
|
|
270
|
+
validate_wave_review,
|
|
271
|
+
plan_wave_depths,
|
|
272
|
+
review_cycle_counts,
|
|
273
|
+
manifest_cell,
|
|
274
|
+
archive_file_inventory,
|
|
275
|
+
render_manifest,
|
|
276
|
+
validate_manifest,
|
|
277
|
+
)
|
|
278
|
+
else: # standalone script or sibling import
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
sys.modules.setdefault("archive_milestone", sys.modules[__name__])
|
|
281
|
+
try:
|
|
282
|
+
from discussion_validate import (
|
|
283
|
+
record_sections,
|
|
284
|
+
required_dialogue_block,
|
|
285
|
+
require_contiguous_ids,
|
|
286
|
+
require_iso_date,
|
|
287
|
+
require_bullet_fields,
|
|
288
|
+
validate_discussion_directory,
|
|
289
|
+
require_append_only_discussion,
|
|
290
|
+
reconcile_append_only_discussion,
|
|
291
|
+
require_safe_discussion_destination,
|
|
292
|
+
finish_discussion_reconciliation,
|
|
293
|
+
remove_interrupted_discussion_copies,
|
|
294
|
+
remove_interrupted_carry_copy,
|
|
295
|
+
require_transaction_context,
|
|
296
|
+
completed_field,
|
|
297
|
+
completed_bullet_field,
|
|
298
|
+
section_lines,
|
|
299
|
+
archived_intent_criteria,
|
|
300
|
+
parse_final_review,
|
|
301
|
+
validate_gap_reviews,
|
|
302
|
+
meaningful_review_evidence,
|
|
303
|
+
archived_wave_tasks,
|
|
304
|
+
plan_wave_criteria,
|
|
305
|
+
validate_wave_review,
|
|
306
|
+
plan_wave_depths,
|
|
307
|
+
review_cycle_counts,
|
|
308
|
+
manifest_cell,
|
|
309
|
+
archive_file_inventory,
|
|
310
|
+
render_manifest,
|
|
311
|
+
validate_manifest,
|
|
312
|
+
)
|
|
313
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
314
|
+
from scripts.discussion_validate import (
|
|
315
|
+
record_sections,
|
|
316
|
+
required_dialogue_block,
|
|
317
|
+
require_contiguous_ids,
|
|
318
|
+
require_iso_date,
|
|
319
|
+
require_bullet_fields,
|
|
320
|
+
validate_discussion_directory,
|
|
321
|
+
require_append_only_discussion,
|
|
322
|
+
reconcile_append_only_discussion,
|
|
323
|
+
require_safe_discussion_destination,
|
|
324
|
+
finish_discussion_reconciliation,
|
|
325
|
+
remove_interrupted_discussion_copies,
|
|
326
|
+
remove_interrupted_carry_copy,
|
|
327
|
+
require_transaction_context,
|
|
328
|
+
completed_field,
|
|
329
|
+
completed_bullet_field,
|
|
330
|
+
section_lines,
|
|
331
|
+
archived_intent_criteria,
|
|
332
|
+
parse_final_review,
|
|
333
|
+
validate_gap_reviews,
|
|
334
|
+
meaningful_review_evidence,
|
|
335
|
+
archived_wave_tasks,
|
|
336
|
+
plan_wave_criteria,
|
|
337
|
+
validate_wave_review,
|
|
338
|
+
plan_wave_depths,
|
|
339
|
+
review_cycle_counts,
|
|
340
|
+
manifest_cell,
|
|
341
|
+
archive_file_inventory,
|
|
342
|
+
render_manifest,
|
|
343
|
+
validate_manifest,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@contextmanager
|
|
348
|
+
def discussion_lock(active_root: Path) -> Iterator[None]:
|
|
349
|
+
if sys.platform == "win32":
|
|
350
|
+
lock_value = require_git_success(
|
|
351
|
+
run_git(
|
|
352
|
+
active_root.parent,
|
|
353
|
+
"rev-parse",
|
|
354
|
+
"--git-path",
|
|
355
|
+
"gsd-path-discussion.lock",
|
|
356
|
+
),
|
|
357
|
+
"resolve discussion lock",
|
|
358
|
+
)
|
|
359
|
+
lock_path = Path(lock_value)
|
|
360
|
+
if not lock_path.is_absolute():
|
|
361
|
+
lock_path = active_root.parent / lock_path
|
|
362
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
with lock_path.open("a+b") as handle:
|
|
364
|
+
handle.seek(0, os.SEEK_END)
|
|
365
|
+
if handle.tell() == 0:
|
|
366
|
+
handle.write(b"\0")
|
|
367
|
+
handle.flush()
|
|
368
|
+
handle.seek(0)
|
|
369
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
370
|
+
try:
|
|
371
|
+
yield
|
|
372
|
+
finally:
|
|
373
|
+
handle.seek(0)
|
|
374
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
descriptor = os.open(active_root, os.O_RDONLY)
|
|
378
|
+
try:
|
|
379
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
380
|
+
yield
|
|
381
|
+
finally:
|
|
382
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
383
|
+
os.close(descriptor)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
atomic_replace = _common.atomic_replace
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
390
|
+
atomic_replace(path, path.parent / STATE_TEMP_NAME, content)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def frontmatter_value(content: str, key: str) -> Optional[str]:
|
|
394
|
+
lines = content.splitlines()
|
|
395
|
+
if not lines or lines[0] != "---":
|
|
396
|
+
raise ArchiveError("STATE.md is missing YAML frontmatter")
|
|
397
|
+
for line in lines[1:]:
|
|
398
|
+
if line == "---":
|
|
399
|
+
break
|
|
400
|
+
match = re.match(rf"^{re.escape(key)}:\s*([^#]*?)\s*(?:#.*)?$", line)
|
|
401
|
+
if match:
|
|
402
|
+
value = match.group(1).strip().strip('"').strip("'")
|
|
403
|
+
return value
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def set_frontmatter_value(content: str, key: str, value: str) -> str:
|
|
408
|
+
lines = content.splitlines(keepends=True)
|
|
409
|
+
if not lines or lines[0].rstrip("\r\n") != "---":
|
|
410
|
+
raise ArchiveError("STATE.md is missing YAML frontmatter")
|
|
411
|
+
|
|
412
|
+
closing_index: Optional[int] = None
|
|
413
|
+
key_index: Optional[int] = None
|
|
414
|
+
for index, line in enumerate(lines[1:], start=1):
|
|
415
|
+
stripped = line.rstrip("\r\n")
|
|
416
|
+
if stripped == "---":
|
|
417
|
+
closing_index = index
|
|
418
|
+
break
|
|
419
|
+
if re.match(rf"^{re.escape(key)}:", stripped):
|
|
420
|
+
key_index = index
|
|
421
|
+
|
|
422
|
+
if closing_index is None:
|
|
423
|
+
raise ArchiveError("STATE.md frontmatter is not closed")
|
|
424
|
+
|
|
425
|
+
replacement = f"{key}: {value}\n"
|
|
426
|
+
if key_index is None:
|
|
427
|
+
lines.insert(closing_index, replacement)
|
|
428
|
+
else:
|
|
429
|
+
lines[key_index] = replacement
|
|
430
|
+
return "".join(lines)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def normalized_slug(value: str) -> str:
|
|
434
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
435
|
+
return slug or "project"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def is_unset(value: Optional[str]) -> bool:
|
|
439
|
+
return not value or value.lower() in {"null", "none"} or value.startswith("<")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def milestone_slug(state: PipelineState) -> str:
|
|
443
|
+
milestone = state.milestone
|
|
444
|
+
if is_unset(milestone):
|
|
445
|
+
raise ArchiveError("STATE.md does not name a milestone")
|
|
446
|
+
return normalized_slug(milestone or "")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def require_archive_milestone(archive_name: str, state: PipelineState) -> None:
|
|
450
|
+
match = ARCHIVE_PATTERN.fullmatch(archive_name)
|
|
451
|
+
if (
|
|
452
|
+
not match
|
|
453
|
+
or int(match.group(1)) < 1
|
|
454
|
+
or match.group(2) != milestone_slug(state)
|
|
455
|
+
):
|
|
456
|
+
raise ArchiveError("persisted archive slug does not match normalized STATE.milestone")
|
|
457
|
+
branch = state.branch
|
|
458
|
+
if (
|
|
459
|
+
branch is None
|
|
460
|
+
or not is_bound_branch(branch)
|
|
461
|
+
or int(match.group(1)) != milestone_number(branch)
|
|
462
|
+
):
|
|
463
|
+
raise ArchiveError("persisted archive sequence does not match STATE.branch")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def require_project_layout(project: Path) -> Path:
|
|
467
|
+
active_root = project / ".project"
|
|
468
|
+
if active_root.is_symlink() or not active_root.is_dir():
|
|
469
|
+
raise ArchiveError(f".project must be a real directory inside the repository: {active_root}")
|
|
470
|
+
if active_root.resolve().parent != project:
|
|
471
|
+
raise ArchiveError(f".project resolves outside the repository: {active_root}")
|
|
472
|
+
state_path = active_root / "STATE.md"
|
|
473
|
+
if state_path.is_symlink() or not state_path.is_file():
|
|
474
|
+
raise ArchiveError(f"missing real state file: {state_path}")
|
|
475
|
+
return active_root
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def safe_archive_root(project: Path) -> Path:
|
|
479
|
+
active_root = require_project_layout(project)
|
|
480
|
+
archive_root = active_root / "archive"
|
|
481
|
+
if archive_root.is_symlink() or not archive_root.is_dir():
|
|
482
|
+
raise ArchiveError(
|
|
483
|
+
f"archive root must be a real, non-symlink directory: {archive_root}"
|
|
484
|
+
)
|
|
485
|
+
if archive_root.resolve().parent != active_root.resolve():
|
|
486
|
+
raise ArchiveError(f"archive root resolves outside .project: {archive_root}")
|
|
487
|
+
for candidate in archive_root.iterdir():
|
|
488
|
+
if candidate.is_symlink():
|
|
489
|
+
raise ArchiveError(f"archive entries must not be symlinks: {candidate}")
|
|
490
|
+
return archive_root
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def create_archive_root(project: Path) -> Path:
|
|
494
|
+
active_root = require_project_layout(project)
|
|
495
|
+
archive_root = active_root / "archive"
|
|
496
|
+
if archive_root.is_symlink():
|
|
497
|
+
raise ArchiveError(f"archive root must not be a symlink: {archive_root}")
|
|
498
|
+
archive_root.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
return safe_archive_root(project)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def archive_relative_from_state(configured: str) -> PurePosixPath:
|
|
503
|
+
relative = PurePosixPath(configured)
|
|
504
|
+
if (
|
|
505
|
+
relative.parent != PurePosixPath(".project/archive")
|
|
506
|
+
or not ARCHIVE_PATTERN.fullmatch(relative.name)
|
|
507
|
+
):
|
|
508
|
+
raise ArchiveError(f"STATE.md has an invalid archive path: {configured}")
|
|
509
|
+
return relative
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def archive_from_state(project: Path, configured: str) -> Path:
|
|
513
|
+
relative = archive_relative_from_state(configured)
|
|
514
|
+
archive_root = safe_archive_root(project)
|
|
515
|
+
archive = project.joinpath(*relative.parts)
|
|
516
|
+
if archive.is_symlink() or archive.resolve(strict=False).parent != archive_root.resolve():
|
|
517
|
+
raise ArchiveError(f"archive target must not escape through a symlink: {archive}")
|
|
518
|
+
return archive
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def strict_state(project: Path) -> tuple[PipelineState, str, Path]:
|
|
522
|
+
try:
|
|
523
|
+
return load_state(project, ".project")
|
|
524
|
+
except PipelineStateError as error:
|
|
525
|
+
raise ArchiveError(f"STATE.md is invalid: {error}") from error
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def archive_sequence_entries(project: Path) -> dict[int, Path]:
|
|
529
|
+
archive_root = project / ".project" / "archive"
|
|
530
|
+
if not archive_root.exists() and not archive_root.is_symlink():
|
|
531
|
+
return {}
|
|
532
|
+
archive_root = safe_archive_root(project)
|
|
533
|
+
entries = {}
|
|
534
|
+
for candidate in archive_root.iterdir():
|
|
535
|
+
match = ARCHIVE_PATTERN.fullmatch(candidate.name)
|
|
536
|
+
if match is None or not candidate.is_dir():
|
|
537
|
+
raise ArchiveError(f"archive root has a noncanonical entry: {candidate.name}")
|
|
538
|
+
number = int(match.group(1))
|
|
539
|
+
if number == 0:
|
|
540
|
+
raise ArchiveError("archive milestone number must be >= 1")
|
|
541
|
+
if number in entries:
|
|
542
|
+
raise ArchiveError(
|
|
543
|
+
f"archive sequence {number:03d} has multiple milestone directories"
|
|
544
|
+
)
|
|
545
|
+
entries[number] = candidate
|
|
546
|
+
return entries
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def resolved_archive_target(
|
|
550
|
+
project: Path, slug: str, parsed_state: PipelineState
|
|
551
|
+
) -> Path:
|
|
552
|
+
branch = parsed_state.branch
|
|
553
|
+
if not isinstance(branch, str) or not is_bound_branch(branch):
|
|
554
|
+
raise ArchiveError("STATE.branch must be a canonical gsd-path/M00N branch")
|
|
555
|
+
expected_number = milestone_number(branch)
|
|
556
|
+
configured = parsed_state.archive
|
|
557
|
+
entries = archive_sequence_entries(project)
|
|
558
|
+
missing_prior = [
|
|
559
|
+
number for number in range(1, expected_number) if number not in entries
|
|
560
|
+
]
|
|
561
|
+
later = sorted(number for number in entries if number > expected_number)
|
|
562
|
+
if missing_prior or later:
|
|
563
|
+
raise ArchiveError(
|
|
564
|
+
f"archive sequence does not match bound branch {branch}: "
|
|
565
|
+
f"missing={missing_prior}, later={later}"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
if configured is not None:
|
|
569
|
+
archive = archive_from_state(project, configured)
|
|
570
|
+
if milestone_number(archive.name) != expected_number:
|
|
571
|
+
raise ArchiveError("persisted archive sequence does not match STATE.branch")
|
|
572
|
+
collision = entries.get(expected_number)
|
|
573
|
+
if collision is not None and collision.resolve() != archive.resolve():
|
|
574
|
+
raise ArchiveError(
|
|
575
|
+
f"archive sequence {expected_number:03d} collides with {collision.name}"
|
|
576
|
+
)
|
|
577
|
+
return archive
|
|
578
|
+
|
|
579
|
+
if expected_number in entries:
|
|
580
|
+
raise ArchiveError(
|
|
581
|
+
f"archive sequence {expected_number:03d} already exists before persistence"
|
|
582
|
+
)
|
|
583
|
+
archive_root = project / ".project" / "archive"
|
|
584
|
+
return archive_root / f"{expected_number:03d}-{normalized_slug(slug)}"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def persisted_archive(
|
|
588
|
+
project: Path, state_path: Path, slug: str, parsed_state: PipelineState
|
|
589
|
+
) -> Path:
|
|
590
|
+
archive = resolved_archive_target(project, slug, parsed_state)
|
|
591
|
+
if parsed_state.archive is not None:
|
|
592
|
+
return archive
|
|
593
|
+
create_archive_root(project)
|
|
594
|
+
relative_archive = archive.relative_to(project).as_posix()
|
|
595
|
+
state_text = state_path.read_text(encoding="utf-8")
|
|
596
|
+
atomic_write(state_path, set_frontmatter_value(state_text, "archive", relative_archive))
|
|
597
|
+
return archive
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def pending_ruling_count(audit: Path) -> int:
|
|
601
|
+
if not audit.is_file():
|
|
602
|
+
return 0
|
|
603
|
+
in_rulings = False
|
|
604
|
+
count = 0
|
|
605
|
+
for line in audit.read_text(encoding="utf-8").splitlines():
|
|
606
|
+
if line == "## User rulings":
|
|
607
|
+
in_rulings = True
|
|
608
|
+
continue
|
|
609
|
+
if in_rulings and line.startswith("## "):
|
|
610
|
+
break
|
|
611
|
+
if not in_rulings or not line.lstrip().startswith("|"):
|
|
612
|
+
continue
|
|
613
|
+
fields = [field.strip() for field in line.strip().strip("|").split("|")]
|
|
614
|
+
if fields and fields[-1] == "no":
|
|
615
|
+
count += 1
|
|
616
|
+
return count
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def is_existing_carry_forward(source: Path, archived: Path) -> bool:
|
|
620
|
+
entries = list(source.iterdir()) if source.is_dir() else []
|
|
621
|
+
archived_audit = archived / "DOCS-AUDIT.md"
|
|
622
|
+
if not archived_audit.is_file() or pending_ruling_count(archived_audit) == 0:
|
|
623
|
+
return False
|
|
624
|
+
if not entries:
|
|
625
|
+
return True
|
|
626
|
+
return (
|
|
627
|
+
len(entries) == 1
|
|
628
|
+
and entries[0].name == "DOCS-AUDIT.md"
|
|
629
|
+
and filecmp.cmp(entries[0], archived_audit, shallow=False)
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def move_directory(source: Path, destination: Path) -> None:
|
|
634
|
+
if source.exists() and destination.exists():
|
|
635
|
+
if source.name == "research" and is_existing_carry_forward(source, destination):
|
|
636
|
+
return
|
|
637
|
+
raise ArchiveError(f"archive collision: both {source} and {destination} exist")
|
|
638
|
+
if source.exists():
|
|
639
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
640
|
+
shutil.move(str(source), str(destination))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def require_complete_transaction_inputs(active_root: Path, archive: Path) -> None:
|
|
644
|
+
for name in DIRECTORIES_TO_ARCHIVE:
|
|
645
|
+
source = active_root / name
|
|
646
|
+
destination = archive / name
|
|
647
|
+
if source.exists() and destination.exists():
|
|
648
|
+
if name == "research" and is_existing_carry_forward(source, destination):
|
|
649
|
+
continue
|
|
650
|
+
raise ArchiveError(f"archive collision: both {source} and {destination} exist")
|
|
651
|
+
if not source.exists() and not destination.exists():
|
|
652
|
+
raise ArchiveError(f"missing both active and archived {name}")
|
|
653
|
+
|
|
654
|
+
for name in OPTIONAL_DIRECTORIES_TO_ARCHIVE:
|
|
655
|
+
source = active_root / name
|
|
656
|
+
destination = archive / name
|
|
657
|
+
if source.exists() and destination.exists():
|
|
658
|
+
if name == "discuss":
|
|
659
|
+
require_append_only_discussion(source, destination)
|
|
660
|
+
continue
|
|
661
|
+
raise ArchiveError(f"archive collision: both {source} and {destination} exist")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def selected_transaction_path(active_root: Path, archive: Path, relative: str) -> Path:
|
|
666
|
+
path = PurePosixPath(relative)
|
|
667
|
+
active_top = active_root / path.parts[0]
|
|
668
|
+
archived_top = archive / path.parts[0]
|
|
669
|
+
if active_top.exists() and not archived_top.exists():
|
|
670
|
+
return active_root.joinpath(*path.parts)
|
|
671
|
+
return archive.joinpath(*path.parts)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def is_real_file(path: Path) -> bool:
|
|
675
|
+
return not path.is_symlink() and path.is_file()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def canonical_task_files(tasks: Path) -> Sequence[Path]:
|
|
679
|
+
if tasks.is_symlink() or not tasks.is_dir():
|
|
680
|
+
return ()
|
|
681
|
+
candidates = sorted(tasks.iterdir())
|
|
682
|
+
if any(
|
|
683
|
+
not TASK_FILE_PATTERN.fullmatch(path.name) or not is_real_file(path)
|
|
684
|
+
for path in candidates
|
|
685
|
+
):
|
|
686
|
+
raise ArchiveError("canonical task artifacts must be real T###-slug.md files")
|
|
687
|
+
return candidates
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def completed_task_files(
|
|
691
|
+
project: Path, tasks: Path, reviewed_head: str
|
|
692
|
+
) -> Sequence[Path]:
|
|
693
|
+
"""Return task artifacts only when each records landed completion evidence."""
|
|
694
|
+
return landed_task_evidence(project, tasks, reviewed_head)[0]
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def landed_task_evidence(
|
|
698
|
+
project: Path, tasks: Path, reviewed_head: str
|
|
699
|
+
) -> tuple[Sequence[Path], int]:
|
|
700
|
+
"""(task artifacts, attested count) once every task proves landed or attested."""
|
|
701
|
+
candidates = canonical_task_files(tasks)
|
|
702
|
+
try:
|
|
703
|
+
proven = verify_landed_task_files(
|
|
704
|
+
project,
|
|
705
|
+
candidates,
|
|
706
|
+
".project/tasks",
|
|
707
|
+
reviewed_head,
|
|
708
|
+
)
|
|
709
|
+
except (IsolationError, ValueError) as error:
|
|
710
|
+
raise ArchiveError(f"task landing proof failed: {error}") from error
|
|
711
|
+
attested = sum(1 for task in proven["tasks"] if task.get("verdict") == "attested")
|
|
712
|
+
return candidates, attested
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def validate_panel_skip_receipt(path: Path, expected_mode: str) -> None:
|
|
716
|
+
if not is_real_file(path):
|
|
717
|
+
raise ArchiveError(f"panel skip receipt must be a real file: {path.name}")
|
|
718
|
+
try:
|
|
719
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
720
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
721
|
+
raise ArchiveError(f"{path.name} is not valid review_panel JSON") from error
|
|
722
|
+
expected_keys = {
|
|
723
|
+
"missing",
|
|
724
|
+
"mode",
|
|
725
|
+
"parent_family",
|
|
726
|
+
"reason",
|
|
727
|
+
"selected",
|
|
728
|
+
"skipped",
|
|
729
|
+
"status",
|
|
730
|
+
}
|
|
731
|
+
if not isinstance(payload, dict) or set(payload) != expected_keys:
|
|
732
|
+
raise ArchiveError(f"{path.name} is not a canonical skipped-panel receipt")
|
|
733
|
+
if (
|
|
734
|
+
expected_mode != "detected"
|
|
735
|
+
or payload["mode"] != expected_mode
|
|
736
|
+
or payload["status"] != "skipped"
|
|
737
|
+
or payload["reason"] != "no advertised cross-model families"
|
|
738
|
+
or payload["selected"] != []
|
|
739
|
+
):
|
|
740
|
+
raise ArchiveError(f"{path.name} does not record the expected skipped resolution")
|
|
741
|
+
parent_family = payload["parent_family"]
|
|
742
|
+
if parent_family is not None and not isinstance(parent_family, str):
|
|
743
|
+
raise ArchiveError(f"{path.name} has an invalid parent_family")
|
|
744
|
+
missing = payload["missing"]
|
|
745
|
+
if (
|
|
746
|
+
not isinstance(missing, list)
|
|
747
|
+
or any(item not in KNOWN_FAMILIES for item in missing)
|
|
748
|
+
or len(missing) != len(set(missing))
|
|
749
|
+
):
|
|
750
|
+
raise ArchiveError(f"{path.name} has invalid missing families")
|
|
751
|
+
skipped = payload["skipped"]
|
|
752
|
+
if not isinstance(skipped, list) or any(
|
|
753
|
+
not isinstance(item, dict)
|
|
754
|
+
or set(item) != {"family", "reason"}
|
|
755
|
+
or item["family"] not in KNOWN_FAMILIES
|
|
756
|
+
or item["reason"] not in {"parent family", "not advertised", "cap"}
|
|
757
|
+
for item in skipped
|
|
758
|
+
):
|
|
759
|
+
raise ArchiveError(f"{path.name} has invalid skipped families")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def require_plan_panel_evidence(
|
|
763
|
+
plan_text: str, panel: Path, skipped_receipt: Path
|
|
764
|
+
) -> dict:
|
|
765
|
+
config = plan_review_panel_config(plan_text)
|
|
766
|
+
panel_exists = is_real_file(panel)
|
|
767
|
+
receipt_exists = is_real_file(skipped_receipt)
|
|
768
|
+
if config["mode"] == "off":
|
|
769
|
+
if receipt_exists:
|
|
770
|
+
raise ArchiveError(
|
|
771
|
+
f"{skipped_receipt.name} conflicts with review_panel mode off"
|
|
772
|
+
)
|
|
773
|
+
return config
|
|
774
|
+
if panel_exists == receipt_exists:
|
|
775
|
+
raise ArchiveError(
|
|
776
|
+
"configured review_panel requires exactly one of review/PLAN-PANEL.md "
|
|
777
|
+
"or review/PLAN-PANEL.skipped.json"
|
|
778
|
+
)
|
|
779
|
+
if receipt_exists:
|
|
780
|
+
validate_panel_skip_receipt(skipped_receipt, str(config["mode"]))
|
|
781
|
+
return config
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class WaveArtifact(NamedTuple):
|
|
785
|
+
path: Path
|
|
786
|
+
wave: int
|
|
787
|
+
cycle: int
|
|
788
|
+
lens: Optional[str]
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
class SkepticArtifact(NamedTuple):
|
|
792
|
+
path: Path
|
|
793
|
+
wave: int
|
|
794
|
+
cycle: int
|
|
795
|
+
locator: str
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
class RepairArtifact(NamedTuple):
|
|
799
|
+
path: Path
|
|
800
|
+
wave: int
|
|
801
|
+
cycle: int
|
|
802
|
+
task: str
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def canonical_wave_files(reviews: Path) -> Sequence[WaveArtifact]:
|
|
806
|
+
if reviews.is_symlink() or not reviews.is_dir():
|
|
807
|
+
return ()
|
|
808
|
+
candidates = sorted(
|
|
809
|
+
path for path in reviews.iterdir() if WAVE_CYCLE_CLAIM_PATTERN.match(path.name)
|
|
810
|
+
)
|
|
811
|
+
matches = [(path, WAVE_FILE_PATTERN.fullmatch(path.name)) for path in candidates]
|
|
812
|
+
if any(
|
|
813
|
+
(
|
|
814
|
+
match is None
|
|
815
|
+
and WAVE_PANEL_SKIP_PATTERN.fullmatch(path.name) is None
|
|
816
|
+
and WAVE_SKEPTIC_PATTERN.fullmatch(path.name) is None
|
|
817
|
+
and WAVE_REPAIR_PATTERN.fullmatch(path.name) is None
|
|
818
|
+
)
|
|
819
|
+
or not is_real_file(path)
|
|
820
|
+
for path, match in matches
|
|
821
|
+
):
|
|
822
|
+
raise ArchiveError(
|
|
823
|
+
"canonical wave artifacts must be real wave-N.cycleC.md files "
|
|
824
|
+
"(a .contract, .adversarial, or .panel lens suffix is allowed), "
|
|
825
|
+
"canonical .panel.skipped.json receipts, or auxiliary "
|
|
826
|
+
".skeptic-<locator>.md and .repair-T###.json evidence"
|
|
827
|
+
)
|
|
828
|
+
return [
|
|
829
|
+
WaveArtifact(
|
|
830
|
+
path,
|
|
831
|
+
int(match.group(1)),
|
|
832
|
+
int(match.group(2)),
|
|
833
|
+
match.group(3).removeprefix(".") if match.group(3) else None,
|
|
834
|
+
)
|
|
835
|
+
for path, match in matches
|
|
836
|
+
if match is not None
|
|
837
|
+
]
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def canonical_wave_skeptic_files(reviews: Path) -> Sequence[SkepticArtifact]:
|
|
841
|
+
if reviews.is_symlink() or not reviews.is_dir():
|
|
842
|
+
return ()
|
|
843
|
+
artifacts = []
|
|
844
|
+
for path in sorted(reviews.iterdir()):
|
|
845
|
+
match = WAVE_SKEPTIC_PATTERN.fullmatch(path.name)
|
|
846
|
+
if match is None:
|
|
847
|
+
continue
|
|
848
|
+
if not is_real_file(path):
|
|
849
|
+
raise ArchiveError(f"skeptic evidence must be a real file: {path.name}")
|
|
850
|
+
artifacts.append(
|
|
851
|
+
SkepticArtifact(
|
|
852
|
+
path,
|
|
853
|
+
int(match.group(1)),
|
|
854
|
+
int(match.group(2)),
|
|
855
|
+
match.group(3),
|
|
856
|
+
)
|
|
857
|
+
)
|
|
858
|
+
return artifacts
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def validate_wave_skeptic_files(
|
|
862
|
+
reviews: Path,
|
|
863
|
+
wave_depths: dict[int, str],
|
|
864
|
+
wave_artifacts: Sequence[WaveArtifact],
|
|
865
|
+
) -> None:
|
|
866
|
+
deep_cycles = {}
|
|
867
|
+
for artifact in wave_artifacts:
|
|
868
|
+
if artifact.lens in {"contract", "adversarial"}:
|
|
869
|
+
deep_cycles.setdefault((artifact.wave, artifact.cycle), set()).add(
|
|
870
|
+
artifact.lens
|
|
871
|
+
)
|
|
872
|
+
required_lenses = {"contract", "adversarial"}
|
|
873
|
+
for artifact in canonical_wave_skeptic_files(reviews):
|
|
874
|
+
key = (artifact.wave, artifact.cycle)
|
|
875
|
+
if (
|
|
876
|
+
wave_depths.get(artifact.wave) != "deep"
|
|
877
|
+
or deep_cycles.get(key) != required_lenses
|
|
878
|
+
):
|
|
879
|
+
raise ArchiveError(
|
|
880
|
+
f"{artifact.path.name} does not match a canonical deep review cycle"
|
|
881
|
+
)
|
|
882
|
+
lines = artifact.path.read_text(encoding="utf-8").splitlines()
|
|
883
|
+
heading = f"# Skeptic — wave {artifact.wave}, cycle {artifact.cycle}"
|
|
884
|
+
if lines.count(heading) != 1:
|
|
885
|
+
raise ArchiveError(
|
|
886
|
+
f"{artifact.path.name} heading does not match its filename"
|
|
887
|
+
)
|
|
888
|
+
completed_field(lines, "- Criterion:", artifact.path.name)
|
|
889
|
+
locator = completed_field(lines, "- Criterion locator:", artifact.path.name)
|
|
890
|
+
if locator != artifact.locator:
|
|
891
|
+
raise ArchiveError(
|
|
892
|
+
f"{artifact.path.name} Criterion locator does not match its filename"
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def canonical_wave_panel_skip_files(reviews: Path) -> dict[tuple[int, int], Path]:
|
|
897
|
+
if reviews.is_symlink() or not reviews.is_dir():
|
|
898
|
+
return {}
|
|
899
|
+
receipts = {}
|
|
900
|
+
for path in sorted(reviews.iterdir()):
|
|
901
|
+
match = WAVE_PANEL_SKIP_PATTERN.fullmatch(path.name)
|
|
902
|
+
if match is None:
|
|
903
|
+
continue
|
|
904
|
+
if not is_real_file(path):
|
|
905
|
+
raise ArchiveError(f"panel skip receipt must be a real file: {path.name}")
|
|
906
|
+
receipts[(int(match.group(1)), int(match.group(2)))] = path
|
|
907
|
+
return receipts
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def canonical_wave_repair_files(reviews: Path) -> Sequence[RepairArtifact]:
|
|
911
|
+
if reviews.is_symlink() or not reviews.is_dir():
|
|
912
|
+
return ()
|
|
913
|
+
artifacts = []
|
|
914
|
+
for path in sorted(reviews.iterdir()):
|
|
915
|
+
match = WAVE_REPAIR_PATTERN.fullmatch(path.name)
|
|
916
|
+
if match is None:
|
|
917
|
+
continue
|
|
918
|
+
if not is_real_file(path):
|
|
919
|
+
raise ArchiveError(f"repair receipt must be a real file: {path.name}")
|
|
920
|
+
try:
|
|
921
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
922
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
923
|
+
raise ArchiveError(f"{path.name} is not valid repair-evidence JSON") from error
|
|
924
|
+
wave = int(match.group(1))
|
|
925
|
+
cycle = int(match.group(2))
|
|
926
|
+
task = match.group(3)
|
|
927
|
+
if (
|
|
928
|
+
not isinstance(payload, dict)
|
|
929
|
+
or payload.get("command") != "repair-evidence"
|
|
930
|
+
or payload.get("status") != "ok"
|
|
931
|
+
or payload.get("source") != {"wave": wave, "cycle": cycle}
|
|
932
|
+
or not isinstance(payload.get("repair"), dict)
|
|
933
|
+
or payload["repair"].get("task") != task
|
|
934
|
+
):
|
|
935
|
+
raise ArchiveError(
|
|
936
|
+
f"{path.name} identity does not match its repair-evidence payload"
|
|
937
|
+
)
|
|
938
|
+
artifacts.append(RepairArtifact(path, wave, cycle, task))
|
|
939
|
+
return artifacts
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def require_canonical_transaction_inputs(active_root: Path, archive: Path) -> None:
|
|
943
|
+
missing = [
|
|
944
|
+
relative
|
|
945
|
+
for relative in REQUIRED_ARCHIVE_FILES
|
|
946
|
+
if not is_real_file(selected_transaction_path(active_root, archive, relative))
|
|
947
|
+
]
|
|
948
|
+
tasks = selected_transaction_path(active_root, archive, "tasks")
|
|
949
|
+
reviews = selected_transaction_path(active_root, archive, "review")
|
|
950
|
+
if not canonical_task_files(tasks):
|
|
951
|
+
missing.append("tasks/T###-slug.md")
|
|
952
|
+
wave_artifacts = canonical_wave_files(reviews)
|
|
953
|
+
if not wave_artifacts:
|
|
954
|
+
missing.append("review/wave-N.cycleC.md")
|
|
955
|
+
plan = selected_transaction_path(active_root, archive, "plan/PLAN.md")
|
|
956
|
+
if missing:
|
|
957
|
+
raise ArchiveError(f"canonical milestone artifacts are missing: {', '.join(missing)}")
|
|
958
|
+
plan_text = plan.read_text(encoding="utf-8")
|
|
959
|
+
_, wave_depths = plan_wave_depths(plan_text)
|
|
960
|
+
validate_wave_skeptic_files(reviews, wave_depths, wave_artifacts)
|
|
961
|
+
canonical_wave_repair_files(reviews)
|
|
962
|
+
require_plan_panel_evidence(
|
|
963
|
+
plan_text,
|
|
964
|
+
selected_transaction_path(active_root, archive, "review/PLAN-PANEL.md"),
|
|
965
|
+
selected_transaction_path(
|
|
966
|
+
active_root, archive, "review/PLAN-PANEL.skipped.json"
|
|
967
|
+
),
|
|
968
|
+
)
|
|
969
|
+
discussion = selected_transaction_path(active_root, archive, "discuss")
|
|
970
|
+
if discussion.exists():
|
|
971
|
+
validate_discussion_directory(discussion)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def require_safe_move_inputs(active_root: Path, archive: Path) -> None:
|
|
975
|
+
archive_device = archive.stat().st_dev
|
|
976
|
+
for name in TRANSACTION_DIRECTORIES:
|
|
977
|
+
source = active_root / name
|
|
978
|
+
destination = archive / name
|
|
979
|
+
if source.is_symlink() or destination.is_symlink():
|
|
980
|
+
raise ArchiveError(f"archive transaction paths must not be symlinks: {name}")
|
|
981
|
+
if source.exists() and source.stat().st_dev != archive_device:
|
|
982
|
+
raise ArchiveError(f"archive move crosses filesystems: {source}")
|
|
983
|
+
|
|
984
|
+
def atomic_copy(source: Path, destination: Path, temporary_name: str = CARRY_TEMP_NAME) -> None:
|
|
985
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
986
|
+
temporary_path = destination.parent / temporary_name
|
|
987
|
+
try:
|
|
988
|
+
if temporary_path.exists() or temporary_path.is_symlink():
|
|
989
|
+
temporary_path.unlink()
|
|
990
|
+
shutil.copy2(source, temporary_path)
|
|
991
|
+
os.replace(temporary_path, destination)
|
|
992
|
+
finally:
|
|
993
|
+
if temporary_path.exists() or temporary_path.is_symlink():
|
|
994
|
+
temporary_path.unlink()
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def prepare(repo: Path, slug: str) -> dict:
|
|
998
|
+
project = repo.resolve()
|
|
999
|
+
active_root = require_project_layout(project)
|
|
1000
|
+
with discussion_lock(active_root):
|
|
1001
|
+
return prepare_locked(project, active_root, slug)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def prepare_locked(project: Path, active_root: Path, slug: str) -> dict:
|
|
1005
|
+
state_path = active_root / "STATE.md"
|
|
1006
|
+
state_temporary = active_root / STATE_TEMP_NAME
|
|
1007
|
+
|
|
1008
|
+
parsed_state, _, loaded_state_path = strict_state(project)
|
|
1009
|
+
if loaded_state_path.resolve() != state_path.resolve():
|
|
1010
|
+
raise ArchiveError("strict STATE.md loader returned an unexpected path")
|
|
1011
|
+
require_transaction_context(project, parsed_state, ("ship", "shipped"))
|
|
1012
|
+
phase = parsed_state.phase
|
|
1013
|
+
status = parsed_state.status
|
|
1014
|
+
if (phase, status) not in {("ship", "active"), ("shipped", "done")}:
|
|
1015
|
+
raise ArchiveError(f"archive transaction is not valid in state: {phase}/{status}")
|
|
1016
|
+
expected_slug = milestone_slug(parsed_state)
|
|
1017
|
+
if normalized_slug(slug) != expected_slug:
|
|
1018
|
+
raise ArchiveError(
|
|
1019
|
+
f"--slug {slug!r} does not match normalized STATE.milestone {expected_slug!r}"
|
|
1020
|
+
)
|
|
1021
|
+
configured = parsed_state.archive
|
|
1022
|
+
if phase == "shipped" and configured is None:
|
|
1023
|
+
raise ArchiveError("shipped state has no persisted archive transaction")
|
|
1024
|
+
if configured is not None:
|
|
1025
|
+
relative_archive = archive_relative_from_state(configured)
|
|
1026
|
+
require_archive_milestone(relative_archive.name, parsed_state)
|
|
1027
|
+
require_uncommitted_archive(project, configured)
|
|
1028
|
+
if state_temporary.exists() or state_temporary.is_symlink():
|
|
1029
|
+
state_temporary.unlink()
|
|
1030
|
+
if configured is None:
|
|
1031
|
+
# Prove the inputs before STATE.archive is persisted or any directory exists.
|
|
1032
|
+
# Archiving a stale final review cannot be undone, so check it here, not only in preflight.
|
|
1033
|
+
final = active_root / "review" / "FINAL.md"
|
|
1034
|
+
reviewed = re.search(r"(?m)^Reviewed HEAD:\s*(\S+)", final.read_text(encoding="utf-8")) if final.is_file() else None
|
|
1035
|
+
head = require_git_success(run_git(project, "rev-parse", "HEAD"), "resolve HEAD").strip()
|
|
1036
|
+
if reviewed and reviewed.group(1) != head:
|
|
1037
|
+
raise ArchiveError(
|
|
1038
|
+
f"FINAL.md Reviewed HEAD {reviewed.group(1)} does not match current HEAD {head}; "
|
|
1039
|
+
"re-run the final review on the current commit before archiving"
|
|
1040
|
+
)
|
|
1041
|
+
target = resolved_archive_target(project, slug, parsed_state)
|
|
1042
|
+
require_archive_milestone(target.name, parsed_state)
|
|
1043
|
+
require_complete_transaction_inputs(active_root, target)
|
|
1044
|
+
require_canonical_transaction_inputs(active_root, target)
|
|
1045
|
+
|
|
1046
|
+
archive = persisted_archive(project, state_path, slug, parsed_state)
|
|
1047
|
+
require_archive_milestone(archive.name, parsed_state)
|
|
1048
|
+
archive.mkdir(parents=True, exist_ok=True)
|
|
1049
|
+
manifest_temporary = archive / MANIFEST_TEMP_NAME
|
|
1050
|
+
if manifest_temporary.exists() or manifest_temporary.is_symlink():
|
|
1051
|
+
manifest_temporary.unlink()
|
|
1052
|
+
|
|
1053
|
+
remove_interrupted_carry_copy(active_root, archive)
|
|
1054
|
+
remove_interrupted_discussion_copies(active_root, archive)
|
|
1055
|
+
finish_discussion_reconciliation(active_root, archive)
|
|
1056
|
+
require_complete_transaction_inputs(active_root, archive)
|
|
1057
|
+
require_canonical_transaction_inputs(active_root, archive)
|
|
1058
|
+
require_safe_move_inputs(active_root, archive)
|
|
1059
|
+
reconcile_append_only_discussion(active_root, archive)
|
|
1060
|
+
for name in TRANSACTION_DIRECTORIES:
|
|
1061
|
+
move_directory(active_root / name, archive / name)
|
|
1062
|
+
|
|
1063
|
+
archived_audit = archive / "research" / "DOCS-AUDIT.md"
|
|
1064
|
+
carried_forward = pending_ruling_count(archived_audit)
|
|
1065
|
+
if carried_forward:
|
|
1066
|
+
active_audit = active_root / "research" / "DOCS-AUDIT.md"
|
|
1067
|
+
active_audit.parent.mkdir(parents=True, exist_ok=True)
|
|
1068
|
+
if active_audit.exists() and not filecmp.cmp(active_audit, archived_audit, shallow=False):
|
|
1069
|
+
raise ArchiveError(f"carry-forward collision: {active_audit} differs from archived audit")
|
|
1070
|
+
if not active_audit.exists():
|
|
1071
|
+
atomic_copy(archived_audit, active_audit)
|
|
1072
|
+
|
|
1073
|
+
return {
|
|
1074
|
+
"archive": archive.relative_to(project).as_posix(),
|
|
1075
|
+
"carried_forward": carried_forward,
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def require_complete_abandon_inputs(active_root: Path, archive: Path) -> None:
|
|
1080
|
+
for name in ABANDON_REQUIRED_DIRECTORIES:
|
|
1081
|
+
source = active_root / name
|
|
1082
|
+
destination = archive / name
|
|
1083
|
+
if source.exists() and destination.exists():
|
|
1084
|
+
if name == "research" and is_existing_carry_forward(source, destination):
|
|
1085
|
+
continue
|
|
1086
|
+
raise ArchiveError(f"archive collision: both {source} and {destination} exist")
|
|
1087
|
+
if not source.exists() and not destination.exists():
|
|
1088
|
+
raise ArchiveError(f"missing both active and archived {name}")
|
|
1089
|
+
|
|
1090
|
+
review_source = active_root / "review"
|
|
1091
|
+
review_destination = archive / "review"
|
|
1092
|
+
if review_source.exists() and review_destination.exists():
|
|
1093
|
+
raise ArchiveError(
|
|
1094
|
+
f"archive collision: both {review_source} and {review_destination} exist"
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
for name in OPTIONAL_DIRECTORIES_TO_ARCHIVE:
|
|
1098
|
+
source = active_root / name
|
|
1099
|
+
destination = archive / name
|
|
1100
|
+
if source.exists() and destination.exists():
|
|
1101
|
+
if name == "discuss":
|
|
1102
|
+
require_append_only_discussion(source, destination)
|
|
1103
|
+
continue
|
|
1104
|
+
raise ArchiveError(f"archive collision: both {source} and {destination} exist")
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def require_canonical_abandon_inputs(active_root: Path, archive: Path) -> None:
|
|
1108
|
+
missing = [
|
|
1109
|
+
relative
|
|
1110
|
+
for relative in ABANDON_REQUIRED_ARCHIVE_FILES
|
|
1111
|
+
if not is_real_file(selected_transaction_path(active_root, archive, relative))
|
|
1112
|
+
]
|
|
1113
|
+
tasks = selected_transaction_path(active_root, archive, "tasks")
|
|
1114
|
+
if tasks.is_symlink() or not tasks.is_dir():
|
|
1115
|
+
missing.append("tasks/")
|
|
1116
|
+
else:
|
|
1117
|
+
canonical_task_files(tasks)
|
|
1118
|
+
reviews = selected_transaction_path(active_root, archive, "review")
|
|
1119
|
+
if reviews.exists():
|
|
1120
|
+
canonical_wave_files(reviews)
|
|
1121
|
+
if missing:
|
|
1122
|
+
raise ArchiveError(f"canonical milestone artifacts are missing: {', '.join(missing)}")
|
|
1123
|
+
discussion = selected_transaction_path(active_root, archive, "discuss")
|
|
1124
|
+
if discussion.exists():
|
|
1125
|
+
validate_discussion_directory(discussion)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def write_abandon_manifest(
|
|
1129
|
+
archive: Path, slug: str, ruling: str, abandoned_on: str
|
|
1130
|
+
) -> None:
|
|
1131
|
+
contents = []
|
|
1132
|
+
for path in archive.rglob("*"):
|
|
1133
|
+
if path.is_symlink():
|
|
1134
|
+
raise ArchiveError(f"archive contents must not be symlinks: {path}")
|
|
1135
|
+
if path.is_file() and path.name != "MANIFEST.md":
|
|
1136
|
+
contents.append(path.relative_to(archive).as_posix())
|
|
1137
|
+
contents.sort()
|
|
1138
|
+
listed_contents = "\n".join(f"- {path}" for path in contents)
|
|
1139
|
+
atomic_replace(
|
|
1140
|
+
archive / "MANIFEST.md",
|
|
1141
|
+
archive / MANIFEST_TEMP_NAME,
|
|
1142
|
+
f"""# Archive — {archive.name}
|
|
1143
|
+
|
|
1144
|
+
Milestone: {slug}
|
|
1145
|
+
Abandoned: {abandoned_on}
|
|
1146
|
+
Reason: {ruling}
|
|
1147
|
+
|
|
1148
|
+
## Contents
|
|
1149
|
+
|
|
1150
|
+
{listed_contents}
|
|
1151
|
+
|
|
1152
|
+
## Notes
|
|
1153
|
+
|
|
1154
|
+
- none
|
|
1155
|
+
""",
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def validate_abandon_manifest(
|
|
1160
|
+
archive: Path, slug: str, ruling: str, abandoned_on: str
|
|
1161
|
+
) -> None:
|
|
1162
|
+
manifest = archive / "MANIFEST.md"
|
|
1163
|
+
if manifest.is_symlink() or not manifest.is_file():
|
|
1164
|
+
raise ArchiveError("abandoned archive MANIFEST.md must be a real file")
|
|
1165
|
+
lines = manifest.read_text(encoding="utf-8").splitlines()
|
|
1166
|
+
if lines.count(f"# Archive — {archive.name}") != 1:
|
|
1167
|
+
raise ArchiveError("abandon manifest header does not match its archive")
|
|
1168
|
+
expected_fields = {
|
|
1169
|
+
"Milestone:": slug,
|
|
1170
|
+
"Abandoned:": abandoned_on,
|
|
1171
|
+
"Reason:": ruling,
|
|
1172
|
+
}
|
|
1173
|
+
for field, expected in expected_fields.items():
|
|
1174
|
+
if completed_field(lines, field, "abandon manifest") != expected:
|
|
1175
|
+
raise ArchiveError(f"abandon manifest {field} does not match its transaction")
|
|
1176
|
+
try:
|
|
1177
|
+
date.fromisoformat(abandoned_on)
|
|
1178
|
+
except ValueError as error:
|
|
1179
|
+
raise ArchiveError("abandon manifest date is invalid") from error
|
|
1180
|
+
if lines.count("## Contents") != 1 or lines.count("## Notes") != 1:
|
|
1181
|
+
raise ArchiveError("abandon manifest requires Contents and Notes sections")
|
|
1182
|
+
contents_start = lines.index("## Contents") + 1
|
|
1183
|
+
listed = []
|
|
1184
|
+
for line in lines[contents_start:]:
|
|
1185
|
+
if line.startswith("## "):
|
|
1186
|
+
break
|
|
1187
|
+
if line.startswith("- "):
|
|
1188
|
+
listed.append(line[2:].strip().strip("`"))
|
|
1189
|
+
if len(listed) != len(set(listed)) or sorted(listed) != archive_file_inventory(archive):
|
|
1190
|
+
raise ArchiveError("abandon manifest contents do not match the archive")
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def abandon_journal_path(project: Path) -> Path:
|
|
1194
|
+
common = require_git_success(
|
|
1195
|
+
run_git(project, "rev-parse", "--path-format=absolute", "--git-common-dir"),
|
|
1196
|
+
"resolve common Git directory",
|
|
1197
|
+
)
|
|
1198
|
+
path = Path(common)
|
|
1199
|
+
if not path.is_absolute():
|
|
1200
|
+
path = project / path
|
|
1201
|
+
return path.resolve() / ABANDON_JOURNAL_NAME
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def read_abandon_journal(path: Path) -> dict:
|
|
1205
|
+
try:
|
|
1206
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1207
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
1208
|
+
raise ArchiveError(f"abandon journal is unreadable: {path}") from error
|
|
1209
|
+
fields = {
|
|
1210
|
+
"schema",
|
|
1211
|
+
"repo",
|
|
1212
|
+
"slug",
|
|
1213
|
+
"ruling",
|
|
1214
|
+
"expected_head",
|
|
1215
|
+
"archive",
|
|
1216
|
+
"branch",
|
|
1217
|
+
"build_status",
|
|
1218
|
+
"abandoned_on",
|
|
1219
|
+
}
|
|
1220
|
+
if not isinstance(payload, dict) or set(payload) != fields:
|
|
1221
|
+
raise ArchiveError("abandon journal has invalid fields")
|
|
1222
|
+
if payload["schema"] != ABANDON_JOURNAL_SCHEMA or any(
|
|
1223
|
+
not isinstance(payload[field], str) for field in fields - {"schema"}
|
|
1224
|
+
):
|
|
1225
|
+
raise ArchiveError("abandon journal has invalid values")
|
|
1226
|
+
if (
|
|
1227
|
+
not re.fullmatch(r"[0-9a-f]{40,64}", payload["expected_head"])
|
|
1228
|
+
or payload["build_status"] not in {"active", "blocked"}
|
|
1229
|
+
or not is_bound_branch(payload["branch"])
|
|
1230
|
+
):
|
|
1231
|
+
raise ArchiveError("abandon journal has invalid transaction metadata")
|
|
1232
|
+
try:
|
|
1233
|
+
date.fromisoformat(payload["abandoned_on"])
|
|
1234
|
+
except ValueError as error:
|
|
1235
|
+
raise ArchiveError("abandon journal has invalid transaction date") from error
|
|
1236
|
+
return payload
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def write_abandon_journal(path: Path, payload: dict) -> None:
|
|
1240
|
+
temporary = path.with_name(f".{path.name}.tmp")
|
|
1241
|
+
if temporary.exists() or temporary.is_symlink():
|
|
1242
|
+
temporary.unlink()
|
|
1243
|
+
atomic_replace(path, temporary, json.dumps(payload, sort_keys=True) + "\n")
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def require_abandon_request(
|
|
1247
|
+
project: Path, journal: dict, slug: str, ruling: str
|
|
1248
|
+
) -> None:
|
|
1249
|
+
expected = {
|
|
1250
|
+
"repo": str(project),
|
|
1251
|
+
"slug": slug,
|
|
1252
|
+
"ruling": ruling,
|
|
1253
|
+
}
|
|
1254
|
+
mismatches = [
|
|
1255
|
+
key
|
|
1256
|
+
for key, value in expected.items()
|
|
1257
|
+
if journal[key] != value
|
|
1258
|
+
]
|
|
1259
|
+
if mismatches:
|
|
1260
|
+
raise ArchiveError(
|
|
1261
|
+
"abandon request differs from journal fields: "
|
|
1262
|
+
+ ", ".join(sorted(mismatches))
|
|
1263
|
+
)
|
|
1264
|
+
resolved = run_git(
|
|
1265
|
+
project,
|
|
1266
|
+
"rev-parse",
|
|
1267
|
+
"--verify",
|
|
1268
|
+
"--quiet",
|
|
1269
|
+
f"{journal['expected_head']}^{{commit}}",
|
|
1270
|
+
)
|
|
1271
|
+
if resolved.returncode != 0 or resolved.stdout.strip() != journal["expected_head"]:
|
|
1272
|
+
raise ArchiveError("abandon journal expected HEAD no longer resolves")
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def update_abandoned_roadmap(
|
|
1276
|
+
path: Path,
|
|
1277
|
+
archive_name: str,
|
|
1278
|
+
slug: str,
|
|
1279
|
+
configured: str,
|
|
1280
|
+
*,
|
|
1281
|
+
write: bool = True,
|
|
1282
|
+
) -> None:
|
|
1283
|
+
if path.is_symlink() or not path.is_file():
|
|
1284
|
+
raise ArchiveError(f"milestone abandon requires a real ROADMAP.md: {path}")
|
|
1285
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
1286
|
+
headings = []
|
|
1287
|
+
for index, line in enumerate(lines):
|
|
1288
|
+
match = ROADMAP_HEADING_PATTERN.fullmatch(line.rstrip("\r\n"))
|
|
1289
|
+
if match is None:
|
|
1290
|
+
continue
|
|
1291
|
+
if int(match.group(1).removeprefix("M")) < 1:
|
|
1292
|
+
raise ArchiveError("ROADMAP milestone number must be >= 1")
|
|
1293
|
+
headings.append((index, match.group(1), match.group(2)))
|
|
1294
|
+
identifier = milestone_id(milestone_number(archive_name))
|
|
1295
|
+
matches = [item for item in headings if item[1:] == (identifier, slug)]
|
|
1296
|
+
if len(matches) != 1:
|
|
1297
|
+
raise ArchiveError(
|
|
1298
|
+
f"ROADMAP.md requires one exact {identifier} — {slug} milestone entry"
|
|
1299
|
+
)
|
|
1300
|
+
start = matches[0][0]
|
|
1301
|
+
end = next((index for index, _, _ in headings if index > start), len(lines))
|
|
1302
|
+
status_indexes = [
|
|
1303
|
+
index for index in range(start + 1, end) if lines[index].startswith("Status:")
|
|
1304
|
+
]
|
|
1305
|
+
archive_indexes = [
|
|
1306
|
+
index for index in range(start + 1, end) if lines[index].startswith("Archive:")
|
|
1307
|
+
]
|
|
1308
|
+
if len(status_indexes) != 1 or len(archive_indexes) > 1:
|
|
1309
|
+
raise ArchiveError("ROADMAP milestone has ambiguous Status or Archive fields")
|
|
1310
|
+
status_index = status_indexes[0]
|
|
1311
|
+
status = lines[status_index].rstrip("\r\n")
|
|
1312
|
+
if status not in {"Status: active", "Status: abandoned"}:
|
|
1313
|
+
raise ArchiveError(f"ROADMAP milestone cannot be abandoned from {status!r}")
|
|
1314
|
+
lines[status_index] = "Status: abandoned\n"
|
|
1315
|
+
if archive_indexes:
|
|
1316
|
+
archive_index = archive_indexes[0]
|
|
1317
|
+
current = lines[archive_index].rstrip("\r\n")
|
|
1318
|
+
if current not in {"Archive: null", f"Archive: {configured}"}:
|
|
1319
|
+
raise ArchiveError("ROADMAP milestone Archive field conflicts with transaction")
|
|
1320
|
+
lines[archive_index] = f"Archive: {configured}\n"
|
|
1321
|
+
else:
|
|
1322
|
+
lines.insert(status_index + 1, f"Archive: {configured}\n")
|
|
1323
|
+
if not write:
|
|
1324
|
+
return
|
|
1325
|
+
temporary = path.with_name(f".{path.name}.gsd-path-tmp")
|
|
1326
|
+
if temporary.exists() or temporary.is_symlink():
|
|
1327
|
+
temporary.unlink()
|
|
1328
|
+
atomic_replace(path, temporary, "".join(lines))
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def append_abandon_lesson(
|
|
1332
|
+
path: Path, archive_name: str, ruling: str, *, write: bool = True
|
|
1333
|
+
) -> None:
|
|
1334
|
+
line = f"- {archive_name} — abandoned: {ruling}"
|
|
1335
|
+
if path.exists() or path.is_symlink():
|
|
1336
|
+
if path.is_symlink() or not path.is_file():
|
|
1337
|
+
raise ArchiveError(f"LESSONS.md must be a real file: {path}")
|
|
1338
|
+
text = path.read_text(encoding="utf-8")
|
|
1339
|
+
else:
|
|
1340
|
+
text = "# Lessons\n"
|
|
1341
|
+
matches = [
|
|
1342
|
+
value
|
|
1343
|
+
for value in text.splitlines()
|
|
1344
|
+
if value.startswith(f"- {archive_name} — abandoned:")
|
|
1345
|
+
]
|
|
1346
|
+
if matches and matches != [line]:
|
|
1347
|
+
raise ArchiveError("LESSONS.md has a conflicting abandon ruling")
|
|
1348
|
+
if matches == [line]:
|
|
1349
|
+
return
|
|
1350
|
+
if not write:
|
|
1351
|
+
raise ArchiveError("LESSONS.md is missing the canonical abandon ruling")
|
|
1352
|
+
rendered = text.rstrip() + f"\n\n{line}\n"
|
|
1353
|
+
temporary = path.with_name(f".{path.name}.gsd-path-tmp")
|
|
1354
|
+
if temporary.exists() or temporary.is_symlink():
|
|
1355
|
+
temporary.unlink()
|
|
1356
|
+
atomic_replace(path, temporary, rendered)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def completed_abandon(
|
|
1360
|
+
project: Path, active_root: Path, slug: str, ruling: str, state: PipelineState
|
|
1361
|
+
) -> dict:
|
|
1362
|
+
if not (
|
|
1363
|
+
state.phase == "roadmap"
|
|
1364
|
+
and state.status == "active"
|
|
1365
|
+
and state.milestone is None
|
|
1366
|
+
and state.archive is None
|
|
1367
|
+
and state.branch is not None
|
|
1368
|
+
):
|
|
1369
|
+
raise ArchiveError(
|
|
1370
|
+
f"milestone abandon is not valid in state: {state.phase}/{state.status}"
|
|
1371
|
+
)
|
|
1372
|
+
require_transaction_context(project, state, ("roadmap",))
|
|
1373
|
+
number = milestone_number(state.branch)
|
|
1374
|
+
archive_name = f"{number:03d}-{slug}"
|
|
1375
|
+
configured = f".project/archive/{archive_name}"
|
|
1376
|
+
archive = archive_from_state(project, configured)
|
|
1377
|
+
if archive.is_symlink() or not archive.is_dir():
|
|
1378
|
+
raise ArchiveError("completed abandoned archive is missing")
|
|
1379
|
+
manifest = archive / "MANIFEST.md"
|
|
1380
|
+
if manifest.is_symlink() or not manifest.is_file():
|
|
1381
|
+
raise ArchiveError("abandoned archive MANIFEST.md must be a real file")
|
|
1382
|
+
manifest_lines = manifest.read_text(encoding="utf-8").splitlines()
|
|
1383
|
+
abandoned_on = completed_field(manifest_lines, "Abandoned:", "abandon manifest")
|
|
1384
|
+
validate_abandon_manifest(archive, slug, ruling, abandoned_on)
|
|
1385
|
+
update_abandoned_roadmap(
|
|
1386
|
+
active_root / "ROADMAP.md",
|
|
1387
|
+
archive_name,
|
|
1388
|
+
slug,
|
|
1389
|
+
configured,
|
|
1390
|
+
write=False,
|
|
1391
|
+
)
|
|
1392
|
+
append_abandon_lesson(
|
|
1393
|
+
active_root / "LESSONS.md", archive_name, ruling, write=False
|
|
1394
|
+
)
|
|
1395
|
+
head = require_git_success(run_git(project, "rev-parse", "HEAD"), "resolve HEAD")
|
|
1396
|
+
parent = require_git_success(
|
|
1397
|
+
run_git(project, "rev-parse", f"{head}^"), "resolve abandon checkpoint parent"
|
|
1398
|
+
)
|
|
1399
|
+
try:
|
|
1400
|
+
checkpoint = isolation_checkpoint(
|
|
1401
|
+
project,
|
|
1402
|
+
parent,
|
|
1403
|
+
f"build: abandon milestone {slug}",
|
|
1404
|
+
f"Why: {ruling}",
|
|
1405
|
+
[".project"],
|
|
1406
|
+
)
|
|
1407
|
+
except IsolationError as error:
|
|
1408
|
+
raise ArchiveError(f"completed abandon checkpoint is invalid: {error}") from error
|
|
1409
|
+
return {
|
|
1410
|
+
"archive": configured,
|
|
1411
|
+
"carried_forward": pending_ruling_count(
|
|
1412
|
+
archive / "research" / "DOCS-AUDIT.md"
|
|
1413
|
+
),
|
|
1414
|
+
"commit": checkpoint["commit"],
|
|
1415
|
+
"status": checkpoint["status"],
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def abandon(repo: Path, slug: str, reason: str) -> dict:
|
|
1420
|
+
project = repo.resolve()
|
|
1421
|
+
active_root = require_project_layout(project)
|
|
1422
|
+
requested_slug = normalized_slug(slug)
|
|
1423
|
+
ruling = " ".join(reason.split())
|
|
1424
|
+
if not ruling:
|
|
1425
|
+
raise ArchiveError("milestone abandon requires a non-empty --reason ruling")
|
|
1426
|
+
journal_path = abandon_journal_path(project)
|
|
1427
|
+
if journal_path.is_symlink():
|
|
1428
|
+
raise ArchiveError(f"abandon journal must not be a symlink: {journal_path}")
|
|
1429
|
+
journal = read_abandon_journal(journal_path) if journal_path.is_file() else None
|
|
1430
|
+
if journal_path.exists() and journal is None:
|
|
1431
|
+
raise ArchiveError(f"abandon journal must be a real file: {journal_path}")
|
|
1432
|
+
if journal is not None:
|
|
1433
|
+
require_abandon_request(project, journal, requested_slug, ruling)
|
|
1434
|
+
else:
|
|
1435
|
+
state, _, _ = strict_state(project)
|
|
1436
|
+
if state.phase == "roadmap":
|
|
1437
|
+
with discussion_lock(active_root):
|
|
1438
|
+
return completed_abandon(
|
|
1439
|
+
project, active_root, requested_slug, ruling, state
|
|
1440
|
+
)
|
|
1441
|
+
with discussion_lock(active_root):
|
|
1442
|
+
journal, carried_forward = abandon_locked(
|
|
1443
|
+
project,
|
|
1444
|
+
active_root,
|
|
1445
|
+
requested_slug,
|
|
1446
|
+
ruling,
|
|
1447
|
+
journal_path,
|
|
1448
|
+
journal,
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
try:
|
|
1452
|
+
state, _, _ = strict_state(project)
|
|
1453
|
+
if state.phase == "build":
|
|
1454
|
+
transition_state(
|
|
1455
|
+
project,
|
|
1456
|
+
expected={
|
|
1457
|
+
"phase": "build",
|
|
1458
|
+
"status": journal["build_status"],
|
|
1459
|
+
"milestone": journal["slug"],
|
|
1460
|
+
"branch": journal["branch"],
|
|
1461
|
+
"archive": journal["archive"],
|
|
1462
|
+
},
|
|
1463
|
+
changes={
|
|
1464
|
+
"phase": "roadmap",
|
|
1465
|
+
"status": "active",
|
|
1466
|
+
"milestone": None,
|
|
1467
|
+
"archive": None,
|
|
1468
|
+
},
|
|
1469
|
+
event=(
|
|
1470
|
+
f"milestone abandoned: {journal['slug']}; "
|
|
1471
|
+
f"archive: {journal['archive']}; ruling: {journal['ruling']}"
|
|
1472
|
+
),
|
|
1473
|
+
)
|
|
1474
|
+
elif not (
|
|
1475
|
+
state.phase == "roadmap"
|
|
1476
|
+
and state.status == "active"
|
|
1477
|
+
and state.milestone is None
|
|
1478
|
+
and state.branch == journal["branch"]
|
|
1479
|
+
and state.archive is None
|
|
1480
|
+
):
|
|
1481
|
+
raise ArchiveError("abandon transaction has an invalid finalized STATE.md")
|
|
1482
|
+
|
|
1483
|
+
finalized_state, _, _ = strict_state(project)
|
|
1484
|
+
require_transaction_context(project, finalized_state, ("roadmap",))
|
|
1485
|
+
checkpoint = isolation_checkpoint(
|
|
1486
|
+
project,
|
|
1487
|
+
journal["expected_head"],
|
|
1488
|
+
f"build: abandon milestone {journal['slug']}",
|
|
1489
|
+
f"Why: {journal['ruling']}",
|
|
1490
|
+
[".project"],
|
|
1491
|
+
)
|
|
1492
|
+
except (PipelineStateError, IsolationError) as error:
|
|
1493
|
+
raise ArchiveError(f"abandon finalization failed: {error}") from error
|
|
1494
|
+
|
|
1495
|
+
journal_path.unlink()
|
|
1496
|
+
return {
|
|
1497
|
+
"archive": journal["archive"],
|
|
1498
|
+
"carried_forward": carried_forward,
|
|
1499
|
+
"commit": checkpoint["commit"],
|
|
1500
|
+
"status": checkpoint["status"],
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def abandon_locked(
|
|
1505
|
+
project: Path,
|
|
1506
|
+
active_root: Path,
|
|
1507
|
+
slug: str,
|
|
1508
|
+
ruling: str,
|
|
1509
|
+
journal_path: Path,
|
|
1510
|
+
journal: Optional[dict],
|
|
1511
|
+
) -> tuple[dict, int]:
|
|
1512
|
+
state_path = active_root / "STATE.md"
|
|
1513
|
+
state_temporary = active_root / STATE_TEMP_NAME
|
|
1514
|
+
|
|
1515
|
+
parsed_state, _, loaded_state_path = strict_state(project)
|
|
1516
|
+
if loaded_state_path.resolve() != state_path.resolve():
|
|
1517
|
+
raise ArchiveError("strict STATE.md loader returned an unexpected path")
|
|
1518
|
+
if not is_real_file(active_root / "ROADMAP.md"):
|
|
1519
|
+
raise ArchiveError(
|
|
1520
|
+
"milestone abandon requires program flow: missing .project/ROADMAP.md"
|
|
1521
|
+
)
|
|
1522
|
+
require_transaction_context(project, parsed_state, ("build", "roadmap"))
|
|
1523
|
+
if journal is None:
|
|
1524
|
+
if parsed_state.phase != "build" or parsed_state.status not in {"active", "blocked"}:
|
|
1525
|
+
raise ArchiveError(
|
|
1526
|
+
f"milestone abandon is not valid in state: "
|
|
1527
|
+
f"{parsed_state.phase}/{parsed_state.status}"
|
|
1528
|
+
)
|
|
1529
|
+
expected_slug = milestone_slug(parsed_state)
|
|
1530
|
+
if slug != expected_slug:
|
|
1531
|
+
raise ArchiveError(
|
|
1532
|
+
f"--slug {slug!r} does not match normalized "
|
|
1533
|
+
f"STATE.milestone {expected_slug!r}"
|
|
1534
|
+
)
|
|
1535
|
+
archive = resolved_archive_target(project, slug, parsed_state)
|
|
1536
|
+
configured = archive.relative_to(project).as_posix()
|
|
1537
|
+
expected_head = require_git_success(
|
|
1538
|
+
run_git(project, "rev-parse", "HEAD"), "resolve abandon base"
|
|
1539
|
+
)
|
|
1540
|
+
journal = {
|
|
1541
|
+
"schema": ABANDON_JOURNAL_SCHEMA,
|
|
1542
|
+
"repo": str(project),
|
|
1543
|
+
"slug": slug,
|
|
1544
|
+
"ruling": ruling,
|
|
1545
|
+
"expected_head": expected_head,
|
|
1546
|
+
"archive": configured,
|
|
1547
|
+
"branch": parsed_state.branch,
|
|
1548
|
+
"build_status": parsed_state.status,
|
|
1549
|
+
"abandoned_on": date.today().isoformat(),
|
|
1550
|
+
}
|
|
1551
|
+
write_abandon_journal(journal_path, journal)
|
|
1552
|
+
else:
|
|
1553
|
+
require_abandon_request(project, journal, slug, ruling)
|
|
1554
|
+
if parsed_state.branch != journal["branch"]:
|
|
1555
|
+
raise ArchiveError("STATE.branch differs from the abandon journal")
|
|
1556
|
+
|
|
1557
|
+
configured = journal["archive"]
|
|
1558
|
+
if parsed_state.phase == "roadmap":
|
|
1559
|
+
archive = archive_from_state(project, configured)
|
|
1560
|
+
if not (
|
|
1561
|
+
parsed_state.status == "active"
|
|
1562
|
+
and parsed_state.milestone is None
|
|
1563
|
+
and parsed_state.archive is None
|
|
1564
|
+
):
|
|
1565
|
+
raise ArchiveError("abandon transaction has an invalid finalized STATE.md")
|
|
1566
|
+
if archive.is_symlink() or not archive.is_dir():
|
|
1567
|
+
raise ArchiveError("abandoned archive is missing during checkpoint recovery")
|
|
1568
|
+
validate_abandon_manifest(
|
|
1569
|
+
archive,
|
|
1570
|
+
journal["slug"],
|
|
1571
|
+
journal["ruling"],
|
|
1572
|
+
journal["abandoned_on"],
|
|
1573
|
+
)
|
|
1574
|
+
update_abandoned_roadmap(
|
|
1575
|
+
active_root / "ROADMAP.md", archive.name, journal["slug"], configured
|
|
1576
|
+
)
|
|
1577
|
+
append_abandon_lesson(
|
|
1578
|
+
active_root / "LESSONS.md", archive.name, journal["ruling"]
|
|
1579
|
+
)
|
|
1580
|
+
require_clean_active_root(active_root, archive)
|
|
1581
|
+
return journal, pending_ruling_count(archive / "research" / "DOCS-AUDIT.md")
|
|
1582
|
+
|
|
1583
|
+
if (
|
|
1584
|
+
parsed_state.status != journal["build_status"]
|
|
1585
|
+
or parsed_state.milestone != journal["slug"]
|
|
1586
|
+
or parsed_state.branch != journal["branch"]
|
|
1587
|
+
):
|
|
1588
|
+
raise ArchiveError("build STATE.md differs from the abandon journal")
|
|
1589
|
+
update_abandoned_roadmap(
|
|
1590
|
+
active_root / "ROADMAP.md",
|
|
1591
|
+
PurePosixPath(configured).name,
|
|
1592
|
+
journal["slug"],
|
|
1593
|
+
configured,
|
|
1594
|
+
write=False,
|
|
1595
|
+
)
|
|
1596
|
+
if parsed_state.archive is not None:
|
|
1597
|
+
relative_archive = archive_relative_from_state(parsed_state.archive)
|
|
1598
|
+
require_archive_milestone(relative_archive.name, parsed_state)
|
|
1599
|
+
if parsed_state.archive != configured:
|
|
1600
|
+
raise ArchiveError("STATE.archive differs from the abandon journal")
|
|
1601
|
+
require_uncommitted_archive(project, configured)
|
|
1602
|
+
if state_temporary.exists() or state_temporary.is_symlink():
|
|
1603
|
+
state_temporary.unlink()
|
|
1604
|
+
|
|
1605
|
+
archive = persisted_archive(project, state_path, slug, parsed_state)
|
|
1606
|
+
if archive.relative_to(project).as_posix() != configured:
|
|
1607
|
+
raise ArchiveError("persisted archive differs from the abandon journal")
|
|
1608
|
+
require_archive_milestone(archive.name, parsed_state)
|
|
1609
|
+
archive.mkdir(parents=True, exist_ok=True)
|
|
1610
|
+
manifest_temporary = archive / MANIFEST_TEMP_NAME
|
|
1611
|
+
if manifest_temporary.exists() or manifest_temporary.is_symlink():
|
|
1612
|
+
manifest_temporary.unlink()
|
|
1613
|
+
|
|
1614
|
+
remove_interrupted_carry_copy(active_root, archive)
|
|
1615
|
+
remove_interrupted_discussion_copies(active_root, archive)
|
|
1616
|
+
finish_discussion_reconciliation(active_root, archive)
|
|
1617
|
+
require_complete_abandon_inputs(active_root, archive)
|
|
1618
|
+
require_canonical_abandon_inputs(active_root, archive)
|
|
1619
|
+
require_safe_move_inputs(active_root, archive)
|
|
1620
|
+
reconcile_append_only_discussion(active_root, archive)
|
|
1621
|
+
for name in TRANSACTION_DIRECTORIES:
|
|
1622
|
+
move_directory(active_root / name, archive / name)
|
|
1623
|
+
|
|
1624
|
+
archived_audit = archive / "research" / "DOCS-AUDIT.md"
|
|
1625
|
+
carried_forward = pending_ruling_count(archived_audit)
|
|
1626
|
+
if carried_forward:
|
|
1627
|
+
active_audit = active_root / "research" / "DOCS-AUDIT.md"
|
|
1628
|
+
active_audit.parent.mkdir(parents=True, exist_ok=True)
|
|
1629
|
+
if active_audit.exists() and not filecmp.cmp(active_audit, archived_audit, shallow=False):
|
|
1630
|
+
raise ArchiveError(f"carry-forward collision: {active_audit} differs from archived audit")
|
|
1631
|
+
if not active_audit.exists():
|
|
1632
|
+
atomic_copy(archived_audit, active_audit)
|
|
1633
|
+
|
|
1634
|
+
write_abandon_manifest(
|
|
1635
|
+
archive,
|
|
1636
|
+
journal["slug"],
|
|
1637
|
+
journal["ruling"],
|
|
1638
|
+
journal["abandoned_on"],
|
|
1639
|
+
)
|
|
1640
|
+
validate_abandon_manifest(
|
|
1641
|
+
archive,
|
|
1642
|
+
journal["slug"],
|
|
1643
|
+
journal["ruling"],
|
|
1644
|
+
journal["abandoned_on"],
|
|
1645
|
+
)
|
|
1646
|
+
update_abandoned_roadmap(
|
|
1647
|
+
active_root / "ROADMAP.md", archive.name, journal["slug"], configured
|
|
1648
|
+
)
|
|
1649
|
+
append_abandon_lesson(
|
|
1650
|
+
active_root / "LESSONS.md", archive.name, journal["ruling"]
|
|
1651
|
+
)
|
|
1652
|
+
require_clean_active_root(active_root, archive)
|
|
1653
|
+
return journal, carried_forward
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
run_git = _common.run_git
|
|
1657
|
+
run_command = _common.run_command
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def require_git_success(result: subprocess.CompletedProcess, action: str) -> str:
|
|
1661
|
+
if result.returncode != 0:
|
|
1662
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
1663
|
+
raise ArchiveError(f"{action} failed: {detail}")
|
|
1664
|
+
return result.stdout.strip()
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def archive_is_committed(project: Path, configured: str) -> bool:
|
|
1668
|
+
return run_git(project, "cat-file", "-e", f"HEAD:{configured}").returncode == 0
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def require_uncommitted_archive(project: Path, configured: str) -> None:
|
|
1672
|
+
if archive_is_committed(project, configured):
|
|
1673
|
+
raise ArchiveError("current archive is already committed; run validate instead")
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def require_clean_older_archives(project: Path, configured: str) -> None:
|
|
1677
|
+
result = run_git(
|
|
1678
|
+
project,
|
|
1679
|
+
"status",
|
|
1680
|
+
"--porcelain=v1",
|
|
1681
|
+
"-z",
|
|
1682
|
+
"--untracked-files=all",
|
|
1683
|
+
"--ignored=matching",
|
|
1684
|
+
"--",
|
|
1685
|
+
".project/archive",
|
|
1686
|
+
)
|
|
1687
|
+
if result.returncode != 0:
|
|
1688
|
+
require_git_success(result, "inspect archive worktree status")
|
|
1689
|
+
status = result.stdout
|
|
1690
|
+
current_prefix = f"{configured}/"
|
|
1691
|
+
dirty = set()
|
|
1692
|
+
ignored_current = set()
|
|
1693
|
+
for record in status.split("\0"):
|
|
1694
|
+
has_status = len(record) > 3 and record[2] == " "
|
|
1695
|
+
code = record[:2] if has_status else ""
|
|
1696
|
+
path = record[3:] if has_status else record
|
|
1697
|
+
if code == "!!" and (
|
|
1698
|
+
path == ".project/archive/"
|
|
1699
|
+
or path == configured
|
|
1700
|
+
or path.startswith(current_prefix)
|
|
1701
|
+
):
|
|
1702
|
+
ignored_current.add(path)
|
|
1703
|
+
if (
|
|
1704
|
+
path.startswith(".project/archive/")
|
|
1705
|
+
and path != configured
|
|
1706
|
+
and not path.startswith(current_prefix)
|
|
1707
|
+
):
|
|
1708
|
+
dirty.add(path)
|
|
1709
|
+
if ignored_current:
|
|
1710
|
+
raise ArchiveError(
|
|
1711
|
+
f"ignored current archive paths would be omitted from shipment: "
|
|
1712
|
+
f"{', '.join(sorted(ignored_current))}"
|
|
1713
|
+
)
|
|
1714
|
+
if dirty:
|
|
1715
|
+
raise ArchiveError(f"dirty older archive paths block shipment: {', '.join(sorted(dirty))}")
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def require_canonical_archive(archive: Path) -> None:
|
|
1719
|
+
missing = [relative for relative in REQUIRED_ARCHIVE_FILES if not is_real_file(archive / relative)]
|
|
1720
|
+
if not canonical_task_files(archive / "tasks"):
|
|
1721
|
+
missing.append("tasks/T###-slug.md")
|
|
1722
|
+
if not canonical_wave_files(archive / "review"):
|
|
1723
|
+
missing.append("review/wave-N.cycleC.md")
|
|
1724
|
+
plan = archive / "plan" / "PLAN.md"
|
|
1725
|
+
for directory in DIRECTORIES_TO_ARCHIVE:
|
|
1726
|
+
path = archive / directory
|
|
1727
|
+
if path.is_symlink() or not path.is_dir():
|
|
1728
|
+
missing.append(f"{directory}/")
|
|
1729
|
+
if missing:
|
|
1730
|
+
raise ArchiveError(f"canonical archive artifacts are missing: {', '.join(sorted(set(missing)))}")
|
|
1731
|
+
require_plan_panel_evidence(
|
|
1732
|
+
plan.read_text(encoding="utf-8"),
|
|
1733
|
+
archive / "review" / "PLAN-PANEL.md",
|
|
1734
|
+
archive / "review" / "PLAN-PANEL.skipped.json",
|
|
1735
|
+
)
|
|
1736
|
+
discussion = archive / "discuss"
|
|
1737
|
+
if discussion.exists() or discussion.is_symlink():
|
|
1738
|
+
validate_discussion_directory(discussion)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def require_clean_active_root(active_root: Path, archive: Path) -> None:
|
|
1742
|
+
archived_audit = archive / "research" / "DOCS-AUDIT.md"
|
|
1743
|
+
carried_forward = pending_ruling_count(archived_audit)
|
|
1744
|
+
# Persistent project metadata ships but never archives with a milestone.
|
|
1745
|
+
allowed = {"STATE.md", "REPOSITORY.md", "archive", "LESSONS.md", "next"}
|
|
1746
|
+
if (active_root / "CHARTER.md").is_file():
|
|
1747
|
+
# Program flow: these persist at the .project/ top level.
|
|
1748
|
+
allowed |= {"CHARTER.md", "ROADMAP.md", "SYNTHESIS.md"}
|
|
1749
|
+
active_research = active_root / "research"
|
|
1750
|
+
|
|
1751
|
+
if carried_forward:
|
|
1752
|
+
allowed.add("research")
|
|
1753
|
+
active_audit = active_research / "DOCS-AUDIT.md"
|
|
1754
|
+
entries = sorted(path.name for path in active_research.iterdir()) if active_research.is_dir() else []
|
|
1755
|
+
if (
|
|
1756
|
+
active_research.is_symlink()
|
|
1757
|
+
or entries != ["DOCS-AUDIT.md"]
|
|
1758
|
+
or active_audit.is_symlink()
|
|
1759
|
+
or not active_audit.is_file()
|
|
1760
|
+
or not filecmp.cmp(active_audit, archived_audit, shallow=False)
|
|
1761
|
+
):
|
|
1762
|
+
raise ArchiveError("pending DOCS-AUDIT rulings lack an exact active carry-forward copy")
|
|
1763
|
+
elif active_research.exists() or active_research.is_symlink():
|
|
1764
|
+
raise ArchiveError("active research exists without a pending carry-forward queue")
|
|
1765
|
+
|
|
1766
|
+
unexpected = sorted(path.name for path in active_root.iterdir() if path.name not in allowed)
|
|
1767
|
+
if unexpected:
|
|
1768
|
+
raise ArchiveError(f"unexpected active .project paths remain: {', '.join(unexpected)}")
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def require_stageable_carry_forward(project: Path, archive: Path) -> None:
|
|
1772
|
+
if pending_ruling_count(archive / "research" / "DOCS-AUDIT.md") == 0:
|
|
1773
|
+
return
|
|
1774
|
+
carry_path = ".project/research/DOCS-AUDIT.md"
|
|
1775
|
+
tracked = run_git(project, "ls-files", "--error-unmatch", "--", carry_path)
|
|
1776
|
+
if tracked.returncode == 0:
|
|
1777
|
+
return
|
|
1778
|
+
if tracked.returncode != 1:
|
|
1779
|
+
require_git_success(tracked, "inspect active carry-forward tracking status")
|
|
1780
|
+
ignored = run_git(project, "check-ignore", "-q", "--no-index", "--", carry_path)
|
|
1781
|
+
if ignored.returncode == 0:
|
|
1782
|
+
raise ArchiveError("ignored active carry-forward is not tracked and would be omitted")
|
|
1783
|
+
if ignored.returncode != 1:
|
|
1784
|
+
require_git_success(ignored, "inspect active carry-forward ignore status")
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def require_committed_carry_forward(project: Path, configured: str, archive: Path, ref: str) -> None:
|
|
1788
|
+
if pending_ruling_count(archive / "research" / "DOCS-AUDIT.md") == 0:
|
|
1789
|
+
return
|
|
1790
|
+
active_blob = require_git_success(
|
|
1791
|
+
run_git(project, "rev-parse", "--verify", f"{ref}:.project/research/DOCS-AUDIT.md"),
|
|
1792
|
+
"verify committed carry-forward",
|
|
1793
|
+
)
|
|
1794
|
+
archived_blob = require_git_success(
|
|
1795
|
+
run_git(
|
|
1796
|
+
project,
|
|
1797
|
+
"rev-parse",
|
|
1798
|
+
"--verify",
|
|
1799
|
+
f"{ref}:{configured}/research/DOCS-AUDIT.md",
|
|
1800
|
+
),
|
|
1801
|
+
"verify archived carry-forward",
|
|
1802
|
+
)
|
|
1803
|
+
if active_blob != archived_blob:
|
|
1804
|
+
raise ArchiveError("committed carry-forward differs from the archived DOCS-AUDIT.md")
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def prepared_transaction(
|
|
1808
|
+
repo: Path,
|
|
1809
|
+
phases: Sequence[str],
|
|
1810
|
+
must_be_uncommitted: bool = False,
|
|
1811
|
+
) -> tuple:
|
|
1812
|
+
project = repo.resolve()
|
|
1813
|
+
active_root = require_project_layout(project)
|
|
1814
|
+
state_path = active_root / "STATE.md"
|
|
1815
|
+
|
|
1816
|
+
state, _, loaded_state_path = strict_state(project)
|
|
1817
|
+
if loaded_state_path.resolve() != state_path.resolve():
|
|
1818
|
+
raise ArchiveError("strict STATE.md loader returned an unexpected path")
|
|
1819
|
+
require_transaction_context(project, state, phases)
|
|
1820
|
+
|
|
1821
|
+
configured = state.archive
|
|
1822
|
+
if configured is None:
|
|
1823
|
+
raise ArchiveError("STATE.md does not name the archive transaction")
|
|
1824
|
+
relative_archive = archive_relative_from_state(configured)
|
|
1825
|
+
require_archive_milestone(relative_archive.name, state)
|
|
1826
|
+
if must_be_uncommitted:
|
|
1827
|
+
require_uncommitted_archive(project, configured)
|
|
1828
|
+
archive = archive_from_state(project, configured)
|
|
1829
|
+
if archive.is_symlink() or not archive.is_dir() or not (archive / "MANIFEST.md").is_file():
|
|
1830
|
+
raise ArchiveError("archive or MANIFEST.md is missing")
|
|
1831
|
+
if (archive / MANIFEST_TEMP_NAME).exists() or (archive / MANIFEST_TEMP_NAME).is_symlink():
|
|
1832
|
+
raise ArchiveError("interrupted manifest temporary file remains; rerun prepare")
|
|
1833
|
+
|
|
1834
|
+
require_canonical_archive(archive)
|
|
1835
|
+
require_clean_active_root(active_root, archive)
|
|
1836
|
+
archived_files, reviewed_head = validate_manifest(project, archive, state)
|
|
1837
|
+
return project, active_root, state, configured, archive, archived_files, reviewed_head
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def preflight(repo: Path) -> dict:
|
|
1841
|
+
project, _, state, configured, archive, _, reviewed_head = prepared_transaction(
|
|
1842
|
+
repo,
|
|
1843
|
+
("ship", "shipped"),
|
|
1844
|
+
must_be_uncommitted=True,
|
|
1845
|
+
)
|
|
1846
|
+
phase = state.phase
|
|
1847
|
+
status = state.status
|
|
1848
|
+
if (phase, status) not in {("ship", "active"), ("shipped", "done")}:
|
|
1849
|
+
raise ArchiveError("archive preflight requires ship/active or uncommitted shipped/done")
|
|
1850
|
+
require_stageable_carry_forward(project, archive)
|
|
1851
|
+
require_clean_older_archives(project, configured)
|
|
1852
|
+
head = require_git_success(run_git(project, "rev-parse", "HEAD"), "resolve preflight HEAD")
|
|
1853
|
+
if reviewed_head != head:
|
|
1854
|
+
raise ArchiveError(
|
|
1855
|
+
f"FINAL.md Reviewed HEAD {reviewed_head} does not match current HEAD {head}"
|
|
1856
|
+
)
|
|
1857
|
+
return {
|
|
1858
|
+
"archive": configured,
|
|
1859
|
+
"carried_forward": pending_ruling_count(archive / "research" / "DOCS-AUDIT.md"),
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def require_canonical_commit_body(
|
|
1864
|
+
project: Path,
|
|
1865
|
+
commit: str,
|
|
1866
|
+
canonical_subject: str,
|
|
1867
|
+
expected_body: str,
|
|
1868
|
+
label: str,
|
|
1869
|
+
) -> None:
|
|
1870
|
+
message = require_git_success(
|
|
1871
|
+
run_git(project, "show", "-s", "--format=%s%x00%b", commit),
|
|
1872
|
+
f"inspect {label} commit message",
|
|
1873
|
+
)
|
|
1874
|
+
subject, separator, body = message.partition("\x00")
|
|
1875
|
+
if not separator:
|
|
1876
|
+
raise ArchiveError(f"{label} commit message is malformed")
|
|
1877
|
+
if subject != canonical_subject:
|
|
1878
|
+
raise ArchiveError(f"{label} commit subject must be {canonical_subject!r}")
|
|
1879
|
+
if body.strip() != expected_body.strip():
|
|
1880
|
+
raise ArchiveError(f"{label} commit body does not match required fields")
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def find_ship_commit(project: Path, archive_name: str) -> str:
|
|
1884
|
+
expected_subject = ship_subject(archive_name)
|
|
1885
|
+
log = require_git_success(
|
|
1886
|
+
run_git(project, "log", "--first-parent", "--format=%H%x00%s", "HEAD"),
|
|
1887
|
+
"inspect HEAD history for the ship commit",
|
|
1888
|
+
)
|
|
1889
|
+
matches = []
|
|
1890
|
+
for record in log.splitlines():
|
|
1891
|
+
if "\x00" not in record:
|
|
1892
|
+
continue
|
|
1893
|
+
commit, subject = record.split("\x00", 1)
|
|
1894
|
+
if subject == expected_subject:
|
|
1895
|
+
matches.append(commit)
|
|
1896
|
+
if not matches:
|
|
1897
|
+
raise ArchiveError(f"no commit with exact subject {expected_subject!r} in HEAD history")
|
|
1898
|
+
if len(matches) != 1:
|
|
1899
|
+
raise ArchiveError(
|
|
1900
|
+
f"multiple commits with exact subject {expected_subject!r} in HEAD history"
|
|
1901
|
+
)
|
|
1902
|
+
return matches[0]
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def validate_shipped_roadmap(text: str, state: PipelineState, archive: str) -> None:
|
|
1906
|
+
if state.milestone is None or state.branch is None:
|
|
1907
|
+
raise ArchiveError("shipped program state must name milestone and branch")
|
|
1908
|
+
lines = text.splitlines()
|
|
1909
|
+
headings = [
|
|
1910
|
+
(index, match)
|
|
1911
|
+
for index, line in enumerate(lines)
|
|
1912
|
+
if (match := ROADMAP_HEADING_PATTERN.fullmatch(line)) is not None
|
|
1913
|
+
]
|
|
1914
|
+
matches = [item for item in headings if item[1].group(2) == state.milestone]
|
|
1915
|
+
if len(matches) != 1:
|
|
1916
|
+
raise ArchiveError("ROADMAP.md must contain one shipped milestone entry")
|
|
1917
|
+
start, heading = matches[0]
|
|
1918
|
+
end = next((index for index, _ in headings if index > start), len(lines))
|
|
1919
|
+
expected_id = milestone_id(milestone_number(state.branch))
|
|
1920
|
+
if heading.group(1) != expected_id:
|
|
1921
|
+
raise ArchiveError("ROADMAP.md shipped milestone id differs from STATE.branch")
|
|
1922
|
+
section = lines[start:end]
|
|
1923
|
+
statuses = [line.split(":", 1)[1].strip() for line in section if line.startswith("Status:")]
|
|
1924
|
+
archives = [line.split(":", 1)[1].strip() for line in section if line.startswith("Archive:")]
|
|
1925
|
+
if statuses != ["shipped"] or archives != [archive]:
|
|
1926
|
+
raise ArchiveError("ROADMAP.md must record Status: shipped and the exact Archive")
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
def validate(repo: Path) -> dict:
|
|
1930
|
+
project, _, state, configured, archive, archived_files, reviewed_head = prepared_transaction(
|
|
1931
|
+
repo,
|
|
1932
|
+
("shipped",),
|
|
1933
|
+
)
|
|
1934
|
+
if state.status != "done":
|
|
1935
|
+
raise ArchiveError("STATE.md is not shipped/done")
|
|
1936
|
+
|
|
1937
|
+
project_status = require_git_success(run_git(project, "status", "--porcelain"), "inspect worktree status")
|
|
1938
|
+
if project_status:
|
|
1939
|
+
raise ArchiveError("ship transaction worktree is not clean")
|
|
1940
|
+
|
|
1941
|
+
ship_commit = find_ship_commit(project, archive.name)
|
|
1942
|
+
|
|
1943
|
+
parents = require_git_success(
|
|
1944
|
+
run_git(project, "rev-list", "--parents", "-n", "1", ship_commit),
|
|
1945
|
+
"inspect ship commit parents",
|
|
1946
|
+
).split()
|
|
1947
|
+
if len(parents) != 2:
|
|
1948
|
+
raise ArchiveError("ship commit must have exactly one parent")
|
|
1949
|
+
|
|
1950
|
+
changed_paths = require_git_success(
|
|
1951
|
+
run_git(
|
|
1952
|
+
project,
|
|
1953
|
+
"diff-tree",
|
|
1954
|
+
"--root",
|
|
1955
|
+
"--no-commit-id",
|
|
1956
|
+
"--name-only",
|
|
1957
|
+
"--no-renames",
|
|
1958
|
+
"-r",
|
|
1959
|
+
ship_commit,
|
|
1960
|
+
),
|
|
1961
|
+
"inspect ship commit paths",
|
|
1962
|
+
).splitlines()
|
|
1963
|
+
outside_project = [path for path in changed_paths if not path.startswith(".project/")]
|
|
1964
|
+
if outside_project:
|
|
1965
|
+
raise ArchiveError(f"ship commit changes paths outside .project: {', '.join(outside_project)}")
|
|
1966
|
+
|
|
1967
|
+
archive_prefix = f"{configured}/"
|
|
1968
|
+
older_archive_changes = [
|
|
1969
|
+
path
|
|
1970
|
+
for path in changed_paths
|
|
1971
|
+
if path.startswith(".project/archive/") and not path.startswith(archive_prefix)
|
|
1972
|
+
]
|
|
1973
|
+
if older_archive_changes:
|
|
1974
|
+
raise ArchiveError(f"ship commit mutates an older archive: {', '.join(older_archive_changes)}")
|
|
1975
|
+
|
|
1976
|
+
allowed_active_prefixes = tuple(f".project/{name}/" for name in TRANSACTION_DIRECTORIES)
|
|
1977
|
+
persistent_program_files = set()
|
|
1978
|
+
charter_path = project / ".project" / "CHARTER.md"
|
|
1979
|
+
roadmap_path = project / ".project" / "ROADMAP.md"
|
|
1980
|
+
if charter_path.is_symlink() or (charter_path.exists() and not charter_path.is_file()):
|
|
1981
|
+
raise ArchiveError("CHARTER.md must be a real file")
|
|
1982
|
+
if roadmap_path.is_symlink() or (roadmap_path.exists() and not roadmap_path.is_file()):
|
|
1983
|
+
raise ArchiveError("ROADMAP.md must be a real file")
|
|
1984
|
+
program_ship = is_real_file(charter_path)
|
|
1985
|
+
if program_ship:
|
|
1986
|
+
persistent_program_files = {
|
|
1987
|
+
".project/CHARTER.md",
|
|
1988
|
+
".project/ROADMAP.md",
|
|
1989
|
+
".project/SYNTHESIS.md",
|
|
1990
|
+
}
|
|
1991
|
+
unexpected_project_paths = [
|
|
1992
|
+
path
|
|
1993
|
+
for path in changed_paths
|
|
1994
|
+
if path != ".project/STATE.md"
|
|
1995
|
+
and path != ".project/LESSONS.md"
|
|
1996
|
+
and path not in persistent_program_files
|
|
1997
|
+
and not path.startswith(archive_prefix)
|
|
1998
|
+
and not path.startswith(allowed_active_prefixes)
|
|
1999
|
+
]
|
|
2000
|
+
if unexpected_project_paths:
|
|
2001
|
+
raise ArchiveError(
|
|
2002
|
+
f"ship commit changes unexpected .project paths: {', '.join(unexpected_project_paths)}"
|
|
2003
|
+
)
|
|
2004
|
+
|
|
2005
|
+
required_paths = {
|
|
2006
|
+
".project/STATE.md",
|
|
2007
|
+
f"{configured}/MANIFEST.md",
|
|
2008
|
+
*(f"{configured}/{path}" for path in archived_files),
|
|
2009
|
+
}
|
|
2010
|
+
if program_ship:
|
|
2011
|
+
required_paths.add(".project/ROADMAP.md")
|
|
2012
|
+
missing_from_commit = sorted(required_paths - set(changed_paths))
|
|
2013
|
+
if missing_from_commit:
|
|
2014
|
+
raise ArchiveError(
|
|
2015
|
+
f"ship commit does not record the complete transaction: {', '.join(missing_from_commit)}"
|
|
2016
|
+
)
|
|
2017
|
+
require_committed_carry_forward(project, configured, archive, ship_commit)
|
|
2018
|
+
|
|
2019
|
+
ship_parent = require_git_success(run_git(project, "rev-parse", f"{ship_commit}^"), "resolve ship parent")
|
|
2020
|
+
if reviewed_head != ship_parent:
|
|
2021
|
+
raise ArchiveError(
|
|
2022
|
+
f"FINAL.md Reviewed HEAD {reviewed_head} does not match ship parent {ship_parent}"
|
|
2023
|
+
)
|
|
2024
|
+
require_canonical_commit_body(
|
|
2025
|
+
project,
|
|
2026
|
+
ship_commit,
|
|
2027
|
+
ship_subject(archive.name),
|
|
2028
|
+
ship_commit_body(configured, reviewed_head),
|
|
2029
|
+
"ship",
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
archive_in_parent = run_git(project, "cat-file", "-e", f"{ship_commit}^:{configured}")
|
|
2033
|
+
if archive_in_parent.returncode == 0:
|
|
2034
|
+
raise ArchiveError("current archive was already committed before the ship commit")
|
|
2035
|
+
|
|
2036
|
+
committed_state = require_git_success(
|
|
2037
|
+
run_git(project, "show", f"{ship_commit}:.project/STATE.md"),
|
|
2038
|
+
"read committed STATE.md",
|
|
2039
|
+
)
|
|
2040
|
+
current_state = (project / ".project" / "STATE.md").read_text(
|
|
2041
|
+
encoding="utf-8"
|
|
2042
|
+
).strip()
|
|
2043
|
+
if committed_state != current_state:
|
|
2044
|
+
raise ArchiveError("ship commit does not contain the shipped state transaction")
|
|
2045
|
+
if program_ship:
|
|
2046
|
+
committed_roadmap = require_git_success(
|
|
2047
|
+
run_git(project, "show", f"{ship_commit}:.project/ROADMAP.md"),
|
|
2048
|
+
"read committed ROADMAP.md",
|
|
2049
|
+
)
|
|
2050
|
+
validate_shipped_roadmap(committed_roadmap, state, configured)
|
|
2051
|
+
|
|
2052
|
+
manifest_path = f"{ship_commit}:{configured}/MANIFEST.md"
|
|
2053
|
+
require_git_success(run_git(project, "cat-file", "-e", manifest_path), "verify committed manifest")
|
|
2054
|
+
|
|
2055
|
+
project_drift = require_git_success(
|
|
2056
|
+
run_git(project, "diff", "--name-only", ship_commit, "HEAD", "--", ".project"),
|
|
2057
|
+
"inspect .project history after the ship commit",
|
|
2058
|
+
)
|
|
2059
|
+
if project_drift:
|
|
2060
|
+
raise ArchiveError(
|
|
2061
|
+
".project changed in history after the ship commit: "
|
|
2062
|
+
+ ", ".join(project_drift.splitlines())
|
|
2063
|
+
+ f"; run: git -C {project} revert <commits after {ship_commit[:12]} that touch .project>"
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
return {"archive": configured, "commit": ship_commit}
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def parser() -> argparse.ArgumentParser:
|
|
2070
|
+
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
2071
|
+
subparsers = argument_parser.add_subparsers(dest="command", required=True)
|
|
2072
|
+
|
|
2073
|
+
prepare_parser = subparsers.add_parser("prepare", help="prepare or resume an archive transaction")
|
|
2074
|
+
prepare_parser.add_argument("--repo", required=True, type=Path)
|
|
2075
|
+
prepare_parser.add_argument("--slug", required=True)
|
|
2076
|
+
|
|
2077
|
+
preflight_parser = subparsers.add_parser(
|
|
2078
|
+
"preflight",
|
|
2079
|
+
help="validate the prepared transaction before the ship commit",
|
|
2080
|
+
)
|
|
2081
|
+
preflight_parser.add_argument("--repo", required=True, type=Path)
|
|
2082
|
+
|
|
2083
|
+
manifest_parser = subparsers.add_parser(
|
|
2084
|
+
"render-manifest",
|
|
2085
|
+
help="render the current uncommitted archive manifest from validated evidence",
|
|
2086
|
+
)
|
|
2087
|
+
manifest_parser.add_argument("--repo", required=True, type=Path)
|
|
2088
|
+
|
|
2089
|
+
validate_parser = subparsers.add_parser("validate", help="validate the committed ship transaction")
|
|
2090
|
+
validate_parser.add_argument("--repo", required=True, type=Path)
|
|
2091
|
+
|
|
2092
|
+
validate_integrated_parser = subparsers.add_parser(
|
|
2093
|
+
"validate-integrated",
|
|
2094
|
+
help=(
|
|
2095
|
+
"validate the committed ship transaction and its default-branch integration; "
|
|
2096
|
+
"pull-request mode requires origin network access"
|
|
2097
|
+
),
|
|
2098
|
+
)
|
|
2099
|
+
validate_integrated_parser.add_argument("--repo", required=True, type=Path)
|
|
2100
|
+
validate_integrated_parser.add_argument("--slug", required=True)
|
|
2101
|
+
|
|
2102
|
+
refresh_parser = subparsers.add_parser(
|
|
2103
|
+
"refresh-origin",
|
|
2104
|
+
help="fetch origin, refresh origin/HEAD, and mirror published milestone tags",
|
|
2105
|
+
)
|
|
2106
|
+
refresh_parser.add_argument("--repo", required=True, type=Path)
|
|
2107
|
+
|
|
2108
|
+
integrate_parser = subparsers.add_parser(
|
|
2109
|
+
"integrate",
|
|
2110
|
+
help="resume and publish the shipped milestone integration",
|
|
2111
|
+
)
|
|
2112
|
+
integrate_parser.add_argument("--repo", required=True, type=Path)
|
|
2113
|
+
integrate_parser.add_argument("--slug", required=True)
|
|
2114
|
+
|
|
2115
|
+
abandon_parser = subparsers.add_parser(
|
|
2116
|
+
"abandon",
|
|
2117
|
+
help="abandon the active build milestone and archive its partial artifacts",
|
|
2118
|
+
)
|
|
2119
|
+
abandon_parser.add_argument("--repo", required=True, type=Path)
|
|
2120
|
+
abandon_parser.add_argument("--slug", required=True)
|
|
2121
|
+
abandon_parser.add_argument("--reason", required=True)
|
|
2122
|
+
return argument_parser
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
2126
|
+
arguments = parser().parse_args(argv)
|
|
2127
|
+
try:
|
|
2128
|
+
if arguments.command in {"validate-integrated", "refresh-origin", "integrate"}:
|
|
2129
|
+
if __package__:
|
|
2130
|
+
from . import integration
|
|
2131
|
+
else:
|
|
2132
|
+
import integration
|
|
2133
|
+
if arguments.command == "prepare":
|
|
2134
|
+
result = prepare(arguments.repo, arguments.slug)
|
|
2135
|
+
elif arguments.command == "render-manifest":
|
|
2136
|
+
result = render_manifest(arguments.repo)
|
|
2137
|
+
elif arguments.command == "preflight":
|
|
2138
|
+
result = preflight(arguments.repo)
|
|
2139
|
+
elif arguments.command == "abandon":
|
|
2140
|
+
result = abandon(arguments.repo, arguments.slug, arguments.reason)
|
|
2141
|
+
elif arguments.command == "validate-integrated":
|
|
2142
|
+
result = integration.validate_integrated(arguments.repo, arguments.slug)
|
|
2143
|
+
elif arguments.command == "refresh-origin":
|
|
2144
|
+
result = integration.refresh_origin(arguments.repo)
|
|
2145
|
+
elif arguments.command == "integrate":
|
|
2146
|
+
result = integration.integrate(arguments.repo, arguments.slug)
|
|
2147
|
+
else:
|
|
2148
|
+
result = validate(arguments.repo)
|
|
2149
|
+
except (ArchiveError, OSError) as error:
|
|
2150
|
+
print(str(error), file=sys.stderr)
|
|
2151
|
+
return 1
|
|
2152
|
+
print(json.dumps(result, sort_keys=True))
|
|
2153
|
+
return 0
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
if __name__ == "__main__":
|
|
2157
|
+
raise SystemExit(main())
|