@opengsd/gsd-path 1.6.2 → 1.7.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 (275) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/DOCS.md +27 -1
  3. package/FULL.md +2 -1
  4. package/HOOKS.md +38 -4
  5. package/QUICK.md +1 -1
  6. package/README.md +19 -11
  7. package/RUNTIME.md +5 -3
  8. package/WORKFLOW.md +9 -2
  9. package/package.json +1 -1
  10. package/scripts/_common.py +76 -2
  11. package/scripts/archive_milestone.py +129 -7
  12. package/scripts/build_recovery.py +25 -2
  13. package/scripts/build_state.py +333 -8
  14. package/scripts/check_handoffs.py +437 -30
  15. package/scripts/check_task_briefs.py +135 -11
  16. package/scripts/discussion_validate.py +46 -10
  17. package/scripts/dispatch_driver.py +28 -3
  18. package/scripts/git_guard.py +44 -4
  19. package/scripts/guard_hook.py +456 -9
  20. package/scripts/install.py +34 -4
  21. package/scripts/integration.py +196 -39
  22. package/scripts/isolation.py +996 -38
  23. package/scripts/lean_verification.py +28 -13
  24. package/scripts/loop_run.py +9 -4
  25. package/scripts/members.py +17 -3
  26. package/scripts/pipeline_diagnose.py +104 -1
  27. package/scripts/pipeline_state.py +10 -2
  28. package/scripts/runtime_store.py +88 -3
  29. package/scripts/state_checkpoint.py +15 -0
  30. package/scripts/status_runtime.py +73 -2
  31. package/scripts/workflow_run.py +10 -3
  32. package/scripts/worktree_paths.py +45 -0
  33. package/skills/gsd-path/BUILD.md +3 -1
  34. package/skills/gsd-path/DEFINE.md +2 -1
  35. package/skills/gsd-path/PLAN.md +10 -0
  36. package/skills/gsd-path/SHIP.md +41 -8
  37. package/skills/gsd-path/SKILL.md +16 -1
  38. package/skills/gsd-path/references/build-native.md +20 -3
  39. package/skills/gsd-path/references/coder.md +6 -3
  40. package/skills/gsd-path/references/reviewer-final.md +10 -0
  41. package/skills/gsd-path/references/reviewer-wave.md +61 -22
  42. package/skills/gsd-path/references/reviewer.md +8 -6
  43. package/skills/gsd-path/references/runtime-selection.md +36 -0
  44. package/skills/gsd-path/references/spec-probes.md +19 -1
  45. package/skills/gsd-path/scripts/_common.py +76 -2
  46. package/skills/gsd-path/scripts/archive_milestone.py +129 -7
  47. package/skills/gsd-path/scripts/build_recovery.py +25 -2
  48. package/skills/gsd-path/scripts/build_state.py +333 -8
  49. package/skills/gsd-path/scripts/check_handoffs.py +437 -30
  50. package/skills/gsd-path/scripts/check_task_briefs.py +135 -11
  51. package/skills/gsd-path/scripts/discussion_validate.py +46 -10
  52. package/skills/gsd-path/scripts/dispatch_driver.py +28 -3
  53. package/skills/gsd-path/scripts/git_guard.py +44 -4
  54. package/skills/gsd-path/scripts/integration.py +196 -39
  55. package/skills/gsd-path/scripts/isolation.py +996 -38
  56. package/skills/gsd-path/scripts/lean_verification.py +28 -13
  57. package/skills/gsd-path/scripts/loop_run.py +9 -4
  58. package/skills/gsd-path/scripts/members.py +17 -3
  59. package/skills/gsd-path/scripts/pipeline_state.py +10 -2
  60. package/skills/gsd-path/scripts/state_checkpoint.py +15 -0
  61. package/skills/gsd-path/scripts/status_runtime.py +73 -2
  62. package/skills/gsd-path/scripts/workflow_run.py +10 -3
  63. package/skills/gsd-path/scripts/worktree_paths.py +45 -0
  64. package/skills/gsd-path/templates/intent.md +5 -3
  65. package/skills/gsd-path/templates/task.md +8 -1
  66. package/skills/gsd-path/templates/wave-review.md +24 -8
  67. package/skills/gsd-path-build/SKILL.md +3 -1
  68. package/skills/gsd-path-build/references/build-native.md +20 -3
  69. package/skills/gsd-path-build/references/coder.md +6 -3
  70. package/skills/gsd-path-build/references/reviewer-final.md +10 -0
  71. package/skills/gsd-path-build/references/reviewer-wave.md +61 -22
  72. package/skills/gsd-path-build/references/reviewer.md +8 -6
  73. package/skills/gsd-path-build/references/runtime-selection.md +36 -0
  74. package/skills/gsd-path-build/references/spec-probes.md +19 -1
  75. package/skills/gsd-path-build/scripts/_common.py +76 -2
  76. package/skills/gsd-path-build/scripts/archive_milestone.py +129 -7
  77. package/skills/gsd-path-build/scripts/build_recovery.py +25 -2
  78. package/skills/gsd-path-build/scripts/build_state.py +333 -8
  79. package/skills/gsd-path-build/scripts/check_handoffs.py +437 -30
  80. package/skills/gsd-path-build/scripts/check_task_briefs.py +135 -11
  81. package/skills/gsd-path-build/scripts/discussion_validate.py +46 -10
  82. package/skills/gsd-path-build/scripts/dispatch_driver.py +28 -3
  83. package/skills/gsd-path-build/scripts/integration.py +196 -39
  84. package/skills/gsd-path-build/scripts/isolation.py +996 -38
  85. package/skills/gsd-path-build/scripts/loop_run.py +9 -4
  86. package/skills/gsd-path-build/scripts/members.py +17 -3
  87. package/skills/gsd-path-build/scripts/pipeline_state.py +10 -2
  88. package/skills/gsd-path-build/scripts/state_checkpoint.py +15 -0
  89. package/skills/gsd-path-build/scripts/workflow_run.py +10 -3
  90. package/skills/gsd-path-build/scripts/worktree_paths.py +45 -0
  91. package/skills/gsd-path-build/templates/task.md +8 -1
  92. package/skills/gsd-path-build/templates/wave-review.md +24 -8
  93. package/skills/gsd-path-decide/references/runtime-selection.md +36 -0
  94. package/skills/gsd-path-decide/scripts/_common.py +76 -2
  95. package/skills/gsd-path-decide/scripts/archive_milestone.py +129 -7
  96. package/skills/gsd-path-decide/scripts/build_recovery.py +25 -2
  97. package/skills/gsd-path-decide/scripts/check_handoffs.py +437 -30
  98. package/skills/gsd-path-decide/scripts/check_task_briefs.py +135 -11
  99. package/skills/gsd-path-decide/scripts/discussion_validate.py +46 -10
  100. package/skills/gsd-path-decide/scripts/integration.py +196 -39
  101. package/skills/gsd-path-decide/scripts/isolation.py +996 -38
  102. package/skills/gsd-path-decide/scripts/loop_run.py +9 -4
  103. package/skills/gsd-path-decide/scripts/members.py +17 -3
  104. package/skills/gsd-path-decide/scripts/pipeline_state.py +10 -2
  105. package/skills/gsd-path-decide/scripts/state_checkpoint.py +15 -0
  106. package/skills/gsd-path-decide/scripts/worktree_paths.py +45 -0
  107. package/skills/gsd-path-define/SKILL.md +2 -1
  108. package/skills/gsd-path-define/references/runtime-selection.md +36 -0
  109. package/skills/gsd-path-define/references/spec-probes.md +19 -1
  110. package/skills/gsd-path-define/scripts/_common.py +76 -2
  111. package/skills/gsd-path-define/scripts/archive_milestone.py +129 -7
  112. package/skills/gsd-path-define/scripts/build_recovery.py +25 -2
  113. package/skills/gsd-path-define/scripts/check_handoffs.py +437 -30
  114. package/skills/gsd-path-define/scripts/check_task_briefs.py +135 -11
  115. package/skills/gsd-path-define/scripts/discussion_validate.py +46 -10
  116. package/skills/gsd-path-define/scripts/integration.py +196 -39
  117. package/skills/gsd-path-define/scripts/isolation.py +996 -38
  118. package/skills/gsd-path-define/scripts/loop_run.py +9 -4
  119. package/skills/gsd-path-define/scripts/members.py +17 -3
  120. package/skills/gsd-path-define/scripts/pipeline_state.py +10 -2
  121. package/skills/gsd-path-define/scripts/state_checkpoint.py +15 -0
  122. package/skills/gsd-path-define/scripts/worktree_paths.py +45 -0
  123. package/skills/gsd-path-define/templates/intent.md +5 -3
  124. package/skills/gsd-path-discuss/references/runtime-selection.md +36 -0
  125. package/skills/gsd-path-discuss/scripts/_common.py +76 -2
  126. package/skills/gsd-path-discuss/scripts/loop_run.py +9 -4
  127. package/skills/gsd-path-discuss/scripts/worktree_paths.py +45 -0
  128. package/skills/gsd-path-docs-audit/references/runtime-selection.md +36 -0
  129. package/skills/gsd-path-docs-audit/scripts/_common.py +76 -2
  130. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +129 -7
  131. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +25 -2
  132. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +437 -30
  133. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +135 -11
  134. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +46 -10
  135. package/skills/gsd-path-docs-audit/scripts/integration.py +196 -39
  136. package/skills/gsd-path-docs-audit/scripts/isolation.py +996 -38
  137. package/skills/gsd-path-docs-audit/scripts/loop_run.py +9 -4
  138. package/skills/gsd-path-docs-audit/scripts/members.py +17 -3
  139. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +10 -2
  140. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +15 -0
  141. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +45 -0
  142. package/skills/gsd-path-forensics/references/runtime-selection.md +36 -0
  143. package/skills/gsd-path-forensics/scripts/_common.py +76 -2
  144. package/skills/gsd-path-forensics/scripts/loop_run.py +9 -4
  145. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +104 -1
  146. package/skills/gsd-path-forensics/scripts/worktree_paths.py +45 -0
  147. package/skills/gsd-path-inspect/references/runtime-selection.md +36 -0
  148. package/skills/gsd-path-inspect/scripts/_common.py +76 -2
  149. package/skills/gsd-path-inspect/scripts/archive_milestone.py +129 -7
  150. package/skills/gsd-path-inspect/scripts/build_recovery.py +25 -2
  151. package/skills/gsd-path-inspect/scripts/check_handoffs.py +437 -30
  152. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +135 -11
  153. package/skills/gsd-path-inspect/scripts/discussion_validate.py +46 -10
  154. package/skills/gsd-path-inspect/scripts/integration.py +196 -39
  155. package/skills/gsd-path-inspect/scripts/isolation.py +996 -38
  156. package/skills/gsd-path-inspect/scripts/loop_run.py +9 -4
  157. package/skills/gsd-path-inspect/scripts/members.py +17 -3
  158. package/skills/gsd-path-inspect/scripts/pipeline_state.py +10 -2
  159. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +15 -0
  160. package/skills/gsd-path-inspect/scripts/workflow_run.py +10 -3
  161. package/skills/gsd-path-inspect/scripts/worktree_paths.py +45 -0
  162. package/skills/gsd-path-loop/references/runtime-selection.md +36 -0
  163. package/skills/gsd-path-loop/scripts/_common.py +76 -2
  164. package/skills/gsd-path-loop/scripts/loop_run.py +9 -4
  165. package/skills/gsd-path-loop/scripts/worktree_paths.py +45 -0
  166. package/skills/gsd-path-migrate/references/runtime-selection.md +36 -0
  167. package/skills/gsd-path-migrate/scripts/_common.py +76 -2
  168. package/skills/gsd-path-migrate/scripts/loop_run.py +9 -4
  169. package/skills/gsd-path-plan/SKILL.md +10 -0
  170. package/skills/gsd-path-plan/references/runtime-selection.md +36 -0
  171. package/skills/gsd-path-plan/references/spec-probes.md +19 -1
  172. package/skills/gsd-path-plan/scripts/_common.py +76 -2
  173. package/skills/gsd-path-plan/scripts/archive_milestone.py +129 -7
  174. package/skills/gsd-path-plan/scripts/build_recovery.py +25 -2
  175. package/skills/gsd-path-plan/scripts/check_handoffs.py +437 -30
  176. package/skills/gsd-path-plan/scripts/check_task_briefs.py +135 -11
  177. package/skills/gsd-path-plan/scripts/discussion_validate.py +46 -10
  178. package/skills/gsd-path-plan/scripts/integration.py +196 -39
  179. package/skills/gsd-path-plan/scripts/isolation.py +996 -38
  180. package/skills/gsd-path-plan/scripts/loop_run.py +9 -4
  181. package/skills/gsd-path-plan/scripts/members.py +17 -3
  182. package/skills/gsd-path-plan/scripts/pipeline_state.py +10 -2
  183. package/skills/gsd-path-plan/scripts/state_checkpoint.py +15 -0
  184. package/skills/gsd-path-plan/scripts/workflow_run.py +10 -3
  185. package/skills/gsd-path-plan/scripts/worktree_paths.py +45 -0
  186. package/skills/gsd-path-plan/templates/task.md +8 -1
  187. package/skills/gsd-path-research/references/runtime-selection.md +36 -0
  188. package/skills/gsd-path-research/scripts/_common.py +76 -2
  189. package/skills/gsd-path-research/scripts/archive_milestone.py +129 -7
  190. package/skills/gsd-path-research/scripts/build_recovery.py +25 -2
  191. package/skills/gsd-path-research/scripts/check_handoffs.py +437 -30
  192. package/skills/gsd-path-research/scripts/check_task_briefs.py +135 -11
  193. package/skills/gsd-path-research/scripts/discussion_validate.py +46 -10
  194. package/skills/gsd-path-research/scripts/integration.py +196 -39
  195. package/skills/gsd-path-research/scripts/isolation.py +996 -38
  196. package/skills/gsd-path-research/scripts/loop_run.py +9 -4
  197. package/skills/gsd-path-research/scripts/members.py +17 -3
  198. package/skills/gsd-path-research/scripts/pipeline_state.py +10 -2
  199. package/skills/gsd-path-research/scripts/state_checkpoint.py +15 -0
  200. package/skills/gsd-path-research/scripts/worktree_paths.py +45 -0
  201. package/skills/gsd-path-roadmap/references/runtime-selection.md +36 -0
  202. package/skills/gsd-path-roadmap/scripts/_common.py +76 -2
  203. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +129 -7
  204. package/skills/gsd-path-roadmap/scripts/build_recovery.py +25 -2
  205. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +437 -30
  206. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +135 -11
  207. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +46 -10
  208. package/skills/gsd-path-roadmap/scripts/integration.py +196 -39
  209. package/skills/gsd-path-roadmap/scripts/isolation.py +996 -38
  210. package/skills/gsd-path-roadmap/scripts/loop_run.py +9 -4
  211. package/skills/gsd-path-roadmap/scripts/members.py +17 -3
  212. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +10 -2
  213. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +15 -0
  214. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +45 -0
  215. package/skills/gsd-path-ship/SKILL.md +41 -8
  216. package/skills/gsd-path-ship/references/reviewer-final.md +10 -0
  217. package/skills/gsd-path-ship/references/reviewer-wave.md +61 -22
  218. package/skills/gsd-path-ship/references/reviewer.md +8 -6
  219. package/skills/gsd-path-ship/references/runtime-selection.md +36 -0
  220. package/skills/gsd-path-ship/references/spec-probes.md +19 -1
  221. package/skills/gsd-path-ship/scripts/_common.py +76 -2
  222. package/skills/gsd-path-ship/scripts/archive_milestone.py +129 -7
  223. package/skills/gsd-path-ship/scripts/build_recovery.py +25 -2
  224. package/skills/gsd-path-ship/scripts/build_state.py +333 -8
  225. package/skills/gsd-path-ship/scripts/check_handoffs.py +437 -30
  226. package/skills/gsd-path-ship/scripts/check_task_briefs.py +135 -11
  227. package/skills/gsd-path-ship/scripts/discussion_validate.py +46 -10
  228. package/skills/gsd-path-ship/scripts/integration.py +196 -39
  229. package/skills/gsd-path-ship/scripts/isolation.py +996 -38
  230. package/skills/gsd-path-ship/scripts/lean_verification.py +28 -13
  231. package/skills/gsd-path-ship/scripts/loop_run.py +9 -4
  232. package/skills/gsd-path-ship/scripts/members.py +17 -3
  233. package/skills/gsd-path-ship/scripts/pipeline_state.py +10 -2
  234. package/skills/gsd-path-ship/scripts/state_checkpoint.py +15 -0
  235. package/skills/gsd-path-ship/scripts/workflow_run.py +10 -3
  236. package/skills/gsd-path-ship/scripts/worktree_paths.py +45 -0
  237. package/skills/gsd-path-ship/templates/wave-review.md +24 -8
  238. package/skills/gsd-path-undo/references/runtime-selection.md +36 -0
  239. package/skills/gsd-path-undo/scripts/_common.py +76 -2
  240. package/skills/gsd-path-undo/scripts/loop_run.py +9 -4
  241. package/skills/gsd-path-undo/scripts/worktree_paths.py +45 -0
  242. package/skills/path/BUILD.md +3 -1
  243. package/skills/path/DEFINE.md +2 -1
  244. package/skills/path/PLAN.md +10 -0
  245. package/skills/path/SHIP.md +41 -8
  246. package/skills/path/SKILL.md +16 -1
  247. package/skills/path/references/build-native.md +20 -3
  248. package/skills/path/references/coder.md +6 -3
  249. package/skills/path/references/reviewer-final.md +10 -0
  250. package/skills/path/references/reviewer-wave.md +61 -22
  251. package/skills/path/references/reviewer.md +8 -6
  252. package/skills/path/references/runtime-selection.md +36 -0
  253. package/skills/path/references/spec-probes.md +19 -1
  254. package/skills/path/scripts/_common.py +76 -2
  255. package/skills/path/scripts/archive_milestone.py +129 -7
  256. package/skills/path/scripts/build_recovery.py +25 -2
  257. package/skills/path/scripts/build_state.py +333 -8
  258. package/skills/path/scripts/check_handoffs.py +437 -30
  259. package/skills/path/scripts/check_task_briefs.py +135 -11
  260. package/skills/path/scripts/discussion_validate.py +46 -10
  261. package/skills/path/scripts/dispatch_driver.py +28 -3
  262. package/skills/path/scripts/git_guard.py +44 -4
  263. package/skills/path/scripts/integration.py +196 -39
  264. package/skills/path/scripts/isolation.py +996 -38
  265. package/skills/path/scripts/lean_verification.py +28 -13
  266. package/skills/path/scripts/loop_run.py +9 -4
  267. package/skills/path/scripts/members.py +17 -3
  268. package/skills/path/scripts/pipeline_state.py +10 -2
  269. package/skills/path/scripts/state_checkpoint.py +15 -0
  270. package/skills/path/scripts/status_runtime.py +73 -2
  271. package/skills/path/scripts/workflow_run.py +10 -3
  272. package/skills/path/scripts/worktree_paths.py +45 -0
  273. package/skills/path/templates/intent.md +5 -3
  274. package/skills/path/templates/task.md +8 -1
  275. package/skills/path/templates/wave-review.md +24 -8
