@opengsd/gsd-path 1.3.1 → 1.4.0

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