@opengsd/gsd-path 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DOCS.md +4 -3
  3. package/FULL.md +64 -4
  4. package/HOOKS.md +63 -2
  5. package/MIGRATE.md +12 -0
  6. package/QUICK.md +4 -2
  7. package/README.md +67 -15
  8. package/RUNTIME.md +23 -5
  9. package/UPDATE.md +35 -0
  10. package/WORKFLOW.md +58 -3
  11. package/package.json +1 -1
  12. package/scripts/_common.py +107 -1
  13. package/scripts/build_recovery.py +62 -30
  14. package/scripts/build_state.py +195 -5
  15. package/scripts/check_handoffs.py +92 -30
  16. package/scripts/check_task_briefs.py +19 -14
  17. package/scripts/detect_project.py +17 -2
  18. package/scripts/discussion_records.py +9 -1
  19. package/scripts/discussion_validate.py +7 -6
  20. package/scripts/dispatch_driver.py +157 -31
  21. package/scripts/git_guard.py +24 -5
  22. package/scripts/guard_hook.py +1216 -47
  23. package/scripts/install.mjs +76 -3
  24. package/scripts/install.py +182 -37
  25. package/scripts/integration.py +98 -64
  26. package/scripts/isolation.py +148 -11
  27. package/scripts/members.py +68 -25
  28. package/scripts/migrate_core.py +2 -0
  29. package/scripts/model_policy.py +5 -1
  30. package/scripts/pipeline_diagnose.py +17 -2
  31. package/scripts/pipeline_git.py +68 -30
  32. package/scripts/pipeline_state.py +28 -6
  33. package/scripts/pipeline_undo.py +17 -8
  34. package/scripts/promote_lookahead.py +3 -3
  35. package/scripts/review_findings.py +180 -44
  36. package/scripts/roadmap.py +9 -1
  37. package/scripts/runtime_store.py +1 -0
  38. package/scripts/state_checkpoint.py +30 -9
  39. package/scripts/state_promote.py +15 -4
  40. package/scripts/task_context.py +2 -0
  41. package/scripts/workflow_run.py +9 -2
  42. package/skills/gsd-path/DEFINE.md +6 -2
  43. package/skills/gsd-path/PLAN.md +3 -0
  44. package/skills/gsd-path/ROADMAP.md +16 -6
  45. package/skills/gsd-path/SHIP.md +26 -4
  46. package/skills/gsd-path/SKILL.md +52 -24
  47. package/skills/gsd-path/references/build-native.md +96 -9
  48. package/skills/gsd-path/references/build-recovery.md +13 -0
  49. package/skills/gsd-path/references/coder.md +7 -3
  50. package/skills/gsd-path/references/operating-rules.md +11 -3
  51. package/skills/gsd-path/references/planner.md +13 -1
  52. package/skills/gsd-path/references/reviewer-final.md +5 -5
  53. package/skills/gsd-path/references/reviewer-wave.md +46 -13
  54. package/skills/gsd-path/references/runtime-selection.md +9 -0
  55. package/skills/gsd-path/scripts/_common.py +107 -1
  56. package/skills/gsd-path/scripts/build_recovery.py +62 -30
  57. package/skills/gsd-path/scripts/build_state.py +195 -5
  58. package/skills/gsd-path/scripts/check_handoffs.py +92 -30
  59. package/skills/gsd-path/scripts/check_task_briefs.py +19 -14
  60. package/skills/gsd-path/scripts/detect_project.py +17 -2
  61. package/skills/gsd-path/scripts/discussion_records.py +9 -1
  62. package/skills/gsd-path/scripts/discussion_validate.py +7 -6
  63. package/skills/gsd-path/scripts/dispatch_driver.py +157 -31
  64. package/skills/gsd-path/scripts/git_guard.py +24 -5
  65. package/skills/gsd-path/scripts/integration.py +98 -64
  66. package/skills/gsd-path/scripts/isolation.py +148 -11
  67. package/skills/gsd-path/scripts/members.py +68 -25
  68. package/skills/gsd-path/scripts/model_policy.py +5 -1
  69. package/skills/gsd-path/scripts/pipeline_git.py +68 -30
  70. package/skills/gsd-path/scripts/pipeline_state.py +28 -6
  71. package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
  72. package/skills/gsd-path/scripts/review_findings.py +180 -44
  73. package/skills/gsd-path/scripts/roadmap.py +9 -1
  74. package/skills/gsd-path/scripts/state_checkpoint.py +30 -9
  75. package/skills/gsd-path/scripts/state_promote.py +15 -4
  76. package/skills/gsd-path/scripts/task_context.py +2 -0
  77. package/skills/gsd-path/scripts/workflow_run.py +9 -2
  78. package/skills/gsd-path/templates/final-review.md +5 -5
  79. package/skills/gsd-path/templates/state.md +1 -0
  80. package/skills/gsd-path/templates/task.md +1 -0
  81. package/skills/gsd-path/templates/wave-review.md +18 -9
  82. package/skills/gsd-path-build/references/build-native.md +96 -9
  83. package/skills/gsd-path-build/references/build-recovery.md +13 -0
  84. package/skills/gsd-path-build/references/coder.md +7 -3
  85. package/skills/gsd-path-build/references/operating-rules.md +11 -3
  86. package/skills/gsd-path-build/references/reviewer-final.md +5 -5
  87. package/skills/gsd-path-build/references/reviewer-wave.md +46 -13
  88. package/skills/gsd-path-build/references/runtime-selection.md +9 -0
  89. package/skills/gsd-path-build/scripts/_common.py +107 -1
  90. package/skills/gsd-path-build/scripts/build_recovery.py +62 -30
  91. package/skills/gsd-path-build/scripts/build_state.py +195 -5
  92. package/skills/gsd-path-build/scripts/check_handoffs.py +92 -30
  93. package/skills/gsd-path-build/scripts/check_task_briefs.py +19 -14
  94. package/skills/gsd-path-build/scripts/detect_project.py +17 -2
  95. package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
  96. package/skills/gsd-path-build/scripts/discussion_validate.py +7 -6
  97. package/skills/gsd-path-build/scripts/dispatch_driver.py +157 -31
  98. package/skills/gsd-path-build/scripts/integration.py +98 -64
  99. package/skills/gsd-path-build/scripts/isolation.py +148 -11
  100. package/skills/gsd-path-build/scripts/members.py +68 -25
  101. package/skills/gsd-path-build/scripts/model_policy.py +5 -1
  102. package/skills/gsd-path-build/scripts/pipeline_git.py +68 -30
  103. package/skills/gsd-path-build/scripts/pipeline_state.py +28 -6
  104. package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
  105. package/skills/gsd-path-build/scripts/review_findings.py +180 -44
  106. package/skills/gsd-path-build/scripts/roadmap.py +9 -1
  107. package/skills/gsd-path-build/scripts/state_checkpoint.py +30 -9
  108. package/skills/gsd-path-build/scripts/state_promote.py +15 -4
  109. package/skills/gsd-path-build/scripts/task_context.py +2 -0
  110. package/skills/gsd-path-build/scripts/workflow_run.py +9 -2
  111. package/skills/gsd-path-build/templates/task.md +1 -0
  112. package/skills/gsd-path-build/templates/wave-review.md +18 -9
  113. package/skills/gsd-path-decide/references/operating-rules.md +11 -3
  114. package/skills/gsd-path-decide/references/runtime-selection.md +9 -0
  115. package/skills/gsd-path-decide/scripts/_common.py +107 -1
  116. package/skills/gsd-path-decide/scripts/build_recovery.py +62 -30
  117. package/skills/gsd-path-decide/scripts/check_handoffs.py +92 -30
  118. package/skills/gsd-path-decide/scripts/check_task_briefs.py +19 -14
  119. package/skills/gsd-path-decide/scripts/detect_project.py +17 -2
  120. package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
  121. package/skills/gsd-path-decide/scripts/discussion_validate.py +7 -6
  122. package/skills/gsd-path-decide/scripts/integration.py +98 -64
  123. package/skills/gsd-path-decide/scripts/isolation.py +148 -11
  124. package/skills/gsd-path-decide/scripts/members.py +68 -25
  125. package/skills/gsd-path-decide/scripts/model_policy.py +5 -1
  126. package/skills/gsd-path-decide/scripts/pipeline_git.py +68 -30
  127. package/skills/gsd-path-decide/scripts/pipeline_state.py +28 -6
  128. package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
  129. package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
  130. package/skills/gsd-path-decide/scripts/state_checkpoint.py +30 -9
  131. package/skills/gsd-path-decide/scripts/state_promote.py +15 -4
  132. package/skills/gsd-path-define/SKILL.md +6 -2
  133. package/skills/gsd-path-define/references/build-recovery.md +13 -0
  134. package/skills/gsd-path-define/references/operating-rules.md +11 -3
  135. package/skills/gsd-path-define/references/runtime-selection.md +9 -0
  136. package/skills/gsd-path-define/scripts/_common.py +107 -1
  137. package/skills/gsd-path-define/scripts/build_recovery.py +62 -30
  138. package/skills/gsd-path-define/scripts/check_handoffs.py +92 -30
  139. package/skills/gsd-path-define/scripts/check_task_briefs.py +19 -14
  140. package/skills/gsd-path-define/scripts/detect_project.py +17 -2
  141. package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
  142. package/skills/gsd-path-define/scripts/discussion_validate.py +7 -6
  143. package/skills/gsd-path-define/scripts/integration.py +98 -64
  144. package/skills/gsd-path-define/scripts/isolation.py +148 -11
  145. package/skills/gsd-path-define/scripts/members.py +68 -25
  146. package/skills/gsd-path-define/scripts/model_policy.py +5 -1
  147. package/skills/gsd-path-define/scripts/pipeline_git.py +68 -30
  148. package/skills/gsd-path-define/scripts/pipeline_state.py +28 -6
  149. package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
  150. package/skills/gsd-path-define/scripts/roadmap.py +9 -1
  151. package/skills/gsd-path-define/scripts/state_checkpoint.py +30 -9
  152. package/skills/gsd-path-define/scripts/state_promote.py +15 -4
  153. package/skills/gsd-path-define/templates/state.md +1 -0
  154. package/skills/gsd-path-discuss/references/operating-rules.md +11 -3
  155. package/skills/gsd-path-discuss/references/runtime-selection.md +9 -0
  156. package/skills/gsd-path-discuss/scripts/_common.py +107 -1
  157. package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
  158. package/skills/gsd-path-discuss/scripts/model_policy.py +5 -1
  159. package/skills/gsd-path-docs-audit/references/operating-rules.md +11 -3
  160. package/skills/gsd-path-docs-audit/references/runtime-selection.md +9 -0
  161. package/skills/gsd-path-docs-audit/scripts/_common.py +107 -1
  162. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +62 -30
  163. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +92 -30
  164. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +19 -14
  165. package/skills/gsd-path-docs-audit/scripts/detect_project.py +17 -2
  166. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
  167. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +7 -6
  168. package/skills/gsd-path-docs-audit/scripts/integration.py +98 -64
  169. package/skills/gsd-path-docs-audit/scripts/isolation.py +148 -11
  170. package/skills/gsd-path-docs-audit/scripts/members.py +68 -25
  171. package/skills/gsd-path-docs-audit/scripts/model_policy.py +5 -1
  172. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +68 -30
  173. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +28 -6
  174. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
  175. package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
  176. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +30 -9
  177. package/skills/gsd-path-docs-audit/scripts/state_promote.py +15 -4
  178. package/skills/gsd-path-forensics/references/operating-rules.md +11 -3
  179. package/skills/gsd-path-forensics/references/runtime-selection.md +9 -0
  180. package/skills/gsd-path-forensics/scripts/_common.py +107 -1
  181. package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
  182. package/skills/gsd-path-forensics/scripts/model_policy.py +5 -1
  183. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
  184. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +17 -8
  185. package/skills/gsd-path-inspect/references/operating-rules.md +11 -3
  186. package/skills/gsd-path-inspect/references/runtime-selection.md +9 -0
  187. package/skills/gsd-path-inspect/scripts/_common.py +107 -1
  188. package/skills/gsd-path-inspect/scripts/build_recovery.py +62 -30
  189. package/skills/gsd-path-inspect/scripts/check_handoffs.py +92 -30
  190. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +19 -14
  191. package/skills/gsd-path-inspect/scripts/detect_project.py +17 -2
  192. package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
  193. package/skills/gsd-path-inspect/scripts/discussion_validate.py +7 -6
  194. package/skills/gsd-path-inspect/scripts/integration.py +98 -64
  195. package/skills/gsd-path-inspect/scripts/isolation.py +148 -11
  196. package/skills/gsd-path-inspect/scripts/members.py +68 -25
  197. package/skills/gsd-path-inspect/scripts/model_policy.py +5 -1
  198. package/skills/gsd-path-inspect/scripts/pipeline_git.py +68 -30
  199. package/skills/gsd-path-inspect/scripts/pipeline_state.py +28 -6
  200. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
  201. package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
  202. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +30 -9
  203. package/skills/gsd-path-inspect/scripts/state_promote.py +15 -4
  204. package/skills/gsd-path-inspect/scripts/workflow_run.py +9 -2
  205. package/skills/gsd-path-inspect/templates/state.md +1 -0
  206. package/skills/gsd-path-loop/references/operating-rules.md +11 -3
  207. package/skills/gsd-path-loop/references/runtime-selection.md +9 -0
  208. package/skills/gsd-path-loop/scripts/_common.py +107 -1
  209. package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
  210. package/skills/gsd-path-loop/scripts/model_policy.py +5 -1
  211. package/skills/gsd-path-migrate/SKILL.md +8 -0
  212. package/skills/gsd-path-migrate/references/operating-rules.md +11 -3
  213. package/skills/gsd-path-migrate/references/runtime-selection.md +9 -0
  214. package/skills/gsd-path-migrate/scripts/_common.py +107 -1
  215. package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
  216. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
  217. package/skills/gsd-path-migrate/scripts/model_policy.py +5 -1
  218. package/skills/gsd-path-plan/SKILL.md +3 -0
  219. package/skills/gsd-path-plan/references/build-recovery.md +13 -0
  220. package/skills/gsd-path-plan/references/operating-rules.md +11 -3
  221. package/skills/gsd-path-plan/references/planner.md +13 -1
  222. package/skills/gsd-path-plan/references/runtime-selection.md +9 -0
  223. package/skills/gsd-path-plan/scripts/_common.py +107 -1
  224. package/skills/gsd-path-plan/scripts/build_recovery.py +62 -30
  225. package/skills/gsd-path-plan/scripts/check_handoffs.py +92 -30
  226. package/skills/gsd-path-plan/scripts/check_task_briefs.py +19 -14
  227. package/skills/gsd-path-plan/scripts/detect_project.py +17 -2
  228. package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
  229. package/skills/gsd-path-plan/scripts/discussion_validate.py +7 -6
  230. package/skills/gsd-path-plan/scripts/integration.py +98 -64
  231. package/skills/gsd-path-plan/scripts/isolation.py +148 -11
  232. package/skills/gsd-path-plan/scripts/members.py +68 -25
  233. package/skills/gsd-path-plan/scripts/model_policy.py +5 -1
  234. package/skills/gsd-path-plan/scripts/pipeline_git.py +68 -30
  235. package/skills/gsd-path-plan/scripts/pipeline_state.py +28 -6
  236. package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
  237. package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
  238. package/skills/gsd-path-plan/scripts/state_checkpoint.py +30 -9
  239. package/skills/gsd-path-plan/scripts/state_promote.py +15 -4
  240. package/skills/gsd-path-plan/scripts/workflow_run.py +9 -2
  241. package/skills/gsd-path-plan/templates/task.md +1 -0
  242. package/skills/gsd-path-research/references/operating-rules.md +11 -3
  243. package/skills/gsd-path-research/references/runtime-selection.md +9 -0
  244. package/skills/gsd-path-research/scripts/_common.py +107 -1
  245. package/skills/gsd-path-research/scripts/build_recovery.py +62 -30
  246. package/skills/gsd-path-research/scripts/check_handoffs.py +92 -30
  247. package/skills/gsd-path-research/scripts/check_task_briefs.py +19 -14
  248. package/skills/gsd-path-research/scripts/detect_project.py +17 -2
  249. package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
  250. package/skills/gsd-path-research/scripts/discussion_validate.py +7 -6
  251. package/skills/gsd-path-research/scripts/integration.py +98 -64
  252. package/skills/gsd-path-research/scripts/isolation.py +148 -11
  253. package/skills/gsd-path-research/scripts/members.py +68 -25
  254. package/skills/gsd-path-research/scripts/model_policy.py +5 -1
  255. package/skills/gsd-path-research/scripts/pipeline_git.py +68 -30
  256. package/skills/gsd-path-research/scripts/pipeline_state.py +28 -6
  257. package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
  258. package/skills/gsd-path-research/scripts/roadmap.py +9 -1
  259. package/skills/gsd-path-research/scripts/state_checkpoint.py +30 -9
  260. package/skills/gsd-path-research/scripts/state_promote.py +15 -4
  261. package/skills/gsd-path-roadmap/SKILL.md +16 -6
  262. package/skills/gsd-path-roadmap/references/operating-rules.md +11 -3
  263. package/skills/gsd-path-roadmap/references/runtime-selection.md +9 -0
  264. package/skills/gsd-path-roadmap/scripts/_common.py +107 -1
  265. package/skills/gsd-path-roadmap/scripts/build_recovery.py +62 -30
  266. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +92 -30
  267. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +19 -14
  268. package/skills/gsd-path-roadmap/scripts/detect_project.py +17 -2
  269. package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
  270. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +7 -6
  271. package/skills/gsd-path-roadmap/scripts/integration.py +98 -64
  272. package/skills/gsd-path-roadmap/scripts/isolation.py +148 -11
  273. package/skills/gsd-path-roadmap/scripts/members.py +68 -25
  274. package/skills/gsd-path-roadmap/scripts/model_policy.py +5 -1
  275. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +68 -30
  276. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +28 -6
  277. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
  278. package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
  279. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +30 -9
  280. package/skills/gsd-path-roadmap/scripts/state_promote.py +15 -4
  281. package/skills/gsd-path-ship/SKILL.md +26 -4
  282. package/skills/gsd-path-ship/references/operating-rules.md +11 -3
  283. package/skills/gsd-path-ship/references/reviewer-final.md +5 -5
  284. package/skills/gsd-path-ship/references/reviewer-wave.md +46 -13
  285. package/skills/gsd-path-ship/references/runtime-selection.md +9 -0
  286. package/skills/gsd-path-ship/scripts/_common.py +107 -1
  287. package/skills/gsd-path-ship/scripts/build_recovery.py +62 -30
  288. package/skills/gsd-path-ship/scripts/build_state.py +195 -5
  289. package/skills/gsd-path-ship/scripts/check_handoffs.py +92 -30
  290. package/skills/gsd-path-ship/scripts/check_task_briefs.py +19 -14
  291. package/skills/gsd-path-ship/scripts/detect_project.py +17 -2
  292. package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
  293. package/skills/gsd-path-ship/scripts/discussion_validate.py +7 -6
  294. package/skills/gsd-path-ship/scripts/integration.py +98 -64
  295. package/skills/gsd-path-ship/scripts/isolation.py +148 -11
  296. package/skills/gsd-path-ship/scripts/members.py +68 -25
  297. package/skills/gsd-path-ship/scripts/model_policy.py +5 -1
  298. package/skills/gsd-path-ship/scripts/pipeline_git.py +68 -30
  299. package/skills/gsd-path-ship/scripts/pipeline_state.py +28 -6
  300. package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
  301. package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
  302. package/skills/gsd-path-ship/scripts/state_checkpoint.py +30 -9
  303. package/skills/gsd-path-ship/scripts/state_promote.py +15 -4
  304. package/skills/gsd-path-ship/scripts/workflow_run.py +9 -2
  305. package/skills/gsd-path-ship/templates/final-review.md +5 -5
  306. package/skills/gsd-path-ship/templates/wave-review.md +18 -9
  307. package/skills/gsd-path-undo/references/operating-rules.md +11 -3
  308. package/skills/gsd-path-undo/references/runtime-selection.md +9 -0
  309. package/skills/gsd-path-undo/scripts/_common.py +107 -1
  310. package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
  311. package/skills/gsd-path-undo/scripts/model_policy.py +5 -1
  312. package/skills/gsd-path-undo/scripts/pipeline_undo.py +17 -8
  313. package/skills/path/DEFINE.md +6 -2
  314. package/skills/path/PLAN.md +3 -0
  315. package/skills/path/ROADMAP.md +16 -6
  316. package/skills/path/SHIP.md +26 -4
  317. package/skills/path/SKILL.md +52 -24
  318. package/skills/path/references/build-native.md +96 -9
  319. package/skills/path/references/build-recovery.md +13 -0
  320. package/skills/path/references/coder.md +7 -3
  321. package/skills/path/references/operating-rules.md +11 -3
  322. package/skills/path/references/planner.md +13 -1
  323. package/skills/path/references/reviewer-final.md +5 -5
  324. package/skills/path/references/reviewer-wave.md +46 -13
  325. package/skills/path/references/runtime-selection.md +9 -0
  326. package/skills/path/scripts/_common.py +107 -1
  327. package/skills/path/scripts/build_recovery.py +62 -30
  328. package/skills/path/scripts/build_state.py +195 -5
  329. package/skills/path/scripts/check_handoffs.py +92 -30
  330. package/skills/path/scripts/check_task_briefs.py +19 -14
  331. package/skills/path/scripts/detect_project.py +17 -2
  332. package/skills/path/scripts/discussion_records.py +9 -1
  333. package/skills/path/scripts/discussion_validate.py +7 -6
  334. package/skills/path/scripts/dispatch_driver.py +157 -31
  335. package/skills/path/scripts/git_guard.py +24 -5
  336. package/skills/path/scripts/integration.py +98 -64
  337. package/skills/path/scripts/isolation.py +148 -11
  338. package/skills/path/scripts/members.py +68 -25
  339. package/skills/path/scripts/model_policy.py +5 -1
  340. package/skills/path/scripts/pipeline_git.py +68 -30
  341. package/skills/path/scripts/pipeline_state.py +28 -6
  342. package/skills/path/scripts/promote_lookahead.py +3 -3
  343. package/skills/path/scripts/review_findings.py +180 -44
  344. package/skills/path/scripts/roadmap.py +9 -1
  345. package/skills/path/scripts/state_checkpoint.py +30 -9
  346. package/skills/path/scripts/state_promote.py +15 -4
  347. package/skills/path/scripts/task_context.py +2 -0
  348. package/skills/path/scripts/workflow_run.py +9 -2
  349. package/skills/path/templates/final-review.md +5 -5
  350. package/skills/path/templates/state.md +1 -0
  351. package/skills/path/templates/task.md +1 -0
  352. package/skills/path/templates/wave-review.md +18 -9
