@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,1039 @@
1
+ #!/usr/bin/env python3
2
+ # gsd-path project runtime
3
+ """Helper-owned undo for unpublished GSD Path pipeline commits and worktrees.
4
+
5
+ Preview is read-only. Apply mutates only after the caller supplies the exact
6
+ HEAD SHA returned by preview. The helper never force-pushes, never rewrites a
7
+ commit that is an ancestor of origin/main, and never invents a git reset the
8
+ model did not ask this command to perform.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Optional, Sequence
23
+
24
+ def _load_pipeline_modules():
25
+ try:
26
+ import archive_milestone
27
+ import isolation
28
+ import pipeline_git
29
+ import pipeline_state
30
+ return archive_milestone, isolation, pipeline_git, pipeline_state
31
+ except ModuleNotFoundError as error:
32
+ if error.name not in {
33
+ "archive_milestone",
34
+ "isolation",
35
+ "pipeline_git",
36
+ "pipeline_state",
37
+ }:
38
+ raise
39
+ try:
40
+ from scripts import archive_milestone, isolation, pipeline_git, pipeline_state
41
+ return archive_milestone, isolation, pipeline_git, pipeline_state
42
+ except ModuleNotFoundError as error:
43
+ if error.name not in {
44
+ "scripts",
45
+ "scripts.archive_milestone",
46
+ "scripts.isolation",
47
+ "scripts.pipeline_git",
48
+ "scripts.pipeline_state",
49
+ }:
50
+ raise
51
+ shared = Path(__file__).resolve().parents[2] / "gsd-path" / "scripts"
52
+ sys.path.insert(0, str(shared))
53
+ import archive_milestone
54
+ import isolation
55
+ import pipeline_git
56
+ import pipeline_state
57
+ return archive_milestone, isolation, pipeline_git, pipeline_state
58
+
59
+
60
+ archive_milestone, isolation, pipeline_git, pipeline_state = _load_pipeline_modules()
61
+ ArchiveError = archive_milestone.ArchiveError
62
+ IsolationError = isolation.IsolationError
63
+ PLAN_APPROVAL_SUBJECT = pipeline_state.PLAN_APPROVAL_SUBJECT
64
+ PipelineStateError = pipeline_state.PipelineStateError
65
+ _activate_roadmap_milestone = pipeline_state._activate_roadmap_milestone
66
+ _approval_details = pipeline_state._approval_details
67
+ _commit_subject_body = pipeline_state._commit_subject_body
68
+ _optional_rev = pipeline_state._optional_rev
69
+ _render_transition = pipeline_state._render_transition
70
+ _shipment_roadmap = pipeline_state._shipment_roadmap
71
+ _repo_root = pipeline_state._repo_root
72
+ _run_git = pipeline_state._run_git
73
+ _state_from_text = pipeline_state._state_from_text
74
+ _git_path = pipeline_state._git_path
75
+ _read_json = pipeline_state._read_json
76
+ _write_json = pipeline_state._write_json
77
+ _worktree_changes = pipeline_state._worktree_changes
78
+ undo_transaction = pipeline_state.undo_transaction
79
+ load_state = pipeline_state.load_state
80
+ status_state = pipeline_state.status_state
81
+ is_bound_branch = pipeline_git.is_bound_branch
82
+ is_ship_subject = pipeline_git.is_ship_subject
83
+
84
+
85
+ UNDO_SCHEMA = "gsd-path/undo/v1"
86
+ UNDO_TRANSACTION_SCHEMA = pipeline_state.UNDO_TRANSACTION_SCHEMA
87
+ UNDO_TRANSACTION_NAME = pipeline_state.UNDO_TRANSACTION_NAME
88
+ ROADMAP_APPROVAL_SUBJECT = "roadmap: program roadmap approved"
89
+ TASK_SUBJECT_RE = re.compile(r"^(T\d{3,}): .+")
90
+ KINDS = ("checkpoint", "task", "uncommitted-archive", "lookahead")
91
+ PROMOTION_PREFIX = "router: promote lookahead milestone "
92
+ ABANDON_PREFIX = "build: abandon milestone "
93
+ INTEGRATE_PREFIX = "integrate: "
94
+
95
+
96
+ class UndoError(RuntimeError):
97
+ """Raised when undo cannot be proven or applied safely."""
98
+
99
+
100
+ def _normalize_archive(value: str) -> str:
101
+ return value.rstrip("/")
102
+
103
+
104
+ def _archive_in_head(repo: Path, archive: str) -> bool:
105
+ relative = _normalize_archive(archive)
106
+ listed = _run_git(
107
+ repo,
108
+ "ls-tree",
109
+ "-r",
110
+ "--name-only",
111
+ "HEAD",
112
+ "--",
113
+ relative,
114
+ f"{relative}/",
115
+ check=False,
116
+ )
117
+ return listed.returncode == 0 and bool(listed.stdout.strip())
118
+
119
+
120
+ def _parent_sha(repo: Path, head: str) -> Optional[str]:
121
+ result = _run_git(repo, "rev-parse", "--verify", f"{head}^", check=False)
122
+ sha = result.stdout.strip()
123
+ return sha if result.returncode == 0 and sha else None
124
+
125
+
126
+ def _product_dirty(dirty: Sequence[str]) -> list[str]:
127
+ return [path for path in dirty if not path.startswith(".project/")]
128
+
129
+
130
+ def _active_journals(journals: dict[str, Optional[str]]) -> dict[str, str]:
131
+ return {key: value for key, value in journals.items() if value}
132
+
133
+
134
+ def _blocked(reasons: Sequence[str]) -> dict[str, object]:
135
+ return {
136
+ "kind": None,
137
+ "head": None,
138
+ "parent": None,
139
+ "subject": None,
140
+ "effects": [],
141
+ "blocked": list(reasons),
142
+ }
143
+
144
+
145
+ def _git_text(repo: Path, revision: str, relative: str) -> str:
146
+ result = _run_git(repo, "show", f"{revision}:{relative}", check=False)
147
+ if result.returncode != 0:
148
+ raise UndoError(f"missing {relative} in {revision}")
149
+ return result.stdout
150
+
151
+
152
+ def _checkpoint_proof_error(
153
+ repo: Path, head: str, parent: str, subject: str
154
+ ) -> Optional[str]:
155
+ changed = set(
156
+ _run_git(
157
+ repo,
158
+ "diff-tree",
159
+ "--no-commit-id",
160
+ "--name-only",
161
+ "-r",
162
+ head,
163
+ ).stdout.splitlines()
164
+ )
165
+ if not changed or any(not path.startswith(".project/") for path in changed):
166
+ return "checkpoint changes paths outside .project"
167
+ state_paths = [
168
+ path
169
+ for path in (".project/STATE.md", ".project/next/STATE.md")
170
+ if path in changed
171
+ ]
172
+ if len(state_paths) != 1:
173
+ return "checkpoint must change exactly one owned STATE.md"
174
+ state_path = state_paths[0]
175
+ project_dir = str(PurePosixPath(state_path).parent)
176
+ try:
177
+ before_text = _git_text(repo, parent, state_path)
178
+ after_text = _git_text(repo, head, state_path)
179
+ before = _state_from_text(before_text, f"{parent}:{state_path}")
180
+ after = _state_from_text(after_text, f"{head}:{state_path}")
181
+ body = _commit_subject_body(repo, head)[1].strip()
182
+ if subject == PLAN_APPROVAL_SUBJECT:
183
+ kind = "plan"
184
+ selected = None
185
+ elif subject == ROADMAP_APPROVAL_SUBJECT:
186
+ kind = "roadmap"
187
+ selected = after.milestone
188
+ else:
189
+ return "checkpoint subject is not canonical"
190
+ changes, event, expected_subject, expected_body = _approval_details(
191
+ kind, before, selected
192
+ )
193
+ if subject != expected_subject or body != expected_body:
194
+ return "checkpoint message does not match its state transition"
195
+ added_dates = re.findall(
196
+ rf"^- (\d{{4}}-\d{{2}}-\d{{2}}) — {kind} — {re.escape(event)}$",
197
+ after_text,
198
+ re.MULTILINE,
199
+ )
200
+ if not added_dates:
201
+ return "checkpoint state lacks its canonical event"
202
+ expected = {
203
+ "phase": before.phase,
204
+ "status": before.status,
205
+ "milestone": before.milestone,
206
+ "branch": before.branch,
207
+ "archive": before.archive,
208
+ }
209
+ _, expected_state, rendered = _render_transition(
210
+ before,
211
+ before_text,
212
+ expected,
213
+ changes,
214
+ event,
215
+ project_dir,
216
+ added_dates[-1],
217
+ kind,
218
+ )
219
+ if rendered != after_text or expected_state != after:
220
+ return "checkpoint STATE.md is not the canonical approval result"
221
+ if kind == "roadmap":
222
+ roadmap_before = _git_text(repo, parent, ".project/ROADMAP.md")
223
+ roadmap_after = _git_text(repo, head, ".project/ROADMAP.md")
224
+ if roadmap_after != _activate_roadmap_milestone(
225
+ roadmap_before, str(selected)
226
+ ):
227
+ return "checkpoint ROADMAP.md is not the canonical approval result"
228
+ except (PipelineStateError, UndoError) as error:
229
+ return str(error)
230
+ return None
231
+
232
+
233
+ def _task_proof_error(repo: Path, head: str) -> Optional[str]:
234
+ try:
235
+ recovered = isolation.recover(repo, Path(".project/tasks"))
236
+ except IsolationError as error:
237
+ return str(error)
238
+ matches = [
239
+ task
240
+ for task in recovered.get("tasks", [])
241
+ if task.get("verdict") == "recovered" and task.get("commit") == head
242
+ ]
243
+ if recovered.get("verdict") != "ok" or len(matches) != 1:
244
+ return "HEAD is not the uniquely proven last task landing"
245
+ return None
246
+
247
+
248
+ def _discussion_extension(repo: Path, archive: str) -> Optional[dict[str, bytes]]:
249
+ active = repo / ".project" / "discuss"
250
+ if not active.exists() and not active.is_symlink():
251
+ return None
252
+ archived = repo / archive / "discuss"
253
+ try:
254
+ archive_milestone.require_append_only_discussion(
255
+ active,
256
+ archived,
257
+ require_dispositions=False,
258
+ )
259
+ except (ArchiveError, OSError) as error:
260
+ raise UndoError(f"discussion records cannot be preserved: {error}") from error
261
+ return {
262
+ name: (active / name).read_bytes()
263
+ for name in archive_milestone.DISCUSSION_FILES
264
+ }
265
+
266
+
267
+ def _restore_discussion(repo: Path, files: dict[str, bytes]) -> None:
268
+ discussion = repo / ".project" / "discuss"
269
+ if discussion.is_symlink() or (discussion.exists() and not discussion.is_dir()):
270
+ raise UndoError("discussion restore destination is unsafe")
271
+ discussion.mkdir(exist_ok=True)
272
+ for name, data in files.items():
273
+ temporary_name: Optional[str] = None
274
+ try:
275
+ with tempfile.NamedTemporaryFile(dir=discussion, delete=False) as handle:
276
+ handle.write(data)
277
+ handle.flush()
278
+ os.fsync(handle.fileno())
279
+ temporary_name = handle.name
280
+ os.replace(temporary_name, discussion / name)
281
+ temporary_name = None
282
+ finally:
283
+ if temporary_name:
284
+ Path(temporary_name).unlink(missing_ok=True)
285
+ try:
286
+ archive_milestone.validate_discussion_directory(
287
+ discussion,
288
+ require_dispositions=False,
289
+ )
290
+ except ArchiveError as error:
291
+ raise UndoError(f"restored discussion records are invalid: {error}") from error
292
+
293
+
294
+ def _commit_discussion(repo: Path, revision: str) -> Optional[dict[str, bytes]]:
295
+ files: dict[str, bytes] = {}
296
+ for name in archive_milestone.DISCUSSION_FILES:
297
+ result = _run_git(
298
+ repo,
299
+ "show",
300
+ f"{revision}:.project/discuss/{name}",
301
+ check=False,
302
+ )
303
+ if result.returncode == 0:
304
+ files[name] = result.stdout.encode()
305
+ if not files:
306
+ return None
307
+ if set(files) != set(archive_milestone.DISCUSSION_FILES):
308
+ raise UndoError(f"commit {revision} has incomplete discussion records")
309
+ return files
310
+
311
+
312
+ def _checkpoint_discussion(
313
+ repo: Path, head: str, parent: str
314
+ ) -> Optional[dict[str, bytes]]:
315
+ current = _commit_discussion(repo, head)
316
+ if current is None:
317
+ return None
318
+ previous = _commit_discussion(repo, parent)
319
+ if previous is not None and any(
320
+ not current[name].startswith(previous[name]) for name in current
321
+ ):
322
+ raise UndoError("checkpoint discussion records are not append-only")
323
+ with tempfile.TemporaryDirectory() as temporary:
324
+ directory = Path(temporary)
325
+ for name, data in current.items():
326
+ (directory / name).write_bytes(data)
327
+ try:
328
+ archive_milestone.validate_discussion_directory(
329
+ directory,
330
+ require_dispositions=False,
331
+ )
332
+ except ArchiveError as error:
333
+ raise UndoError(f"checkpoint discussion records are invalid: {error}") from error
334
+ return current
335
+
336
+
337
+ def _encode_discussion(
338
+ files: Optional[dict[str, bytes]],
339
+ ) -> Optional[dict[str, str]]:
340
+ if files is None:
341
+ return None
342
+ return {name: data.decode("utf-8") for name, data in files.items()}
343
+
344
+
345
+ def _decode_discussion(value: object) -> Optional[dict[str, bytes]]:
346
+ if value is None:
347
+ return None
348
+ expected = set(archive_milestone.DISCUSSION_FILES)
349
+ if not isinstance(value, dict) or set(value) != expected:
350
+ raise UndoError("undo transaction has invalid discussion records")
351
+ if any(not isinstance(content, str) for content in value.values()):
352
+ raise UndoError("undo transaction discussion records must be text")
353
+ return {str(name): content.encode("utf-8") for name, content in value.items()}
354
+
355
+
356
+ def pending_transaction(repo: Path) -> Optional[dict[str, object]]:
357
+ try:
358
+ return undo_transaction(_repo_root(repo))
359
+ except PipelineStateError as error:
360
+ raise UndoError(str(error)) from error
361
+
362
+
363
+ def _archive_metadata_error(
364
+ repo: Path,
365
+ state_fields: dict[str, object],
366
+ configured: str,
367
+ dirty: Sequence[str],
368
+ ) -> Optional[str]:
369
+ archive = _normalize_archive(configured)
370
+ tracked = _run_git(
371
+ repo,
372
+ "ls-tree",
373
+ "-r",
374
+ "--name-only",
375
+ "HEAD",
376
+ "--",
377
+ *(f".project/{name}" for name in archive_milestone.TRANSACTION_DIRECTORIES),
378
+ ).stdout.splitlines()
379
+ allowed = {
380
+ ".project/STATE.md",
381
+ *tracked,
382
+ *(
383
+ f".project/discuss/{name}"
384
+ for name in archive_milestone.DISCUSSION_FILES
385
+ ),
386
+ }
387
+ if state_fields.get("phase") == "shipped":
388
+ allowed.add(".project/ROADMAP.md")
389
+ unowned = sorted(
390
+ path
391
+ for path in dirty
392
+ if path not in allowed
393
+ and path != archive
394
+ and not path.startswith(f"{archive}/")
395
+ )
396
+ if unowned:
397
+ return "archive undo found unowned .project changes: " + ", ".join(unowned)
398
+
399
+ archive_path = repo / archive
400
+ expected_inventory = {path.removeprefix(".project/") for path in tracked}
401
+ try:
402
+ actual_inventory = list(archive_milestone.archive_file_inventory(archive_path))
403
+ final_review = archive_path / "review" / "FINAL.md"
404
+ gap_reviews = sorted((archive_path / "review").glob("final-gap-*.md"))
405
+ if final_review.exists() or gap_reviews:
406
+ reviewed_head, _ = archive_milestone.parse_final_review(archive_path)
407
+ archive_milestone.validate_gap_reviews(archive_path, reviewed_head)
408
+ if reviewed_head != _optional_rev(repo, "HEAD"):
409
+ return "archive undo final review does not match current HEAD"
410
+ expected_inventory.add("review/FINAL.md")
411
+ expected_inventory.update(
412
+ path.relative_to(archive_path).as_posix() for path in gap_reviews
413
+ )
414
+ actual_directories = sorted(
415
+ path.relative_to(archive_path).as_posix()
416
+ for path in archive_path.rglob("*")
417
+ if path.is_dir()
418
+ )
419
+ except (ArchiveError, OSError) as error:
420
+ return f"archive undo cannot prove archive ownership: {error}"
421
+ expected_inventory = sorted(expected_inventory)
422
+ expected_directories = sorted(
423
+ {
424
+ parent.as_posix()
425
+ for relative in expected_inventory
426
+ for parent in PurePosixPath(relative).parents
427
+ if parent != PurePosixPath(".")
428
+ }
429
+ )
430
+ if (
431
+ actual_inventory != expected_inventory
432
+ or actual_directories != expected_directories
433
+ ):
434
+ missing = sorted(set(expected_inventory) - set(actual_inventory))
435
+ extra = sorted(set(actual_inventory) - set(expected_inventory))
436
+ extra_directories = sorted(
437
+ set(actual_directories) - set(expected_directories)
438
+ )
439
+ return (
440
+ "archive undo inventory differs from helper-owned files: "
441
+ f"missing={missing}, extra={extra}, extra_directories={extra_directories}"
442
+ )
443
+ manifest_paths = sorted(
444
+ path.relative_to(archive_path).as_posix()
445
+ for path in archive_path.rglob("MANIFEST.md")
446
+ )
447
+ if manifest_paths not in ([], ["MANIFEST.md"]):
448
+ return "archive undo found unowned MANIFEST.md paths: " + ", ".join(
449
+ manifest_paths
450
+ )
451
+ manifest = archive_path / "MANIFEST.md"
452
+ if manifest.exists() or manifest.is_symlink():
453
+ try:
454
+ archive_milestone.validate_manifest(repo, archive_path, load_state(repo)[0])
455
+ except (ArchiveError, OSError) as error:
456
+ return f"archive undo cannot prove MANIFEST.md ownership: {error}"
457
+
458
+ head_state = _git_text(repo, "HEAD", ".project/STATE.md")
459
+ prepared_state = archive_milestone.set_frontmatter_value(
460
+ head_state,
461
+ "archive",
462
+ configured,
463
+ )
464
+ current_state = (repo / ".project" / "STATE.md").read_text(encoding="utf-8")
465
+ phase = state_fields.get("phase")
466
+ status = state_fields.get("status")
467
+ if (phase, status) == ("ship", "active"):
468
+ if current_state != prepared_state:
469
+ return "archive undo found unowned STATE.md changes"
470
+ if ".project/ROADMAP.md" in dirty:
471
+ return "archive undo found unowned ROADMAP.md changes"
472
+ return None
473
+ if (phase, status) != ("shipped", "done"):
474
+ return "archive undo requires ship/active or shipped/done"
475
+
476
+ prepared = _state_from_text(prepared_state, "prepared archive STATE.md")
477
+ events = (
478
+ "archive preflight passed; shipment recorded",
479
+ "archive preflight passed; shipment recorded; program complete",
480
+ )
481
+ rendered_states = []
482
+ for event in events:
483
+ try:
484
+ _, _, rendered = _render_transition(
485
+ prepared,
486
+ prepared_state,
487
+ {
488
+ "phase": "ship",
489
+ "status": "active",
490
+ "branch": prepared.branch,
491
+ "archive": configured,
492
+ },
493
+ {"phase": "shipped", "status": "done"},
494
+ event,
495
+ ".project",
496
+ )
497
+ except PipelineStateError:
498
+ continue
499
+ rendered_states.append(rendered)
500
+ if current_state not in rendered_states:
501
+ return "archive undo found unowned shipment STATE.md changes"
502
+ if ".project/ROADMAP.md" in dirty:
503
+ try:
504
+ head_roadmap = _git_text(repo, "HEAD", ".project/ROADMAP.md")
505
+ expected_roadmap = _shipment_roadmap(head_roadmap, prepared, configured)
506
+ except (PipelineStateError, UndoError):
507
+ return "archive undo cannot prove ROADMAP.md shipment ownership"
508
+ current_roadmap = (repo / ".project" / "ROADMAP.md").read_text(
509
+ encoding="utf-8"
510
+ )
511
+ if current_roadmap != expected_roadmap:
512
+ return "archive undo found unowned ROADMAP.md changes"
513
+ return None
514
+
515
+
516
+ def _prepare_transaction(
517
+ repo: Path,
518
+ kind: str,
519
+ expected_head: str,
520
+ parent: str,
521
+ branch: str,
522
+ archive: Optional[str],
523
+ discussion: Optional[dict[str, bytes]],
524
+ ) -> dict[str, object]:
525
+ transaction = {
526
+ "schema": UNDO_TRANSACTION_SCHEMA,
527
+ "repo": str(repo),
528
+ "kind": kind,
529
+ "expected_head": expected_head,
530
+ "parent": parent,
531
+ "branch": branch,
532
+ "worktree_fingerprint": _worktree_fingerprint(repo),
533
+ "archive": archive,
534
+ "archive_fingerprint": (
535
+ _tree_fingerprint(repo / _normalize_archive(archive))
536
+ if archive is not None
537
+ else None
538
+ ),
539
+ "discussion": _encode_discussion(discussion),
540
+ }
541
+ _write_json(_git_path(repo, UNDO_TRANSACTION_NAME), transaction)
542
+ return transaction
543
+
544
+
545
+ def _tree_fingerprint(root: Path) -> str:
546
+ if root.is_symlink() or not root.is_dir():
547
+ raise UndoError(f"undo-owned tree must be a real directory: {root}")
548
+ digest = hashlib.sha256()
549
+ for path in sorted(root.rglob("*")):
550
+ relative = path.relative_to(root).as_posix()
551
+ if path.is_symlink():
552
+ raise UndoError(f"undo-owned tree contains a symlink: {path}")
553
+ if path.is_dir():
554
+ digest.update(f"d\0{relative}\0".encode())
555
+ elif path.is_file():
556
+ digest.update(f"f\0{relative}\0{path.stat().st_mode & 0o777}\0".encode())
557
+ digest.update(path.read_bytes())
558
+ else:
559
+ raise UndoError(f"undo-owned tree contains a special file: {path}")
560
+ return digest.hexdigest()
561
+
562
+
563
+ def _worktree_fingerprint(repo: Path) -> str:
564
+ digest = hashlib.sha256()
565
+ for arguments in (("diff", "--binary"), ("diff", "--cached", "--binary")):
566
+ output = _run_git(repo, *arguments).stdout
567
+ digest.update("\0".join(arguments).encode())
568
+ digest.update(output.encode())
569
+ untracked = sorted(
570
+ path
571
+ for path in _run_git(
572
+ repo,
573
+ "ls-files",
574
+ "--others",
575
+ "--exclude-standard",
576
+ "-z",
577
+ ).stdout.split("\0")
578
+ if path
579
+ )
580
+ for relative in untracked:
581
+ path = repo / relative
582
+ digest.update(relative.encode())
583
+ if path.is_symlink():
584
+ digest.update(b"l\0")
585
+ digest.update(os.readlink(path).encode())
586
+ elif path.is_file():
587
+ digest.update(f"f\0{path.stat().st_mode & 0o777}\0".encode())
588
+ digest.update(path.read_bytes())
589
+ else:
590
+ digest.update(b"special\0")
591
+ return digest.hexdigest()
592
+
593
+
594
+ def _post_reset_recovery_error(
595
+ repo: Path,
596
+ transaction: dict[str, object],
597
+ ) -> Optional[str]:
598
+ dirty = set(_worktree_changes(repo))
599
+ allowed = set()
600
+ discussion = transaction.get("discussion")
601
+ if isinstance(discussion, dict):
602
+ for name, content in discussion.items():
603
+ relative = f".project/discuss/{name}"
604
+ allowed.add(relative)
605
+ path = repo / relative
606
+ if relative in dirty and (
607
+ not path.is_file()
608
+ or path.is_symlink()
609
+ or path.read_text(encoding="utf-8") != content
610
+ ):
611
+ return f"undo recovery discussion drifted: {relative}"
612
+ archive = transaction.get("archive")
613
+ if isinstance(archive, str):
614
+ archive_path = repo / _normalize_archive(archive)
615
+ if archive_path.exists() or archive_path.is_symlink():
616
+ expected = transaction.get("archive_fingerprint")
617
+ if _tree_fingerprint(archive_path) != expected:
618
+ return "undo recovery archive inventory drifted"
619
+ allowed.update(
620
+ path.relative_to(repo).as_posix()
621
+ for path in archive_path.rglob("*")
622
+ if path.is_file()
623
+ )
624
+ unexpected = sorted(dirty - allowed)
625
+ if unexpected:
626
+ return "undo recovery found unowned worktree changes: " + ", ".join(unexpected)
627
+ return None
628
+
629
+
630
+ def _transaction_recovery_stage(
631
+ repo: Path,
632
+ transaction: dict[str, object],
633
+ ) -> str:
634
+ expected_head = str(transaction["expected_head"])
635
+ parent = str(transaction["parent"])
636
+ current = _optional_rev(repo, "HEAD")
637
+ if transaction["kind"] == "checkpoint":
638
+ allowed = {expected_head, parent}
639
+ else:
640
+ allowed = {expected_head}
641
+ if current not in allowed:
642
+ raise UndoError("HEAD moved during undo recovery")
643
+ current_branch = status_state(repo)["git"].get("branch")
644
+ if current_branch != transaction["branch"]:
645
+ raise UndoError(
646
+ f"undo recovery is on {current_branch or '<detached>'}, expected {transaction['branch']}"
647
+ )
648
+ if current == expected_head and _worktree_fingerprint(repo) == transaction.get(
649
+ "worktree_fingerprint"
650
+ ):
651
+ return "prepared"
652
+ recovery_error = _post_reset_recovery_error(repo, transaction)
653
+ if recovery_error:
654
+ raise UndoError(recovery_error)
655
+ return "reset"
656
+
657
+
658
+ def _finish_transaction(repo: Path, transaction: dict[str, object]) -> None:
659
+ stage = _transaction_recovery_stage(repo, transaction)
660
+ if stage == "prepared":
661
+ _require_unpublished_reset(repo, str(transaction["branch"]))
662
+ _reset_to(repo, str(transaction["parent"]))
663
+ discussion = _decode_discussion(transaction.get("discussion"))
664
+ if discussion is not None:
665
+ _restore_discussion(repo, discussion)
666
+ archive = transaction.get("archive")
667
+ if isinstance(archive, str) and not _archive_in_head(repo, archive):
668
+ _remove_untracked_tree(repo, archive)
669
+ path = _git_path(repo, UNDO_TRANSACTION_NAME)
670
+ path.unlink()
671
+ descriptor = os.open(path.parent, os.O_RDONLY)
672
+ try:
673
+ os.fsync(descriptor)
674
+ finally:
675
+ os.close(descriptor)
676
+
677
+
678
+ def classify_undo(repo: Path) -> dict[str, object]:
679
+ resolved = _repo_root(repo)
680
+ status = status_state(resolved)
681
+ state_fields = status["state"]
682
+ git = status["git"]
683
+ head = git.get("head")
684
+ branch = git.get("branch")
685
+ dirty = list(git.get("dirty") or [])
686
+ subject = str(git.get("subject") or "")
687
+ journals = _active_journals(status["journals"])
688
+ recorded_branch = state_fields.get("branch")
689
+ archive = state_fields.get("archive")
690
+
691
+ if not head:
692
+ return _blocked(["HEAD has no commit"])
693
+ if recorded_branch is None or not is_bound_branch(str(recorded_branch)):
694
+ return _blocked(["undo requires STATE.branch to be a bound gsd-path/M00N branch"])
695
+ if branch != recorded_branch:
696
+ return _blocked(
697
+ [f"current branch {branch or '<detached>'} != STATE.branch {recorded_branch}"]
698
+ )
699
+ if journals:
700
+ names = ", ".join(sorted(journals))
701
+ return _blocked(
702
+ [f"an in-progress helper journal exists ({names}); resume that transaction first"]
703
+ )
704
+ if git.get("ancestor_of_origin_main"):
705
+ return _blocked(["HEAD is an ancestor of origin/main; undo would rewrite shipped history"])
706
+
707
+ product = _product_dirty(dirty)
708
+ if product:
709
+ return _blocked(
710
+ ["worktree has changes outside .project/: " + ", ".join(product)]
711
+ )
712
+
713
+ if archive and state_fields.get("phase") in {"ship", "shipped"}:
714
+ archive_path = _normalize_archive(str(archive))
715
+ if not _archive_in_head(resolved, archive_path):
716
+ try:
717
+ _discussion_extension(resolved, archive_path)
718
+ except UndoError as error:
719
+ return _blocked([str(error)])
720
+ metadata_error = _archive_metadata_error(
721
+ resolved,
722
+ state_fields,
723
+ str(archive),
724
+ dirty,
725
+ )
726
+ if metadata_error:
727
+ return _blocked([metadata_error])
728
+ return {
729
+ "kind": "uncommitted-archive",
730
+ "head": head,
731
+ "parent": head,
732
+ "subject": subject,
733
+ "archive": archive_path,
734
+ "branch": recorded_branch,
735
+ "effects": [
736
+ "restore tracked .project/ files to HEAD",
737
+ f"delete untracked {archive_path}",
738
+ ],
739
+ "blocked": [],
740
+ }
741
+
742
+ next_dir = resolved / ".project" / "next"
743
+ next_tracked = _run_git(
744
+ resolved,
745
+ "ls-files",
746
+ "--",
747
+ ".project/next",
748
+ check=False,
749
+ ).stdout.splitlines()
750
+ if (next_dir.exists() or next_dir.is_symlink()) and not any(
751
+ line.strip() for line in next_tracked
752
+ ):
753
+ if next_dir.is_symlink() or not next_dir.is_dir():
754
+ return _blocked(["lookahead discard requires a real .project/next directory"])
755
+ try:
756
+ next_state, _, _ = load_state(resolved, ".project/next")
757
+ except (PipelineStateError, OSError) as error:
758
+ return _blocked([f"lookahead ownership is unproven: {error}"])
759
+ if next_state.project != state_fields.get("project"):
760
+ return _blocked(["lookahead ownership is unproven: project differs"])
761
+ extra = [
762
+ path
763
+ for path in dirty
764
+ if path != ".project/next" and not path.startswith(".project/next/")
765
+ ]
766
+ if extra:
767
+ return _blocked(
768
+ ["lookahead discard requires a clean worktree except .project/next/"]
769
+ )
770
+ return {
771
+ "kind": "lookahead",
772
+ "head": head,
773
+ "parent": head,
774
+ "subject": subject,
775
+ "branch": recorded_branch,
776
+ "effects": ["delete untracked .project/next/"],
777
+ "blocked": [],
778
+ }
779
+
780
+ if dirty:
781
+ return _blocked(["worktree is dirty; restore or commit before undoing HEAD"])
782
+
783
+ _, body = _commit_subject_body(resolved, head)
784
+ parent = _parent_sha(resolved, head)
785
+ if parent is None:
786
+ return _blocked(["HEAD has no parent; undo refuses to delete the only commit"])
787
+ if git.get("published"):
788
+ return _blocked(
789
+ [
790
+ f"HEAD is published on origin/{recorded_branch}; undo never force-pushes"
791
+ ]
792
+ )
793
+
794
+ if subject in {PLAN_APPROVAL_SUBJECT, ROADMAP_APPROVAL_SUBJECT}:
795
+ kind = "plan" if subject == PLAN_APPROVAL_SUBJECT else "roadmap"
796
+ proof_error = _checkpoint_proof_error(resolved, head, parent, subject)
797
+ if proof_error:
798
+ return _blocked([f"checkpoint ownership is unproven: {proof_error}"])
799
+ return {
800
+ "kind": "checkpoint",
801
+ "checkpoint": kind,
802
+ "branch": recorded_branch,
803
+ "head": head,
804
+ "parent": parent,
805
+ "subject": subject,
806
+ "effects": [
807
+ f"reset {recorded_branch} to {parent}",
808
+ f"STATE returns to {kind}/active",
809
+ ],
810
+ "blocked": [],
811
+ }
812
+
813
+ if TASK_SUBJECT_RE.fullmatch(subject) and body.lstrip().startswith("Task:"):
814
+ proof_error = _task_proof_error(resolved, head)
815
+ if proof_error:
816
+ return _blocked([f"task landing ownership is unproven: {proof_error}"])
817
+ return {
818
+ "kind": "task",
819
+ "branch": recorded_branch,
820
+ "head": head,
821
+ "parent": parent,
822
+ "subject": subject,
823
+ "effects": [
824
+ f"reset {recorded_branch} to {parent}",
825
+ "the last task landing commit is dropped",
826
+ ],
827
+ "blocked": [],
828
+ }
829
+
830
+ slug = state_fields.get("milestone") or ""
831
+ archive_name = ""
832
+ if archive:
833
+ archive_name = Path(_normalize_archive(str(archive))).name
834
+ if archive_name and is_ship_subject(subject, archive_name):
835
+ return _blocked(
836
+ ["HEAD is a ship commit; finish integration with $gsd-path-ship"]
837
+ )
838
+ if subject.startswith(PROMOTION_PREFIX):
839
+ return _blocked(["HEAD is a lookahead promotion commit; undo refuses it"])
840
+ if subject.startswith(ABANDON_PREFIX):
841
+ return _blocked(["HEAD is a milestone-abandon commit; abandoned entries are immutable"])
842
+ if subject.startswith(INTEGRATE_PREFIX):
843
+ return _blocked(["HEAD is an integration merge; undo never rewrites main"])
844
+ return _blocked([f"HEAD is not an undoable pipeline commit: {subject or '<empty>'}"])
845
+
846
+
847
+ def preview(repo: Path) -> dict[str, object]:
848
+ resolved = _repo_root(repo)
849
+ transaction = pending_transaction(resolved)
850
+ if transaction is None:
851
+ target = classify_undo(resolved)
852
+ else:
853
+ try:
854
+ stage = _transaction_recovery_stage(resolved, transaction)
855
+ if stage == "prepared":
856
+ _require_unpublished_reset(resolved, str(transaction["branch"]))
857
+ except UndoError as error:
858
+ target = _blocked([str(error)])
859
+ else:
860
+ target = {
861
+ "kind": transaction["kind"],
862
+ "head": transaction["expected_head"],
863
+ "parent": transaction["parent"],
864
+ "subject": "interrupted undo transaction",
865
+ "branch": transaction["branch"],
866
+ "archive": transaction["archive"],
867
+ "effects": ["resume the validated interrupted undo transaction"],
868
+ "blocked": [],
869
+ }
870
+ state, _, path = load_state(resolved)
871
+ return {
872
+ "schema": UNDO_SCHEMA,
873
+ "status": "preview",
874
+ "path": str(path),
875
+ "state": state.json(),
876
+ "target": target,
877
+ "apply": None
878
+ if target.get("kind") is None
879
+ else {
880
+ "command": "apply",
881
+ "kind": target["kind"],
882
+ "expected_head": target["head"],
883
+ },
884
+ }
885
+
886
+
887
+ def _reset_to(repo: Path, revision: str) -> None:
888
+ result = _run_git(repo, "reset", "--hard", revision, check=False)
889
+ if result.returncode != 0:
890
+ detail = result.stderr.strip() or result.stdout.strip() or "git reset failed"
891
+ raise UndoError(f"git reset --hard {revision} failed: {detail}")
892
+
893
+
894
+ def _require_unpublished_reset(repo: Path, expected_branch: str) -> None:
895
+ git = status_state(repo)["git"]
896
+ if git.get("branch") != expected_branch:
897
+ raise UndoError(
898
+ f"reset is on {git.get('branch') or '<detached>'}, expected {expected_branch}"
899
+ )
900
+ if git.get("published"):
901
+ raise UndoError("HEAD became published on its recorded remote branch")
902
+ if git.get("ancestor_of_origin_main"):
903
+ raise UndoError("HEAD became published through origin/main")
904
+
905
+
906
+ def _remove_untracked_tree(repo: Path, relative: str) -> None:
907
+ normalized = PurePosixPath(relative)
908
+ if normalized.is_absolute() or ".." in normalized.parts or not normalized.parts:
909
+ raise UndoError(f"refusing to delete an invalid path: {relative}")
910
+ current = repo
911
+ for part in normalized.parts[:-1]:
912
+ current /= part
913
+ if current.is_symlink():
914
+ raise UndoError(f"refusing to delete through a symlink parent: {relative}")
915
+ path = repo.joinpath(*normalized.parts)
916
+ if path.is_symlink():
917
+ path.unlink()
918
+ return
919
+ if path.is_dir():
920
+ shutil.rmtree(path)
921
+ return
922
+ if path.exists():
923
+ path.unlink()
924
+
925
+
926
+ def apply_undo(repo: Path, kind: str, expected_head: str) -> dict[str, object]:
927
+ if kind not in KINDS:
928
+ raise UndoError(f"unsupported undo kind: {kind}")
929
+ resolved = _repo_root(repo)
930
+ transaction = pending_transaction(resolved)
931
+ if transaction is not None:
932
+ if transaction["kind"] != kind or transaction["expected_head"] != expected_head:
933
+ raise UndoError("undo transaction differs from this apply request")
934
+ _finish_transaction(resolved, transaction)
935
+ after = preview(resolved)
936
+ return {
937
+ "schema": UNDO_SCHEMA,
938
+ "status": "applied",
939
+ "kind": kind,
940
+ "head_before": expected_head,
941
+ "head_after": _optional_rev(resolved, "HEAD"),
942
+ "state": after["state"],
943
+ "remaining": after["target"],
944
+ }
945
+ previewed = preview(resolved)
946
+ target = previewed["target"]
947
+ if target.get("kind") is None:
948
+ raise UndoError("; ".join(target.get("blocked") or ["undo is blocked"]))
949
+ if target["kind"] != kind:
950
+ raise UndoError(
951
+ f"preview kind is {target['kind']}, not {kind}; rerun preview"
952
+ )
953
+ if target["head"] != expected_head:
954
+ raise UndoError("expected-head does not match current HEAD from preview")
955
+ head = _optional_rev(resolved, "HEAD")
956
+ if head != expected_head:
957
+ raise UndoError("HEAD moved after preview; rerun preview")
958
+
959
+ if kind == "checkpoint":
960
+ parent = target["parent"]
961
+ if not parent:
962
+ raise UndoError("parent revision is not a safe reset target")
963
+ discussion = _checkpoint_discussion(resolved, expected_head, str(parent))
964
+ transaction = _prepare_transaction(
965
+ resolved,
966
+ kind,
967
+ expected_head,
968
+ str(parent),
969
+ str(target["branch"]),
970
+ None,
971
+ discussion,
972
+ )
973
+ _finish_transaction(resolved, transaction)
974
+ elif kind == "task":
975
+ parent = target["parent"]
976
+ if not parent:
977
+ raise UndoError("parent revision is not a safe reset target")
978
+ if _worktree_changes(resolved):
979
+ raise UndoError("worktree changed after task undo preview")
980
+ _require_unpublished_reset(resolved, str(target["branch"]))
981
+ _reset_to(resolved, str(parent))
982
+ elif kind == "uncommitted-archive":
983
+ archive = str(target["archive"])
984
+ discussion = _discussion_extension(resolved, archive)
985
+ transaction = _prepare_transaction(
986
+ resolved,
987
+ kind,
988
+ expected_head,
989
+ expected_head,
990
+ str(target["branch"]),
991
+ archive,
992
+ discussion,
993
+ )
994
+ _finish_transaction(resolved, transaction)
995
+ else:
996
+ _remove_untracked_tree(resolved, ".project/next")
997
+
998
+ after = preview(resolved)
999
+ return {
1000
+ "schema": UNDO_SCHEMA,
1001
+ "status": "applied",
1002
+ "kind": kind,
1003
+ "head_before": expected_head,
1004
+ "head_after": after["target"].get("head") or _optional_rev(resolved, "HEAD"),
1005
+ "state": after["state"],
1006
+ "remaining": after["target"],
1007
+ }
1008
+
1009
+
1010
+ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
1011
+ parser = argparse.ArgumentParser(description=__doc__)
1012
+ subparsers = parser.add_subparsers(dest="command", required=True)
1013
+ preview_parser = subparsers.add_parser("preview")
1014
+ preview_parser.add_argument("--repo", required=True, type=Path)
1015
+ apply_parser = subparsers.add_parser("apply")
1016
+ apply_parser.add_argument("--repo", required=True, type=Path)
1017
+ apply_parser.add_argument("--kind", required=True, choices=KINDS)
1018
+ apply_parser.add_argument("--expected-head", required=True)
1019
+ return parser.parse_args(argv)
1020
+
1021
+
1022
+ def main(argv: Optional[Sequence[str]] = None) -> int:
1023
+ args = parse_args(argv)
1024
+ try:
1025
+ if args.command == "preview":
1026
+ result = preview(args.repo)
1027
+ elif args.command == "apply":
1028
+ result = apply_undo(args.repo, args.kind, args.expected_head)
1029
+ else: # pragma: no cover - argparse rejects unknown commands
1030
+ raise UndoError(f"unknown command: {args.command}")
1031
+ except (UndoError, PipelineStateError) as error:
1032
+ print(f"error: {error}", file=sys.stderr)
1033
+ return 1
1034
+ print(json.dumps(result, sort_keys=True))
1035
+ return 0
1036
+
1037
+
1038
+ if __name__ == "__main__":
1039
+ raise SystemExit(main())