@@ -424,6 +424,10 @@ GIT_REASONS = {
424
424
  "(STATE.branch); create a new branch instead"
425
425
  ),
426
426
  "update-ref": "git update-ref deletion destroys refs the recovery protocol inspects",
427
+ "owned-refspec": (
428
+ "git commands naming refs/gsd-path/ or runtime-hotfix are denied: that "
429
+ "namespace holds orchestrator-owned authorizations an agent must not create or move"
430
+ ),
427
431
  "worktree": (
428
432
  "git worktree remove --force discards uncommitted task work the recovery "
429
433
  "protocol needs; commit or stash it, then remove without --force"
@@ -1431,6 +1435,133 @@ def command_can_destroy_files(invocation):
1431
1435
  )
1432
1436
 
1433
1437
 
1438
+ def _deletion_overlaps(candidate, root):
1439
+ """Whether a recursive delete of `candidate` could remove `root`.
1440
+
1441
+ A candidate inside the root deletes its contents, and a candidate that
1442
+ CONTAINS the root deletes it wholesale (`rm -r` of a parent directory), so
1443
+ both directions deny. Existing-path identity covers case variants that a
1444
+ lexical comparison misses on case-insensitive filesystems.
1445
+ """
1446
+ try:
1447
+ candidate.relative_to(root)
1448
+ return True
1449
+ except ValueError:
1450
+ pass
1451
+ try:
1452
+ root.relative_to(candidate)
1453
+ return True
1454
+ except ValueError:
1455
+ pass
1456
+ return _within_existing_root(candidate, root) or _within_existing_root(root, candidate)
1457
+
1458
+
1459
+ def _strictly_inside(candidate, root):
1460
+ if candidate == root:
1461
+ return False
1462
+ try:
1463
+ candidate.relative_to(root)
1464
+ return True
1465
+ except ValueError:
1466
+ return any(_same_existing_path(ancestor, root) for ancestor in candidate.parents)
1467
+
1468
+
1469
+ def destructive_state_root_reason(operands, working_directories):
1470
+ """Why a destructive operand is pipeline state, or None when outside it.
1471
+
1472
+ Delete restrictions cover the pipeline's state directories, not only the
1473
+ repository (#468): the guard's home tree, the configured worktree root,
1474
+ and every pinned workspace root sit outside any repository, so the path
1475
+ kinds alone would call them external. Unresolvable workspace state denies
1476
+ rather than guesses.
1477
+ """
1478
+ directories = working_directories or [os.getcwd()]
1479
+ commons = set()
1480
+ checkouts = set()
1481
+ for directory in directories:
1482
+ result = subprocess.run(
1483
+ ["git", "-C", str(directory), "rev-parse", "--show-toplevel", "--git-common-dir"],
1484
+ capture_output=True, encoding="utf-8", errors="replace", check=False,
1485
+ env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
1486
+ )
1487
+ lines = result.stdout.splitlines()
1488
+ if result.returncode == 0 and len(lines) == 2:
1489
+ checkouts.add(Path(lines[0]).resolve(strict=False))
1490
+ common = Path(lines[1])
1491
+ if not common.is_absolute():
1492
+ common = Path(directory) / common
1493
+ commons.add(common.resolve(strict=False))
1494
+ try:
1495
+ roots = _workspace_roots_for_commons(commons)
1496
+ except ValueError as error:
1497
+ return f"pipeline workspace state cannot be resolved: {error}"
1498
+ for operand in operands:
1499
+ for candidate in target_paths(operand, directories, Path(os.getcwd())):
1500
+ for root in roots:
1501
+ # A managed worktree under a state root is the agent's own
1502
+ # checkout; deletes inside it are ordinary product work.
1503
+ if _deletion_overlaps(candidate, root) and not any(
1504
+ _strictly_inside(candidate, checkout) and _strictly_inside(checkout, root)
1505
+ for checkout in checkouts
1506
+ ):
1507
+ return f"destructive target is pipeline state: {operand}"
1508
+ return None
1509
+
1510
+
1511
+ def _closed_repo_root(working_directories):
1512
+ """The closed-milestone repository for these working directories, if any."""
1513
+ for directory in working_directories or [os.getcwd()]:
1514
+ root = subprocess.run(
1515
+ ["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
1516
+ capture_output=True, encoding="utf-8", errors="replace", check=False,
1517
+ )
1518
+ if root.returncode != 0:
1519
+ continue
1520
+ repo = Path(root.stdout.strip())
1521
+ if closed_milestone_reason(repo):
1522
+ return repo
1523
+ return None
1524
+
1525
+
1526
+ def closed_destructive_scope_reason(arguments, working_directories):
1527
+ """Why a destructive command stays inside pipeline scope, or None outside it.
1528
+
1529
+ A closed milestone accepts no new work, but a scratch file entirely outside
1530
+ the repository and every state root is no work (#468). The allowance needs
1531
+ every operand provably external to the closed repository — the path kinds
1532
+ check the lexical path, the symlink-resolved path, control aliases, and
1533
+ member checkouts — and the state-root check covers the directories that
1534
+ live outside the repository.
1535
+ """
1536
+ end = arguments.index("--") if "--" in arguments else len(arguments)
1537
+ operands = [
1538
+ argument for argument in arguments[:end] if argument and not argument.startswith("-")
1539
+ ] + [argument for argument in arguments[end + 1:] if argument]
1540
+ if not operands:
1541
+ return "destructive command names no target"
1542
+ repo = _closed_repo_root(working_directories)
1543
+ if repo is None:
1544
+ return "pipeline scope cannot be classified"
1545
+ control_roots = repository_control_roots(repo)
1546
+ try:
1547
+ import members # the guard's runtime directory
1548
+ member_roots = [Path(member["checkout"]) for member in members.read_members(repo)]
1549
+ except Exception as error:
1550
+ return f"member scope cannot be resolved: {error}"
1551
+ for operand in operands:
1552
+ if target_kind(operand, working_directories, repo, control_roots) != "external":
1553
+ return f"destructive target stays inside the repository: {operand}"
1554
+ # A recursive delete of an ancestor removes the repository (and any
1555
+ # member checkout) even though the operand itself classifies external.
1556
+ for candidate in target_paths(operand, working_directories, repo):
1557
+ if _deletion_overlaps(candidate, repo.resolve()):
1558
+ return f"destructive target contains the repository: {operand}"
1559
+ for member_root in member_roots:
1560
+ if _deletion_overlaps(candidate, member_root.resolve(strict=False)):
1561
+ return f"destructive target contains a member checkout: {operand}"
1562
+ return destructive_state_root_reason(operands, working_directories)
1563
+
1564
+
1434
1565
  def literal_path(operand):
1435
1566
  if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
1436
1567
  operand = operand[1:-1]
@@ -2687,6 +2818,114 @@ def unresolved_runner_word(token):
2687
2818
  ) and not parameters_double_quoted(token)