@@ -407,6 +407,18 @@ GIT_REASONS = {
407
407
  "clean": "git clean -f deletes untracked evidence and retained task worktrees",
408
408
  "push": "force pushes rewrite build-branch history the pipeline resumes from",
409
409
  "branch": "git branch -D destroys task branches the recovery protocol inspects",
410
+ "unresolved-target": (
411
+ "{action} needs a literal target the guard can resolve; name the branch, "
412
+ "ref, or registered worktree directly"
413
+ ),
414
+ "git-environment": (
415
+ "env changes Git's repository or alias configuration context; run the Git "
416
+ "command without resetting or unsetting Git-related environment variables"
417
+ ),
418
+ "update-ref-order": (
419
+ "git update-ref deletion after another Git command can follow a symbolic "
420
+ "ref that command changed; run it as its own command or add --no-deref"
421
+ ),
410
422
  "branch-move": (
411
423
  "git branch -m renames a branch the pipeline resumes by name "
412
424
  "(STATE.branch); create a new branch instead"
@@ -1385,7 +1397,12 @@ def segment_directories(tokens, working_directories, contexts=None):
1385
1397
  if name not in SHELL_DIRECTORY_VARIABLES
1386
1398
  }
1387
1399
  expanded = expand_environment_parameters(target, tracked, tracked_only=True)
1388
- if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(expanded):
1400
+ # An unquoted empty expansion leaves no word: the shell changes to HOME.
1401
+ if (
1402
+ (target and not expanded)
1403
+ or SHELL_PARAMETER_SYNTAX.search(expanded)
1404
+ or CMD_PARAMETER_SYNTAX.search(expanded)
1405
+ ):
1389
1406
  raise ValueError(
1390
1407
  f"cd target {target} cannot be resolved by the guard; "
1391
1408
  "run that command first and cd to the literal result"
@@ -1890,9 +1907,15 @@ def shell_assignment_changes(segment, values):
1890
1907
  return updates, removes
1891
1908
 
1892
1909
 
1893
- def shell_assignment_contexts(tokens, initial=None):
1910
+ def shell_assignment_contexts(tokens, initial=None, working_directories=None):
1894
1911
  """Return the persistent shell variables visible before each segment."""
1895
1912
  values = dict(initial or {})
1913
+ directories = list(working_directories or [os.getcwd()])
1914
+ if "PWD" not in values:
1915
+ values["PWD"] = (
1916
+ os.path.abspath(native_path_text(directories[0]))
1917
+ if len(directories) == 1 else "$PWD"
1918
+ )
1896
1919
  contexts = []
1897
1920
  segments = list(command_segments(tokens))
1898
1921
  flows = shell_segment_flow(tokens)
@@ -1907,15 +1930,39 @@ def shell_assignment_contexts(tokens, initial=None):
1907
1930
  if conditional:
1908
1931
  values[name] = f"${name}"
1909
1932
  else:
1910
- values.pop(name, None)
1933
+ # An unset variable expands to empty, never its inherited value.
1934
+ values[name] = ""
1911
1935
  for name, value in updates.items():
1912
1936
  values[name] = f"${name}" if conditional else value
1937
+ invocation = command_invocation(segment)
1938
+ if invocation is not None and invocation[0] in DIRECTORY_CHANGE_COMMANDS:
1939
+ # The same tracked expansion as segment_directories.
1940
+ target = expand_environment_parameters(
1941
+ directory_change_target(invocation[1]),
1942
+ {
1943
+ name: value
1944
+ for name, value in contexts[-1].items()
1945
+ if name not in SHELL_DIRECTORY_VARIABLES
1946
+ },
1947
+ tracked_only=True,
1948
+ )
1949
+ directories = (
1950
+ [target] if is_absolute_path(target)
1951
+ else [f"{directory}/{target}" for directory in directories]
1952
+ )
1953
+ values["OLDPWD"] = values.get("PWD", "$PWD")
1954
+ values["PWD"] = (
1955
+ os.path.abspath(native_path_text(directories[0]))
1956
+ if not conditional and len(directories) == 1 else "$PWD"
1957
+ )
1958
+ if conditional:
1959
+ values["OLDPWD"] = "$OLDPWD"
1913
1960
  return contexts, values
1914
1961
 
1915
1962
 
1916
1963
  def shell_segment_contexts(tokens, working_directories, initial=None):
1917
1964
  """Yield each segment with its working directory and prior shell variables."""
1918
- contexts, _ = shell_assignment_contexts(tokens, initial)
1965
+ contexts, _ = shell_assignment_contexts(tokens, initial, working_directories)
1919
1966
  segments = list(segment_directories(tokens, working_directories, contexts))
1920
1967
  if len(segments) != len(contexts):
1921
1968
  raise ValueError("shell command segments cannot be aligned")
@@ -2208,6 +2255,10 @@ def decode_patch_path(raw_path, strip_prefix):
2208
2255
 
2209
2256
 
2210
2257
  PARAMETER_MARKERS = frozenset("$%!")
2258
+ SHELL_DESCRIPTOR_WORD = re.compile(r"[0-9]{2,}|\{\w+\}", re.ASCII)
2259
+ SHELL_OPERATOR_PARTS = re.compile(
2260
+ r"[<>]\(|&>>?|<<<|<<|>>|>&|<&|<>|>\||[<>]|&&|\|\||\|&|[\s\S]"
2261
+ )
2211
2262
 
2212
2263
 
2213
2264
  class ShellToken(str):
@@ -2216,6 +2267,11 @@ class ShellToken(str):
2216
2267
  `redirection` is true for an unquoted redirection operator and for one
2217
2268
  ASCII file descriptor digit written against one (the 2 of 2>file, not of
2218
2269
  2 >file). zsh passes a longer or non-ASCII digit word on as an argument.
2270
+ `descriptor` is true for a longer digit word or a {name} word written
2271
+ against a redirection operator: bash reads it as a file descriptor, zsh
2272
+ as an argument.
2273
+ `body` holds the tokens of a process substitution on its operator token,
2274
+ once destructive_git_reason has folded them.
2219
2275
  `literal` is "bare" for a word written with no quote or escape, "quoted"
2220
2276
  for a word inside one pair of plain quotes, "mixed" for plain quoted and
2221
2277
  unquoted parts where no unquoted part can expand, and None when the shell
@@ -2225,7 +2281,9 @@ class ShellToken(str):
2225
2281
 
2226
2282
  double_quoted = ()
2227
2283
  redirection = False
2284
+ descriptor = False
2228
2285
  literal = None
2286
+ body = None
2229
2287
 
2230
2288
 
2231
2289
  def double_quoted_markers(command):
@@ -2406,11 +2464,12 @@ def shell_tokens(command, direct=True):
2406
2464
  break
2407
2465
  start = end - len(token)
2408
2466
  before = command[start - 1:start] if start > 0 else ""
2467
+ escaped = (start - len(command[:start].rstrip("\\"))) % 2 == 1
2409
2468
  redirection = command[start:end] == token and (
2410
2469
  (
2411
2470
  set(token) <= SHELL_WRITE_REDIRECTION_CHARS
2412
2471
  and ("<" in token or ">" in token)
2413
- and before != "\\"
2472
+ and not escaped
2414
2473
  )
2415
2474
  or (
2416
2475
  len(token) == 1
@@ -2419,10 +2478,38 @@ def shell_tokens(command, direct=True):
2419
2478
  and (not before or before in " \t|;&()<>\n\r")
2420
2479
  )
2421
2480
  )
2422
- token = ShellToken(token)
2423
- token.redirection = redirection
2424
- token.literal = literal
2425
- tokens.append(token)
2481
+ descriptor = (
2482
+ command[start:end] == token
2483
+ and SHELL_DESCRIPTOR_WORD.fullmatch(token) is not None
2484
+ and command[end:end + 1] in ("<", ">")
2485
+ and (not before or before in " \t|;&()<>\n\r")
2486
+ )
2487
+ # The shell reads a control operator and a redirection operator
2488
+ # written against each other as two operators.
2489
+ parts = [token]
2490
+ if (
2491
+ command[start:end] == token
2492
+ and set(token) <= set("|;&()<>\n\r")
2493
+ and ("<" in token or ">" in token)
2494
+ and not escaped
2495
+ ):
2496
+ parts = []
2497
+ for part in SHELL_OPERATOR_PARTS.findall(token):
2498
+ if "<" in part or ">" in part or not parts or (
2499
+ "<" in parts[-1] or ">" in parts[-1]
2500
+ ):
2501
+ parts.append(part)
2502
+ else:
2503
+ parts[-1] += part
2504
+ for part in parts:
2505
+ part = ShellToken(part)
2506
+ part.redirection = redirection if len(parts) == 1 else (
2507
+ ("<" in part or ">" in part)
2508
+ and set(part) <= SHELL_WRITE_REDIRECTION_CHARS
2509
+ )
2510
+ part.descriptor = descriptor
2511
+ part.literal = literal
2512
+ tokens.append(part)
2426
2513
  except ValueError as error:
2427
2514
  raise ValueError(
2428
2515
  f"shell command cannot be tokenized ({error}); balance the quotes "
@@ -2438,14 +2525,46 @@ def shell_tokens(command, direct=True):
2438
2525
  marked = []
2439
2526
  for token in tokens:
2440
2527
  count = sum(character in PARAMETER_MARKERS for character in token)
2441
- redirection, literal = token.redirection, token.literal
2528
+ redirection, descriptor, literal = (
2529
+ token.redirection, token.descriptor, token.literal
2530
+ )
2442
2531
  token = ShellToken(token)
2443
- token.redirection, token.literal = redirection, literal
2532
+ token.redirection, token.descriptor, token.literal = (
2533
+ redirection, descriptor, literal
2534
+ )
2444
2535
  token.double_quoted, flags = tuple(flags[:count]), flags[count:]
2445
2536
  marked.append(token)
2446
2537
  return marked
2447
2538
 
2448
2539
 
2540
+ def shell_parameter_quoting_uncertain(command):
2541
+ """Detect literal parameters whose quoting is lost by shell_tokens."""
2542
+ command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
2543
+ # These forms expand in cmd, but are literal directory text in POSIX shells.
2544
+ if CMD_PARAMETER_SYNTAX.search(command):
2545
+ return True
2546
+ quote = None
2547
+ index = 0
2548
+ while index < len(command):
2549
+ character = command[index]
2550
+ if quote == "'":
2551
+ if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index):
2552
+ return True
2553
+ if character == "'":
2554
+ quote = None
2555
+ elif character == "\\":
2556
+ if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index + 1):
2557
+ return True
2558
+ index += 1
2559
+ elif character in "'\"":
2560
+ if quote is None:
2561
+ quote = character
2562
+ elif character == quote:
2563
+ quote = None
2564
+ index += 1
2565
+ return False
2566
+
2567
+
2449
2568
  def command_segments(tokens):
2450
2569
  """Yield simple commands with separators and leading control words removed."""
2451
2570
  segment = []
@@ -2932,21 +3051,31 @@ def has_short_option(arguments, option):
2932
3051
  )
