@opengsd/gsd-path 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -207
- package/CHANGELOG.md +281 -0
- package/DOCS.md +13 -7
- package/FULL.md +9 -3
- package/GUIDE.md +1 -1
- package/HOOKS.md +9 -6
- package/QUICK.md +4 -1
- package/README.md +63 -11
- package/RUNTIME.md +1 -1
- package/UPDATE.md +57 -8
- package/WORKFLOW.md +22 -6
- package/package.json +9 -8
- package/scripts/_common.py +472 -19
- package/scripts/archive_milestone.py +146 -73
- package/scripts/bootstrap_repository.py +8 -5
- package/scripts/build_recovery.py +4 -1
- package/scripts/build_state.py +66 -23
- package/scripts/check_docs_audit.py +14 -3
- package/scripts/check_handoffs.py +116 -20
- package/scripts/check_task_briefs.py +60 -8
- package/scripts/check_update.py +2 -2
- package/scripts/core_hook_gate.py +10 -1
- package/scripts/core_hook_settings.py +11 -4
- package/scripts/detect_project.py +345 -15
- package/scripts/discussion_records.py +19 -13
- package/scripts/discussion_validate.py +66 -21
- package/scripts/dispatch_driver.py +111 -26
- package/scripts/git_guard.py +116 -29
- package/scripts/guard_hook.py +151 -29
- package/scripts/install.mjs +54 -38
- package/scripts/install.py +562 -41
- package/scripts/integration.py +434 -24
- package/scripts/isolation.py +1077 -28
- package/scripts/lean_verification.py +99 -19
- package/scripts/loop_run.py +20 -30
- package/scripts/members.py +704 -0
- package/scripts/migrate_core.py +2 -2
- package/scripts/path_config.py +2 -2
- package/scripts/pipeline_diagnose.py +82 -31
- package/scripts/pipeline_git.py +61 -22
- package/scripts/pipeline_state.py +176 -54
- package/scripts/pipeline_undo.py +117 -22
- package/scripts/promote_lookahead.py +1 -1
- package/scripts/review_panel.py +1 -1
- package/scripts/runtime_store.py +35 -9
- package/scripts/skill-resources.json +28 -3
- package/scripts/state_checkpoint.py +140 -31
- package/scripts/state_promote.py +2 -0
- package/scripts/status_runtime.py +36 -6
- package/scripts/sync_skill_resources.py +8 -7
- package/scripts/token_budget.py +12 -8
- package/scripts/workflow_run.py +103 -29
- package/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path/BUILD.md +7 -1
- package/skills/gsd-path/DECIDE.md +11 -4
- package/skills/gsd-path/DEFINE.md +8 -1
- package/skills/gsd-path/DOCS-AUDIT.md +6 -4
- package/skills/gsd-path/INSPECT.md +5 -4
- package/skills/gsd-path/PLAN.md +7 -1
- package/skills/gsd-path/RESEARCH.md +2 -1
- package/skills/gsd-path/ROADMAP.md +2 -1
- package/skills/gsd-path/SHIP.md +27 -10
- package/skills/gsd-path/SKILL.md +45 -1
- package/skills/gsd-path/references/build-native.md +47 -14
- package/skills/gsd-path/references/codebase-mapper.md +2 -1
- package/skills/gsd-path/references/docs-auditor.md +2 -1
- package/skills/gsd-path/references/operating-rules.md +214 -0
- package/skills/gsd-path/references/planner.md +8 -0
- package/skills/gsd-path/references/reviewer-wave.md +3 -1
- 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/scripts/_common.py +472 -19
- package/skills/gsd-path/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path/scripts/bootstrap_repository.py +8 -5
- package/skills/gsd-path/scripts/build_recovery.py +4 -1
- package/skills/gsd-path/scripts/build_state.py +66 -23
- package/skills/gsd-path/scripts/check_docs_audit.py +14 -3
- package/skills/gsd-path/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path/scripts/check_update.py +2 -2
- package/skills/gsd-path/scripts/detect_project.py +345 -15
- package/skills/gsd-path/scripts/discussion_records.py +19 -13
- package/skills/gsd-path/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path/scripts/dispatch_driver.py +111 -26
- package/skills/gsd-path/scripts/git_guard.py +116 -29
- package/skills/gsd-path/scripts/integration.py +434 -24
- package/skills/gsd-path/scripts/isolation.py +1077 -28
- package/skills/gsd-path/scripts/lean_verification.py +99 -19
- package/skills/gsd-path/scripts/loop_run.py +20 -30
- package/skills/gsd-path/scripts/members.py +704 -0
- package/skills/gsd-path/scripts/path_config.py +2 -2
- package/skills/gsd-path/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path/scripts/review_panel.py +1 -1
- package/skills/gsd-path/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path/scripts/state_promote.py +2 -0
- package/skills/gsd-path/scripts/status_runtime.py +36 -6
- package/skills/gsd-path/scripts/token_budget.py +12 -8
- package/skills/gsd-path/scripts/workflow_run.py +103 -29
- package/skills/gsd-path/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path/templates/codebase.md +1 -1
- package/skills/gsd-path/templates/docs-audit.md +1 -1
- package/skills/gsd-path/templates/final-review.md +5 -2
- package/skills/gsd-path/templates/gap-review.md +2 -1
- package/skills/gsd-path/templates/task.md +1 -0
- package/skills/gsd-path/templates/wave-review.md +3 -1
- package/skills/gsd-path-build/SKILL.md +7 -1
- package/skills/gsd-path-build/references/build-native.md +47 -14
- package/skills/gsd-path-build/references/operating-rules.md +214 -0
- package/skills/gsd-path-build/references/reviewer-wave.md +3 -1
- 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-build/scripts/_common.py +472 -19
- package/skills/gsd-path-build/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-build/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-build/scripts/build_state.py +66 -23
- package/skills/gsd-path-build/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-build/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-build/scripts/detect_project.py +345 -15
- package/skills/gsd-path-build/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-build/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-build/scripts/dispatch_driver.py +111 -26
- package/skills/gsd-path-build/scripts/integration.py +434 -24
- package/skills/gsd-path-build/scripts/isolation.py +1077 -28
- package/skills/gsd-path-build/scripts/loop_run.py +20 -30
- package/skills/gsd-path-build/scripts/members.py +704 -0
- package/skills/gsd-path-build/scripts/path_config.py +2 -2
- package/skills/gsd-path-build/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-build/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-build/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-build/scripts/review_panel.py +1 -1
- package/skills/gsd-path-build/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-build/scripts/state_promote.py +2 -0
- package/skills/gsd-path-build/scripts/token_budget.py +12 -8
- package/skills/gsd-path-build/scripts/workflow_run.py +103 -29
- package/skills/gsd-path-build/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-build/templates/task.md +1 -0
- package/skills/gsd-path-build/templates/wave-review.md +3 -1
- package/skills/gsd-path-decide/SKILL.md +11 -4
- package/skills/gsd-path-decide/references/operating-rules.md +214 -0
- package/skills/gsd-path-decide/scripts/_common.py +472 -19
- package/skills/gsd-path-decide/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-decide/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-decide/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-decide/scripts/detect_project.py +345 -15
- package/skills/gsd-path-decide/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-decide/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-decide/scripts/integration.py +434 -24
- package/skills/gsd-path-decide/scripts/isolation.py +1077 -28
- package/skills/gsd-path-decide/scripts/loop_run.py +20 -30
- package/skills/gsd-path-decide/scripts/members.py +704 -0
- package/skills/gsd-path-decide/scripts/path_config.py +2 -2
- package/skills/gsd-path-decide/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-decide/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-decide/scripts/review_panel.py +1 -1
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-decide/scripts/state_promote.py +2 -0
- package/skills/gsd-path-decide/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-define/SKILL.md +8 -1
- package/skills/gsd-path-define/references/operating-rules.md +214 -0
- package/skills/gsd-path-define/scripts/_common.py +472 -19
- package/skills/gsd-path-define/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-define/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-define/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-define/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-define/scripts/detect_project.py +345 -15
- package/skills/gsd-path-define/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-define/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-define/scripts/integration.py +434 -24
- package/skills/gsd-path-define/scripts/isolation.py +1077 -28
- package/skills/gsd-path-define/scripts/loop_run.py +20 -30
- package/skills/gsd-path-define/scripts/members.py +704 -0
- package/skills/gsd-path-define/scripts/path_config.py +2 -2
- package/skills/gsd-path-define/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-define/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-define/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-define/scripts/review_panel.py +1 -1
- package/skills/gsd-path-define/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-define/scripts/state_promote.py +2 -0
- package/skills/gsd-path-define/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-discuss/SKILL.md +2 -1
- package/skills/gsd-path-discuss/references/operating-rules.md +214 -0
- package/skills/gsd-path-discuss/scripts/_common.py +472 -19
- package/skills/gsd-path-discuss/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-discuss/scripts/loop_run.py +20 -30
- package/skills/gsd-path-discuss/scripts/path_config.py +2 -2
- package/skills/gsd-path-discuss/scripts/review_panel.py +1 -1
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-docs-audit/SKILL.md +6 -4
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +2 -1
- package/skills/gsd-path-docs-audit/references/operating-rules.md +214 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +472 -19
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +14 -3
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +345 -15
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-docs-audit/scripts/integration.py +434 -24
- package/skills/gsd-path-docs-audit/scripts/isolation.py +1077 -28
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +20 -30
- package/skills/gsd-path-docs-audit/scripts/members.py +704 -0
- package/skills/gsd-path-docs-audit/scripts/path_config.py +2 -2
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-docs-audit/scripts/review_panel.py +1 -1
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +2 -0
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +1 -1
- package/skills/gsd-path-forensics/SKILL.md +2 -1
- package/skills/gsd-path-forensics/references/operating-rules.md +214 -0
- package/skills/gsd-path-forensics/scripts/_common.py +472 -19
- package/skills/gsd-path-forensics/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-forensics/scripts/loop_run.py +20 -30
- package/skills/gsd-path-forensics/scripts/path_config.py +2 -2
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +82 -31
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +117 -22
- package/skills/gsd-path-forensics/scripts/review_panel.py +1 -1
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-inspect/SKILL.md +5 -4
- package/skills/gsd-path-inspect/references/codebase-mapper.md +2 -1
- package/skills/gsd-path-inspect/references/docs-auditor.md +2 -1
- package/skills/gsd-path-inspect/references/operating-rules.md +214 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/_common.py +472 -19
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-inspect/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +14 -3
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-inspect/scripts/detect_project.py +345 -15
- package/skills/gsd-path-inspect/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-inspect/scripts/integration.py +434 -24
- package/skills/gsd-path-inspect/scripts/isolation.py +1077 -28
- package/skills/gsd-path-inspect/scripts/loop_run.py +20 -30
- package/skills/gsd-path-inspect/scripts/members.py +704 -0
- package/skills/gsd-path-inspect/scripts/path_config.py +2 -2
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-inspect/scripts/review_panel.py +1 -1
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-inspect/scripts/state_promote.py +2 -0
- package/skills/gsd-path-inspect/scripts/workflow_run.py +103 -29
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-inspect/templates/codebase.md +1 -1
- package/skills/gsd-path-inspect/templates/docs-audit.md +1 -1
- package/skills/gsd-path-loop/SKILL.md +2 -1
- package/skills/gsd-path-loop/references/operating-rules.md +214 -0
- package/skills/gsd-path-loop/scripts/_common.py +472 -19
- package/skills/gsd-path-loop/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-loop/scripts/loop_run.py +20 -30
- package/skills/gsd-path-loop/scripts/path_config.py +2 -2
- package/skills/gsd-path-loop/scripts/review_panel.py +1 -1
- package/skills/gsd-path-loop/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-migrate/SKILL.md +2 -1
- package/skills/gsd-path-migrate/references/operating-rules.md +214 -0
- package/skills/gsd-path-migrate/scripts/_common.py +472 -19
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +10 -1
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +11 -4
- package/skills/gsd-path-migrate/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-migrate/scripts/loop_run.py +20 -30
- package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -2
- package/skills/gsd-path-migrate/scripts/path_config.py +2 -2
- package/skills/gsd-path-migrate/scripts/review_panel.py +1 -1
- package/skills/gsd-path-plan/SKILL.md +7 -1
- package/skills/gsd-path-plan/references/operating-rules.md +214 -0
- package/skills/gsd-path-plan/references/planner.md +8 -0
- package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/_common.py +472 -19
- package/skills/gsd-path-plan/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-plan/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-plan/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-plan/scripts/detect_project.py +345 -15
- package/skills/gsd-path-plan/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-plan/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-plan/scripts/integration.py +434 -24
- package/skills/gsd-path-plan/scripts/isolation.py +1077 -28
- package/skills/gsd-path-plan/scripts/loop_run.py +20 -30
- package/skills/gsd-path-plan/scripts/members.py +704 -0
- package/skills/gsd-path-plan/scripts/path_config.py +2 -2
- package/skills/gsd-path-plan/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-plan/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-plan/scripts/review_panel.py +1 -1
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-plan/scripts/state_promote.py +2 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +103 -29
- package/skills/gsd-path-plan/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-plan/templates/task.md +1 -0
- package/skills/gsd-path-research/SKILL.md +2 -1
- package/skills/gsd-path-research/references/operating-rules.md +214 -0
- package/skills/gsd-path-research/scripts/_common.py +472 -19
- package/skills/gsd-path-research/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-research/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-research/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-research/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-research/scripts/detect_project.py +345 -15
- package/skills/gsd-path-research/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-research/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-research/scripts/integration.py +434 -24
- package/skills/gsd-path-research/scripts/isolation.py +1077 -28
- package/skills/gsd-path-research/scripts/loop_run.py +20 -30
- package/skills/gsd-path-research/scripts/members.py +704 -0
- package/skills/gsd-path-research/scripts/path_config.py +2 -2
- package/skills/gsd-path-research/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-research/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-research/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-research/scripts/review_panel.py +1 -1
- package/skills/gsd-path-research/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-research/scripts/state_promote.py +2 -0
- package/skills/gsd-path-research/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-roadmap/SKILL.md +2 -1
- package/skills/gsd-path-roadmap/references/operating-rules.md +214 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +472 -19
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-roadmap/scripts/detect_project.py +345 -15
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-roadmap/scripts/integration.py +434 -24
- package/skills/gsd-path-roadmap/scripts/isolation.py +1077 -28
- package/skills/gsd-path-roadmap/scripts/loop_run.py +20 -30
- package/skills/gsd-path-roadmap/scripts/members.py +704 -0
- package/skills/gsd-path-roadmap/scripts/path_config.py +2 -2
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-roadmap/scripts/review_panel.py +1 -1
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-roadmap/scripts/state_promote.py +2 -0
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-ship/SKILL.md +27 -10
- package/skills/gsd-path-ship/references/operating-rules.md +214 -0
- package/skills/gsd-path-ship/references/reviewer-wave.md +3 -1
- package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/_common.py +472 -19
- package/skills/gsd-path-ship/scripts/archive_milestone.py +146 -73
- package/skills/gsd-path-ship/scripts/build_recovery.py +4 -1
- package/skills/gsd-path-ship/scripts/build_state.py +66 -23
- package/skills/gsd-path-ship/scripts/check_handoffs.py +116 -20
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +60 -8
- package/skills/gsd-path-ship/scripts/detect_project.py +345 -15
- package/skills/gsd-path-ship/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-ship/scripts/discussion_validate.py +66 -21
- package/skills/gsd-path-ship/scripts/integration.py +434 -24
- package/skills/gsd-path-ship/scripts/isolation.py +1077 -28
- package/skills/gsd-path-ship/scripts/lean_verification.py +99 -19
- package/skills/gsd-path-ship/scripts/loop_run.py +20 -30
- package/skills/gsd-path-ship/scripts/members.py +704 -0
- package/skills/gsd-path-ship/scripts/path_config.py +2 -2
- package/skills/gsd-path-ship/scripts/pipeline_git.py +61 -22
- package/skills/gsd-path-ship/scripts/pipeline_state.py +176 -54
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-ship/scripts/review_panel.py +1 -1
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +140 -31
- package/skills/gsd-path-ship/scripts/state_promote.py +2 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +103 -29
- package/skills/gsd-path-ship/scripts/worktree_paths.py +18 -8
- package/skills/gsd-path-ship/templates/final-review.md +5 -2
- package/skills/gsd-path-ship/templates/gap-review.md +2 -1
- package/skills/gsd-path-ship/templates/wave-review.md +3 -1
- package/skills/gsd-path-undo/SKILL.md +2 -1
- package/skills/gsd-path-undo/references/operating-rules.md +214 -0
- package/skills/gsd-path-undo/scripts/_common.py +472 -19
- package/skills/gsd-path-undo/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-undo/scripts/loop_run.py +20 -30
- package/skills/gsd-path-undo/scripts/path_config.py +2 -2
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +117 -22
- package/skills/gsd-path-undo/scripts/review_panel.py +1 -1
- package/skills/gsd-path-undo/scripts/worktree_paths.py +18 -8
- package/skills/path/BUILD.md +7 -1
- package/skills/path/DECIDE.md +11 -4
- package/skills/path/DEFINE.md +8 -1
- package/skills/path/DOCS-AUDIT.md +6 -4
- package/skills/path/INSPECT.md +5 -4
- package/skills/path/PLAN.md +7 -1
- package/skills/path/RESEARCH.md +2 -1
- package/skills/path/ROADMAP.md +2 -1
- package/skills/path/SHIP.md +27 -10
- package/skills/path/SKILL.md +45 -1
- package/skills/path/references/build-native.md +47 -14
- package/skills/path/references/codebase-mapper.md +2 -1
- package/skills/path/references/docs-auditor.md +2 -1
- package/skills/path/references/operating-rules.md +214 -0
- package/skills/path/references/planner.md +8 -0
- package/skills/path/references/reviewer-wave.md +3 -1
- package/skills/path/scripts/_common.py +472 -19
- package/skills/path/scripts/archive_milestone.py +146 -73
- package/skills/path/scripts/bootstrap_repository.py +8 -5
- package/skills/path/scripts/build_recovery.py +4 -1
- package/skills/path/scripts/build_state.py +66 -23
- package/skills/path/scripts/check_docs_audit.py +14 -3
- package/skills/path/scripts/check_handoffs.py +116 -20
- package/skills/path/scripts/check_task_briefs.py +60 -8
- package/skills/path/scripts/check_update.py +2 -2
- package/skills/path/scripts/detect_project.py +345 -15
- package/skills/path/scripts/discussion_records.py +19 -13
- package/skills/path/scripts/discussion_validate.py +66 -21
- package/skills/path/scripts/dispatch_driver.py +111 -26
- package/skills/path/scripts/git_guard.py +116 -29
- package/skills/path/scripts/integration.py +434 -24
- package/skills/path/scripts/isolation.py +1077 -28
- package/skills/path/scripts/lean_verification.py +99 -19
- package/skills/path/scripts/loop_run.py +20 -30
- package/skills/path/scripts/members.py +704 -0
- package/skills/path/scripts/path_config.py +2 -2
- package/skills/path/scripts/pipeline_git.py +61 -22
- package/skills/path/scripts/pipeline_state.py +176 -54
- package/skills/path/scripts/promote_lookahead.py +1 -1
- package/skills/path/scripts/review_panel.py +1 -1
- package/skills/path/scripts/state_checkpoint.py +140 -31
- package/skills/path/scripts/state_promote.py +2 -0
- package/skills/path/scripts/status_runtime.py +36 -6
- package/skills/path/scripts/token_budget.py +12 -8
- package/skills/path/scripts/workflow_run.py +103 -29
- package/skills/path/scripts/worktree_paths.py +18 -8
- package/skills/path/templates/codebase.md +1 -1
- package/skills/path/templates/docs-audit.md +1 -1
- package/skills/path/templates/final-review.md +5 -2
- package/skills/path/templates/gap-review.md +2 -1
- package/skills/path/templates/task.md +1 -0
- package/skills/path/templates/wave-review.md +3 -1
package/scripts/install.py
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
"""Install GSD Path skills for supported coding agents."""
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
+
import difflib
|
|
5
6
|
import errno
|
|
6
7
|
import hashlib
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
import re
|
|
11
|
+
import shlex
|
|
10
12
|
import shutil
|
|
11
13
|
import subprocess
|
|
12
14
|
import sys
|
|
13
15
|
import tempfile
|
|
16
|
+
import time
|
|
14
17
|
from dataclasses import dataclass, field
|
|
15
18
|
from pathlib import Path
|
|
16
19
|
from typing import Callable, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
|
|
@@ -45,6 +48,10 @@ LOCAL_ROOTS = local_roots_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
|
|
|
45
48
|
SKILL_NAMES = skill_names_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
|
|
46
49
|
SKILL_ALIASES = dict(sync_skill_resources.RESOURCE_MANIFEST["skill_aliases"])
|
|
47
50
|
ROUTER_ALIASES = dict(sync_skill_resources.RESOURCE_MANIFEST["router_aliases"])
|
|
51
|
+
LOCAL_EDITS_NOTE = (
|
|
52
|
+
"; local edits inside these skills are not carried forward "
|
|
53
|
+
"(see UPDATE.md#local-additions)"
|
|
54
|
+
)
|
|
48
55
|
CLAUDE_BRIDGE = "@../AGENTS.md\n@../WORKFLOW.md\n"
|
|
49
56
|
HOOKS_DIRECTORY = ".gsd-path"
|
|
50
57
|
GUARD_SCRIPTS = ("guard_hook.py", "git_guard.py")
|
|
@@ -56,6 +63,21 @@ PROJECT_STATUS_MARKER = "gsd-path project status launcher"
|
|
|
56
63
|
INSTALL_LOCK_NAME = ".gsd-path-install-lock"
|
|
57
64
|
INSTALL_LOCK_OWNER = "owner.json"
|
|
58
65
|
INSTALL_LOCK_SCHEMA = "gsd-path/install-lock/v2"
|
|
66
|
+
AGENTS_BEGIN = "<!-- gsd-path:begin -->"
|
|
67
|
+
AGENTS_END = "<!-- gsd-path:end -->"
|
|
68
|
+
# Codex default project_doc_max_bytes; it silently cuts the rest of AGENTS.md.
|
|
69
|
+
CODEX_DOC_LIMIT = 32768
|
|
70
|
+
# sha256 of each released whole-file AGENTS.md (v1.0.0, v1.1.0, v1.2.0-v1.3.1).
|
|
71
|
+
RELEASED_AGENTS = frozenset(
|
|
72
|
+
{
|
|
73
|
+
"dfc969299e10c4430742c66c273436136a813d75e332cecf9403f94c904b330b",
|
|
74
|
+
"3a5c4ed62f397725acce962a7fdfafd5a3296f0a784acafc55454b5304a4eb7c",
|
|
75
|
+
"831de61d4b4b624319930162d45ac88c49cc13ca22894f37733ba68e6503f972",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
LEGACY_AGENTS_TITLE = "# AGENTS.md — Operating Rules for the GSD Path Pipeline"
|
|
79
|
+
# Every released whole-file AGENTS.md carries this marker.
|
|
80
|
+
LEGACY_AGENTS_MARKER = "<!-- gsd-path/plain-prompt-reentry/v1 -->"
|
|
59
81
|
PROJECT_CONTRACTS = (
|
|
60
82
|
("AGENTS.md", "## Plain-prompt re-entry"),
|
|
61
83
|
("WORKFLOW.md", "### Plain-prompt re-entry"),
|
|
@@ -231,19 +253,149 @@ GIT_HOOKS = (
|
|
|
231
253
|
("pre-push", pre_push_hook),
|
|
232
254
|
)
|
|
233
255
|
GIT_HOOK_NAMES = tuple(name for name, _ in GIT_HOOKS)
|
|
256
|
+
MEMBER_HOOK_MARKER = "gsd-path member guard"
|
|
257
|
+
CHAINED_HOOK_SUFFIX = ".gsd-path-chained"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def member_hook(interpreter: str, name: str) -> str:
|
|
261
|
+
"""A member hook finds its coordinator through the member marker, so a
|
|
262
|
+
moved coordinator needs only `members.py repair`, then runs any chained hook."""
|
|
263
|
+
guard = f'{interpreter} "$coordinator/{HOOKS_DIRECTORY}/git_guard.py" {name} "$@"'
|
|
264
|
+
lines = [
|
|
265
|
+
"#!/bin/sh",
|
|
266
|
+
f"# {MEMBER_HOOK_MARKER}: runs the coordinator's git_guard.py named by this",
|
|
267
|
+
"# repository's member marker, then any chained hook.",
|
|
268
|
+
"common=$(git rev-parse --path-format=absolute --git-common-dir) || exit 1",
|
|
269
|
+
f"coordinator=$({interpreter} -c 'import json, sys; print(json.load(open(sys.argv[1]))[\"coordinator\"])' "
|
|
270
|
+
'"$common/gsd-path/member.json") || {',
|
|
271
|
+
' echo "gsd-path guard: member marker is unreadable; run members.py repair --repo <coordinator>" >&2',
|
|
272
|
+
" exit 1",
|
|
273
|
+
"}",
|
|
274
|
+
f'chained="$(dirname "$0")/{name}{CHAINED_HOOK_SUFFIX}"',
|
|
275
|
+
]
|
|
276
|
+
if name == "pre-push":
|
|
277
|
+
# Git gives pre-push its ref updates on stdin; both hooks need them.
|
|
278
|
+
lines += [
|
|
279
|
+
"input=$(cat)",
|
|
280
|
+
'[ -n "$input" ] && input="$input\n"',
|
|
281
|
+
f'printf %s "$input" | {guard} || exit 1',
|
|
282
|
+
'if [ -x "$chained" ]; then printf %s "$input" | exec "$chained" "$@"; fi',
|
|
283
|
+
]
|
|
284
|
+
else:
|
|
285
|
+
lines += [f"{guard} || exit 1", 'if [ -x "$chained" ]; then exec "$chained" "$@"; fi']
|
|
286
|
+
return "\n".join(lines) + "\n"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def install_member_hooks(coordinator: Path, member: Path, dry_run: bool = False) -> List[str]:
|
|
290
|
+
"""Install git hooks that hold a member repo to its coordinator's guard.
|
|
291
|
+
|
|
292
|
+
Unmanaged hooks are kept as `<hook>.gsd-path-chained` and run after the
|
|
293
|
+
guard passes; the member's own single-repo Path hooks are replaced.
|
|
294
|
+
"""
|
|
295
|
+
_validate_project_git_root(member)
|
|
296
|
+
# Imported here so other installer commands keep their small import closure.
|
|
297
|
+
try:
|
|
298
|
+
from . import members
|
|
299
|
+
except ImportError:
|
|
300
|
+
import members
|
|
301
|
+
try:
|
|
302
|
+
role = members.member_role(member)
|
|
303
|
+
except members.MembersError as error:
|
|
304
|
+
raise InstallerError(str(error)) from error
|
|
305
|
+
if role is None or not _same_path(Path(role["coordinator"]), coordinator):
|
|
306
|
+
raise InstallerError(f"{member} is not a member of {coordinator}; run members.py add first")
|
|
307
|
+
guard = coordinator / HOOKS_DIRECTORY / "git_guard.py"
|
|
308
|
+
if guard.is_symlink() or not _is_managed_guard_script(guard):
|
|
309
|
+
raise InstallerError(f"coordinator guard is not installed: {guard}")
|
|
310
|
+
hooks_dir = _resolve_git_hooks_path(member)
|
|
311
|
+
if hooks_dir is None:
|
|
312
|
+
raise InstallerError(f"cannot resolve the git hooks directory of {member}")
|
|
313
|
+
common = Path(subprocess.run(
|
|
314
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
315
|
+
cwd=os.fspath(member), capture_output=True, encoding="utf-8", errors="replace", check=True,
|
|
316
|
+
).stdout.strip()).resolve()
|
|
317
|
+
resolved = hooks_dir.resolve()
|
|
318
|
+
worktree_output = subprocess.run(
|
|
319
|
+
["git", "worktree", "list", "--porcelain", "-z"],
|
|
320
|
+
cwd=os.fspath(member), capture_output=True, check=True,
|
|
321
|
+
).stdout
|
|
322
|
+
worktrees = [
|
|
323
|
+
Path(os.fsdecode(field[len(b"worktree "):])).resolve()
|
|
324
|
+
for field in worktree_output.split(b"\0") if field.startswith(b"worktree ")
|
|
325
|
+
]
|
|
326
|
+
if not worktrees or (
|
|
327
|
+
any(resolved.is_relative_to(root) for root in worktrees)
|
|
328
|
+
and not resolved.is_relative_to(common)
|
|
329
|
+
):
|
|
330
|
+
raise InstallerError(
|
|
331
|
+
f"git hooks path is inside the member worktree: {hooks_dir}; "
|
|
332
|
+
"point core.hooksPath outside the worktree or unset it"
|
|
333
|
+
)
|
|
334
|
+
interpreter = _required_python_runtime("--member-of")
|
|
335
|
+
plan = []
|
|
336
|
+
for name in GIT_HOOK_NAMES:
|
|
337
|
+
hook, chained = hooks_dir / name, hooks_dir / f"{name}{CHAINED_HOOK_SUFFIX}"
|
|
338
|
+
if hook.is_symlink() or chained.is_symlink():
|
|
339
|
+
raise InstallerError(f"refusing a symlinked git hook: {hook}")
|
|
340
|
+
if _lexists(hook) and not hook.is_file():
|
|
341
|
+
raise InstallerError(f"git hook is not a regular file: {hook}")
|
|
342
|
+
text = hook.read_text(encoding="utf-8", errors="replace") if hook.is_file() else None
|
|
343
|
+
chain = (
|
|
344
|
+
text is not None
|
|
345
|
+
and MEMBER_HOOK_MARKER not in text
|
|
346
|
+
and not _is_managed_git_hook_content(text)
|
|
347
|
+
)
|
|
348
|
+
if chain and _lexists(chained):
|
|
349
|
+
raise InstallerError(f"cannot chain {hook}: {chained} already exists")
|
|
350
|
+
plan.append((name, hook, chained, chain))
|
|
351
|
+
lines = []
|
|
352
|
+
for name, hook, chained, chain in plan:
|
|
353
|
+
note = " (keeps the existing hook as a chained hook)" if chain else ""
|
|
354
|
+
lines.append(f"member hook: {hook}{note}" + (" (dry run)" if dry_run else ""))
|
|
355
|
+
if dry_run:
|
|
356
|
+
return lines
|
|
357
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
staged = []
|
|
359
|
+
try:
|
|
360
|
+
for name, _, _, _ in plan:
|
|
361
|
+
temporary = hooks_dir / f".{name}.gsd-path-tmp"
|
|
362
|
+
# O_EXCL already refuses any existing name; O_NOFOLLOW (absent on
|
|
363
|
+
# Windows) adds defense in depth where it exists.
|
|
364
|
+
flags = (os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
365
|
+
| getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0))
|
|
366
|
+
fd = os.open(temporary, flags, 0o755)
|
|
367
|
+
staged.append(temporary)
|
|
368
|
+
with os.fdopen(fd, "wb") as output:
|
|
369
|
+
if hasattr(os, "fchmod"): # Windows before 3.13; Git for Windows needs no exec bit.
|
|
370
|
+
os.fchmod(output.fileno(), 0o755)
|
|
371
|
+
output.write(member_hook(interpreter, name).encode("utf-8"))
|
|
372
|
+
for name, hook, chained, chain in plan:
|
|
373
|
+
if chain:
|
|
374
|
+
os.replace(hook, chained)
|
|
375
|
+
os.replace(hooks_dir / f".{name}.gsd-path-tmp", hook)
|
|
376
|
+
finally:
|
|
377
|
+
for temporary in staged:
|
|
378
|
+
temporary.unlink(missing_ok=True)
|
|
379
|
+
return lines
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# Every interpreter a managed hook may be pinned to; each is a command prefix.
|
|
383
|
+
INTERPRETER_CANDIDATES = ("python3", "python", "py -3")
|
|
234
384
|
|
|
235
385
|
|
|
236
386
|
def _detect_python_interpreter() -> Optional[str]:
|
|
237
|
-
"""Probe for a runnable interpreter (python3, then python).
|
|
387
|
+
"""Probe for a runnable interpreter (python3, then python, then py -3).
|
|
238
388
|
|
|
239
389
|
Emitted hooks must never hard-code an interpreter that does not exist on
|
|
240
|
-
this machine (python3 is typically absent on Windows
|
|
390
|
+
this machine (python3 is typically absent on Windows, and python.org
|
|
391
|
+
installs may put only the py launcher on PATH). The result is a command
|
|
392
|
+
prefix; split it before spawning.
|
|
241
393
|
"""
|
|
242
|
-
for candidate in
|
|
394
|
+
for candidate in INTERPRETER_CANDIDATES:
|
|
243
395
|
try:
|
|
244
396
|
result = subprocess.run(
|
|
245
397
|
[
|
|
246
|
-
candidate,
|
|
398
|
+
*candidate.split(),
|
|
247
399
|
"-B",
|
|
248
400
|
"-c",
|
|
249
401
|
"import sys; raise SystemExit(sys.version_info < (3, 9))",
|
|
@@ -277,7 +429,7 @@ def _resolve_git_hooks_path(project: "Path") -> Optional["Path"]:
|
|
|
277
429
|
["git", "rev-parse", "--show-toplevel", "--git-path", "hooks"],
|
|
278
430
|
cwd=os.fspath(project),
|
|
279
431
|
capture_output=True,
|
|
280
|
-
|
|
432
|
+
encoding="utf-8", errors="replace",
|
|
281
433
|
check=False,
|
|
282
434
|
)
|
|
283
435
|
except OSError:
|
|
@@ -534,7 +686,7 @@ def _stamp_installed_versions(staged_root: Path, version: Optional[str]) -> None
|
|
|
534
686
|
if not version:
|
|
535
687
|
return
|
|
536
688
|
for name in {"gsd-path", *ROUTER_ALIASES}:
|
|
537
|
-
(staged_root / name / "VERSION").
|
|
689
|
+
(staged_root / name / "VERSION").write_bytes(f"{version}\n".encode("utf-8"))
|
|
538
690
|
|
|
539
691
|
|
|
540
692
|
def _lexists(path: Path) -> bool:
|
|
@@ -670,7 +822,7 @@ def stage_target(source_root: Path, target: str, staged_root: Path) -> None:
|
|
|
670
822
|
encoding="utf-8"
|
|
671
823
|
)
|
|
672
824
|
for dispatch in sorted(staged_root.glob("*/references/dispatch.md")):
|
|
673
|
-
dispatch.
|
|
825
|
+
dispatch.write_bytes(adapter.encode("utf-8"))
|
|
674
826
|
if target == "cursor":
|
|
675
827
|
shutil.copy2(
|
|
676
828
|
source_root / "platforms" / "cursor" / "agent.md",
|
|
@@ -687,9 +839,8 @@ def stage_target(source_root: Path, target: str, staged_root: Path) -> None:
|
|
|
687
839
|
path_invocation = "path" if target == "opencode" else "/path"
|
|
688
840
|
for name in SKILL_NAMES:
|
|
689
841
|
entrypoint = staged_root / name / "SKILL.md"
|
|
690
|
-
entrypoint.
|
|
691
|
-
_augment_frontmatter(entrypoint.read_text(encoding="utf-8"), target),
|
|
692
|
-
encoding="utf-8",
|
|
842
|
+
entrypoint.write_bytes(
|
|
843
|
+
_augment_frontmatter(entrypoint.read_text(encoding="utf-8"), target).encode("utf-8"),
|
|
693
844
|
)
|
|
694
845
|
for markdown in sorted(staged_root.rglob("*.md")):
|
|
695
846
|
content = markdown.read_text(encoding="utf-8")
|
|
@@ -698,7 +849,7 @@ def stage_target(source_root: Path, target: str, staged_root: Path) -> None:
|
|
|
698
849
|
if target == SHARED_AGENT_PROFILE
|
|
699
850
|
else content.replace("$gsd-path", invocation).replace("$path", path_invocation)
|
|
700
851
|
)
|
|
701
|
-
markdown.
|
|
852
|
+
markdown.write_bytes(transformed.encode("utf-8"))
|
|
702
853
|
|
|
703
854
|
|
|
704
855
|
def _missing_directories(path: Path) -> List[Path]:
|
|
@@ -735,7 +886,7 @@ def _process_identity(pid: int) -> Optional[str]:
|
|
|
735
886
|
else:
|
|
736
887
|
command = ["ps", "-o", "lstart=", "-p", str(pid)]
|
|
737
888
|
try:
|
|
738
|
-
result = subprocess.run(command, capture_output=True,
|
|
889
|
+
result = subprocess.run(command, capture_output=True, encoding="utf-8", errors="replace", check=False)
|
|
739
890
|
except OSError:
|
|
740
891
|
return None
|
|
741
892
|
value = result.stdout.strip()
|
|
@@ -743,11 +894,39 @@ def _process_identity(pid: int) -> Optional[str]:
|
|
|
743
894
|
|
|
744
895
|
|
|
745
896
|
def _process_alive(pid: int) -> bool:
|
|
897
|
+
"""Whether pid names a running process, without signalling it.
|
|
898
|
+
|
|
899
|
+
Windows os.kill(pid, 0) sends CTRL_C_EVENT, so Windows asks the kernel.
|
|
900
|
+
status_runtime and install carry verbatim copies (they cannot import this
|
|
901
|
+
module); tests/test_common_platform.py keeps them identical.
|
|
902
|
+
"""
|
|
903
|
+
if pid <= 0:
|
|
904
|
+
return False
|
|
905
|
+
if os.name != "nt":
|
|
906
|
+
try:
|
|
907
|
+
os.kill(pid, 0)
|
|
908
|
+
except OSError as error:
|
|
909
|
+
return error.errno == errno.EPERM
|
|
910
|
+
return True
|
|
911
|
+
import ctypes
|
|
912
|
+
from ctypes import wintypes
|
|
913
|
+
|
|
914
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
915
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
916
|
+
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
917
|
+
kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
918
|
+
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
919
|
+
handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
920
|
+
if not handle:
|
|
921
|
+
# ERROR_ACCESS_DENIED: a protected process exists but refuses the query.
|
|
922
|
+
return ctypes.get_last_error() == 5
|
|
746
923
|
try:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
924
|
+
code = wintypes.DWORD()
|
|
925
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
926
|
+
return True
|
|
927
|
+
return code.value == 259 # STILL_ACTIVE
|
|
928
|
+
finally:
|
|
929
|
+
kernel32.CloseHandle(handle)
|
|
751
930
|
|
|
752
931
|
|
|
753
932
|
def _stale_install_lock_snapshot(lock: Path) -> Tuple[os.stat_result, bytes]:
|
|
@@ -784,6 +963,31 @@ def _stale_install_lock_snapshot(lock: Path) -> Tuple[os.stat_result, bytes]:
|
|
|
784
963
|
return observed, owner_bytes
|
|
785
964
|
|
|
786
965
|
|
|
966
|
+
# Access denied and sharing violation: on Windows a scanner or indexer that
|
|
967
|
+
# briefly opens a freshly written file blocks moving it or its directory.
|
|
968
|
+
_WINDOWS_BUSY_ERRORS = (5, 32)
|
|
969
|
+
_MOVE_ATTEMPTS = 20
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _move_retrying(move: Callable[[Path, Path], object], source: Path, destination: Path) -> None:
|
|
973
|
+
"""Run ``move``, retried on Windows while another process briefly holds ``source``."""
|
|
974
|
+
if os.name != "nt":
|
|
975
|
+
move(source, destination)
|
|
976
|
+
return
|
|
977
|
+
for attempt in range(_MOVE_ATTEMPTS):
|
|
978
|
+
try:
|
|
979
|
+
move(source, destination)
|
|
980
|
+
return
|
|
981
|
+
except PermissionError as error:
|
|
982
|
+
# A directory moved onto an existing directory also reports access
|
|
983
|
+
# denied; that is not transient, so never retry it.
|
|
984
|
+
if (getattr(error, "winerror", None) not in _WINDOWS_BUSY_ERRORS
|
|
985
|
+
or os.path.isdir(destination)
|
|
986
|
+
or attempt == _MOVE_ATTEMPTS - 1):
|
|
987
|
+
raise
|
|
988
|
+
time.sleep(min(0.01 * 2 ** attempt, 0.25))
|
|
989
|
+
|
|
990
|
+
|
|
787
991
|
def _recover_stale_install_lock(lock: Path, quarantine: Path) -> Optional[Path]:
|
|
788
992
|
legacy_quarantine = lock.with_name(f"{lock.name}.stale")
|
|
789
993
|
if not _lexists(lock):
|
|
@@ -839,19 +1043,18 @@ def _create_install_lock(lock: Path) -> None:
|
|
|
839
1043
|
quarantine = None
|
|
840
1044
|
published = False
|
|
841
1045
|
try:
|
|
842
|
-
(staging / INSTALL_LOCK_OWNER).
|
|
843
|
-
json.dumps(
|
|
1046
|
+
(staging / INSTALL_LOCK_OWNER).write_bytes(
|
|
1047
|
+
(json.dumps(
|
|
844
1048
|
{
|
|
845
1049
|
"schema": INSTALL_LOCK_SCHEMA,
|
|
846
1050
|
"pid": os.getpid(),
|
|
847
1051
|
"identity": identity,
|
|
848
1052
|
}
|
|
849
1053
|
)
|
|
850
|
-
+ "\n",
|
|
851
|
-
encoding="utf-8",
|
|
1054
|
+
+ "\n").encode("utf-8"),
|
|
852
1055
|
)
|
|
853
1056
|
quarantine = _recover_stale_install_lock(lock, recovery)
|
|
854
|
-
|
|
1057
|
+
_move_retrying(Path.rename, staging, lock)
|
|
855
1058
|
published = True
|
|
856
1059
|
if quarantine is not None:
|
|
857
1060
|
shutil.rmtree(quarantine)
|
|
@@ -926,7 +1129,7 @@ def _backup_existing(
|
|
|
926
1129
|
for entry, backup_name in existing:
|
|
927
1130
|
stored = transaction.backup / backup_name
|
|
928
1131
|
transaction.moved.append((entry, stored))
|
|
929
|
-
os.replace
|
|
1132
|
+
_move_retrying(os.replace, entry, stored)
|
|
930
1133
|
|
|
931
1134
|
|
|
932
1135
|
def _reserve_directory(path: Path) -> None:
|
|
@@ -987,7 +1190,7 @@ def _rollback_target(transaction: TargetTransaction) -> None:
|
|
|
987
1190
|
_remove_path(destination)
|
|
988
1191
|
for original, stored in reversed(transaction.moved):
|
|
989
1192
|
if _lexists(stored):
|
|
990
|
-
os.replace
|
|
1193
|
+
_move_retrying(os.replace, stored, original)
|
|
991
1194
|
if transaction.backup is not None:
|
|
992
1195
|
try:
|
|
993
1196
|
transaction.backup.rmdir()
|
|
@@ -1082,7 +1285,8 @@ def _update_replacement(
|
|
|
1082
1285
|
project: Path, destination: Path, hooks_dir: Optional[Path]
|
|
1083
1286
|
) -> Optional[Callable[[Path], bool]]:
|
|
1084
1287
|
"""--update replaces managed runtime, guard, and git hook files in place
|
|
1085
|
-
and keeps everything else (
|
|
1288
|
+
and keeps everything else (WORKFLOW.md, CLAUDE.md); AGENTS.md has its own
|
|
1289
|
+
block merge. Returns
|
|
1086
1290
|
the managed-file check for a replaceable destination, or None for a
|
|
1087
1291
|
kept one."""
|
|
1088
1292
|
parent = project / HOOKS_DIRECTORY
|
|
@@ -1143,6 +1347,7 @@ def _project_result(
|
|
|
1143
1347
|
):
|
|
1144
1348
|
replaceable = (
|
|
1145
1349
|
destination in mergers
|
|
1350
|
+
or destination == project / "AGENTS.md"
|
|
1146
1351
|
or _update_replacement(project, destination, hooks_dir) is not None
|
|
1147
1352
|
)
|
|
1148
1353
|
bucket = kept if _lexists(destination) and not replaceable else refreshed
|
|
@@ -1155,13 +1360,140 @@ def _project_result(
|
|
|
1155
1360
|
|
|
1156
1361
|
|
|
1157
1362
|
def _existing_contract_error(destination: Path) -> "InstallerError":
|
|
1158
|
-
|
|
1363
|
+
message = (
|
|
1159
1364
|
f"project contract already exists: {destination} — the installer never "
|
|
1160
|
-
"overwrites project files.
|
|
1365
|
+
"overwrites project files."
|
|
1366
|
+
)
|
|
1367
|
+
document = destination.name in ("AGENTS.md", "WORKFLOW.md") or (
|
|
1368
|
+
destination.name == "CLAUDE.md" and destination.parent.name == ".claude"
|
|
1369
|
+
)
|
|
1370
|
+
if document:
|
|
1371
|
+
aside = destination.with_name(f"{destination.stem}.pre-path{destination.suffix}")
|
|
1372
|
+
if _lexists(aside):
|
|
1373
|
+
message += (
|
|
1374
|
+
f" First install: {aside} already exists; move the contract to an "
|
|
1375
|
+
f"unused name, rerun, then merge its rules into the new {destination.name}."
|
|
1376
|
+
)
|
|
1377
|
+
else:
|
|
1378
|
+
message += (
|
|
1379
|
+
f" First install: move it aside with `mv {shlex.quote(str(destination))} "
|
|
1380
|
+
f"{shlex.quote(str(aside))}`, rerun, then merge its rules into the new "
|
|
1381
|
+
f"{destination.name}."
|
|
1382
|
+
)
|
|
1383
|
+
message += " Already a GSD Path project:"
|
|
1384
|
+
return InstallerError(
|
|
1385
|
+
f"{message} Run --update --project PATH to refresh managed "
|
|
1161
1386
|
"runtime files; merge template changes manually (see UPDATE.md)."
|
|
1162
1387
|
)
|
|
1163
1388
|
|
|
1164
1389
|
|
|
1390
|
+
def _agents_block(template: str) -> str:
|
|
1391
|
+
return f"{AGENTS_BEGIN}\n{template.rstrip(chr(10))}\n{AGENTS_END}\n"
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _split_agents(text: str, destination: Path) -> Optional[Tuple[str, str]]:
|
|
1395
|
+
"""Owner text (before, after) around Path's block, or None without markers.
|
|
1396
|
+
Markers count only as whole lines; inline mentions are owner text."""
|
|
1397
|
+
begins = [m.start() for m in re.finditer(rf"(?m)^{re.escape(AGENTS_BEGIN)}\r?$", text)]
|
|
1398
|
+
ends = list(re.finditer(rf"(?m)^{re.escape(AGENTS_END)}\r?(?:\n|$)", text))
|
|
1399
|
+
if not begins and not ends:
|
|
1400
|
+
return None
|
|
1401
|
+
if len(begins) != 1 or len(ends) != 1 or ends[0].start() < begins[0]:
|
|
1402
|
+
begins, ends = len(begins), len(ends)
|
|
1403
|
+
raise InstallerError(
|
|
1404
|
+
f"{destination} needs exactly one {AGENTS_BEGIN} line followed by one "
|
|
1405
|
+
f"{AGENTS_END} line; found {begins} begin and {ends} end markers. "
|
|
1406
|
+
"Fix the markers by hand, then rerun."
|
|
1407
|
+
)
|
|
1408
|
+
return text[:begins[0]], text[ends[0].end():]
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _read_agents(destination: Path) -> Optional[bytes]:
|
|
1412
|
+
if not _lexists(destination):
|
|
1413
|
+
return None
|
|
1414
|
+
try:
|
|
1415
|
+
return destination.read_bytes()
|
|
1416
|
+
except OSError as error:
|
|
1417
|
+
raise InstallerError(f"cannot read {destination}: {error}") from error
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _merged_agents(
|
|
1421
|
+
source_root: Path, destination: Path, raw: Optional[bytes] = None
|
|
1422
|
+
) -> bytes:
|
|
1423
|
+
"""AGENTS.md with Path's block inserted or replaced; owner text kept as is.
|
|
1424
|
+
raw is the current file (None when absent); read from disk when omitted."""
|
|
1425
|
+
template = (source_root / "AGENTS.md").read_text(encoding="utf-8")
|
|
1426
|
+
block = _agents_block(template)
|
|
1427
|
+
if raw is None:
|
|
1428
|
+
raw = _read_agents(destination)
|
|
1429
|
+
if raw is not None:
|
|
1430
|
+
try:
|
|
1431
|
+
text = raw.decode("utf-8")
|
|
1432
|
+
except UnicodeDecodeError as error:
|
|
1433
|
+
raise InstallerError(f"cannot read {destination}: {error}") from error
|
|
1434
|
+
parts = _split_agents(text, destination)
|
|
1435
|
+
if parts is not None:
|
|
1436
|
+
merged = parts[0] + block + parts[1]
|
|
1437
|
+
elif hashlib.sha256(raw).hexdigest() in RELEASED_AGENTS:
|
|
1438
|
+
merged = block # One-time migration of an unedited whole-file install.
|
|
1439
|
+
elif LEGACY_AGENTS_TITLE in text or re.search(
|
|
1440
|
+
rf"(?m)^{re.escape(LEGACY_AGENTS_MARKER)}\r?$", text
|
|
1441
|
+
):
|
|
1442
|
+
diff = "".join(
|
|
1443
|
+
difflib.unified_diff(
|
|
1444
|
+
template.splitlines(keepends=True),
|
|
1445
|
+
text.splitlines(keepends=True),
|
|
1446
|
+
"AGENTS.md (current GSD Path contract)",
|
|
1447
|
+
str(destination),
|
|
1448
|
+
)
|
|
1449
|
+
)
|
|
1450
|
+
raise InstallerError(
|
|
1451
|
+
f"{destination} is an edited whole-file GSD Path contract; Path cannot "
|
|
1452
|
+
"tell its text from your edits. Migrate by hand: 1) keep your own "
|
|
1453
|
+
f"rules from the diff below; 2) move the file aside with `mv "
|
|
1454
|
+
f"{shlex.quote(str(destination))} "
|
|
1455
|
+
f"{shlex.quote(str(destination.with_name('AGENTS.pre-path.md')))}`; "
|
|
1456
|
+
"3) rerun; 4) add your rules after the "
|
|
1457
|
+
f"{AGENTS_END} line.\n{diff}"
|
|
1458
|
+
)
|
|
1459
|
+
else:
|
|
1460
|
+
merged = block + "\n" + text # Block first: Codex cuts the tail.
|
|
1461
|
+
else:
|
|
1462
|
+
merged = block
|
|
1463
|
+
data = merged.encode("utf-8")
|
|
1464
|
+
if len(data) > CODEX_DOC_LIMIT:
|
|
1465
|
+
aside = destination.with_name("AGENTS.pre-path.md")
|
|
1466
|
+
raise InstallerError(
|
|
1467
|
+
f"{destination} would be {len(data)} bytes (existing file {len(raw or b'')} "
|
|
1468
|
+
f"bytes, GSD Path block {len(block.encode('utf-8'))} bytes). Codex reads "
|
|
1469
|
+
f"only the first {CODEX_DOC_LIMIT} bytes and silently cuts the rest. "
|
|
1470
|
+
f"Move it aside with `mv {shlex.quote(str(destination))} "
|
|
1471
|
+
f"{shlex.quote(str(aside))}`, rerun, then add back only the rules that fit."
|
|
1472
|
+
)
|
|
1473
|
+
return data
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _apply_agents(
|
|
1477
|
+
source_root: Path, destination: Path, transaction: "ProjectTransaction"
|
|
1478
|
+
) -> None:
|
|
1479
|
+
original = _read_agents(destination)
|
|
1480
|
+
merged = _merged_agents(source_root, destination, original)
|
|
1481
|
+
if original is None:
|
|
1482
|
+
try:
|
|
1483
|
+
with destination.open("xb") as output:
|
|
1484
|
+
transaction.copied.append(destination)
|
|
1485
|
+
output.write(merged)
|
|
1486
|
+
except FileExistsError as error:
|
|
1487
|
+
raise _existing_contract_error(destination) from error
|
|
1488
|
+
return
|
|
1489
|
+
if original != merged:
|
|
1490
|
+
if _read_agents(destination) != original:
|
|
1491
|
+
raise InstallerError(f"{destination} changed during install; rerun")
|
|
1492
|
+
mode = destination.stat().st_mode & 0o777
|
|
1493
|
+
_atomic_write(destination, merged, mode)
|
|
1494
|
+
transaction.replaced.append((destination, original, mode))
|
|
1495
|
+
|
|
1496
|
+
|
|
1165
1497
|
def _validate_project_git_root(project: Path) -> None:
|
|
1166
1498
|
probe = project
|
|
1167
1499
|
while not _lexists(probe):
|
|
@@ -1171,7 +1503,7 @@ def _validate_project_git_root(project: Path) -> None:
|
|
|
1171
1503
|
["git", "rev-parse", "--show-toplevel"],
|
|
1172
1504
|
cwd=probe,
|
|
1173
1505
|
capture_output=True,
|
|
1174
|
-
|
|
1506
|
+
encoding="utf-8", errors="replace",
|
|
1175
1507
|
check=False,
|
|
1176
1508
|
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
1177
1509
|
)
|
|
@@ -1186,6 +1518,90 @@ def _validate_project_git_root(project: Path) -> None:
|
|
|
1186
1518
|
raise InstallerError(f"project path is not the Git worktree root: {project}")
|
|
1187
1519
|
|
|
1188
1520
|
|
|
1521
|
+
GIT_ATTRIBUTES_BEGIN = "# gsd-path:begin"
|
|
1522
|
+
GIT_ATTRIBUTES_END = "# gsd-path:end"
|
|
1523
|
+
# Pipeline state is compared and hashed byte for byte. Git for Windows installs
|
|
1524
|
+
# with core.autocrlf=true, which would check .project files out as CRLF in
|
|
1525
|
+
# every worktree; this local rule keeps them LF without touching product files.
|
|
1526
|
+
GIT_ATTRIBUTES_BLOCK = (
|
|
1527
|
+
f"{GIT_ATTRIBUTES_BEGIN}\n/.project/** text eol=lf\n{GIT_ATTRIBUTES_END}\n"
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def _git_attributes_path(project: Path) -> Optional[Path]:
|
|
1532
|
+
"""The repository's local attributes file, shared by every worktree.
|
|
1533
|
+
|
|
1534
|
+
`--git-path info/attributes` resolves to the common git directory, so
|
|
1535
|
+
managed worktrees see the same rule. None when git cannot resolve it.
|
|
1536
|
+
"""
|
|
1537
|
+
try:
|
|
1538
|
+
result = subprocess.run(
|
|
1539
|
+
["git", "rev-parse", "--show-toplevel", "--git-path", "info/attributes"],
|
|
1540
|
+
cwd=os.fspath(project), capture_output=True,
|
|
1541
|
+
encoding="utf-8", errors="replace", check=False,
|
|
1542
|
+
)
|
|
1543
|
+
except OSError:
|
|
1544
|
+
return None
|
|
1545
|
+
lines = [line for line in result.stdout.splitlines() if line]
|
|
1546
|
+
if result.returncode != 0 or len(lines) != 2 or not _same_path(Path(lines[0]), project):
|
|
1547
|
+
return None
|
|
1548
|
+
return Path(os.path.abspath(os.path.join(os.fspath(project), lines[1])))
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _merged_git_attributes(raw: Optional[bytes], destination: Path) -> bytes:
|
|
1552
|
+
"""The attributes file with Path's block inserted or replaced; other lines kept."""
|
|
1553
|
+
if raw is None:
|
|
1554
|
+
return GIT_ATTRIBUTES_BLOCK.encode("utf-8")
|
|
1555
|
+
text = raw.decode("utf-8", "surrogateescape")
|
|
1556
|
+
lines = text.splitlines(keepends=True)
|
|
1557
|
+
begins = [i for i, line in enumerate(lines) if line.rstrip("\r\n") == GIT_ATTRIBUTES_BEGIN]
|
|
1558
|
+
ends = [i for i, line in enumerate(lines) if line.rstrip("\r\n") == GIT_ATTRIBUTES_END]
|
|
1559
|
+
if not begins and not ends:
|
|
1560
|
+
separator = "" if not text or text.endswith("\n") else "\n"
|
|
1561
|
+
merged = text + separator + GIT_ATTRIBUTES_BLOCK
|
|
1562
|
+
elif len(begins) != 1 or len(ends) != 1 or ends[0] < begins[0]:
|
|
1563
|
+
raise InstallerError(
|
|
1564
|
+
f"{destination} needs exactly one {GIT_ATTRIBUTES_BEGIN} line followed by "
|
|
1565
|
+
f"one {GIT_ATTRIBUTES_END} line; fix the markers by hand, then rerun"
|
|
1566
|
+
)
|
|
1567
|
+
else:
|
|
1568
|
+
merged = "".join(lines[:begins[0]]) + GIT_ATTRIBUTES_BLOCK + "".join(lines[ends[0] + 1:])
|
|
1569
|
+
return merged.encode("utf-8", "surrogateescape")
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _read_git_attributes(destination: Path) -> Optional[bytes]:
|
|
1573
|
+
if not _lexists(destination):
|
|
1574
|
+
return None
|
|
1575
|
+
if destination.is_symlink() or not destination.is_file():
|
|
1576
|
+
raise InstallerError(f"unsafe git attributes file: {destination}")
|
|
1577
|
+
try:
|
|
1578
|
+
return destination.read_bytes()
|
|
1579
|
+
except OSError as error:
|
|
1580
|
+
raise InstallerError(f"cannot read {destination}: {error}") from error
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def _apply_git_attributes(project: Path, transaction: "ProjectTransaction") -> None:
|
|
1584
|
+
destination = _git_attributes_path(project)
|
|
1585
|
+
if destination is None:
|
|
1586
|
+
return
|
|
1587
|
+
original = _read_git_attributes(destination)
|
|
1588
|
+
merged = _merged_git_attributes(original, destination)
|
|
1589
|
+
if original == merged:
|
|
1590
|
+
return
|
|
1591
|
+
_create_directory(destination.parent, transaction.created_directories)
|
|
1592
|
+
if original is None:
|
|
1593
|
+
try:
|
|
1594
|
+
with destination.open("xb") as output:
|
|
1595
|
+
transaction.copied.append(destination)
|
|
1596
|
+
output.write(merged)
|
|
1597
|
+
except FileExistsError as error:
|
|
1598
|
+
raise InstallerError(f"{destination} changed during install; rerun") from error
|
|
1599
|
+
return
|
|
1600
|
+
mode = destination.stat().st_mode & 0o777
|
|
1601
|
+
_atomic_write(destination, merged, mode)
|
|
1602
|
+
transaction.replaced.append((destination, original, mode))
|
|
1603
|
+
|
|
1604
|
+
|
|
1189
1605
|
def _validate_project(
|
|
1190
1606
|
source_root: Path,
|
|
1191
1607
|
project: Path,
|
|
@@ -1232,11 +1648,18 @@ def _validate_project(
|
|
|
1232
1648
|
runtime_store.prepare(source_root, project, dry_run=True)
|
|
1233
1649
|
except (OSError, ValueError) as error:
|
|
1234
1650
|
raise InstallerError(str(error)) from error
|
|
1651
|
+
attributes = _git_attributes_path(project)
|
|
1652
|
+
if attributes is not None:
|
|
1653
|
+
_merged_git_attributes(_read_git_attributes(attributes), attributes)
|
|
1235
1654
|
mergers = _native_settings_mergers(project, selected, hooks)
|
|
1236
1655
|
for destination, _, _, _ in _project_destinations(
|
|
1237
1656
|
project, selected, hooks, interpreter, hooks_dir
|
|
1238
1657
|
):
|
|
1239
|
-
if
|
|
1658
|
+
if destination == project / "AGENTS.md":
|
|
1659
|
+
if destination.is_symlink():
|
|
1660
|
+
raise _existing_contract_error(destination)
|
|
1661
|
+
_merged_agents(source_root, destination)
|
|
1662
|
+
elif _lexists(destination):
|
|
1240
1663
|
merge = mergers.get(destination)
|
|
1241
1664
|
managed = (
|
|
1242
1665
|
_update_replacement(project, destination, hooks_dir) if update else None
|
|
@@ -1265,7 +1688,10 @@ def _apply_project(
|
|
|
1265
1688
|
interpreter: str,
|
|
1266
1689
|
hooks_dir: Optional[Path],
|
|
1267
1690
|
update: bool = False,
|
|
1691
|
+
contracts: bool = True,
|
|
1268
1692
|
) -> None:
|
|
1693
|
+
"""contracts=False (hook refresh) creates a missing AGENTS.md but never
|
|
1694
|
+
rewrites an existing one."""
|
|
1269
1695
|
try:
|
|
1270
1696
|
pin = runtime_store.prepare(source_root, project)
|
|
1271
1697
|
except (OSError, ValueError) as error:
|
|
@@ -1278,6 +1704,11 @@ def _apply_project(
|
|
|
1278
1704
|
if destination.name == "runtime.json" and destination.parent == project / HOOKS_DIRECTORY:
|
|
1279
1705
|
content = runtime_store.pin_text(pin)
|
|
1280
1706
|
_create_directory(destination.parent, transaction.created_directories)
|
|
1707
|
+
if destination == project / "AGENTS.md" and (
|
|
1708
|
+
contracts or not _lexists(destination)
|
|
1709
|
+
):
|
|
1710
|
+
_apply_agents(source_root, destination, transaction)
|
|
1711
|
+
continue
|
|
1281
1712
|
merge = mergers.get(destination)
|
|
1282
1713
|
if _lexists(destination) and not destination.is_symlink():
|
|
1283
1714
|
replaceable = (
|
|
@@ -1320,6 +1751,7 @@ def _apply_project(
|
|
|
1320
1751
|
if created:
|
|
1321
1752
|
_remove_path(destination)
|
|
1322
1753
|
raise
|
|
1754
|
+
_apply_git_attributes(project, transaction)
|
|
1323
1755
|
|
|
1324
1756
|
|
|
1325
1757
|
def _rollback_project(transaction: ProjectTransaction) -> None:
|
|
@@ -1411,7 +1843,7 @@ def _atomic_write(
|
|
|
1411
1843
|
with os.fdopen(descriptor, "wb") as output:
|
|
1412
1844
|
output.write(content)
|
|
1413
1845
|
else:
|
|
1414
|
-
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
1846
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
|
|
1415
1847
|
output.write(content)
|
|
1416
1848
|
if mode is not None:
|
|
1417
1849
|
temporary.chmod(mode)
|
|
@@ -1448,7 +1880,7 @@ def _is_managed_hook_entry(entry) -> bool:
|
|
|
1448
1880
|
def _is_guard_command(command: str) -> bool:
|
|
1449
1881
|
normalized = command.replace("\\", "/")
|
|
1450
1882
|
match = re.fullmatch(
|
|
1451
|
-
r"(?:python3|python)\s+(?:\"([^\"\r\n]+)\"|'([^'\r\n]+)'|(\S+))",
|
|
1883
|
+
r"(?:python3|python|py\s+-3)\s+(?:\"([^\"\r\n]+)\"|'([^'\r\n]+)'|(\S+))",
|
|
1452
1884
|
normalized,
|
|
1453
1885
|
)
|
|
1454
1886
|
if match is None:
|
|
@@ -1761,7 +2193,7 @@ def refresh_hooks(
|
|
|
1761
2193
|
_validate_hooks_refresh(source_root, project, full, hooks_dir, selected, True)
|
|
1762
2194
|
transaction = ProjectTransaction()
|
|
1763
2195
|
try:
|
|
1764
|
-
_apply_project(source_root, project, selected, full, transaction, interpreter, hooks_dir, update=True)
|
|
2196
|
+
_apply_project(source_root, project, selected, full, transaction, interpreter, hooks_dir, update=True, contracts=False)
|
|
1765
2197
|
except BaseException:
|
|
1766
2198
|
_rollback_project(transaction)
|
|
1767
2199
|
raise
|
|
@@ -1777,7 +2209,7 @@ def refresh_hooks(
|
|
|
1777
2209
|
refresh_targets.append(target)
|
|
1778
2210
|
transaction = ProjectTransaction()
|
|
1779
2211
|
try:
|
|
1780
|
-
_apply_project(source_root, project, refresh_targets, True, transaction, interpreter, hooks_dir, update=True)
|
|
2212
|
+
_apply_project(source_root, project, refresh_targets, True, transaction, interpreter, hooks_dir, update=True, contracts=False)
|
|
1781
2213
|
except BaseException:
|
|
1782
2214
|
_rollback_project(transaction)
|
|
1783
2215
|
raise
|
|
@@ -1940,10 +2372,10 @@ def _validated_project_state(source_root: Path, project: Path) -> dict:
|
|
|
1940
2372
|
interpreter = _required_python_runtime("--doctor")
|
|
1941
2373
|
try:
|
|
1942
2374
|
result = subprocess.run(
|
|
1943
|
-
[interpreter, "-B", str(validator), "validate", "--repo", str(project)],
|
|
2375
|
+
[*interpreter.split(), "-B", str(validator), "validate", "--repo", str(project)],
|
|
1944
2376
|
cwd=project,
|
|
1945
2377
|
capture_output=True,
|
|
1946
|
-
|
|
2378
|
+
encoding="utf-8", errors="replace",
|
|
1947
2379
|
check=False,
|
|
1948
2380
|
)
|
|
1949
2381
|
except OSError as error:
|
|
@@ -1981,10 +2413,10 @@ def _validate_project_runtime_status(source_root: Path, project: Path) -> None:
|
|
|
1981
2413
|
environment["GIT_OPTIONAL_LOCKS"] = "0"
|
|
1982
2414
|
try:
|
|
1983
2415
|
result = subprocess.run(
|
|
1984
|
-
[interpreter, "-B", str(runtime), "status", "--repo", str(project)],
|
|
2416
|
+
[*interpreter.split(), "-B", str(runtime), "status", "--repo", str(project)],
|
|
1985
2417
|
cwd=project,
|
|
1986
2418
|
capture_output=True,
|
|
1987
|
-
|
|
2419
|
+
encoding="utf-8", errors="replace",
|
|
1988
2420
|
check=False,
|
|
1989
2421
|
env=environment,
|
|
1990
2422
|
)
|
|
@@ -2148,9 +2580,13 @@ def _valid_status_branch(value: object) -> Optional[re.Match[str]]:
|
|
|
2148
2580
|
|
|
2149
2581
|
def _is_executable(path: Path) -> bool:
|
|
2150
2582
|
try:
|
|
2151
|
-
|
|
2583
|
+
status = path.stat()
|
|
2152
2584
|
except OSError:
|
|
2153
2585
|
return False
|
|
2586
|
+
if os.name == "nt":
|
|
2587
|
+
# Windows has no exec bit; Git for Windows runs any regular hook file.
|
|
2588
|
+
return path.is_file()
|
|
2589
|
+
return bool(status.st_mode & 0o111)
|
|
2154
2590
|
|
|
2155
2591
|
|
|
2156
2592
|
def _native_guard_contract(
|
|
@@ -2165,6 +2601,32 @@ def _native_guard_contract(
|
|
|
2165
2601
|
return None
|
|
2166
2602
|
|
|
2167
2603
|
|
|
2604
|
+
def _windows_path_warnings() -> List[str]:
|
|
2605
|
+
"""Settings that deep managed-worktree paths need past 260 characters."""
|
|
2606
|
+
import winreg
|
|
2607
|
+
|
|
2608
|
+
warnings = []
|
|
2609
|
+
try:
|
|
2610
|
+
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
|
2611
|
+
r"SYSTEM\CurrentControlSet\Control\FileSystem") as key:
|
|
2612
|
+
enabled = winreg.QueryValueEx(key, "LongPathsEnabled")[0] == 1
|
|
2613
|
+
except OSError:
|
|
2614
|
+
enabled = False
|
|
2615
|
+
if not enabled:
|
|
2616
|
+
warnings.append("windows: long paths are disabled (LongPathsEnabled=0); files deep in "
|
|
2617
|
+
"managed worktrees may exceed 260 characters")
|
|
2618
|
+
try:
|
|
2619
|
+
result = subprocess.run(["git", "config", "--get", "core.longpaths"], capture_output=True,
|
|
2620
|
+
encoding="utf-8", errors="replace", check=False)
|
|
2621
|
+
except OSError:
|
|
2622
|
+
# Without git on PATH there is no git setting to advise on.
|
|
2623
|
+
return warnings
|
|
2624
|
+
if result.stdout.strip().lower() != "true":
|
|
2625
|
+
warnings.append("windows: git core.longpaths is not true; run "
|
|
2626
|
+
"`git config --global core.longpaths true`")
|
|
2627
|
+
return warnings
|
|
2628
|
+
|
|
2629
|
+
|
|
2168
2630
|
def doctor(
|
|
2169
2631
|
source_root: Path,
|
|
2170
2632
|
targets: Sequence[str],
|
|
@@ -2186,6 +2648,9 @@ def doctor(
|
|
|
2186
2648
|
version = _read_package_version(source_root / "package.json")
|
|
2187
2649
|
if version is None:
|
|
2188
2650
|
push("fail", "package: version cannot be read")
|
|
2651
|
+
if os.name == "nt":
|
|
2652
|
+
for text in _windows_path_warnings():
|
|
2653
|
+
push("warn", text)
|
|
2189
2654
|
seen: List[Tuple[str, Path]] = []
|
|
2190
2655
|
installed_targets = set()
|
|
2191
2656
|
for target in targets:
|
|
@@ -2235,6 +2700,29 @@ def doctor(
|
|
|
2235
2700
|
if project is None:
|
|
2236
2701
|
return findings
|
|
2237
2702
|
|
|
2703
|
+
attributes = _git_attributes_path(project)
|
|
2704
|
+
if attributes is not None:
|
|
2705
|
+
try:
|
|
2706
|
+
current = _read_git_attributes(attributes)
|
|
2707
|
+
if current is None or _merged_git_attributes(current, attributes) != current:
|
|
2708
|
+
push("warn", f"git: {attributes} lacks the GSD Path LF rule for .project — "
|
|
2709
|
+
f'run --update --project "{project}"')
|
|
2710
|
+
else:
|
|
2711
|
+
push("ok", "git: .project is checked out LF in every worktree")
|
|
2712
|
+
except InstallerError as error:
|
|
2713
|
+
push("fail", f"git: {error}")
|
|
2714
|
+
crlf = sorted(
|
|
2715
|
+
path.relative_to(project).as_posix()
|
|
2716
|
+
for path in (project / ".project").rglob("*")
|
|
2717
|
+
if path.is_file() and not path.is_symlink() and b"\r\n" in path.read_bytes()
|
|
2718
|
+
) if (project / ".project").is_dir() else []
|
|
2719
|
+
if crlf:
|
|
2720
|
+
# git checkout skips files it considers clean, so delete them first.
|
|
2721
|
+
push("warn", f"git: {len(crlf)} .project file(s) were checked out as CRLF "
|
|
2722
|
+
f"(first: {crlf[0]}); with the LF rule in place and changes "
|
|
2723
|
+
"committed, run `git ls-files -z .project | xargs -0 rm -f && "
|
|
2724
|
+
"git checkout -- .project` to restore LF")
|
|
2725
|
+
|
|
2238
2726
|
for name, heading in PROJECT_CONTRACTS:
|
|
2239
2727
|
contract = project / name
|
|
2240
2728
|
if contract.is_symlink():
|
|
@@ -2249,6 +2737,26 @@ def doctor(
|
|
|
2249
2737
|
canonical = read_project_file(source_root / name, f"package: {name}")
|
|
2250
2738
|
if canonical is None:
|
|
2251
2739
|
continue
|
|
2740
|
+
if name == "AGENTS.md":
|
|
2741
|
+
text = content.decode("utf-8", errors="replace")
|
|
2742
|
+
try:
|
|
2743
|
+
parts = _split_agents(text, contract)
|
|
2744
|
+
except InstallerError as error:
|
|
2745
|
+
push("fail", f"project: {error}")
|
|
2746
|
+
continue
|
|
2747
|
+
if parts is None:
|
|
2748
|
+
push("fail", f'project: AGENTS.md has no GSD Path block — run --update --project "{project}"')
|
|
2749
|
+
elif _agents_block(canonical.decode("utf-8", errors="replace")) not in text:
|
|
2750
|
+
push("fail", f'project: AGENTS.md block is stale — run --update --project "{project}"')
|
|
2751
|
+
elif len(content) > CODEX_DOC_LIMIT:
|
|
2752
|
+
push(
|
|
2753
|
+
"fail",
|
|
2754
|
+
f"project: AGENTS.md is {len(content)} bytes; Codex reads only "
|
|
2755
|
+
f"the first {CODEX_DOC_LIMIT} — reduce the file to keep all rules visible",
|
|
2756
|
+
)
|
|
2757
|
+
else:
|
|
2758
|
+
push("ok", "project: AGENTS.md block present")
|
|
2759
|
+
continue
|
|
2252
2760
|
installed_section = _contract_section(
|
|
2253
2761
|
content.decode("utf-8", errors="replace"), heading
|
|
2254
2762
|
)
|
|
@@ -2442,7 +2950,7 @@ def doctor(
|
|
|
2442
2950
|
try:
|
|
2443
2951
|
parsed = json.loads(settings.read_text(encoding="utf-8"))
|
|
2444
2952
|
entries = parsed.get("hooks", {}).get(event)
|
|
2445
|
-
variants = [entry(candidate) for candidate in
|
|
2953
|
+
variants = [entry(candidate) for candidate in INTERPRETER_CANDIDATES]
|
|
2446
2954
|
current = isinstance(entries, list) and any(
|
|
2447
2955
|
candidate in variants for candidate in entries
|
|
2448
2956
|
)
|
|
@@ -2488,7 +2996,7 @@ def doctor(
|
|
|
2488
2996
|
push("warn", f"hooks: {label} is not a managed GSD Path git hook")
|
|
2489
2997
|
elif not any(
|
|
2490
2998
|
hook_text == generator(candidate)
|
|
2491
|
-
for candidate in
|
|
2999
|
+
for candidate in INTERPRETER_CANDIDATES
|
|
2492
3000
|
):
|
|
2493
3001
|
push("warn", f"hooks: {label} is stale — run --hooks-refresh-full")
|
|
2494
3002
|
elif not _is_executable(hook_path):
|
|
@@ -2715,7 +3223,7 @@ def install(
|
|
|
2715
3223
|
results.append(
|
|
2716
3224
|
"codex-legacy: backed up "
|
|
2717
3225
|
f"{len(legacy_transaction.moved)} entries to "
|
|
2718
|
-
f"{legacy_transaction.backup}"
|
|
3226
|
+
f"{legacy_transaction.backup}{LOCAL_EDITS_NOTE}"
|
|
2719
3227
|
)
|
|
2720
3228
|
for index, plan in enumerate(deployments):
|
|
2721
3229
|
transaction = TargetTransaction(plan.root)
|
|
@@ -2726,7 +3234,7 @@ def install(
|
|
|
2726
3234
|
results.append(
|
|
2727
3235
|
f"{'+'.join(plan.targets)}: backed up "
|
|
2728
3236
|
f"{len(transaction.moved)} entries "
|
|
2729
|
-
f"to {transaction.backup}"
|
|
3237
|
+
f"to {transaction.backup}{LOCAL_EDITS_NOTE}"
|
|
2730
3238
|
)
|
|
2731
3239
|
if project is not None:
|
|
2732
3240
|
result_line = _project_result(
|
|
@@ -2778,6 +3286,7 @@ def parser() -> argparse.ArgumentParser:
|
|
|
2778
3286
|
argument_parser.add_argument("--local", action="store_true")
|
|
2779
3287
|
argument_parser.add_argument("--dry-run", action="store_true")
|
|
2780
3288
|
argument_parser.add_argument("--project", type=Path)
|
|
3289
|
+
argument_parser.add_argument("--member-of", type=Path)
|
|
2781
3290
|
argument_parser.add_argument("--doctor", action="store_true")
|
|
2782
3291
|
runtime_actions = argument_parser.add_mutually_exclusive_group()
|
|
2783
3292
|
for action in ("restore", "upgrade", "migrate"):
|
|
@@ -2845,6 +3354,18 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
2845
3354
|
except (InstallerError, OSError, ValueError) as error:
|
|
2846
3355
|
print(f"error: {error}", file=sys.stderr)
|
|
2847
3356
|
return 1
|
|
3357
|
+
if arguments.member_of is not None:
|
|
3358
|
+
if project is None:
|
|
3359
|
+
argument_parser.error("--member-of requires --project")
|
|
3360
|
+
try:
|
|
3361
|
+
for line in install_member_hooks(
|
|
3362
|
+
absolute_path(arguments.member_of), project, arguments.dry_run
|
|
3363
|
+
):
|
|
3364
|
+
print(line)
|
|
3365
|
+
return 0
|
|
3366
|
+
except (InstallerError, OSError, subprocess.CalledProcessError) as error:
|
|
3367
|
+
print(f"error: {error}", file=sys.stderr)
|
|
3368
|
+
return 1
|
|
2848
3369
|
if arguments.doctor:
|
|
2849
3370
|
named = [target for target in TARGETS if getattr(arguments, target)]
|
|
2850
3371
|
selected = named if named and not arguments.all_targets else list(TARGETS)
|