@opengsd/gsd-path 1.5.0 → 1.6.1
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 +1 -1
- package/CHANGELOG.md +169 -0
- package/DOCS.md +4 -3
- package/FULL.md +68 -7
- package/HOOKS.md +81 -4
- package/MIGRATE.md +12 -0
- package/QUICK.md +4 -2
- package/README.md +69 -16
- package/RUNTIME.md +23 -5
- package/UPDATE.md +35 -0
- package/WORKFLOW.md +66 -7
- package/package.json +1 -1
- package/scripts/_common.py +150 -1
- package/scripts/build_recovery.py +62 -30
- package/scripts/build_state.py +195 -5
- package/scripts/check_handoffs.py +92 -30
- package/scripts/check_task_briefs.py +27 -19
- package/scripts/detect_project.py +17 -2
- package/scripts/discussion_records.py +9 -1
- package/scripts/discussion_validate.py +7 -6
- package/scripts/dispatch_driver.py +174 -36
- package/scripts/git_guard.py +42 -7
- package/scripts/guard_hook.py +1216 -47
- package/scripts/install.mjs +76 -3
- package/scripts/install.py +182 -37
- package/scripts/integration.py +115 -68
- package/scripts/isolation.py +192 -23
- package/scripts/members.py +68 -25
- package/scripts/migrate_core.py +2 -0
- package/scripts/model_policy.py +13 -2
- package/scripts/pipeline_diagnose.py +17 -2
- package/scripts/pipeline_git.py +87 -35
- package/scripts/pipeline_state.py +31 -7
- package/scripts/pipeline_undo.py +17 -8
- 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 +1 -0
- package/scripts/state_checkpoint.py +30 -9
- package/scripts/state_promote.py +92 -5
- package/scripts/task_context.py +2 -0
- package/scripts/workflow_run.py +9 -2
- package/skills/gsd-path/DEFINE.md +6 -2
- package/skills/gsd-path/PLAN.md +6 -2
- package/skills/gsd-path/ROADMAP.md +16 -6
- package/skills/gsd-path/SHIP.md +38 -11
- package/skills/gsd-path/SKILL.md +52 -24
- package/skills/gsd-path/references/build-native.md +96 -9
- package/skills/gsd-path/references/build-recovery.md +13 -0
- package/skills/gsd-path/references/coder.md +7 -3
- package/skills/gsd-path/references/model-policy.md +3 -1
- package/skills/gsd-path/references/operating-rules.md +14 -5
- package/skills/gsd-path/references/planner.md +13 -1
- package/skills/gsd-path/references/reviewer-final.md +5 -5
- package/skills/gsd-path/references/reviewer-wave.md +46 -13
- package/skills/gsd-path/references/runtime-selection.md +10 -0
- package/skills/gsd-path/scripts/_common.py +150 -1
- package/skills/gsd-path/scripts/build_recovery.py +62 -30
- package/skills/gsd-path/scripts/build_state.py +195 -5
- package/skills/gsd-path/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path/scripts/detect_project.py +17 -2
- package/skills/gsd-path/scripts/discussion_records.py +9 -1
- package/skills/gsd-path/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path/scripts/dispatch_driver.py +174 -36
- package/skills/gsd-path/scripts/git_guard.py +42 -7
- package/skills/gsd-path/scripts/integration.py +115 -68
- package/skills/gsd-path/scripts/isolation.py +192 -23
- package/skills/gsd-path/scripts/members.py +68 -25
- package/skills/gsd-path/scripts/model_policy.py +13 -2
- package/skills/gsd-path/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path/scripts/state_promote.py +92 -5
- package/skills/gsd-path/scripts/task_context.py +2 -0
- package/skills/gsd-path/scripts/workflow_run.py +9 -2
- package/skills/gsd-path/templates/final-review.md +5 -5
- package/skills/gsd-path/templates/state.md +1 -0
- package/skills/gsd-path/templates/task.md +1 -0
- package/skills/gsd-path/templates/wave-review.md +18 -9
- package/skills/gsd-path-build/references/build-native.md +96 -9
- 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/model-policy.md +3 -1
- package/skills/gsd-path-build/references/operating-rules.md +14 -5
- package/skills/gsd-path-build/references/reviewer-final.md +5 -5
- package/skills/gsd-path-build/references/reviewer-wave.md +46 -13
- package/skills/gsd-path-build/references/runtime-selection.md +10 -0
- package/skills/gsd-path-build/scripts/_common.py +150 -1
- package/skills/gsd-path-build/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-build/scripts/build_state.py +195 -5
- package/skills/gsd-path-build/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-build/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-build/scripts/detect_project.py +17 -2
- package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-build/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-build/scripts/dispatch_driver.py +174 -36
- package/skills/gsd-path-build/scripts/integration.py +115 -68
- package/skills/gsd-path-build/scripts/isolation.py +192 -23
- package/skills/gsd-path-build/scripts/members.py +68 -25
- package/skills/gsd-path-build/scripts/model_policy.py +13 -2
- package/skills/gsd-path-build/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-build/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-build/scripts/state_promote.py +92 -5
- package/skills/gsd-path-build/scripts/task_context.py +2 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-build/templates/task.md +1 -0
- package/skills/gsd-path-build/templates/wave-review.md +18 -9
- package/skills/gsd-path-decide/references/model-policy.md +3 -1
- package/skills/gsd-path-decide/references/operating-rules.md +14 -5
- package/skills/gsd-path-decide/references/runtime-selection.md +10 -0
- package/skills/gsd-path-decide/scripts/_common.py +150 -1
- package/skills/gsd-path-decide/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-decide/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-decide/scripts/detect_project.py +17 -2
- package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-decide/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-decide/scripts/integration.py +115 -68
- package/skills/gsd-path-decide/scripts/isolation.py +192 -23
- package/skills/gsd-path-decide/scripts/members.py +68 -25
- package/skills/gsd-path-decide/scripts/model_policy.py +13 -2
- package/skills/gsd-path-decide/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-decide/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-decide/scripts/state_promote.py +92 -5
- package/skills/gsd-path-define/SKILL.md +6 -2
- package/skills/gsd-path-define/references/build-recovery.md +13 -0
- package/skills/gsd-path-define/references/operating-rules.md +14 -5
- package/skills/gsd-path-define/references/runtime-selection.md +10 -0
- package/skills/gsd-path-define/scripts/_common.py +150 -1
- package/skills/gsd-path-define/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-define/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-define/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-define/scripts/detect_project.py +17 -2
- package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-define/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-define/scripts/integration.py +115 -68
- package/skills/gsd-path-define/scripts/isolation.py +192 -23
- package/skills/gsd-path-define/scripts/members.py +68 -25
- package/skills/gsd-path-define/scripts/model_policy.py +13 -2
- package/skills/gsd-path-define/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-define/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-define/scripts/state_promote.py +92 -5
- package/skills/gsd-path-define/templates/state.md +1 -0
- package/skills/gsd-path-discuss/references/operating-rules.md +14 -5
- package/skills/gsd-path-discuss/references/runtime-selection.md +10 -0
- package/skills/gsd-path-discuss/scripts/_common.py +150 -1
- package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-discuss/scripts/model_policy.py +13 -2
- package/skills/gsd-path-docs-audit/references/model-policy.md +3 -1
- package/skills/gsd-path-docs-audit/references/operating-rules.md +14 -5
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +10 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +150 -1
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +17 -2
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-docs-audit/scripts/integration.py +115 -68
- package/skills/gsd-path-docs-audit/scripts/isolation.py +192 -23
- package/skills/gsd-path-docs-audit/scripts/members.py +68 -25
- package/skills/gsd-path-docs-audit/scripts/model_policy.py +13 -2
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +92 -5
- package/skills/gsd-path-forensics/references/operating-rules.md +14 -5
- package/skills/gsd-path-forensics/references/runtime-selection.md +10 -0
- package/skills/gsd-path-forensics/scripts/_common.py +150 -1
- package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-forensics/scripts/model_policy.py +13 -2
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +17 -8
- package/skills/gsd-path-inspect/references/model-policy.md +3 -1
- package/skills/gsd-path-inspect/references/operating-rules.md +14 -5
- package/skills/gsd-path-inspect/references/runtime-selection.md +10 -0
- package/skills/gsd-path-inspect/scripts/_common.py +150 -1
- package/skills/gsd-path-inspect/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-inspect/scripts/detect_project.py +17 -2
- package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-inspect/scripts/integration.py +115 -68
- package/skills/gsd-path-inspect/scripts/isolation.py +192 -23
- package/skills/gsd-path-inspect/scripts/members.py +68 -25
- package/skills/gsd-path-inspect/scripts/model_policy.py +13 -2
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-inspect/scripts/state_promote.py +92 -5
- package/skills/gsd-path-inspect/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-inspect/templates/state.md +1 -0
- package/skills/gsd-path-loop/references/operating-rules.md +14 -5
- package/skills/gsd-path-loop/references/runtime-selection.md +10 -0
- package/skills/gsd-path-loop/scripts/_common.py +150 -1
- package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-loop/scripts/model_policy.py +13 -2
- package/skills/gsd-path-migrate/SKILL.md +8 -0
- package/skills/gsd-path-migrate/references/operating-rules.md +14 -5
- package/skills/gsd-path-migrate/references/runtime-selection.md +10 -0
- package/skills/gsd-path-migrate/scripts/_common.py +150 -1
- package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
- package/skills/gsd-path-migrate/scripts/model_policy.py +13 -2
- package/skills/gsd-path-plan/SKILL.md +6 -2
- package/skills/gsd-path-plan/references/build-recovery.md +13 -0
- package/skills/gsd-path-plan/references/model-policy.md +3 -1
- package/skills/gsd-path-plan/references/operating-rules.md +14 -5
- package/skills/gsd-path-plan/references/planner.md +13 -1
- package/skills/gsd-path-plan/references/runtime-selection.md +10 -0
- package/skills/gsd-path-plan/scripts/_common.py +150 -1
- package/skills/gsd-path-plan/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-plan/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-plan/scripts/detect_project.py +17 -2
- package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-plan/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-plan/scripts/integration.py +115 -68
- package/skills/gsd-path-plan/scripts/isolation.py +192 -23
- package/skills/gsd-path-plan/scripts/members.py +68 -25
- package/skills/gsd-path-plan/scripts/model_policy.py +13 -2
- package/skills/gsd-path-plan/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-plan/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-plan/scripts/state_promote.py +92 -5
- package/skills/gsd-path-plan/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-plan/templates/task.md +1 -0
- package/skills/gsd-path-research/references/model-policy.md +3 -1
- package/skills/gsd-path-research/references/operating-rules.md +14 -5
- package/skills/gsd-path-research/references/runtime-selection.md +10 -0
- package/skills/gsd-path-research/scripts/_common.py +150 -1
- package/skills/gsd-path-research/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-research/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-research/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-research/scripts/detect_project.py +17 -2
- package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-research/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-research/scripts/integration.py +115 -68
- package/skills/gsd-path-research/scripts/isolation.py +192 -23
- package/skills/gsd-path-research/scripts/members.py +68 -25
- package/skills/gsd-path-research/scripts/model_policy.py +13 -2
- package/skills/gsd-path-research/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-research/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-research/scripts/state_promote.py +92 -5
- package/skills/gsd-path-roadmap/SKILL.md +16 -6
- package/skills/gsd-path-roadmap/references/model-policy.md +3 -1
- package/skills/gsd-path-roadmap/references/operating-rules.md +14 -5
- package/skills/gsd-path-roadmap/references/runtime-selection.md +10 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +150 -1
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-roadmap/scripts/detect_project.py +17 -2
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-roadmap/scripts/integration.py +115 -68
- package/skills/gsd-path-roadmap/scripts/isolation.py +192 -23
- package/skills/gsd-path-roadmap/scripts/members.py +68 -25
- package/skills/gsd-path-roadmap/scripts/model_policy.py +13 -2
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-roadmap/scripts/state_promote.py +92 -5
- package/skills/gsd-path-ship/SKILL.md +38 -11
- package/skills/gsd-path-ship/references/model-policy.md +3 -1
- package/skills/gsd-path-ship/references/operating-rules.md +14 -5
- package/skills/gsd-path-ship/references/reviewer-final.md +5 -5
- package/skills/gsd-path-ship/references/reviewer-wave.md +46 -13
- package/skills/gsd-path-ship/references/runtime-selection.md +10 -0
- package/skills/gsd-path-ship/scripts/_common.py +150 -1
- package/skills/gsd-path-ship/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-ship/scripts/build_state.py +195 -5
- package/skills/gsd-path-ship/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +27 -19
- package/skills/gsd-path-ship/scripts/detect_project.py +17 -2
- package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-ship/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-ship/scripts/integration.py +115 -68
- package/skills/gsd-path-ship/scripts/isolation.py +192 -23
- package/skills/gsd-path-ship/scripts/members.py +68 -25
- package/skills/gsd-path-ship/scripts/model_policy.py +13 -2
- package/skills/gsd-path-ship/scripts/pipeline_git.py +87 -35
- package/skills/gsd-path-ship/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/gsd-path-ship/scripts/state_promote.py +92 -5
- package/skills/gsd-path-ship/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-ship/templates/final-review.md +5 -5
- package/skills/gsd-path-ship/templates/wave-review.md +18 -9
- package/skills/gsd-path-undo/references/operating-rules.md +14 -5
- package/skills/gsd-path-undo/references/runtime-selection.md +10 -0
- package/skills/gsd-path-undo/scripts/_common.py +150 -1
- package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-undo/scripts/model_policy.py +13 -2
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +17 -8
- package/skills/path/DEFINE.md +6 -2
- package/skills/path/PLAN.md +6 -2
- package/skills/path/ROADMAP.md +16 -6
- package/skills/path/SHIP.md +38 -11
- package/skills/path/SKILL.md +52 -24
- package/skills/path/references/build-native.md +96 -9
- package/skills/path/references/build-recovery.md +13 -0
- package/skills/path/references/coder.md +7 -3
- package/skills/path/references/model-policy.md +3 -1
- package/skills/path/references/operating-rules.md +14 -5
- package/skills/path/references/planner.md +13 -1
- package/skills/path/references/reviewer-final.md +5 -5
- package/skills/path/references/reviewer-wave.md +46 -13
- package/skills/path/references/runtime-selection.md +10 -0
- package/skills/path/scripts/_common.py +150 -1
- package/skills/path/scripts/build_recovery.py +62 -30
- package/skills/path/scripts/build_state.py +195 -5
- package/skills/path/scripts/check_handoffs.py +92 -30
- package/skills/path/scripts/check_task_briefs.py +27 -19
- package/skills/path/scripts/detect_project.py +17 -2
- package/skills/path/scripts/discussion_records.py +9 -1
- package/skills/path/scripts/discussion_validate.py +7 -6
- package/skills/path/scripts/dispatch_driver.py +174 -36
- package/skills/path/scripts/git_guard.py +42 -7
- package/skills/path/scripts/integration.py +115 -68
- package/skills/path/scripts/isolation.py +192 -23
- package/skills/path/scripts/members.py +68 -25
- package/skills/path/scripts/model_policy.py +13 -2
- package/skills/path/scripts/pipeline_git.py +87 -35
- package/skills/path/scripts/pipeline_state.py +31 -7
- 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 +30 -9
- package/skills/path/scripts/state_promote.py +92 -5
- package/skills/path/scripts/task_context.py +2 -0
- package/skills/path/scripts/workflow_run.py +9 -2
- package/skills/path/templates/final-review.md +5 -5
- package/skills/path/templates/state.md +1 -0
- package/skills/path/templates/task.md +1 -0
- package/skills/path/templates/wave-review.md +18 -9
|
@@ -11,6 +11,7 @@ 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
|
|
@@ -103,6 +104,10 @@ class IsolationError(RuntimeError):
|
|
|
103
104
|
"""Raised when isolation, landing, or retirement cannot proceed."""
|
|
104
105
|
|
|
105
106
|
|
|
107
|
+
class RemovalError(IsolationError):
|
|
108
|
+
"""Raised when the removal of a sidecar worktree fails."""
|
|
109
|
+
|
|
110
|
+
|
|
106
111
|
run_git = _common.run_git
|
|
107
112
|
|
|
108
113
|
|
|
@@ -146,7 +151,10 @@ def require_commit(repo: Path, value: str) -> str:
|
|
|
146
151
|
|
|
147
152
|
def require_full_sha(value: str) -> str:
|
|
148
153
|
if not re.fullmatch(r"[0-9a-f]{40}", value):
|
|
149
|
-
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
|
+
)
|
|
150
158
|
return value
|
|
151
159
|
|
|
152
160
|
|
|
@@ -714,6 +722,18 @@ def member_checkout(coordinator: Path, member: str) -> Tuple[Path, str]:
|
|
|
714
722
|
return checkout, str(role["project"])
|
|
715
723
|
|
|
716
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
|
+
|
|
717
737
|
def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
|
|
718
738
|
"""Path's own checkout of a locked member's bound branch, outside the team's checkout."""
|
|
719
739
|
checkout, _, entry = _member_context(coordinator, member)
|
|
@@ -833,7 +853,10 @@ def retire_member_task(
|
|
|
833
853
|
if destination.resolve() in registered:
|
|
834
854
|
if registered[destination.resolve()] != ref:
|
|
835
855
|
raise IsolationError(f"member task sidecar is on another branch: {destination}")
|
|
836
|
-
|
|
856
|
+
try:
|
|
857
|
+
_remove_worktree(checkout, destination, force)
|
|
858
|
+
except IsolationError as error:
|
|
859
|
+
raise RemovalError(str(error)) from error
|
|
837
860
|
authorization = run_git(checkout, "update-ref", "-d", member_task_authorization_ref(project, task_id))
|
|
838
861
|
if authorization.returncode != 0:
|
|
839
862
|
raise IsolationError((authorization.stderr or authorization.stdout).strip() or "git update-ref failed")
|
|
@@ -1177,7 +1200,7 @@ def _prove_member_commit(
|
|
|
1177
1200
|
parent, commit).splitlines())
|
|
1178
1201
|
if not changed:
|
|
1179
1202
|
return "commit changes nothing"
|
|
1180
|
-
undeclared =
|
|
1203
|
+
undeclared = undeclared_paths(repo, parent, changed, allowed)
|
|
1181
1204
|
if undeclared:
|
|
1182
1205
|
return "undeclared paths: " + ", ".join(undeclared)
|
|
1183
1206
|
if git_output(repo, "log", "-1", "--format=%s", commit) != subject:
|
|
@@ -1193,12 +1216,20 @@ def _commit_member_source(sidecar: Path, journal: Dict[str, object], allowed: Se
|
|
|
1193
1216
|
if pending:
|
|
1194
1217
|
if current_sha(sidecar) != member_base:
|
|
1195
1218
|
raise IsolationError("dirty member source HEAD must equal the member base")
|
|
1196
|
-
undeclared =
|
|
1219
|
+
undeclared = undeclared_paths(sidecar, member_base, pending, allowed)
|
|
1197
1220
|
if undeclared:
|
|
1198
1221
|
raise IsolationError("undeclared paths: " + ", ".join(undeclared))
|
|
1199
|
-
git_output(sidecar, "add", "-A", "--", *sorted(pending))
|
|
1200
1222
|
body = member_commit_body(str(journal["task_file"]), sorted(pending), member_base, str(journal["base"]))
|
|
1201
|
-
|
|
1223
|
+
# The paths and the body grow with the change set, so both go through
|
|
1224
|
+
# stdin: a Windows command line holds about 32 KiB.
|
|
1225
|
+
for arguments, text in (
|
|
1226
|
+
(("add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"),
|
|
1227
|
+
"".join(f"{path}\0" for path in sorted(pending))),
|
|
1228
|
+
(("commit", "-q", "-F", "-"), _commit_message(str(journal["subject"]), body)),
|
|
1229
|
+
):
|
|
1230
|
+
result = run_git(sidecar, *arguments, input=text)
|
|
1231
|
+
if result.returncode != 0:
|
|
1232
|
+
raise IsolationError((result.stderr or result.stdout).strip() or "git command failed")
|
|
1202
1233
|
source = current_sha(sidecar)
|
|
1203
1234
|
if source == member_base:
|
|
1204
1235
|
raise IsolationError("no changes to land")
|
|
@@ -1646,6 +1677,25 @@ def committed_paths_since(repo: Path, base: str) -> Set[str]:
|
|
|
1646
1677
|
)
|
|
1647
1678
|
|
|
1648
1679
|
|
|
1680
|
+
def _stage_paths(repo: Path, mode: str, paths: Set[str]) -> None:
|
|
1681
|
+
"""Stage `paths` with one `git add`.
|
|
1682
|
+
|
|
1683
|
+
One call for each path fails when a file replaces a tracked directory: the
|
|
1684
|
+
first call drops the files below the directory from the index, and then
|
|
1685
|
+
their pathspecs match no file. The `./` prefix stops pathspec magic, so a
|
|
1686
|
+
name such as `:!b` cannot exclude another path of the set. Git names the
|
|
1687
|
+
path in its error text.
|
|
1688
|
+
"""
|
|
1689
|
+
if not paths:
|
|
1690
|
+
return
|
|
1691
|
+
added = run_git(
|
|
1692
|
+
repo, "add", mode, "--pathspec-from-file=-", "--pathspec-file-nul",
|
|
1693
|
+
input="".join(f"./{path}\0" for path in sorted(paths)),
|
|
1694
|
+
)
|
|
1695
|
+
if added.returncode != 0:
|
|
1696
|
+
raise IsolationError(f"could not stage the change set: {added.stderr.strip()}")
|
|
1697
|
+
|
|
1698
|
+
|
|
1649
1699
|
def ignored_legacy_ledger(repo: Path) -> bool:
|
|
1650
1700
|
"""True when a verify ledger absent from HEAD is hidden by an ignore rule.
|
|
1651
1701
|
|
|
@@ -1661,17 +1711,25 @@ def ignored_legacy_ledger(repo: Path) -> bool:
|
|
|
1661
1711
|
return run_git(repo, "check-ignore", "-q", "--no-index", "--", VERIFY_LEDGER_PATH).returncode == 0
|
|
1662
1712
|
|
|
1663
1713
|
|
|
1714
|
+
def _commit_message(subject: str, body: str) -> str:
|
|
1715
|
+
"""The message for `git commit -F -`.
|
|
1716
|
+
|
|
1717
|
+
A landing body lists every changed path. As a `-m` argument it can exceed
|
|
1718
|
+
the Windows command-line limit, so the message goes through stdin. Like
|
|
1719
|
+
`-m`, the message ends its last line.
|
|
1720
|
+
"""
|
|
1721
|
+
message = f"{subject}\n\n{body}"
|
|
1722
|
+
return message if message.endswith("\n") else message + "\n"
|
|
1723
|
+
|
|
1724
|
+
|
|
1664
1725
|
def _commit_pending(
|
|
1665
1726
|
repo: Path, pending: Set[str], subject: str, body: str, forced: Set[str] = frozenset()
|
|
1666
1727
|
) -> str:
|
|
1667
1728
|
reset = run_git(repo, "reset", "-q", "HEAD")
|
|
1668
1729
|
if reset.returncode != 0:
|
|
1669
1730
|
raise IsolationError("could not clear the index before commit")
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
added = run_git(repo, "add", mode, "--", path)
|
|
1673
|
-
if added.returncode != 0:
|
|
1674
|
-
raise IsolationError(f"could not stage {path}")
|
|
1731
|
+
_stage_paths(repo, "-A", pending - forced)
|
|
1732
|
+
_stage_paths(repo, "-f", pending & forced)
|
|
1675
1733
|
staged = _split_paths(
|
|
1676
1734
|
git_output(
|
|
1677
1735
|
repo,
|
|
@@ -1684,12 +1742,38 @@ def _commit_pending(
|
|
|
1684
1742
|
)
|
|
1685
1743
|
if staged != pending:
|
|
1686
1744
|
raise IsolationError("staged paths do not match the uncommitted change set")
|
|
1687
|
-
committed = run_git(repo, "commit", "-q", "-
|
|
1745
|
+
committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
|
|
1688
1746
|
if committed.returncode != 0:
|
|
1689
1747
|
raise IsolationError((committed.stderr or committed.stdout).strip() or "commit failed")
|
|
1690
1748
|
return current_sha(repo)
|
|
1691
1749
|
|
|
1692
1750
|
|
|
1751
|
+
def undeclared_paths(
|
|
1752
|
+
repo: Path, base: str, paths: Set[str], declared: Set[str]
|
|
1753
|
+
) -> list[str]:
|
|
1754
|
+
"""Return the sorted paths that the declared entries do not cover.
|
|
1755
|
+
|
|
1756
|
+
An entry covers itself and, as a declared directory, the files below it.
|
|
1757
|
+
An entry that is a file at `base` covers only itself: the task-brief lint
|
|
1758
|
+
rejects a path below a base file.
|
|
1759
|
+
"""
|
|
1760
|
+
|
|
1761
|
+
@functools.lru_cache(maxsize=None)
|
|
1762
|
+
def directory(entry: str) -> bool:
|
|
1763
|
+
kind = run_git(repo, "cat-file", "-t", f"{base}:{entry}")
|
|
1764
|
+
return kind.returncode != 0 or kind.stdout.strip() == "tree"
|
|
1765
|
+
|
|
1766
|
+
return sorted(
|
|
1767
|
+
path
|
|
1768
|
+
for path in paths
|
|
1769
|
+
if path not in declared
|
|
1770
|
+
and not any(
|
|
1771
|
+
str(parent) in declared and directory(str(parent))
|
|
1772
|
+
for parent in list(PurePosixPath(path).parents)[:-1]
|
|
1773
|
+
)
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
|
|
1693
1777
|
def validate_allowed_changes(
|
|
1694
1778
|
repo: Path,
|
|
1695
1779
|
base: str,
|
|
@@ -1703,7 +1787,7 @@ def validate_allowed_changes(
|
|
|
1703
1787
|
if not landing:
|
|
1704
1788
|
raise IsolationError("no changes to land")
|
|
1705
1789
|
since_base = committed_paths_since(repo, base) | landing
|
|
1706
|
-
unexpected =
|
|
1790
|
+
unexpected = undeclared_paths(repo, base, since_base, allowed)
|
|
1707
1791
|
if unexpected:
|
|
1708
1792
|
raise IsolationError(
|
|
1709
1793
|
"unexpected paths: " + ", ".join(unexpected)
|
|
@@ -1737,18 +1821,15 @@ def commit_allowed_changes(
|
|
|
1737
1821
|
reset = run_git(repo, "reset", "-q", "HEAD")
|
|
1738
1822
|
if reset.returncode != 0:
|
|
1739
1823
|
raise IsolationError("could not clear the index before landing")
|
|
1740
|
-
|
|
1741
|
-
added = run_git(repo, "add", "-A", "--", path)
|
|
1742
|
-
if added.returncode != 0:
|
|
1743
|
-
raise IsolationError(f"could not stage {path}")
|
|
1824
|
+
_stage_paths(repo, "-A", staging)
|
|
1744
1825
|
staged = _split_paths(
|
|
1745
|
-
git_output(repo, "diff", "--cached", "--name-only", "--relative")
|
|
1826
|
+
git_output(repo, "diff", "--no-renames", "--cached", "--name-only", "--relative")
|
|
1746
1827
|
)
|
|
1747
1828
|
if staged != staging:
|
|
1748
1829
|
raise IsolationError("staged paths do not match the uncommitted change set")
|
|
1749
1830
|
verified_tree = git_output(repo, "write-tree")
|
|
1750
1831
|
verified_delta = _tree_delta(repo, base, verified_tree)
|
|
1751
|
-
committed = run_git(repo, "commit", "-q", "-
|
|
1832
|
+
committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
|
|
1752
1833
|
if committed.returncode != 0:
|
|
1753
1834
|
raise IsolationError(
|
|
1754
1835
|
(committed.stderr or committed.stdout).strip() or "commit failed"
|
|
@@ -2619,7 +2700,7 @@ def _prove_task_commit(
|
|
|
2619
2700
|
)
|
|
2620
2701
|
if task_file not in changed:
|
|
2621
2702
|
return None, "commit does not touch the task file"
|
|
2622
|
-
stray =
|
|
2703
|
+
stray = undeclared_paths(repo, base, changed, contract_paths)
|
|
2623
2704
|
if stray:
|
|
2624
2705
|
return None, f"commit touches undeclared paths: {', '.join(stray)}"
|
|
2625
2706
|
if landing_error:
|
|
@@ -2711,6 +2792,56 @@ def _task_branch_proof_error(
|
|
|
2711
2792
|
return None
|
|
2712
2793
|
|
|
2713
2794
|
|
|
2795
|
+
def only_missing_files(worktree: Path) -> bool:
|
|
2796
|
+
"""True when the only difference of a worktree from its HEAD is tracked files that are missing.
|
|
2797
|
+
|
|
2798
|
+
An untracked or a gitignored file is content that Git does not have, so it makes this False.
|
|
2799
|
+
"""
|
|
2800
|
+
status = run_git(worktree, "status", "--porcelain", "--untracked-files=all", "--ignored=matching")
|
|
2801
|
+
lines = status.stdout.splitlines()
|
|
2802
|
+
return status.returncode == 0 and bool(lines) and all(line.startswith(" D") for line in lines)
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
def holds_only_landed_files(repo: Path, path: Path, commits: Sequence[str]) -> bool:
|
|
2806
|
+
"""True when `path` is a directory, not a usable worktree, that holds only files of `commits`.
|
|
2807
|
+
|
|
2808
|
+
Each file must be the same blob at the same path in one of the commits, so the removal of
|
|
2809
|
+
the directory loses nothing that Git does not have. A check that cannot be done is False.
|
|
2810
|
+
"""
|
|
2811
|
+
|
|
2812
|
+
def unreadable(error: OSError) -> None:
|
|
2813
|
+
raise error
|
|
2814
|
+
|
|
2815
|
+
try:
|
|
2816
|
+
if path.is_symlink() or not path.is_dir() or path.resolve() != path:
|
|
2817
|
+
return False
|
|
2818
|
+
root = run_git(path, "rev-parse", "--show-toplevel")
|
|
2819
|
+
if root.returncode == 0 and Path(root.stdout.strip()).resolve() == path:
|
|
2820
|
+
return False
|
|
2821
|
+
files = []
|
|
2822
|
+
for folder, folders, names in os.walk(path, onerror=unreadable):
|
|
2823
|
+
if any(os.path.islink(os.path.join(folder, name)) for name in folders):
|
|
2824
|
+
return False
|
|
2825
|
+
files += [os.path.join(folder, name) for name in names]
|
|
2826
|
+
# What a removal left of the worktree link is not content.
|
|
2827
|
+
files = [name for name in files if name != str(path / ".git")]
|
|
2828
|
+
if not all(stat.S_ISREG(os.lstat(name).st_mode) for name in files):
|
|
2829
|
+
return False
|
|
2830
|
+
known = {
|
|
2831
|
+
entry.split(" ", 2)[2]
|
|
2832
|
+
for commit in commits
|
|
2833
|
+
for entry in git_text(repo, "ls-tree", "-r", "-z", commit).split("\0")
|
|
2834
|
+
if entry
|
|
2835
|
+
}
|
|
2836
|
+
hashed = run_git(repo, "hash-object", "--stdin-paths", input="".join(f"{name}\n" for name in files))
|
|
2837
|
+
except (OSError, IsolationError):
|
|
2838
|
+
return False
|
|
2839
|
+
hashes = hashed.stdout.split()
|
|
2840
|
+
return hashed.returncode == 0 and len(hashes) == len(files) and all(
|
|
2841
|
+
f"{sha}\t{Path(name).relative_to(path).as_posix()}" in known for sha, name in zip(hashes, files)
|
|
2842
|
+
)
|
|
2843
|
+
|
|
2844
|
+
|
|
2714
2845
|
def _worktree_report(
|
|
2715
2846
|
primary: Path, path: Path, expected_branch: str
|
|
2716
2847
|
) -> tuple[Optional[Dict[str, object]], Optional[str]]:
|
|
@@ -2944,6 +3075,7 @@ def _recover_task(
|
|
|
2944
3075
|
head: str,
|
|
2945
3076
|
bound: str,
|
|
2946
3077
|
canonical_task_file: Optional[str] = None,
|
|
3078
|
+
inspect_isolate: bool = True,
|
|
2947
3079
|
) -> Dict[str, object]:
|
|
2948
3080
|
task_file = canonical_task_file or relative_posix(str(path.relative_to(primary)))
|
|
2949
3081
|
report: Dict[str, object] = {"task": task_file}
|
|
@@ -2951,6 +3083,11 @@ def _recover_task(
|
|
|
2951
3083
|
def result(verdict: str, **extra: object) -> Dict[str, object]:
|
|
2952
3084
|
return {**report, "verdict": verdict, **extra}
|
|
2953
3085
|
|
|
3086
|
+
def leftover(commit: object, base: object) -> Dict[str, object]:
|
|
3087
|
+
# The landing is proven, so this block names only what is left to remove.
|
|
3088
|
+
return result("block", reason=f"task landed at {commit}; remove its leftover isolate: {sidecar}",
|
|
3089
|
+
commit=commit, base=base, leftover=str(sidecar))
|
|
3090
|
+
|
|
2954
3091
|
try:
|
|
2955
3092
|
task_text, _ = _read_task_text(path)
|
|
2956
3093
|
except IsolationError as error:
|
|
@@ -2977,14 +3114,22 @@ def _recover_task(
|
|
|
2977
3114
|
return result(**_prove_member_task(primary, task_file, fields, subject, history))
|
|
2978
3115
|
task_branch = task_branch_name(task_id)
|
|
2979
3116
|
branch_exists = task_branch in branches
|
|
2980
|
-
|
|
2981
|
-
|
|
3117
|
+
sidecar = sidecar_root(primary, "task", task_id)
|
|
3118
|
+
worktree, worktree_error = (
|
|
3119
|
+
_worktree_report(primary, sidecar, task_branch) if inspect_isolate else (None, None)
|
|
2982
3120
|
)
|
|
2983
3121
|
report["worktree"] = worktree
|
|
2984
3122
|
report["task_branch"] = task_branch if branch_exists else None
|
|
2985
3123
|
report["rejected"] = []
|
|
2986
3124
|
|
|
2987
3125
|
if worktree_error:
|
|
3126
|
+
landed = _recover_task(
|
|
3127
|
+
primary, path, history, branches, head, bound, canonical_task_file, False
|
|
3128
|
+
) if status == "done" else {}
|
|
3129
|
+
if landed.get("verdict") == "recovered" and holds_only_landed_files(
|
|
3130
|
+
primary, sidecar, [str(landed["commit"]), *([task_branch] if branch_exists else [])]
|
|
3131
|
+
):
|
|
3132
|
+
return leftover(landed["commit"], landed["base"])
|
|
2988
3133
|
return result("block", reason=worktree_error)
|
|
2989
3134
|
|
|
2990
3135
|
valid_statuses = {"pending", "in-progress", "done", "failed", "blocked"}
|
|
@@ -3238,6 +3383,9 @@ def _recover_task(
|
|
|
3238
3383
|
if branch_error:
|
|
3239
3384
|
return result("block", reason=branch_error)
|
|
3240
3385
|
if worktree is not None and not bool(worktree["clean"]):
|
|
3386
|
+
if only_missing_files(sidecar):
|
|
3387
|
+
# Only landed files are missing: a removal stopped part way, and nothing unknown is there.
|
|
3388
|
+
return leftover(commit, base)
|
|
3241
3389
|
return result(
|
|
3242
3390
|
"block",
|
|
3243
3391
|
reason="proven task retains a dirty worktree",
|
|
@@ -3803,7 +3951,11 @@ def _attest_body_fields(body: str) -> tuple[Dict[str, str], list[str]]:
|
|
|
3803
3951
|
|
|
3804
3952
|
|
|
3805
3953
|
def _attested_changes(repo: Path, base: str, head: str, declared: Set[str]) -> list[str]:
|
|
3806
|
-
"""Declared paths that changed between base and head, matched literally
|
|
3954
|
+
"""Declared paths that changed between base and head, matched literally.
|
|
3955
|
+
|
|
3956
|
+
Land also accepts files below a declared directory. Attestation does not:
|
|
3957
|
+
a recorded attestation body must stay provable.
|
|
3958
|
+
"""
|
|
3807
3959
|
changed = set(git_output(repo, "diff", "--name-only", base, head).splitlines())
|
|
3808
3960
|
return sorted(changed & declared)
|
|
3809
3961
|
|
|
@@ -4711,6 +4863,23 @@ def retire_unused_verify(
|
|
|
4711
4863
|
return retire(primary, expected, branch, False)
|
|
4712
4864
|
|
|
4713
4865
|
|
|
4866
|
+
def retire_stale_verify(primary: Path, worktree: Path, branch: str) -> Dict[str, object]:
|
|
4867
|
+
"""Retire the Verify sidecar of an earlier prepare-task only when it is proven untouched.
|
|
4868
|
+
|
|
4869
|
+
The reflog of its branch is the record of its creation base. It must hold only the
|
|
4870
|
+
creation, at a commit of the bound branch; `retire_unused_verify` then proves the checkout.
|
|
4871
|
+
"""
|
|
4872
|
+
try:
|
|
4873
|
+
created = git_output(primary, "reflog", "show", "--format=%H", f"refs/heads/{branch}").split()
|
|
4874
|
+
if len(created) != 1:
|
|
4875
|
+
raise IsolationError("its branch moved after its creation, or has no record of its creation")
|
|
4876
|
+
if run_git(primary, "merge-base", "--is-ancestor", created[0], "HEAD").returncode != 0:
|
|
4877
|
+
raise IsolationError("its branch is not at a commit of the bound branch")
|
|
4878
|
+
return retire_unused_verify(primary, worktree, created[0], branch)
|
|
4879
|
+
except IsolationError as error:
|
|
4880
|
+
raise IsolationError(f"Verify sidecar {worktree} is kept: {error}") from error
|
|
4881
|
+
|
|
4882
|
+
|
|
4714
4883
|
def parser() -> argparse.ArgumentParser:
|
|
4715
4884
|
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
4716
4885
|
subparsers = argument_parser.add_subparsers(dest="command", required=True)
|
|
@@ -35,6 +35,8 @@ MARKER_SCHEMA = "gsd-path/member/v1"
|
|
|
35
35
|
MARKER_KEYS = {"schema", "coordinator", "project", "name"}
|
|
36
36
|
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
37
37
|
FIELDS = ("Checkout", "Remote", "Integration")
|
|
38
|
+
# Optional: `add` records it one time; a row without it means main.
|
|
39
|
+
DEFAULT_BRANCH_FIELD = "Default branch"
|
|
38
40
|
INTEGRATIONS = ("default", "direct", "pull-request")
|
|
39
41
|
GITHUB_REMOTE_RE = re.compile(
|
|
40
42
|
r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([^/\s]+)/([^/\s]+)"
|
|
@@ -221,9 +223,10 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
|
|
|
221
223
|
if not line.strip():
|
|
222
224
|
continue
|
|
223
225
|
key, separator, value = line.partition(":")
|
|
224
|
-
|
|
226
|
+
field = key.lower().replace(" ", "_")
|
|
227
|
+
if not separator or key not in (*FIELDS, DEFAULT_BRANCH_FIELD) or field in current:
|
|
225
228
|
raise MembersError(f"MEMBERS.md line {number}: unexpected line {line!r}")
|
|
226
|
-
current[
|
|
229
|
+
current[field] = value.strip()
|
|
227
230
|
if not preamble or preamble[0] != "# Members":
|
|
228
231
|
raise MembersError("MEMBERS.md format error: expected # Members")
|
|
229
232
|
rest = preamble[1:]
|
|
@@ -247,17 +250,37 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
|
|
|
247
250
|
)
|
|
248
251
|
if not Path(member["checkout"]).is_absolute():
|
|
249
252
|
raise MembersError(f"MEMBERS.md member {member['name']} Checkout must be absolute")
|
|
253
|
+
if not _common.valid_default_branch(default_branch(member)):
|
|
254
|
+
raise MembersError(
|
|
255
|
+
f"MEMBERS.md member {member['name']} has invalid Default branch: {member['default_branch']}"
|
|
256
|
+
)
|
|
250
257
|
return members
|
|
251
258
|
|
|
252
259
|
|
|
260
|
+
def default_branch(member: dict[str, str]) -> str:
|
|
261
|
+
"""The default branch a MEMBERS.md row records; a row without the field means main."""
|
|
262
|
+
return member.get("default_branch", "main")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def member_default_branch(coordinator: Path, name: str) -> str:
|
|
266
|
+
"""The default branch MEMBERS.md records for the member `name`."""
|
|
267
|
+
for member in read_members(coordinator):
|
|
268
|
+
if member["name"] == name:
|
|
269
|
+
return default_branch(member)
|
|
270
|
+
raise MembersError(f"member {name} is not in MEMBERS.md")
|
|
271
|
+
|
|
272
|
+
|
|
253
273
|
def check_member(
|
|
254
274
|
coordinator: Path,
|
|
255
275
|
coordinator_name: str,
|
|
256
276
|
checkout: Path,
|
|
257
|
-
|
|
277
|
+
recorded: Optional[dict[str, str]] = None,
|
|
258
278
|
refuse_reserved_refs: bool = True,
|
|
259
|
-
) -> str:
|
|
260
|
-
"""Refuse a checkout that cannot join; return its origin URL.
|
|
279
|
+
) -> tuple[str, str]:
|
|
280
|
+
"""Refuse a checkout that cannot join; return its origin URL and default branch.
|
|
281
|
+
|
|
282
|
+
`recorded` is the member's MEMBERS.md row. Without it (`add`), the default
|
|
283
|
+
branch is the local origin/HEAD, read this one time; an unset ref means main."""
|
|
261
284
|
if checkout.is_symlink() or not checkout.is_dir():
|
|
262
285
|
raise MembersError(f"member checkout is not a real directory: {checkout}")
|
|
263
286
|
checkout = checkout.resolve()
|
|
@@ -279,12 +302,21 @@ def check_member(
|
|
|
279
302
|
remote = _git(checkout, "config", "--get", "remote.origin.url")
|
|
280
303
|
if not GITHUB_REMOTE_RE.fullmatch(remote):
|
|
281
304
|
raise MembersError(f"member requires a GitHub.com origin: {remote}")
|
|
282
|
-
if
|
|
283
|
-
raise MembersError(f"member origin changed: {
|
|
284
|
-
|
|
285
|
-
if
|
|
286
|
-
|
|
287
|
-
|
|
305
|
+
if recorded is not None and remote != recorded["remote"]:
|
|
306
|
+
raise MembersError(f"member origin changed: {recorded['remote']} -> {remote}")
|
|
307
|
+
live = _common.origin_default_branch(checkout)
|
|
308
|
+
default = live if recorded is None else default_branch(recorded)
|
|
309
|
+
if live != default:
|
|
310
|
+
raise MembersError(
|
|
311
|
+
f"member remote default must be {default}, got {live}: {checkout}; "
|
|
312
|
+
"run `git remote set-head origin --auto` when the local origin/HEAD is stale"
|
|
313
|
+
)
|
|
314
|
+
if not _common.valid_default_branch(default):
|
|
315
|
+
raise MembersError(
|
|
316
|
+
f"member default branch {default!r} cannot be recorded in MEMBERS.md: it is a "
|
|
317
|
+
"GSD Path reserved name or it has a character that the file cannot hold"
|
|
318
|
+
)
|
|
319
|
+
require_origin_branch(checkout, default)
|
|
288
320
|
member_state = checkout / ".project" / "STATE.md"
|
|
289
321
|
if member_state.exists() or member_state.is_symlink():
|
|
290
322
|
if member_state.is_symlink() or not member_state.is_file():
|
|
@@ -303,7 +335,7 @@ def check_member(
|
|
|
303
335
|
colliding = [ref for ref in refs if ref.startswith(prefixes)]
|
|
304
336
|
if colliding:
|
|
305
337
|
raise MembersError("member refs collide with coordinator names: " + ", ".join(colliding))
|
|
306
|
-
return remote
|
|
338
|
+
return remote, default
|
|
307
339
|
|
|
308
340
|
|
|
309
341
|
MEMBER_CLOSE_SCHEMA = "gsd-path/member-close/v1"
|
|
@@ -338,12 +370,14 @@ def write_member_close(common: Path, archive_name: str, rows: list[dict]) -> Non
|
|
|
338
370
|
_common.atomic_write(path, json.dumps({"schema": MEMBER_CLOSE_SCHEMA, "members": rows}, indent=2) + "\n")
|
|
339
371
|
|
|
340
372
|
|
|
341
|
-
def
|
|
373
|
+
def require_origin_branch(checkout: Path, branch: str) -> str:
|
|
374
|
+
"""The commit of the member's fetched default branch `branch`."""
|
|
342
375
|
baseline = _common.run_git(
|
|
343
|
-
checkout, "rev-parse", "--verify", "--quiet", "refs/remotes/origin/
|
|
376
|
+
checkout, "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}^{{commit}}"
|
|
344
377
|
)
|
|
345
378
|
if baseline.returncode != 0:
|
|
346
|
-
raise MembersError("member requires refs/remotes/origin/
|
|
379
|
+
raise MembersError(f"member requires refs/remotes/origin/{branch}; run git fetch origin")
|
|
380
|
+
return baseline.stdout.strip()
|
|
347
381
|
|
|
348
382
|
|
|
349
383
|
def member_ref_prefixes(project: str) -> tuple[str, ...]:
|
|
@@ -405,7 +439,8 @@ def _task_members(coordinator: Path) -> set[str]:
|
|
|
405
439
|
|
|
406
440
|
def lock_build_members(coordinator: Path) -> Optional[list[dict[str, str]]]:
|
|
407
441
|
"""At build start, lock the members tasks name (MEMBERS.md order) and create
|
|
408
|
-
each member's bound branch at its origin
|
|
442
|
+
each member's bound branch at its recorded default branch on origin. None when
|
|
443
|
+
no task names a member."""
|
|
409
444
|
root, state = _coordinator(coordinator)
|
|
410
445
|
named = _task_members(root)
|
|
411
446
|
lock_path = root / LOCK_PATH
|
|
@@ -444,8 +479,8 @@ def lock_build_members(coordinator: Path) -> Optional[list[dict[str, str]]]:
|
|
|
444
479
|
remote = _git(checkout, "config", "--get", "remote.origin.url")
|
|
445
480
|
if remote != member["remote"]:
|
|
446
481
|
raise MembersError(f"member origin changed: {member['remote']} -> {remote}")
|
|
447
|
-
|
|
448
|
-
base =
|
|
482
|
+
default = default_branch(member)
|
|
483
|
+
base = require_origin_branch(checkout, default)
|
|
449
484
|
existing = _common.run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
|
|
450
485
|
if existing.returncode == 0:
|
|
451
486
|
tip = existing.stdout.strip()
|
|
@@ -458,7 +493,7 @@ def lock_build_members(coordinator: Path) -> Optional[list[dict[str, str]]]:
|
|
|
458
493
|
elif _common.run_git(checkout, "merge-base", "--is-ancestor", tip, base).returncode == 0:
|
|
459
494
|
base = tip
|
|
460
495
|
else:
|
|
461
|
-
raise MembersError(f"member {member['name']} branch {branch} has commits not on origin/
|
|
496
|
+
raise MembersError(f"member {member['name']} branch {branch} has commits not on origin/{default}")
|
|
462
497
|
else:
|
|
463
498
|
missing.append((checkout, base))
|
|
464
499
|
entries.append({"name": member["name"], "branch": branch, "base": base})
|
|
@@ -475,6 +510,8 @@ def render(members: Sequence[dict[str, str]]) -> str:
|
|
|
475
510
|
f"Checkout: {member['checkout']}\n"
|
|
476
511
|
f"Remote: {member['remote']}\n"
|
|
477
512
|
f"Integration: {member['integration']}\n"
|
|
513
|
+
# A row that never had the field keeps none, so old rows are not rewritten.
|
|
514
|
+
+ (f"{DEFAULT_BRANCH_FIELD}: {member['default_branch']}\n" if "default_branch" in member else "")
|
|
478
515
|
for member in members
|
|
479
516
|
]
|
|
480
517
|
return "\n".join([HEADER, *sections])
|
|
@@ -488,7 +525,7 @@ def add_member(repo: Path, name: str, checkout: Path, integration: str) -> list[
|
|
|
488
525
|
raise MembersError(f"invalid member name: {name!r}")
|
|
489
526
|
members = read_members(root)
|
|
490
527
|
resolved = checkout.resolve()
|
|
491
|
-
remote = check_member(root, state.project, checkout)
|
|
528
|
+
remote, default = check_member(root, state.project, checkout)
|
|
492
529
|
common = Path(_git(resolved, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
|
|
493
530
|
identity = _remote_identity(remote)
|
|
494
531
|
for member in members:
|
|
@@ -499,7 +536,8 @@ def add_member(repo: Path, name: str, checkout: Path, integration: str) -> list[
|
|
|
499
536
|
raise MembersError(f"member already recorded: {member['name']}")
|
|
500
537
|
_refuse_foreign_marker(resolved, root, state.project, name)
|
|
501
538
|
members.append(
|
|
502
|
-
{"name": name, "checkout": str(resolved), "remote": remote, "integration": integration
|
|
539
|
+
{"name": name, "checkout": str(resolved), "remote": remote, "integration": integration,
|
|
540
|
+
"default_branch": default}
|
|
503
541
|
)
|
|
504
542
|
_write_marker(resolved, root, state.project, name)
|
|
505
543
|
_common.atomic_write(root / ".project" / MEMBERS_FILE, render(members))
|
|
@@ -560,8 +598,9 @@ def create_member(repo: Path, name: str, checkout: Path, integration: str, githu
|
|
|
560
598
|
_common.atomic_write(journal, json.dumps(recorded, indent=2, sort_keys=True) + "\n")
|
|
561
599
|
listed = [member for member in read_members(root) if member["name"] == name]
|
|
562
600
|
if listed:
|
|
563
|
-
if (len(listed) != 1 or listed[0]
|
|
564
|
-
|
|
601
|
+
if (len(listed) != 1 or {**listed[0], "default_branch": default_branch(listed[0])} != {
|
|
602
|
+
"name": name, "checkout": str(resolved), "remote": url,
|
|
603
|
+
"integration": integration, "default_branch": "main"}
|
|
565
604
|
or member_role(resolved) != {"coordinator": root, "project": state.project, "name": name}):
|
|
566
605
|
raise MembersError(f"member already recorded with a different checkout or marker: {name}")
|
|
567
606
|
journal.unlink()
|
|
@@ -610,6 +649,10 @@ def create_member(repo: Path, name: str, checkout: Path, integration: str, githu
|
|
|
610
649
|
cloned = _common.run_git(resolved.parent, "clone", "-q", url, str(resolved))
|
|
611
650
|
if cloned.returncode != 0:
|
|
612
651
|
raise MembersError(f"could not clone {url}: {(cloned.stderr or cloned.stdout).strip()}")
|
|
652
|
+
# ponytail: --create stays main-only, because a new repository uses main;
|
|
653
|
+
# `add` joins an existing repository with any default branch.
|
|
654
|
+
if _common.origin_default_branch(resolved) != "main":
|
|
655
|
+
raise MembersError(f"member remote default must be main: {resolved}")
|
|
613
656
|
recorded["step"] = "join"
|
|
614
657
|
_common.atomic_write(journal, json.dumps(recorded, indent=2, sort_keys=True) + "\n")
|
|
615
658
|
joined = add_member(root, name, resolved, integration)
|
|
@@ -622,7 +665,7 @@ def validate_members(repo: Path) -> list[dict[str, str]]:
|
|
|
622
665
|
members = read_members(root)
|
|
623
666
|
for member in members:
|
|
624
667
|
checkout = Path(member["checkout"])
|
|
625
|
-
check_member(root, state.project, checkout, member
|
|
668
|
+
check_member(root, state.project, checkout, member, refuse_reserved_refs=False)
|
|
626
669
|
try:
|
|
627
670
|
role = member_role(checkout)
|
|
628
671
|
except MembersError as error:
|
|
@@ -641,7 +684,7 @@ def repair_members(repo: Path) -> list[dict[str, str]]:
|
|
|
641
684
|
members = read_members(root)
|
|
642
685
|
for member in members:
|
|
643
686
|
checkout = Path(member["checkout"])
|
|
644
|
-
check_member(root, state.project, checkout, member
|
|
687
|
+
check_member(root, state.project, checkout, member, refuse_reserved_refs=False)
|
|
645
688
|
_refuse_foreign_marker(checkout.resolve(), root, state.project, member["name"])
|
|
646
689
|
for member in members:
|
|
647
690
|
checkout = Path(member["checkout"]).resolve()
|
|
@@ -196,16 +196,27 @@ def task_settings(path):
|
|
|
196
196
|
|
|
197
197
|
def native_record(args):
|
|
198
198
|
"""The parent owns lifecycle; release requires its recorded terminal-result receipt."""
|
|
199
|
+
# A relative path would resolve against cwd, which may be the immutable runtime store.
|
|
200
|
+
if not args.record.is_absolute():
|
|
201
|
+
raise PolicyError(f'--record must be an absolute path: {args.record}')
|
|
199
202
|
record = args.record.resolve()
|
|
203
|
+
missing = f'release requires an existing record and terminal-result evidence: {record}'
|
|
204
|
+
# Check before log_lock creates the record directory and its lock file.
|
|
205
|
+
if args.action == 'release' and not record.exists():
|
|
206
|
+
raise PolicyError(missing)
|
|
200
207
|
with log_lock({'log': str(record)}):
|
|
201
208
|
data = read_json(record) if record.exists() else None
|
|
202
209
|
identity = {'repo': str(args.repo.resolve()), 'project_dir': args.project_dir,
|
|
203
210
|
'scope': args.scope, 'assignment': args.assignment, 'role': args.role}
|
|
204
211
|
if data is not None and data.get('identity') != identity:
|
|
205
|
-
|
|
212
|
+
recorded = data.get('identity')
|
|
213
|
+
recorded = recorded if isinstance(recorded, dict) else {}
|
|
214
|
+
differing = ', '.join(f'{field}: {value}, record has {recorded.get(field)}'
|
|
215
|
+
for field, value in identity.items() if recorded.get(field) != value)
|
|
216
|
+
raise PolicyError(f'record belongs to another assignment or milestone/track/cycle ({differing})')
|
|
206
217
|
if args.action == 'release':
|
|
207
218
|
if data is None or not args.terminal_result:
|
|
208
|
-
raise PolicyError(
|
|
219
|
+
raise PolicyError(missing)
|
|
209
220
|
data.update(active=False, terminal_result=args.terminal_result)
|
|
210
221
|
else:
|
|
211
222
|
if args.capabilities is None:
|