2933
3052
 
2934
3053
 
2935
- def git_alias(command, git_options):
2936
- result = subprocess.run(
2937
- ["git", *git_options, "config", "--get", f"alias.{command}"],
2938
- encoding="utf-8", errors="replace",
2939
- capture_output=True,
2940
- check=False,
2941
- )
2942
- if result.returncode == 1:
2943
- return None
2944
- if result.returncode != 0:
2945
- raise ValueError("git alias configuration cannot be validated")
2946
- alias = result.stdout.strip()
2947
- if not alias or alias.startswith("!"):
2948
- raise ValueError("git alias configuration cannot be validated")
2949
- return alias
3054
+ def git_alias(command, git_options, working_directories=None):
3055
+ aliases = set()
3056
+ for directory in working_directories or [os.getcwd()]:
3057
+ try:
3058
+ result = subprocess.run(
3059
+ ["git", *git_options, "config", "--get", f"alias.{command}"],
3060
+ cwd=directory,
3061
+ encoding="utf-8", errors="replace",
3062
+ capture_output=True,
3063
+ check=False,
3064
+ )
3065
+ except OSError:
3066
+ raise ValueError("git alias configuration cannot be validated") from None
3067
+ if result.returncode == 1:
3068
+ aliases.add(None)
3069
+ continue
3070
+ if result.returncode != 0:
3071
+ raise ValueError("git alias configuration cannot be validated")
3072
+ alias = result.stdout.strip()
3073
+ if not alias or alias.startswith("!"):
3074
+ raise ValueError("git alias configuration cannot be validated")
3075
+ aliases.add(alias)
3076
+ if len(aliases) > 1:
3077
+ raise ValueError("git alias configuration is ambiguous across working directories")
3078
+ return next(iter(aliases), None)
2950
3079
 