2688
2819
 
2689
2820
 
2821
+ POWERSHELL_ASSIGNMENT_TARGET = re.compile(r"^\$(?:env:)?[A-Za-z_][A-Za-z0-9_]*$")
2822
+ POWERSHELL_LITERAL_VALUE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.:/\\ +,-]*")
2823
+ POWERSHELL_STATIC_CALL = re.compile(r"\[(?P<type>[^\]()]+)\]::")
2824
+ POWERSHELL_PURE_TYPES = frozenset(
2825
+ {
2826
+ "math", "string", "int", "int32", "long", "int64", "double", "bool",
2827
+ "byte", "char", "decimal", "single", "guid", "datetime", "timespan",
2828
+ "version",
2829
+ "system.math", "system.string", "system.int32", "system.int64",
2830
+ "system.double", "system.boolean", "system.guid", "system.datetime",
2831
+ "system.timespan", "system.version",
2832
+ }
2833
+ )
2834
+ POWERSHELL_DYNAMIC_COMMANDS = frozenset(
2835
+ {
2836
+ "invoke-expression", "iex", "invoke-command", "icm", "add-type",
2837
+ "start-process", "saps", "sap", "start-job", "new-object",
2838
+ }
2839
+ )
2840
+
2841
+
2842
+ def powershell_assignment_invocation(segment, unwrap_runners=True):
2843
+ """The invocation a validated PowerShell assignment runs, or None.
2844
+
2845
+ ``$name = literal`` and ``$env:NAME = literal`` set a process-local
2846
+ variable or environment entry. PowerShell runs a bare-word value as a
2847
+ pipeline, so the value is judged as the command it would run. The value
2848
+ must be plain literals; anything richer — expansions, substitutions,
2849
+ member access — keeps the unresolvable denial rather than guessing at
2850
+ expression semantics. An environment name that changes where Git reads
2851
+ its configuration is refused exactly like the shell form (#468).
2852
+ """
2853
+ if len(segment) < 3 or segment[1] != "=":
2854
+ return None
2855
+ if POWERSHELL_ASSIGNMENT_TARGET.fullmatch(segment[0]) is None:
2856
+ return None
2857
+ name = segment[0].removeprefix("$")
2858
+ folded = name.casefold()
2859
+ if ":" in name:
2860
+ variable, _, scope = name.partition(":")
2861
+ if variable != "env":
2862
+ return None
2863
+ if scope.casefold() in {"home", "xdg_config_home"} or scope.casefold().startswith("git_"):
2864
+ raise ValueError(
2865
+ f"{name} changes where Git reads its configuration, which the guard "
2866
+ "cannot validate; run git without it"
2867
+ )
2868
+ elif folded in {"home", "xdg_config_home"} or folded.startswith("git_"):
2869
+ return None # shell-style names keep their shell semantics, not PowerShell's
2870
+ if any(POWERSHELL_LITERAL_VALUE.fullmatch(token) is None for token in segment[2:]):
2871
+ return None
2872
+ # A value naming a write or dynamic command runs it; deny without resolving paths.
2873
+ command_names = SHELL_WRITE_COMMANDS | POWERSHELL_DYNAMIC_COMMANDS
2874
+ if any(token.casefold().removesuffix(".exe") in command_names for token in segment[2:]):
2875
+ return None
2876
+ return command_invocation(segment[2:], unwrap_runners)
2877
+
2878
+
2879
+ def powershell_expression_reason(tokens, depth=0):
2880
+ """Why a PowerShell expression form stays denied, or None when safe.
2881
+
2882
+ PowerShell expression statements are not executables: assignments are
2883
+ process-local, while .NET static member calls run arbitrary code
2884
+ (``[System.IO.File]::WriteAllText`` writes a file the write-target
2885
+ classification never sees). The guard allows the provably pure type
2886
+ members and denies every other static call — wherever the token appears,
2887
+ including inside a script block or a wrapped ``-Command`` payload — plus
2888
+ every dynamic-execution command (aliases included) inside a block or as
2889
+ the executable, and the call operator (#468).
2890
+ """
2891
+ previous = None
2892
+ for token in tokens:
2893
+ if token == "&" and (
2894
+ previous is None
2895
+ or previous.endswith("{")
2896
+ or set(previous) <= set("|;&()\n\r")
2897
+ ):
2898
+ return "the PowerShell call operator target cannot be resolved"
2899
+ previous = token
2900
+ for segment in command_segments(tokens):
2901
+ wrapped = wrapped_command_tokens(segment, expand_parameters=False)
2902
+ if wrapped is not None:
2903
+ if depth >= 4:
2904
+ return "nested command payloads exceed the guard's inspection depth"
2905
+ reason = powershell_expression_reason(wrapped, depth + 1)
2906
+ if reason:
2907
+ return reason
2908
+ continue
2909
+ invocation = command_invocation(list(segment))
2910
+ if invocation is None:
2911
+ continue
2912
+ executable = invocation[0]
2913
+ if executable in POWERSHELL_DYNAMIC_COMMANDS:
2914
+ return f"dynamic execution cannot be inspected by the guard: {executable}"
2915
+ block_depth = 0
2916
+ for token in segment:
2917
+ match = POWERSHELL_STATIC_CALL.search(token)
2918
+ if match is not None and match.group("type").strip().casefold() not in POWERSHELL_PURE_TYPES:
2919
+ return (f"static member call is not on the guard's read-only type "
2920
+ f"whitelist: {token}")
2921
+ word = token.lstrip("{")
2922
+ block_depth += len(token) - len(word)
2923
+ if block_depth and word.rstrip("}").casefold() in POWERSHELL_DYNAMIC_COMMANDS:
2924
+ return f"dynamic execution cannot be inspected by the guard: {token}"
2925
+ block_depth = max(0, block_depth - (len(word) - len(word.rstrip("}"))))
2926
+ return None
2927
+
2928
+
2690
2929
  def command_invocation(segment, unwrap_runners=True):
