@opengsd/gsd-path 1.4.0 → 1.6.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/CHANGELOG.md +460 -341
- package/DOCS.md +16 -5
- package/FULL.md +67 -6
- package/HOOKS.md +158 -10
- package/MIGRATE.md +12 -0
- package/QUICK.md +4 -2
- package/README.md +136 -116
- package/RUNTIME.md +24 -6
- package/UPDATE.md +39 -3
- package/WORKFLOW.md +80 -4
- package/package.json +5 -2
- package/platforms/shared-agents/dispatch.md +13 -0
- package/scripts/_common.py +256 -2
- package/scripts/archive_milestone.py +14 -13
- package/scripts/build_recovery.py +155 -58
- package/scripts/build_state.py +274 -8
- package/scripts/check_docs_audit.py +77 -4
- package/scripts/check_handoffs.py +635 -57
- package/scripts/check_task_briefs.py +293 -32
- package/scripts/detect_project.py +260 -20
- package/scripts/discussion_records.py +9 -1
- package/scripts/discussion_validate.py +93 -35
- package/scripts/dispatch_driver.py +549 -53
- package/scripts/git_guard.py +27 -8
- package/scripts/guard_hook.py +2630 -210
- package/scripts/install.mjs +99 -11
- package/scripts/install.py +231 -46
- package/scripts/integration.py +244 -65
- package/scripts/isolation.py +539 -70
- package/scripts/loop_run.py +16 -4
- package/scripts/members.py +77 -32
- package/scripts/migrate_core.py +2 -0
- package/scripts/model_policy.py +5 -1
- package/scripts/path_config.py +3 -3
- package/scripts/pipeline_diagnose.py +17 -2
- package/scripts/pipeline_git.py +131 -42
- package/scripts/pipeline_state.py +139 -39
- package/scripts/pipeline_undo.py +118 -16
- package/scripts/promote_lookahead.py +3 -3
- package/scripts/review_findings.py +180 -44
- package/scripts/roadmap.py +9 -1
- package/scripts/runtime_store.py +21 -2
- package/scripts/skill-resources.json +9 -2
- package/scripts/state_checkpoint.py +39 -36
- package/scripts/state_promote.py +15 -4
- package/scripts/status_runtime.py +22 -4
- package/scripts/task_context.py +14 -3
- package/scripts/wizard.mjs +1 -0
- package/scripts/workflow_run.py +14 -4
- package/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path/DEFINE.md +34 -5
- package/skills/gsd-path/PLAN.md +14 -3
- package/skills/gsd-path/RESEARCH.md +11 -0
- package/skills/gsd-path/ROADMAP.md +16 -6
- package/skills/gsd-path/SHIP.md +32 -5
- package/skills/gsd-path/SKILL.md +52 -24
- package/skills/gsd-path/references/build-native.md +112 -10
- package/skills/gsd-path/references/build-recovery.md +13 -0
- package/skills/gsd-path/references/coder.md +7 -3
- package/skills/gsd-path/references/dispatch.md +13 -0
- package/skills/gsd-path/references/docs-auditor.md +5 -0
- package/skills/gsd-path/references/operating-rules.md +11 -3
- package/skills/gsd-path/references/planner.md +26 -4
- package/skills/gsd-path/references/reviewer-final.md +20 -2
- package/skills/gsd-path/references/reviewer-wave.md +56 -12
- package/skills/gsd-path/references/reviewer.md +3 -1
- package/skills/gsd-path/references/runtime-selection.md +9 -0
- package/skills/gsd-path/references/spec-probes.md +150 -0
- package/skills/gsd-path/scripts/_common.py +256 -2
- package/skills/gsd-path/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path/scripts/build_recovery.py +155 -58
- package/skills/gsd-path/scripts/build_state.py +274 -8
- package/skills/gsd-path/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path/scripts/detect_project.py +260 -20
- package/skills/gsd-path/scripts/discussion_records.py +9 -1
- package/skills/gsd-path/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path/scripts/dispatch_driver.py +549 -53
- package/skills/gsd-path/scripts/git_guard.py +27 -8
- package/skills/gsd-path/scripts/integration.py +244 -65
- package/skills/gsd-path/scripts/isolation.py +539 -70
- package/skills/gsd-path/scripts/loop_run.py +16 -4
- package/skills/gsd-path/scripts/members.py +77 -32
- package/skills/gsd-path/scripts/model_policy.py +5 -1
- package/skills/gsd-path/scripts/path_config.py +3 -3
- package/skills/gsd-path/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path/scripts/review_findings.py +180 -44
- package/skills/gsd-path/scripts/roadmap.py +9 -1
- package/skills/gsd-path/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path/scripts/state_promote.py +15 -4
- package/skills/gsd-path/scripts/status_runtime.py +22 -4
- package/skills/gsd-path/scripts/task_context.py +14 -3
- package/skills/gsd-path/scripts/workflow_run.py +14 -4
- package/skills/gsd-path/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path/templates/docs-audit.md +5 -1
- package/skills/gsd-path/templates/final-review.md +11 -2
- package/skills/gsd-path/templates/intent.md +30 -0
- package/skills/gsd-path/templates/patch-findings.md +3 -3
- package/skills/gsd-path/templates/plan.md +17 -2
- package/skills/gsd-path/templates/research-handoff.md +14 -3
- package/skills/gsd-path/templates/state.md +2 -1
- package/skills/gsd-path/templates/task.md +1 -0
- package/skills/gsd-path/templates/wave-review.md +28 -9
- package/skills/gsd-path-build/references/build-native.md +112 -10
- package/skills/gsd-path-build/references/build-recovery.md +13 -0
- package/skills/gsd-path-build/references/coder.md +7 -3
- package/skills/gsd-path-build/references/dispatch.md +13 -0
- package/skills/gsd-path-build/references/operating-rules.md +11 -3
- package/skills/gsd-path-build/references/reviewer-final.md +20 -2
- package/skills/gsd-path-build/references/reviewer-wave.md +56 -12
- package/skills/gsd-path-build/references/reviewer.md +3 -1
- package/skills/gsd-path-build/references/runtime-selection.md +9 -0
- package/skills/gsd-path-build/references/spec-probes.md +150 -0
- package/skills/gsd-path-build/scripts/_common.py +256 -2
- package/skills/gsd-path-build/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-build/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-build/scripts/build_state.py +274 -8
- package/skills/gsd-path-build/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-build/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-build/scripts/detect_project.py +260 -20
- package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-build/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-build/scripts/dispatch_driver.py +549 -53
- package/skills/gsd-path-build/scripts/integration.py +244 -65
- package/skills/gsd-path-build/scripts/isolation.py +539 -70
- package/skills/gsd-path-build/scripts/loop_run.py +16 -4
- package/skills/gsd-path-build/scripts/members.py +77 -32
- package/skills/gsd-path-build/scripts/model_policy.py +5 -1
- package/skills/gsd-path-build/scripts/path_config.py +3 -3
- package/skills/gsd-path-build/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-build/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-build/scripts/review_findings.py +180 -44
- package/skills/gsd-path-build/scripts/roadmap.py +9 -1
- package/skills/gsd-path-build/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-build/scripts/state_promote.py +15 -4
- package/skills/gsd-path-build/scripts/task_context.py +14 -3
- package/skills/gsd-path-build/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-build/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-build/templates/task.md +1 -0
- package/skills/gsd-path-build/templates/wave-review.md +28 -9
- package/skills/gsd-path-decide/references/dispatch.md +13 -0
- package/skills/gsd-path-decide/references/operating-rules.md +11 -3
- package/skills/gsd-path-decide/references/runtime-selection.md +9 -0
- package/skills/gsd-path-decide/scripts/_common.py +256 -2
- package/skills/gsd-path-decide/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-decide/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-decide/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-decide/scripts/detect_project.py +260 -20
- package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-decide/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-decide/scripts/integration.py +244 -65
- package/skills/gsd-path-decide/scripts/isolation.py +539 -70
- package/skills/gsd-path-decide/scripts/loop_run.py +16 -4
- package/skills/gsd-path-decide/scripts/members.py +77 -32
- package/skills/gsd-path-decide/scripts/model_policy.py +5 -1
- package/skills/gsd-path-decide/scripts/path_config.py +3 -3
- package/skills/gsd-path-decide/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-decide/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-decide/scripts/state_promote.py +15 -4
- package/skills/gsd-path-decide/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-define/SKILL.md +34 -5
- package/skills/gsd-path-define/references/build-recovery.md +13 -0
- package/skills/gsd-path-define/references/operating-rules.md +11 -3
- package/skills/gsd-path-define/references/runtime-selection.md +9 -0
- package/skills/gsd-path-define/references/spec-probes.md +150 -0
- package/skills/gsd-path-define/scripts/_common.py +256 -2
- package/skills/gsd-path-define/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-define/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-define/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-define/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-define/scripts/detect_project.py +260 -20
- package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-define/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-define/scripts/integration.py +244 -65
- package/skills/gsd-path-define/scripts/isolation.py +539 -70
- package/skills/gsd-path-define/scripts/loop_run.py +16 -4
- package/skills/gsd-path-define/scripts/members.py +77 -32
- package/skills/gsd-path-define/scripts/model_policy.py +5 -1
- package/skills/gsd-path-define/scripts/path_config.py +3 -3
- package/skills/gsd-path-define/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-define/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-define/scripts/roadmap.py +9 -1
- package/skills/gsd-path-define/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-define/scripts/state_promote.py +15 -4
- package/skills/gsd-path-define/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-define/templates/intent.md +30 -0
- package/skills/gsd-path-define/templates/state.md +2 -1
- package/skills/gsd-path-discuss/references/operating-rules.md +11 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +9 -0
- package/skills/gsd-path-discuss/scripts/_common.py +256 -2
- package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-discuss/scripts/loop_run.py +16 -4
- package/skills/gsd-path-discuss/scripts/model_policy.py +5 -1
- package/skills/gsd-path-discuss/scripts/path_config.py +3 -3
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +5 -0
- package/skills/gsd-path-docs-audit/references/operating-rules.md +11 -3
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +9 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +256 -2
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +260 -20
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-docs-audit/scripts/integration.py +244 -65
- package/skills/gsd-path-docs-audit/scripts/isolation.py +539 -70
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +16 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +77 -32
- package/skills/gsd-path-docs-audit/scripts/model_policy.py +5 -1
- package/skills/gsd-path-docs-audit/scripts/path_config.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +15 -4
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +5 -1
- package/skills/gsd-path-forensics/references/operating-rules.md +11 -3
- package/skills/gsd-path-forensics/references/runtime-selection.md +9 -0
- package/skills/gsd-path-forensics/scripts/_common.py +256 -2
- package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-forensics/scripts/loop_run.py +16 -4
- package/skills/gsd-path-forensics/scripts/model_policy.py +5 -1
- package/skills/gsd-path-forensics/scripts/path_config.py +3 -3
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +118 -16
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-inspect/references/dispatch.md +13 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +5 -0
- package/skills/gsd-path-inspect/references/operating-rules.md +11 -3
- package/skills/gsd-path-inspect/references/runtime-selection.md +9 -0
- package/skills/gsd-path-inspect/scripts/_common.py +256 -2
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-inspect/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-inspect/scripts/detect_project.py +260 -20
- package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-inspect/scripts/integration.py +244 -65
- package/skills/gsd-path-inspect/scripts/isolation.py +539 -70
- package/skills/gsd-path-inspect/scripts/loop_run.py +16 -4
- package/skills/gsd-path-inspect/scripts/members.py +77 -32
- package/skills/gsd-path-inspect/scripts/model_policy.py +5 -1
- package/skills/gsd-path-inspect/scripts/path_config.py +3 -3
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-inspect/scripts/state_promote.py +15 -4
- package/skills/gsd-path-inspect/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-inspect/templates/docs-audit.md +5 -1
- package/skills/gsd-path-inspect/templates/state.md +2 -1
- package/skills/gsd-path-loop/references/operating-rules.md +11 -3
- package/skills/gsd-path-loop/references/runtime-selection.md +9 -0
- package/skills/gsd-path-loop/scripts/_common.py +256 -2
- package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-loop/scripts/loop_run.py +16 -4
- package/skills/gsd-path-loop/scripts/model_policy.py +5 -1
- package/skills/gsd-path-loop/scripts/path_config.py +3 -3
- package/skills/gsd-path-loop/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-migrate/SKILL.md +8 -0
- package/skills/gsd-path-migrate/references/operating-rules.md +11 -3
- package/skills/gsd-path-migrate/references/runtime-selection.md +9 -0
- package/skills/gsd-path-migrate/scripts/_common.py +256 -2
- package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-migrate/scripts/loop_run.py +16 -4
- package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
- package/skills/gsd-path-migrate/scripts/model_policy.py +5 -1
- package/skills/gsd-path-migrate/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/SKILL.md +14 -3
- package/skills/gsd-path-plan/references/build-recovery.md +13 -0
- package/skills/gsd-path-plan/references/dispatch.md +13 -0
- package/skills/gsd-path-plan/references/operating-rules.md +11 -3
- package/skills/gsd-path-plan/references/planner.md +26 -4
- package/skills/gsd-path-plan/references/runtime-selection.md +9 -0
- package/skills/gsd-path-plan/references/spec-probes.md +150 -0
- package/skills/gsd-path-plan/scripts/_common.py +256 -2
- package/skills/gsd-path-plan/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-plan/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-plan/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-plan/scripts/detect_project.py +260 -20
- package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-plan/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-plan/scripts/integration.py +244 -65
- package/skills/gsd-path-plan/scripts/isolation.py +539 -70
- package/skills/gsd-path-plan/scripts/loop_run.py +16 -4
- package/skills/gsd-path-plan/scripts/members.py +77 -32
- package/skills/gsd-path-plan/scripts/model_policy.py +5 -1
- package/skills/gsd-path-plan/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-plan/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-plan/scripts/state_promote.py +15 -4
- package/skills/gsd-path-plan/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-plan/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
- package/skills/gsd-path-plan/templates/plan.md +17 -2
- package/skills/gsd-path-plan/templates/task.md +1 -0
- package/skills/gsd-path-research/SKILL.md +11 -0
- package/skills/gsd-path-research/references/dispatch.md +13 -0
- package/skills/gsd-path-research/references/operating-rules.md +11 -3
- package/skills/gsd-path-research/references/runtime-selection.md +9 -0
- package/skills/gsd-path-research/scripts/_common.py +256 -2
- package/skills/gsd-path-research/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-research/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-research/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-research/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-research/scripts/detect_project.py +260 -20
- package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-research/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-research/scripts/integration.py +244 -65
- package/skills/gsd-path-research/scripts/isolation.py +539 -70
- package/skills/gsd-path-research/scripts/loop_run.py +16 -4
- package/skills/gsd-path-research/scripts/members.py +77 -32
- package/skills/gsd-path-research/scripts/model_policy.py +5 -1
- package/skills/gsd-path-research/scripts/path_config.py +3 -3
- package/skills/gsd-path-research/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-research/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-research/scripts/roadmap.py +9 -1
- package/skills/gsd-path-research/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-research/scripts/state_promote.py +15 -4
- package/skills/gsd-path-research/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-research/templates/research-handoff.md +14 -3
- package/skills/gsd-path-roadmap/SKILL.md +16 -6
- package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
- package/skills/gsd-path-roadmap/references/operating-rules.md +11 -3
- package/skills/gsd-path-roadmap/references/runtime-selection.md +9 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +256 -2
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-roadmap/scripts/detect_project.py +260 -20
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-roadmap/scripts/integration.py +244 -65
- package/skills/gsd-path-roadmap/scripts/isolation.py +539 -70
- package/skills/gsd-path-roadmap/scripts/loop_run.py +16 -4
- package/skills/gsd-path-roadmap/scripts/members.py +77 -32
- package/skills/gsd-path-roadmap/scripts/model_policy.py +5 -1
- package/skills/gsd-path-roadmap/scripts/path_config.py +3 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-roadmap/scripts/state_promote.py +15 -4
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-ship/SKILL.md +32 -5
- package/skills/gsd-path-ship/references/dispatch.md +13 -0
- package/skills/gsd-path-ship/references/operating-rules.md +11 -3
- package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
- package/skills/gsd-path-ship/references/reviewer-wave.md +56 -12
- package/skills/gsd-path-ship/references/reviewer.md +3 -1
- package/skills/gsd-path-ship/references/runtime-selection.md +9 -0
- package/skills/gsd-path-ship/references/spec-probes.md +150 -0
- package/skills/gsd-path-ship/scripts/_common.py +256 -2
- package/skills/gsd-path-ship/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-ship/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-ship/scripts/build_state.py +274 -8
- package/skills/gsd-path-ship/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-ship/scripts/detect_project.py +260 -20
- package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-ship/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-ship/scripts/integration.py +244 -65
- package/skills/gsd-path-ship/scripts/isolation.py +539 -70
- package/skills/gsd-path-ship/scripts/loop_run.py +16 -4
- package/skills/gsd-path-ship/scripts/members.py +77 -32
- package/skills/gsd-path-ship/scripts/model_policy.py +5 -1
- package/skills/gsd-path-ship/scripts/path_config.py +3 -3
- package/skills/gsd-path-ship/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-ship/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-ship/scripts/state_promote.py +15 -4
- package/skills/gsd-path-ship/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-ship/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-ship/templates/final-review.md +11 -2
- package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
- package/skills/gsd-path-ship/templates/wave-review.md +28 -9
- package/skills/gsd-path-undo/references/operating-rules.md +11 -3
- package/skills/gsd-path-undo/references/runtime-selection.md +9 -0
- package/skills/gsd-path-undo/scripts/_common.py +256 -2
- package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-undo/scripts/loop_run.py +16 -4
- package/skills/gsd-path-undo/scripts/model_policy.py +5 -1
- package/skills/gsd-path-undo/scripts/path_config.py +3 -3
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +118 -16
- package/skills/gsd-path-undo/scripts/worktree_paths.py +54 -2
- package/skills/path/DEFINE.md +34 -5
- package/skills/path/PLAN.md +14 -3
- package/skills/path/RESEARCH.md +11 -0
- package/skills/path/ROADMAP.md +16 -6
- package/skills/path/SHIP.md +32 -5
- package/skills/path/SKILL.md +52 -24
- package/skills/path/references/build-native.md +112 -10
- package/skills/path/references/build-recovery.md +13 -0
- package/skills/path/references/coder.md +7 -3
- package/skills/path/references/dispatch.md +13 -0
- package/skills/path/references/docs-auditor.md +5 -0
- package/skills/path/references/operating-rules.md +11 -3
- package/skills/path/references/planner.md +26 -4
- package/skills/path/references/reviewer-final.md +20 -2
- package/skills/path/references/reviewer-wave.md +56 -12
- package/skills/path/references/reviewer.md +3 -1
- package/skills/path/references/runtime-selection.md +9 -0
- package/skills/path/references/spec-probes.md +150 -0
- package/skills/path/scripts/_common.py +256 -2
- package/skills/path/scripts/archive_milestone.py +14 -13
- package/skills/path/scripts/build_recovery.py +155 -58
- package/skills/path/scripts/build_state.py +274 -8
- package/skills/path/scripts/check_docs_audit.py +77 -4
- package/skills/path/scripts/check_handoffs.py +635 -57
- package/skills/path/scripts/check_task_briefs.py +293 -32
- package/skills/path/scripts/detect_project.py +260 -20
- package/skills/path/scripts/discussion_records.py +9 -1
- package/skills/path/scripts/discussion_validate.py +93 -35
- package/skills/path/scripts/dispatch_driver.py +549 -53
- package/skills/path/scripts/git_guard.py +27 -8
- package/skills/path/scripts/integration.py +244 -65
- package/skills/path/scripts/isolation.py +539 -70
- package/skills/path/scripts/loop_run.py +16 -4
- package/skills/path/scripts/members.py +77 -32
- package/skills/path/scripts/model_policy.py +5 -1
- package/skills/path/scripts/path_config.py +3 -3
- package/skills/path/scripts/pipeline_git.py +131 -42
- package/skills/path/scripts/pipeline_state.py +139 -39
- package/skills/path/scripts/promote_lookahead.py +3 -3
- package/skills/path/scripts/review_findings.py +180 -44
- package/skills/path/scripts/roadmap.py +9 -1
- package/skills/path/scripts/state_checkpoint.py +39 -36
- package/skills/path/scripts/state_promote.py +15 -4
- package/skills/path/scripts/status_runtime.py +22 -4
- package/skills/path/scripts/task_context.py +14 -3
- package/skills/path/scripts/workflow_run.py +14 -4
- package/skills/path/scripts/worktree_paths.py +54 -2
- package/skills/path/templates/docs-audit.md +5 -1
- package/skills/path/templates/final-review.md +11 -2
- package/skills/path/templates/intent.md +30 -0
- package/skills/path/templates/patch-findings.md +3 -3
- package/skills/path/templates/plan.md +17 -2
- package/skills/path/templates/research-handoff.md +14 -3
- package/skills/path/templates/state.md +2 -1
- package/skills/path/templates/task.md +1 -0
- package/skills/path/templates/wave-review.md +28 -9
- package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
|
@@ -11,10 +11,12 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
13
|
import contextlib
|
|
14
|
+
import functools
|
|
14
15
|
import hashlib
|
|
15
16
|
import io
|
|
16
17
|
import json
|
|
17
18
|
import shlex
|
|
19
|
+
import shutil
|
|
18
20
|
import os
|
|
19
21
|
import re
|
|
20
22
|
import stat
|
|
@@ -27,11 +29,6 @@ import tempfile
|
|
|
27
29
|
from pathlib import Path, PurePosixPath
|
|
28
30
|
from typing import Dict, Iterator, Optional, Sequence, Set, Tuple
|
|
29
31
|
|
|
30
|
-
if sys.platform == "win32":
|
|
31
|
-
import msvcrt
|
|
32
|
-
else:
|
|
33
|
-
import fcntl
|
|
34
|
-
|
|
35
32
|
if __package__:
|
|
36
33
|
from .pipeline_git import (
|
|
37
34
|
attest_commit_body,
|
|
@@ -107,6 +104,10 @@ class IsolationError(RuntimeError):
|
|
|
107
104
|
"""Raised when isolation, landing, or retirement cannot proceed."""
|
|
108
105
|
|
|
109
106
|
|
|
107
|
+
class RemovalError(IsolationError):
|
|
108
|
+
"""Raised when the removal of a sidecar worktree fails."""
|
|
109
|
+
|
|
110
|
+
|
|
110
111
|
run_git = _common.run_git
|
|
111
112
|
|
|
112
113
|
|
|
@@ -150,7 +151,10 @@ def require_commit(repo: Path, value: str) -> str:
|
|
|
150
151
|
|
|
151
152
|
def require_full_sha(value: str) -> str:
|
|
152
153
|
if not re.fullmatch(r"[0-9a-f]{40}", value):
|
|
153
|
-
raise IsolationError(
|
|
154
|
+
raise IsolationError(
|
|
155
|
+
f"base must be a full 40-character SHA: {value}; "
|
|
156
|
+
"resolve a ref or a short SHA with git rev-parse first"
|
|
157
|
+
)
|
|
154
158
|
return value
|
|
155
159
|
|
|
156
160
|
|
|
@@ -176,6 +180,12 @@ def sidecar_root(primary: Path, kind: str, name: str, *, pin: bool = False) -> P
|
|
|
176
180
|
raise IsolationError(str(error)) from error
|
|
177
181
|
|
|
178
182
|
|
|
183
|
+
def release_workspace_quietly(primary: Path) -> None:
|
|
184
|
+
"""Best-effort placement hygiene after a sidecar retires; never blocks landing."""
|
|
185
|
+
with contextlib.suppress(ValueError, OSError):
|
|
186
|
+
worktree_paths.release_workspace(primary)
|
|
187
|
+
|
|
188
|
+
|
|
179
189
|
def validate_task_id(task_id: str) -> str:
|
|
180
190
|
if not TASK_ID_RE.fullmatch(task_id):
|
|
181
191
|
raise IsolationError(f"invalid task id: {task_id}")
|
|
@@ -439,8 +449,9 @@ def collect_artifact_recoveries(primary: Path) -> list[Dict[str, object]]:
|
|
|
439
449
|
|
|
440
450
|
|
|
441
451
|
def _git_file(repo: Path, revision: str, path: str) -> bytes:
|
|
452
|
+
git_path = PurePosixPath(path.replace("\\", "/")).as_posix()
|
|
442
453
|
result = subprocess.run(
|
|
443
|
-
("git", "-C", str(repo), "show", f"{revision}:{
|
|
454
|
+
("git", "-C", str(repo), "show", f"{revision}:{git_path}"),
|
|
444
455
|
capture_output=True,
|
|
445
456
|
check=False,
|
|
446
457
|
)
|
|
@@ -481,6 +492,70 @@ def _same_directory(path: Path, expected: os.stat_result) -> bool:
|
|
|
481
492
|
return stat.S_ISDIR(current.st_mode) and os.path.samestat(current, expected)
|
|
482
493
|
|
|
483
494
|
|
|
495
|
+
def _long_path(absolute: str) -> str:
|
|
496
|
+
if absolute.startswith("\\\\?\\"):
|
|
497
|
+
return absolute
|
|
498
|
+
if absolute.startswith("\\\\"):
|
|
499
|
+
return "\\\\?\\UNC\\" + absolute[2:]
|
|
500
|
+
return "\\\\?\\" + absolute
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _force_rmtree(path: Path) -> None:
|
|
504
|
+
"""Delete a sidecar tree directly, tolerating read-only files and long paths."""
|
|
505
|
+
def _writable(function, target, _error):
|
|
506
|
+
os.chmod(target, stat.S_IWRITE)
|
|
507
|
+
function(target)
|
|
508
|
+
|
|
509
|
+
# The \\?\ prefix sidesteps Windows MAX_PATH on deep sidecar trees.
|
|
510
|
+
target = Path(_long_path(os.path.abspath(path))) if os.name == "nt" else path
|
|
511
|
+
try:
|
|
512
|
+
shutil.rmtree(target, onerror=_writable)
|
|
513
|
+
except OSError as error:
|
|
514
|
+
raise IsolationError(f"could not delete {path}: {error}") from error
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _remove_worktree(primary: Path, destination: Path, force: bool) -> None:
|
|
518
|
+
"""Remove a sidecar worktree, tolerating populated submodules and locked trees.
|
|
519
|
+
|
|
520
|
+
git refuses to remove worktrees whose submodules were ever initialized, and
|
|
521
|
+
forced removal can still fail deleting deep trees (Windows "Directory not
|
|
522
|
+
empty"). A dirty sidecar keeps git's refusal unless `force`, so forced
|
|
523
|
+
retries only run for a provably clean tree. The direct-delete fallback runs
|
|
524
|
+
only for git's tree-deletion failures, so any other git error leaves the
|
|
525
|
+
sidecar intact for diagnosis.
|
|
526
|
+
"""
|
|
527
|
+
tree_delete_failure = ("failed to delete", "directory not empty", "permission denied")
|
|
528
|
+
|
|
529
|
+
def failure(*flags: str) -> str:
|
|
530
|
+
result = run_git(primary, "worktree", "remove", *flags, str(destination))
|
|
531
|
+
return (result.stderr or result.stdout).strip() if result.returncode else ""
|
|
532
|
+
|
|
533
|
+
detail = failure("--force") if force else failure()
|
|
534
|
+
if not detail:
|
|
535
|
+
return
|
|
536
|
+
if not force and destination.is_dir():
|
|
537
|
+
status = run_git(
|
|
538
|
+
destination, "status", "--porcelain", "--untracked-files=all"
|
|
539
|
+
)
|
|
540
|
+
if status.returncode != 0 or status.stdout.strip():
|
|
541
|
+
raise IsolationError(detail or "git worktree remove failed")
|
|
542
|
+
run_git(destination, "submodule", "deinit", "--all", "--force")
|
|
543
|
+
detail = failure("--force")
|
|
544
|
+
if not detail:
|
|
545
|
+
return
|
|
546
|
+
if destination.is_dir() and any(
|
|
547
|
+
marker in detail.lower() for marker in tree_delete_failure
|
|
548
|
+
):
|
|
549
|
+
_force_rmtree(destination)
|
|
550
|
+
detail = failure("--force")
|
|
551
|
+
if detail and not destination.is_dir():
|
|
552
|
+
pruned = run_git(primary, "worktree", "prune")
|
|
553
|
+
if pruned.returncode == 0:
|
|
554
|
+
detail = ""
|
|
555
|
+
if detail or destination.is_dir() or destination.resolve() in _registered_worktrees(primary):
|
|
556
|
+
raise IsolationError(detail or "git worktree remove failed")
|
|
557
|
+
|
|
558
|
+
|
|
484
559
|
def _cleanup_reserved_worktree(
|
|
485
560
|
primary: Path,
|
|
486
561
|
branch: str,
|
|
@@ -647,6 +722,18 @@ def member_checkout(coordinator: Path, member: str) -> Tuple[Path, str]:
|
|
|
647
722
|
return checkout, str(role["project"])
|
|
648
723
|
|
|
649
724
|
|
|
725
|
+
def member_default_branch(coordinator: Path, member: str) -> str:
|
|
726
|
+
"""The default branch that MEMBERS.md records for a member; a row without it means main."""
|
|
727
|
+
try:
|
|
728
|
+
import members
|
|
729
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
730
|
+
from scripts import members
|
|
731
|
+
try:
|
|
732
|
+
return members.member_default_branch(require_directory(coordinator, "coordinator"), member)
|
|
733
|
+
except members.MembersError as error:
|
|
734
|
+
raise IsolationError(str(error)) from error
|
|
735
|
+
|
|
736
|
+
|
|
650
737
|
def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
|
|
651
738
|
"""Path's own checkout of a locked member's bound branch, outside the team's checkout."""
|
|
652
739
|
checkout, _, entry = _member_context(coordinator, member)
|
|
@@ -758,7 +845,7 @@ def retire_member_task(
|
|
|
758
845
|
raise IsolationError(f"member task branch {ref} has unlanded commits")
|
|
759
846
|
destination = sidecar_root(checkout, "task", name)
|
|
760
847
|
copy_rejected = None
|
|
761
|
-
if force:
|
|
848
|
+
if force and task_file:
|
|
762
849
|
rejected, copy_rejected = _rejected_member_attempt_error(coordinator, member, task_id, task_file, destination)
|
|
763
850
|
if rejected:
|
|
764
851
|
raise IsolationError(rejected)
|
|
@@ -766,9 +853,10 @@ def retire_member_task(
|
|
|
766
853
|
if destination.resolve() in registered:
|
|
767
854
|
if registered[destination.resolve()] != ref:
|
|
768
855
|
raise IsolationError(f"member task sidecar is on another branch: {destination}")
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
856
|
+
try:
|
|
857
|
+
_remove_worktree(checkout, destination, force)
|
|
858
|
+
except IsolationError as error:
|
|
859
|
+
raise RemovalError(str(error)) from error
|
|
772
860
|
authorization = run_git(checkout, "update-ref", "-d", member_task_authorization_ref(project, task_id))
|
|
773
861
|
if authorization.returncode != 0:
|
|
774
862
|
raise IsolationError((authorization.stderr or authorization.stdout).strip() or "git update-ref failed")
|
|
@@ -776,6 +864,7 @@ def retire_member_task(
|
|
|
776
864
|
deleted = run_git(checkout, "update-ref", "-d", ref, tip.stdout.strip())
|
|
777
865
|
if deleted.returncode != 0:
|
|
778
866
|
raise IsolationError((deleted.stderr or deleted.stdout).strip() or "git update-ref failed")
|
|
867
|
+
release_workspace_quietly(checkout)
|
|
779
868
|
return copy_rejected
|
|
780
869
|
|
|
781
870
|
|
|
@@ -936,9 +1025,11 @@ def activate_member_task(
|
|
|
936
1025
|
created = run_git(checkout, "update-ref", ref, member_base, "0" * 40)
|
|
937
1026
|
if created.returncode != 0 and run_git(checkout, "rev-parse", "--verify", "--quiet", ref).stdout.strip() != member_base:
|
|
938
1027
|
raise IsolationError("could not record member task authorization")
|
|
939
|
-
|
|
1028
|
+
result = {"agent": agent, "base": resolved_base, "copy": str(copy), "member": member,
|
|
940
1029
|
"member_base": member_base, "status": "in-progress", "task_branch": branch,
|
|
941
1030
|
"task_file": task_file, "worktree": str(sidecar)}
|
|
1031
|
+
_write_native_member_dispatch_record(coordinator, result)
|
|
1032
|
+
return result
|
|
942
1033
|
|
|
943
1034
|
|
|
944
1035
|
def retained_member_task(
|
|
@@ -1050,25 +1141,8 @@ def _member_journal_path(coordinator: Path, task_id: str) -> Path:
|
|
|
1050
1141
|
@contextlib.contextmanager
|
|
1051
1142
|
def _member_landing_lock(coordinator: Path) -> Iterator[None]:
|
|
1052
1143
|
path = common_git_dir(coordinator).joinpath(*MEMBER_LANDING_DIR, ".lock")
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
if sys.platform == "win32":
|
|
1056
|
-
handle.seek(0, os.SEEK_END)
|
|
1057
|
-
if handle.tell() == 0:
|
|
1058
|
-
handle.write(b"\0")
|
|
1059
|
-
handle.flush()
|
|
1060
|
-
handle.seek(0)
|
|
1061
|
-
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
1062
|
-
else:
|
|
1063
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
1064
|
-
try:
|
|
1065
|
-
yield
|
|
1066
|
-
finally:
|
|
1067
|
-
if sys.platform == "win32":
|
|
1068
|
-
handle.seek(0)
|
|
1069
|
-
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
1070
|
-
else:
|
|
1071
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
1144
|
+
with _common.exclusive_lock(path):
|
|
1145
|
+
yield
|
|
1072
1146
|
|
|
1073
1147
|
|
|
1074
1148
|
def _require_member_coordinator_branch(coordinator: Path) -> None:
|
|
@@ -1126,7 +1200,7 @@ def _prove_member_commit(
|
|
|
1126
1200
|
parent, commit).splitlines())
|
|
1127
1201
|
if not changed:
|
|
1128
1202
|
return "commit changes nothing"
|
|
1129
|
-
undeclared =
|
|
1203
|
+
undeclared = undeclared_paths(repo, parent, changed, allowed)
|
|
1130
1204
|
if undeclared:
|
|
1131
1205
|
return "undeclared paths: " + ", ".join(undeclared)
|
|
1132
1206
|
if git_output(repo, "log", "-1", "--format=%s", commit) != subject:
|
|
@@ -1142,7 +1216,7 @@ def _commit_member_source(sidecar: Path, journal: Dict[str, object], allowed: Se
|
|
|
1142
1216
|
if pending:
|
|
1143
1217
|
if current_sha(sidecar) != member_base:
|
|
1144
1218
|
raise IsolationError("dirty member source HEAD must equal the member base")
|
|
1145
|
-
undeclared =
|
|
1219
|
+
undeclared = undeclared_paths(sidecar, member_base, pending, allowed)
|
|
1146
1220
|
if undeclared:
|
|
1147
1221
|
raise IsolationError("undeclared paths: " + ", ".join(undeclared))
|
|
1148
1222
|
git_output(sidecar, "add", "-A", "--", *sorted(pending))
|
|
@@ -1411,8 +1485,9 @@ def retire_member_verify(created: Dict[str, Dict[str, str]]) -> None:
|
|
|
1411
1485
|
"""Remove the member siblings of a coordinator verify sidecar; they hold no output."""
|
|
1412
1486
|
for record in reversed(list(created.values())):
|
|
1413
1487
|
checkout, destination, branch = _member_verify_sidecar(record)
|
|
1414
|
-
|
|
1488
|
+
_remove_worktree(checkout, destination, True)
|
|
1415
1489
|
git_output(checkout, "branch", "-D", branch)
|
|
1490
|
+
release_workspace_quietly(checkout)
|
|
1416
1491
|
|
|
1417
1492
|
|
|
1418
1493
|
def check_member_verify(created: Dict[str, Dict[str, str]]) -> None:
|
|
@@ -1444,6 +1519,89 @@ def clean_verify(primary: Path, worktree: Path, base: str, branch: str) -> Dict[
|
|
|
1444
1519
|
return {"worktree": str(worktree), "branch": branch, "base": expected, "removed": removed}
|
|
1445
1520
|
|
|
1446
1521
|
|
|
1522
|
+
def ensure_verify_sidecar(primary: Path, base: str, branch: str) -> Dict[str, object]:
|
|
1523
|
+
"""Return the named verify sidecar, recreating it when it is missing.
|
|
1524
|
+
|
|
1525
|
+
Finish needs the sidecar prepare-task created. A deleted sidecar must be
|
|
1526
|
+
rebuildable from the recorded base without a clean primary: the serial
|
|
1527
|
+
task's own product changes are exactly the tree finish reproduces next,
|
|
1528
|
+
so this must not reuse isolate_verify's clean-tree gate.
|
|
1529
|
+
"""
|
|
1530
|
+
primary = require_directory(primary, "primary worktree")
|
|
1531
|
+
if worktree_root(primary) != primary:
|
|
1532
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
1533
|
+
resolved_base = require_commit(primary, require_full_sha(base))
|
|
1534
|
+
if not branch.startswith(VERIFY_BRANCH_PREFIX):
|
|
1535
|
+
raise IsolationError("verify sidecar reproduction requires a verify branch")
|
|
1536
|
+
destination = _sidecar_path_for_branch(primary, branch)
|
|
1537
|
+
ref = f"refs/heads/{branch}"
|
|
1538
|
+
registered = _registered_worktrees(primary)
|
|
1539
|
+
if destination.is_symlink() or (destination.exists() and not destination.is_dir()):
|
|
1540
|
+
raise IsolationError(f"worktree path already exists: {destination}")
|
|
1541
|
+
if destination.is_dir():
|
|
1542
|
+
try:
|
|
1543
|
+
# A leftover empty directory (a half-deleted sidecar) clears;
|
|
1544
|
+
# anything non-empty must be the sidecar itself.
|
|
1545
|
+
destination.rmdir()
|
|
1546
|
+
except OSError:
|
|
1547
|
+
try:
|
|
1548
|
+
same = (
|
|
1549
|
+
worktree_root(destination) == destination
|
|
1550
|
+
and destination.resolve() in registered
|
|
1551
|
+
and registered[destination.resolve()] == ref
|
|
1552
|
+
and common_git_dir(destination) == common_git_dir(primary)
|
|
1553
|
+
)
|
|
1554
|
+
except IsolationError:
|
|
1555
|
+
same = False
|
|
1556
|
+
if not same:
|
|
1557
|
+
raise IsolationError(
|
|
1558
|
+
f"verify sidecar ownership changed: {destination}"
|
|
1559
|
+
) from None
|
|
1560
|
+
if (
|
|
1561
|
+
require_attached(destination) != branch
|
|
1562
|
+
or current_sha(destination) != resolved_base
|
|
1563
|
+
):
|
|
1564
|
+
raise IsolationError(f"verify sidecar HEAD changed: {destination}")
|
|
1565
|
+
return {
|
|
1566
|
+
"base": resolved_base,
|
|
1567
|
+
"branch": branch,
|
|
1568
|
+
"kind": "verify",
|
|
1569
|
+
"mode": "sidecar",
|
|
1570
|
+
"worktree": str(destination),
|
|
1571
|
+
}
|
|
1572
|
+
if destination.resolve() in registered:
|
|
1573
|
+
cleared = run_git(primary, "worktree", "remove", str(destination))
|
|
1574
|
+
if cleared.returncode != 0:
|
|
1575
|
+
run_git(primary, "worktree", "prune")
|
|
1576
|
+
if destination.resolve() in _registered_worktrees(primary):
|
|
1577
|
+
raise IsolationError(
|
|
1578
|
+
(cleared.stderr or cleared.stdout).strip()
|
|
1579
|
+
or "could not clear stale worktree registration"
|
|
1580
|
+
)
|
|
1581
|
+
existing = run_git(primary, "show-ref", "--verify", "--quiet", ref)
|
|
1582
|
+
if existing.returncode == 0:
|
|
1583
|
+
deleted = run_git(primary, "update-ref", "-d", ref)
|
|
1584
|
+
if deleted.returncode != 0:
|
|
1585
|
+
raise IsolationError(
|
|
1586
|
+
(deleted.stderr or deleted.stdout).strip() or "git update-ref failed"
|
|
1587
|
+
)
|
|
1588
|
+
create_named_worktree(primary, branch, destination, resolved_base)
|
|
1589
|
+
return {
|
|
1590
|
+
"base": resolved_base,
|
|
1591
|
+
"branch": branch,
|
|
1592
|
+
"kind": "verify",
|
|
1593
|
+
"mode": "sidecar",
|
|
1594
|
+
"worktree": str(destination),
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def reproduce_verify_sidecar(sidecar_path: Path, tree: str) -> None:
|
|
1599
|
+
"""Copy the primary's current task tree into the verify sidecar (clean_verify's inverse)."""
|
|
1600
|
+
git_output(sidecar_path, "read-tree", "-u", "--reset", tree)
|
|
1601
|
+
if git_output(sidecar_path, "write-tree") != tree:
|
|
1602
|
+
raise IsolationError("sidecar reproduction differs from the primary task diff")
|
|
1603
|
+
|
|
1604
|
+
|
|
1447
1605
|
def _split_paths(block: str) -> Set[str]:
|
|
1448
1606
|
return {line.strip() for line in block.splitlines() if line.strip()}
|
|
1449
1607
|
|
|
@@ -1451,7 +1609,7 @@ def _split_paths(block: str) -> Set[str]:
|
|
|
1451
1609
|
# Top-level .project entries the pipeline owns; ship checks filter other entries by Git visibility.
|
|
1452
1610
|
PROJECT_ENTRIES = frozenset({
|
|
1453
1611
|
"STATE.md", "LESSONS.md", "REPOSITORY.md", "MEMBERS.md", "CHARTER.md", "ROADMAP.md",
|
|
1454
|
-
"SYNTHESIS.md",
|
|
1612
|
+
"SYNTHESIS.md", "config.json", "model-policy.json",
|
|
1455
1613
|
"intent", "research", "plan", "tasks", "review", "build", "discuss", "archive", "next",
|
|
1456
1614
|
})
|
|
1457
1615
|
|
|
@@ -1536,7 +1694,7 @@ def _commit_pending(
|
|
|
1536
1694
|
mode = "-f" if path in forced else "-A"
|
|
1537
1695
|
added = run_git(repo, "add", mode, "--", path)
|
|
1538
1696
|
if added.returncode != 0:
|
|
1539
|
-
raise IsolationError(f"could not stage {path}")
|
|
1697
|
+
raise IsolationError(f"could not stage {path}: {added.stderr.strip()}")
|
|
1540
1698
|
staged = _split_paths(
|
|
1541
1699
|
git_output(
|
|
1542
1700
|
repo,
|
|
@@ -1555,15 +1713,51 @@ def _commit_pending(
|
|
|
1555
1713
|
return current_sha(repo)
|
|
1556
1714
|
|
|
1557
1715
|
|
|
1716
|
+
def undeclared_paths(
|
|
1717
|
+
repo: Path, base: str, paths: Set[str], declared: Set[str]
|
|
1718
|
+
) -> list[str]:
|
|
1719
|
+
"""Return the sorted paths that the declared entries do not cover.
|
|
1720
|
+
|
|
1721
|
+
An entry covers itself and, as a declared directory, the files below it.
|
|
1722
|
+
An entry that is a file at `base` covers only itself: the task-brief lint
|
|
1723
|
+
rejects a path below a base file.
|
|
1724
|
+
"""
|
|
1725
|
+
|
|
1726
|
+
@functools.lru_cache(maxsize=None)
|
|
1727
|
+
def directory(entry: str) -> bool:
|
|
1728
|
+
kind = run_git(repo, "cat-file", "-t", f"{base}:{entry}")
|
|
1729
|
+
return kind.returncode != 0 or kind.stdout.strip() == "tree"
|
|
1730
|
+
|
|
1731
|
+
return sorted(
|
|
1732
|
+
path
|
|
1733
|
+
for path in paths
|
|
1734
|
+
if path not in declared
|
|
1735
|
+
and not any(
|
|
1736
|
+
str(parent) in declared and directory(str(parent))
|
|
1737
|
+
for parent in list(PurePosixPath(path).parents)[:-1]
|
|
1738
|
+
)
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
|
|
1558
1742
|
def validate_allowed_changes(
|
|
1559
|
-
repo: Path,
|
|
1743
|
+
repo: Path,
|
|
1744
|
+
base: str,
|
|
1745
|
+
pending: Set[str],
|
|
1746
|
+
allowed: Set[str],
|
|
1747
|
+
tolerated: Set[str] = frozenset(),
|
|
1560
1748
|
) -> None:
|
|
1561
|
-
|
|
1749
|
+
# Tolerated paths (primary bookkeeping) may stay uncommitted; the next
|
|
1750
|
+
# `.project` checkpoint commits them.
|
|
1751
|
+
landing = pending - tolerated
|
|
1752
|
+
if not landing:
|
|
1562
1753
|
raise IsolationError("no changes to land")
|
|
1563
|
-
since_base = committed_paths_since(repo, base) |
|
|
1564
|
-
unexpected =
|
|
1754
|
+
since_base = committed_paths_since(repo, base) | landing
|
|
1755
|
+
unexpected = undeclared_paths(repo, base, since_base, allowed)
|
|
1565
1756
|
if unexpected:
|
|
1566
|
-
raise IsolationError(
|
|
1757
|
+
raise IsolationError(
|
|
1758
|
+
"unexpected paths: " + ", ".join(unexpected)
|
|
1759
|
+
+ "; landing allows only the task file and its declared files"
|
|
1760
|
+
)
|
|
1567
1761
|
def _clean_blob_oid(repo: Path, path: str, text: str, *, write: bool) -> str:
|
|
1568
1762
|
arguments = ["hash-object"]
|
|
1569
1763
|
if write:
|
|
@@ -1583,21 +1777,23 @@ def commit_allowed_changes(
|
|
|
1583
1777
|
subject: str,
|
|
1584
1778
|
allowed: Set[str],
|
|
1585
1779
|
body: str,
|
|
1780
|
+
tolerated: Set[str] = frozenset(),
|
|
1586
1781
|
) -> tuple[str, str, Dict[str, Optional[tuple[str, str]]]]:
|
|
1587
1782
|
require_attached(repo)
|
|
1588
1783
|
pending = uncommitted_paths(repo)
|
|
1589
|
-
validate_allowed_changes(repo, base, pending, allowed)
|
|
1784
|
+
validate_allowed_changes(repo, base, pending, allowed, tolerated)
|
|
1785
|
+
staging = pending - tolerated
|
|
1590
1786
|
reset = run_git(repo, "reset", "-q", "HEAD")
|
|
1591
1787
|
if reset.returncode != 0:
|
|
1592
1788
|
raise IsolationError("could not clear the index before landing")
|
|
1593
|
-
for path in sorted(
|
|
1789
|
+
for path in sorted(staging):
|
|
1594
1790
|
added = run_git(repo, "add", "-A", "--", path)
|
|
1595
1791
|
if added.returncode != 0:
|
|
1596
|
-
raise IsolationError(f"could not stage {path}")
|
|
1792
|
+
raise IsolationError(f"could not stage {path}: {added.stderr.strip()}")
|
|
1597
1793
|
staged = _split_paths(
|
|
1598
1794
|
git_output(repo, "diff", "--cached", "--name-only", "--relative")
|
|
1599
1795
|
)
|
|
1600
|
-
if staged !=
|
|
1796
|
+
if staged != staging:
|
|
1601
1797
|
raise IsolationError("staged paths do not match the uncommitted change set")
|
|
1602
1798
|
verified_tree = git_output(repo, "write-tree")
|
|
1603
1799
|
verified_delta = _tree_delta(repo, base, verified_tree)
|
|
@@ -1743,6 +1939,17 @@ def activate_task(
|
|
|
1743
1939
|
if task_branch is None:
|
|
1744
1940
|
if branch.startswith(TASK_BRANCH_PREFIX):
|
|
1745
1941
|
raise IsolationError("parallel task activation requires --task-branch")
|
|
1942
|
+
live = [
|
|
1943
|
+
path
|
|
1944
|
+
for path, ref in _registered_worktrees(worktree).items()
|
|
1945
|
+
if ref == f"refs/heads/{task_branch_name(task_id)}"
|
|
1946
|
+
and path != worktree
|
|
1947
|
+
]
|
|
1948
|
+
if live:
|
|
1949
|
+
raise IsolationError(
|
|
1950
|
+
"task has an active parallel worktree; serial activation in "
|
|
1951
|
+
f"another worktree is refused (activate {live[0]} with --task-branch)"
|
|
1952
|
+
)
|
|
1746
1953
|
elif task_branch != task_branch_name(task_id) or branch != task_branch:
|
|
1747
1954
|
raise IsolationError("task activation branch does not match the task worktree")
|
|
1748
1955
|
normalized_task = relative_posix(task_file)
|
|
@@ -1773,7 +1980,7 @@ def activate_task(
|
|
|
1773
1980
|
if existing.returncode != 0 or existing.stdout.strip() != resolved_base:
|
|
1774
1981
|
_replace_regular_file(task_path, text.encode("utf-8"), mode)
|
|
1775
1982
|
raise IsolationError("could not record task worktree authorization")
|
|
1776
|
-
|
|
1983
|
+
result = {
|
|
1777
1984
|
"agent": agent,
|
|
1778
1985
|
"base": resolved_base,
|
|
1779
1986
|
"status": "in-progress",
|
|
@@ -1781,6 +1988,30 @@ def activate_task(
|
|
|
1781
1988
|
"task_file": normalized_task,
|
|
1782
1989
|
"worktree": str(worktree),
|
|
1783
1990
|
}
|
|
1991
|
+
_write_native_dispatch_record(worktree, result)
|
|
1992
|
+
return result
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
def _write_native_dispatch_record(worktree: Path, activation: Dict[str, object]) -> None:
|
|
1996
|
+
try:
|
|
1997
|
+
if __package__:
|
|
1998
|
+
from . import dispatch_driver
|
|
1999
|
+
else:
|
|
2000
|
+
import dispatch_driver
|
|
2001
|
+
except ImportError:
|
|
2002
|
+
return
|
|
2003
|
+
dispatch_driver.record_native_activation(worktree, activation)
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _write_native_member_dispatch_record(coordinator: Path, activation: Dict[str, object]) -> None:
|
|
2007
|
+
try:
|
|
2008
|
+
if __package__:
|
|
2009
|
+
from . import dispatch_driver
|
|
2010
|
+
else:
|
|
2011
|
+
import dispatch_driver
|
|
2012
|
+
except ImportError:
|
|
2013
|
+
return
|
|
2014
|
+
dispatch_driver.record_native_member_activation(coordinator, activation)
|
|
1784
2015
|
|
|
1785
2016
|
|
|
1786
2017
|
def deactivate_task(
|
|
@@ -1899,6 +2130,59 @@ def stamp_task_landed(task_path: Path, base: str) -> None:
|
|
|
1899
2130
|
)
|
|
1900
2131
|
|
|
1901
2132
|
|
|
2133
|
+
def block_serial_task(
|
|
2134
|
+
primary: Path, task_file: str, task_id: str, base: str
|
|
2135
|
+
) -> Dict[str, str]:
|
|
2136
|
+
"""Record an explicit blocked result on its still-active serial task."""
|
|
2137
|
+
primary = require_directory(primary, "primary worktree")
|
|
2138
|
+
if worktree_root(primary) != primary:
|
|
2139
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
2140
|
+
normalized_task = relative_posix(task_file)
|
|
2141
|
+
if not normalized_task.startswith(".project/tasks/"):
|
|
2142
|
+
raise IsolationError("serial task file must stay under .project/tasks/")
|
|
2143
|
+
task_id = validate_task_id(task_id)
|
|
2144
|
+
expected_base = require_commit(primary, require_full_sha(base))
|
|
2145
|
+
task_path = _real_file(primary, normalized_task, "serial task file")
|
|
2146
|
+
text, mode = _read_task_text(task_path)
|
|
2147
|
+
fields, error = task_frontmatter(text)
|
|
2148
|
+
if error or fields is None:
|
|
2149
|
+
raise IsolationError(error or "serial task frontmatter is unreadable")
|
|
2150
|
+
if fields.get("id") != task_id:
|
|
2151
|
+
raise IsolationError("serial task id changed before blocked result")
|
|
2152
|
+
if fields.get("status") not in {"in-progress", "blocked"}:
|
|
2153
|
+
raise IsolationError("serial task is no longer active")
|
|
2154
|
+
if fields.get("base") != expected_base:
|
|
2155
|
+
raise IsolationError("serial task base changed before blocked result")
|
|
2156
|
+
if fields.get("task_branch") != "null":
|
|
2157
|
+
raise IsolationError("serial task records a task branch")
|
|
2158
|
+
recorded_worktree = fields.get("worktree")
|
|
2159
|
+
if not isinstance(recorded_worktree, str) or Path(recorded_worktree).resolve() != primary:
|
|
2160
|
+
raise IsolationError("serial task worktree changed before blocked result")
|
|
2161
|
+
|
|
2162
|
+
if fields.get("status") == "blocked":
|
|
2163
|
+
return {"task_id": task_id, "task_file": normalized_task,
|
|
2164
|
+
"base": expected_base, "status": "blocked"}
|
|
2165
|
+
|
|
2166
|
+
head, body = split_frontmatter(text)
|
|
2167
|
+
replaced = False
|
|
2168
|
+
lines = []
|
|
2169
|
+
for line in head:
|
|
2170
|
+
if _frontmatter_key(line) == "status":
|
|
2171
|
+
if replaced:
|
|
2172
|
+
raise IsolationError("serial task frontmatter repeats status")
|
|
2173
|
+
replaced = True
|
|
2174
|
+
lines.append("status: blocked")
|
|
2175
|
+
else:
|
|
2176
|
+
lines.append(line)
|
|
2177
|
+
if not replaced:
|
|
2178
|
+
raise IsolationError("serial task frontmatter is missing status")
|
|
2179
|
+
_replace_regular_file(
|
|
2180
|
+
task_path, ("---\n" + "\n".join(lines) + "\n---\n" + body).encode("utf-8"), mode
|
|
2181
|
+
)
|
|
2182
|
+
return {"task_id": task_id, "task_file": normalized_task,
|
|
2183
|
+
"base": expected_base, "status": "blocked"}
|
|
2184
|
+
|
|
2185
|
+
|
|
1902
2186
|
def _restore_landing_state(
|
|
1903
2187
|
worktree: Path, task_path: Path, task_bytes: bytes, task_mode: int, index_tree: str
|
|
1904
2188
|
) -> None:
|
|
@@ -1933,7 +2217,12 @@ def _restore_rejected_commit(
|
|
|
1933
2217
|
|
|
1934
2218
|
|
|
1935
2219
|
def _stamp_and_commit(
|
|
1936
|
-
worktree: Path,
|
|
2220
|
+
worktree: Path,
|
|
2221
|
+
base: str,
|
|
2222
|
+
subject: str,
|
|
2223
|
+
task_file: str,
|
|
2224
|
+
allowed: Set[str],
|
|
2225
|
+
tolerated: Set[str] = frozenset(),
|
|
1937
2226
|
) -> tuple[str, Dict[str, Optional[tuple[str, str]]]]:
|
|
1938
2227
|
task_path = worktree / task_file
|
|
1939
2228
|
task_text, task_mode = _read_task_text(task_path)
|
|
@@ -1954,14 +2243,14 @@ def _stamp_and_commit(
|
|
|
1954
2243
|
pending = uncommitted_paths(worktree)
|
|
1955
2244
|
if stamped != task_bytes:
|
|
1956
2245
|
pending.add(task_file)
|
|
1957
|
-
validate_allowed_changes(worktree, base, pending, allowed)
|
|
2246
|
+
validate_allowed_changes(worktree, base, pending, allowed, tolerated)
|
|
1958
2247
|
original_head = current_sha(worktree)
|
|
1959
2248
|
index_tree = git_output(worktree, "write-tree")
|
|
1960
2249
|
try:
|
|
1961
2250
|
_replace_regular_file(task_path, stamped, task_mode)
|
|
1962
|
-
body = task_commit_body(task_file, uncommitted_paths(worktree), base)
|
|
2251
|
+
body = task_commit_body(task_file, uncommitted_paths(worktree) - tolerated, base)
|
|
1963
2252
|
commit, verified_tree, verified_delta = commit_allowed_changes(
|
|
1964
|
-
worktree, base, subject, allowed, body
|
|
2253
|
+
worktree, base, subject, allowed, body, tolerated
|
|
1965
2254
|
)
|
|
1966
2255
|
proof_error = _landing_commit_proof_error(
|
|
1967
2256
|
worktree,
|
|
@@ -2039,6 +2328,7 @@ def land(
|
|
|
2039
2328
|
subject,
|
|
2040
2329
|
relative_posix(task_file),
|
|
2041
2330
|
allowed,
|
|
2331
|
+
BOOKKEEPING_PATHS,
|
|
2042
2332
|
)
|
|
2043
2333
|
return {
|
|
2044
2334
|
"bound_branch": bound,
|
|
@@ -2333,7 +2623,7 @@ def _landing_retry_error(
|
|
|
2333
2623
|
if task_file not in pending:
|
|
2334
2624
|
return "interrupted landing does not retain the task change"
|
|
2335
2625
|
try:
|
|
2336
|
-
validate_allowed_changes(repo, base, pending, contract_paths)
|
|
2626
|
+
validate_allowed_changes(repo, base, pending, contract_paths, BOOKKEEPING_PATHS)
|
|
2337
2627
|
except IsolationError as validation_error:
|
|
2338
2628
|
return str(validation_error)
|
|
2339
2629
|
return None
|
|
@@ -2378,7 +2668,7 @@ def _prove_task_commit(
|
|
|
2378
2668
|
)
|
|
2379
2669
|
if task_file not in changed:
|
|
2380
2670
|
return None, "commit does not touch the task file"
|
|
2381
|
-
stray =
|
|
2671
|
+
stray = undeclared_paths(repo, base, changed, contract_paths)
|
|
2382
2672
|
if stray:
|
|
2383
2673
|
return None, f"commit touches undeclared paths: {', '.join(stray)}"
|
|
2384
2674
|
if landing_error:
|
|
@@ -2470,6 +2760,56 @@ def _task_branch_proof_error(
|
|
|
2470
2760
|
return None
|
|
2471
2761
|
|
|
2472
2762
|
|
|
2763
|
+
def only_missing_files(worktree: Path) -> bool:
|
|
2764
|
+
"""True when the only difference of a worktree from its HEAD is tracked files that are missing.
|
|
2765
|
+
|
|
2766
|
+
An untracked or a gitignored file is content that Git does not have, so it makes this False.
|
|
2767
|
+
"""
|
|
2768
|
+
status = run_git(worktree, "status", "--porcelain", "--untracked-files=all", "--ignored=matching")
|
|
2769
|
+
lines = status.stdout.splitlines()
|
|
2770
|
+
return status.returncode == 0 and bool(lines) and all(line.startswith(" D") for line in lines)
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
def holds_only_landed_files(repo: Path, path: Path, commits: Sequence[str]) -> bool:
|
|
2774
|
+
"""True when `path` is a directory, not a usable worktree, that holds only files of `commits`.
|
|
2775
|
+
|
|
2776
|
+
Each file must be the same blob at the same path in one of the commits, so the removal of
|
|
2777
|
+
the directory loses nothing that Git does not have. A check that cannot be done is False.
|
|
2778
|
+
"""
|
|
2779
|
+
|
|
2780
|
+
def unreadable(error: OSError) -> None:
|
|
2781
|
+
raise error
|
|
2782
|
+
|
|
2783
|
+
try:
|
|
2784
|
+
if path.is_symlink() or not path.is_dir() or path.resolve() != path:
|
|
2785
|
+
return False
|
|
2786
|
+
root = run_git(path, "rev-parse", "--show-toplevel")
|
|
2787
|
+
if root.returncode == 0 and Path(root.stdout.strip()).resolve() == path:
|
|
2788
|
+
return False
|
|
2789
|
+
files = []
|
|
2790
|
+
for folder, folders, names in os.walk(path, onerror=unreadable):
|
|
2791
|
+
if any(os.path.islink(os.path.join(folder, name)) for name in folders):
|
|
2792
|
+
return False
|
|
2793
|
+
files += [os.path.join(folder, name) for name in names]
|
|
2794
|
+
# What a removal left of the worktree link is not content.
|
|
2795
|
+
files = [name for name in files if name != str(path / ".git")]
|
|
2796
|
+
if not all(stat.S_ISREG(os.lstat(name).st_mode) for name in files):
|
|
2797
|
+
return False
|
|
2798
|
+
known = {
|
|
2799
|
+
entry.split(" ", 2)[2]
|
|
2800
|
+
for commit in commits
|
|
2801
|
+
for entry in git_text(repo, "ls-tree", "-r", "-z", commit).split("\0")
|
|
2802
|
+
if entry
|
|
2803
|
+
}
|
|
2804
|
+
hashed = run_git(repo, "hash-object", "--stdin-paths", input="".join(f"{name}\n" for name in files))
|
|
2805
|
+
except (OSError, IsolationError):
|
|
2806
|
+
return False
|
|
2807
|
+
hashes = hashed.stdout.split()
|
|
2808
|
+
return hashed.returncode == 0 and len(hashes) == len(files) and all(
|
|
2809
|
+
f"{sha}\t{Path(name).relative_to(path).as_posix()}" in known for sha, name in zip(hashes, files)
|
|
2810
|
+
)
|
|
2811
|
+
|
|
2812
|
+
|
|
2473
2813
|
def _worktree_report(
|
|
2474
2814
|
primary: Path, path: Path, expected_branch: str
|
|
2475
2815
|
) -> tuple[Optional[Dict[str, object]], Optional[str]]:
|
|
@@ -2556,6 +2896,40 @@ def _resume_task_error(
|
|
|
2556
2896
|
return _retained_task_contract_error(current_text, isolate_text)
|
|
2557
2897
|
|
|
2558
2898
|
|
|
2899
|
+
def _serial_bookkeeping_error(primary: Path, base: str, head: str) -> Optional[str]:
|
|
2900
|
+
"""Prove that serial task history advanced only through linear .project commits."""
|
|
2901
|
+
if base == head:
|
|
2902
|
+
return None
|
|
2903
|
+
if run_git(primary, "merge-base", "--is-ancestor", base, head).returncode != 0:
|
|
2904
|
+
return "recorded base is not an ancestor of the current branch"
|
|
2905
|
+
commits = git_output(
|
|
2906
|
+
primary, "rev-list", "--first-parent", "--reverse", f"{base}..{head}"
|
|
2907
|
+
).splitlines()
|
|
2908
|
+
previous = base
|
|
2909
|
+
for commit in commits:
|
|
2910
|
+
parents = git_output(primary, "rev-list", "--parents", "-n", "1", commit).split()
|
|
2911
|
+
if len(parents) != 2 or parents[1] != previous:
|
|
2912
|
+
return "history contains an unproven merge or commit sequence"
|
|
2913
|
+
paths = git_output(
|
|
2914
|
+
primary,
|
|
2915
|
+
"diff-tree",
|
|
2916
|
+
"--no-commit-id",
|
|
2917
|
+
"--name-only",
|
|
2918
|
+
"-r",
|
|
2919
|
+
"--no-renames",
|
|
2920
|
+
previous,
|
|
2921
|
+
commit,
|
|
2922
|
+
).splitlines()
|
|
2923
|
+
if not paths:
|
|
2924
|
+
return "history contains an empty commit"
|
|
2925
|
+
if any(path != ".project" and not path.startswith(".project/") for path in paths):
|
|
2926
|
+
return "history contains changes outside .project"
|
|
2927
|
+
previous = commit
|
|
2928
|
+
if previous != head:
|
|
2929
|
+
return "history does not prove a first-parent path from the recorded base"
|
|
2930
|
+
return None
|
|
2931
|
+
|
|
2932
|
+
|
|
2559
2933
|
MEMBER_RECORD_RE = re.compile(
|
|
2560
2934
|
r"Task: (?P<task>\S+)\nBase: (?P<base>[0-9a-f]{40})\n"
|
|
2561
2935
|
r"Member: (?P<member>\S+) (?P<landing>[0-9a-f]{40}) (?P<member_base>[0-9a-f]{40})"
|
|
@@ -2669,6 +3043,7 @@ def _recover_task(
|
|
|
2669
3043
|
head: str,
|
|
2670
3044
|
bound: str,
|
|
2671
3045
|
canonical_task_file: Optional[str] = None,
|
|
3046
|
+
inspect_isolate: bool = True,
|
|
2672
3047
|
) -> Dict[str, object]:
|
|
2673
3048
|
task_file = canonical_task_file or relative_posix(str(path.relative_to(primary)))
|
|
2674
3049
|
report: Dict[str, object] = {"task": task_file}
|
|
@@ -2676,6 +3051,11 @@ def _recover_task(
|
|
|
2676
3051
|
def result(verdict: str, **extra: object) -> Dict[str, object]:
|
|
2677
3052
|
return {**report, "verdict": verdict, **extra}
|
|
2678
3053
|
|
|
3054
|
+
def leftover(commit: object, base: object) -> Dict[str, object]:
|
|
3055
|
+
# The landing is proven, so this block names only what is left to remove.
|
|
3056
|
+
return result("block", reason=f"task landed at {commit}; remove its leftover isolate: {sidecar}",
|
|
3057
|
+
commit=commit, base=base, leftover=str(sidecar))
|
|
3058
|
+
|
|
2679
3059
|
try:
|
|
2680
3060
|
task_text, _ = _read_task_text(path)
|
|
2681
3061
|
except IsolationError as error:
|
|
@@ -2702,14 +3082,22 @@ def _recover_task(
|
|
|
2702
3082
|
return result(**_prove_member_task(primary, task_file, fields, subject, history))
|
|
2703
3083
|
task_branch = task_branch_name(task_id)
|
|
2704
3084
|
branch_exists = task_branch in branches
|
|
2705
|
-
|
|
2706
|
-
|
|
3085
|
+
sidecar = sidecar_root(primary, "task", task_id)
|
|
3086
|
+
worktree, worktree_error = (
|
|
3087
|
+
_worktree_report(primary, sidecar, task_branch) if inspect_isolate else (None, None)
|
|
2707
3088
|
)
|
|
2708
3089
|
report["worktree"] = worktree
|
|
2709
3090
|
report["task_branch"] = task_branch if branch_exists else None
|
|
2710
3091
|
report["rejected"] = []
|
|
2711
3092
|
|
|
2712
3093
|
if worktree_error:
|
|
3094
|
+
landed = _recover_task(
|
|
3095
|
+
primary, path, history, branches, head, bound, canonical_task_file, False
|
|
3096
|
+
) if status == "done" else {}
|
|
3097
|
+
if landed.get("verdict") == "recovered" and holds_only_landed_files(
|
|
3098
|
+
primary, sidecar, [str(landed["commit"]), *([task_branch] if branch_exists else [])]
|
|
3099
|
+
):
|
|
3100
|
+
return leftover(landed["commit"], landed["base"])
|
|
2713
3101
|
return result("block", reason=worktree_error)
|
|
2714
3102
|
|
|
2715
3103
|
valid_statuses = {"pending", "in-progress", "done", "failed", "blocked"}
|
|
@@ -2845,8 +3233,12 @@ def _recover_task(
|
|
|
2845
3233
|
)
|
|
2846
3234
|
except IsolationError as error:
|
|
2847
3235
|
return result("block", reason=f"{status} task has invalid base: {error}")
|
|
2848
|
-
|
|
2849
|
-
|
|
3236
|
+
advance_error = _serial_bookkeeping_error(primary, base, head)
|
|
3237
|
+
if advance_error:
|
|
3238
|
+
return result(
|
|
3239
|
+
"block",
|
|
3240
|
+
reason=f"{status} serial task advanced past its base: {advance_error}",
|
|
3241
|
+
)
|
|
2850
3242
|
primary_worktree, primary_error = _worktree_report(primary, primary, bound)
|
|
2851
3243
|
if primary_error:
|
|
2852
3244
|
return result("block", reason=primary_error)
|
|
@@ -2860,6 +3252,15 @@ def _recover_task(
|
|
|
2860
3252
|
str(primary),
|
|
2861
3253
|
"null",
|
|
2862
3254
|
)
|
|
3255
|
+
if head != base and resume_error is None:
|
|
3256
|
+
return result(
|
|
3257
|
+
"reconcile",
|
|
3258
|
+
base=base,
|
|
3259
|
+
reason=(
|
|
3260
|
+
"serial task advanced through .project bookkeeping; reconcile the "
|
|
3261
|
+
"attempt and any retained Verify sidecar before resetting it to pending"
|
|
3262
|
+
),
|
|
3263
|
+
)
|
|
2863
3264
|
if resume_error:
|
|
2864
3265
|
return result("block", reason=resume_error)
|
|
2865
3266
|
return result("resume", base=base)
|
|
@@ -2888,8 +3289,12 @@ def _recover_task(
|
|
|
2888
3289
|
if serial_retained:
|
|
2889
3290
|
if str(fields.get("task_branch", "")) != "null":
|
|
2890
3291
|
return result("block", reason=f"{status} serial task records a task branch")
|
|
2891
|
-
|
|
2892
|
-
|
|
3292
|
+
advance_error = _serial_bookkeeping_error(primary, base, head)
|
|
3293
|
+
if advance_error:
|
|
3294
|
+
return result(
|
|
3295
|
+
"block",
|
|
3296
|
+
reason=f"{status} serial task advanced past its base: {advance_error}",
|
|
3297
|
+
)
|
|
2893
3298
|
primary_worktree, primary_error = _worktree_report(primary, primary, bound)
|
|
2894
3299
|
if primary_error:
|
|
2895
3300
|
return result("block", reason=primary_error)
|
|
@@ -2946,6 +3351,9 @@ def _recover_task(
|
|
|
2946
3351
|
if branch_error:
|
|
2947
3352
|
return result("block", reason=branch_error)
|
|
2948
3353
|
if worktree is not None and not bool(worktree["clean"]):
|
|
3354
|
+
if only_missing_files(sidecar):
|
|
3355
|
+
# Only landed files are missing: a removal stopped part way, and nothing unknown is there.
|
|
3356
|
+
return leftover(commit, base)
|
|
2949
3357
|
return result(
|
|
2950
3358
|
"block",
|
|
2951
3359
|
reason="proven task retains a dirty worktree",
|
|
@@ -3511,7 +3919,11 @@ def _attest_body_fields(body: str) -> tuple[Dict[str, str], list[str]]:
|
|
|
3511
3919
|
|
|
3512
3920
|
|
|
3513
3921
|
def _attested_changes(repo: Path, base: str, head: str, declared: Set[str]) -> list[str]:
|
|
3514
|
-
"""Declared paths that changed between base and head, matched literally
|
|
3922
|
+
"""Declared paths that changed between base and head, matched literally.
|
|
3923
|
+
|
|
3924
|
+
Land also accepts files below a declared directory. Attestation does not:
|
|
3925
|
+
a recorded attestation body must stay provable.
|
|
3926
|
+
"""
|
|
3515
3927
|
changed = set(git_output(repo, "diff", "--name-only", base, head).splitlines())
|
|
3516
3928
|
return sorted(changed & declared)
|
|
3517
3929
|
|
|
@@ -4321,6 +4733,7 @@ def retire(
|
|
|
4321
4733
|
or f"could not delete {branch}"
|
|
4322
4734
|
)
|
|
4323
4735
|
branch_retired = True
|
|
4736
|
+
release_workspace_quietly(primary)
|
|
4324
4737
|
return {
|
|
4325
4738
|
"bound_branch": bound,
|
|
4326
4739
|
"branch": branch,
|
|
@@ -4354,22 +4767,13 @@ def retire(
|
|
|
4354
4767
|
raise IsolationError(
|
|
4355
4768
|
"worktree is dirty; pass --force only from the retry-retirement path"
|
|
4356
4769
|
)
|
|
4357
|
-
|
|
4358
|
-
primary,
|
|
4359
|
-
"worktree",
|
|
4360
|
-
"remove",
|
|
4361
|
-
*(("--force",) if force else ()),
|
|
4362
|
-
str(resolved_worktree),
|
|
4363
|
-
)
|
|
4364
|
-
if remove.returncode != 0:
|
|
4365
|
-
raise IsolationError(
|
|
4366
|
-
(remove.stderr or remove.stdout).strip() or "git worktree remove failed"
|
|
4367
|
-
)
|
|
4770
|
+
_remove_worktree(primary, resolved_worktree, force)
|
|
4368
4771
|
deleted = run_git(primary, "branch", "-D", retire_branch)
|
|
4369
4772
|
if deleted.returncode != 0:
|
|
4370
4773
|
raise IsolationError(
|
|
4371
4774
|
(deleted.stderr or deleted.stdout).strip() or f"could not delete {retire_branch}"
|
|
4372
4775
|
)
|
|
4776
|
+
release_workspace_quietly(primary)
|
|
4373
4777
|
return {
|
|
4374
4778
|
"bound_branch": bound,
|
|
4375
4779
|
"branch": retire_branch,
|
|
@@ -4379,6 +4783,71 @@ def retire(
|
|
|
4379
4783
|
}
|
|
4380
4784
|
|
|
4381
4785
|
|
|
4786
|
+
def retire_unused_verify(
|
|
4787
|
+
primary: Path, worktree: Path, base: str, branch: str
|
|
4788
|
+
) -> Dict[str, object]:
|
|
4789
|
+
"""Retire a Verify sidecar only when it is provably untouched since creation."""
|
|
4790
|
+
primary = require_directory(primary, "primary worktree")
|
|
4791
|
+
if worktree_root(primary) != primary:
|
|
4792
|
+
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
4793
|
+
if not branch.startswith(VERIFY_BRANCH_PREFIX):
|
|
4794
|
+
raise IsolationError("unused Verify retirement requires a verify branch")
|
|
4795
|
+
expected = _sidecar_path_for_branch(primary, branch)
|
|
4796
|
+
resolved_worktree = worktree.resolve()
|
|
4797
|
+
if resolved_worktree != expected:
|
|
4798
|
+
raise IsolationError("unused Verify path differs from its branch")
|
|
4799
|
+
expected_base = require_commit(primary, require_full_sha(base))
|
|
4800
|
+
|
|
4801
|
+
if os.path.lexists(worktree):
|
|
4802
|
+
if worktree.is_symlink() or not worktree.is_dir() or worktree.resolve() != worktree:
|
|
4803
|
+
raise IsolationError(f"unused Verify path is not an owned worktree: {worktree}")
|
|
4804
|
+
if worktree_root(worktree) != worktree:
|
|
4805
|
+
raise IsolationError(f"unused Verify path is not its Git root: {worktree}")
|
|
4806
|
+
if common_git_dir(primary) != common_git_dir(worktree):
|
|
4807
|
+
raise IsolationError("unused Verify sidecar belongs to another repository")
|
|
4808
|
+
if require_attached(worktree) != branch:
|
|
4809
|
+
raise IsolationError("unused Verify sidecar branch changed")
|
|
4810
|
+
if current_sha(worktree) != expected_base:
|
|
4811
|
+
raise IsolationError("unused Verify sidecar HEAD changed; preserving it")
|
|
4812
|
+
dirty = git_output(
|
|
4813
|
+
worktree,
|
|
4814
|
+
"status",
|
|
4815
|
+
"--porcelain",
|
|
4816
|
+
"--untracked-files=all",
|
|
4817
|
+
"--ignored=matching",
|
|
4818
|
+
)
|
|
4819
|
+
if dirty:
|
|
4820
|
+
raise IsolationError("unused Verify sidecar has uncommitted files; preserving it")
|
|
4821
|
+
else:
|
|
4822
|
+
branch_head = run_git(
|
|
4823
|
+
primary, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"
|
|
4824
|
+
)
|
|
4825
|
+
if branch_head.returncode == 0 and branch_head.stdout.strip() != expected_base:
|
|
4826
|
+
raise IsolationError("unused Verify branch HEAD changed; preserving it")
|
|
4827
|
+
for registered_path, registered_branch in _registered_worktrees(primary).items():
|
|
4828
|
+
if registered_branch == f"refs/heads/{branch}" and registered_path != expected:
|
|
4829
|
+
raise IsolationError("unused Verify branch is checked out at another path")
|
|
4830
|
+
|
|
4831
|
+
return retire(primary, expected, branch, False)
|
|
4832
|
+
|
|
4833
|
+
|
|
4834
|
+
def retire_stale_verify(primary: Path, worktree: Path, branch: str) -> Dict[str, object]:
|
|
4835
|
+
"""Retire the Verify sidecar of an earlier prepare-task only when it is proven untouched.
|
|
4836
|
+
|
|
4837
|
+
The reflog of its branch is the record of its creation base. It must hold only the
|
|
4838
|
+
creation, at a commit of the bound branch; `retire_unused_verify` then proves the checkout.
|
|
4839
|
+
"""
|
|
4840
|
+
try:
|
|
4841
|
+
created = git_output(primary, "reflog", "show", "--format=%H", f"refs/heads/{branch}").split()
|
|
4842
|
+
if len(created) != 1:
|
|
4843
|
+
raise IsolationError("its branch moved after its creation, or has no record of its creation")
|
|
4844
|
+
if run_git(primary, "merge-base", "--is-ancestor", created[0], "HEAD").returncode != 0:
|
|
4845
|
+
raise IsolationError("its branch is not at a commit of the bound branch")
|
|
4846
|
+
return retire_unused_verify(primary, worktree, created[0], branch)
|
|
4847
|
+
except IsolationError as error:
|
|
4848
|
+
raise IsolationError(f"Verify sidecar {worktree} is kept: {error}") from error
|
|
4849
|
+
|
|
4850
|
+
|
|
4382
4851
|
def parser() -> argparse.ArgumentParser:
|
|
4383
4852
|
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
4384
4853
|
subparsers = argument_parser.add_subparsers(dest="command", required=True)
|