2951
3080
 
2952
3081
  def update_ref_deletes(arguments):
@@ -2966,28 +3095,1012 @@ def update_ref_deletes(arguments):
2966
3095
  operands[1] == "" or not operands[1].strip("0")
2967
3096
  )
2968
3097
  return (
2969
- "-d" in arguments
3098
+ has_short_option(arguments, "d") # also a cluster such as -dm
2970
3099
  or "--delete" in arguments
2971
- or any(argument.startswith("--stdin") for argument in arguments)
3100
+ or any(git_option_in(argument, {"--stdin"}) for argument in arguments)
2972
3101
  or zero_value
2973
3102
  )
2974
3103
 
2975
3104
 
3105
+ PATH_OWNED_REF_PREFIXES = (
3106
+ "gsd-path/",
3107
+ "gsd-path-task/",
3108
+ "gsd-path-verify/",
3109
+ "gsd-path-integrate/",
3110
+ )
3111
+ # Git ignores an alias that has the name of one of its own commands, so these
3112
+ # need no alias inspection. Each has been a Git command since Git 2.31, the
3113
+ # oldest Git that has the rev-parse --path-format option the pipeline uses.
3114
+ # Deprecated commands stay out: newer Git lets an alias replace them
3115
+ # (whatchanged, pack-redundant). A name that is absent only fails closed.
3116
+ GIT_BUILT_IN_COMMANDS = frozenset({
3117
+ "add", "am", "annotate", "apply", "archive", "bisect", "blame", "branch",
3118
+ "bugreport", "bundle", "cat-file", "check-attr", "check-ignore",
3119
+ "check-mailmap", "check-ref-format", "checkout", "checkout-index", "cherry",
3120
+ "cherry-pick", "clean", "clone", "column", "commit", "commit-graph",
3121
+ "commit-tree", "config", "count-objects", "credential", "credential-store",
3122
+ "describe", "diff", "diff-files", "diff-index", "diff-tree", "difftool",
3123
+ "fast-export", "fast-import", "fetch", "fetch-pack", "fmt-merge-msg",
3124
+ "for-each-ref", "for-each-repo", "format-patch", "fsck", "gc",
3125
+ "get-tar-commit-id", "grep", "hash-object", "help", "index-pack", "init",
3126
+ "interpret-trailers", "log", "ls-files", "ls-remote", "ls-tree", "mailinfo",
3127
+ "mailsplit", "maintenance", "merge", "merge-base", "merge-file",
3128
+ "merge-index", "merge-tree", "mergetool", "mktag", "mktree",
3129
+ "multi-pack-index", "mv", "name-rev", "notes", "pack-objects", "pack-refs",
3130
+ "patch-id", "prune", "prune-packed", "pull", "push", "range-diff",
3131
+ "read-tree", "rebase", "receive-pack", "reflog", "remote", "repack",
3132
+ "replace", "rerere", "reset", "restore", "rev-list", "rev-parse", "revert",
3133
+ "rm", "send-pack", "shortlog", "show", "show-branch", "show-index",
3134
+ "show-ref", "sparse-checkout", "stage", "stash", "status", "stripspace",
3135
+ "submodule", "switch", "symbolic-ref", "tag", "unpack-file",
3136
+ "unpack-objects", "update-index", "update-ref", "update-server-info",
3137
+ "upload-archive", "upload-pack", "var", "verify-commit", "verify-pack",
3138
+ "verify-tag", "version", "worktree", "write-tree",
3139
+ })
3140
+
3141
+
3142
+ def _ownership_scalar(raw, path, key):
3143
+ value = raw.strip()
3144
+ if not value:
3145
+ return None
3146
+ if value.startswith('"'):
3147
+ quoted = re.match(r'^("(?:\\.|[^"\\])*\")(?:[ \t]+#.*)?$', value)
3148
+ if quoted is None:
3149
+ raise ValueError(f"{path.name} has an unreadable {key} value")
3150
+ try:
3151
+ value = json.loads(quoted.group(1))
3152
+ except json.JSONDecodeError:
3153
+ raise ValueError(f"{path.name} has an unreadable {key} value") from None
3154
+ if not isinstance(value, str):
3155
+ raise ValueError(f"{path.name} has a non-string {key} value")
3156
+ return value or None
3157
+ if value.startswith("'"):
3158
+ quoted = re.fullmatch(r"'((?:[^']|'')*)'(?:[ \t]+#.*)?", value)
3159
+ if quoted is None:
3160
+ raise ValueError(f"{path.name} has an unreadable {key} value")
3161
+ value = quoted.group(1).replace("''", "'")
3162
+ return value or None
3163
+ value = re.sub(r"[ \t]+#.*$", "", value).strip()
3164
+ if not value or value.casefold() in {"null", "~", "none"}:
3165
+ return None
3166
+ if value.casefold() in {"true", "false", "yes", "no", "on", "off"} or re.fullmatch(
3167
+ r"[-+]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?", value
3168
+ ):
3169
+ raise ValueError(f"{path.name} has a non-string {key} value")
3170
+ if value[0] in "[{|>&*!" or ": " in value:
3171
+ raise ValueError(f"{path.name} has a non-scalar {key} value")
3172
+ return value
3173
+
3174
+
3175
+ def ownership_frontmatter(path, keys, *, missing_ok=False):
3176
+ try:
3177
+ metadata = path.lstat()
3178
+ except FileNotFoundError:
3179
+ if missing_ok and not os.path.lexists(path):
3180
+ return {}
3181
+ raise ValueError(f"{path.name} ownership metadata cannot be read") from None
3182
+ except OSError as error:
3183
+ raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
3184
+ if not stat.S_ISREG(metadata.st_mode):
3185
+ raise ValueError(f"{path.name} ownership metadata is not a regular file")
3186
+ try:
3187
+ lines = path.read_text(encoding="utf-8").splitlines()
3188
+ except (OSError, UnicodeDecodeError) as error:
3189
+ raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
3190
+ if not lines or lines[0] != "---":
3191
+ raise ValueError(f"{path.name} has no readable frontmatter")
3192
+ try:
3193
+ end = lines.index("---", 1)
3194
+ except ValueError:
3195
+ raise ValueError(f"{path.name} frontmatter is not terminated") from None
3196
+
3197
+ values = {}
3198
+ pending_scalar = None
3199
+ for line in lines[1:end]:
3200
+ if not line.strip() or line.lstrip().startswith("#"):
3201
+ continue
3202
+ if line[0].isspace():
3203
+ if pending_scalar is not None:
3204
+ raise ValueError(
3205
+ f"{path.name} has a non-scalar {pending_scalar} value"
3206
+ )
3207
+ continue
3208
+ pending_scalar = None
3209
+ match = re.fullmatch(r"(?P<key>[A-Za-z_][A-Za-z0-9_-]*):(?P<value>.*)", line)
3210
+ if match is None:
3211
+ raise ValueError(f"{path.name} has unreadable frontmatter")
3212
+ key = match.group("key")
3213
+ if key not in keys:
3214
+ continue
3215
+ if key in values:
3216
+ raise ValueError(f"{path.name} repeats ownership field {key}")
3217
+ value = _ownership_scalar(match.group("value"), path, key)
3218
+ values[key] = value
3219
+ if value is None and not match.group("value").strip():
3220
+ pending_scalar = key
3221
+ return values
3222
+
3223
+
3224
+ def git_base_directories(git_options, working_directories):
3225
+ """Resolve target paths from each shell cwd after Git's ordered ``-C`` options."""
3226
+ result = []
3227
+ for directory in working_directories or [os.getcwd()]:
3228
+ current = Path(directory or os.getcwd()).resolve()
3229
+ index = 0
3230
+ while index < len(git_options):
3231
+ option = git_options[index]
3232
+ value = git_options[index + 1]
3233
+ if option == "-C":
3234
+ target = Path(value)
3235
+ current = (target if target.is_absolute() else current / target).resolve()
3236
+ index += 2
3237
+ result.append(current)
3238
+ return result
3239
+
3240
+
3241
+ def _git_output(git_options, directory, *arguments):
3242
+ try:
3243
+ result = subprocess.run(
3244
+ ["git", *git_options, *arguments],
3245
+ cwd=directory,
3246
+ capture_output=True,
3247
+ encoding="utf-8",
3248
+ errors="replace",
3249
+ check=False,
3250
+ )
3251
+ except OSError:
3252
+ raise ValueError("git target repository cannot be resolved") from None
3253
+ if result.returncode != 0:
3254
+ raise ValueError("git target repository cannot be resolved")
3255
+ return result.stdout
3256
+
3257
+
3258
+ def registered_worktree_records(output, separator="\0"):
3259
+ records = {}
3260
+ for record in output.split(separator * 2):
3261
+ fields = [field for field in record.split(separator) if field]
3262
+ if not fields:
3263
+ continue
3264
+ paths = [field[len("worktree "):] for field in fields if field.startswith("worktree ")]
3265
+ branches = [field[len("branch "):] for field in fields if field.startswith("branch ")]
3266
+ if len(paths) != 1 or len(branches) > 1:
3267
+ raise ValueError("registered worktree metadata cannot be resolved")
3268
+ path = Path(paths[0])
3269
+ if not path.is_absolute():
3270
+ raise ValueError("registered worktree path is not absolute")
3271
+ resolved = path.resolve()
3272
+ if resolved in records:
3273
+ raise ValueError("registered worktree metadata is ambiguous")
3274
+ records[resolved] = branches[0] if branches else None
3275
+ if not records:
3276
+ raise ValueError("registered worktrees cannot be resolved")
3277
+ return records
3278
+
3279
+
3280
+ def git_target_contexts(git_options, working_directories):
3281
+ contexts = []
3282
+ for directory, effective_directory in zip(
3283
+ working_directories or [os.getcwd()],
3284
+ git_base_directories(git_options, working_directories),
3285
+ ):
3286
+ root = Path(
3287
+ _git_output(git_options, directory, "rev-parse", "--show-toplevel").strip()
3288
+ ).resolve()
3289
+ listing = ("worktree", "list", "--porcelain")
3290
+ try:
3291
+ output, separator = _git_output(git_options, directory, *listing, "-z"), "\0"
3292
+ except ValueError:
3293
+ # Git before 2.36 has no -z here; pipeline_git.worktree_list_porcelain
3294
+ # retries the same way. A line then ends each field.
3295
+ output, separator = _git_output(git_options, directory, *listing), "\n"
3296
+ records = registered_worktree_records(output, separator)
3297
+ if root not in records:
3298
+ raise ValueError("git target repository worktree cannot be resolved")
3299
+ common = Path(
3300
+ _git_output(git_options, directory, "rev-parse", "--git-common-dir").strip()
3301
+ )
3302
+ if not common.is_absolute():
3303
+ common = effective_directory / common
3304
+ contexts.append(
3305
+ {
3306
+ "root": root,
3307
+ "directory": directory,
3308
+ "effective_directory": effective_directory,
3309
+ "common": common.resolve(),
3310
+ "git_options": list(git_options),
3311
+ "worktrees": records,
3312
+ }
3313
+ )
3314
+ return contexts
3315
+
3316
+
3317
+ def path_owned_records(roots):
3318
+ branches, worktrees = set(), set()
3319
+ for root in roots:
3320
+ project = root / ".project"
3321
+ if not os.path.lexists(project):
3322
+ continue
3323
+ if is_link_like(project) or not project.is_dir():
3324
+ raise ValueError(".project ownership metadata cannot be resolved")
3325
+ state = ownership_frontmatter(project / "STATE.md", {"branch"}, missing_ok=True)
3326
+ if state.get("branch"):
3327
+ branches.add(state["branch"])
3328
+ task_files = []
3329
+
3330
+ def raise_walk_error(error):
3331
+ raise error
3332
+
3333
+ try:
3334
+ for directory, child_directories, filenames in os.walk(
3335
+ project, onerror=raise_walk_error, followlinks=False
3336
+ ):
3337
+ if any(is_link_like(Path(directory) / name) for name in child_directories):
3338
+ raise ValueError("task ownership metadata contains a linked directory")
3339
+ if Path(directory).name == "tasks":
3340
+ task_files.extend(
3341
+ Path(directory) / name
3342
+ for name in filenames
3343
+ if name.endswith(".md")
3344
+ )
3345
+ except OSError as error:
3346
+ raise ValueError(f"task ownership metadata cannot be listed: {error}") from None
3347
+ task_files.sort()
3348
+ for task in task_files:
3349
+ fields = ownership_frontmatter(task, {"task_branch", "worktree"})
3350
+ if fields.get("task_branch"):
3351
+ branches.add(fields["task_branch"])
3352
+ recorded = fields.get("worktree")
3353
+ if recorded:
3354
+ path = Path(recorded).expanduser()
3355
+ if not path.is_absolute():
3356
+ path = root / path
3357
+ try:
3358
+ worktrees.add(path.resolve())
3359
+ except (OSError, RuntimeError):
3360
+ raise ValueError("task worktree ownership path cannot be resolved") from None
3361
+ return branches, worktrees
3362
+
3363
+
3364
+ def ref_is_path_owned(name, owned_branches):
3365
+ lowered = name.casefold()
3366
+ # git stash drop and clear are denied too; refs/stash holds each stash.
3367
+ if lowered.startswith("refs/gsd-path/") or lowered == "refs/stash":
3368
+ return True
3369
+ if lowered.startswith("refs/remotes/"):
3370
+ # The remote-tracking ref of a Path branch can be the last ref to its
3371
+ # commits. A remote name can contain a slash, so try each split.
3372
+ parts = name.split("/")[2:]
3373
+ return any(
3374
+ ref_is_path_owned("refs/heads/" + "/".join(parts[index:]), owned_branches)
3375
+ for index in range(1, len(parts))
3376
+ )
3377
+ short = name
3378
+ if short.startswith("refs/heads/"):
3379
+ short = short[len("refs/heads/"):]
3380
+ elif short.startswith("heads/"):
3381
+ short = short[len("heads/"):]
3382
+ if short.casefold().startswith(PATH_OWNED_REF_PREFIXES):
3383
+ return True
3384
+
3385
+ def canonical(value):
3386
+ if value.startswith("refs/heads/"):
3387
+ return value[len("refs/heads/"):]
3388
+ if value.startswith("heads/"):
3389
+ return value[len("heads/"):]
3390
+ return value
3391
+
3392
+ # Loose refs that differ only in case are one file on macOS and Windows.
3393
+ return any(
3394
+ canonical(name).casefold() == canonical(branch).casefold()
3395
+ for branch in owned_branches
3396
+ )
3397
+
3398
+
3399
+ def _literal_target(value):
3400
+ return bool(value) and not any(character in value for character in "*?[]{}~")
3401
+
3402
+
3403
+ def _positional_targets(arguments):
3404
+ targets = []
3405
+ after_separator = False
3406
+ for argument in arguments:
3407
+ if after_separator:
3408
+ targets.append(argument)
3409
+ elif argument == "--":
3410
+ after_separator = True
3411
+ elif not argument.startswith("-"):
3412
+ targets.append(argument)
3413
+ return targets
3414
+
3415
+
3416
+ PLAIN_REDIRECTION_OPERATORS = frozenset({">", ">>", "<", ">&", "&>", "&>>"})
3417
+
3418
+
3419
+ def _opens_process_substitution(token):
3420
+ return token in ("<(", ">(") and getattr(token, "literal", None) != "quoted"
3421
+
3422
+
3423
+ def _fold_process_substitutions(tokens):
3424
+ """The tokens with each process substitution body moved onto its operator."""
3425
+ folded = []
3426
+ body = None
3427
+ depth = 0
3428
+ for token in tokens:
3429
+ opens = _opens_process_substitution(token) and (
3430
+ getattr(token, "body", None) is None
3431
+ )
3432
+ if depth == 0:
3433
+ if opens:
3434
+ depth, body = 1, []
3435
+ operator = ShellToken(token)
3436
+ operator.literal = getattr(token, "literal", None)
3437
+ operator.body = body
3438
+ token = operator
3439
+ folded.append(token)
3440
+ continue
3441
+ if opens:
3442
+ depth += 1
3443
+ elif token and set(token) <= set("|;&()\n\r") and (
3444
+ getattr(token, "literal", None) != "quoted"
3445
+ ):
3446
+ for index, character in enumerate(token):
3447
+ depth += (character == "(") - (character == ")")
3448
+ if depth == 0:
3449
+ if token[index + 1:]:
3450
+ folded.append(ShellToken(token[index + 1:]))
3451
+ if token[:index]:
3452
+ body.append(token[:index])
3453
+ break
3454
+ if depth == 0:
3455
+ continue
3456
+ body.append(token)
3457
+ return folded
3458
+
3459
+
3460
+ def _runs_git_or_wrapper(segment, assignments):
3461
+ words, _exact = _without_redirections(segment)
3462
+ return (
3463
+ wrapped_command_tokens(words if len(words) > 1 else segment) is not None
3464
+ or git_command(words, assignments) is not None
3465
+ )
3466
+
3467
+
3468
+ def _holds_git_command(tokens, assignments):
3469
+ """Whether the tokens run Git or a wrapper, at each process substitution depth."""
3470
+ for segment in command_segments(tokens):
3471
+ words = []
3472
+ for token in [*segment, None]:
3473
+ if token is not None and not _opens_process_substitution(token):
3474
+ if not getattr(token, "descriptor", False):
3475
+ words.append(token)
3476
+ continue
3477
+ if words and _runs_git_or_wrapper(words, assignments):
3478
+ return True
3479
+ words = []
3480
+ if getattr(token, "body", None) and _holds_git_command(
3481
+ token.body, assignments
3482
+ ):
3483
+ return True
3484
+ return False
3485
+
3486
+
3487
+ def _without_descriptor(token):
3488
+ word = ShellToken(token)
3489
+ word.redirection = getattr(token, "redirection", False)
3490
+ word.literal = getattr(token, "literal", None)
3491
+ return word
3492
+
3493
+
3494
+ def _without_redirections(words):
3495
+ """The words of a simple command without the redirections the shell parsed.
3496
+
3497
+ Each redirection operator goes with the word after it and one file
3498
+ descriptor digit written against it. The second result is false when one
3499
+ of them is not plain: an operator outside PLAIN_REDIRECTION_OPERATORS or
3500
+ a target that is not one literal word. A process substitution operator is
3501
+ removed too and is never plain.
3502
+ """
3503
+ kept = []
3504
+ exact = True
3505
+ index = 0
3506
+ while index < len(words):
3507
+ word = words[index]
3508
+ if _opens_process_substitution(word):
3509
+ exact = False
3510
+ index += 1
3511
+ continue
3512
+ if not getattr(word, "redirection", False):
3513
+ kept.append(word)
3514
+ index += 1
3515
+ continue
3516
+ if word.isdigit():
3517
+ index += 1
3518
+ word = words[index] if index < len(words) else ""
3519
+ target = words[index + 1] if index + 1 < len(words) else ""
3520
+ exact = exact and not (
3521
+ word not in PLAIN_REDIRECTION_OPERATORS
3522
+ or getattr(target, "redirection", False)
3523
+ or getattr(target, "literal", None) not in ("bare", "quoted")
3524
+ or not _literal_target(target)
3525
+ or any(character in target for character in "$`<>()&|#")
3526
+ )
3527
+ index += 2
3528
+ return kept, exact
3529
+
3530
+
3531
+ def _frontmatter_roots(contexts):
3532
+ return {
3533
+ path
3534
+ for context in contexts
3535
+ for path in context["worktrees"]
3536
+ }
3537
+
3538
+
3539
+ def _git_refs(context):
3540
+ output = _git_output(
3541
+ context["git_options"],
3542
+ context["directory"],
3543
+ "for-each-ref",
3544
+ "--format=%(refname)",
3545
+ )
3546
+ return {line for line in output.splitlines() if line}
3547
+
3548
+
3549
+ def _symbolic_ref_target(context, target):
3550
+ try:
3551
+ result = subprocess.run(
3552
+ ["git", *context["git_options"], "symbolic-ref", "--quiet", target],
3553
+ cwd=context["directory"],
3554
+ capture_output=True,
3555
+ encoding="utf-8",
3556
+ errors="replace",
3557
+ check=False,
3558
+ )
3559
+ except OSError:
3560
+ raise ValueError("git ref target cannot be resolved") from None
3561
+ if result.returncode == 1:
3562
+ return None
3563
+ if result.returncode != 0:
3564
+ raise ValueError("git ref target cannot be resolved")
3565
+ return result.stdout.strip()
3566
+
3567
+
3568
+ def _update_ref_names(target, refs, context, no_deref):
3569
+ """The ref names a deletion of this operand touches, or None when unresolved.
3570
+
3571
+ Git reads an update-ref operand as a full name below the git directory,
3572
+ never as a branch short name. It follows each symbolic ref to the ref it
3573
+ deletes; with --no-deref it deletes the named symbolic ref itself. A name
3574
+ outside refs/ (HEAD, a symbolic ref at the top of the git directory) is
3575
+ unresolved: an ordinary command such as git switch changes its target.
3576
+ """
3577
+ if not target.startswith("refs/"):
3578
+ return None
3579
+ names = [target]
3580
+ while True:
3581
+ symbolic = _symbolic_ref_target(context, names[-1])
3582
+ if symbolic is None:
3583
+ return names if names[-1] in refs else None
3584
+ if no_deref:
3585
+ return names
3586
+ if symbolic in names:
3587
+ return None
3588
+ names.append(symbolic)
3589
+
3590
+
3591
+ def _workspace_roots(contexts):
3592
+ roots = [(Path.home() / ".gsd-path").resolve()]
3593
+ configured = os.environ.get("GSD_PATH_WORKTREE_ROOT")
3594
+ if configured:
3595
+ configured_root = Path(configured).expanduser()
3596
+ if not configured_root.is_absolute():
3597
+ raise ValueError("GSD_PATH_WORKTREE_ROOT must be absolute")
3598
+ roots.append(configured_root.resolve())
3599
+ for common in {context["common"] for context in contexts}:
3600
+ receipts = common / "gsd-path" / "workspaces"
3601
+ if not os.path.lexists(receipts):
3602
+ continue
3603
+ if is_link_like(receipts) or not receipts.is_dir():
3604
+ raise ValueError("pinned workspace receipts cannot be resolved")
3605
+ try:
3606
+ with os.scandir(receipts) as entries:
3607
+ receipt_files = sorted(
3608
+ (Path(entry.path) for entry in entries if entry.name.endswith(".json")),
3609
+ key=lambda path: path.name,
3610
+ )
3611
+ except OSError as error:
3612
+ raise ValueError(f"pinned workspace receipts cannot be listed: {error}") from None
3613
+ for receipt in receipt_files:
3614
+ try:
3615
+ metadata = receipt.lstat()
3616
+ if not stat.S_ISREG(metadata.st_mode):
3617
+ raise ValueError("receipt is not a regular file")
3618
+ data = json.loads(receipt.read_text(encoding="utf-8"))
3619
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
3620
+ raise ValueError(
3621
+ f"pinned workspace receipt cannot be read: {receipt.name}"
3622
+ ) from None
3623
+ if (
3624
+ not isinstance(data, dict)
3625
+ or not isinstance(data.get("primary"), str)
3626
+ or not isinstance(data.get("root"), str)
3627
+ or not Path(data["primary"]).is_absolute()
3628
+ or not Path(data["root"]).is_absolute()
3629
+ ):
3630
+ raise ValueError(
3631
+ f"pinned workspace receipt is malformed: {receipt.name}"
3632
+ )
3633
+ roots.append(Path(data["root"]).resolve())
3634
+ return roots
3635
+
3636
+
3637
+ def owned_target_reason(command, arguments, git_options, working_directories):
3638
+ action = {
3639
+ "branch": "git branch -D",
3640
+ "update-ref": "git update-ref deletion",
3641
+ "worktree": "git worktree remove --force",
3642
+ }[command]
3643
+ unresolved = GIT_REASONS["unresolved-target"].format(action=action)
3644
+ # The shell expands a leading ~ and a glob in an option value such as the
3645
+ # -C directory; the guard has only the literal text.
3646
+ if any(
3647
+ value[:1] == "~" or any(character in value for character in "*?[]{}")
3648
+ for value in git_options[1::2]
3649
+ ):
3650
+ return unresolved
3651
+ contexts = git_target_contexts(git_options, working_directories)
3652
+ branches, task_worktrees = path_owned_records(_frontmatter_roots(contexts))
3653
+
3654
+ if command == "branch":
3655
+ # Each word that is not an option is read as a branch name. Git does
3656
+ # the same after --color; after an option with a separate value
3657
+ # (--sort key) this only adds a name to check.
3658
+ targets = _positional_targets(arguments)
3659
+ if not targets or not all(_literal_target(target) for target in targets):
3660
+ return unresolved
3661
+ remote = has_short_option(arguments, "r") or any(
3662
+ git_option_in(argument, {"--remotes"}) for argument in arguments
3663
+ )
3664
+ for target in targets:
3665
+ # Git reads the name in exactly one namespace, with no other form.
3666
+ ref = f"refs/{'remotes' if remote else 'heads'}/{target}"
3667
+ if ref_is_path_owned(ref, branches) or ref_is_path_owned(
3668
+ f"refs/heads/{target}", branches
3669
+ ):
3670
+ return GIT_REASONS["branch"]
3671
+ if any(ref not in _git_refs(context) for context in contexts):
3672
+ return unresolved
3673
+ return None
3674
+
3675
+ if command == "update-ref":
3676
+ operands, options = [], set()
3677
+ index = 0
3678
+ while index < len(arguments):
3679
+ argument = arguments[index]
3680
+ if argument == "-m": # its value is not an option, also when it is -d
3681
+ index += 2
3682
+ continue
3683
+ if argument.startswith("-"):
3684
+ options.add(argument)
3685
+ index += 1
3686
+ continue
3687
+ operands.append(argument)
3688
+ index += 1
3689
+ deletion_flag = "-d" in options
3690
+ expected_counts = {1, 2} if deletion_flag else {2, 3}
3691
+ if len(operands) not in expected_counts or not _literal_target(operands[0]):
3692
+ return unresolved
3693
+ if not deletion_flag and not (operands[1] == "" or not operands[1].strip("0")):
3694
+ return unresolved
3695
+ if deletion_flag and len(operands) == 2 and not _literal_target(operands[1]):
3696
+ return unresolved
3697
+ if not deletion_flag and len(operands) == 3 and not _literal_target(operands[2]):
3698
+ return unresolved
3699
+ # Dereferencing a symbolic ref must not erase ownership of its name.
3700
+ if ref_is_path_owned(operands[0], branches):
3701
+ return GIT_REASONS["update-ref"]
3702
+ for context in contexts:
3703
+ names = _update_ref_names(
3704
+ operands[0], _git_refs(context), context, "--no-deref" in options
3705
+ )
3706
+ if names is None:
3707
+ return unresolved
3708
+ if any(ref_is_path_owned(name, branches) for name in names):
3709
+ return GIT_REASONS["update-ref"]
3710
+ return None
3711
+
3712
+ targets = _positional_targets(arguments[1:])
3713
+ if len(targets) != 1 or not _literal_target(targets[0]):
3714
+ return unresolved
3715
+ recorded_paths = set(task_worktrees)
3716
+ managed_roots = _workspace_roots(contexts)
3717
+ for context in contexts:
3718
+ target = Path(targets[0])
3719
+ if not target.is_absolute():
3720
+ target = context["effective_directory"] / target
3721
+ resolved_target = None
3722
+ try:
3723
+ resolved_target = target.resolve(strict=True)
3724
+ except (OSError, RuntimeError):
3725
+ pass
3726
+ registered = context["worktrees"]
3727
+ # Git tries a unique path suffix first, then the path, and compares
3728
+ # without case on macOS and Windows. Both readings must agree.
3729
+ matches = {resolved_target} & set(registered)
3730
+ suffix = tuple(
3731
+ part.casefold() for part in Path(targets[0]).parts if part not in {"", "."}
3732
+ )
3733
+ if suffix and not Path(targets[0]).is_absolute() and ".." not in suffix:
3734
+ by_suffix = [
3735
+ path for path in registered
3736
+ if tuple(part.casefold() for part in path.parts[-len(suffix):]) == suffix
3737
+ ]
3738
+ if len(by_suffix) == 1:
3739
+ matches.add(by_suffix[0])
3740
+ if len(matches) != 1:
3741
+ return unresolved
3742
+ resolved_target = matches.pop()
3743
+ if not resolved_target.is_dir():
3744
+ return unresolved
3745
+ branch = registered[resolved_target]
3746
+ primary = next(iter(registered))
3747
+ if (
3748
+ resolved_target in recorded_paths
3749
+ or (branch is not None and ref_is_path_owned(branch, branches))
3750
+ or any(
3751
+ resolved_target == root or resolved_target.is_relative_to(root)
3752
+ for root in (*managed_roots, primary.parent / f"{primary.name}.gsd-path")
3753
+ )
3754
+ or (
3755
+ resolved_target.parent == primary.parent
3756
+ and resolved_target.name.startswith(f".{primary.name}-gsd-path-integrate-")
3757
+ )
3758
+ ):
3759
+ return GIT_REASONS["worktree"]
3760
+ return None
3761
+
3762
+
3763
+ def _path_has_symlink_component(value):
3764
+ path = Path(value)
3765
+ if not path.is_absolute():
3766
+ path = Path.cwd() / path
3767
+ current = Path(path.anchor)
3768
+ for component in path.parts[1:]:
3769
+ if component in {"", "."}:
3770
+ continue
3771
+ if component == "..":
3772
+ current = current.parent
3773
+ continue
3774
+ current = current / component
3775
+ try:
3776
+ if os.path.lexists(current) and is_link_like(current):
3777
+ return True
3778
+ except OSError:
3779
+ return True
3780
+ return False
3781
+
3782
+
3783
+ def env_wrapper_changes_git_context(segment):
3784
+ """Whether an env wrapper makes ownership or alias lookup context uncertain."""
3785
+ index = 0
3786
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
3787
+ index += 1
3788
+ if index >= len(segment):
3789
+ return False
3790
+ executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
3791
+ if executable != "env":
3792
+ return False
3793
+
3794
+ def affects_git_context(name):
3795
+ folded = name.upper()
3796
+ return folded.startswith("GIT_") or folded in {"HOME", "XDG_CONFIG_HOME"}
3797
+
3798
+ index += 1
3799
+ while index < len(segment):
3800
+ token = segment[index]
3801
+ if token == "--":
3802
+ return False
3803
+ if token in {"-i", "--ignore-environment"}:
3804
+ return True
3805
+ if token.startswith("--unset="):
3806
+ if affects_git_context(token.split("=", 1)[1]):
3807
+ return True
3808
+ index += 1
3809
+ continue
3810
+ if token in {"-u", "--unset"}:
3811
+ if index + 1 >= len(segment):
3812
+ return False
3813
+ if affects_git_context(segment[index + 1]):
3814
+ return True
3815
+ index += 2
3816
+ continue
3817
+ if SHELL_ASSIGNMENT_PATTERN.match(token):
3818
+ if affects_git_context(token.partition("=")[0]):
3819
+ return True
3820
+ index += 1
3821
+ continue
3822
+ option = token.split("=", 1)[0]
3823
+ if option in ENV_OPTIONS_WITH_VALUES:
3824
+ index += 1 if "=" in token else 2
3825
+ continue
3826
+ if option in ENV_OPTIONS_WITHOUT_VALUES:
3827
+ index += 1
3828
+ continue
3829
+ return False
3830
+ return False
3831
+
3832
+
3833
+ def ownership_cwd_is_uncertain(tokens, working_directories=None):
3834
+ """Whether a scoped delete follows a cd whose shell control flow is unclear."""
3835
+ cwd_wrappers = COMMAND_WRAPPERS | {
3836
+ "eval", "source", ".",
3837
+ # These change what a later cd does, or run a cd later.
3838
+ "alias", "trap", "enable", "disable",
3839
+ }
3840
+ segments = []
3841
+ current = []
3842
+ separator = None
3843
+ depth = 0
3844
+ comment = False
3845
+ for token in tokens:
3846
+ # Most shells run nothing from a # word to the end of the line, but
3847
+ # the guard reads that text as commands. A cd there can run or not.
3848
+ if comment and "\n" in token:
3849
+ if not set(token) <= set("|;&()\n\r"):
3850
+ return True
3851
+ comment = False
3852
+ elif comment and token.casefold() in DIRECTORY_CHANGE_COMMANDS:
3853
+ return True
3854
+ if token and set(token) <= set("|;&()\n\r"):
3855
+ if "()" in token:
3856
+ return True # a function definition: its body runs only on a call
3857
+ if current:
3858
+ segments.append((current, separator, depth))
3859
+ current = []
3860
+ if "(" in token:
3861
+ depth += token.count("(")
3862
+ if ")" in token:
3863
+ depth = max(0, depth - token.count(")"))
3864
+ separator = token.strip()
3865
+ else:
3866
+ comment = comment or (
3867
+ token.startswith("#") and getattr(token, "literal", None) != "quoted"
3868
+ )
3869
+ current.append(token)
3870
+ if current:
3871
+ segments.append((current, separator, depth))
3872
+
3873
+ uncertain = False
3874
+ conditional = False
3875
+ current_directories = list(working_directories or [os.getcwd()])
3876
+ initial_symlink = any(
3877
+ _path_has_symlink_component(directory) for directory in current_directories
3878
+ )
3879
+ for index, (segment, previous_separator, group_depth) in enumerate(segments):
3880
+ plain, _exact = _without_redirections(segment)
3881
+ redirected = len(plain) != len(segment)
3882
+ segment = plain
3883
+ if not segment:
3884
+ continue
3885
+ if env_wrapper_changes_git_context(segment):
3886
+ return True
3887
+ if segment[0] == "function":
3888
+ return True
3889
+ if any(token.casefold() in SHELL_CONTROL_WORDS for token in segment):
3890
+ conditional = True
3891
+ while segment and segment[0].casefold() in SHELL_CONTROL_WORDS:
3892
+ segment = segment[1:]
3893
+ invocation = command_invocation(segment)
3894
+ if invocation is None:
3895
+ continue
3896
+ command, arguments = invocation
3897
+ next_separator = (
3898
+ segments[index + 1][1] if index + 1 < len(segments) else None
3899
+ )
3900
+ if command in DIRECTORY_CHANGE_COMMANDS:
3901
+ target = directory_change_target(arguments)
3902
+ absolute = is_absolute_path(target)
3903
+ dotted = target.replace("\\", "/")
3904
+ next_directories = (
3905
+ [target]
3906
+ if absolute
3907
+ else [f"{directory}/{target}" for directory in current_directories]
3908
+ )
3909
+ if (
3910
+ # Only a plain `cd <directory>` has the same effect in each shell.
3911
+ segment[0] != "cd"
3912
+ or redirected
3913
+ or arguments not in ([target], ["--", target])
3914
+ # A shell with CDPATH looks for a plain relative name in other
3915
+ # directories first; ./name and ../name are not looked up there.
3916
+ or not (
3917
+ absolute
3918
+ or dotted in {".", ".."}
3919
+ or dotted.startswith(("./", "../"))
3920
+ )
3921
+ or any(character in target for character in "*?[]{}$")
3922
+ or group_depth > 0
3923
+ or previous_separator in {"&&", "||", "&"}
3924
+ or next_separator in {"||", "&"}
3925
+ or (previous_separator is not None and "|" in previous_separator)
3926
+ or (next_separator is not None and "|" in next_separator)
3927
+ or conditional
3928
+ or initial_symlink
3929
+ or any(_path_has_symlink_component(path) for path in next_directories)
3930
+ ):
3931
+ uncertain = True
3932
+ current_directories = next_directories
3933
+ initial_symlink = False
3934
+ if command in cwd_wrappers and index + 1 < len(segments):
3935
+ uncertain = True
3936
+ if uncertain and index + 1 < len(segments):
3937
+ return True
3938
+ if any(token.casefold() in {"fi", "done", "esac"} for token in segment):
3939
+ conditional = False
3940
+ return False
3941
+
3942
+
3943
+ def assignments_change_git_context(assignments):
3944
+ return any(
3945
+ name.upper().startswith("GIT_")
3946
+ or name.upper() in {"HOME", "XDG_CONFIG_HOME"}
3947
+ for name in assignments
3948
+ )
3949
+
3950
+ GIT_CONTEXT_NAME = re.compile(r"GIT_\w+|HOME|XDG_CONFIG_HOME")
3951
+ # export, declare, typeset, local, readonly and unset of these names are
3952
+ # denied where the guard reads assignments.
3953
+ VARIABLE_SETTING_COMMANDS = frozenset(
3954
+ {"read", "getopts", "mapfile", "readarray", "integer", "float", "vared"}
3955
+ )
3956
+ LOOP_VARIABLE_WORDS = frozenset({"for", "select"})
3957
+
3958
+
3959
+ def command_sets_git_context_name(tokens):
3960
+ """Whether a command that the guard does not track sets a Git context variable."""
3961
+ words = []
3962
+ for token in [*tokens, ";"]:
3963
+ if not (token and set(token) <= set("|;&()\n\r")):
3964
+ words.append(token)
3965
+ continue
3966
+ words, _exact = _without_redirections(words)
3967
+ while words and (
3968
+ words[0].casefold() in SHELL_CONTROL_WORDS - LOOP_VARIABLE_WORDS
3969
+ or words[0] in COMMAND_WRAPPERS
3970
+ or SHELL_ASSIGNMENT_PATTERN.match(words[0])
3971
+ ):
3972
+ del words[0]
3973
+ command, operands, words = "".join(words[:1]), words[1:], []
3974
+ if command in LOOP_VARIABLE_WORDS:
3975
+ operands = operands[:1]
3976
+ elif command in {"printf", "print"}:
3977
+ operands = [
3978
+ name for option, name in zip(operands, operands[1:]) if option == "-v"
3979
+ ] + [option[2:] for option in operands if option.startswith("-v")]
3980
+ elif command not in VARIABLE_SETTING_COMMANDS:
3981
+ continue
3982
+ if any(GIT_CONTEXT_NAME.fullmatch(operand) for operand in operands):
3983
+ return True
3984
+ return False
3985
+
3986
+
2976
3987
  def destructive_git_reason(
2977
- tokens, resolved_aliases=frozenset(), initial_assignments=None
3988
+ tokens,
3989
+ resolved_aliases=frozenset(),
3990
+ initial_assignments=None,
3991
+ working_directories=None,
3992
+ cwd_reliable=True,
3993
+ git_context_reliable=True,
3994
+ earlier_git=False,
3995
+ fold=True,
2978
3996
  ):