2691
2930
  """`unwrap_runners` is False where a command runner itself must be judged."""
2692
2931
  index = 0
@@ -2781,6 +3020,9 @@ def command_invocation(segment, unwrap_runners=True):
2781
3020
  break
2782
3021
  arguments = segment[index + 1 :]
2783
3022
  if SHELL_PARAMETER_SYNTAX.search(executable):
3023
+ assignment = powershell_assignment_invocation(list(segment[index:]), unwrap_runners)
3024
+ if assignment is not None:
3025
+ return assignment
2784
3026
  raise ValueError(
2785
3027
  f"executable {segment[index]} cannot be resolved by the guard; "
2786
3028
  "name the program literally"
@@ -2918,6 +3160,16 @@ def git_command(segment, assignments=None):
2918
3160
  if option != "-C":
2919
3161
  require_resolved_git_word(value)
2920
3162
  index += 1
3163
+ if option not in {"-C", "-c", "--config-env"} and git_option_in(
3164
+ option, GIT_PARAMETER_DENIED_OPTIONS
3165
+ ):
3166
+ # A denied global option executes a program git runs
3167
+ # (--upload-pack/--exec/...); it must never slip past as a
3168
+ # pre-subcommand token the subcommand checks never see (#468).
3169
+ # -c/--config-env keep their dedicated config-key checks below.
3170
+ raise ValueError(
3171
+ f"git option {option} cannot be verified by the guard"
3172
+ )
2921
3173
  if option in {"-c", "--config-env"}:
2922
3174
  config_key, _, config_value = str(value).partition("=")
2923
3175
  config_key = config_key.casefold()
@@ -3589,6 +3841,10 @@ def _update_ref_names(target, refs, context, no_deref):
3589
3841
 
3590
3842
 
3591
3843
  def _workspace_roots(contexts):
3844
+ return _workspace_roots_for_commons({context["common"] for context in contexts})
3845
+
3846
+
3847
+ def _workspace_roots_for_commons(commons):
3592
3848
  roots = [(Path.home() / ".gsd-path").resolve()]
3593
3849
  configured = os.environ.get("GSD_PATH_WORKTREE_ROOT")
3594
3850
  if configured:
@@ -3596,7 +3852,7 @@ def _workspace_roots(contexts):
3596
3852
  if not configured_root.is_absolute():
3597
3853
  raise ValueError("GSD_PATH_WORKTREE_ROOT must be absolute")
3598
3854
  roots.append(configured_root.resolve())
3599
- for common in {context["common"] for context in contexts}:
3855
+ for common in commons:
3600
3856
  receipts = common / "gsd-path" / "workspaces"
3601
3857
  if not os.path.lexists(receipts):
3602
3858
  continue
@@ -4089,6 +4345,12 @@ def destructive_git_reason(
4089
4345
  if invocation is None:
4090
4346
  continue
4091
4347
  command, arguments, git_options = invocation
4348
+ if any(
4349
+ protected in word.casefold()
4350
+ for word in words
4351
+ for protected in ("refs/gsd-path/", "runtime-hotfix")
4352
+ ):
4353
+ return GIT_REASONS["owned-refspec"]
4092
4354
  # Git matches its own commands by exact case, but alias names in any case.
4093
4355
  built_in = (
4094
4356
  command_invocation(words)[1][-len(arguments) - 1] in GIT_BUILT_IN_COMMANDS
@@ -4307,20 +4569,48 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
4307
4569
  runtime = here / "runtime"
4308
4570
  if (here / "runtime.json").exists():
4309
4571
  try:
4310
- from status_runtime import resolve_runtime
4311
- runtime = resolve_runtime(here.parent)
4312
- except (OSError, ValueError):
4572
+ # Option (i) on #468: acceptance is the pinned runtime plus the
4573
+ # validated successors its own hashed manifest names. The walk
4574
+ # comes from the PINNED runtime's own bytes — never the project
4575
+ # launcher's preloaded module, which may be stale or divergent —
4576
+ # and exactly the bytes whose sha256 the pinned manifest names:
4577
+ # read once, hash against the digest-proven manifest, execute
4578
+ # the verified buffer, so no swap between check and exec can
4579
+ # run unproven code.
4580
+ import hashlib
4581
+ import types
4582
+
4583
+ from status_runtime import (
4584
+ declaration, manifest_digest, runtime_home, validate_runtime,
4585
+ )
4586
+ pin = declaration(here.parent)
4587
+ validate_runtime(pin)
4588
+ root = runtime_home() / pin["digest"]
4589
+ manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
4590
+ if (not isinstance(manifest, dict)
4591
+ or manifest_digest(manifest) != pin["digest"]):
4592
+ return False
4593
+ expected = manifest.get("files", {}).get("status_runtime.py")
4594
+ data = (root / "status_runtime.py").read_bytes()
4595
+ if (not isinstance(expected, str)
4596
+ or hashlib.sha256(data).hexdigest() != expected):
4597
+ return False
4598
+ module = types.ModuleType("gsd_path_pinned_status_runtime")
4599
+ exec(compile(data, str(root / "status_runtime.py"), "exec"), module.__dict__)
4600
+ runtimes = module.accepted_runtime_dirs(here.parent)
4601
+ except (OSError, ValueError, TypeError, ImportError, AttributeError):
4313
4602
  return False
4314
- elif not runtime.exists():
4315
- runtime = here
4316
- runtime = runtime.resolve()
4603
+ else:
4604
+ if not runtime.exists():
4605
+ runtime = here
4606
+ runtimes = {runtime.resolve()}
4317
4607
  for base in working_directories or [os.getcwd()]:
4318
4608
  try:
4319
4609
  # A Git Bash /c/... operand names the C: drive, not a folder named c.
4320
4610
  script = (Path(base) / native_path_text(operand)).resolve()
4321
4611
  if (
4322
4612
  script.name in helpers
4323
- and (script.is_relative_to(runtime) or (
4613
+ and (any(script.is_relative_to(runtime_dir) for runtime_dir in runtimes) or (
4324
4614
  script.name == "status_runtime.py" and script == here / "status_runtime.py"
4325
4615
  ))
4326
4616
  and script.is_file()
@@ -4522,6 +4812,106 @@ def closed_git_listing(tokens):
4522
4812
  return not positionals
4523
4813
 
4524
4814
 
4815
+ REMOTE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
4816
+ FETCH_DESTINATION = re.compile(r"^refs/remotes/[^\s]+/")
4817
+
4818
+
4819
+ FETCH_BEHAVIOR_KEYS = (
4820
+ "remote.{remote}.uploadpack", "fetch.uploadpack",
4821
+ "remote.{remote}.prune", "fetch.prune",
4822
+ "remote.{remote}.pruneTags", "fetch.pruneTags",
4823
+ "remote.{remote}.mirror", "remote.{remote}.tagopt",
4824
+ )
4825
+
4826
+
4827
+ def _configured_fetch_read_only(repo, remote) -> bool:
4828
+ """The configured remote fetches into remote-tracking refs only.
4829
+
4830
+ Repo config can change what a bare `git fetch REMOTE` does, so the
4831
+ guard proves it instead of assuming it: the remote has a URL (an
4832
+ unconfigured name makes git fetch from a local directory of that
4833
+ name); none of uploadpack (runs a program), prune/pruneTags (delete
4834
+ refs), mirror (rewrites every ref) or tagopt (forces tag fetches) is
4835
+ set at any scope; and every `remote.<name>.fetch` refspec writes only
4836
+ under refs/remotes/ (a hand-added `+refs/heads/main:refs/heads/retired`
4837
+ moves a local branch). Default tag auto-follow still creates missing
4838
+ tags that point at fetched commits; it never moves an existing ref.
4839
+ """
4840
+ def config(*arguments):
4841
+ try:
4842
+ return subprocess.run(
4843
+ ["git", "config", *arguments], cwd=repo, capture_output=True,
4844
+ encoding="utf-8", errors="replace", check=False,
4845
+ )
4846
+ except OSError:
4847
+ return None
4848
+
4849
+ url = config("--get", f"remote.{remote}.url")
4850
+ if url is None or url.returncode != 0 or not url.stdout.strip():
4851
+ return False
4852
+ for key in FETCH_BEHAVIOR_KEYS:
4853
+ result = config("--get", key.format(remote=remote))
4854
+ if result is None or result.returncode != 1:
4855
+ return False
4856
+ refspecs = config("--get-all", f"remote.{remote}.fetch")
4857
+ if refspecs is None or refspecs.returncode not in (0, 1):
4858
+ return False
4859
+ for line in refspecs.stdout.splitlines():
4860
+ destination = line.rsplit(":", 1)[-1].strip()
4861
+ if not FETCH_DESTINATION.match(destination):
4862
+ return False
4863
+ return True
4864
+
4865
+
4866
+ def _closed_fetch_allowed(git, segment, directories) -> bool:
4867
+ """A read-only fetch on a closed milestone (#468).
4868
+
4869
+ `git fetch [REMOTE]` is allowed only once these properties are PROVEN,
4870
+ never assumed:
4871
+ (1) the raw segment is `git (-C DIR)* fetch`: no other global option
4872
+ precedes the subcommand (walked on raw tokens, consuming each -C with
4873
+ its value, so neither valueless flags like -p nor a -C value named
4874
+ fetch can slip past; --upload-pack/--exec additionally raise in
4875
+ git_command);
4876
+ (2) the remote's configuration fetches read-only (see
4877
+ _configured_fetch_read_only).
4878
+ Any refspec or option on the command line stays denied.
4879
+ """
4880
+ if git is None:
4881
+ return False
4882
+ subcommand, arguments, _options = git
4883
+ if subcommand != "fetch":
4884
+ return False
4885
+ words = [str(token) for token in segment]
4886
+ index = 1
4887
+ while words[index:index + 1] == ["-C"]:
4888
+ index += 2
4889
+ if words[index:index + 1] != ["fetch"]:
4890
+ return False
4891
+ remote = arguments[0] if arguments else None
4892
+ if remote is not None and not REMOTE_NAME.fullmatch(remote):
4893
+ return False
4894
+ if len(arguments) > 1:
4895
+ return False
4896
+ if remote is None:
4897
+ # No argument fetches the current branch's remote, else origin.
4898
+ remote = "origin"
4899
+ if directories:
4900
+ branch = _git_output([], directories[0], "branch", "--show-current")
4901
+ if branch:
4902
+ configured_branch_remote = subprocess.run(
4903
+ ["git", "config", "--get", f"branch.{branch}.remote"],
4904
+ cwd=directories[0], capture_output=True, encoding="utf-8",
4905
+ errors="replace", check=False,
4906
+ ).stdout.strip()
4907
+ if configured_branch_remote:
4908
+ remote = configured_branch_remote
4909
+ return all(
4910
+ _configured_fetch_read_only(Path(directory), remote)
4911
+ for directory in directories or [Path.cwd()]
4912
+ )
4913
+
4914
+
4525
4915
  def closed_shell_execution_reason(
4526
4916
  command, tokens, working_directories, initial_assignments=None
4527
4917
  ):
@@ -4568,17 +4958,54 @@ def closed_shell_execution_reason(
4568
4958
  if invocation is None:
4569
4959
  continue
4570
4960
  executable, _ = invocation
4571
- if executable in DIRECTORY_CHANGE_COMMANDS or checked_segment == ["git", "fetch", "origin"]:
4961
+ if executable in DIRECTORY_CHANGE_COMMANDS or _closed_fetch_allowed(
4962
+ git, segment, directories
4963
+ ):
4572
4964
  continue
4573
4965
  plain = checked_segment[:next((i for i, token in enumerate(checked_segment) if is_redirection(token)), len(checked_segment))]
4574
4966
  if executable in {"echo", "printf"} or closed_git_listing(plain) or archive_command_is_read_only(
4575
4967
  shlex.join(plain), plain, True, CLOSED_READ_GIT_COMMANDS
4576
4968
  ):
4577
4969
  continue
4970
+ if command_can_destroy_files(invocation) and closed_destructive_scope_reason(
4971
+ list(invocation[1]), directories
4972
+ ) is None:
4973
+ # A literal delete entirely outside the repository and every state
4974
+ # root is scratch cleanup, not new work on the closed milestone (#468).
4975
+ continue
4578
4976
  return closed
4579
4977
  return None
4580
4978
 
4581
4979
 
4980
+ def runtime_hotfix_invocation(tokens):
4981
+ """True when any simple command, prefixed or wrapped, names isolation.py then runtime-hotfix."""
4982
+ for segment in command_segments(tokens):
4983
+ for index, (token, following) in enumerate(zip(segment, [*segment[1:], ""])):
4984
+ if following != "runtime-hotfix":
4985
+ continue
4986
+ clustered = re.fullmatch(r"-[A-Za-ln-z]*m(.+)", token)
4987
+ if clustered:
4988
+ module = clustered.group(1)
4989
+ elif any(
4990
+ earlier.startswith("-") and not earlier.startswith("--") and "m" in earlier
4991
+ for earlier in segment[:index]
4992
+ ):
4993
+ module = token
4994
+ else:
4995
+ module = None
4996
+ if token == "-" or token.replace("\\", "/").rsplit("/", 1)[-1] == "isolation.py" or (
4997
+ module is not None and module.rsplit(".", 1)[-1] == "isolation"
4998
+ ):
4999
+ return True
5000
+ try:
5001
+ wrapped = wrapped_command_tokens(segment, expand_parameters=False)
5002
+ except ValueError: # the shape checks below refuse what cannot be unwrapped
5003
+ continue
5004
+ if wrapped is not None and runtime_hotfix_invocation(wrapped):
5005
+ return True
5006
+ return False
5007
+
5008
+
4582
5009
  def command_denial(command, working_directories, allow_destructive=True):
4583
5010
  """Return why a shell command is denied, or None when it may run."""
4584
5011
  working_directories = working_directories or [os.getcwd()]
@@ -4592,6 +5019,16 @@ def command_denial(command, working_directories, allow_destructive=True):
4592
5019
  ):
4593
5020
  return None
4594
5021
  bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
5022
+ # runtime-hotfix is an operator-only recovery (#480): its receipt is
5023
+ # the owner authorization a landing later trusts, so an agent running
5024
+ # the command self-authorizes. Deny by subcommand name regardless of
5025
+ # the script's path.
5026
+ if runtime_hotfix_invocation(tokens):
5027
+ return (
5028
+ "isolation.py runtime-hotfix records the owner authorization a "
5029
+ "landing trusts; it is operator-only — request the hotfix from "
5030
+ "the orchestrator instead of running it"
5031
+ )
4595
5032
  destructive = list(destructive_shell_invocations(tokens))
4596
5033
  if destructive and (
4597
5034
  not allow_destructive
@@ -4605,6 +5042,16 @@ def command_denial(command, working_directories, allow_destructive=True):
4605
5042
  )
4606
5043
  ):
4607
5044
  return DESTRUCTIVE_SHAPE_REASON
5045
+ if destructive:
5046
+ state_reason = destructive_state_root_reason(
5047
+ [operand for _, operands in destructive for operand in operands],
5048
+ working_directories,
5049
+ )
5050
+ if state_reason:
5051
+ return state_reason
5052
+ expression_reason = powershell_expression_reason(tokens)
5053
+ if expression_reason:
5054
+ return expression_reason
4608
5055
  reason = closed_shell_execution_reason(command, tokens, working_directories)
4609
5056
  if reason:
4610
5057
  return reason
@@ -345,6 +345,9 @@ def install_member_hooks(coordinator: Path, member: Path, dry_run: bool = False)
345
345
  plan = []
346
346
  for name in GIT_HOOK_NAMES:
347
347
  hook, chained = hooks_dir / name, hooks_dir / f"{name}{CHAINED_HOOK_SUFFIX}"
348
+ if _hook_is_disabled(hooks_dir, name):
349
+ plan.append((name, hook, chained, False, True))
350
+ continue
348
351
  if hook.is_symlink() or chained.is_symlink():
349
352
  raise InstallerError(f"refusing a symlinked git hook: {hook}")
350
353
  if _lexists(hook) and not hook.is_file():
@@ -357,17 +360,21 @@ def install_member_hooks(coordinator: Path, member: Path, dry_run: bool = False)
357
360
  )
358
361
  if chain and _lexists(chained):
359
362
  raise InstallerError(f"cannot chain {hook}: {chained} already exists")
360
- plan.append((name, hook, chained, chain))
363
+ plan.append((name, hook, chained, chain, False))
361
364
  lines = []
362
- for name, hook, chained, chain in plan:
365
+ for name, hook, chained, chain, disabled in plan:
363
366
  note = " (keeps the existing hook as a chained hook)" if chain else ""
367
+ if disabled:
368
+ note = " (left disabled)"
364
369
  lines.append(f"member hook: {hook}{note}" + (" (dry run)" if dry_run else ""))
365
370
  if dry_run:
366
371
  return lines
367
372
  hooks_dir.mkdir(parents=True, exist_ok=True)
368
373
  staged = []
369
374
  try:
370
- for name, _, _, _ in plan:
375
+ for name, _, _, _, disabled in plan:
376
+ if disabled:
377
+ continue
371
378
  temporary = hooks_dir / f".{name}.gsd-path-tmp"
372
379
  # O_EXCL already refuses any existing name; O_NOFOLLOW (absent on
373
380
  # Windows) adds defense in depth where it exists.
@@ -379,7 +386,9 @@ def install_member_hooks(coordinator: Path, member: Path, dry_run: bool = False)
379
386
  if hasattr(os, "fchmod"): # Windows before 3.13; Git for Windows needs no exec bit.
380
387
  os.fchmod(output.fileno(), 0o755)
381
388
  output.write(member_hook(interpreter, name).encode("utf-8"))
382
- for name, hook, chained, chain in plan:
389
+ for name, hook, chained, chain, disabled in plan:
390
+ if disabled:
391
+ continue
383
392
  if chain:
384
393
  os.replace(hook, chained)
385
394
  os.replace(hooks_dir / f".{name}.gsd-path-tmp", hook)
@@ -1308,11 +1317,25 @@ def _project_destinations(
1308
1317
  if hooks_dir is not None:
1309
1318
  for hook_name, generator in GIT_HOOKS:
1310
1319
  hook = hooks_dir / hook_name
1320
+ if _hook_is_disabled(hooks_dir, hook_name):
1321
+ continue
1311
1322
  chained = _lexists(_chained_hook_path(hook)) or _foreign_git_hook(hook)
1312
1323
  destinations.append((hook, None, generator(interpreter, chained), True))
1313
1324
  return destinations
1314
1325
 
1315
1326
 
1327
+ def _hook_is_disabled(hooks_dir: Path, hook_name: str) -> bool:
1328
+ """Whether the user deliberately disabled this hook (#469).
1329
+
1330
+ A hook renamed to `<name>.disabled*` stays disabled: reinstalling beside
1331
+ the rename would silently re-enable it. The rename only disables while
1332
+ the active hook is absent — a leftover active hook next to a rename is
1333
+ inspected and refreshed normally, never hidden. The convention matches
1334
+ the installer's own `disabled-gsd-skills-<n>` rename style.
1335
+ """
1336
+ return not _lexists(hooks_dir / hook_name) and any(hooks_dir.glob(f"{hook_name}.disabled*"))
1337
+
1338
+
1316
1339
  def _native_settings_mergers(
1317
1340
  project: Path, selected: Sequence[str], hooks: bool
1318
1341
  ) -> Mapping[Path, Callable[[Path, str], str]]:
@@ -3050,6 +3073,13 @@ def doctor(
3050
3073
  label = _describe_project_path(project, hook_path)
3051
3074
  if hook_path in unreadable_git_hooks:
3052
3075
  continue
3076
+ if _hook_is_disabled(hooks_dir, hook_name):
3077
+ push(
3078
+ "ok",
3079
+ f"hooks: {hook_name} is deliberately disabled "
3080
+ f"({hook_name}.disabled* present in {hooks_dir})",
3081
+ )
3082
+ continue
3053
3083
  if not _lexists(hook_path):
3054
3084
  push(
3055
3085
  "fail",