@opengsd/gsd-path 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (398) hide show
  1. package/AGENTS.md +397 -0
  2. package/DOCS.md +341 -0
  3. package/FULL.md +475 -0
  4. package/GUIDE.md +23 -0
  5. package/HOOKS.md +273 -0
  6. package/LICENSE +21 -0
  7. package/MIGRATE.md +100 -0
  8. package/QUICK.md +146 -0
  9. package/README.md +280 -0
  10. package/RUNTIME.md +229 -0
  11. package/UPDATE.md +196 -0
  12. package/WORKFLOW.md +683 -0
  13. package/package.json +80 -0
  14. package/platforms/claude/dispatch.md +63 -0
  15. package/platforms/copilot/dispatch.md +61 -0
  16. package/platforms/cursor/agent.md +17 -0
  17. package/platforms/cursor/dispatch.md +63 -0
  18. package/platforms/grok/dispatch.md +61 -0
  19. package/platforms/kimi/dispatch.md +64 -0
  20. package/platforms/kiro/dispatch.md +58 -0
  21. package/platforms/opencode/dispatch.md +62 -0
  22. package/platforms/qwen/dispatch.md +60 -0
  23. package/platforms/shared-agents/dispatch.md +131 -0
  24. package/plugin.json +14 -0
  25. package/scripts/_common.py +180 -0
  26. package/scripts/archive_milestone.py +2265 -0
  27. package/scripts/bootstrap_repository.py +710 -0
  28. package/scripts/build_state.py +854 -0
  29. package/scripts/check_docs_audit.py +463 -0
  30. package/scripts/check_handoffs.py +1855 -0
  31. package/scripts/check_task_briefs.py +376 -0
  32. package/scripts/check_update.py +93 -0
  33. package/scripts/core_hook_gate.py +34 -0
  34. package/scripts/core_hook_settings.py +119 -0
  35. package/scripts/detect_project.py +1517 -0
  36. package/scripts/discussion_records.py +619 -0
  37. package/scripts/discussion_validate.py +1321 -0
  38. package/scripts/dispatch_driver.py +1812 -0
  39. package/scripts/git_guard.py +759 -0
  40. package/scripts/guard_hook.py +2124 -0
  41. package/scripts/install.mjs +3239 -0
  42. package/scripts/install.py +3008 -0
  43. package/scripts/integration.py +1433 -0
  44. package/scripts/isolation.py +3538 -0
  45. package/scripts/lean_verification.py +256 -0
  46. package/scripts/loop_run.py +837 -0
  47. package/scripts/migrate_core.py +169 -0
  48. package/scripts/pipeline_diagnose.py +715 -0
  49. package/scripts/pipeline_git.py +940 -0
  50. package/scripts/pipeline_state.py +2291 -0
  51. package/scripts/pipeline_undo.py +1039 -0
  52. package/scripts/promote_lookahead.py +415 -0
  53. package/scripts/review_findings.py +714 -0
  54. package/scripts/review_panel.py +553 -0
  55. package/scripts/skill-resources.json +340 -0
  56. package/scripts/state_checkpoint.py +1039 -0
  57. package/scripts/state_promote.py +743 -0
  58. package/scripts/status_runtime.py +122 -0
  59. package/scripts/sync_skill_resources.py +259 -0
  60. package/scripts/token_budget.py +146 -0
  61. package/scripts/wizard.mjs +179 -0
  62. package/scripts/workflow_run.py +117 -0
  63. package/skills/gsd-path/BUILD.md +700 -0
  64. package/skills/gsd-path/DECIDE.md +115 -0
  65. package/skills/gsd-path/DEFINE.md +297 -0
  66. package/skills/gsd-path/DOCS-AUDIT.md +190 -0
  67. package/skills/gsd-path/INSPECT.md +167 -0
  68. package/skills/gsd-path/PLAN.md +411 -0
  69. package/skills/gsd-path/RESEARCH.md +159 -0
  70. package/skills/gsd-path/ROADMAP.md +215 -0
  71. package/skills/gsd-path/SHIP.md +412 -0
  72. package/skills/gsd-path/SKILL.md +471 -0
  73. package/skills/gsd-path/agents/openai.yaml +6 -0
  74. package/skills/gsd-path/references/codebase-mapper.md +52 -0
  75. package/skills/gsd-path/references/coder.md +90 -0
  76. package/skills/gsd-path/references/decider.md +36 -0
  77. package/skills/gsd-path/references/dispatch.md +131 -0
  78. package/skills/gsd-path/references/docs-auditor.md +60 -0
  79. package/skills/gsd-path/references/planner.md +104 -0
  80. package/skills/gsd-path/references/researcher.md +42 -0
  81. package/skills/gsd-path/references/reviewer.md +188 -0
  82. package/skills/gsd-path/references/roadmapper.md +48 -0
  83. package/skills/gsd-path/scripts/_common.py +180 -0
  84. package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
  85. package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
  86. package/skills/gsd-path/scripts/build_state.py +854 -0
  87. package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
  88. package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
  89. package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
  90. package/skills/gsd-path/scripts/check_update.py +93 -0
  91. package/skills/gsd-path/scripts/detect_project.py +1517 -0
  92. package/skills/gsd-path/scripts/discussion_records.py +619 -0
  93. package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
  94. package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
  95. package/skills/gsd-path/scripts/integration.py +1433 -0
  96. package/skills/gsd-path/scripts/isolation.py +3538 -0
  97. package/skills/gsd-path/scripts/lean_verification.py +256 -0
  98. package/skills/gsd-path/scripts/loop_run.py +837 -0
  99. package/skills/gsd-path/scripts/pipeline_git.py +940 -0
  100. package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
  101. package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
  102. package/skills/gsd-path/scripts/review_findings.py +714 -0
  103. package/skills/gsd-path/scripts/review_panel.py +553 -0
  104. package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
  105. package/skills/gsd-path/scripts/state_promote.py +743 -0
  106. package/skills/gsd-path/scripts/token_budget.py +146 -0
  107. package/skills/gsd-path/scripts/workflow_run.py +117 -0
  108. package/skills/gsd-path/templates/answers.md +29 -0
  109. package/skills/gsd-path/templates/archive-manifest.md +37 -0
  110. package/skills/gsd-path/templates/charter.md +50 -0
  111. package/skills/gsd-path/templates/codebase.md +46 -0
  112. package/skills/gsd-path/templates/dialogue.md +21 -0
  113. package/skills/gsd-path/templates/docs-audit.md +69 -0
  114. package/skills/gsd-path/templates/evidence.md +24 -0
  115. package/skills/gsd-path/templates/final-review.md +27 -0
  116. package/skills/gsd-path/templates/gap-review.md +20 -0
  117. package/skills/gsd-path/templates/intent.md +98 -0
  118. package/skills/gsd-path/templates/loop.md +59 -0
  119. package/skills/gsd-path/templates/patch-findings.md +16 -0
  120. package/skills/gsd-path/templates/plan-panel.md +25 -0
  121. package/skills/gsd-path/templates/plan.md +89 -0
  122. package/skills/gsd-path/templates/repository.md +16 -0
  123. package/skills/gsd-path/templates/research-handoff.md +25 -0
  124. package/skills/gsd-path/templates/roadmap.md +38 -0
  125. package/skills/gsd-path/templates/skeptic.md +36 -0
  126. package/skills/gsd-path/templates/state.md +27 -0
  127. package/skills/gsd-path/templates/synthesis.md +36 -0
  128. package/skills/gsd-path/templates/task.md +74 -0
  129. package/skills/gsd-path/templates/wave-panel.md +25 -0
  130. package/skills/gsd-path/templates/wave-review.md +64 -0
  131. package/skills/gsd-path-build/SKILL.md +700 -0
  132. package/skills/gsd-path-build/agents/openai.yaml +6 -0
  133. package/skills/gsd-path-build/references/coder.md +90 -0
  134. package/skills/gsd-path-build/references/dispatch.md +131 -0
  135. package/skills/gsd-path-build/references/reviewer.md +188 -0
  136. package/skills/gsd-path-build/scripts/_common.py +180 -0
  137. package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
  138. package/skills/gsd-path-build/scripts/build_state.py +854 -0
  139. package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
  140. package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
  141. package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
  142. package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
  143. package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
  144. package/skills/gsd-path-build/scripts/integration.py +1433 -0
  145. package/skills/gsd-path-build/scripts/isolation.py +3538 -0
  146. package/skills/gsd-path-build/scripts/loop_run.py +837 -0
  147. package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
  148. package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
  149. package/skills/gsd-path-build/scripts/review_findings.py +714 -0
  150. package/skills/gsd-path-build/scripts/review_panel.py +553 -0
  151. package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
  152. package/skills/gsd-path-build/scripts/state_promote.py +743 -0
  153. package/skills/gsd-path-build/scripts/token_budget.py +146 -0
  154. package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
  155. package/skills/gsd-path-build/templates/skeptic.md +36 -0
  156. package/skills/gsd-path-build/templates/task.md +74 -0
  157. package/skills/gsd-path-build/templates/wave-panel.md +25 -0
  158. package/skills/gsd-path-build/templates/wave-review.md +64 -0
  159. package/skills/gsd-path-decide/SKILL.md +115 -0
  160. package/skills/gsd-path-decide/agents/openai.yaml +6 -0
  161. package/skills/gsd-path-decide/references/decider.md +36 -0
  162. package/skills/gsd-path-decide/references/dispatch.md +131 -0
  163. package/skills/gsd-path-decide/scripts/_common.py +180 -0
  164. package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
  165. package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
  166. package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
  167. package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
  168. package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
  169. package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
  170. package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
  171. package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
  172. package/skills/gsd-path-decide/templates/synthesis.md +36 -0
  173. package/skills/gsd-path-define/SKILL.md +297 -0
  174. package/skills/gsd-path-define/agents/openai.yaml +6 -0
  175. package/skills/gsd-path-define/scripts/_common.py +180 -0
  176. package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
  177. package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
  178. package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
  179. package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
  180. package/skills/gsd-path-define/scripts/isolation.py +3538 -0
  181. package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
  182. package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
  183. package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
  184. package/skills/gsd-path-define/scripts/state_promote.py +743 -0
  185. package/skills/gsd-path-define/templates/charter.md +50 -0
  186. package/skills/gsd-path-define/templates/intent.md +98 -0
  187. package/skills/gsd-path-define/templates/state.md +27 -0
  188. package/skills/gsd-path-discuss/SKILL.md +162 -0
  189. package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
  190. package/skills/gsd-path-discuss/scripts/_common.py +180 -0
  191. package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
  192. package/skills/gsd-path-discuss/templates/answers.md +29 -0
  193. package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
  194. package/skills/gsd-path-docs-audit/SKILL.md +190 -0
  195. package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
  196. package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
  197. package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
  198. package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
  199. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
  200. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
  201. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
  202. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
  203. package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
  204. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
  205. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
  206. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
  207. package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
  208. package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
  209. package/skills/gsd-path-forensics/SKILL.md +39 -0
  210. package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
  211. package/skills/gsd-path-forensics/scripts/_common.py +180 -0
  212. package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
  213. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
  214. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
  215. package/skills/gsd-path-inspect/SKILL.md +167 -0
  216. package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
  217. package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
  218. package/skills/gsd-path-inspect/references/dispatch.md +131 -0
  219. package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
  220. package/skills/gsd-path-inspect/scripts/_common.py +180 -0
  221. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
  222. package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
  223. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
  224. package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
  225. package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
  226. package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
  227. package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
  228. package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
  229. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
  230. package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
  231. package/skills/gsd-path-inspect/templates/codebase.md +46 -0
  232. package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
  233. package/skills/gsd-path-inspect/templates/state.md +27 -0
  234. package/skills/gsd-path-loop/SKILL.md +98 -0
  235. package/skills/gsd-path-loop/agents/openai.yaml +6 -0
  236. package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
  237. package/skills/gsd-path-loop/scripts/_common.py +180 -0
  238. package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
  239. package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
  240. package/skills/gsd-path-loop/templates/loop.md +59 -0
  241. package/skills/gsd-path-migrate/SKILL.md +134 -0
  242. package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
  243. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
  244. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
  245. package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
  246. package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
  247. package/skills/gsd-path-plan/SKILL.md +411 -0
  248. package/skills/gsd-path-plan/agents/openai.yaml +6 -0
  249. package/skills/gsd-path-plan/references/dispatch.md +131 -0
  250. package/skills/gsd-path-plan/references/planner.md +104 -0
  251. package/skills/gsd-path-plan/scripts/_common.py +180 -0
  252. package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
  253. package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
  254. package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
  255. package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
  256. package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
  257. package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
  258. package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
  259. package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
  260. package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
  261. package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
  262. package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
  263. package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
  264. package/skills/gsd-path-plan/templates/plan.md +89 -0
  265. package/skills/gsd-path-plan/templates/task.md +74 -0
  266. package/skills/gsd-path-research/SKILL.md +159 -0
  267. package/skills/gsd-path-research/agents/openai.yaml +6 -0
  268. package/skills/gsd-path-research/references/dispatch.md +131 -0
  269. package/skills/gsd-path-research/references/researcher.md +42 -0
  270. package/skills/gsd-path-research/scripts/_common.py +180 -0
  271. package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
  272. package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
  273. package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
  274. package/skills/gsd-path-research/scripts/isolation.py +3538 -0
  275. package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
  276. package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
  277. package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
  278. package/skills/gsd-path-research/scripts/state_promote.py +743 -0
  279. package/skills/gsd-path-research/templates/evidence.md +24 -0
  280. package/skills/gsd-path-research/templates/research-handoff.md +25 -0
  281. package/skills/gsd-path-roadmap/SKILL.md +215 -0
  282. package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
  283. package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
  284. package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
  285. package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
  286. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
  287. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
  288. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
  289. package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
  290. package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
  291. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
  292. package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
  293. package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
  294. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
  295. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
  296. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
  297. package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
  298. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
  299. package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
  300. package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
  301. package/skills/gsd-path-ship/SKILL.md +412 -0
  302. package/skills/gsd-path-ship/agents/openai.yaml +6 -0
  303. package/skills/gsd-path-ship/references/dispatch.md +131 -0
  304. package/skills/gsd-path-ship/references/reviewer.md +188 -0
  305. package/skills/gsd-path-ship/scripts/_common.py +180 -0
  306. package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
  307. package/skills/gsd-path-ship/scripts/build_state.py +854 -0
  308. package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
  309. package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
  310. package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
  311. package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
  312. package/skills/gsd-path-ship/scripts/integration.py +1433 -0
  313. package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
  314. package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
  315. package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
  316. package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
  317. package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
  318. package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
  319. package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
  320. package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
  321. package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
  322. package/skills/gsd-path-ship/templates/final-review.md +27 -0
  323. package/skills/gsd-path-ship/templates/gap-review.md +20 -0
  324. package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
  325. package/skills/gsd-path-ship/templates/wave-review.md +64 -0
  326. package/skills/gsd-path-undo/SKILL.md +45 -0
  327. package/skills/gsd-path-undo/agents/openai.yaml +6 -0
  328. package/skills/gsd-path-undo/scripts/_common.py +180 -0
  329. package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
  330. package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
  331. package/skills/path/BUILD.md +700 -0
  332. package/skills/path/DECIDE.md +115 -0
  333. package/skills/path/DEFINE.md +297 -0
  334. package/skills/path/DOCS-AUDIT.md +190 -0
  335. package/skills/path/INSPECT.md +167 -0
  336. package/skills/path/PLAN.md +411 -0
  337. package/skills/path/RESEARCH.md +159 -0
  338. package/skills/path/ROADMAP.md +215 -0
  339. package/skills/path/SHIP.md +412 -0
  340. package/skills/path/SKILL.md +471 -0
  341. package/skills/path/agents/openai.yaml +6 -0
  342. package/skills/path/references/codebase-mapper.md +52 -0
  343. package/skills/path/references/coder.md +90 -0
  344. package/skills/path/references/decider.md +36 -0
  345. package/skills/path/references/dispatch.md +131 -0
  346. package/skills/path/references/docs-auditor.md +60 -0
  347. package/skills/path/references/planner.md +104 -0
  348. package/skills/path/references/researcher.md +42 -0
  349. package/skills/path/references/reviewer.md +188 -0
  350. package/skills/path/references/roadmapper.md +48 -0
  351. package/skills/path/scripts/_common.py +180 -0
  352. package/skills/path/scripts/archive_milestone.py +2265 -0
  353. package/skills/path/scripts/bootstrap_repository.py +710 -0
  354. package/skills/path/scripts/build_state.py +854 -0
  355. package/skills/path/scripts/check_docs_audit.py +463 -0
  356. package/skills/path/scripts/check_handoffs.py +1855 -0
  357. package/skills/path/scripts/check_task_briefs.py +376 -0
  358. package/skills/path/scripts/check_update.py +93 -0
  359. package/skills/path/scripts/detect_project.py +1517 -0
  360. package/skills/path/scripts/discussion_records.py +619 -0
  361. package/skills/path/scripts/discussion_validate.py +1321 -0
  362. package/skills/path/scripts/dispatch_driver.py +1812 -0
  363. package/skills/path/scripts/integration.py +1433 -0
  364. package/skills/path/scripts/isolation.py +3538 -0
  365. package/skills/path/scripts/lean_verification.py +256 -0
  366. package/skills/path/scripts/loop_run.py +837 -0
  367. package/skills/path/scripts/pipeline_git.py +940 -0
  368. package/skills/path/scripts/pipeline_state.py +2291 -0
  369. package/skills/path/scripts/promote_lookahead.py +415 -0
  370. package/skills/path/scripts/review_findings.py +714 -0
  371. package/skills/path/scripts/review_panel.py +553 -0
  372. package/skills/path/scripts/state_checkpoint.py +1039 -0
  373. package/skills/path/scripts/state_promote.py +743 -0
  374. package/skills/path/scripts/token_budget.py +146 -0
  375. package/skills/path/scripts/workflow_run.py +117 -0
  376. package/skills/path/templates/answers.md +29 -0
  377. package/skills/path/templates/archive-manifest.md +37 -0
  378. package/skills/path/templates/charter.md +50 -0
  379. package/skills/path/templates/codebase.md +46 -0
  380. package/skills/path/templates/dialogue.md +21 -0
  381. package/skills/path/templates/docs-audit.md +69 -0
  382. package/skills/path/templates/evidence.md +24 -0
  383. package/skills/path/templates/final-review.md +27 -0
  384. package/skills/path/templates/gap-review.md +20 -0
  385. package/skills/path/templates/intent.md +98 -0
  386. package/skills/path/templates/loop.md +59 -0
  387. package/skills/path/templates/patch-findings.md +16 -0
  388. package/skills/path/templates/plan-panel.md +25 -0
  389. package/skills/path/templates/plan.md +89 -0
  390. package/skills/path/templates/repository.md +16 -0
  391. package/skills/path/templates/research-handoff.md +25 -0
  392. package/skills/path/templates/roadmap.md +38 -0
  393. package/skills/path/templates/skeptic.md +36 -0
  394. package/skills/path/templates/state.md +27 -0
  395. package/skills/path/templates/synthesis.md +36 -0
  396. package/skills/path/templates/task.md +74 -0
  397. package/skills/path/templates/wave-panel.md +25 -0
  398. package/skills/path/templates/wave-review.md +64 -0