2979
- segments = list(command_segments(tokens))
2980
- contexts, _ = shell_assignment_contexts(tokens, initial_assignments)
2981
- for segment, assignments in zip(segments, contexts):
2982
- wrapped = wrapped_command_tokens(segment)
3997
+ # A process substitution has two readings: the tokens as they stand, and
3998
+ # the tokens with each substitution body folded onto its operator.
3999
+ folded = _fold_process_substitutions(tokens) if fold else tokens
4000
+ if len(folded) != len(tokens):
4001
+ reason = destructive_git_reason(
4002
+ tokens,
4003
+ resolved_aliases,
4004
+ initial_assignments=initial_assignments,
4005
+ working_directories=working_directories,
4006
+ cwd_reliable=cwd_reliable,
4007
+ git_context_reliable=git_context_reliable,
4008
+ earlier_git=earlier_git,
4009
+ fold=False,
4010
+ )
4011
+ if reason is not None:
4012
+ return reason
4013
+ tokens = folded
4014
+ cwd_reliable = cwd_reliable and not ownership_cwd_is_uncertain(
4015
+ tokens, working_directories
4016
+ )
4017
+ git_context_reliable = (
4018
+ git_context_reliable and not command_sets_git_context_name(tokens)
4019
+ )
4020
+ this_git = False
4021
+ for segment, directories, assignments, _prefix in shell_segment_contexts(
4022
+ tokens, working_directories or [os.getcwd()], initial_assignments
4023
+ ):
4024
+ # Whether a Git command or a wrapped command ran before this segment.
4025
+ earlier_git = earlier_git or this_git
4026
+ # A dollar expansion can introduce literal percent/bang text; the second
4027
+ # expansion pass must not silently reinterpret it as a different path.
4028
+ cwd_reliable = cwd_reliable and not any(
4029
+ CMD_PARAMETER_SYNTAX.search(NAMED_SHELL_PARAMETER_SYNTAX.sub(
4030
+ lambda match: environment_parameter_value(match, assignments), token
4031
+ ))
4032
+ for token in segment
4033
+ )
4034
+ # A descriptor word is a file descriptor in bash and an argument in
4035
+ # zsh; a process substitution runs a second command. Each reading is
4036
+ # checked, and a scoped deletion in one of them is denied.
4037
+ descriptor = any(getattr(token, "descriptor", False) for token in segment)
4038
+ if descriptor:
4039
+ readings = [
4040
+ [token for token in segment if not getattr(token, "descriptor", False)],
4041
+ [
4042
+ _without_descriptor(token)
4043
+ if getattr(token, "descriptor", False) else token
4044
+ for token in segment
4045
+ ],
4046
+ ]
4047
+ else:
4048
+ readings = [token.body for token in segment if getattr(token, "body", None)]
4049
+ for reading in readings:
4050
+ reason = destructive_git_reason(
4051
+ reading,
4052
+ resolved_aliases,
4053
+ initial_assignments=assignments,
4054
+ working_directories=directories,
4055
+ cwd_reliable=False,
4056
+ git_context_reliable=git_context_reliable,
4057
+ earlier_git=earlier_git,
4058
+ )
4059
+ if reason is not None:
4060
+ return reason
4061
+ nested_git = (
4062
+ descriptor or any(_opens_process_substitution(token) for token in segment)
4063
+ ) and _holds_git_command(segment, assignments)
4064
+ if descriptor:
4065
+ this_git = nested_git
4066
+ continue
4067
+ # A redirection can stand at each position of a simple command.
4068
+ words, exact = _without_redirections(segment)
4069
+ segment_git_context_reliable = (
4070
+ git_context_reliable
4071
+ and not env_wrapper_changes_git_context(words)
4072
+ and not assignments_change_git_context(assignments)
4073
+ )
4074
+ wrapped = wrapped_command_tokens(words if len(words) > 1 else segment)
2983
4075
  if wrapped is not None:
