@opengsd/gsd-path 1.0.0 → 1.2.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 (498) hide show
  1. package/AGENTS.md +33 -13
  2. package/CHANGELOG.md +68 -0
  3. package/DOCS.md +75 -6
  4. package/FULL.md +12 -15
  5. package/HOOKS.md +18 -9
  6. package/QUICK.md +8 -6
  7. package/README.md +218 -14
  8. package/RUNTIME.md +16 -5
  9. package/UPDATE.md +30 -11
  10. package/WORKFLOW.md +35 -16
  11. package/package.json +23 -1
  12. package/platforms/claude/dispatch.md +4 -4
  13. package/platforms/copilot/dispatch.md +4 -4
  14. package/platforms/cursor/dispatch.md +5 -10
  15. package/platforms/grok/dispatch.md +4 -6
  16. package/platforms/kimi/dispatch.md +4 -4
  17. package/platforms/kiro/dispatch.md +4 -6
  18. package/platforms/opencode/dispatch.md +4 -4
  19. package/platforms/qwen/dispatch.md +4 -4
  20. package/platforms/shared-agents/dispatch.md +12 -19
  21. package/scripts/_common.py +5 -0
  22. package/scripts/archive_milestone.py +30 -124
  23. package/scripts/bootstrap_repository.py +33 -4
  24. package/scripts/build_recovery.py +292 -0
  25. package/scripts/build_state.py +2 -0
  26. package/scripts/check_docs_audit.py +7 -3
  27. package/scripts/check_handoffs.py +7 -1
  28. package/scripts/check_task_briefs.py +3 -0
  29. package/scripts/detect_project.py +22 -3
  30. package/scripts/discussion_records.py +3 -0
  31. package/scripts/discussion_validate.py +8 -3
  32. package/scripts/dispatch_driver.py +233 -22
  33. package/scripts/git_guard.py +14 -5
  34. package/scripts/guard_hook.py +11 -1
  35. package/scripts/install.mjs +54 -1448
  36. package/scripts/install.py +155 -188
  37. package/scripts/integration.py +54 -28
  38. package/scripts/isolation.py +24 -6
  39. package/scripts/jev_review.py +138 -0
  40. package/scripts/lean_verification.py +3 -1
  41. package/scripts/loop_run.py +2 -0
  42. package/scripts/model_policy.py +279 -0
  43. package/scripts/path_config.py +248 -0
  44. package/scripts/pipeline_diagnose.py +15 -3
  45. package/scripts/pipeline_git.py +3 -0
  46. package/scripts/pipeline_state.py +117 -186
  47. package/scripts/pipeline_undo.py +14 -7
  48. package/scripts/promote_lookahead.py +33 -162
  49. package/scripts/review_panel.py +11 -0
  50. package/scripts/roadmap.py +326 -0
  51. package/scripts/runtime_store.py +278 -0
  52. package/scripts/skill-resources.json +182 -4
  53. package/scripts/state_checkpoint.py +114 -72
  54. package/scripts/state_promote.py +47 -44
  55. package/scripts/status_runtime.py +92 -6
  56. package/scripts/task_context.py +73 -0
  57. package/scripts/token_budget.py +40 -1
  58. package/scripts/workflow_run.py +128 -8
  59. package/scripts/worktree_paths.py +94 -0
  60. package/skills/gsd-path/BUILD.md +54 -575
  61. package/skills/gsd-path/DECIDE.md +4 -6
  62. package/skills/gsd-path/DEFINE.md +23 -21
  63. package/skills/gsd-path/DOCS-AUDIT.md +2 -0
  64. package/skills/gsd-path/INSPECT.md +32 -8
  65. package/skills/gsd-path/PLAN.md +29 -19
  66. package/skills/gsd-path/RESEARCH.md +5 -6
  67. package/skills/gsd-path/ROADMAP.md +24 -58
  68. package/skills/gsd-path/SHIP.md +29 -17
  69. package/skills/gsd-path/SKILL.md +46 -22
  70. package/skills/gsd-path/references/build-abandon.md +33 -0
  71. package/skills/gsd-path/references/build-native.md +502 -0
  72. package/skills/gsd-path/references/build-recovery.md +78 -0
  73. package/skills/gsd-path/references/coder.md +8 -3
  74. package/skills/gsd-path/references/config.md +57 -0
  75. package/skills/gsd-path/references/dispatch.md +12 -19
  76. package/skills/gsd-path/references/docs-auditor.md +14 -0
  77. package/skills/gsd-path/references/jev-review.md +82 -0
  78. package/skills/gsd-path/references/model-policy.md +139 -0
  79. package/skills/gsd-path/references/reviewer-final.md +21 -0
  80. package/skills/gsd-path/references/reviewer-panel.md +17 -0
  81. package/skills/gsd-path/references/reviewer-skeptic.md +20 -0
  82. package/skills/gsd-path/references/reviewer-wave.md +89 -0
  83. package/skills/gsd-path/references/reviewer.md +12 -148
  84. package/skills/gsd-path/references/runtime-selection.md +25 -0
  85. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  86. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  87. package/skills/gsd-path/scripts/_common.py +5 -0
  88. package/skills/gsd-path/scripts/archive_milestone.py +30 -124
  89. package/skills/gsd-path/scripts/bootstrap_repository.py +33 -4
  90. package/skills/gsd-path/scripts/build_recovery.py +292 -0
  91. package/skills/gsd-path/scripts/build_state.py +2 -0
  92. package/skills/gsd-path/scripts/check_docs_audit.py +7 -3
  93. package/skills/gsd-path/scripts/check_handoffs.py +7 -1
  94. package/skills/gsd-path/scripts/check_task_briefs.py +3 -0
  95. package/skills/gsd-path/scripts/detect_project.py +22 -3
  96. package/skills/gsd-path/scripts/discussion_records.py +3 -0
  97. package/skills/gsd-path/scripts/discussion_validate.py +8 -3
  98. package/skills/gsd-path/scripts/dispatch_driver.py +233 -22
  99. package/skills/gsd-path/scripts/git_guard.py +768 -0
  100. package/skills/gsd-path/scripts/integration.py +54 -28
  101. package/skills/gsd-path/scripts/isolation.py +24 -6
  102. package/skills/gsd-path/scripts/jev_review.py +138 -0
  103. package/skills/gsd-path/scripts/lean_verification.py +3 -1
  104. package/skills/gsd-path/scripts/loop_run.py +2 -0
  105. package/skills/gsd-path/scripts/model_policy.py +279 -0
  106. package/skills/gsd-path/scripts/path_config.py +248 -0
  107. package/skills/gsd-path/scripts/pipeline_git.py +3 -0
  108. package/skills/gsd-path/scripts/pipeline_state.py +117 -186
  109. package/skills/gsd-path/scripts/promote_lookahead.py +33 -162
  110. package/skills/gsd-path/scripts/review_panel.py +11 -0
  111. package/skills/gsd-path/scripts/roadmap.py +326 -0
  112. package/skills/gsd-path/scripts/state_checkpoint.py +114 -72
  113. package/skills/gsd-path/scripts/state_promote.py +47 -44
  114. package/skills/gsd-path/scripts/status_runtime.py +208 -0
  115. package/skills/gsd-path/scripts/task_context.py +73 -0
  116. package/skills/gsd-path/scripts/token_budget.py +40 -1
  117. package/skills/gsd-path/scripts/workflow_run.py +128 -8
  118. package/skills/gsd-path/scripts/worktree_paths.py +94 -0
  119. package/skills/gsd-path/templates/skeptic.md +1 -1
  120. package/skills/gsd-path/templates/task.md +2 -0
  121. package/skills/gsd-path/templates/wave-panel.md +1 -1
  122. package/skills/gsd-path/templates/wave-review.md +1 -1
  123. package/skills/gsd-path-build/SKILL.md +54 -575
  124. package/skills/gsd-path-build/references/build-abandon.md +33 -0
  125. package/skills/gsd-path-build/references/build-native.md +502 -0
  126. package/skills/gsd-path-build/references/build-recovery.md +78 -0
  127. package/skills/gsd-path-build/references/coder.md +8 -3
  128. package/skills/gsd-path-build/references/dispatch.md +12 -19
  129. package/skills/gsd-path-build/references/jev-review.md +82 -0
  130. package/skills/gsd-path-build/references/model-policy.md +139 -0
  131. package/skills/gsd-path-build/references/reviewer-final.md +21 -0
  132. package/skills/gsd-path-build/references/reviewer-panel.md +17 -0
  133. package/skills/gsd-path-build/references/reviewer-skeptic.md +20 -0
  134. package/skills/gsd-path-build/references/reviewer-wave.md +89 -0
  135. package/skills/gsd-path-build/references/reviewer.md +12 -148
  136. package/skills/gsd-path-build/references/runtime-selection.md +25 -0
  137. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  138. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  139. package/skills/gsd-path-build/scripts/_common.py +5 -0
  140. package/skills/gsd-path-build/scripts/archive_milestone.py +30 -124
  141. package/skills/gsd-path-build/scripts/build_recovery.py +292 -0
  142. package/skills/gsd-path-build/scripts/build_state.py +2 -0
  143. package/skills/gsd-path-build/scripts/check_handoffs.py +7 -1
  144. package/skills/gsd-path-build/scripts/check_task_briefs.py +3 -0
  145. package/skills/gsd-path-build/scripts/detect_project.py +1536 -0
  146. package/skills/gsd-path-build/scripts/discussion_records.py +3 -0
  147. package/skills/gsd-path-build/scripts/discussion_validate.py +8 -3
  148. package/skills/gsd-path-build/scripts/dispatch_driver.py +233 -22
  149. package/skills/gsd-path-build/scripts/integration.py +54 -28
  150. package/skills/gsd-path-build/scripts/isolation.py +24 -6
  151. package/skills/gsd-path-build/scripts/jev_review.py +138 -0
  152. package/skills/gsd-path-build/scripts/loop_run.py +2 -0
  153. package/skills/gsd-path-build/scripts/model_policy.py +279 -0
  154. package/skills/gsd-path-build/scripts/path_config.py +248 -0
  155. package/skills/gsd-path-build/scripts/pipeline_git.py +3 -0
  156. package/skills/gsd-path-build/scripts/pipeline_state.py +117 -186
  157. package/skills/gsd-path-build/scripts/promote_lookahead.py +286 -0
  158. package/skills/gsd-path-build/scripts/review_panel.py +11 -0
  159. package/skills/gsd-path-build/scripts/roadmap.py +326 -0
  160. package/skills/gsd-path-build/scripts/state_checkpoint.py +114 -72
  161. package/skills/gsd-path-build/scripts/state_promote.py +47 -44
  162. package/skills/gsd-path-build/scripts/task_context.py +73 -0
  163. package/skills/gsd-path-build/scripts/token_budget.py +40 -1
  164. package/skills/gsd-path-build/scripts/workflow_run.py +128 -8
  165. package/skills/gsd-path-build/scripts/worktree_paths.py +94 -0
  166. package/skills/gsd-path-build/templates/skeptic.md +1 -1
  167. package/skills/gsd-path-build/templates/task.md +2 -0
  168. package/skills/gsd-path-build/templates/wave-panel.md +1 -1
  169. package/skills/gsd-path-build/templates/wave-review.md +1 -1
  170. package/skills/gsd-path-decide/SKILL.md +4 -6
  171. package/skills/gsd-path-decide/references/dispatch.md +12 -19
  172. package/skills/gsd-path-decide/references/model-policy.md +139 -0
  173. package/skills/gsd-path-decide/references/runtime-selection.md +25 -0
  174. package/skills/gsd-path-decide/scripts/_common.py +5 -0
  175. package/skills/gsd-path-decide/scripts/archive_milestone.py +2171 -0
  176. package/skills/gsd-path-decide/scripts/build_recovery.py +292 -0
  177. package/skills/gsd-path-decide/scripts/check_handoffs.py +7 -1
  178. package/skills/gsd-path-decide/scripts/check_task_briefs.py +3 -0
  179. package/skills/gsd-path-decide/scripts/detect_project.py +1536 -0
  180. package/skills/gsd-path-decide/scripts/discussion_records.py +3 -0
  181. package/skills/gsd-path-decide/scripts/discussion_validate.py +1326 -0
  182. package/skills/gsd-path-decide/scripts/integration.py +1459 -0
  183. package/skills/gsd-path-decide/scripts/isolation.py +24 -6
  184. package/skills/gsd-path-decide/scripts/loop_run.py +839 -0
  185. package/skills/gsd-path-decide/scripts/model_policy.py +279 -0
  186. package/skills/gsd-path-decide/scripts/path_config.py +248 -0
  187. package/skills/gsd-path-decide/scripts/pipeline_git.py +3 -0
  188. package/skills/gsd-path-decide/scripts/pipeline_state.py +117 -186
  189. package/skills/gsd-path-decide/scripts/promote_lookahead.py +286 -0
  190. package/skills/gsd-path-decide/scripts/review_panel.py +564 -0
  191. package/skills/gsd-path-decide/scripts/roadmap.py +326 -0
  192. package/skills/gsd-path-decide/scripts/state_checkpoint.py +114 -72
  193. package/skills/gsd-path-decide/scripts/state_promote.py +47 -44
  194. package/skills/gsd-path-decide/scripts/worktree_paths.py +94 -0
  195. package/skills/gsd-path-define/SKILL.md +23 -21
  196. package/skills/gsd-path-define/references/build-recovery.md +78 -0
  197. package/skills/gsd-path-define/references/runtime-selection.md +25 -0
  198. package/skills/gsd-path-define/scripts/_common.py +5 -0
  199. package/skills/gsd-path-define/scripts/archive_milestone.py +2171 -0
  200. package/skills/gsd-path-define/scripts/build_recovery.py +292 -0
  201. package/skills/gsd-path-define/scripts/check_handoffs.py +7 -1
  202. package/skills/gsd-path-define/scripts/check_task_briefs.py +3 -0
  203. package/skills/gsd-path-define/scripts/detect_project.py +22 -3
  204. package/skills/gsd-path-define/scripts/discussion_records.py +3 -0
  205. package/skills/gsd-path-define/scripts/discussion_validate.py +1326 -0
  206. package/skills/gsd-path-define/scripts/integration.py +1459 -0
  207. package/skills/gsd-path-define/scripts/isolation.py +24 -6
  208. package/skills/gsd-path-define/scripts/loop_run.py +839 -0
  209. package/skills/gsd-path-define/scripts/model_policy.py +279 -0
  210. package/skills/gsd-path-define/scripts/path_config.py +248 -0
  211. package/skills/gsd-path-define/scripts/pipeline_git.py +3 -0
  212. package/skills/gsd-path-define/scripts/pipeline_state.py +117 -186
  213. package/skills/gsd-path-define/scripts/promote_lookahead.py +286 -0
  214. package/skills/gsd-path-define/scripts/review_panel.py +564 -0
  215. package/skills/gsd-path-define/scripts/roadmap.py +326 -0
  216. package/skills/gsd-path-define/scripts/state_checkpoint.py +114 -72
  217. package/skills/gsd-path-define/scripts/state_promote.py +47 -44
  218. package/skills/gsd-path-define/scripts/worktree_paths.py +94 -0
  219. package/skills/gsd-path-discuss/SKILL.md +2 -0
  220. package/skills/gsd-path-discuss/references/runtime-selection.md +25 -0
  221. package/skills/gsd-path-discuss/scripts/_common.py +5 -0
  222. package/skills/gsd-path-discuss/scripts/discussion_records.py +3 -0
  223. package/skills/gsd-path-discuss/scripts/loop_run.py +839 -0
  224. package/skills/gsd-path-discuss/scripts/model_policy.py +279 -0
  225. package/skills/gsd-path-discuss/scripts/path_config.py +248 -0
  226. package/skills/gsd-path-discuss/scripts/review_panel.py +564 -0
  227. package/skills/gsd-path-discuss/scripts/worktree_paths.py +94 -0
  228. package/skills/gsd-path-docs-audit/SKILL.md +2 -0
  229. package/skills/gsd-path-docs-audit/references/dispatch.md +12 -19
  230. package/skills/gsd-path-docs-audit/references/docs-auditor.md +14 -0
  231. package/skills/gsd-path-docs-audit/references/model-policy.md +139 -0
  232. package/skills/gsd-path-docs-audit/references/runtime-selection.md +25 -0
  233. package/skills/gsd-path-docs-audit/scripts/_common.py +5 -0
  234. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +2171 -0
  235. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +292 -0
  236. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +7 -3
  237. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +7 -1
  238. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +3 -0
  239. package/skills/gsd-path-docs-audit/scripts/detect_project.py +1536 -0
  240. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +3 -0
  241. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +1326 -0
  242. package/skills/gsd-path-docs-audit/scripts/integration.py +1459 -0
  243. package/skills/gsd-path-docs-audit/scripts/isolation.py +24 -6
  244. package/skills/gsd-path-docs-audit/scripts/loop_run.py +839 -0
  245. package/skills/gsd-path-docs-audit/scripts/model_policy.py +279 -0
  246. package/skills/gsd-path-docs-audit/scripts/path_config.py +248 -0
  247. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +3 -0
  248. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +117 -186
  249. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +286 -0
  250. package/skills/gsd-path-docs-audit/scripts/review_panel.py +564 -0
  251. package/skills/gsd-path-docs-audit/scripts/roadmap.py +326 -0
  252. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +114 -72
  253. package/skills/gsd-path-docs-audit/scripts/state_promote.py +47 -44
  254. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +94 -0
  255. package/skills/gsd-path-forensics/SKILL.md +9 -5
  256. package/skills/gsd-path-forensics/references/runtime-selection.md +25 -0
  257. package/skills/gsd-path-forensics/scripts/_common.py +5 -0
  258. package/skills/gsd-path-forensics/scripts/discussion_records.py +3 -0
  259. package/skills/gsd-path-forensics/scripts/loop_run.py +839 -0
  260. package/skills/gsd-path-forensics/scripts/model_policy.py +279 -0
  261. package/skills/gsd-path-forensics/scripts/path_config.py +248 -0
  262. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +15 -3
  263. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +14 -7
  264. package/skills/gsd-path-forensics/scripts/review_panel.py +564 -0
  265. package/skills/gsd-path-forensics/scripts/worktree_paths.py +94 -0
  266. package/skills/gsd-path-inspect/SKILL.md +32 -8
  267. package/skills/gsd-path-inspect/references/dispatch.md +12 -19
  268. package/skills/gsd-path-inspect/references/docs-auditor.md +14 -0
  269. package/skills/gsd-path-inspect/references/model-policy.md +139 -0
  270. package/skills/gsd-path-inspect/references/runtime-selection.md +25 -0
  271. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  272. package/skills/gsd-path-inspect/scripts/_common.py +5 -0
  273. package/skills/gsd-path-inspect/scripts/archive_milestone.py +2171 -0
  274. package/skills/gsd-path-inspect/scripts/build_recovery.py +292 -0
  275. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +7 -3
  276. package/skills/gsd-path-inspect/scripts/check_handoffs.py +7 -1
  277. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +3 -0
  278. package/skills/gsd-path-inspect/scripts/detect_project.py +22 -3
  279. package/skills/gsd-path-inspect/scripts/discussion_records.py +3 -0
  280. package/skills/gsd-path-inspect/scripts/discussion_validate.py +1326 -0
  281. package/skills/gsd-path-inspect/scripts/integration.py +1459 -0
  282. package/skills/gsd-path-inspect/scripts/isolation.py +24 -6
  283. package/skills/gsd-path-inspect/scripts/loop_run.py +839 -0
  284. package/skills/gsd-path-inspect/scripts/model_policy.py +279 -0
  285. package/skills/gsd-path-inspect/scripts/path_config.py +248 -0
  286. package/skills/gsd-path-inspect/scripts/pipeline_git.py +3 -0
  287. package/skills/gsd-path-inspect/scripts/pipeline_state.py +117 -186
  288. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +286 -0
  289. package/skills/gsd-path-inspect/scripts/review_panel.py +564 -0
  290. package/skills/gsd-path-inspect/scripts/roadmap.py +326 -0
  291. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +114 -72
  292. package/skills/gsd-path-inspect/scripts/state_promote.py +47 -44
  293. package/skills/gsd-path-inspect/scripts/workflow_run.py +237 -0
  294. package/skills/gsd-path-inspect/scripts/worktree_paths.py +94 -0
  295. package/skills/gsd-path-loop/SKILL.md +2 -0
  296. package/skills/gsd-path-loop/references/runtime-selection.md +25 -0
  297. package/skills/gsd-path-loop/scripts/_common.py +5 -0
  298. package/skills/gsd-path-loop/scripts/discussion_records.py +3 -0
  299. package/skills/gsd-path-loop/scripts/loop_run.py +2 -0
  300. package/skills/gsd-path-loop/scripts/model_policy.py +279 -0
  301. package/skills/gsd-path-loop/scripts/path_config.py +248 -0
  302. package/skills/gsd-path-loop/scripts/review_panel.py +564 -0
  303. package/skills/gsd-path-loop/scripts/worktree_paths.py +94 -0
  304. package/skills/gsd-path-migrate/SKILL.md +2 -0
  305. package/skills/gsd-path-migrate/references/runtime-selection.md +25 -0
  306. package/skills/gsd-path-migrate/scripts/_common.py +185 -0
  307. package/skills/gsd-path-migrate/scripts/discussion_records.py +3 -0
  308. package/skills/gsd-path-migrate/scripts/loop_run.py +839 -0
  309. package/skills/gsd-path-migrate/scripts/model_policy.py +279 -0
  310. package/skills/gsd-path-migrate/scripts/path_config.py +248 -0
  311. package/skills/gsd-path-migrate/scripts/review_panel.py +564 -0
  312. package/skills/gsd-path-plan/SKILL.md +29 -19
  313. package/skills/gsd-path-plan/references/build-recovery.md +78 -0
  314. package/skills/gsd-path-plan/references/dispatch.md +12 -19
  315. package/skills/gsd-path-plan/references/model-policy.md +139 -0
  316. package/skills/gsd-path-plan/references/runtime-selection.md +25 -0
  317. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  318. package/skills/gsd-path-plan/scripts/_common.py +5 -0
  319. package/skills/gsd-path-plan/scripts/archive_milestone.py +2171 -0
  320. package/skills/gsd-path-plan/scripts/build_recovery.py +292 -0
  321. package/skills/gsd-path-plan/scripts/check_handoffs.py +7 -1
  322. package/skills/gsd-path-plan/scripts/check_task_briefs.py +3 -0
  323. package/skills/gsd-path-plan/scripts/detect_project.py +1536 -0
  324. package/skills/gsd-path-plan/scripts/discussion_records.py +3 -0
  325. package/skills/gsd-path-plan/scripts/discussion_validate.py +1326 -0
  326. package/skills/gsd-path-plan/scripts/integration.py +1459 -0
  327. package/skills/gsd-path-plan/scripts/isolation.py +24 -6
  328. package/skills/gsd-path-plan/scripts/loop_run.py +839 -0
  329. package/skills/gsd-path-plan/scripts/model_policy.py +279 -0
  330. package/skills/gsd-path-plan/scripts/path_config.py +248 -0
  331. package/skills/gsd-path-plan/scripts/pipeline_git.py +3 -0
  332. package/skills/gsd-path-plan/scripts/pipeline_state.py +117 -186
  333. package/skills/gsd-path-plan/scripts/promote_lookahead.py +286 -0
  334. package/skills/gsd-path-plan/scripts/review_panel.py +11 -0
  335. package/skills/gsd-path-plan/scripts/roadmap.py +326 -0
  336. package/skills/gsd-path-plan/scripts/state_checkpoint.py +114 -72
  337. package/skills/gsd-path-plan/scripts/state_promote.py +47 -44
  338. package/skills/gsd-path-plan/scripts/workflow_run.py +128 -8
  339. package/skills/gsd-path-plan/scripts/worktree_paths.py +94 -0
  340. package/skills/gsd-path-plan/templates/task.md +2 -0
  341. package/skills/gsd-path-research/SKILL.md +5 -6
  342. package/skills/gsd-path-research/references/dispatch.md +12 -19
  343. package/skills/gsd-path-research/references/model-policy.md +139 -0
  344. package/skills/gsd-path-research/references/runtime-selection.md +25 -0
  345. package/skills/gsd-path-research/scripts/_common.py +5 -0
  346. package/skills/gsd-path-research/scripts/archive_milestone.py +2171 -0
  347. package/skills/gsd-path-research/scripts/build_recovery.py +292 -0
  348. package/skills/gsd-path-research/scripts/check_handoffs.py +7 -1
  349. package/skills/gsd-path-research/scripts/check_task_briefs.py +3 -0
  350. package/skills/gsd-path-research/scripts/detect_project.py +1536 -0
  351. package/skills/gsd-path-research/scripts/discussion_records.py +3 -0
  352. package/skills/gsd-path-research/scripts/discussion_validate.py +1326 -0
  353. package/skills/gsd-path-research/scripts/integration.py +1459 -0
  354. package/skills/gsd-path-research/scripts/isolation.py +24 -6
  355. package/skills/gsd-path-research/scripts/loop_run.py +839 -0
  356. package/skills/gsd-path-research/scripts/model_policy.py +279 -0
  357. package/skills/gsd-path-research/scripts/path_config.py +248 -0
  358. package/skills/gsd-path-research/scripts/pipeline_git.py +3 -0
  359. package/skills/gsd-path-research/scripts/pipeline_state.py +117 -186
  360. package/skills/gsd-path-research/scripts/promote_lookahead.py +286 -0
  361. package/skills/gsd-path-research/scripts/review_panel.py +564 -0
  362. package/skills/gsd-path-research/scripts/roadmap.py +326 -0
  363. package/skills/gsd-path-research/scripts/state_checkpoint.py +114 -72
  364. package/skills/gsd-path-research/scripts/state_promote.py +47 -44
  365. package/skills/gsd-path-research/scripts/worktree_paths.py +94 -0
  366. package/skills/gsd-path-roadmap/SKILL.md +24 -58
  367. package/skills/gsd-path-roadmap/references/dispatch.md +12 -19
  368. package/skills/gsd-path-roadmap/references/model-policy.md +139 -0
  369. package/skills/gsd-path-roadmap/references/runtime-selection.md +25 -0
  370. package/skills/gsd-path-roadmap/scripts/_common.py +5 -0
  371. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +30 -124
  372. package/skills/gsd-path-roadmap/scripts/build_recovery.py +292 -0
  373. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +7 -1
  374. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +3 -0
  375. package/skills/gsd-path-roadmap/scripts/detect_project.py +22 -3
  376. package/skills/gsd-path-roadmap/scripts/discussion_records.py +3 -0
  377. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +8 -3
  378. package/skills/gsd-path-roadmap/scripts/integration.py +54 -28
  379. package/skills/gsd-path-roadmap/scripts/isolation.py +24 -6
  380. package/skills/gsd-path-roadmap/scripts/loop_run.py +839 -0
  381. package/skills/gsd-path-roadmap/scripts/model_policy.py +279 -0
  382. package/skills/gsd-path-roadmap/scripts/path_config.py +248 -0
  383. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +3 -0
  384. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +117 -186
  385. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +33 -162
  386. package/skills/gsd-path-roadmap/scripts/review_panel.py +11 -0
  387. package/skills/gsd-path-roadmap/scripts/roadmap.py +326 -0
  388. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +114 -72
  389. package/skills/gsd-path-roadmap/scripts/state_promote.py +47 -44
  390. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +94 -0
  391. package/skills/gsd-path-ship/SKILL.md +29 -17
  392. package/skills/gsd-path-ship/references/dispatch.md +12 -19
  393. package/skills/gsd-path-ship/references/jev-review.md +82 -0
  394. package/skills/gsd-path-ship/references/model-policy.md +139 -0
  395. package/skills/gsd-path-ship/references/reviewer-final.md +21 -0
  396. package/skills/gsd-path-ship/references/reviewer-panel.md +17 -0
  397. package/skills/gsd-path-ship/references/reviewer-skeptic.md +20 -0
  398. package/skills/gsd-path-ship/references/reviewer-wave.md +89 -0
  399. package/skills/gsd-path-ship/references/reviewer.md +12 -148
  400. package/skills/gsd-path-ship/references/runtime-selection.md +25 -0
  401. package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  402. package/skills/gsd-path-ship/scripts/_common.py +5 -0
  403. package/skills/gsd-path-ship/scripts/archive_milestone.py +30 -124
  404. package/skills/gsd-path-ship/scripts/build_recovery.py +292 -0
  405. package/skills/gsd-path-ship/scripts/build_state.py +2 -0
  406. package/skills/gsd-path-ship/scripts/check_handoffs.py +7 -1
  407. package/skills/gsd-path-ship/scripts/check_task_briefs.py +3 -0
  408. package/skills/gsd-path-ship/scripts/detect_project.py +1536 -0
  409. package/skills/gsd-path-ship/scripts/discussion_records.py +3 -0
  410. package/skills/gsd-path-ship/scripts/discussion_validate.py +8 -3
  411. package/skills/gsd-path-ship/scripts/integration.py +54 -28
  412. package/skills/gsd-path-ship/scripts/isolation.py +24 -6
  413. package/skills/gsd-path-ship/scripts/jev_review.py +138 -0
  414. package/skills/gsd-path-ship/scripts/lean_verification.py +3 -1
  415. package/skills/gsd-path-ship/scripts/loop_run.py +839 -0
  416. package/skills/gsd-path-ship/scripts/model_policy.py +279 -0
  417. package/skills/gsd-path-ship/scripts/path_config.py +248 -0
  418. package/skills/gsd-path-ship/scripts/pipeline_git.py +3 -0
  419. package/skills/gsd-path-ship/scripts/pipeline_state.py +117 -186
  420. package/skills/gsd-path-ship/scripts/promote_lookahead.py +286 -0
  421. package/skills/gsd-path-ship/scripts/review_panel.py +11 -0
  422. package/skills/gsd-path-ship/scripts/roadmap.py +326 -0
  423. package/skills/gsd-path-ship/scripts/state_checkpoint.py +114 -72
  424. package/skills/gsd-path-ship/scripts/state_promote.py +47 -44
  425. package/skills/gsd-path-ship/scripts/workflow_run.py +128 -8
  426. package/skills/gsd-path-ship/scripts/worktree_paths.py +94 -0
  427. package/skills/gsd-path-ship/templates/wave-review.md +1 -1
  428. package/skills/gsd-path-undo/SKILL.md +2 -0
  429. package/skills/gsd-path-undo/references/runtime-selection.md +25 -0
  430. package/skills/gsd-path-undo/scripts/_common.py +5 -0
  431. package/skills/gsd-path-undo/scripts/discussion_records.py +3 -0
  432. package/skills/gsd-path-undo/scripts/loop_run.py +839 -0
  433. package/skills/gsd-path-undo/scripts/model_policy.py +279 -0
  434. package/skills/gsd-path-undo/scripts/path_config.py +248 -0
  435. package/skills/gsd-path-undo/scripts/pipeline_undo.py +14 -7
  436. package/skills/gsd-path-undo/scripts/review_panel.py +564 -0
  437. package/skills/gsd-path-undo/scripts/worktree_paths.py +94 -0
  438. package/skills/path/BUILD.md +54 -575
  439. package/skills/path/DECIDE.md +4 -6
  440. package/skills/path/DEFINE.md +23 -21
  441. package/skills/path/DOCS-AUDIT.md +2 -0
  442. package/skills/path/INSPECT.md +32 -8
  443. package/skills/path/PLAN.md +29 -19
  444. package/skills/path/RESEARCH.md +5 -6
  445. package/skills/path/ROADMAP.md +24 -58
  446. package/skills/path/SHIP.md +29 -17
  447. package/skills/path/SKILL.md +46 -22
  448. package/skills/path/references/build-abandon.md +33 -0
  449. package/skills/path/references/build-native.md +502 -0
  450. package/skills/path/references/build-recovery.md +78 -0
  451. package/skills/path/references/coder.md +8 -3
  452. package/skills/path/references/config.md +57 -0
  453. package/skills/path/references/dispatch.md +12 -19
  454. package/skills/path/references/docs-auditor.md +14 -0
  455. package/skills/path/references/jev-review.md +82 -0
  456. package/skills/path/references/model-policy.md +139 -0
  457. package/skills/path/references/reviewer-final.md +21 -0
  458. package/skills/path/references/reviewer-panel.md +17 -0
  459. package/skills/path/references/reviewer-skeptic.md +20 -0
  460. package/skills/path/references/reviewer-wave.md +89 -0
  461. package/skills/path/references/reviewer.md +12 -148
  462. package/skills/path/references/runtime-selection.md +25 -0
  463. package/skills/path/scripts/_common.py +5 -0
  464. package/skills/path/scripts/archive_milestone.py +30 -124
  465. package/skills/path/scripts/bootstrap_repository.py +33 -4
  466. package/skills/path/scripts/build_recovery.py +292 -0
  467. package/skills/path/scripts/build_state.py +2 -0
  468. package/skills/path/scripts/check_docs_audit.py +7 -3
  469. package/skills/path/scripts/check_handoffs.py +7 -1
  470. package/skills/path/scripts/check_task_briefs.py +3 -0
  471. package/skills/path/scripts/detect_project.py +22 -3
  472. package/skills/path/scripts/discussion_records.py +3 -0
  473. package/skills/path/scripts/discussion_validate.py +8 -3
  474. package/skills/path/scripts/dispatch_driver.py +233 -22
  475. package/skills/path/scripts/git_guard.py +768 -0
  476. package/skills/path/scripts/integration.py +54 -28
  477. package/skills/path/scripts/isolation.py +24 -6
  478. package/skills/path/scripts/jev_review.py +138 -0
  479. package/skills/path/scripts/lean_verification.py +3 -1
  480. package/skills/path/scripts/loop_run.py +2 -0
  481. package/skills/path/scripts/model_policy.py +279 -0
  482. package/skills/path/scripts/path_config.py +248 -0
  483. package/skills/path/scripts/pipeline_git.py +3 -0
  484. package/skills/path/scripts/pipeline_state.py +117 -186
  485. package/skills/path/scripts/promote_lookahead.py +33 -162
  486. package/skills/path/scripts/review_panel.py +11 -0
  487. package/skills/path/scripts/roadmap.py +326 -0
  488. package/skills/path/scripts/state_checkpoint.py +114 -72
  489. package/skills/path/scripts/state_promote.py +47 -44
  490. package/skills/path/scripts/status_runtime.py +208 -0
  491. package/skills/path/scripts/task_context.py +73 -0
  492. package/skills/path/scripts/token_budget.py +40 -1
  493. package/skills/path/scripts/workflow_run.py +128 -8
  494. package/skills/path/scripts/worktree_paths.py +94 -0
  495. package/skills/path/templates/skeptic.md +1 -1
  496. package/skills/path/templates/task.md +2 -0
  497. package/skills/path/templates/wave-panel.md +1 -1
  498. package/skills/path/templates/wave-review.md +1 -1