@@ -0,0 +1,3008 @@
1
+ #!/usr/bin/env python3
2
+ """Install GSD Path skills for supported coding agents."""
3
+
4
+ import argparse
5
+ import errno
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Callable, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
16
+
17
+ sys.dont_write_bytecode = True
18
+
19
+ try:
20
+ from . import sync_skill_resources
21
+ except ImportError: # Direct execution from scripts/.
22
+ import sync_skill_resources # type: ignore
23
+
24
+
25
+ def skill_names_for_manifest(manifest: Mapping) -> Tuple[str, ...]:
26
+ return tuple(manifest["skills"])
27
+
28
+
29
+ def targets_for_manifest(manifest: Mapping) -> Tuple[str, ...]:
30
+ return tuple(manifest["hosts"])
31
+
32
+
33
+ def local_roots_for_manifest(manifest: Mapping) -> Mapping[str, str]:
34
+ return {
35
+ target: config["local_root"]
36
+ for target, config in manifest["hosts"].items()
37
+ }
38
+
39
+
40
+ TARGETS = targets_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
41
+ LOCAL_ROOTS = local_roots_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
42
+ SKILL_NAMES = skill_names_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
43
+ SKILL_ALIASES = dict(sync_skill_resources.RESOURCE_MANIFEST["skill_aliases"])
44
+ ROUTER_ALIASES = dict(sync_skill_resources.RESOURCE_MANIFEST["router_aliases"])
45
+ CLAUDE_BRIDGE = "@../AGENTS.md\n@../WORKFLOW.md\n"
46
+ HOOKS_DIRECTORY = ".gsd-path"
47
+ GUARD_SCRIPTS = ("guard_hook.py", "git_guard.py")
48
+ GUARD_MARKER = "gsd-path guard"
49
+ PROJECT_RUNTIME_SCRIPTS = (
50
+ "pipeline_state.py",
51
+ "check_handoffs.py",
52
+ "check_task_briefs.py",
53
+ "isolation.py",
54
+ "discussion_records.py",
55
+ "pipeline_git.py",
56
+ "promote_lookahead.py",
57
+ "detect_project.py",
58
+ "pipeline_diagnose.py",
59
+ "pipeline_undo.py",
60
+ "archive_milestone.py",
61
+ "review_panel.py",
62
+ "_common.py",
63
+ "state_checkpoint.py",
64
+ "state_promote.py",
65
+ "discussion_validate.py",
66
+ "integration.py",
67
+ )
68
+ PROJECT_RUNTIME_MARKER = "gsd-path project runtime"
69
+ PROJECT_STATUS_LAUNCHER = "status_runtime.py"
70
+ PROJECT_STATUS_MARKER = "gsd-path project status launcher"
71
+ INSTALL_LOCK_NAME = ".gsd-path-install-lock"
72
+ INSTALL_LOCK_OWNER = "owner.json"
73
+ INSTALL_LOCK_SCHEMA = "gsd-path/install-lock/v2"
74
+ PROJECT_CONTRACTS = (
75
+ ("AGENTS.md", "## Plain-prompt re-entry"),
76
+ ("WORKFLOW.md", "### Plain-prompt re-entry"),
77
+ )
78
+ STATUS_ACTIONS = frozenset(
79
+ {
80
+ "bind-initial",
81
+ "block",
82
+ "resume-checkpoint",
83
+ "resume-next-handoff",
84
+ "resume-promotion",
85
+ "resume-shipment",
86
+ "resume-undo",
87
+ "run-phase",
88
+ "validate-integrated",
89
+ "wait",
90
+ }
91
+ )
92
+ STATUS_PHASES = frozenset(
93
+ {
94
+ "inspect",
95
+ "define",
96
+ "research",
97
+ "decide",
98
+ "roadmap",
99
+ "plan",
100
+ "build",
101
+ "ship",
102
+ "shipped",
103
+ }
104
+ )
105
+ STATUS_VALUES = frozenset({"active", "done", "blocked"})
106
+ STATUS_STATE_FIELDS = frozenset(
107
+ {
108
+ "pipeline",
109
+ "project",
110
+ "milestone",
111
+ "phase",
112
+ "status",
113
+ "branch",
114
+ "archive",
115
+ "integration_default",
116
+ "integration",
117
+ "integration_source",
118
+ }
119
+ )
120
+ STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
121
+ STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
122
+ STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
123
+ STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
124
+ STATUS_ARCHIVE = re.compile(r"^\.project/archive/(\d{3,})-([a-z0-9][a-z0-9-]*)/?$")
125
+ STATUS_TRANSITIONS = {
126
+ "inspect": frozenset({"inspect", "define"}),
127
+ "define": frozenset({"define", "research", "plan"}),
128
+ "research": frozenset({"research", "decide"}),
129
+ "decide": frozenset({"decide", "roadmap", "plan"}),
130
+ "roadmap": frozenset({"roadmap", "define"}),
131
+ "plan": frozenset({"plan", "build"}),
132
+ "build": frozenset({"build"}),
133
+ "ship": frozenset({"ship", "plan"}),
134
+ "shipped": frozenset({"ship"}),
135
+ }
136
+ CLAUDE_MATCHER = ".*"
137
+ def _claude_guard_entry(interpreter: str) -> dict:
138
+ """The managed PreToolUse guard entry, as an object."""
139
+ return {
140
+ "matcher": CLAUDE_MATCHER,
141
+ "hooks": [
142
+ {
143
+ "type": "command",
144
+ "command": (
145
+ f"{interpreter} \"$CLAUDE_PROJECT_DIR/"
146
+ f"{HOOKS_DIRECTORY}/guard_hook.py\""
147
+ ),
148
+ }
149
+ ],
150
+ }
151
+
152
+
153
+ def claude_hooks_settings(interpreter: str) -> str:
154
+ return (
155
+ json.dumps(
156
+ {"hooks": {"PreToolUse": [_claude_guard_entry(interpreter)]}},
157
+ indent=2,
158
+ )
159
+ + "\n"
160
+ )
161
+
162
+
163
+ def _codex_guard_command(interpreter: str) -> str:
164
+ return (
165
+ f'{interpreter} "$(git rev-parse --show-toplevel)/'
166
+ f'{HOOKS_DIRECTORY}/guard_hook.py"'
167
+ )
168
+
169
+
170
+ def _codex_guard_command_windows(interpreter: str) -> str:
171
+ return (
172
+ 'powershell.exe -NoProfile -NonInteractive -Command '
173
+ '"$root = git rev-parse --show-toplevel; '
174
+ f"& {interpreter} (Join-Path $root '{HOOKS_DIRECTORY}\\guard_hook.py')\""
175
+ )
176
+
177
+
178
+ def codex_hooks_settings(interpreter: str) -> str:
179
+ return json.dumps(
180
+ {"hooks": {"PreToolUse": [_codex_guard_entry(interpreter)]}},
181
+ indent=2,
182
+ ) + "\n"
183
+
184
+
185
+ def _codex_guard_entry(interpreter: str) -> dict:
186
+ return {
187
+ "matcher": ".*",
188
+ "hooks": [
189
+ {
190
+ "type": "command",
191
+ "command": _codex_guard_command(interpreter),
192
+ "commandWindows": _codex_guard_command_windows(interpreter),
193
+ }
194
+ ],
195
+ }
196
+
197
+
198
+ def cursor_hooks_settings(interpreter: str) -> str:
199
+ return json.dumps(
200
+ {
201
+ "version": 1,
202
+ "hooks": {"preToolUse": [_cursor_guard_entry(interpreter)]},
203
+ },
204
+ indent=2,
205
+ ) + "\n"
206
+
207
+
208
+ def _cursor_guard_entry(interpreter: str) -> dict:
209
+ return {
210
+ "command": f'{interpreter} "{HOOKS_DIRECTORY}/guard_hook.py"',
211
+ "matcher": ".*",
212
+ "failClosed": True,
213
+ }
214
+
215
+
216
+ def pre_commit_hook(interpreter: str) -> str:
217
+ return (
218
+ "#!/bin/sh\n"
219
+ "# gsd-path guard: archive immutability before commit.\n"
220
+ f"exec {interpreter} \"$(git rev-parse --show-toplevel)/"
221
+ f"{HOOKS_DIRECTORY}/git_guard.py\" pre-commit\n"
222
+ )
223
+
224
+
225
+ def commit_msg_hook(interpreter: str) -> str:
226
+ return (
227
+ "#!/bin/sh\n"
228
+ "# gsd-path guard: archive immutability and ship-commit purity.\n"
229
+ f"exec {interpreter} \"$(git rev-parse --show-toplevel)/"
230
+ f"{HOOKS_DIRECTORY}/git_guard.py\" commit-msg \"$1\"\n"
231
+ )
232
+
233
+
234
+ def pre_push_hook(interpreter: str) -> str:
235
+ return (
236
+ "#!/bin/sh\n"
237
+ "# gsd-path guard: bound branches publish only as ship commits.\n"
238
+ f"exec {interpreter} \"$(git rev-parse --show-toplevel)/"
239
+ f"{HOOKS_DIRECTORY}/git_guard.py\" pre-push \"$@\"\n"
240
+ )
241
+
242
+
243
+ GIT_HOOKS = (
244
+ ("pre-commit", pre_commit_hook),
245
+ ("commit-msg", commit_msg_hook),
246
+ ("pre-push", pre_push_hook),
247
+ )
248
+ GIT_HOOK_NAMES = tuple(name for name, _ in GIT_HOOKS)
249
+
250
+
251
+ def _detect_python_interpreter() -> Optional[str]:
252
+ """Probe for a runnable interpreter (python3, then python).
253
+
254
+ Emitted hooks must never hard-code an interpreter that does not exist on
255
+ this machine (python3 is typically absent on Windows).
256
+ """
257
+ for candidate in ("python3", "python"):
258
+ try:
259
+ result = subprocess.run(
260
+ [
261
+ candidate,
262
+ "-B",
263
+ "-c",
264
+ "import sys; raise SystemExit(sys.version_info < (3, 9))",
265
+ ],
266
+ capture_output=True,
267
+ check=False,
268
+ )
269
+ except OSError:
270
+ continue
271
+ if result.returncode == 0:
272
+ return candidate
273
+ return None
274
+
275
+
276
+ def _effective_interpreter() -> Optional[str]:
277
+ """Single owner of the interpreter probe and its policy.
278
+
279
+ Hook installation fails before writing when no interpreter works.
280
+ """
281
+ return _detect_python_interpreter()
282
+
283
+
284
+ def _resolve_git_hooks_path(project: "Path") -> Optional["Path"]:
285
+ """Effective hooks dir via `git rev-parse --git-path hooks`.
286
+
287
+ Honors core.hooksPath and linked worktrees. Returns None when git cannot
288
+ resolve it for this project.
289
+ """
290
+ try:
291
+ result = subprocess.run(
292
+ ["git", "rev-parse", "--show-toplevel", "--git-path", "hooks"],
293
+ cwd=os.fspath(project),
294
+ capture_output=True,
295
+ text=True,
296
+ check=False,
297
+ )
298
+ except OSError:
299
+ return None
300
+ if result.returncode != 0:
301
+ return None
302
+ lines = [line for line in result.stdout.splitlines() if line]
303
+ if len(lines) != 2:
304
+ return None
305
+ toplevel, hooks_path = lines
306
+ if not _same_path(Path(toplevel), project):
307
+ return None
308
+ return Path(os.path.abspath(os.path.join(os.fspath(project), hooks_path)))
309
+
310
+
311
+ def _git_hooks_directory(project: "Path") -> Optional["Path"]:
312
+ return _git_hooks_location(project)[0]
313
+
314
+
315
+ def _git_hooks_location(project: "Path") -> Tuple[Optional["Path"], bool]:
316
+ dot_git = project / ".git"
317
+ if not _lexists(dot_git):
318
+ return None, False
319
+ resolved = _resolve_git_hooks_path(project)
320
+ if resolved is not None:
321
+ return resolved, True
322
+ # Fallback when git is not runnable: only a plain .git directory is safe.
323
+ return (dot_git / "hooks" if dot_git.is_dir() else None), False
324
+
325
+
326
+ def _required_python_runtime(
327
+ command: str, selected: Sequence[str] = ()
328
+ ) -> str:
329
+ suffix = f" for selected hosts: {', '.join(selected)}" if selected else ""
330
+ interpreter = _effective_interpreter()
331
+ if interpreter is None:
332
+ raise InstallerError(
333
+ f"{command} requires a working Python interpreter{suffix}"
334
+ )
335
+ return interpreter
336
+
337
+
338
+ def _required_hook_runtime(
339
+ project: Path, command: str, selected: Sequence[str] = ()
340
+ ) -> Tuple[str, Path]:
341
+ suffix = f" for selected hosts: {', '.join(selected)}" if selected else ""
342
+ interpreter = _required_python_runtime(command, selected)
343
+ hooks_dir, resolved = _git_hooks_location(project)
344
+ if not resolved or hooks_dir is None:
345
+ raise InstallerError(
346
+ f"{command} requires an initialized Git repository with a "
347
+ f"resolvable hooks directory{suffix}"
348
+ )
349
+ return interpreter, hooks_dir
350
+
351
+
352
+ OPENCODE_NOTE = (
353
+ "note: OpenCode stable discovers the skills but has no documented hard "
354
+ 'explicit-only switch; OpenCode v2 honors opencode/autoinvoke="false" '
355
+ "and /gsd-path."
356
+ )
357
+ ANTIGRAVITY_NOTE = (
358
+ "note: Antigravity discovers /gsd-path but has no documented hard "
359
+ "explicit-only skill switch; invoke the skill explicitly."
360
+ )
361
+ KIRO_NOTE = (
362
+ "note: Kiro discovers /gsd-path but has no documented hard explicit-only "
363
+ "skill switch; invoke the skill explicitly."
364
+ )
365
+ HOST_NOTES = {
366
+ "opencode": OPENCODE_NOTE,
367
+ "antigravity": ANTIGRAVITY_NOTE,
368
+ "kiro": KIRO_NOTE,
369
+ }
370
+ EXPLICIT_ONLY_TARGETS = frozenset(
371
+ {"claude", "grok", "copilot", "qwen", "cursor", "zed", "kimi", "shared-agents"}
372
+ )
373
+ SHARED_AGENT_TARGETS = frozenset({"codex", "antigravity", "zed"})
374
+ SHARED_AGENT_PROFILE = "shared-agents"
375
+ CURSOR_AGENT_FILENAME = "gsd-path.md"
376
+ CURSOR_AGENT_BACKUP_NAME = "cursor-agent-gsd-path.md"
377
+
378
+
379
+ def _shared_invocations(text: str) -> str:
380
+ hosts = sync_skill_resources.RESOURCE_MANIFEST["hosts"]
381
+
382
+ def replace(match: re.Match) -> str:
383
+ quote = match.group("quote") or ""
384
+ skill = match.group("skill")[1:]
385
+ arguments = match.group("arguments") or ""
386
+ paired_quote = match.group("paired_quote") or ""
387
+ paired_skill = match.group("paired_skill")
388
+ paired_arguments = match.group("paired_arguments") or ""
389
+ if paired_skill is not None and (
390
+ paired_skill != skill
391
+ or paired_arguments != arguments
392
+ or paired_quote != quote
393
+ ):
394
+ raise InstallerError("shared invocation pair is inconsistent")
395
+ codex = f"{hosts['codex']['invocation_prefix']}{skill}"
396
+ others = f"{hosts['antigravity']['invocation_prefix']}{skill}"
397
+ return (
398
+ f"{quote}{codex}{arguments}{quote} (Codex) or "
399
+ f"{quote}{others}{arguments}{quote} (Antigravity/Zed)"
400
+ )
401
+
402
+ return re.sub(
403
+ r"(?P<quote>`?)(?P<skill>\$(?:gsd-path(?:-[a-z0-9]+)*|path))"
404
+ r"(?P<arguments> status)?(?P=quote)"
405
+ r"(?: \(Codex\) (?:and|or) (?P<paired_quote>`?)/"
406
+ r"(?P<paired_skill>gsd-path(?:-[a-z0-9]+)*|path)"
407
+ r"(?P<paired_arguments> status)?(?P=paired_quote)"
408
+ r" \((?:other hosts|Antigravity/Zed)\))?",
409
+ replace,
410
+ text,
411
+ )
412
+
413
+
414
+ class InstallerError(RuntimeError):
415
+ """A safe, user-facing installation failure."""
416
+
417
+
418
+ @dataclass(frozen=True)
419
+ class TargetPlan:
420
+ name: str
421
+ root: Path
422
+
423
+
424
+ @dataclass(frozen=True)
425
+ class DeploymentPlan:
426
+ profile: str
427
+ root: Path
428
+ targets: Tuple[str, ...]
429
+
430
+
431
+ @dataclass
432
+ class TargetTransaction:
433
+ root: Path
434
+ created_directories: List[Path] = field(default_factory=list)
435
+ backup: Optional[Path] = None
436
+ moved: List[Tuple[Path, Path]] = field(default_factory=list)
437
+ installed: List[Path] = field(default_factory=list)
438
+
439
+
440
+ @dataclass
441
+ class ProjectTransaction:
442
+ created_directories: List[Path] = field(default_factory=list)
443
+ copied: List[Path] = field(default_factory=list)
444
+ replaced: List[Tuple[Path, bytes, int]] = field(default_factory=list)
445
+
446
+
447
+ def absolute_path(path: Path) -> Path:
448
+ return Path(os.path.abspath(os.fspath(path.expanduser())))
449
+
450
+
451
+ def local_root(target: str, project_dir: Path) -> Path:
452
+ try:
453
+ relative = LOCAL_ROOTS[target]
454
+ except KeyError as error:
455
+ raise ValueError(f"unsupported target: {target}") from error
456
+ return absolute_path(project_dir) / relative
457
+
458
+
459
+ def default_root(target: str, environ: Optional[Mapping[str, str]] = None) -> Path:
460
+ env = os.environ if environ is None else environ
461
+ if target == "codex":
462
+ return absolute_path(Path("~/.agents/skills"))
463
+ if target == "claude":
464
+ return absolute_path(Path(env.get("CLAUDE_CONFIG_DIR") or "~/.claude") / "skills")
465
+ if target == "grok":
466
+ return absolute_path(Path(env.get("GROK_HOME") or "~/.grok") / "skills")
467
+ if target == "opencode":
468
+ config_dir = env.get("OPENCODE_CONFIG_DIR")
469
+ if config_dir:
470
+ base = Path(config_dir)
471
+ else:
472
+ config_file = env.get("OPENCODE_CONFIG")
473
+ if config_file:
474
+ base = absolute_path(Path(config_file)).parent
475
+ else:
476
+ base = Path(env.get("XDG_CONFIG_HOME") or "~/.config") / "opencode"
477
+ return absolute_path(base / "skills")
478
+ if target == "copilot":
479
+ return absolute_path(Path(env.get("COPILOT_HOME") or "~/.copilot") / "skills")
480
+ if target == "qwen":
481
+ return absolute_path(Path(env.get("QWEN_HOME") or "~/.qwen") / "skills")
482
+ if target == "antigravity":
483
+ return absolute_path(Path("~/.gemini/antigravity-cli/skills"))
484
+ if target == "cursor":
485
+ return absolute_path(Path("~/.cursor/skills"))
486
+ if target == "zed":
487
+ return absolute_path(Path("~/.agents/skills"))
488
+ if target == "kiro":
489
+ return absolute_path(Path(env.get("KIRO_HOME") or "~/.kiro") / "skills")
490
+ if target == "kimi":
491
+ return absolute_path(
492
+ Path(env.get("KIMI_CODE_HOME") or "~/.kimi-code") / "skills"
493
+ )
494
+ raise ValueError(f"unsupported target: {target}")
495
+
496
+
497
+ def legacy_codex_root(environ: Optional[Mapping[str, str]] = None) -> Path:
498
+ env = os.environ if environ is None else environ
499
+ return absolute_path(Path(env.get("CODEX_HOME") or "~/.codex") / "skills")
500
+
501
+
502
+ def _is_managed_name(name: str) -> bool:
503
+ normalized = name.casefold()
504
+ return (
505
+ normalized in ("ogsd", "gsd-path")
506
+ or normalized in ROUTER_ALIASES
507
+ or normalized.startswith(("ogsd-", "gsd-path-"))
508
+ )
509
+
510
+
511
+ def _is_owned_router_alias(skill_dir: Path) -> bool:
512
+ runtime = skill_dir / "scripts" / "pipeline_state.py"
513
+ if not runtime.is_file():
514
+ return False
515
+ version_file = skill_dir / "VERSION"
516
+ if not version_file.is_file(): # installs before alias version stamping
517
+ return _is_managed_project_runtime(runtime)
518
+ try:
519
+ version = version_file.read_text(encoding="utf-8").strip()
520
+ except (OSError, UnicodeError):
521
+ return False
522
+ return re.fullmatch(r"[0-9]+(?:\.[0-9]+)+", version) is not None
523
+
524
+
525
+ def _is_managed_install_entry(root: Path, name: str) -> bool:
526
+ if not _is_managed_name(name):
527
+ return False
528
+ if name.casefold() not in ROUTER_ALIASES:
529
+ return True
530
+ return _is_owned_router_alias(root / name)
531
+
532
+
533
+ def _reject_router_alias_collisions(root: Path) -> None:
534
+ if not root.is_dir():
535
+ return
536
+ for alias in ROUTER_ALIASES:
537
+ candidate = root / alias
538
+ if not _lexists(candidate):
539
+ continue
540
+ if _is_owned_router_alias(candidate):
541
+ continue
542
+ raise InstallerError(
543
+ f"refusing to replace unrelated skill {candidate}; "
544
+ "rename it or move it aside before installing GSD Path"
545
+ )
546
+
547
+
548
+ def _stamp_installed_versions(staged_root: Path, version: Optional[str]) -> None:
549
+ if not version:
550
+ return
551
+ for name in {"gsd-path", *ROUTER_ALIASES}:
552
+ (staged_root / name / "VERSION").write_text(f"{version}\n", encoding="utf-8")
553
+
554
+
555
+ def _lexists(path: Path) -> bool:
556
+ return os.path.lexists(os.fspath(path))
557
+
558
+
559
+ def _validate_directory_destination(path: Path, label: str) -> None:
560
+ if _lexists(path):
561
+ if path.is_symlink():
562
+ raise InstallerError(f"{label} is a symlink: {path}")
563
+ if not path.is_dir():
564
+ raise InstallerError(f"{label} is not a directory: {path}")
565
+ return
566
+ parent = path.parent
567
+ while not _lexists(parent):
568
+ parent = parent.parent
569
+ if parent.is_symlink() or not parent.is_dir():
570
+ raise InstallerError(f"{label} has an unsafe parent: {parent}")
571
+
572
+
573
+ def _reject_source_symlinks(path: Path) -> None:
574
+ for directory, names, files in os.walk(path, followlinks=False):
575
+ base = Path(directory)
576
+ for name in names + files:
577
+ candidate = base / name
578
+ if candidate.is_symlink():
579
+ raise InstallerError(f"source contains a symlink: {candidate}")
580
+
581
+
582
+ def validate_source(source_root: Path, profiles: Sequence[str]) -> Tuple[str, ...]:
583
+ problems = sync_skill_resources.mismatches(source_root)
584
+ if problems:
585
+ raise InstallerError("source resources are stale: " + "; ".join(problems))
586
+
587
+ skills_root = source_root / "skills"
588
+ if not skills_root.is_dir() or skills_root.is_symlink():
589
+ raise InstallerError(f"missing safe skills directory: {skills_root}")
590
+ found = {
591
+ entry.name
592
+ for entry in skills_root.iterdir()
593
+ if _is_managed_name(entry.name)
594
+ and (entry.name.startswith("gsd-path") or entry.name in ROUTER_ALIASES)
595
+ }
596
+ if found != set(SKILL_NAMES):
597
+ raise InstallerError(
598
+ f"expected exactly {len(SKILL_NAMES)} GSD Path skills; found "
599
+ + ", ".join(sorted(found))
600
+ )
601
+ for name in SKILL_NAMES:
602
+ skill = skills_root / name
603
+ if skill.is_symlink() or not skill.is_dir() or not (skill / "SKILL.md").is_file():
604
+ raise InstallerError(f"invalid skill directory: {skill}")
605
+ _reject_source_symlinks(skill)
606
+
607
+ for profile in profiles:
608
+ adapter = source_root / "platforms" / profile / "dispatch.md"
609
+ if adapter.is_symlink() or not adapter.is_file():
610
+ raise InstallerError(f"missing dispatch adapter: {adapter}")
611
+ if profile == "cursor":
612
+ agent = source_root / "platforms" / "cursor" / "agent.md"
613
+ if agent.is_symlink() or not agent.is_file():
614
+ raise InstallerError(f"missing Cursor subagent: {agent}")
615
+ return SKILL_NAMES
616
+
617
+
618
+ def _augment_frontmatter(text: str, target: str) -> str:
619
+ lines = text.splitlines()
620
+ if not lines or lines[0] != "---":
621
+ raise InstallerError("SKILL.md is missing YAML frontmatter")
622
+ try:
623
+ end = lines.index("---", 1)
624
+ except ValueError as error:
625
+ raise InstallerError("SKILL.md has unterminated YAML frontmatter") from error
626
+ header = lines[1:end]
627
+
628
+ if target in EXPLICIT_ONLY_TARGETS:
629
+ key = "disable-model-invocation:"
630
+ replacement = "disable-model-invocation: true"
631
+ for index, line in enumerate(header):
632
+ if line.startswith(key):
633
+ header[index] = replacement
634
+ break
635
+ else:
636
+ header.append(replacement)
637
+ if target in {"opencode", SHARED_AGENT_PROFILE}:
638
+ try:
639
+ metadata = header.index("metadata:")
640
+ except ValueError:
641
+ header.extend(
642
+ [
643
+ "metadata:",
644
+ ' opencode/autoinvoke: "false"',
645
+ ' opencode/slash: "true"',
646
+ ]
647
+ )
648
+ else:
649
+ section_end = metadata + 1
650
+ while section_end < len(header) and (
651
+ not header[section_end] or header[section_end][0].isspace()
652
+ ):
653
+ section_end += 1
654
+ values = {
655
+ "opencode/autoinvoke:": ' opencode/autoinvoke: "false"',
656
+ "opencode/slash:": ' opencode/slash: "true"',
657
+ }
658
+ for key, replacement in values.items():
659
+ for index in range(metadata + 1, section_end):
660
+ if header[index].strip().startswith(key):
661
+ header[index] = replacement
662
+ break
663
+ else:
664
+ header.insert(section_end, replacement)
665
+ section_end += 1
666
+
667
+ transformed = ["---", *header, "---", *lines[end + 1 :]]
668
+ return "\n".join(transformed) + ("\n" if text.endswith(("\n", "\r")) else "")
669
+
670
+
671
+ def stage_target(source_root: Path, target: str, staged_root: Path) -> None:
672
+ skills_root = source_root / "skills"
673
+ for name in SKILL_NAMES:
674
+ shutil.copytree(skills_root / name, staged_root / name)
675
+
676
+ manifest = source_root / "package.json"
677
+ if manifest.is_file():
678
+ try:
679
+ version = json.loads(manifest.read_text(encoding="utf-8")).get("version")
680
+ except (json.JSONDecodeError, AttributeError):
681
+ version = None
682
+ _stamp_installed_versions(staged_root, version)
683
+
684
+ adapter = (source_root / "platforms" / target / "dispatch.md").read_text(
685
+ encoding="utf-8"
686
+ )
687
+ for dispatch in sorted(staged_root.glob("*/references/dispatch.md")):
688
+ dispatch.write_text(adapter, encoding="utf-8")
689
+ if target == "cursor":
690
+ shutil.copy2(
691
+ source_root / "platforms" / "cursor" / "agent.md",
692
+ staged_root / CURSOR_AGENT_FILENAME,
693
+ )
694
+
695
+ if target != "codex":
696
+ if target != SHARED_AGENT_PROFILE:
697
+ for name in SKILL_NAMES:
698
+ metadata = staged_root / name / "agents"
699
+ if metadata.is_dir():
700
+ shutil.rmtree(metadata)
701
+ invocation = "gsd-path" if target == "opencode" else "/gsd-path"
702
+ path_invocation = "path" if target == "opencode" else "/path"
703
+ for name in SKILL_NAMES:
704
+ entrypoint = staged_root / name / "SKILL.md"
705
+ entrypoint.write_text(
706
+ _augment_frontmatter(entrypoint.read_text(encoding="utf-8"), target),
707
+ encoding="utf-8",
708
+ )
709
+ for markdown in sorted(staged_root.rglob("*.md")):
710
+ content = markdown.read_text(encoding="utf-8")
711
+ transformed = (
712
+ _shared_invocations(content)
713
+ if target == SHARED_AGENT_PROFILE
714
+ else content.replace("$gsd-path", invocation).replace("$path", path_invocation)
715
+ )
716
+ markdown.write_text(transformed, encoding="utf-8")
717
+
718
+
719
+ def _missing_directories(path: Path) -> List[Path]:
720
+ missing = []
721
+ cursor = path
722
+ while not _lexists(cursor):
723
+ missing.append(cursor)
724
+ cursor = cursor.parent
725
+ return list(reversed(missing))
726
+
727
+
728
+ def _create_directory(path: Path, created: List[Path]) -> None:
729
+ missing = _missing_directories(path)
730
+ created.extend(missing)
731
+ path.mkdir(parents=True, exist_ok=True)
732
+
733
+
734
+ def _release_install_locks(locks: Sequence[Path], created: Sequence[Path]) -> None:
735
+ for lock in reversed(locks):
736
+ (lock / INSTALL_LOCK_OWNER).unlink(missing_ok=True)
737
+ lock.rmdir()
738
+ _remove_empty_directories(created)
739
+
740
+
741
+ def _process_identity(pid: int) -> Optional[str]:
742
+ if os.name == "nt":
743
+ command = [
744
+ "powershell.exe",
745
+ "-NoProfile",
746
+ "-NonInteractive",
747
+ "-Command",
748
+ f"(Get-Process -Id {pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks",
749
+ ]
750
+ else:
751
+ command = ["ps", "-o", "lstart=", "-p", str(pid)]
752
+ try:
753
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
754
+ except OSError:
755
+ return None
756
+ value = result.stdout.strip()
757
+ return f"{os.name}:{value}" if result.returncode == 0 and value else None
758
+
759
+
760
+ def _process_alive(pid: int) -> bool:
761
+ try:
762
+ os.kill(pid, 0)
763
+ except OSError as error:
764
+ return error.errno == errno.EPERM
765
+ return True
766
+
767
+
768
+ def _stale_install_lock_snapshot(lock: Path) -> Tuple[os.stat_result, bytes]:
769
+ if lock.is_symlink() or not lock.is_dir():
770
+ raise InstallerError(f"unsafe installation lock: {lock}")
771
+ try:
772
+ observed = lock.stat()
773
+ entries = list(lock.iterdir())
774
+ owner_bytes = (lock / INSTALL_LOCK_OWNER).read_bytes()
775
+ owner = json.loads(owner_bytes)
776
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
777
+ raise InstallerError(
778
+ f"installation already in progress for {lock.parent}"
779
+ ) from error
780
+ pid = owner.get("pid")
781
+ identity = owner.get("identity")
782
+ valid = (
783
+ owner.get("schema") == INSTALL_LOCK_SCHEMA
784
+ and isinstance(pid, int)
785
+ and not isinstance(pid, bool)
786
+ and pid > 0
787
+ and isinstance(identity, str)
788
+ and identity
789
+ and len(entries) == 1
790
+ and entries[0].name == INSTALL_LOCK_OWNER
791
+ )
792
+ current_identity = _process_identity(pid) if valid else None
793
+ if (
794
+ not valid
795
+ or current_identity == identity
796
+ or (current_identity is None and _process_alive(pid))
797
+ ):
798
+ raise InstallerError(f"installation already in progress for {lock.parent}")
799
+ return observed, owner_bytes
800
+
801
+
802
+ def _recover_stale_install_lock(lock: Path, quarantine: Path) -> Optional[Path]:
803
+ legacy_quarantine = lock.with_name(f"{lock.name}.stale")
804
+ if not _lexists(lock):
805
+ prefix = f"{lock.name}.stale-"
806
+ for orphan in lock.parent.glob(f"{prefix}*"):
807
+ staging = lock.parent / orphan.name.removeprefix(prefix)
808
+ try:
809
+ _stale_install_lock_snapshot(orphan)
810
+ if _lexists(staging):
811
+ _stale_install_lock_snapshot(staging)
812
+ except InstallerError:
813
+ continue
814
+ if _lexists(staging):
815
+ shutil.rmtree(staging)
816
+ shutil.rmtree(orphan)
817
+ if _lexists(legacy_quarantine):
818
+ _stale_install_lock_snapshot(legacy_quarantine)
819
+ shutil.rmtree(legacy_quarantine)
820
+ return None
821
+ observed, owner_bytes = _stale_install_lock_snapshot(lock)
822
+ if _lexists(legacy_quarantine):
823
+ _stale_install_lock_snapshot(legacy_quarantine)
824
+ shutil.rmtree(legacy_quarantine)
825
+ try:
826
+ lock.rename(quarantine)
827
+ except (FileNotFoundError, FileExistsError) as error:
828
+ raise InstallerError(
829
+ f"installation already in progress for {lock.parent}"
830
+ ) from error
831
+ try:
832
+ moved = quarantine.stat()
833
+ moved_owner = (quarantine / INSTALL_LOCK_OWNER).read_bytes()
834
+ except OSError as error:
835
+ raise InstallerError(
836
+ f"installation already in progress for {lock.parent}"
837
+ ) from error
838
+ if (
839
+ (moved.st_dev, moved.st_ino) != (observed.st_dev, observed.st_ino)
840
+ or moved_owner != owner_bytes
841
+ ):
842
+ if not _lexists(lock):
843
+ quarantine.rename(lock)
844
+ raise InstallerError(f"installation already in progress for {lock.parent}")
845
+ return quarantine
846
+
847
+
848
+ def _create_install_lock(lock: Path) -> None:
849
+ identity = _process_identity(os.getpid())
850
+ if identity is None:
851
+ raise InstallerError("cannot determine installer process identity")
852
+ staging = Path(tempfile.mkdtemp(prefix=".install-lock-stage-", dir=lock.parent))
853
+ recovery = lock.with_name(f"{lock.name}.stale-{staging.name}")
854
+ quarantine = None
855
+ published = False
856
+ try:
857
+ (staging / INSTALL_LOCK_OWNER).write_text(
858
+ json.dumps(
859
+ {
860
+ "schema": INSTALL_LOCK_SCHEMA,
861
+ "pid": os.getpid(),
862
+ "identity": identity,
863
+ }
864
+ )
865
+ + "\n",
866
+ encoding="utf-8",
867
+ )
868
+ quarantine = _recover_stale_install_lock(lock, recovery)
869
+ staging.rename(lock)
870
+ published = True
871
+ if quarantine is not None:
872
+ shutil.rmtree(quarantine)
873
+ except BaseException:
874
+ shutil.rmtree(staging, ignore_errors=True)
875
+ if quarantine is not None and _lexists(quarantine):
876
+ if published and _lexists(lock):
877
+ shutil.rmtree(lock)
878
+ if not _lexists(lock):
879
+ quarantine.rename(lock)
880
+ else:
881
+ shutil.rmtree(quarantine)
882
+ if _lexists(lock):
883
+ raise InstallerError(
884
+ f"installation already in progress for {lock.parent}"
885
+ )
886
+ raise
887
+
888
+
889
+ def _acquire_install_locks(roots: Iterable[Path]) -> Tuple[List[Path], List[Path]]:
890
+ locks: List[Path] = []
891
+ for root in roots:
892
+ candidate = root.parent / INSTALL_LOCK_NAME
893
+ if not any(_same_path(candidate, existing) for existing in locks):
894
+ locks.append(candidate)
895
+ locks.sort(key=lambda candidate: os.path.normcase(os.fspath(candidate)))
896
+ acquired: List[Path] = []
897
+ created: List[Path] = []
898
+ try:
899
+ for lock in locks:
900
+ _create_directory(lock.parent, created)
901
+ _create_install_lock(lock)
902
+ acquired.append(lock)
903
+ except BaseException:
904
+ _release_install_locks(acquired, created)
905
+ raise
906
+ return acquired, created
907
+
908
+
909
+ def _backup_path(root: Path, reserved: Sequence[Path] = ()) -> Path:
910
+ candidate = root.parent / "disabled-gsd-skills"
911
+ number = 1
912
+ while _lexists(candidate) or any(
913
+ _same_path(candidate, path) for path in reserved
914
+ ):
915
+ candidate = root.parent / f"disabled-gsd-skills-{number}"
916
+ number += 1
917
+ return candidate
918
+
919
+
920
+ def _backup_existing(
921
+ transaction: TargetTransaction,
922
+ extras: Sequence[Tuple[Path, str]] = (),
923
+ ) -> None:
924
+ existing = sorted(
925
+ (
926
+ (entry, entry.name)
927
+ for entry in transaction.root.iterdir()
928
+ if _is_managed_install_entry(transaction.root, entry.name)
929
+ ),
930
+ key=lambda item: item[0].name,
931
+ )
932
+ existing.extend((path, backup_name) for path, backup_name in extras if _lexists(path))
933
+ if existing:
934
+ transaction.backup = _backup_path(transaction.root)
935
+ transaction.backup.mkdir()
936
+ for entry, backup_name in existing:
937
+ stored = transaction.backup / backup_name
938
+ transaction.moved.append((entry, stored))
939
+ os.replace(entry, stored)
940
+
941
+
942
+ def _reserve_directory(path: Path) -> None:
943
+ path.mkdir()
944
+
945
+
946
+ def _reserve_file(path: Path) -> None:
947
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
948
+ try:
949
+ os.close(descriptor)
950
+ except BaseException:
951
+ path.unlink(missing_ok=True)
952
+ raise
953
+
954
+
955
+ def _apply_target(
956
+ plan: DeploymentPlan, staged_root: Path, transaction: TargetTransaction
957
+ ) -> None:
958
+ _create_directory(plan.root, transaction.created_directories)
959
+ extras = []
960
+ cursor_agent = None
961
+ if plan.profile == "cursor":
962
+ cursor_agent = plan.root.parent / "agents" / CURSOR_AGENT_FILENAME
963
+ _create_directory(cursor_agent.parent, transaction.created_directories)
964
+ extras.append((cursor_agent, CURSOR_AGENT_BACKUP_NAME))
965
+ _reject_router_alias_collisions(plan.root)
966
+ _backup_existing(transaction, extras)
967
+ for name in SKILL_NAMES:
968
+ destination = plan.root / name
969
+ _reserve_directory(destination)
970
+ transaction.installed.append(destination)
971
+ shutil.copytree(staged_root / name, destination, dirs_exist_ok=True)
972
+ if cursor_agent is not None:
973
+ _reserve_file(cursor_agent)
974
+ transaction.installed.append(cursor_agent)
975
+ shutil.copy2(staged_root / CURSOR_AGENT_FILENAME, cursor_agent)
976
+
977
+
978
+ def _remove_path(path: Path) -> None:
979
+ if not _lexists(path):
980
+ return
981
+ if path.is_symlink() or not path.is_dir():
982
+ path.unlink()
983
+ else:
984
+ shutil.rmtree(path)
985
+
986
+
987
+ def _remove_empty_directories(paths: Iterable[Path]) -> None:
988
+ for path in reversed(list(paths)):
989
+ try:
990
+ path.rmdir()
991
+ except OSError:
992
+ pass
993
+
994
+
995
+ def _rollback_target(transaction: TargetTransaction) -> None:
996
+ for destination in reversed(transaction.installed):
997
+ _remove_path(destination)
998
+ for original, stored in reversed(transaction.moved):
999
+ if _lexists(stored):
1000
+ os.replace(stored, original)
1001
+ if transaction.backup is not None:
1002
+ try:
1003
+ transaction.backup.rmdir()
1004
+ except OSError:
1005
+ pass
1006
+ _remove_empty_directories(transaction.created_directories)
1007
+
1008
+
1009
+ def _project_destinations(
1010
+ project: Path,
1011
+ selected: Sequence[str],
1012
+ hooks: bool,
1013
+ interpreter: str,
1014
+ hooks_dir: Optional[Path],
1015
+ ) -> List[Tuple[Path, Optional[str], Optional[str], bool]]:
1016
+ """(destination, source name, literal content, executable) per file.
1017
+
1018
+ hooks_dir is the pre-resolved git hooks directory (or None); resolving
1019
+ it once per run avoids repeated `git rev-parse` spawns.
1020
+ """
1021
+ destinations: List[Tuple[Path, Optional[str], Optional[str], bool]] = [
1022
+ (project / "AGENTS.md", "AGENTS.md", None, False),
1023
+ (project / "WORKFLOW.md", "WORKFLOW.md", None, False),
1024
+ (
1025
+ project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER,
1026
+ f"scripts/{PROJECT_STATUS_LAUNCHER}",
1027
+ None,
1028
+ False,
1029
+ ),
1030
+ ]
1031
+ destinations.extend(
1032
+ (
1033
+ project / HOOKS_DIRECTORY / "runtime" / name,
1034
+ f"scripts/{name}",
1035
+ None,
1036
+ False,
1037
+ )
1038
+ for name in PROJECT_RUNTIME_SCRIPTS
1039
+ )
1040
+ if "claude" in selected:
1041
+ destinations.append(
1042
+ (project / ".claude" / "CLAUDE.md", None, CLAUDE_BRIDGE, False)
1043
+ )
1044
+ if hooks:
1045
+ for name in GUARD_SCRIPTS:
1046
+ destinations.append(
1047
+ (project / HOOKS_DIRECTORY / name, f"scripts/{name}", None, False)
1048
+ )
1049
+ if "claude" in selected:
1050
+ destinations.append(
1051
+ (
1052
+ project / ".claude" / "settings.json",
1053
+ None,
1054
+ claude_hooks_settings(interpreter),
1055
+ False,
1056
+ )
1057
+ )
1058
+ if "codex" in selected:
1059
+ destinations.append(
1060
+ (
1061
+ project / ".codex" / "hooks.json",
1062
+ None,
1063
+ codex_hooks_settings(interpreter),
1064
+ False,
1065
+ )
1066
+ )
1067
+ if "cursor" in selected:
1068
+ destinations.append(
1069
+ (
1070
+ project / ".cursor" / "hooks.json",
1071
+ None,
1072
+ cursor_hooks_settings(interpreter),
1073
+ False,
1074
+ )
1075
+ )
1076
+ if hooks_dir is not None:
1077
+ for hook_name, generator in GIT_HOOKS:
1078
+ destinations.append(
1079
+ (hooks_dir / hook_name, None, generator(interpreter), True)
1080
+ )
1081
+ return destinations
1082
+
1083
+
1084
+ def _native_settings_mergers(
1085
+ project: Path, selected: Sequence[str], hooks: bool
1086
+ ) -> Mapping[Path, Callable[[Path, str], str]]:
1087
+ mergers = {}
1088
+ if not hooks:
1089
+ return mergers
1090
+ if "claude" in selected:
1091
+ mergers[project / ".claude" / "settings.json"] = _merged_claude_settings
1092
+ if "codex" in selected:
1093
+ mergers[project / ".codex" / "hooks.json"] = _merged_codex_settings
1094
+ if "cursor" in selected:
1095
+ mergers[project / ".cursor" / "hooks.json"] = _merged_cursor_settings
1096
+ return mergers
1097
+
1098
+
1099
+ def _update_replacement(
1100
+ project: Path, destination: Path, hooks_dir: Optional[Path]
1101
+ ) -> Optional[Callable[[Path], bool]]:
1102
+ """--update replaces managed runtime, guard, and git hook files in place
1103
+ and keeps everything else (AGENTS.md, WORKFLOW.md, CLAUDE.md). Returns
1104
+ the managed-file check for a replaceable destination, or None for a
1105
+ kept one."""
1106
+ parent = project / HOOKS_DIRECTORY
1107
+ directory = destination.parent
1108
+ if _same_path(directory, parent / "runtime"):
1109
+ return _is_managed_project_runtime
1110
+ if _same_path(destination, parent / PROJECT_STATUS_LAUNCHER):
1111
+ return _is_managed_project_status_launcher
1112
+ if any(_same_path(destination, parent / name) for name in GUARD_SCRIPTS):
1113
+ return _is_managed_guard_script
1114
+ if hooks_dir is not None and _same_path(directory, hooks_dir):
1115
+ return _is_managed_git_hook
1116
+ return None
1117
+
1118
+
1119
+ def _describe_project_path(project: Path, destination: Path) -> str:
1120
+ try:
1121
+ return destination.relative_to(project).as_posix()
1122
+ except ValueError:
1123
+ return destination.as_posix()
1124
+
1125
+
1126
+ def _project_files(
1127
+ project: Path,
1128
+ selected: Sequence[str],
1129
+ hooks: bool,
1130
+ interpreter: str,
1131
+ hooks_dir: Optional[Path],
1132
+ ) -> str:
1133
+ return ", ".join(
1134
+ _describe_project_path(project, destination)
1135
+ for destination, _, _, _ in _project_destinations(
1136
+ project, selected, hooks, interpreter, hooks_dir
1137
+ )
1138
+ )
1139
+
1140
+
1141
+ def _project_result(
1142
+ project: Path,
1143
+ selected: Sequence[str],
1144
+ hooks: bool,
1145
+ interpreter: str,
1146
+ hooks_dir: Optional[Path],
1147
+ update: bool,
1148
+ dry_run: bool,
1149
+ ) -> str:
1150
+ if not update:
1151
+ files = _project_files(project, selected, hooks, interpreter, hooks_dir)
1152
+ verb = "would copy" if dry_run else "copied"
1153
+ return f"project: {verb} {files} to {project}"
1154
+ mergers = _native_settings_mergers(project, selected, hooks)
1155
+ refreshed = []
1156
+ kept = []
1157
+ for destination, _, _, _ in _project_destinations(
1158
+ project, selected, hooks, interpreter, hooks_dir
1159
+ ):
1160
+ replaceable = (
1161
+ destination in mergers
1162
+ or _update_replacement(project, destination, hooks_dir) is not None
1163
+ )
1164
+ bucket = kept if _lexists(destination) and not replaceable else refreshed
1165
+ bucket.append(_describe_project_path(project, destination))
1166
+ verb = "would refresh" if dry_run else "refreshed"
1167
+ line = f"project: {verb} {', '.join(refreshed)} in {project}"
1168
+ if kept:
1169
+ line += f"; kept {', '.join(kept)}"
1170
+ return line
1171
+
1172
+
1173
+ def _existing_contract_error(destination: Path) -> "InstallerError":
1174
+ return InstallerError(
1175
+ f"project contract already exists: {destination} — the installer never "
1176
+ "overwrites project files. Run --update --project PATH to refresh managed "
1177
+ "runtime files; merge template changes manually (see UPDATE.md)."
1178
+ )
1179
+
1180
+
1181
+ def _validate_project_git_root(project: Path) -> None:
1182
+ probe = project
1183
+ while not _lexists(probe):
1184
+ probe = probe.parent
1185
+ try:
1186
+ result = subprocess.run(
1187
+ ["git", "rev-parse", "--show-toplevel"],
1188
+ cwd=probe,
1189
+ capture_output=True,
1190
+ text=True,
1191
+ check=False,
1192
+ env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
1193
+ )
1194
+ except OSError:
1195
+ return
1196
+ top_level = result.stdout.strip()
1197
+ if (
1198
+ result.returncode == 0
1199
+ and top_level
1200
+ and not _same_path(Path(top_level), project)
1201
+ ):
1202
+ raise InstallerError(f"project path is not the Git worktree root: {project}")
1203
+
1204
+
1205
+ def _validate_project(
1206
+ source_root: Path,
1207
+ project: Path,
1208
+ selected: Sequence[str],
1209
+ hooks: bool,
1210
+ reserved_roots: Sequence[Tuple[str, Path]],
1211
+ interpreter: str,
1212
+ hooks_dir: Optional[Path],
1213
+ update: bool = False,
1214
+ ) -> None:
1215
+ _validate_directory_destination(project, "project path")
1216
+ _validate_project_git_root(project)
1217
+ _validate_directory_destination(
1218
+ project / HOOKS_DIRECTORY, "project runtime parent directory"
1219
+ )
1220
+ _validate_directory_destination(
1221
+ project / HOOKS_DIRECTORY / "runtime", "project runtime directory"
1222
+ )
1223
+ project_directories = []
1224
+ if "claude" in selected:
1225
+ project_directories.append(("Claude", project / ".claude"))
1226
+ if hooks and "codex" in selected:
1227
+ project_directories.append(("Codex", project / ".codex"))
1228
+ if hooks and "cursor" in selected:
1229
+ project_directories.append(("Cursor", project / ".cursor"))
1230
+ for label, directory in project_directories:
1231
+ if _lexists(directory) and (
1232
+ directory.is_symlink() or not directory.is_dir()
1233
+ ):
1234
+ raise InstallerError(f"unsafe {label} project directory: {directory}")
1235
+ sources = [
1236
+ "AGENTS.md",
1237
+ "WORKFLOW.md",
1238
+ f"scripts/{PROJECT_STATUS_LAUNCHER}",
1239
+ *(f"scripts/{name}" for name in PROJECT_RUNTIME_SCRIPTS),
1240
+ ]
1241
+ if hooks:
1242
+ sources.extend(f"scripts/{name}" for name in GUARD_SCRIPTS)
1243
+ for source_name in sources:
1244
+ source = source_root / source_name
1245
+ if source.is_symlink() or not source.is_file():
1246
+ raise InstallerError(f"missing project contract: {source}")
1247
+ mergers = _native_settings_mergers(project, selected, hooks)
1248
+ for destination, _, _, _ in _project_destinations(
1249
+ project, selected, hooks, interpreter, hooks_dir
1250
+ ):
1251
+ if _lexists(destination):
1252
+ merge = mergers.get(destination)
1253
+ managed = (
1254
+ _update_replacement(project, destination, hooks_dir) if update else None
1255
+ )
1256
+ if destination.is_symlink() or (merge is None and not update):
1257
+ raise _existing_contract_error(destination)
1258
+ if merge is not None:
1259
+ merge(destination, interpreter)
1260
+ elif managed is not None and not managed(destination):
1261
+ raise InstallerError(
1262
+ f"not a managed GSD Path project file: {destination}"
1263
+ )
1264
+ for label, root in reserved_roots:
1265
+ if _paths_overlap(destination, root):
1266
+ raise InstallerError(
1267
+ f"project contract overlaps {label}: {destination}, {root}"
1268
+ )
1269
+
1270
+
1271
+ def _apply_project(
1272
+ source_root: Path,
1273
+ project: Path,
1274
+ selected: Sequence[str],
1275
+ hooks: bool,
1276
+ transaction: ProjectTransaction,
1277
+ interpreter: str,
1278
+ hooks_dir: Optional[Path],
1279
+ update: bool = False,
1280
+ ) -> None:
1281
+ _create_directory(project, transaction.created_directories)
1282
+ mergers = _native_settings_mergers(project, selected, hooks)
1283
+ for destination, source_name, content, executable in _project_destinations(
1284
+ project, selected, hooks, interpreter, hooks_dir
1285
+ ):
1286
+ _create_directory(destination.parent, transaction.created_directories)
1287
+ merge = mergers.get(destination)
1288
+ if _lexists(destination) and not destination.is_symlink():
1289
+ replaceable = (
1290
+ update
1291
+ and _update_replacement(project, destination, hooks_dir) is not None
1292
+ )
1293
+ if merge is not None or replaceable:
1294
+ original = destination.read_bytes()
1295
+ mode = destination.stat().st_mode & 0o777
1296
+ if merge is not None:
1297
+ replacement: Union[str, bytes] = merge(destination, interpreter)
1298
+ elif source_name:
1299
+ replacement = (source_root / source_name).read_bytes()
1300
+ else:
1301
+ replacement = content or ""
1302
+ _atomic_write(
1303
+ destination, replacement, 0o755 if executable else mode
1304
+ )
1305
+ transaction.replaced.append((destination, original, mode))
1306
+ continue
1307
+ if update:
1308
+ continue
1309
+ created = False
1310
+ try:
1311
+ if source_name:
1312
+ with (source_root / source_name).open("rb") as source:
1313
+ with destination.open("xb") as output:
1314
+ created = True
1315
+ shutil.copyfileobj(source, output)
1316
+ else:
1317
+ with destination.open("x", encoding="utf-8") as output:
1318
+ created = True
1319
+ output.write(content)
1320
+ if executable:
1321
+ destination.chmod(0o755)
1322
+ transaction.copied.append(destination)
1323
+ except FileExistsError as error:
1324
+ raise _existing_contract_error(destination) from error
1325
+ except (Exception, KeyboardInterrupt):
1326
+ if created:
1327
+ _remove_path(destination)
1328
+ raise
1329
+
1330
+
1331
+ def _rollback_project(transaction: ProjectTransaction) -> None:
1332
+ for destination, original, mode in reversed(transaction.replaced):
1333
+ _atomic_write(destination, original, mode)
1334
+ for destination in reversed(transaction.copied):
1335
+ _remove_path(destination)
1336
+ _remove_empty_directories(transaction.created_directories)
1337
+
1338
+
1339
+ def _managed_file_contains(destination: Path, marker: str, label: str) -> bool:
1340
+ if not destination.is_file():
1341
+ return False
1342
+ try:
1343
+ content = destination.read_text(encoding="utf-8", errors="replace")
1344
+ except OSError as error:
1345
+ raise InstallerError(f"cannot read {label}: {destination}") from error
1346
+ return marker in content
1347
+
1348
+
1349
+ def _is_managed_guard_script(destination: Path) -> bool:
1350
+ return _managed_file_contains(destination, GUARD_MARKER, "guard script")
1351
+
1352
+
1353
+ def _is_managed_project_status_launcher(destination: Path) -> bool:
1354
+ return _managed_file_contains(
1355
+ destination, PROJECT_STATUS_MARKER, "project status launcher"
1356
+ )
1357
+
1358
+
1359
+ def _is_managed_project_runtime(destination: Path) -> bool:
1360
+ return _managed_file_contains(
1361
+ destination, PROJECT_RUNTIME_MARKER, "project runtime"
1362
+ )
1363
+
1364
+
1365
+ def _is_managed_git_hook_content(text: str) -> bool:
1366
+ return GUARD_MARKER in text and "git_guard.py" in text
1367
+
1368
+
1369
+ def _is_managed_git_hook(destination: Path) -> bool:
1370
+ if not destination.is_file():
1371
+ return False
1372
+ try:
1373
+ text = destination.read_text(encoding="utf-8", errors="replace")
1374
+ except OSError:
1375
+ return False
1376
+ return _is_managed_git_hook_content(text)
1377
+
1378
+
1379
+ def _is_managed_hook_settings(destination: Path) -> bool:
1380
+ if not destination.is_file():
1381
+ return False
1382
+ try:
1383
+ parsed = _parsed_managed_settings(destination)
1384
+ except InstallerError:
1385
+ return False
1386
+ return _has_managed_hook_settings(parsed)
1387
+
1388
+
1389
+ def _has_managed_guard_wiring(project: Path) -> bool:
1390
+ settings = (
1391
+ project / ".claude" / "settings.json",
1392
+ project / ".codex" / "hooks.json",
1393
+ project / ".cursor" / "hooks.json",
1394
+ )
1395
+ if any(_is_managed_hook_settings(path) for path in settings):
1396
+ return True
1397
+ hooks_dir = _git_hooks_directory(project)
1398
+ return hooks_dir is not None and any(
1399
+ _is_managed_git_hook(hooks_dir / name)
1400
+ for name in GIT_HOOK_NAMES
1401
+ )
1402
+
1403
+
1404
+ def _atomic_temporary(destination: Path) -> Tuple[int, Path]:
1405
+ descriptor, name = tempfile.mkstemp(
1406
+ prefix=f".{destination.name}.gsd-path-tmp-", dir=destination.parent
1407
+ )
1408
+ return descriptor, Path(name)
1409
+
1410
+
1411
+ def _atomic_write(
1412
+ destination: Path, content: Union[str, bytes], mode: Optional[int] = None
1413
+ ) -> None:
1414
+ descriptor, temporary = _atomic_temporary(destination)
1415
+ try:
1416
+ if isinstance(content, bytes):
1417
+ with os.fdopen(descriptor, "wb") as output:
1418
+ output.write(content)
1419
+ else:
1420
+ with os.fdopen(descriptor, "w", encoding="utf-8") as output:
1421
+ output.write(content)
1422
+ if mode is not None:
1423
+ temporary.chmod(mode)
1424
+ elif destination.is_file() and not destination.is_symlink():
1425
+ temporary.chmod(destination.stat().st_mode & 0o777)
1426
+ os.replace(temporary, destination)
1427
+ except BaseException:
1428
+ _remove_path(temporary)
1429
+ raise
1430
+
1431
+
1432
+ def _atomic_copy(source: Path, destination: Path) -> None:
1433
+ descriptor, temporary = _atomic_temporary(destination)
1434
+ try:
1435
+ with source.open("rb") as input_file:
1436
+ with os.fdopen(descriptor, "wb") as output:
1437
+ shutil.copyfileobj(input_file, output)
1438
+ os.replace(temporary, destination)
1439
+ except BaseException:
1440
+ _remove_path(temporary)
1441
+ raise
1442
+
1443
+
1444
+ def _is_managed_hook_entry(entry) -> bool:
1445
+ """A PreToolUse entry is ours when one of its commands runs the guard."""
1446
+ if not isinstance(entry, dict):
1447
+ return False
1448
+ hooks_list = entry.get("hooks")
1449
+ if not isinstance(hooks_list, list):
1450
+ return False
1451
+ return any(_is_managed_command_hook(hook) for hook in hooks_list)
1452
+
1453
+
1454
+ def _is_guard_command(command: str) -> bool:
1455
+ normalized = command.replace("\\", "/")
1456
+ match = re.fullmatch(
1457
+ r"(?:python3|python)\s+(?:\"([^\"\r\n]+)\"|'([^'\r\n]+)'|(\S+))",
1458
+ normalized,
1459
+ )
1460
+ if match is None:
1461
+ return False
1462
+ script = next(value for value in match.groups() if value is not None)
1463
+ managed_script = f"{HOOKS_DIRECTORY}/guard_hook.py"
1464
+ return script == managed_script or script.endswith(f"/{managed_script}")
1465
+
1466
+
1467
+ def _is_managed_command_hook(hook) -> bool:
1468
+ return (
1469
+ isinstance(hook, dict)
1470
+ and isinstance(hook.get("command"), str)
1471
+ and _is_guard_command(hook["command"])
1472
+ )
1473
+
1474
+
1475
+ def _is_managed_direct_hook_entry(entry) -> bool:
1476
+ return _is_managed_command_hook(entry)
1477
+
1478
+
1479
+ def _has_managed_hook_settings(parsed: dict) -> bool:
1480
+ hooks = parsed.get("hooks")
1481
+ if not isinstance(hooks, dict):
1482
+ return False
1483
+ nested = hooks.get("PreToolUse")
1484
+ direct = hooks.get("preToolUse")
1485
+ return (
1486
+ isinstance(nested, list)
1487
+ and any(_is_managed_hook_entry(entry) for entry in nested)
1488
+ ) or (
1489
+ isinstance(direct, list)
1490
+ and any(_is_managed_direct_hook_entry(entry) for entry in direct)
1491
+ )
1492
+
1493
+
1494
+ def _parsed_managed_settings(settings: Path) -> dict:
1495
+ try:
1496
+ content = settings.read_text(encoding="utf-8", errors="replace")
1497
+ except OSError as error:
1498
+ raise InstallerError(
1499
+ f"cannot read managed hook settings file: {settings}: {error}"
1500
+ ) from error
1501
+ try:
1502
+ parsed = json.loads(content)
1503
+ except json.JSONDecodeError as error:
1504
+ raise InstallerError(
1505
+ f"managed hook settings file is not valid JSON: {settings}"
1506
+ ) from error
1507
+ if not isinstance(parsed, dict):
1508
+ raise InstallerError(
1509
+ f"managed hook settings file is not a JSON object: {settings}"
1510
+ )
1511
+ return parsed
1512
+
1513
+
1514
+ def _merged_hook_settings(
1515
+ settings: Path, event_name: str, managed_entry: dict, is_managed_entry
1516
+ ) -> str:
1517
+ parsed = _parsed_managed_settings(settings)
1518
+ hooks_object = parsed.get("hooks")
1519
+ if not isinstance(hooks_object, dict):
1520
+ hooks_object = {}
1521
+ existing = hooks_object.get(event_name)
1522
+ if not isinstance(existing, list):
1523
+ existing = []
1524
+ merged = []
1525
+ replaced = False
1526
+ for entry in existing:
1527
+ if is_managed_entry(entry):
1528
+ if not replaced:
1529
+ merged.append(managed_entry)
1530
+ replaced = True
1531
+ else:
1532
+ merged.append(entry)
1533
+ if not replaced:
1534
+ merged.append(managed_entry)
1535
+ hooks_object[event_name] = merged
1536
+ parsed["hooks"] = hooks_object
1537
+ return json.dumps(parsed, indent=2) + "\n"
1538
+
1539
+
1540
+ def _merged_nested_hook_settings(settings: Path, managed_entry: dict) -> str:
1541
+ parsed = _parsed_managed_settings(settings)
1542
+ hooks_object = parsed.get("hooks")
1543
+ if not isinstance(hooks_object, dict):
1544
+ hooks_object = {}
1545
+ existing = hooks_object.get("PreToolUse")
1546
+ if not isinstance(existing, list):
1547
+ existing = []
1548
+ merged = []
1549
+ replaced = False
1550
+ for entry in existing:
1551
+ if not _is_managed_hook_entry(entry):
1552
+ merged.append(entry)
1553
+ continue
1554
+ unrelated_hooks = [
1555
+ hook for hook in entry["hooks"] if not _is_managed_command_hook(hook)
1556
+ ]
1557
+ if not replaced:
1558
+ merged.append(managed_entry)
1559
+ replaced = True
1560
+ if unrelated_hooks:
1561
+ merged.append({**entry, "hooks": unrelated_hooks})
1562
+ if not replaced:
1563
+ merged.append(managed_entry)
1564
+ hooks_object["PreToolUse"] = merged
1565
+ parsed["hooks"] = hooks_object
1566
+ return json.dumps(parsed, indent=2) + "\n"
1567
+
1568
+
1569
+ def _merged_claude_settings(settings: Path, interpreter: str) -> str:
1570
+ return _merged_nested_hook_settings(settings, _claude_guard_entry(interpreter))
1571
+
1572
+
1573
+ def _merged_codex_settings(settings: Path, interpreter: str) -> str:
1574
+ managed = codex_hooks_settings(interpreter)
1575
+ managed_entry = json.loads(managed)["hooks"]["PreToolUse"][0]
1576
+ return _merged_nested_hook_settings(settings, managed_entry)
1577
+
1578
+
1579
+ def _merged_cursor_settings(settings: Path, interpreter: str) -> str:
1580
+ managed = cursor_hooks_settings(interpreter)
1581
+ managed_entry = json.loads(managed)["hooks"]["preToolUse"][0]
1582
+ return _merged_hook_settings(
1583
+ settings, "preToolUse", managed_entry, _is_managed_direct_hook_entry
1584
+ )
1585
+
1586
+
1587
+ def _refreshes_guards(project: Path, full: bool, initialize: bool) -> bool:
1588
+ return full or initialize or _has_managed_guard_wiring(project) or any(
1589
+ _lexists(project / HOOKS_DIRECTORY / name) for name in GUARD_SCRIPTS
1590
+ )
1591
+
1592
+
1593
+ def _contract_section(content: str, heading: str) -> Optional[str]:
1594
+ normalized = content.replace("\r\n", "\n")
1595
+ match = re.search(rf"(?m)^{re.escape(heading)}$", normalized)
1596
+ if match is None:
1597
+ return None
1598
+ depth = heading.index(" ")
1599
+ after_heading = normalized.find("\n", match.start())
1600
+ body_start = len(normalized) if after_heading == -1 else after_heading + 1
1601
+ next_heading = re.search(rf"(?m)^#{{1,{depth}}}\s", normalized[body_start:])
1602
+ end = len(normalized) if next_heading is None else body_start + next_heading.start()
1603
+ return normalized[match.start():end].rstrip()
1604
+
1605
+
1606
+ def _has_legacy_project_contracts(source_root: Path, project: Path) -> bool:
1607
+ for name, heading in PROJECT_CONTRACTS:
1608
+ contract = project / name
1609
+ source = source_root / name
1610
+ if (
1611
+ contract.is_symlink()
1612
+ or not contract.is_file()
1613
+ or source.is_symlink()
1614
+ or not source.is_file()
1615
+ ):
1616
+ return False
1617
+ installed = contract.read_text(encoding="utf-8", errors="replace")
1618
+ canonical = source.read_text(encoding="utf-8", errors="replace")
1619
+ installed_section = _contract_section(installed, heading)
1620
+ if (
1621
+ installed_section is None
1622
+ or installed_section != _contract_section(canonical, heading)
1623
+ ):
1624
+ return False
1625
+ return True
1626
+
1627
+
1628
+ def _validate_hooks_refresh(
1629
+ source_root: Path,
1630
+ project: Path,
1631
+ full: bool,
1632
+ hooks_dir: Optional[Path],
1633
+ selected: Sequence[str],
1634
+ initialize: bool = False,
1635
+ ) -> None:
1636
+ _validate_directory_destination(project, "project path")
1637
+ _validate_project_git_root(project)
1638
+ _validate_directory_destination(project / HOOKS_DIRECTORY, "guard hooks directory")
1639
+ _validate_directory_destination(
1640
+ project / HOOKS_DIRECTORY / "runtime", "project runtime directory"
1641
+ )
1642
+ refresh_guards = _refreshes_guards(project, full, initialize)
1643
+ runtime_exists = any(
1644
+ _lexists(project / HOOKS_DIRECTORY / "runtime" / name)
1645
+ for name in PROJECT_RUNTIME_SCRIPTS
1646
+ )
1647
+ if (
1648
+ not initialize
1649
+ and not refresh_guards
1650
+ and not runtime_exists
1651
+ and not _has_legacy_project_contracts(source_root, project)
1652
+ ):
1653
+ raise InstallerError(
1654
+ f"no managed GSD Path hooks or runtime found in project: {project}"
1655
+ )
1656
+ runtime = project / HOOKS_DIRECTORY / "runtime"
1657
+ launcher = project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER
1658
+ launcher_source = source_root / "scripts" / PROJECT_STATUS_LAUNCHER
1659
+ if launcher.is_symlink() or (
1660
+ _lexists(launcher) and not _is_managed_project_status_launcher(launcher)
1661
+ ):
1662
+ raise InstallerError(f"not a managed GSD Path status launcher: {launcher}")
1663
+ if launcher_source.is_symlink() or not launcher_source.is_file():
1664
+ raise InstallerError(
1665
+ f"missing project status launcher source: {launcher_source}"
1666
+ )
1667
+ if runtime.is_dir():
1668
+ unexpected = [
1669
+ entry.name for entry in runtime.iterdir() if entry.name not in PROJECT_RUNTIME_SCRIPTS
1670
+ ]
1671
+ if unexpected:
1672
+ raise InstallerError(
1673
+ "unexpected project runtime entries: " + ", ".join(unexpected)
1674
+ )
1675
+ if refresh_guards:
1676
+ for name in GUARD_SCRIPTS:
1677
+ destination = project / HOOKS_DIRECTORY / name
1678
+ exists = _lexists(destination)
1679
+ if destination.is_symlink():
1680
+ raise InstallerError(f"refusing to refresh a symlink: {destination}")
1681
+ if exists and not _is_managed_guard_script(destination):
1682
+ raise InstallerError(
1683
+ f"not a managed GSD Path guard script: {destination}"
1684
+ )
1685
+ source = source_root / "scripts" / name
1686
+ if source.is_symlink() or not source.is_file():
1687
+ raise InstallerError(f"missing guard script source: {source}")
1688
+ for name in PROJECT_RUNTIME_SCRIPTS:
1689
+ destination = project / HOOKS_DIRECTORY / "runtime" / name
1690
+ if destination.is_symlink():
1691
+ raise InstallerError(f"refusing to refresh a symlink: {destination}")
1692
+ if _lexists(destination) and not _is_managed_project_runtime(destination):
1693
+ raise InstallerError(
1694
+ f"not a managed GSD Path project runtime: {destination}"
1695
+ )
1696
+ source = source_root / "scripts" / name
1697
+ if source.is_symlink() or not source.is_file():
1698
+ raise InstallerError(f"missing project runtime source: {source}")
1699
+ if full:
1700
+ for target, label, settings in (
1701
+ ("claude", "Claude", project / ".claude" / "settings.json"),
1702
+ ("codex", "Codex", project / ".codex" / "hooks.json"),
1703
+ ("cursor", "Cursor", project / ".cursor" / "hooks.json"),
1704
+ ):
1705
+ exists = _lexists(settings)
1706
+ if initialize and target not in selected:
1707
+ continue
1708
+ if not exists and target not in selected:
1709
+ continue
1710
+ _validate_directory_destination(
1711
+ settings.parent, f"unsafe {label} project directory"
1712
+ )
1713
+ if settings.is_symlink():
1714
+ raise InstallerError(f"refusing to refresh a symlink: {settings}")
1715
+ if exists:
1716
+ parsed = _parsed_managed_settings(settings)
1717
+ if target not in selected and not _has_managed_hook_settings(parsed):
1718
+ raise InstallerError(
1719
+ f"not a managed GSD Path hook settings file: {settings}"
1720
+ )
1721
+ if hooks_dir is not None:
1722
+ for hook_name in GIT_HOOK_NAMES:
1723
+ hook_path = hooks_dir / hook_name
1724
+ if hook_path.is_symlink():
1725
+ raise InstallerError(
1726
+ f"refusing to refresh a symlink: {hook_path}"
1727
+ )
1728
+ if _lexists(hook_path) and not _is_managed_git_hook(hook_path):
1729
+ raise InstallerError(
1730
+ f"not a managed GSD Path git hook: {hook_path}"
1731
+ )
1732
+
1733
+
1734
+ def _refresh_hooks_unlocked(
1735
+ source_root: Path,
1736
+ project: Path,
1737
+ full: bool,
1738
+ dry_run: bool = False,
1739
+ selected: Sequence[str] = (),
1740
+ initialize: bool = False,
1741
+ ) -> List[str]:
1742
+ """Refreshed project-relative paths; "note:"-prefixed entries are
1743
+ user-facing notes rather than refreshed files."""
1744
+ hooks_dir = _git_hooks_directory(project) if full else None
1745
+ interpreter: Optional[str] = None
1746
+ if not dry_run:
1747
+ if full:
1748
+ interpreter, hooks_dir = _required_hook_runtime(
1749
+ project,
1750
+ "--hooks-init" if initialize else "--hooks-refresh-full",
1751
+ selected,
1752
+ )
1753
+ else:
1754
+ interpreter = _required_python_runtime("--hooks-refresh", selected)
1755
+ _validate_hooks_refresh(
1756
+ source_root, project, full, hooks_dir, selected, initialize
1757
+ )
1758
+ refreshed: List[str] = []
1759
+ refresh_guards = _refreshes_guards(project, full, initialize)
1760
+ runtime = project / HOOKS_DIRECTORY / "runtime"
1761
+ if not dry_run:
1762
+ runtime.parent.mkdir(parents=True, exist_ok=True)
1763
+ staging = Path(tempfile.mkdtemp(prefix=".runtime-stage-", dir=runtime.parent))
1764
+ previous = staging.with_name(staging.name + "-previous")
1765
+ moved_previous = False
1766
+ published_runtime = False
1767
+ launcher = runtime.parent / PROJECT_STATUS_LAUNCHER
1768
+ launcher_original = launcher.read_bytes() if _lexists(launcher) else None
1769
+ launcher_mode = (
1770
+ launcher.stat().st_mode & 0o777 if launcher_original is not None else None
1771
+ )
1772
+ guard_originals: List[Tuple[Path, Optional[bytes], Optional[int]]] = []
1773
+ try:
1774
+ for name in PROJECT_RUNTIME_SCRIPTS:
1775
+ shutil.copy2(source_root / "scripts" / name, staging / name)
1776
+ _atomic_copy(source_root / "scripts" / PROJECT_STATUS_LAUNCHER, launcher)
1777
+ if _lexists(runtime):
1778
+ os.replace(runtime, previous)
1779
+ moved_previous = True
1780
+ os.replace(staging, runtime)
1781
+ published_runtime = True
1782
+ if refresh_guards:
1783
+ for name in GUARD_SCRIPTS:
1784
+ destination = project / HOOKS_DIRECTORY / name
1785
+ original = destination.read_bytes() if _lexists(destination) else None
1786
+ mode = (
1787
+ destination.stat().st_mode & 0o777
1788
+ if original is not None
1789
+ else None
1790
+ )
1791
+ guard_originals.append((destination, original, mode))
1792
+ _atomic_copy(source_root / "scripts" / name, destination)
1793
+ if moved_previous:
1794
+ _remove_path(previous)
1795
+ except BaseException as error:
1796
+ for destination, original, mode in reversed(guard_originals):
1797
+ if original is None:
1798
+ _remove_path(destination)
1799
+ else:
1800
+ _atomic_write(destination, original, mode)
1801
+ if launcher_original is None:
1802
+ _remove_path(launcher)
1803
+ else:
1804
+ _atomic_write(launcher, launcher_original, launcher_mode)
1805
+ if published_runtime and _lexists(runtime):
1806
+ _remove_path(runtime)
1807
+ if not _lexists(runtime) and moved_previous and _lexists(previous):
1808
+ os.replace(previous, runtime)
1809
+ if isinstance(error, Exception):
1810
+ raise InstallerError(
1811
+ f"project runtime refresh failed: {error}"
1812
+ ) from error
1813
+ raise
1814
+ finally:
1815
+ _remove_path(staging)
1816
+ for name in PROJECT_RUNTIME_SCRIPTS:
1817
+ refreshed.append(_describe_project_path(project, runtime / name))
1818
+ refreshed.append(
1819
+ _describe_project_path(
1820
+ project, project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER
1821
+ )
1822
+ )
1823
+ if refresh_guards:
1824
+ for name in GUARD_SCRIPTS:
1825
+ destination = project / HOOKS_DIRECTORY / name
1826
+ refreshed.append(_describe_project_path(project, destination))
1827
+ if full:
1828
+ for target, settings, merge, generated in (
1829
+ (
1830
+ "claude",
1831
+ project / ".claude" / "settings.json",
1832
+ _merged_claude_settings,
1833
+ claude_hooks_settings,
1834
+ ),
1835
+ (
1836
+ "codex",
1837
+ project / ".codex" / "hooks.json",
1838
+ _merged_codex_settings,
1839
+ codex_hooks_settings,
1840
+ ),
1841
+ (
1842
+ "cursor",
1843
+ project / ".cursor" / "hooks.json",
1844
+ _merged_cursor_settings,
1845
+ cursor_hooks_settings,
1846
+ ),
1847
+ ):
1848
+ if initialize and target not in selected:
1849
+ continue
1850
+ exists = _lexists(settings)
1851
+ if exists or target in selected:
1852
+ if not dry_run:
1853
+ settings.parent.mkdir(parents=True, exist_ok=True)
1854
+ content = (
1855
+ merge(settings, interpreter)
1856
+ if exists
1857
+ else generated(interpreter)
1858
+ )
1859
+ _atomic_write(settings, content)
1860
+ refreshed.append(_describe_project_path(project, settings))
1861
+ if hooks_dir is not None:
1862
+ for hook_name, generator in GIT_HOOKS:
1863
+ hook_path = hooks_dir / hook_name
1864
+ if not dry_run:
1865
+ content = generator(interpreter)
1866
+ hook_path.parent.mkdir(parents=True, exist_ok=True)
1867
+ _atomic_write(hook_path, content, mode=0o755)
1868
+ refreshed.append(_describe_project_path(project, hook_path))
1869
+ return refreshed
1870
+
1871
+
1872
+ def refresh_hooks(
1873
+ source_root: Path,
1874
+ project: Path,
1875
+ full: bool,
1876
+ dry_run: bool = False,
1877
+ selected: Sequence[str] = (),
1878
+ initialize: bool = False,
1879
+ ) -> List[str]:
1880
+ if dry_run:
1881
+ return _refresh_hooks_unlocked(
1882
+ source_root, project, full, True, selected, initialize
1883
+ )
1884
+ _validate_directory_destination(project, "project path")
1885
+ locks, created = _acquire_install_locks([project / HOOKS_DIRECTORY])
1886
+ try:
1887
+ return _refresh_hooks_unlocked(
1888
+ source_root, project, full, False, selected, initialize
1889
+ )
1890
+ finally:
1891
+ _release_install_locks(locks, created)
1892
+
1893
+
1894
+ def _comparison_path(path: Path) -> Path:
1895
+ value = os.path.normcase(os.fspath(path.resolve(strict=False)))
1896
+ if sys.platform == "darwin":
1897
+ value = value.casefold()
1898
+ return Path(value)
1899
+
1900
+
1901
+ def _paths_overlap(left: Path, right: Path) -> bool:
1902
+ left = _comparison_path(left)
1903
+ right = _comparison_path(right)
1904
+ return left == right or left in right.parents or right in left.parents
1905
+
1906
+
1907
+ def _same_path(left: Path, right: Path) -> bool:
1908
+ return _comparison_path(left) == _comparison_path(right)
1909
+
1910
+
1911
+ def _deployment_plans(plans: Sequence[TargetPlan]) -> List[DeploymentPlan]:
1912
+ if len({plan.name for plan in plans}) != len(plans):
1913
+ raise InstallerError("each target may be selected only once")
1914
+ unsupported = sorted({plan.name for plan in plans} - set(TARGETS))
1915
+ if unsupported:
1916
+ raise InstallerError("unsupported target: " + ", ".join(unsupported))
1917
+
1918
+ groups: List[List[TargetPlan]] = []
1919
+ for plan in plans:
1920
+ for group in groups:
1921
+ if _same_path(group[0].root, plan.root):
1922
+ group.append(plan)
1923
+ break
1924
+ else:
1925
+ groups.append([plan])
1926
+
1927
+ deployments = []
1928
+ for group in groups:
1929
+ targets = tuple(plan.name for plan in group)
1930
+ target_set = set(targets)
1931
+ if len(group) > 1:
1932
+ if not target_set <= SHARED_AGENT_TARGETS:
1933
+ labels = ", ".join(targets)
1934
+ raise InstallerError(
1935
+ "only Codex, Antigravity, and Zed may share a skills root: "
1936
+ f"{labels}"
1937
+ )
1938
+ profile = SHARED_AGENT_PROFILE
1939
+ elif group[0].name in SHARED_AGENT_TARGETS:
1940
+ profile = SHARED_AGENT_PROFILE
1941
+ elif _same_path(group[0].root, default_root("codex")):
1942
+ raise InstallerError(
1943
+ f"{group[0].name} cannot install a host-specific bundle to the "
1944
+ "shared ~/.agents/skills root"
1945
+ )
1946
+ else:
1947
+ profile = group[0].name
1948
+ deployments.append(DeploymentPlan(profile, group[0].root, targets))
1949
+ return deployments
1950
+
1951
+
1952
+ def _validate_distinct_roots(plans: Sequence[DeploymentPlan]) -> None:
1953
+ for index, left in enumerate(plans):
1954
+ for right in plans[index + 1 :]:
1955
+ if _paths_overlap(left.root, right.root):
1956
+ raise InstallerError(
1957
+ "target roots overlap: "
1958
+ f"{'+'.join(left.targets)}={left.root}, "
1959
+ f"{'+'.join(right.targets)}={right.root}"
1960
+ )
1961
+
1962
+ cursor_plans = [plan for plan in plans if plan.profile == "cursor"]
1963
+ for cursor_plan in cursor_plans:
1964
+ agent_root = cursor_plan.root.parent / "agents"
1965
+ if _paths_overlap(agent_root, cursor_plan.root):
1966
+ raise InstallerError(
1967
+ "Cursor skills and agent roots overlap: "
1968
+ f"skills={cursor_plan.root}, agents={agent_root}"
1969
+ )
1970
+ for plan in plans:
1971
+ if plan is cursor_plan:
1972
+ continue
1973
+ if _paths_overlap(agent_root, plan.root):
1974
+ raise InstallerError(
1975
+ "Cursor agent root overlaps a target root: "
1976
+ f"cursor={agent_root}, {'+'.join(plan.targets)}={plan.root}"
1977
+ )
1978
+
1979
+
1980
+ def _append_host_notes(results: List[str], selected: Sequence[str]) -> None:
1981
+ for target in TARGETS:
1982
+ if target in selected and target in HOST_NOTES:
1983
+ results.append(HOST_NOTES[target])
1984
+
1985
+
1986
+ def _install_result(
1987
+ plan: DeploymentPlan, dry_run: bool = False, update: bool = False
1988
+ ) -> str:
1989
+ verb = "update" if update else "install"
1990
+ action = f"would {verb}" if dry_run else ("updated" if update else "installed")
1991
+ label = "+".join(plan.targets)
1992
+ skill_count = len(SKILL_NAMES)
1993
+ if plan.profile == "cursor":
1994
+ agent = plan.root.parent / "agents" / CURSOR_AGENT_FILENAME
1995
+ return (
1996
+ f"{label}: {action} {skill_count} skills to {plan.root} "
1997
+ f"and custom subagent to {agent}"
1998
+ )
1999
+ shared = " shared" if plan.profile == SHARED_AGENT_PROFILE else ""
2000
+ return f"{label}: {action} {skill_count}{shared} skills to {plan.root}"
2001
+
2002
+
2003
+ def _managed_entry_count(plan: DeploymentPlan) -> int:
2004
+ count = 0
2005
+ if plan.root.is_dir():
2006
+ count = sum(
2007
+ 1
2008
+ for entry in plan.root.iterdir()
2009
+ if _is_managed_install_entry(plan.root, entry.name)
2010
+ )
2011
+ if plan.profile == "cursor" and _lexists(
2012
+ plan.root.parent / "agents" / CURSOR_AGENT_FILENAME
2013
+ ):
2014
+ count += 1
2015
+ return count
2016
+
2017
+
2018
+ def _has_managed_install(root: Path) -> bool:
2019
+ return root.is_dir() and any(
2020
+ _is_managed_install_entry(root, entry.name) for entry in root.iterdir()
2021
+ )
2022
+
2023
+
2024
+ STATE_SCHEMA = "gsd-path/state/v1"
2025
+
2026
+
2027
+ def _read_package_version(manifest: Path) -> Optional[str]:
2028
+ try:
2029
+ parsed = json.loads(manifest.read_text(encoding="utf-8"))
2030
+ except (OSError, UnicodeError, json.JSONDecodeError):
2031
+ return None
2032
+ version = parsed.get("version") if isinstance(parsed, dict) else None
2033
+ return version if isinstance(version, str) and version else None
2034
+
2035
+
2036
+ def _validated_project_state(source_root: Path, project: Path) -> dict:
2037
+ validator = source_root / "scripts" / "pipeline_state.py"
2038
+ if validator.is_symlink() or not validator.is_file():
2039
+ raise InstallerError(f"canonical state validator is unavailable: {validator}")
2040
+ interpreter = _required_python_runtime("--doctor")
2041
+ try:
2042
+ result = subprocess.run(
2043
+ [interpreter, "-B", str(validator), "validate", "--repo", str(project)],
2044
+ cwd=project,
2045
+ capture_output=True,
2046
+ text=True,
2047
+ check=False,
2048
+ )
2049
+ except OSError as error:
2050
+ raise InstallerError(f"canonical state validation failed: {error}") from error
2051
+ if result.returncode != 0:
2052
+ detail = result.stderr.strip() or result.stdout.strip() or "unknown failure"
2053
+ raise InstallerError(f"canonical state validation failed: {detail}")
2054
+ try:
2055
+ payload = json.loads(result.stdout)
2056
+ except json.JSONDecodeError as error:
2057
+ raise InstallerError("canonical state validator returned invalid JSON") from error
2058
+ if not isinstance(payload, dict):
2059
+ raise InstallerError("canonical state validator returned an invalid payload")
2060
+ state = payload.get("state")
2061
+ if (
2062
+ payload.get("schema") != STATE_SCHEMA
2063
+ or payload.get("status") != "valid"
2064
+ or not isinstance(state, dict)
2065
+ or not isinstance(state.get("phase"), str)
2066
+ or not isinstance(state.get("status"), str)
2067
+ ):
2068
+ raise InstallerError("canonical state validator returned an invalid payload")
2069
+ return state
2070
+
2071
+
2072
+ def _validate_project_runtime_status(source_root: Path, project: Path) -> None:
2073
+ interpreter = _required_python_runtime("--doctor")
2074
+ runtime = source_root / "scripts" / "pipeline_state.py"
2075
+ environment = os.environ.copy()
2076
+ environment["GIT_OPTIONAL_LOCKS"] = "0"
2077
+ try:
2078
+ result = subprocess.run(
2079
+ [interpreter, "-B", str(runtime), "status", "--repo", str(project)],
2080
+ cwd=project,
2081
+ capture_output=True,
2082
+ text=True,
2083
+ check=False,
2084
+ env=environment,
2085
+ )
2086
+ except OSError as error:
2087
+ raise InstallerError(f"project runtime status failed: {error}") from error
2088
+ if result.returncode != 0:
2089
+ detail = result.stderr.strip() or result.stdout.strip() or "unknown failure"
2090
+ raise InstallerError(f"project runtime status failed: {detail}")
2091
+ try:
2092
+ payload = json.loads(result.stdout)
2093
+ except json.JSONDecodeError as error:
2094
+ raise InstallerError("project runtime status returned invalid JSON") from error
2095
+ if not _valid_status_payload(payload, project):
2096
+ raise InstallerError("project runtime status returned an invalid payload")
2097
+
2098
+
2099
+ def _project_runtime_matches(source_root: Path, project: Path) -> bool:
2100
+ pairs = [
2101
+ (
2102
+ project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER,
2103
+ source_root / "scripts" / PROJECT_STATUS_LAUNCHER,
2104
+ ),
2105
+ *(
2106
+ (
2107
+ project / HOOKS_DIRECTORY / "runtime" / name,
2108
+ source_root / "scripts" / name,
2109
+ )
2110
+ for name in PROJECT_RUNTIME_SCRIPTS
2111
+ ),
2112
+ ]
2113
+ try:
2114
+ return all(
2115
+ not destination.is_symlink()
2116
+ and destination.is_file()
2117
+ and destination.read_bytes() == source.read_bytes()
2118
+ for destination, source in pairs
2119
+ )
2120
+ except OSError:
2121
+ return False
2122
+
2123
+
2124
+ def _valid_status_payload(payload: object, project: Path) -> bool:
2125
+ if not isinstance(payload, dict) or payload.get("schema") != "gsd-path/status/v1":
2126
+ return False
2127
+ state = payload.get("state")
2128
+ route = payload.get("route")
2129
+ status_path = payload.get("path")
2130
+ if (
2131
+ payload.get("advance") is not False
2132
+ or not isinstance(state, dict)
2133
+ or not _valid_status_state(state)
2134
+ or not isinstance(route, dict)
2135
+ or route.get("action") not in STATUS_ACTIONS
2136
+ or not isinstance(route.get("reason"), str)
2137
+ or not route["reason"]
2138
+ or not isinstance(status_path, str)
2139
+ or not Path(status_path).is_absolute()
2140
+ or not _same_path(Path(status_path), project / ".project" / "STATE.md")
2141
+ ):
2142
+ return False
2143
+ action = route["action"]
2144
+ if action == "run-phase":
2145
+ phase = route.get("phase")
2146
+ return (
2147
+ isinstance(phase, str)
2148
+ and phase in STATUS_TRANSITIONS[state["phase"]]
2149
+ and state["branch"] is not None
2150
+ and payload.get("next_skill") == f"gsd-path-{phase}"
2151
+ )
2152
+ if "phase" in route:
2153
+ return False
2154
+ if action == "bind-initial":
2155
+ branch = route.get("branch")
2156
+ return (
2157
+ state["branch"] is None
2158
+ and isinstance(branch, str)
2159
+ and _valid_status_branch(branch) is not None
2160
+ and payload.get("next_skill") == "gsd-path"
2161
+ )
2162
+ if state["branch"] is None:
2163
+ return False
2164
+ if action == "validate-integrated" and not (
2165
+ state["phase"] == "shipped" and state["status"] == "done"
2166
+ ):
2167
+ return False
2168
+ if action == "wait" and not (
2169
+ state["phase"] == "plan" and state["status"] == "done"
2170
+ ):
2171
+ return False
2172
+ expected = "gsd-path-undo" if action == "resume-undo" else None
2173
+ if action not in {"resume-undo", "wait"}:
2174
+ expected = "gsd-path"
2175
+ return payload.get("next_skill") == expected
2176
+
2177
+
2178
+ def _valid_status_state(state: dict) -> bool:
2179
+ if set(state) != STATUS_STATE_FIELDS:
2180
+ return False
2181
+ milestone = state["milestone"]
2182
+ branch = state["branch"]
2183
+ archive = state["archive"]
2184
+ integration_default = state["integration_default"]
2185
+ integration = state["integration"]
2186
+ integration_source = state["integration_source"]
2187
+ archive_match = (
2188
+ STATUS_ARCHIVE.fullmatch(archive or "")
2189
+ if isinstance(archive, (str, type(None)))
2190
+ else None
2191
+ )
2192
+ if (
2193
+ state["pipeline"] != "gsd-path/v2"
2194
+ or not isinstance(state["project"], str)
2195
+ or STATUS_SLUG.fullmatch(state["project"]) is None
2196
+ or not (
2197
+ milestone is None
2198
+ or isinstance(milestone, str)
2199
+ and STATUS_SLUG.fullmatch(milestone) is not None
2200
+ )
2201
+ or not isinstance(state["phase"], str)
2202
+ or state["phase"] not in STATUS_PHASES
2203
+ or not isinstance(state["status"], str)
2204
+ or state["status"] not in STATUS_VALUES
2205
+ or integration_default not in STATUS_INTEGRATION_MODES
2206
+ or integration not in STATUS_INTEGRATION_MODES
2207
+ or integration_source not in STATUS_INTEGRATION_SOURCES
2208
+ or (
2209
+ integration_source == "default"
2210
+ and integration != integration_default
2211
+ )
2212
+ or not (branch is None or _valid_status_branch(branch) is not None)
2213
+ or not (archive is None or archive_match is not None)
2214
+ ):
2215
+ return False
2216
+ if state["phase"] == "shipped" and (state["status"] != "done" or archive is None):
2217
+ return False
2218
+ if archive is not None and state["phase"] not in {"build", "ship", "shipped"}:
2219
+ return False
2220
+ if state["phase"] in {"ship", "shipped"} and branch is None:
2221
+ return False
2222
+ if archive_match is not None:
2223
+ branch_match = _valid_status_branch(branch)
2224
+ return (
2225
+ milestone == archive_match.group(2)
2226
+ and branch_match is not None
2227
+ and int(branch_match.group(1)) == int(archive_match.group(1))
2228
+ )
2229
+ return True
2230
+
2231
+
2232
+ def _valid_status_branch(value: object) -> Optional[re.Match[str]]:
2233
+ match = STATUS_BRANCH.fullmatch(value) if isinstance(value, str) else None
2234
+ return match if match is not None and int(match.group(1)) >= 1 else None
2235
+
2236
+
2237
+ def _is_executable(path: Path) -> bool:
2238
+ try:
2239
+ return bool(path.stat().st_mode & 0o111)
2240
+ except OSError:
2241
+ return False
2242
+
2243
+
2244
+ def _native_guard_contract(
2245
+ target: str, project: Path
2246
+ ) -> Optional[Tuple[Path, str, Callable[[str], dict]]]:
2247
+ if target == "claude":
2248
+ return project / ".claude" / "settings.json", "PreToolUse", _claude_guard_entry
2249
+ if target == "codex":
2250
+ return project / ".codex" / "hooks.json", "PreToolUse", _codex_guard_entry
2251
+ if target == "cursor":
2252
+ return project / ".cursor" / "hooks.json", "preToolUse", _cursor_guard_entry
2253
+ return None
2254
+
2255
+
2256
+ def doctor(
2257
+ source_root: Path,
2258
+ targets: Sequence[str],
2259
+ root_for: Callable[[str], Path],
2260
+ project: Optional[Path] = None,
2261
+ ) -> List[dict]:
2262
+ findings: List[dict] = []
2263
+
2264
+ def push(level: str, text: str) -> None:
2265
+ findings.append({"level": level, "text": text})
2266
+
2267
+ def read_project_file(path: Path, label: str) -> Optional[bytes]:
2268
+ try:
2269
+ return path.read_bytes()
2270
+ except OSError as error:
2271
+ push("fail", f"{label} cannot be read: {error}")
2272
+ return None
2273
+
2274
+ version = _read_package_version(source_root / "package.json")
2275
+ if version is None:
2276
+ push("fail", "package: version cannot be read")
2277
+ seen: List[Tuple[str, Path]] = []
2278
+ installed_targets = set()
2279
+ for target in targets:
2280
+ root = root_for(target)
2281
+ prior = next(
2282
+ (name for name, other in seen if _same_path(other, root)), None
2283
+ )
2284
+ if prior is not None:
2285
+ push("note", f"{target}: shares {prior}'s skills root")
2286
+ continue
2287
+ seen.append((target, root))
2288
+ try:
2289
+ managed_install = _has_managed_install(root)
2290
+ except OSError as error:
2291
+ push("fail", f"{target}: skills root cannot be read: {error}")
2292
+ continue
2293
+ if not managed_install:
2294
+ push("note", f"{target}: not installed ({root})")
2295
+ continue
2296
+ installed_targets.add(target)
2297
+ missing = [name for name in SKILL_NAMES if not (root / name).is_dir()]
2298
+ if missing:
2299
+ push(
2300
+ "fail",
2301
+ f"{target}: incomplete install at {root} — missing {', '.join(missing)}",
2302
+ )
2303
+ continue
2304
+ try:
2305
+ stamp = (root / "gsd-path" / "VERSION").read_text(
2306
+ encoding="utf-8"
2307
+ ).strip()
2308
+ except (OSError, UnicodeError):
2309
+ stamp = ""
2310
+ if not stamp:
2311
+ push(
2312
+ "warn",
2313
+ f"{target}: {len(SKILL_NAMES)} skills at {root}, no VERSION stamp — run --update",
2314
+ )
2315
+ elif version and stamp != version:
2316
+ push(
2317
+ "warn",
2318
+ f"{target}: stale install at {root} (v{stamp}, current v{version}) — run --update",
2319
+ )
2320
+ else:
2321
+ push("ok", f"{target}: {len(SKILL_NAMES)} skills at {root} (v{stamp})")
2322
+
2323
+ if project is None:
2324
+ return findings
2325
+
2326
+ for name, heading in PROJECT_CONTRACTS:
2327
+ contract = project / name
2328
+ if contract.is_symlink():
2329
+ push("fail", f"project: contract {name} is a symlink")
2330
+ continue
2331
+ if not contract.is_file():
2332
+ push("fail", f'project: missing contract {name} — run --project "{project}"')
2333
+ continue
2334
+ content = read_project_file(contract, f"project: {name}")
2335
+ if content is None:
2336
+ continue
2337
+ canonical = read_project_file(source_root / name, f"package: {name}")
2338
+ if canonical is None:
2339
+ continue
2340
+ installed_section = _contract_section(
2341
+ content.decode("utf-8", errors="replace"), heading
2342
+ )
2343
+ canonical_section = _contract_section(
2344
+ canonical.decode("utf-8", errors="replace"), heading
2345
+ )
2346
+ if installed_section is not None and installed_section == canonical_section:
2347
+ push("ok", f"project: {name} present")
2348
+ else:
2349
+ push(
2350
+ "fail",
2351
+ f"project: {name} lacks plain-prompt re-entry — merge the current contract",
2352
+ )
2353
+
2354
+ bridge = project / ".claude" / "CLAUDE.md"
2355
+ if not bridge.is_file():
2356
+ push(
2357
+ "note",
2358
+ "project: no .claude/CLAUDE.md bridge (only written for --claude installs)",
2359
+ )
2360
+ else:
2361
+ content = read_project_file(bridge, "project: .claude/CLAUDE.md")
2362
+ if content is not None:
2363
+ if content == CLAUDE_BRIDGE.encode():
2364
+ push("ok", "project: .claude/CLAUDE.md bridge present")
2365
+ else:
2366
+ push(
2367
+ "note",
2368
+ "project: .claude/CLAUDE.md exists but is not the managed bridge",
2369
+ )
2370
+
2371
+ runtime = project / HOOKS_DIRECTORY / "runtime"
2372
+ launcher = project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER
2373
+ runtime_current = True
2374
+ if launcher.is_symlink():
2375
+ push("fail", "project: status launcher is a symlink")
2376
+ runtime_current = False
2377
+ elif not launcher.is_file():
2378
+ push("fail", "project: missing status launcher")
2379
+ runtime_current = False
2380
+ else:
2381
+ launcher_content = read_project_file(launcher, "project: status launcher")
2382
+ launcher_source = read_project_file(
2383
+ source_root / "scripts" / PROJECT_STATUS_LAUNCHER,
2384
+ "package: status launcher",
2385
+ )
2386
+ if launcher_content is None or launcher_source is None:
2387
+ runtime_current = False
2388
+ else:
2389
+ if PROJECT_STATUS_MARKER.encode() not in launcher_content:
2390
+ push("warn", "project: status launcher is not managed")
2391
+ runtime_current = False
2392
+ elif launcher_content != launcher_source:
2393
+ push(
2394
+ "warn",
2395
+ "project: status launcher is stale — refresh it with the project contracts",
2396
+ )
2397
+ runtime_current = False
2398
+ else:
2399
+ push("ok", "project: status launcher current")
2400
+ try:
2401
+ _validate_directory_destination(
2402
+ project / HOOKS_DIRECTORY, "project runtime parent directory"
2403
+ )
2404
+ _validate_directory_destination(runtime, "project runtime directory")
2405
+ except InstallerError as error:
2406
+ push("fail", f"project: unsafe runtime — {error}")
2407
+ runtime_current = False
2408
+ else:
2409
+ for name in PROJECT_RUNTIME_SCRIPTS:
2410
+ destination = runtime / name
2411
+ if destination.is_symlink():
2412
+ push("fail", f"project: runtime {name} is a symlink")
2413
+ runtime_current = False
2414
+ continue
2415
+ if not destination.is_file():
2416
+ push("fail", f"project: missing runtime {name}")
2417
+ runtime_current = False
2418
+ continue
2419
+ content = read_project_file(destination, f"project: runtime {name}")
2420
+ if content is None:
2421
+ runtime_current = False
2422
+ continue
2423
+ source = read_project_file(
2424
+ source_root / "scripts" / name, f"package: runtime {name}"
2425
+ )
2426
+ if source is None:
2427
+ runtime_current = False
2428
+ continue
2429
+ if PROJECT_RUNTIME_MARKER.encode() not in content:
2430
+ push("warn", f"project: runtime {name} is not managed")
2431
+ runtime_current = False
2432
+ elif content != source:
2433
+ push(
2434
+ "warn",
2435
+ f"project: runtime {name} is stale — refresh it with the project contracts",
2436
+ )
2437
+ runtime_current = False
2438
+ else:
2439
+ push("ok", f"project: runtime {name} current")
2440
+
2441
+ guard_installed = any(
2442
+ _lexists(project / HOOKS_DIRECTORY / name) for name in GUARD_SCRIPTS
2443
+ )
2444
+ native_wired_targets = set()
2445
+ for target in targets:
2446
+ contract = _native_guard_contract(target, project)
2447
+ if contract is not None and (
2448
+ contract[0].is_symlink()
2449
+ or _is_managed_hook_settings(contract[0])
2450
+ ):
2451
+ native_wired_targets.add(target)
2452
+ guard_wired = bool(native_wired_targets)
2453
+ unreadable_git_hooks = {}
2454
+ if _lexists(project / ".git"):
2455
+ hooks_dir = _git_hooks_directory(project)
2456
+ if hooks_dir is not None:
2457
+ for name in GIT_HOOK_NAMES:
2458
+ hook_path = hooks_dir / name
2459
+ if not _lexists(hook_path):
2460
+ continue
2461
+ if hook_path.is_symlink():
2462
+ guard_wired = True
2463
+ continue
2464
+ try:
2465
+ hook_text = hook_path.read_text(
2466
+ encoding="utf-8", errors="replace"
2467
+ )
2468
+ except OSError as error:
2469
+ unreadable_git_hooks[hook_path] = error
2470
+ else:
2471
+ guard_wired = (
2472
+ _is_managed_git_hook_content(hook_text) or guard_wired
2473
+ )
2474
+ for hook_path, error in unreadable_git_hooks.items():
2475
+ label = _describe_project_path(project, hook_path)
2476
+ push("fail", f"hooks: {label} cannot be read: {error}")
2477
+ guard_installed = guard_installed or guard_wired
2478
+ if not guard_installed:
2479
+ push("note", "hooks: guard hooks not installed (opt in with --hooks; see HOOKS.md)")
2480
+ else:
2481
+ for name in GUARD_SCRIPTS:
2482
+ destination = project / HOOKS_DIRECTORY / name
2483
+ if destination.is_symlink():
2484
+ push("fail", f"hooks: {HOOKS_DIRECTORY}/{name} is a symlink")
2485
+ continue
2486
+ if not destination.is_file():
2487
+ push("fail", f"hooks: missing {HOOKS_DIRECTORY}/{name} — run --hooks-refresh")
2488
+ continue
2489
+ content = read_project_file(destination, f"hooks: {HOOKS_DIRECTORY}/{name}")
2490
+ if content is None:
2491
+ continue
2492
+ source = read_project_file(
2493
+ source_root / "scripts" / name, f"package: guard {name}"
2494
+ )
2495
+ if source is None:
2496
+ continue
2497
+ if GUARD_MARKER.encode() not in content:
2498
+ push("warn", f"hooks: {HOOKS_DIRECTORY}/{name} is not a managed guard script")
2499
+ elif content != source:
2500
+ push("warn", f"hooks: {HOOKS_DIRECTORY}/{name} is stale — run --hooks-refresh")
2501
+ else:
2502
+ push("ok", f"hooks: {HOOKS_DIRECTORY}/{name} current")
2503
+
2504
+ hosts = sync_skill_resources.RESOURCE_MANIFEST["hosts"]
2505
+ for target in targets:
2506
+ if target not in installed_targets and target not in native_wired_targets:
2507
+ continue
2508
+ contract = _native_guard_contract(target, project)
2509
+ if contract is None:
2510
+ if hosts[target].get("guard_tier") != "git-only":
2511
+ push("fail", f"hooks: {target} declares a native guard without a health contract")
2512
+ continue
2513
+ settings, event, entry = contract
2514
+ if settings.is_symlink():
2515
+ push("fail", f"hooks: {target} native guard wiring is a symlink")
2516
+ continue
2517
+ if not settings.is_file():
2518
+ push("fail", f"hooks: {target} native guard wiring is missing — run --hooks-refresh-full")
2519
+ continue
2520
+ try:
2521
+ parsed = json.loads(settings.read_text(encoding="utf-8"))
2522
+ entries = parsed.get("hooks", {}).get(event)
2523
+ variants = [entry(candidate) for candidate in ("python3", "python")]
2524
+ current = isinstance(entries, list) and any(
2525
+ candidate in variants for candidate in entries
2526
+ )
2527
+ except (OSError, UnicodeError, json.JSONDecodeError, AttributeError):
2528
+ current = False
2529
+ if current:
2530
+ push("ok", f"hooks: {target} native guard wiring present")
2531
+ else:
2532
+ push("fail", f"hooks: {target} native guard wiring is stale — run --hooks-refresh-full")
2533
+
2534
+ dot_git = project / ".git"
2535
+ if _lexists(dot_git):
2536
+ hooks_dir = _git_hooks_directory(project)
2537
+ if hooks_dir is None:
2538
+ push(
2539
+ "fail",
2540
+ "hooks: cannot resolve the git hooks directory (is git runnable?); git hooks unverified",
2541
+ )
2542
+ else:
2543
+ custom = not _same_path(hooks_dir, dot_git / "hooks")
2544
+ missing_from_custom = False
2545
+ for hook_name, generator in GIT_HOOKS:
2546
+ hook_path = hooks_dir / hook_name
2547
+ label = _describe_project_path(project, hook_path)
2548
+ if hook_path in unreadable_git_hooks:
2549
+ continue
2550
+ if not _lexists(hook_path):
2551
+ push(
2552
+ "fail",
2553
+ f"hooks: {hook_name} is missing from the effective git hooks directory "
2554
+ f"{hooks_dir} — run --hooks-refresh-full",
2555
+ )
2556
+ if custom:
2557
+ missing_from_custom = True
2558
+ elif hook_path.is_symlink():
2559
+ push("fail", f"hooks: {label} is a symlink")
2560
+ else:
2561
+ content = read_project_file(hook_path, f"hooks: {label}")
2562
+ if content is None:
2563
+ continue
2564
+ hook_text = content.decode("utf-8", errors="replace")
2565
+ if not _is_managed_git_hook_content(hook_text):
2566
+ push("warn", f"hooks: {label} is not a managed GSD Path git hook")
2567
+ elif not any(
2568
+ hook_text == generator(candidate)
2569
+ for candidate in ("python3", "python")
2570
+ ):
2571
+ push("warn", f"hooks: {label} is stale — run --hooks-refresh-full")
2572
+ elif not _is_executable(hook_path):
2573
+ push(
2574
+ "warn",
2575
+ f"hooks: {label} is not executable — run --hooks-refresh-full",
2576
+ )
2577
+ else:
2578
+ push("ok", f"hooks: {label} wired")
2579
+ if missing_from_custom:
2580
+ push(
2581
+ "warn",
2582
+ f"hooks: core.hooksPath points this repository at {hooks_dir}, "
2583
+ "but the guard hooks are not wired there",
2584
+ )
2585
+
2586
+ project_state = project / ".project"
2587
+ state_file = project_state / "STATE.md"
2588
+ if not project_state.is_dir():
2589
+ push("note", "state: no .project/ pipeline state (nothing started yet)")
2590
+ elif not state_file.is_file():
2591
+ push("warn", "state: .project/ exists but STATE.md is missing")
2592
+ else:
2593
+ try:
2594
+ state = _validated_project_state(source_root, project)
2595
+ if not runtime_current:
2596
+ raise InstallerError(
2597
+ "project runtime status was not executed because the installed runtime is not current"
2598
+ )
2599
+ _validate_project_runtime_status(source_root, project)
2600
+ if not _project_runtime_matches(source_root, project):
2601
+ raise InstallerError("project runtime changed during doctor validation")
2602
+ except InstallerError as error:
2603
+ push("fail", f"state: {error}")
2604
+ else:
2605
+ push("ok", f"state: {state['phase']}/{state['status']}")
2606
+ return findings
2607
+
2608
+
2609
+ def detect_installs(
2610
+ targets: Sequence[str], roots: Mapping[str, Path]
2611
+ ) -> List[TargetPlan]:
2612
+ return [
2613
+ TargetPlan(target, roots[target])
2614
+ for target in targets
2615
+ if _has_managed_install(roots[target])
2616
+ ]
2617
+
2618
+
2619
+ def _planned_backup_roots(
2620
+ legacy_root: Optional[Path], deployments: Sequence[DeploymentPlan]
2621
+ ) -> List[Tuple[str, Path]]:
2622
+ transactions = []
2623
+ if legacy_root is not None and legacy_root.is_dir() and any(
2624
+ _is_managed_name(entry.name) for entry in legacy_root.iterdir()
2625
+ ):
2626
+ transactions.append(("Codex legacy backup", legacy_root))
2627
+ transactions.extend(
2628
+ (f"{'+'.join(plan.targets)} backup", plan.root)
2629
+ for plan in deployments
2630
+ if _managed_entry_count(plan)
2631
+ )
2632
+
2633
+ planned = []
2634
+ reserved = []
2635
+ for label, root in transactions:
2636
+ backup = _backup_path(root, reserved)
2637
+ reserved.append(backup)
2638
+ planned.append((label, backup))
2639
+ return planned
2640
+
2641
+
2642
+ def install(
2643
+ source_root: Path,
2644
+ plans: Sequence[TargetPlan],
2645
+ project: Optional[Path] = None,
2646
+ dry_run: bool = False,
2647
+ hooks: bool = False,
2648
+ migrate_legacy: bool = True,
2649
+ update: bool = False,
2650
+ ) -> List[str]:
2651
+ if hooks and project is None:
2652
+ raise InstallerError("--hooks requires --project")
2653
+ selected = [plan.name for plan in plans]
2654
+ interpreter = "python3"
2655
+ hooks_dir: Optional[Path] = None
2656
+ if project is not None and not hooks and not dry_run:
2657
+ interpreter = _required_python_runtime("--project", selected)
2658
+ elif hooks:
2659
+ interpreter, hooks_dir = _required_hook_runtime(project, "--hooks", selected)
2660
+ deployments = _deployment_plans(plans)
2661
+ adapters = list(dict.fromkeys(deployment.profile for deployment in deployments))
2662
+ validate_source(source_root, adapters)
2663
+ _validate_distinct_roots(deployments)
2664
+ for plan in plans:
2665
+ _validate_directory_destination(plan.root, f"{plan.name} skills root")
2666
+ if _paths_overlap(plan.root, source_root):
2667
+ raise InstallerError(
2668
+ f"{plan.name} skills root overlaps source repository: {plan.root}"
2669
+ )
2670
+ for deployment in deployments:
2671
+ if deployment.profile != "cursor":
2672
+ continue
2673
+ cursor_agent_root = deployment.root.parent / "agents"
2674
+ _validate_directory_destination(cursor_agent_root, "Cursor agent root")
2675
+ if _paths_overlap(cursor_agent_root, source_root):
2676
+ raise InstallerError(
2677
+ f"Cursor agent root overlaps source repository: {cursor_agent_root}"
2678
+ )
2679
+ legacy_root = (
2680
+ legacy_codex_root() if migrate_legacy and "codex" in selected else None
2681
+ )
2682
+ reserved_roots = [
2683
+ (f"{'+'.join(plan.targets)} skills root", plan.root) for plan in deployments
2684
+ ]
2685
+ reserved_roots.extend(
2686
+ ("Cursor agent root", plan.root.parent / "agents")
2687
+ for plan in deployments
2688
+ if plan.profile == "cursor"
2689
+ )
2690
+ if legacy_root is not None:
2691
+ codex_roots = [
2692
+ plan.root for plan in deployments if "codex" in plan.targets
2693
+ ]
2694
+ if any(_same_path(legacy_root, root) for root in codex_roots):
2695
+ legacy_root = None
2696
+ elif _paths_overlap(legacy_root, source_root):
2697
+ raise InstallerError(
2698
+ f"Codex legacy root overlaps source repository: {legacy_root}"
2699
+ )
2700
+ else:
2701
+ for label, root in reserved_roots:
2702
+ if _paths_overlap(legacy_root, root):
2703
+ raise InstallerError(
2704
+ f"Codex legacy root overlaps {label}: {legacy_root}, {root}"
2705
+ )
2706
+ if legacy_root is not None:
2707
+ _validate_directory_destination(legacy_root, "Codex legacy skills root")
2708
+
2709
+ planned_backups = _planned_backup_roots(legacy_root, deployments)
2710
+ mutation_roots = list(reserved_roots)
2711
+ if legacy_root is not None:
2712
+ mutation_roots.append(("Codex legacy skills root", legacy_root))
2713
+ for backup_label, backup in planned_backups:
2714
+ for root_label, root in mutation_roots:
2715
+ if _paths_overlap(backup, root):
2716
+ raise InstallerError(
2717
+ f"{backup_label} overlaps {root_label}: {backup}, {root}"
2718
+ )
2719
+
2720
+ if project is not None:
2721
+ _validate_directory_destination(project, "project path")
2722
+ if dry_run:
2723
+ _validate_project(
2724
+ source_root,
2725
+ project,
2726
+ selected,
2727
+ hooks,
2728
+ [*mutation_roots, *planned_backups],
2729
+ interpreter,
2730
+ hooks_dir,
2731
+ update,
2732
+ )
2733
+
2734
+ results = []
2735
+ with tempfile.TemporaryDirectory(prefix="gsd-path-install-") as temporary:
2736
+ staging = Path(temporary)
2737
+ staged = []
2738
+ for index, plan in enumerate(deployments):
2739
+ staged_root = staging / f"{index}-{plan.profile}"
2740
+ staged.append(staged_root)
2741
+ staged_root.mkdir()
2742
+ stage_target(source_root, plan.profile, staged_root)
2743
+
2744
+ if dry_run:
2745
+ if legacy_root is not None and legacy_root.is_dir():
2746
+ legacy_count = sum(
2747
+ 1 for entry in legacy_root.iterdir() if _is_managed_name(entry.name)
2748
+ )
2749
+ if legacy_count:
2750
+ results.append(
2751
+ f"codex-legacy: would back up {legacy_count} entries from {legacy_root}"
2752
+ )
2753
+ for plan in deployments:
2754
+ _reject_router_alias_collisions(plan.root)
2755
+ count = _managed_entry_count(plan)
2756
+ suffix = f"; would back up {count} entries" if count else ""
2757
+ results.append(
2758
+ _install_result(plan, dry_run=True, update=update) + suffix
2759
+ )
2760
+ if project is not None:
2761
+ results.append(
2762
+ _project_result(
2763
+ project, selected, hooks, interpreter, hooks_dir, update, True
2764
+ )
2765
+ )
2766
+ _append_host_notes(results, selected)
2767
+ return results
2768
+
2769
+ lock_roots = [plan.root for plan in deployments]
2770
+ if legacy_root is not None and legacy_root.is_dir():
2771
+ lock_roots.append(legacy_root)
2772
+ if project is not None:
2773
+ lock_roots.append(project / HOOKS_DIRECTORY)
2774
+ install_locks, lock_directories = _acquire_install_locks(lock_roots)
2775
+ target_transactions: List[TargetTransaction] = []
2776
+ project_transaction = ProjectTransaction()
2777
+ try:
2778
+ if project is not None:
2779
+ _validate_project(
2780
+ source_root,
2781
+ project,
2782
+ selected,
2783
+ hooks,
2784
+ [*mutation_roots, *planned_backups],
2785
+ interpreter,
2786
+ hooks_dir,
2787
+ update,
2788
+ )
2789
+ if legacy_root is not None and legacy_root.is_dir():
2790
+ legacy_transaction = TargetTransaction(legacy_root)
2791
+ target_transactions.append(legacy_transaction)
2792
+ _backup_existing(legacy_transaction)
2793
+ if legacy_transaction.backup is not None:
2794
+ results.append(
2795
+ "codex-legacy: backed up "
2796
+ f"{len(legacy_transaction.moved)} entries to "
2797
+ f"{legacy_transaction.backup}"
2798
+ )
2799
+ for index, plan in enumerate(deployments):
2800
+ transaction = TargetTransaction(plan.root)
2801
+ target_transactions.append(transaction)
2802
+ _apply_target(plan, staged[index], transaction)
2803
+ results.append(_install_result(plan, update=update))
2804
+ if transaction.backup is not None:
2805
+ results.append(
2806
+ f"{'+'.join(plan.targets)}: backed up "
2807
+ f"{len(transaction.moved)} entries "
2808
+ f"to {transaction.backup}"
2809
+ )
2810
+ if project is not None:
2811
+ result_line = _project_result(
2812
+ project, selected, hooks, interpreter, hooks_dir, update, False
2813
+ )
2814
+ _apply_project(
2815
+ source_root,
2816
+ project,
2817
+ selected,
2818
+ hooks,
2819
+ project_transaction,
2820
+ interpreter,
2821
+ hooks_dir,
2822
+ update,
2823
+ )
2824
+ results.append(result_line)
2825
+ except (Exception, KeyboardInterrupt) as error:
2826
+ rollback_errors = []
2827
+ try:
2828
+ _rollback_project(project_transaction)
2829
+ except (Exception, KeyboardInterrupt) as rollback_error:
2830
+ rollback_errors.append(str(rollback_error))
2831
+ for transaction in reversed(target_transactions):
2832
+ try:
2833
+ _rollback_target(transaction)
2834
+ except (Exception, KeyboardInterrupt) as rollback_error:
2835
+ rollback_errors.append(str(rollback_error))
2836
+ detail = ""
2837
+ if rollback_errors:
2838
+ detail = f"; rollback incomplete: {'; '.join(rollback_errors)}"
2839
+ reason = "interrupted" if isinstance(error, KeyboardInterrupt) else str(error)
2840
+ raise InstallerError(
2841
+ f"installation failed and was rolled back: {reason}{detail}"
2842
+ ) from error
2843
+ finally:
2844
+ _release_install_locks(install_locks, lock_directories)
2845
+ _append_host_notes(results, selected)
2846
+ return results
2847
+
2848
+
2849
+ def parser() -> argparse.ArgumentParser:
2850
+ argument_parser = argparse.ArgumentParser(description=__doc__)
2851
+ for target in TARGETS:
2852
+ argument_parser.add_argument(f"--{target}", action="store_true")
2853
+ argument_parser.add_argument(f"--{target}-root", type=Path)
2854
+ argument_parser.add_argument("--all", action="store_true", dest="all_targets")
2855
+ argument_parser.add_argument("--update", action="store_true")
2856
+ argument_parser.add_argument("--local", action="store_true")
2857
+ argument_parser.add_argument("--dry-run", action="store_true")
2858
+ argument_parser.add_argument("--project", type=Path)
2859
+ argument_parser.add_argument("--doctor", action="store_true")
2860
+ argument_parser.add_argument("--hooks", action="store_true")
2861
+ argument_parser.add_argument("--hooks-init", action="store_true")
2862
+ argument_parser.add_argument("--hooks-refresh", action="store_true")
2863
+ argument_parser.add_argument("--hooks-refresh-full", action="store_true")
2864
+ argument_parser.add_argument(
2865
+ "--source-root",
2866
+ type=Path,
2867
+ default=Path(__file__).resolve().parents[1],
2868
+ help=argparse.SUPPRESS,
2869
+ )
2870
+ return argument_parser
2871
+
2872
+
2873
+ def main(argv: Optional[Sequence[str]] = None) -> int:
2874
+ argument_parser = parser()
2875
+ arguments = argument_parser.parse_args(argv)
2876
+ source_root = absolute_path(arguments.source_root)
2877
+ project = (
2878
+ absolute_path(arguments.project) if arguments.project is not None else None
2879
+ )
2880
+ if arguments.doctor:
2881
+ named = [target for target in TARGETS if getattr(arguments, target)]
2882
+ selected = named if named and not arguments.all_targets else list(TARGETS)
2883
+
2884
+ def root_for(target: str) -> Path:
2885
+ override = getattr(arguments, f"{target}_root")
2886
+ if override is not None:
2887
+ return absolute_path(override)
2888
+ if arguments.local:
2889
+ return local_root(target, Path.cwd())
2890
+ return default_root(target)
2891
+
2892
+ findings = doctor(source_root, selected, root_for, project)
2893
+ for finding in findings:
2894
+ level = finding["level"]
2895
+ text = finding["text"]
2896
+ if level == "fail":
2897
+ print(f"error: {text}", file=sys.stderr)
2898
+ elif level == "ok":
2899
+ print(text)
2900
+ else:
2901
+ print(f"note: {text}")
2902
+ failed = sum(finding["level"] == "fail" for finding in findings)
2903
+ print(
2904
+ f"{failed} problem{'s' if failed != 1 else ''} found."
2905
+ if failed
2906
+ else "Healthy."
2907
+ )
2908
+ return 1 if failed else 0
2909
+ hooks_init = arguments.hooks_init
2910
+ if hooks_init or arguments.hooks_refresh or arguments.hooks_refresh_full:
2911
+ if project is None:
2912
+ option = "--hooks-init" if hooks_init else "--hooks-refresh"
2913
+ print(f"error: {option} requires --project", file=sys.stderr)
2914
+ return 2
2915
+ try:
2916
+ selected = [
2917
+ target
2918
+ for target in TARGETS
2919
+ if arguments.all_targets or getattr(arguments, target)
2920
+ ]
2921
+ if hooks_init and not selected:
2922
+ print(
2923
+ "error: --hooks-init requires at least one target or --all",
2924
+ file=sys.stderr,
2925
+ )
2926
+ return 2
2927
+ problems = sync_skill_resources.mismatches(source_root)
2928
+ if problems:
2929
+ raise InstallerError(
2930
+ "source resources are stale: " + "; ".join(problems)
2931
+ )
2932
+ refreshed = refresh_hooks(
2933
+ source_root,
2934
+ project,
2935
+ hooks_init or arguments.hooks_refresh_full,
2936
+ arguments.dry_run,
2937
+ selected,
2938
+ hooks_init,
2939
+ )
2940
+ notes = [line for line in refreshed if line.startswith("note:")]
2941
+ files = [line for line in refreshed if not line.startswith("note:")]
2942
+ if arguments.dry_run:
2943
+ prefix = "would initialize" if hooks_init else "would refresh"
2944
+ else:
2945
+ prefix = "initialized" if hooks_init else "refreshed"
2946
+ print(f"hooks: {prefix} {', '.join(files)}")
2947
+ for note in notes:
2948
+ print(note)
2949
+ except InstallerError as error:
2950
+ print(f"error: {error}", file=sys.stderr)
2951
+ return 1
2952
+ return 0
2953
+ selected = [
2954
+ target
2955
+ for target in TARGETS
2956
+ if arguments.all_targets or getattr(arguments, target)
2957
+ ]
2958
+ if not selected:
2959
+ if arguments.update:
2960
+ selected = list(TARGETS)
2961
+ else:
2962
+ argument_parser.error("select at least one target or --all")
2963
+
2964
+ roots = {}
2965
+ for target in selected:
2966
+ override = getattr(arguments, f"{target}_root")
2967
+ roots[target] = (
2968
+ absolute_path(override)
2969
+ if override is not None
2970
+ else (
2971
+ local_root(target, Path.cwd())
2972
+ if arguments.local
2973
+ else default_root(target)
2974
+ )
2975
+ )
2976
+ plans = (
2977
+ detect_installs(selected, roots)
2978
+ if arguments.update
2979
+ else [TargetPlan(target, roots[target]) for target in selected]
2980
+ )
2981
+ if arguments.update and not plans:
2982
+ scope = "this project" if arguments.local else "global roots"
2983
+ print(
2984
+ "error: no existing GSD Path skills found to update "
2985
+ f"({scope}); run an install first, e.g. --all"
2986
+ + (" --local" if arguments.local else ""),
2987
+ file=sys.stderr,
2988
+ )
2989
+ return 1
2990
+ try:
2991
+ for result in install(
2992
+ source_root,
2993
+ plans,
2994
+ project,
2995
+ arguments.dry_run,
2996
+ arguments.hooks,
2997
+ migrate_legacy=not arguments.local,
2998
+ update=arguments.update,
2999
+ ):
3000
+ print(result)
3001
+ except InstallerError as error:
3002
+ print(f"error: {error}", file=sys.stderr)
3003
+ return 1
3004
+ return 0
3005
+
3006
+
3007
+ if __name__ == "__main__":
3008
+ raise SystemExit(main())