2984
- reason = destructive_git_reason(wrapped, resolved_aliases, assignments)
4076
+ reason = destructive_git_reason(
4077
+ wrapped,
4078
+ resolved_aliases,
4079
+ initial_assignments=assignments,
4080
+ working_directories=directories,
4081
+ cwd_reliable=cwd_reliable and exact,
4082
+ git_context_reliable=segment_git_context_reliable,
4083
+ earlier_git=earlier_git,
4084
+ )
2985
4085
  if reason is not None:
2986
4086
  return reason
2987
- invocation = git_command(segment, assignments)
4087
+ invocation = git_command(words, assignments)
4088
+ this_git = nested_git or wrapped is not None or invocation is not None
2988
4089
  if invocation is None:
2989
4090
  continue
2990
4091
  command, arguments, git_options = invocation
4092
+ # Git matches its own commands by exact case, but alias names in any case.
4093
+ built_in = (
4094
+ command_invocation(words)[1][-len(arguments) - 1] in GIT_BUILT_IN_COMMANDS
4095
+ )
4096
+ if not segment_git_context_reliable and not built_in:
4097
+ return GIT_REASONS["git-environment"]
4098
+ # A scoped deletion needs literal words: the shell can change a variable
4099
+ # in ways the guard does not track (read, +=, for, printf -v).
4100
+ resolvable = cwd_reliable and exact and not any(
4101
+ SHELL_PARAMETER_SYNTAX.search(token) for token in segment
4102
+ )
4103
+ directories = [native_path_text(directory) for directory in directories]
2991
4104
  if command == "config":
