@opengsd/gsd-path 1.0.0

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