@@ -0,0 +1,1459 @@
1
+ #!/usr/bin/env python3
2
+ # gsd-path project runtime
3
+ """Own milestone integration, publication, tags, and completion proof."""
4
+
5
+ import sys
6
+
7
+ # Runtime helpers must not modify their immutable installation.
8
+ sys.dont_write_bytecode = True
9
+
10
+ import json
11
+ import re
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import Optional
14
+
15
+ try:
16
+ from pipeline_git import (
17
+ bound_branch_name,
18
+ default_branch_name,
19
+ integrate_commit_body,
20
+ integrate_subject,
21
+ is_bound_branch,
22
+ milestone_id,
23
+ milestone_number,
24
+ ship_subject,
25
+ )
26
+ from pipeline_state import PipelineState
27
+ from isolation import publication_base
28
+ import _common
29
+ import worktree_paths
30
+ except ImportError: # pragma: no cover - package import used by tests
31
+ from scripts.pipeline_git import (
32
+ bound_branch_name,
33
+ default_branch_name,
34
+ integrate_commit_body,
35
+ integrate_subject,
36
+ is_bound_branch,
37
+ milestone_id,
38
+ milestone_number,
39
+ ship_subject,
40
+ )
41
+ from scripts.pipeline_state import PipelineState
42
+ from scripts.isolation import publication_base
43
+ from scripts import _common
44
+ from scripts import worktree_paths
45
+
46
+
47
+ if __package__:
48
+ from . import archive_milestone
49
+ from .archive_milestone import ArchiveError
50
+ else:
51
+ import archive_milestone
52
+ from archive_milestone import ArchiveError
53
+
54
+
55
+ PR_CREDIT_LINE = (
56
+ "PR prepared with [GSD Path](https://github.com/open-gsd/gsd-path)."
57
+ )
58
+ GITHUB_HOST = "github.com"
59
+
60
+
61
+ run_git = _common.run_git
62
+ run_command = _common.run_command
63
+
64
+
65
+ def resolve_remote_default(project: Path) -> str:
66
+ result = run_git(project, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
67
+ remote_ref = result.stdout.strip()
68
+ prefix = "refs/remotes/"
69
+ if result.returncode != 0 or not remote_ref.startswith(prefix):
70
+ raise ArchiveError("origin/HEAD is unresolved; fetch before validating integration")
71
+ return remote_ref.removeprefix(prefix)
72
+
73
+
74
+ def live_remote_ref(project: Path, ref: str) -> Optional[str]:
75
+ result = run_git(
76
+ project,
77
+ "ls-remote",
78
+ "--exit-code",
79
+ "--refs",
80
+ "origin",
81
+ ref,
82
+ )
83
+ if result.returncode == 2:
84
+ return None
85
+ if result.returncode != 0:
86
+ raise ArchiveError(f"could not inspect {ref} on origin: git ls-remote failed")
87
+ rows = [line.split("\t", 1) for line in result.stdout.splitlines() if line]
88
+ if len(rows) != 1 or len(rows[0]) != 2 or rows[0][1] != ref:
89
+ raise ArchiveError(f"origin returned ambiguous ref data for {ref}")
90
+ return rows[0][0]
91
+
92
+
93
+ def publishable_bound_branch(project: Path, branch: str, ship_commit: str) -> Optional[str]:
94
+ """The live origin SHA the bound ref may move from: absent, the ship
95
+ commit, or an owner-adopted rebased head proven by its archived receipt."""
96
+ remote_sha = live_remote_ref(project, f"refs/heads/{branch}")
97
+ if remote_sha is None or remote_sha == ship_commit:
98
+ return remote_sha
99
+ try:
100
+ approved_base = publication_base(project, branch, ship_commit)
101
+ except (RuntimeError, OSError) as error:
102
+ raise ArchiveError(f"rebase publication proof failed: {error}") from error
103
+ if remote_sha != approved_base:
104
+ raise ArchiveError(f"origin/{branch} moved or collides: {remote_sha} != {ship_commit}")
105
+ return remote_sha
106
+
107
+
108
+ def publish_bound_branch(project: Path, branch: str, ship_commit: str) -> None:
109
+ remote_ref = f"refs/heads/{branch}"
110
+ remote_sha = publishable_bound_branch(project, branch, ship_commit)
111
+ if remote_sha != ship_commit:
112
+ # An absent ref carries an empty lease; an adopted head leases its exact SHA.
113
+ archive_milestone.require_git_success(
114
+ run_git(
115
+ project,
116
+ "push",
117
+ f"--force-with-lease={remote_ref}:{remote_sha or ''}",
118
+ "origin",
119
+ f"{ship_commit}:{remote_ref}",
120
+ ),
121
+ "publish bound branch under its observed lease",
122
+ )
123
+
124
+ confirmed_sha = live_remote_ref(project, remote_ref)
125
+ if confirmed_sha != ship_commit:
126
+ raise ArchiveError(f"origin/{branch} does not equal the ship commit after publish")
127
+ archive_milestone.require_git_success(
128
+ run_git(
129
+ project,
130
+ "update-ref",
131
+ f"refs/remotes/origin/{branch}",
132
+ ship_commit,
133
+ ),
134
+ "refresh local bound-branch ref",
135
+ )
136
+
137
+
138
+ def refresh_origin(repo: Path) -> dict:
139
+ """Fetch origin, refresh origin/HEAD, and mirror milestone tags."""
140
+ project = repo.resolve()
141
+ archive_milestone.require_git_success(run_git(project, "fetch", "--prune", "origin"), "fetch origin")
142
+ archive_milestone.require_git_success(
143
+ run_git(project, "remote", "set-head", "origin", "--auto"),
144
+ "refresh origin/HEAD",
145
+ )
146
+ mirrored = run_git(
147
+ project,
148
+ "fetch",
149
+ "--prune",
150
+ "origin",
151
+ "refs/tags/milestone/*:refs/remotes/origin/tags/milestone/*",
152
+ )
153
+ if mirrored.returncode != 0:
154
+ detail = (mirrored.stderr or mirrored.stdout).strip()
155
+ if "couldn't find remote ref" not in detail.casefold() and detail:
156
+ raise ArchiveError(f"could not refresh published milestone tags: {detail}")
157
+ remote_default = resolve_remote_default(project)
158
+ return {"remote_default": remote_default}
159
+
160
+
161
+ def optional_ref(project: Path, ref: str) -> Optional[str]:
162
+ result = run_git(project, "rev-parse", "--verify", "--quiet", ref)
163
+ return result.stdout.strip() if result.returncode == 0 else None
164
+
165
+
166
+ def integration_names(project: Path, archive_name: str, *, pin: bool = False) -> tuple[str, Path]:
167
+ identifier = milestone_id(milestone_number(archive_name))
168
+ branch = f"gsd-path-integrate/{identifier}"
169
+ try:
170
+ worktree = worktree_paths.worktree_path(project, "integrate", identifier, pin=pin)
171
+ except (ValueError, OSError) as error:
172
+ raise ArchiveError(str(error)) from error
173
+ return branch, worktree
174
+
175
+
176
+ def registered_worktree(project: Path, branch: str) -> Optional[Path]:
177
+ output = archive_milestone.require_git_success(
178
+ run_git(project, "worktree", "list", "--porcelain"),
179
+ "inspect integration worktrees",
180
+ )
181
+ branch_ref = f"branch refs/heads/{branch}"
182
+ matches = []
183
+ for record in output.split("\n\n"):
184
+ lines = record.splitlines()
185
+ if branch_ref not in lines:
186
+ continue
187
+ worktree_lines = [
188
+ line.removeprefix("worktree ")
189
+ for line in lines
190
+ if line.startswith("worktree ")
191
+ ]
192
+ if len(worktree_lines) != 1:
193
+ raise ArchiveError(
194
+ f"integration branch {branch} has a malformed worktree record"
195
+ )
196
+ matches.append(Path(worktree_lines[0]).resolve())
197
+ if len(matches) > 1:
198
+ raise ArchiveError(f"integration branch {branch} is checked out more than once")
199
+ return matches[0] if matches else None
200
+
201
+
202
+ def remove_registered_worktree(project: Path, branch: str, expected_path: Path) -> None:
203
+ registered = registered_worktree(project, branch)
204
+ if registered is None:
205
+ if expected_path.exists() or expected_path.is_symlink():
206
+ raise ArchiveError(f"unregistered integration worktree path exists: {expected_path}")
207
+ return
208
+ if registered != expected_path.resolve():
209
+ raise ArchiveError(
210
+ f"integration branch {branch} is checked out at unexpected path: {registered}"
211
+ )
212
+ status = archive_milestone.require_git_success(
213
+ run_git(registered, "status", "--porcelain", "--untracked-files=all"),
214
+ "inspect integration worktree status",
215
+ )
216
+ if status:
217
+ raise ArchiveError("integration worktree is dirty; refusing cleanup")
218
+ archive_milestone.require_git_success(
219
+ run_git(project, "worktree", "remove", str(registered)),
220
+ "remove integration worktree",
221
+ )
222
+
223
+
224
+ def delete_integration_branch(project: Path, branch: str, expected: str) -> None:
225
+ ref = f"refs/heads/{branch}"
226
+ actual = optional_ref(project, ref)
227
+ if actual is None:
228
+ return
229
+ if actual != expected:
230
+ raise ArchiveError(f"integration branch {branch} moved unexpectedly")
231
+ archive_milestone.require_git_success(
232
+ run_git(project, "update-ref", "-d", ref, expected),
233
+ "delete integration branch",
234
+ )
235
+
236
+
237
+ def require_generated_integration_commit(
238
+ project: Path,
239
+ commit: str,
240
+ archive_path: str,
241
+ archive_name: str,
242
+ ship_commit: str,
243
+ default_name: str,
244
+ bound_branch: str,
245
+ first_parent: Optional[str] = None,
246
+ ) -> None:
247
+ subject = archive_milestone.require_git_success(
248
+ run_git(project, "show", "-s", "--format=%s", commit),
249
+ "inspect integration commit subject",
250
+ )
251
+ expected_subject = integrate_subject(archive_name, default_name)
252
+ if subject != expected_subject:
253
+ raise ArchiveError(f"integration commit subject must be {expected_subject!r}")
254
+ archive_milestone.require_canonical_commit_body(
255
+ project,
256
+ commit,
257
+ expected_subject,
258
+ integrate_commit_body(archive_path, ship_commit, default_name, bound_branch),
259
+ "integration",
260
+ )
261
+ parents = archive_milestone.require_git_success(
262
+ run_git(project, "rev-list", "--parents", "-n", "1", commit),
263
+ "inspect integration merge parents",
264
+ ).split()
265
+ if len(parents) != 3 or parents[2] != ship_commit:
266
+ raise ArchiveError("integration commit is not the canonical merge of the ship commit")
267
+ if first_parent is not None and parents[1] != first_parent:
268
+ raise ArchiveError("integration merge was created from a stale remote default")
269
+
270
+
271
+ def discard_unpublished_stale_integration(
272
+ project: Path,
273
+ branch: str,
274
+ merge_commit: str,
275
+ remote_default_sha: str,
276
+ default_name: str,
277
+ archive_name: str,
278
+ bound_branch: str,
279
+ ) -> None:
280
+ live_default = live_remote_ref(project, f"refs/heads/{default_name}")
281
+ if live_default != remote_default_sha:
282
+ raise ArchiveError(f"origin/{default_name} advanced again; rerun integrate")
283
+ published = run_git(
284
+ project, "merge-base", "--is-ancestor", merge_commit, remote_default_sha
285
+ )
286
+ if published.returncode == 0:
287
+ raise ArchiveError("stale integration merge is already published on origin/main")
288
+ if published.returncode != 1:
289
+ archive_milestone.require_git_success(published, "inspect stale integration publication")
290
+
291
+ bound_ref = f"refs/heads/{bound_branch}"
292
+ if live_remote_ref(project, bound_ref) is not None:
293
+ raise ArchiveError(
294
+ "stale integration recovery requires the remote bound branch to be absent"
295
+ )
296
+ tag_name = f"milestone/{archive_name}"
297
+ published_tag_ref = f"refs/tags/{tag_name}"
298
+ if live_remote_ref(project, published_tag_ref) is not None:
299
+ raise ArchiveError(
300
+ "stale integration recovery requires the remote milestone tag to be absent"
301
+ )
302
+
303
+ local_tag_ref = f"refs/tags/{tag_name}"
304
+ local_tag_object = optional_ref(project, local_tag_ref)
305
+ if local_tag_object is not None:
306
+ require_annotated_tag(
307
+ project,
308
+ local_tag_ref,
309
+ merge_commit,
310
+ f"stale milestone tag {tag_name}",
311
+ )
312
+ archive_milestone.require_git_success(
313
+ run_git(project, "update-ref", "-d", local_tag_ref, local_tag_object),
314
+ "delete unpublished stale milestone tag",
315
+ )
316
+
317
+ tracking_tag_ref = f"refs/remotes/origin/tags/{tag_name}"
318
+ tracking_tag_object = optional_ref(project, tracking_tag_ref)
319
+ if tracking_tag_object is not None:
320
+ if local_tag_object is None or tracking_tag_object != local_tag_object:
321
+ raise ArchiveError("local published-tag tracking ref is not safely recoverable")
322
+ archive_milestone.require_git_success(
323
+ run_git(
324
+ project,
325
+ "update-ref",
326
+ "-d",
327
+ tracking_tag_ref,
328
+ tracking_tag_object,
329
+ ),
330
+ "delete stale published-tag tracking ref",
331
+ )
332
+ delete_integration_branch(project, branch, merge_commit)
333
+
334
+
335
+ def create_integration_merge(
336
+ project: Path,
337
+ worktree: Path,
338
+ branch: str,
339
+ remote_default_sha: str,
340
+ archive_path: str,
341
+ archive_name: str,
342
+ ship_commit: str,
343
+ default_name: str,
344
+ bound_branch: str,
345
+ ) -> tuple[str, bool]:
346
+ if worktree.exists() or worktree.is_symlink():
347
+ raise ArchiveError(f"integration worktree path already exists: {worktree}")
348
+ branch_ref = f"refs/heads/{branch}"
349
+ branch_tip = optional_ref(project, branch_ref)
350
+ if branch_tip is None:
351
+ add = run_git(
352
+ project,
353
+ "worktree",
354
+ "add",
355
+ "--quiet",
356
+ "-b",
357
+ branch,
358
+ str(worktree),
359
+ remote_default_sha,
360
+ )
361
+ elif branch_tip == remote_default_sha:
362
+ add = run_git(project, "worktree", "add", "--quiet", str(worktree), branch)
363
+ else:
364
+ require_generated_integration_commit(
365
+ project,
366
+ branch_tip,
367
+ archive_path,
368
+ archive_name,
369
+ ship_commit,
370
+ default_name,
371
+ bound_branch,
372
+ )
373
+ parents = archive_milestone.require_git_success(
374
+ run_git(project, "rev-list", "--parents", "-n", "1", branch_tip),
375
+ "inspect existing integration merge parents",
376
+ ).split()
377
+ if parents[1] == remote_default_sha:
378
+ return branch_tip, False
379
+ discard_unpublished_stale_integration(
380
+ project,
381
+ branch,
382
+ branch_tip,
383
+ remote_default_sha,
384
+ default_name,
385
+ archive_name,
386
+ bound_branch,
387
+ )
388
+ add = run_git(
389
+ project,
390
+ "worktree",
391
+ "add",
392
+ "--quiet",
393
+ "-b",
394
+ branch,
395
+ str(worktree),
396
+ remote_default_sha,
397
+ )
398
+ archive_milestone.require_git_success(add, "create integration worktree")
399
+
400
+ merge = run_git(
401
+ worktree,
402
+ "merge",
403
+ "--no-ff",
404
+ "-m",
405
+ integrate_subject(archive_name, default_name),
406
+ "-m",
407
+ integrate_commit_body(archive_path, ship_commit, default_name, bound_branch),
408
+ ship_commit,
409
+ )
410
+ if merge.returncode != 0:
411
+ conflicted = optional_ref(worktree, "MERGE_HEAD") is not None
412
+ detail = (merge.stderr or merge.stdout).strip()
413
+ conflicts = ""
414
+ if conflicted:
415
+ conflicts = run_git(
416
+ worktree, "diff", "--name-only", "--diff-filter=U"
417
+ ).stdout.strip()
418
+ archive_milestone.require_git_success(
419
+ run_git(worktree, "merge", "--abort"),
420
+ "abort integration merge",
421
+ )
422
+ remove_registered_worktree(project, branch, worktree)
423
+ delete_integration_branch(project, branch, remote_default_sha)
424
+ if conflicted:
425
+ raise ArchiveError(
426
+ "integration merge conflicted; Git aborted it without resolving files: "
427
+ + ", ".join(conflicts.splitlines())
428
+ + "; closed milestone remains unchanged; run gsd-path-forensics and "
429
+ "obtain a user-approved integration recovery plan; do not merge into or re-ship the bound branch"
430
+ )
431
+ raise ArchiveError(f"create integration merge failed: {detail}")
432
+
433
+ merge_commit = archive_milestone.require_git_success(
434
+ run_git(worktree, "rev-parse", "HEAD"), "resolve integration merge"
435
+ )
436
+ require_generated_integration_commit(
437
+ project,
438
+ merge_commit,
439
+ archive_path,
440
+ archive_name,
441
+ ship_commit,
442
+ default_name,
443
+ bound_branch,
444
+ remote_default_sha,
445
+ )
446
+ return merge_commit, True
447
+
448
+
449
+ def require_annotated_tag(
450
+ project: Path,
451
+ ref: str,
452
+ merge_commit: str,
453
+ label: str,
454
+ message: Optional[str] = None,
455
+ ) -> str:
456
+ object_id = optional_ref(project, ref)
457
+ if object_id is None:
458
+ raise ArchiveError(f"missing {label}")
459
+ tag_type = archive_milestone.require_git_success(
460
+ run_git(project, "cat-file", "-t", ref), f"inspect {label}"
461
+ )
462
+ if tag_type != "tag":
463
+ raise ArchiveError(f"{label} must be annotated")
464
+ target = archive_milestone.require_git_success(
465
+ run_git(project, "rev-parse", f"{ref}^{{commit}}"), f"resolve {label} target"
466
+ )
467
+ if target != merge_commit:
468
+ raise ArchiveError(f"{label} does not point at the integration merge")
469
+ if message is not None:
470
+ actual_message = archive_milestone.require_git_success(
471
+ run_git(project, "for-each-ref", "--format=%(contents)", ref),
472
+ f"inspect {label} message",
473
+ )
474
+ if actual_message != message.strip():
475
+ raise ArchiveError(f"{label} has unexpected metadata")
476
+ return object_id
477
+
478
+
479
+ def ensure_integration_tag(
480
+ project: Path,
481
+ tag_name: str,
482
+ merge_commit: str,
483
+ message: Optional[str] = None,
484
+ ) -> tuple[str, bool]:
485
+ tag_ref = f"refs/tags/{tag_name}"
486
+ tracking_ref = f"refs/remotes/origin/tags/{tag_name}"
487
+ local_object = optional_ref(project, tag_ref)
488
+ published_object = live_remote_ref(project, tag_ref)
489
+ tracking_object = optional_ref(project, tracking_ref)
490
+ if tracking_object != published_object:
491
+ if published_object is None:
492
+ archive_milestone.require_git_success(
493
+ run_git(project, "update-ref", "-d", tracking_ref, tracking_object),
494
+ "prune local milestone-tag ref",
495
+ )
496
+ else:
497
+ archive_milestone.require_git_success(
498
+ run_git(project, "update-ref", tracking_ref, published_object),
499
+ "refresh local milestone-tag ref",
500
+ )
501
+ if published_object is not None:
502
+ published_object = require_annotated_tag(
503
+ project,
504
+ tracking_ref,
505
+ merge_commit,
506
+ f"published milestone tag {tag_name}",
507
+ message,
508
+ )
509
+ if local_object is None:
510
+ if published_object is not None:
511
+ archive_milestone.require_git_success(
512
+ run_git(project, "update-ref", tag_ref, published_object),
513
+ "restore local milestone tag",
514
+ )
515
+ else:
516
+ tag_message = message or f"milestone {tag_name.removeprefix('milestone/')}"
517
+ archive_milestone.require_git_success(
518
+ run_git(
519
+ project,
520
+ "tag",
521
+ "--no-sign",
522
+ "-a",
523
+ "-m",
524
+ tag_message,
525
+ tag_name,
526
+ merge_commit,
527
+ ),
528
+ "create milestone tag",
529
+ )
530
+ local_object = require_annotated_tag(
531
+ project,
532
+ tag_ref,
533
+ merge_commit,
534
+ f"milestone tag {tag_name}",
535
+ message,
536
+ )
537
+ if published_object is not None and published_object != local_object:
538
+ raise ArchiveError(f"local and published milestone tag {tag_name} differ")
539
+ return local_object, published_object is not None
540
+
541
+
542
+ def github_repository(project: Path) -> str:
543
+ remote = archive_milestone.require_git_success(
544
+ run_git(project, "remote", "get-url", "origin"),
545
+ "resolve origin URL",
546
+ )
547
+ patterns = (
548
+ r"https://github\.com/([^/]+/[^/]+?)(?:\.git)?$",
549
+ r"git@github\.com:([^/]+/[^/]+?)(?:\.git)?$",
550
+ r"ssh://git@github\.com/([^/]+/[^/]+?)(?:\.git)?$",
551
+ )
552
+ for pattern in patterns:
553
+ match = re.fullmatch(pattern, remote)
554
+ if match:
555
+ return match.group(1)
556
+ raise ArchiveError("pull-request integration requires a GitHub.com origin")
557
+
558
+
559
+ def require_github_authentication() -> None:
560
+ archive_milestone.require_git_success(
561
+ run_command("gh", "auth", "status", "--hostname", GITHUB_HOST),
562
+ "verify GitHub authentication",
563
+ )
564
+
565
+
566
+ def github_api_json(*arguments: str) -> object:
567
+ output = archive_milestone.require_git_success(
568
+ run_command("gh", "api", "--hostname", GITHUB_HOST, *arguments),
569
+ "call GitHub API",
570
+ )
571
+ try:
572
+ return json.loads(output)
573
+ except json.JSONDecodeError as error:
574
+ raise ArchiveError("GitHub API returned invalid JSON") from error
575
+
576
+
577
+ def require_pull_request_shape(value: object) -> dict:
578
+ if not isinstance(value, dict):
579
+ raise ArchiveError("GitHub pull request response is not an object")
580
+ required = {
581
+ "number",
582
+ "state",
583
+ "html_url",
584
+ "merged_at",
585
+ "merge_commit_sha",
586
+ "base",
587
+ "head",
588
+ }
589
+ if not required.issubset(value):
590
+ raise ArchiveError("GitHub pull request response is missing fields")
591
+ if not isinstance(value["base"], dict) or not isinstance(value["head"], dict):
592
+ raise ArchiveError("GitHub pull request refs are invalid")
593
+ number = value["number"]
594
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
595
+ raise ArchiveError("GitHub pull request response has invalid fields")
596
+ if value["state"] not in {"open", "closed"}:
597
+ raise ArchiveError("GitHub pull request response has invalid fields")
598
+ html_url = value["html_url"]
599
+ if not isinstance(html_url, str) or re.fullmatch(
600
+ rf"https://github\.com/[^/]+/[^/]+/pull/{number}", html_url
601
+ ) is None:
602
+ raise ArchiveError("GitHub pull request response has invalid fields")
603
+ merged_at = value["merged_at"]
604
+ if merged_at is not None and not isinstance(merged_at, str):
605
+ raise ArchiveError("GitHub pull request response has invalid fields")
606
+ merge_commit = value["merge_commit_sha"]
607
+ if merge_commit is not None and (
608
+ not isinstance(merge_commit, str)
609
+ or re.fullmatch(r"[0-9a-f]{40}", merge_commit) is None
610
+ ):
611
+ raise ArchiveError("GitHub pull request response has invalid fields")
612
+ if not isinstance(value["base"].get("ref"), str):
613
+ raise ArchiveError("GitHub pull request response has invalid fields")
614
+ if not isinstance(value["head"].get("ref"), str):
615
+ raise ArchiveError("GitHub pull request response has invalid fields")
616
+ head_sha = value["head"].get("sha")
617
+ if not isinstance(head_sha, str) or re.fullmatch(r"[0-9a-f]{40}", head_sha) is None:
618
+ raise ArchiveError("GitHub pull request response has invalid fields")
619
+ return value
620
+
621
+
622
+ def require_pull_request_pages(value: object) -> list[dict]:
623
+ if not isinstance(value, list) or any(
624
+ not isinstance(page, list) for page in value
625
+ ):
626
+ raise ArchiveError("GitHub pull request pages are invalid")
627
+ return [require_pull_request_shape(item) for page in value for item in page]
628
+
629
+
630
+ def pull_request_head_matches_repository(pull: dict, repository: str) -> bool:
631
+ head_repository = pull["head"].get("repo")
632
+ full_name = (
633
+ head_repository.get("full_name")
634
+ if isinstance(head_repository, dict)
635
+ else None
636
+ )
637
+ return isinstance(full_name, str) and full_name.casefold() == repository.casefold()
638
+
639
+
640
+ def require_pull_request_identity(
641
+ repository: str,
642
+ pull: dict,
643
+ branch: str,
644
+ ship_commit: str,
645
+ ) -> None:
646
+ if pull["base"].get("ref") != "main":
647
+ raise ArchiveError("GitHub pull request base is not main")
648
+ if pull["head"].get("ref") != branch:
649
+ raise ArchiveError("GitHub pull request head is not the bound branch")
650
+ if pull["head"].get("sha") != ship_commit:
651
+ raise ArchiveError("GitHub pull request head is not the ship commit")
652
+ if not pull_request_head_matches_repository(pull, repository):
653
+ raise ArchiveError("GitHub pull request head repository is not origin")
654
+
655
+
656
+ def find_pull_request(repository: str, branch: str, ship_commit: str) -> Optional[dict]:
657
+ pulls = require_pull_request_pages(
658
+ github_api_json(
659
+ f"repos/{repository}/pulls",
660
+ "--method",
661
+ "GET",
662
+ "-f",
663
+ "state=all",
664
+ "-f",
665
+ "base=main",
666
+ "--paginate",
667
+ "--slurp",
668
+ )
669
+ )
670
+ pulls = [
671
+ pull
672
+ for pull in pulls
673
+ if pull["base"].get("ref") == "main"
674
+ and (
675
+ pull["head"].get("sha") == ship_commit
676
+ or (
677
+ pull["head"].get("ref") == branch
678
+ and pull_request_head_matches_repository(pull, repository)
679
+ )
680
+ )
681
+ ]
682
+ if len(pulls) > 1:
683
+ raise ArchiveError("multiple pull requests target main from the ship commit")
684
+ if not pulls:
685
+ return None
686
+ pull = pulls[0]
687
+ require_pull_request_identity(repository, pull, branch, ship_commit)
688
+ return pull
689
+
690
+
691
+ def pull_request_body(archive_path: str, ship_commit: str, branch: str) -> str:
692
+ return (
693
+ f"{integrate_commit_body(archive_path, ship_commit, 'main', branch)}"
694
+ f"\n\n---\n{PR_CREDIT_LINE}"
695
+ )
696
+
697
+
698
+ def update_pull_request_body(
699
+ repository: str,
700
+ pull: dict,
701
+ archive_path: str,
702
+ ship_commit: str,
703
+ branch: str,
704
+ ) -> dict:
705
+ expected = pull_request_body(archive_path, ship_commit, branch)
706
+ current = pull.get("body")
707
+ if isinstance(current, str) and current.rstrip().endswith(PR_CREDIT_LINE):
708
+ return pull
709
+ response = github_api_json(
710
+ f"repos/{repository}/pulls/{pull['number']}",
711
+ "--method",
712
+ "PATCH",
713
+ "-f",
714
+ f"body={expected}",
715
+ )
716
+ updated = require_pull_request_shape(response)
717
+ require_pull_request_identity(repository, updated, branch, ship_commit)
718
+ if updated.get("body") != expected:
719
+ raise ArchiveError("updated GitHub pull request is missing the credit footer")
720
+ return updated
721
+
722
+
723
+ def create_pull_request(
724
+ repository: str,
725
+ branch: str,
726
+ archive_path: str,
727
+ archive_name: str,
728
+ ship_commit: str,
729
+ ) -> dict:
730
+ body = pull_request_body(archive_path, ship_commit, branch)
731
+ response = github_api_json(
732
+ f"repos/{repository}/pulls",
733
+ "--method",
734
+ "POST",
735
+ "-f",
736
+ f"title={integrate_subject(archive_name, 'main')}",
737
+ "-f",
738
+ f"head={branch}",
739
+ "-f",
740
+ "base=main",
741
+ "-f",
742
+ f"body={body}",
743
+ )
744
+ pull = require_pull_request_shape(response)
745
+ require_pull_request_identity(repository, pull, branch, ship_commit)
746
+ return pull
747
+
748
+
749
+ def pull_request_tag_message(
750
+ archive_name: str,
751
+ pull_request: str,
752
+ ship_commit: str,
753
+ landing: str,
754
+ ) -> str:
755
+ return (
756
+ f"milestone {archive_name}\n\n"
757
+ "Mode: pull-request\n"
758
+ f"Pull-Request: {pull_request}\n"
759
+ f"Ship: {ship_commit}\n"
760
+ f"Landing: {landing}"
761
+ )
762
+
763
+
764
+ def pull_request_tag_metadata(project: Path, tag_ref: str) -> dict[str, str]:
765
+ message = archive_milestone.require_git_success(
766
+ run_git(project, "for-each-ref", "--format=%(contents)", tag_ref),
767
+ "inspect pull-request milestone tag",
768
+ )
769
+ fields: dict[str, str] = {}
770
+ for line in message.splitlines():
771
+ match = re.fullmatch(r"(Mode|Pull-Request|Ship|Landing): (.+)", line)
772
+ if match:
773
+ if match.group(1) in fields:
774
+ raise ArchiveError("pull-request milestone tag repeats metadata")
775
+ fields[match.group(1)] = match.group(2)
776
+ if set(fields) != {"Mode", "Pull-Request", "Ship", "Landing"}:
777
+ raise ArchiveError("pull-request milestone tag is missing metadata")
778
+ if fields["Mode"] != "pull-request":
779
+ raise ArchiveError("pull-request milestone tag has the wrong mode")
780
+ return fields
781
+
782
+
783
+ def require_pull_request_merge(
784
+ project: Path,
785
+ merge_commit: str,
786
+ ship_commit: str,
787
+ remote_default: str,
788
+ ) -> None:
789
+ resolved = optional_ref(project, merge_commit)
790
+ if resolved != merge_commit:
791
+ raise ArchiveError("GitHub merge commit is unavailable after fetch")
792
+ parents = archive_milestone.require_git_success(
793
+ run_git(project, "rev-list", "--parents", "-n", "1", merge_commit),
794
+ "inspect pull-request merge parents",
795
+ ).split()
796
+ if len(parents) != 3:
797
+ raise ArchiveError("pull-request integration must use a two-parent merge commit")
798
+ if parents[2] != ship_commit:
799
+ raise ArchiveError("pull-request merge second parent is not the ship commit")
800
+ first_parent = archive_milestone.require_git_success(
801
+ run_git(project, "rev-list", "--first-parent", remote_default),
802
+ "inspect remote-default first-parent history",
803
+ ).splitlines()
804
+ if merge_commit not in first_parent:
805
+ raise ArchiveError("pull-request merge is not on origin/main first-parent history")
806
+
807
+
808
+ def require_pull_request_merge_provenance(
809
+ repository: str,
810
+ number: int,
811
+ merge_commit: Optional[str] = None,
812
+ ) -> None:
813
+ owner, name = repository.split("/", 1)
814
+ query = """query($owner:String!,$name:String!,$number:Int!){
815
+ repository(owner:$owner,name:$name){
816
+ pullRequest(number:$number){
817
+ mergeQueue:timelineItems(first:1,itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT]){
818
+ nodes{__typename}
819
+ }
820
+ autoMerge:timelineItems(first:1,itemTypes:[AUTO_MERGE_ENABLED_EVENT]){
821
+ nodes{__typename}
822
+ }
823
+ mergeAction:timelineItems(last:1,itemTypes:[MERGED_EVENT]){
824
+ nodes{__typename ... on MergedEvent{actor{__typename login} commit{oid}}}
825
+ }
826
+ }
827
+ }
828
+ }"""
829
+ payload = github_api_json(
830
+ "graphql",
831
+ "-f",
832
+ f"query={query}",
833
+ "-F",
834
+ f"owner={owner}",
835
+ "-F",
836
+ f"name={name}",
837
+ "-F",
838
+ f"number={number}",
839
+ )
840
+ data = payload.get("data") if isinstance(payload, dict) else None
841
+ repository_data = data.get("repository") if isinstance(data, dict) else None
842
+ pull_request = (
843
+ repository_data.get("pullRequest")
844
+ if isinstance(repository_data, dict)
845
+ else None
846
+ )
847
+ checks = (
848
+ (
849
+ "mergeQueue",
850
+ "AddedToMergeQueueEvent",
851
+ "GitHub merge-queue provenance is invalid",
852
+ "pull-request integration cannot use a merge queue",
853
+ ),
854
+ (
855
+ "autoMerge",
856
+ "AutoMergeEnabledEvent",
857
+ "GitHub auto-merge provenance is invalid",
858
+ "pull-request integration cannot use auto-merge",
859
+ ),
860
+ )
861
+ for field, typename, invalid_message, rejection_message in checks:
862
+ timeline = pull_request.get(field) if isinstance(pull_request, dict) else None
863
+ nodes = timeline.get("nodes") if isinstance(timeline, dict) else None
864
+ if not isinstance(nodes, list):
865
+ raise ArchiveError(invalid_message)
866
+ if nodes:
867
+ if any(
868
+ not isinstance(node, dict) or node.get("__typename") != typename
869
+ for node in nodes
870
+ ):
871
+ raise ArchiveError(invalid_message)
872
+ raise ArchiveError(rejection_message)
873
+ merge_timeline = (
874
+ pull_request.get("mergeAction")
875
+ if isinstance(pull_request, dict)
876
+ else None
877
+ )
878
+ merge_nodes = (
879
+ merge_timeline.get("nodes")
880
+ if isinstance(merge_timeline, dict)
881
+ else None
882
+ )
883
+ if not isinstance(merge_nodes, list):
884
+ raise ArchiveError("GitHub pull-request merge provenance is invalid")
885
+ if merge_commit is None:
886
+ if merge_nodes:
887
+ raise ArchiveError("open pull request has invalid merge provenance")
888
+ return
889
+ if len(merge_nodes) != 1:
890
+ raise ArchiveError("pull request was not merged by a GitHub merge action")
891
+ merge_node = merge_nodes[0]
892
+ commit = merge_node.get("commit") if isinstance(merge_node, dict) else None
893
+ actor = merge_node.get("actor") if isinstance(merge_node, dict) else None
894
+ if (
895
+ not isinstance(merge_node, dict)
896
+ or merge_node.get("__typename") != "MergedEvent"
897
+ or not isinstance(commit, dict)
898
+ or commit.get("oid") != merge_commit
899
+ ):
900
+ raise ArchiveError("GitHub pull-request merge action does not match landing")
901
+ if (
902
+ not isinstance(actor, dict)
903
+ or actor.get("__typename") != "User"
904
+ or not isinstance(actor.get("login"), str)
905
+ or not actor["login"]
906
+ ):
907
+ raise ArchiveError("pull request must be merged by a human GitHub user")
908
+
909
+
910
+ def integrate_pull_request(
911
+ project: Path,
912
+ state: PipelineState,
913
+ archive_path: str,
914
+ archive_name: str,
915
+ ship_commit: str,
916
+ ) -> dict:
917
+ branch = state.branch
918
+ if branch is None:
919
+ raise ArchiveError("pull-request integration requires a bound branch")
920
+ require_github_authentication()
921
+ repository = github_repository(project)
922
+ pull = find_pull_request(repository, branch, ship_commit)
923
+ if pull is None:
924
+ publish_bound_branch(project, branch, ship_commit)
925
+ pull = create_pull_request(
926
+ repository,
927
+ branch,
928
+ archive_path,
929
+ archive_name,
930
+ ship_commit,
931
+ )
932
+ elif pull["merged_at"] is None:
933
+ if pull["state"] != "open":
934
+ raise ArchiveError("GitHub pull request was closed without merging")
935
+ pull = update_pull_request_body(
936
+ repository,
937
+ pull,
938
+ archive_path,
939
+ ship_commit,
940
+ branch,
941
+ )
942
+ if pull["merged_at"] is None:
943
+ if pull["state"] != "open":
944
+ raise ArchiveError("GitHub pull request was closed without merging")
945
+ publish_bound_branch(project, branch, ship_commit)
946
+ require_pull_request_merge_provenance(repository, pull["number"])
947
+ return {
948
+ "status": "awaiting-merge",
949
+ "mode": "pull-request",
950
+ "archive": archive_path,
951
+ "commit": ship_commit,
952
+ "pull_request": pull["html_url"],
953
+ }
954
+
955
+ merge_commit = pull["merge_commit_sha"]
956
+ if pull["state"] != "closed" or not isinstance(merge_commit, str):
957
+ raise ArchiveError("GitHub pull request merge metadata is invalid")
958
+ require_pull_request_merge_provenance(
959
+ repository,
960
+ pull["number"],
961
+ merge_commit,
962
+ )
963
+ remote_default = refresh_origin(project)["remote_default"]
964
+ require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
965
+ pull = update_pull_request_body(
966
+ repository,
967
+ pull,
968
+ archive_path,
969
+ ship_commit,
970
+ branch,
971
+ )
972
+ tag_name = f"milestone/{archive_name}"
973
+ message = pull_request_tag_message(
974
+ archive_name,
975
+ pull["html_url"],
976
+ ship_commit,
977
+ merge_commit,
978
+ )
979
+ tag_object, tag_published = ensure_integration_tag(
980
+ project,
981
+ tag_name,
982
+ merge_commit,
983
+ message,
984
+ )
985
+ if not tag_published:
986
+ archive_milestone.require_git_success(
987
+ run_git(
988
+ project,
989
+ "push",
990
+ "origin",
991
+ f"refs/tags/{tag_name}:refs/tags/{tag_name}",
992
+ ),
993
+ "push milestone tag",
994
+ )
995
+ archive_milestone.require_git_success(
996
+ run_git(
997
+ project,
998
+ "update-ref",
999
+ f"refs/remotes/origin/tags/{tag_name}",
1000
+ tag_object,
1001
+ ),
1002
+ "refresh local milestone-tag ref",
1003
+ )
1004
+ return validate_integrated(project, archive_milestone.milestone_slug(state))
1005
+
1006
+
1007
+ def integrate(repo: Path, slug: str) -> dict:
1008
+ project = repo.resolve()
1009
+ active_root = archive_milestone.require_project_layout(project)
1010
+ state, _, loaded_state_path = archive_milestone.strict_state(project)
1011
+ if loaded_state_path.resolve() != (active_root / "STATE.md").resolve():
1012
+ raise ArchiveError("strict STATE.md loader returned an unexpected path")
1013
+ expected_slug = archive_milestone.milestone_slug(state)
1014
+ if archive_milestone.normalized_slug(slug) != expected_slug:
1015
+ raise ArchiveError(
1016
+ f"--slug {slug!r} does not match normalized STATE.milestone {expected_slug!r}"
1017
+ )
1018
+
1019
+ shipped = archive_milestone.validate(project)
1020
+ archive_path = shipped["archive"]
1021
+ ship_commit = shipped["commit"]
1022
+ archive_name = PurePosixPath(archive_path).name
1023
+ bound_branch = state.branch
1024
+ expected_branch = bound_branch_name(milestone_number(archive_name))
1025
+ if bound_branch != expected_branch:
1026
+ raise ArchiveError(f"integration requires canonical bound branch {expected_branch}")
1027
+ head = archive_milestone.require_git_success(
1028
+ run_git(project, "rev-parse", "HEAD"), "resolve bound branch HEAD"
1029
+ )
1030
+ if head != ship_commit:
1031
+ raise ArchiveError("bound worktree must remain at the ship commit during integration")
1032
+ subject = archive_milestone.require_git_success(
1033
+ run_git(project, "show", "-s", "--format=%s", ship_commit),
1034
+ "inspect ship commit subject",
1035
+ )
1036
+ if subject != ship_subject(archive_name):
1037
+ raise ArchiveError("integration only generates canonical M00N shipment history")
1038
+
1039
+ remote_default = refresh_origin(project)["remote_default"]
1040
+ default_name = default_branch_name(remote_default)
1041
+ if default_name != "main":
1042
+ raise ArchiveError(f"remote default must be main, got {default_name!r}")
1043
+ if bound_branch == default_name:
1044
+ raise ArchiveError("bound branch must not be the remote default")
1045
+ remote_default_sha = archive_milestone.require_git_success(
1046
+ run_git(project, "rev-parse", remote_default), "resolve remote default"
1047
+ )
1048
+ if state.integration == "pull-request":
1049
+ return integrate_pull_request(
1050
+ project,
1051
+ state,
1052
+ archive_path,
1053
+ archive_name,
1054
+ ship_commit,
1055
+ )
1056
+ publishable_bound_branch(project, bound_branch, ship_commit)
1057
+ integration_branch, worktree = integration_names(project, archive_name, pin=True)
1058
+ interrupted_worktree = registered_worktree(project, integration_branch)
1059
+ if (
1060
+ interrupted_worktree is not None
1061
+ and optional_ref(interrupted_worktree, "MERGE_HEAD") is not None
1062
+ ):
1063
+ if interrupted_worktree != worktree.resolve():
1064
+ raise ArchiveError(
1065
+ f"integration branch {integration_branch} is checked out at unexpected path: "
1066
+ f"{interrupted_worktree}"
1067
+ )
1068
+ conflicts = run_git(
1069
+ interrupted_worktree, "diff", "--name-only", "--diff-filter=U"
1070
+ ).stdout.strip()
1071
+ archive_milestone.require_git_success(
1072
+ run_git(interrupted_worktree, "merge", "--abort"),
1073
+ "abort interrupted integration merge",
1074
+ )
1075
+ remove_registered_worktree(project, integration_branch, worktree)
1076
+ interrupted_tip = optional_ref(project, f"refs/heads/{integration_branch}")
1077
+ if interrupted_tip is not None:
1078
+ delete_integration_branch(project, integration_branch, interrupted_tip)
1079
+ raise ArchiveError(
1080
+ "interrupted integration merge conflicted; Git aborted it without resolving files: "
1081
+ + ", ".join(conflicts.splitlines())
1082
+ + "; closed milestone remains unchanged; run gsd-path-forensics and "
1083
+ "obtain a user-approved integration recovery plan; do not merge into or re-ship the bound branch"
1084
+ )
1085
+ remove_registered_worktree(project, integration_branch, worktree)
1086
+ worktree_active = False
1087
+
1088
+ contains_ship = run_git(
1089
+ project, "merge-base", "--is-ancestor", ship_commit, remote_default
1090
+ )
1091
+ if contains_ship.returncode == 0:
1092
+ merge_commit = find_integrate_commit(
1093
+ project, remote_default, archive_name, ship_commit
1094
+ )
1095
+ require_generated_integration_commit(
1096
+ project,
1097
+ merge_commit,
1098
+ archive_path,
1099
+ archive_name,
1100
+ ship_commit,
1101
+ default_name,
1102
+ bound_branch,
1103
+ )
1104
+ elif contains_ship.returncode == 1:
1105
+ merge_commit, worktree_active = create_integration_merge(
1106
+ project,
1107
+ worktree,
1108
+ integration_branch,
1109
+ remote_default_sha,
1110
+ archive_path,
1111
+ archive_name,
1112
+ ship_commit,
1113
+ default_name,
1114
+ bound_branch,
1115
+ )
1116
+ else:
1117
+ archive_milestone.require_git_success(contains_ship, "inspect ship ancestry on the remote default")
1118
+ raise AssertionError("unreachable")
1119
+
1120
+ action_error = None
1121
+ try:
1122
+ tag_name = f"milestone/{archive_name}"
1123
+ tag_object, tag_published = ensure_integration_tag(
1124
+ project, tag_name, merge_commit
1125
+ )
1126
+
1127
+ published_merge = run_git(
1128
+ project, "merge-base", "--is-ancestor", merge_commit, remote_default
1129
+ )
1130
+ if published_merge.returncode == 1:
1131
+ archive_milestone.require_git_success(
1132
+ run_git(
1133
+ project,
1134
+ "push",
1135
+ "origin",
1136
+ f"{merge_commit}:refs/heads/{default_name}",
1137
+ ),
1138
+ "push integration merge to main",
1139
+ )
1140
+ archive_milestone.require_git_success(
1141
+ run_git(project, "update-ref", f"refs/remotes/{remote_default}", merge_commit),
1142
+ "refresh local remote-default ref",
1143
+ )
1144
+ elif published_merge.returncode != 0:
1145
+ archive_milestone.require_git_success(published_merge, "inspect published integration merge")
1146
+
1147
+ publish_bound_branch(project, bound_branch, ship_commit)
1148
+ if not tag_published:
1149
+ archive_milestone.require_git_success(
1150
+ run_git(
1151
+ project,
1152
+ "push",
1153
+ "origin",
1154
+ f"refs/tags/{tag_name}:refs/tags/{tag_name}",
1155
+ ),
1156
+ "push milestone tag",
1157
+ )
1158
+ archive_milestone.require_git_success(
1159
+ run_git(
1160
+ project,
1161
+ "update-ref",
1162
+ f"refs/remotes/origin/tags/{tag_name}",
1163
+ tag_object,
1164
+ ),
1165
+ "refresh local milestone-tag ref",
1166
+ )
1167
+ except (ArchiveError, OSError) as error:
1168
+ action_error = error
1169
+
1170
+ try:
1171
+ if worktree_active:
1172
+ remove_registered_worktree(project, integration_branch, worktree)
1173
+ except (ArchiveError, OSError) as cleanup_error:
1174
+ if action_error is not None:
1175
+ raise ArchiveError(
1176
+ f"{action_error}; integration worktree cleanup failed: {cleanup_error}"
1177
+ )
1178
+ raise
1179
+ if action_error is not None:
1180
+ raise action_error
1181
+
1182
+ branch_tip = optional_ref(project, f"refs/heads/{integration_branch}")
1183
+ if branch_tip is not None:
1184
+ if branch_tip != merge_commit:
1185
+ ancestor = run_git(
1186
+ project, "merge-base", "--is-ancestor", branch_tip, merge_commit
1187
+ )
1188
+ if ancestor.returncode != 0:
1189
+ raise ArchiveError(f"integration branch {integration_branch} moved unexpectedly")
1190
+ delete_integration_branch(project, integration_branch, branch_tip)
1191
+ return validate_integrated(project, slug)
1192
+
1193
+
1194
+ def find_integrate_commit(
1195
+ project: Path,
1196
+ remote_default: str,
1197
+ archive_name: str,
1198
+ ship_commit: str,
1199
+ ) -> str:
1200
+ default_name = default_branch_name(remote_default)
1201
+ expected_subject = integrate_subject(archive_name, default_name)
1202
+ log = archive_milestone.require_git_success(
1203
+ run_git(project, "log", "--first-parent", "--format=%H%x00%s", remote_default),
1204
+ f"inspect {remote_default} first-parent history for the integration merge",
1205
+ )
1206
+ matches = []
1207
+ for record in log.splitlines():
1208
+ if "\x00" not in record:
1209
+ continue
1210
+ commit, subject = record.split("\x00", 1)
1211
+ if subject == expected_subject:
1212
+ matches.append(commit)
1213
+ if not matches:
1214
+ raise ArchiveError(
1215
+ f"no commit with exact subject {expected_subject!r} "
1216
+ f"in {remote_default} first-parent history"
1217
+ )
1218
+ if len(matches) != 1:
1219
+ raise ArchiveError(
1220
+ f"multiple commits with exact subject {expected_subject!r} "
1221
+ f"in {remote_default} first-parent history"
1222
+ )
1223
+ merge_commit = matches[0]
1224
+ parents = archive_milestone.require_git_success(
1225
+ run_git(project, "rev-list", "--parents", "-n", "1", merge_commit),
1226
+ "inspect integration merge parents",
1227
+ ).split()
1228
+ if len(parents) != 3:
1229
+ raise ArchiveError(f"integration commit for {archive_name} is not a merge commit")
1230
+ if parents[2] != ship_commit:
1231
+ raise ArchiveError("integration merge second parent is not the ship commit")
1232
+ return merge_commit
1233
+
1234
+
1235
+ def validate_integrated(
1236
+ repo: Path, slug: str, *, refresh: bool = True, historical: bool = False
1237
+ ) -> dict:
1238
+ project = repo.resolve()
1239
+ active_root = archive_milestone.require_project_layout(project)
1240
+ state, _, loaded_state_path = archive_milestone.strict_state(project)
1241
+ if loaded_state_path.resolve() != (active_root / "STATE.md").resolve():
1242
+ raise ArchiveError("strict STATE.md loader returned an unexpected path")
1243
+ expected_slug = archive_milestone.milestone_slug(state)
1244
+ if archive_milestone.normalized_slug(slug) != expected_slug:
1245
+ raise ArchiveError(
1246
+ f"--slug {slug!r} does not match normalized STATE.milestone {expected_slug!r}"
1247
+ )
1248
+
1249
+ # (a) The shipped transaction itself must still validate.
1250
+ shipped = archive_milestone.validate(repo, historical=historical)
1251
+ configured = shipped["archive"]
1252
+ ship_commit = shipped["commit"]
1253
+ archive_name = PurePosixPath(configured).name
1254
+
1255
+ # Refresh whenever origin exists so direct mode cannot miss a published tag.
1256
+ # Fixtures that only plant refs/remotes/origin/* keep resolve_remote_default.
1257
+ has_origin = run_git(project, "remote", "get-url", "origin").returncode == 0
1258
+ if has_origin and refresh:
1259
+ remote_default = refresh_origin(project)["remote_default"]
1260
+ else:
1261
+ remote_default = resolve_remote_default(project)
1262
+ default_name = default_branch_name(remote_default)
1263
+ bound_branch = state.branch
1264
+ if bound_branch is None:
1265
+ raise ArchiveError("STATE.md does not name a bound build branch")
1266
+ expected_branch = bound_branch_name(milestone_number(archive_name))
1267
+ ship_subject_text = archive_milestone.require_git_success(
1268
+ run_git(project, "log", "-1", "--format=%s", ship_commit),
1269
+ "read ship commit subject",
1270
+ )
1271
+ if ship_subject_text != ship_subject(archive_name):
1272
+ raise ArchiveError("current milestone ship commit subject is not canonical")
1273
+ if not is_bound_branch(bound_branch):
1274
+ raise ArchiveError(f"bound branch {bound_branch!r} is not gsd-path/M00N")
1275
+ if is_bound_branch(bound_branch) and bound_branch != expected_branch:
1276
+ expected_milestone = expected_branch.removeprefix("gsd-path/")
1277
+ raise ArchiveError(
1278
+ f"bound branch {bound_branch} does not match archive milestone {expected_milestone}"
1279
+ )
1280
+ if bound_branch == default_name:
1281
+ raise ArchiveError(
1282
+ f"bound branch {bound_branch!r} is the remote default; "
1283
+ "ship merges onto main, never onto the work branch"
1284
+ )
1285
+ if default_name != "main":
1286
+ raise ArchiveError(f"remote default must be main, got {default_name!r}")
1287
+
1288
+ # (b) Resolve the integration proof selected before build.
1289
+ tag_name = f"milestone/{archive_name}"
1290
+ tag_ref = f"refs/tags/{tag_name}"
1291
+ pull_request = None
1292
+ if state.integration == "pull-request":
1293
+ if run_git(project, "rev-parse", "--verify", "--quiet", tag_ref).returncode != 0:
1294
+ raise ArchiveError(f"missing milestone tag: {tag_name}")
1295
+ metadata = pull_request_tag_metadata(project, tag_ref)
1296
+ if metadata["Ship"] != ship_commit:
1297
+ raise ArchiveError("pull-request milestone tag names the wrong ship commit")
1298
+ merge_commit = metadata["Landing"]
1299
+ pull_request = metadata["Pull-Request"]
1300
+ require_github_authentication()
1301
+ repository = github_repository(project)
1302
+ pull = find_pull_request(repository, bound_branch, ship_commit)
1303
+ if pull is None:
1304
+ raise ArchiveError("GitHub pull request for the ship commit is missing")
1305
+ if pull["html_url"] != pull_request:
1306
+ raise ArchiveError("milestone tag names the wrong pull request")
1307
+ if (
1308
+ pull["state"] != "closed"
1309
+ or pull["merged_at"] is None
1310
+ or pull["merge_commit_sha"] != merge_commit
1311
+ ):
1312
+ raise ArchiveError("GitHub pull request is not merged at the tagged landing")
1313
+ require_pull_request_merge_provenance(
1314
+ repository,
1315
+ pull["number"],
1316
+ merge_commit,
1317
+ )
1318
+ require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
1319
+ require_annotated_tag(
1320
+ project,
1321
+ tag_ref,
1322
+ merge_commit,
1323
+ f"milestone tag {tag_name}",
1324
+ pull_request_tag_message(
1325
+ archive_name,
1326
+ pull_request,
1327
+ ship_commit,
1328
+ merge_commit,
1329
+ ),
1330
+ )
1331
+ else:
1332
+ merge_commit = find_integrate_commit(
1333
+ project, remote_default, archive_name, ship_commit
1334
+ )
1335
+ archive_milestone.require_canonical_commit_body(
1336
+ project,
1337
+ merge_commit,
1338
+ integrate_subject(archive_name, default_name),
1339
+ integrate_commit_body(configured, ship_commit, default_name, bound_branch),
1340
+ "integration",
1341
+ )
1342
+ if run_git(project, "rev-parse", "--verify", "--quiet", tag_ref).returncode != 0:
1343
+ raise ArchiveError(f"missing milestone tag: {tag_name}")
1344
+ require_annotated_tag(
1345
+ project,
1346
+ tag_ref,
1347
+ merge_commit,
1348
+ f"milestone tag {tag_name}",
1349
+ )
1350
+
1351
+ # (c) The remote default must contain the merge commit.
1352
+ contains = run_git(project, "merge-base", "--is-ancestor", merge_commit, remote_default)
1353
+ if contains.returncode != 0:
1354
+ raise ArchiveError(f"{remote_default} does not contain the integration merge")
1355
+ if (historical
1356
+ and run_git(project, "branch", "--show-current").stdout.strip() != bound_branch
1357
+ and run_git(project, "merge-base", "--is-ancestor", merge_commit, "HEAD").returncode):
1358
+ raise ArchiveError("HEAD does not contain the integration merge")
1359
+
1360
+ # (e) The bound branch tip and annotated tag must be published on origin.
1361
+ require_published_integration(
1362
+ project,
1363
+ bound_branch,
1364
+ ship_commit,
1365
+ tag_name,
1366
+ merge_commit,
1367
+ allow_missing_bound=state.integration == "pull-request" or (historical and has_origin),
1368
+ retired=historical,
1369
+ )
1370
+
1371
+ result = {
1372
+ "archive": configured,
1373
+ "commit": ship_commit,
1374
+ "integrate": merge_commit,
1375
+ "landing": merge_commit,
1376
+ "base": archive_milestone.require_git_success(
1377
+ run_git(project, "rev-parse", remote_default),
1378
+ "resolve integrated remote default",
1379
+ ),
1380
+ "mode": state.integration,
1381
+ "tag": tag_name,
1382
+ }
1383
+ if has_origin and not refresh:
1384
+ # Status must not fetch or write refs. Missing/stale local evidence is
1385
+ # unverified; the normal ship validator remains the refresh owner.
1386
+ refs = [(f"refs/heads/{default_name}", result["base"]),
1387
+ (f"refs/tags/{tag_name}", optional_ref(project, tag_ref))]
1388
+ if state.integration == "direct":
1389
+ bound_ref = f"refs/heads/{bound_branch}"
1390
+ if not historical or live_remote_ref(project, bound_ref) is not None:
1391
+ refs.append((bound_ref, ship_commit))
1392
+ for ref, expected in refs:
1393
+ if live_remote_ref(project, ref) != expected:
1394
+ raise ArchiveError(f"published {ref} differs from local integration evidence")
1395
+ if pull_request is not None:
1396
+ result["pull_request"] = pull_request
1397
+ return result
1398
+
1399
+
1400
+ def require_published_integration(
1401
+ project: Path,
1402
+ bound_branch: str,
1403
+ ship_commit: str,
1404
+ tag_name: str,
1405
+ merge_commit: str,
1406
+ allow_missing_bound: bool = False,
1407
+ retired: bool = False,
1408
+ ) -> None:
1409
+ if allow_missing_bound:
1410
+ published_ship = live_remote_ref(project, f"refs/heads/{bound_branch}")
1411
+ if published_ship is not None and published_ship != ship_commit:
1412
+ raise ArchiveError(
1413
+ f"origin/{bound_branch} is {published_ship}, expected ship commit {ship_commit}"
1414
+ )
1415
+ published_tag = live_remote_ref(project, f"refs/tags/{tag_name}")
1416
+ if published_tag is None:
1417
+ raise ArchiveError(f"missing published milestone tag: origin/tags/{tag_name}")
1418
+ local_tag = optional_ref(project, f"refs/tags/{tag_name}")
1419
+ if local_tag != published_tag:
1420
+ raise ArchiveError(f"local and published milestone tag {tag_name} differ")
1421
+ require_annotated_tag(
1422
+ project,
1423
+ published_tag,
1424
+ merge_commit,
1425
+ f"published milestone tag {tag_name}",
1426
+ )
1427
+ return
1428
+
1429
+ bound_ref = f"refs/remotes/origin/{bound_branch}"
1430
+ if run_git(project, "rev-parse", "--verify", "--quiet", bound_ref).returncode != 0:
1431
+ if not retired:
1432
+ raise ArchiveError(f"missing published bound branch: origin/{bound_branch}")
1433
+ else:
1434
+ published_ship = archive_milestone.require_git_success(
1435
+ run_git(project, "rev-parse", bound_ref),
1436
+ "resolve published bound branch",
1437
+ )
1438
+ if published_ship != ship_commit:
1439
+ raise ArchiveError(
1440
+ f"origin/{bound_branch} is {published_ship}, expected ship commit {ship_commit}"
1441
+ )
1442
+
1443
+ remote_tag = f"refs/remotes/origin/tags/{tag_name}"
1444
+ if run_git(project, "rev-parse", "--verify", "--quiet", remote_tag).returncode != 0:
1445
+ raise ArchiveError(f"missing published milestone tag: origin/tags/{tag_name}")
1446
+ remote_tag_type = archive_milestone.require_git_success(
1447
+ run_git(project, "cat-file", "-t", remote_tag),
1448
+ "inspect published milestone tag type",
1449
+ )
1450
+ if remote_tag_type != "tag":
1451
+ raise ArchiveError(f"published milestone tag {tag_name} must be annotated")
1452
+ remote_tag_target = archive_milestone.require_git_success(
1453
+ run_git(project, "rev-parse", f"{remote_tag}^{{commit}}"),
1454
+ "resolve published milestone tag target",
1455
+ )
1456
+ if remote_tag_target != merge_commit:
1457
+ raise ArchiveError(
1458
+ f"published milestone tag {tag_name} does not point at the integration merge"
1459
+ )