2992
4105
  alias_indexes = [
2993
4106
  index
@@ -2999,17 +4112,32 @@ def destructive_git_reason(
2999
4112
  "git config alias.* defines an alias the guard cannot inspect; "
3000
4113
  "run the underlying git command directly"
3001
4114
  )
3002
- alias = git_alias(command, git_options)
4115
+ # A directory that an earlier segment creates does not exist yet.
4116
+ alias = None
4117
+ if not built_in or all(os.path.isdir(directory) for directory in directories):
4118
+ alias = git_alias(command, git_options, directories)
3003
4119
  if alias is not None:
3004
4120
  if command in resolved_aliases:
3005
4121
  raise ValueError(
3006
4122
  f"git alias {command} refers to itself; run the underlying "
3007
4123
  "git command directly"
3008
4124
  )
4125
+ # Git passes each word of an alias on as an argument; it runs no shell.
4126
+ alias_words = shell_tokens(alias, direct=False)
4127
+ for word in alias_words:
4128
+ word.redirection = False
3009
4129
  reason = destructive_git_reason(
3010
- ["git", *git_options, *shell_tokens(alias, direct=False), *arguments],
4130
+ ["git", *git_options, *alias_words, *arguments],
3011
4131
  resolved_aliases | {command},
3012
- assignments,
4132
+ initial_assignments=assignments,
4133
+ working_directories=directories,
4134
+ # Non-shell Git aliases do not expand environment parameters.
4135
+ cwd_reliable=resolvable and not (
4136
+ NAMED_SHELL_PARAMETER_SYNTAX.search(alias)
4137
+ or CMD_PARAMETER_SYNTAX.search(alias)
4138
+ ),
4139
+ git_context_reliable=segment_git_context_reliable,
4140
+ earlier_git=earlier_git,
3013
4141
  )
3014
4142
  if reason is not None:
3015
4143
  return reason
@@ -3037,18 +4165,29 @@ def destructive_git_reason(
3037
4165
  if force_option or force_refspec:
3038
4166
  return GIT_REASONS[command]
3039
4167
  if command == "branch":
4168
+ # Git accepts each unique prefix of a long option (--del, --forc).
3040
4169
  deletes = (
3041
- "--delete" in arguments
4170
+ any(git_option_in(argument, {"--delete"}) for argument in arguments)
3042
4171
  or has_short_option(arguments, "d")
3043
4172
  or has_short_option(arguments, "D")
3044
4173
  )
3045
4174
  forces = (
3046
- "--force" in arguments
4175
+ any(git_option_in(argument, {"--force"}) for argument in arguments)
3047
4176
  or has_short_option(arguments, "f")
3048
4177
  or has_short_option(arguments, "D")
3049
4178
  )
3050
4179
  if deletes and forces:
3051
- return GIT_REASONS[command]
4180
+ if not segment_git_context_reliable:
4181
+ return GIT_REASONS["git-environment"]
4182
+ if not resolvable:
4183
+ return GIT_REASONS["unresolved-target"].format(
4184
+ action="git branch -D"
4185
+ )
4186
+ reason = owned_target_reason(
4187
+ command, arguments, git_options, directories
4188
+ )
4189
+ if reason is not None:
4190
+ return reason
3052
4191
  if (
3053
4192
  "--move" in arguments
3054
4193
  or has_short_option(arguments, "m")
@@ -3057,10 +4196,39 @@ def destructive_git_reason(
3057
4196
  return GIT_REASONS["branch-move"]
3058
4197
  if command == "update-ref":
3059
4198
  if update_ref_deletes(arguments):
3060
- return GIT_REASONS[command]
4199
+ if not segment_git_context_reliable:
4200
+ return GIT_REASONS["git-environment"]
4201
+ if not resolvable:
4202
+ return GIT_REASONS["unresolved-target"].format(
4203
+ action="git update-ref deletion"
4204
+ )
4205
+ # The guard reads each symbolic ref now; an earlier Git command
4206
+ # of this command line can point it at a Path-owned ref later.
4207
+ # (-m can take the text --no-deref as its value.)
4208
+ if earlier_git and (
4209
+ "--no-deref" not in arguments or "-m" in arguments
4210
+ ):
4211
+ return GIT_REASONS["update-ref-order"]
4212
+ reason = owned_target_reason(
4213
+ command, arguments, git_options, directories
4214
+ )
4215
+ if reason is not None:
4216
+ return reason
3061
4217
  if command == "worktree" and arguments[:1] == ["remove"]:
3062
- if "--force" in arguments or has_short_option(arguments[1:], "f"):
3063
- return GIT_REASONS[command]
4218
+ if any(
4219
+ git_option_in(argument, {"--force"}) for argument in arguments
4220
+ ) or has_short_option(arguments[1:], "f"):
4221
+ if not segment_git_context_reliable:
4222
+ return GIT_REASONS["git-environment"]
4223
+ if not resolvable:
4224
+ return GIT_REASONS["unresolved-target"].format(
4225
+ action="git worktree remove --force"
4226
+ )
4227
+ reason = owned_target_reason(
4228
+ "worktree", arguments, git_options, directories
4229
+ )
4230
+ if reason is not None:
4231
+ return reason
3064
4232
  if command == "stash" and arguments[:1] in (["drop"], ["clear"]):
3065
4233
  return GIT_REASONS[command]
3066
4234
  if command in {"checkout", "restore"} and any(
@@ -3469,9 +4637,10 @@ def command_denial(command, working_directories, allow_destructive=True):
3469
4637
  if archive_write_attempt(resolved_segment, (segment, assignments)):
3470
4638
  return ARCHIVE_REASON
3471
4639
  return ARCHIVE_UNPROVEN_READ_REASON
3472
- reason = destructive_git_reason(tokens) or protected_shell_write_reason(
3473
- tokens, working_directories
3474
- )
4640
+ reason = destructive_git_reason(
4641
+ tokens, working_directories=working_directories,
4642
+ cwd_reliable=not shell_parameter_quoting_uncertain(outer),
4643
+ ) or protected_shell_write_reason(tokens, working_directories)
3475
4644
  except ValueError as error:
3476
4645
  raise ValueError(describe_substitutions(str(error), substitutions)) from None
3477
4646
  return reason and describe_substitutions(reason, substitutions)