@opengsd/gsd-path 1.0.0 → 1.1.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 (395) hide show
  1. package/AGENTS.md +28 -11
  2. package/DOCS.md +6 -5
  3. package/FULL.md +9 -13
  4. package/QUICK.md +3 -3
  5. package/README.md +151 -14
  6. package/RUNTIME.md +7 -1
  7. package/WORKFLOW.md +30 -14
  8. package/package.json +15 -1
  9. package/platforms/shared-agents/dispatch.md +6 -0
  10. package/scripts/archive_milestone.py +10 -118
  11. package/scripts/bootstrap_repository.py +25 -3
  12. package/scripts/build_recovery.py +287 -0
  13. package/scripts/check_docs_audit.py +5 -3
  14. package/scripts/check_handoffs.py +1 -1
  15. package/scripts/detect_project.py +11 -2
  16. package/scripts/dispatch_driver.py +4 -2
  17. package/scripts/git_guard.py +2 -1
  18. package/scripts/install.mjs +2 -0
  19. package/scripts/install.py +2 -0
  20. package/scripts/integration.py +28 -22
  21. package/scripts/isolation.py +9 -1
  22. package/scripts/lean_verification.py +1 -1
  23. package/scripts/pipeline_diagnose.py +12 -3
  24. package/scripts/pipeline_state.py +114 -186
  25. package/scripts/pipeline_undo.py +11 -7
  26. package/scripts/promote_lookahead.py +30 -162
  27. package/scripts/roadmap.py +321 -0
  28. package/scripts/skill-resources.json +79 -1
  29. package/scripts/state_checkpoint.py +109 -72
  30. package/scripts/state_promote.py +42 -44
  31. package/scripts/task_context.py +73 -0
  32. package/scripts/token_budget.py +40 -1
  33. package/scripts/workflow_run.py +116 -7
  34. package/skills/gsd-path/BUILD.md +47 -575
  35. package/skills/gsd-path/DECIDE.md +2 -6
  36. package/skills/gsd-path/DEFINE.md +10 -14
  37. package/skills/gsd-path/INSPECT.md +30 -8
  38. package/skills/gsd-path/PLAN.md +25 -17
  39. package/skills/gsd-path/RESEARCH.md +3 -6
  40. package/skills/gsd-path/ROADMAP.md +22 -58
  41. package/skills/gsd-path/SHIP.md +5 -4
  42. package/skills/gsd-path/SKILL.md +24 -14
  43. package/skills/gsd-path/references/build-abandon.md +33 -0
  44. package/skills/gsd-path/references/build-native.md +494 -0
  45. package/skills/gsd-path/references/build-recovery.md +78 -0
  46. package/skills/gsd-path/references/coder.md +8 -3
  47. package/skills/gsd-path/references/dispatch.md +6 -0
  48. package/skills/gsd-path/references/docs-auditor.md +14 -0
  49. package/skills/gsd-path/references/reviewer-final.md +21 -0
  50. package/skills/gsd-path/references/reviewer-panel.md +17 -0
  51. package/skills/gsd-path/references/reviewer-skeptic.md +20 -0
  52. package/skills/gsd-path/references/reviewer-wave.md +89 -0
  53. package/skills/gsd-path/references/reviewer.md +12 -150
  54. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  55. package/skills/gsd-path/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  56. package/skills/gsd-path/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
  57. package/skills/gsd-path/scripts/__pycache__/build_state.cpython-312.pyc +0 -0
  58. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  59. package/skills/gsd-path/scripts/__pycache__/check_task_briefs.cpython-312.pyc +0 -0
  60. package/skills/gsd-path/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  61. package/skills/gsd-path/scripts/__pycache__/discussion_records.cpython-312.pyc +0 -0
  62. package/skills/gsd-path/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  63. package/skills/gsd-path/scripts/__pycache__/integration.cpython-312.pyc +0 -0
  64. package/skills/gsd-path/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  65. package/skills/gsd-path/scripts/__pycache__/loop_run.cpython-312.pyc +0 -0
  66. package/skills/gsd-path/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  67. package/skills/gsd-path/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  68. package/skills/gsd-path/scripts/__pycache__/review_findings.cpython-312.pyc +0 -0
  69. package/skills/gsd-path/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  70. package/skills/gsd-path/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  71. package/skills/gsd-path/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  72. package/skills/gsd-path/scripts/__pycache__/task_context.cpython-312.pyc +0 -0
  73. package/skills/gsd-path/scripts/__pycache__/workflow_run.cpython-312.pyc +0 -0
  74. package/skills/gsd-path/scripts/archive_milestone.py +10 -118
  75. package/skills/gsd-path/scripts/bootstrap_repository.py +25 -3
  76. package/skills/gsd-path/scripts/build_recovery.py +287 -0
  77. package/skills/gsd-path/scripts/check_docs_audit.py +5 -3
  78. package/skills/gsd-path/scripts/check_handoffs.py +1 -1
  79. package/skills/gsd-path/scripts/detect_project.py +11 -2
  80. package/skills/gsd-path/scripts/dispatch_driver.py +4 -2
  81. package/skills/gsd-path/scripts/integration.py +28 -22
  82. package/skills/gsd-path/scripts/isolation.py +9 -1
  83. package/skills/gsd-path/scripts/lean_verification.py +1 -1
  84. package/skills/gsd-path/scripts/pipeline_state.py +114 -186
  85. package/skills/gsd-path/scripts/promote_lookahead.py +30 -162
  86. package/skills/gsd-path/scripts/roadmap.py +321 -0
  87. package/skills/gsd-path/scripts/state_checkpoint.py +109 -72
  88. package/skills/gsd-path/scripts/state_promote.py +42 -44
  89. package/skills/gsd-path/scripts/task_context.py +73 -0
  90. package/skills/gsd-path/scripts/token_budget.py +40 -1
  91. package/skills/gsd-path/scripts/workflow_run.py +116 -7
  92. package/skills/gsd-path/templates/wave-review.md +1 -1
  93. package/skills/gsd-path-build/SKILL.md +47 -575
  94. package/skills/gsd-path-build/references/build-abandon.md +33 -0
  95. package/skills/gsd-path-build/references/build-native.md +494 -0
  96. package/skills/gsd-path-build/references/build-recovery.md +78 -0
  97. package/skills/gsd-path-build/references/coder.md +8 -3
  98. package/skills/gsd-path-build/references/dispatch.md +6 -0
  99. package/skills/gsd-path-build/references/reviewer-final.md +21 -0
  100. package/skills/gsd-path-build/references/reviewer-panel.md +17 -0
  101. package/skills/gsd-path-build/references/reviewer-skeptic.md +20 -0
  102. package/skills/gsd-path-build/references/reviewer-wave.md +89 -0
  103. package/skills/gsd-path-build/references/reviewer.md +12 -150
  104. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  105. package/skills/gsd-path-build/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  106. package/skills/gsd-path-build/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
  107. package/skills/gsd-path-build/scripts/__pycache__/build_state.cpython-312.pyc +0 -0
  108. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  109. package/skills/gsd-path-build/scripts/__pycache__/check_task_briefs.cpython-312.pyc +0 -0
  110. package/skills/gsd-path-build/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  111. package/skills/gsd-path-build/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  112. package/skills/gsd-path-build/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  113. package/skills/gsd-path-build/scripts/__pycache__/loop_run.cpython-312.pyc +0 -0
  114. package/skills/gsd-path-build/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  115. package/skills/gsd-path-build/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  116. package/skills/gsd-path-build/scripts/__pycache__/review_findings.cpython-312.pyc +0 -0
  117. package/skills/gsd-path-build/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  118. package/skills/gsd-path-build/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  119. package/skills/gsd-path-build/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  120. package/skills/gsd-path-build/scripts/__pycache__/task_context.cpython-312.pyc +0 -0
  121. package/skills/gsd-path-build/scripts/__pycache__/workflow_run.cpython-312.pyc +0 -0
  122. package/skills/gsd-path-build/scripts/archive_milestone.py +10 -118
  123. package/skills/gsd-path-build/scripts/build_recovery.py +287 -0
  124. package/skills/gsd-path-build/scripts/check_handoffs.py +1 -1
  125. package/skills/gsd-path-build/scripts/detect_project.py +1526 -0
  126. package/skills/gsd-path-build/scripts/dispatch_driver.py +4 -2
  127. package/skills/gsd-path-build/scripts/integration.py +28 -22
  128. package/skills/gsd-path-build/scripts/isolation.py +9 -1
  129. package/skills/gsd-path-build/scripts/pipeline_state.py +114 -186
  130. package/skills/gsd-path-build/scripts/promote_lookahead.py +283 -0
  131. package/skills/gsd-path-build/scripts/roadmap.py +321 -0
  132. package/skills/gsd-path-build/scripts/state_checkpoint.py +109 -72
  133. package/skills/gsd-path-build/scripts/state_promote.py +42 -44
  134. package/skills/gsd-path-build/scripts/task_context.py +73 -0
  135. package/skills/gsd-path-build/scripts/token_budget.py +40 -1
  136. package/skills/gsd-path-build/scripts/workflow_run.py +116 -7
  137. package/skills/gsd-path-build/templates/wave-review.md +1 -1
  138. package/skills/gsd-path-decide/SKILL.md +2 -6
  139. package/skills/gsd-path-decide/references/dispatch.md +6 -0
  140. package/skills/gsd-path-decide/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  141. package/skills/gsd-path-decide/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  142. package/skills/gsd-path-decide/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  143. package/skills/gsd-path-decide/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  144. package/skills/gsd-path-decide/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  145. package/skills/gsd-path-decide/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  146. package/skills/gsd-path-decide/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  147. package/skills/gsd-path-decide/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  148. package/skills/gsd-path-decide/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  149. package/skills/gsd-path-decide/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  150. package/skills/gsd-path-decide/scripts/archive_milestone.py +2157 -0
  151. package/skills/gsd-path-decide/scripts/build_recovery.py +287 -0
  152. package/skills/gsd-path-decide/scripts/check_handoffs.py +1 -1
  153. package/skills/gsd-path-decide/scripts/detect_project.py +1526 -0
  154. package/skills/gsd-path-decide/scripts/discussion_validate.py +1321 -0
  155. package/skills/gsd-path-decide/scripts/integration.py +1439 -0
  156. package/skills/gsd-path-decide/scripts/isolation.py +9 -1
  157. package/skills/gsd-path-decide/scripts/pipeline_state.py +114 -186
  158. package/skills/gsd-path-decide/scripts/promote_lookahead.py +283 -0
  159. package/skills/gsd-path-decide/scripts/review_panel.py +553 -0
  160. package/skills/gsd-path-decide/scripts/roadmap.py +321 -0
  161. package/skills/gsd-path-decide/scripts/state_checkpoint.py +109 -72
  162. package/skills/gsd-path-decide/scripts/state_promote.py +42 -44
  163. package/skills/gsd-path-define/SKILL.md +10 -14
  164. package/skills/gsd-path-define/references/build-recovery.md +78 -0
  165. package/skills/gsd-path-define/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  166. package/skills/gsd-path-define/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  167. package/skills/gsd-path-define/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  168. package/skills/gsd-path-define/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  169. package/skills/gsd-path-define/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  170. package/skills/gsd-path-define/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  171. package/skills/gsd-path-define/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  172. package/skills/gsd-path-define/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  173. package/skills/gsd-path-define/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  174. package/skills/gsd-path-define/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  175. package/skills/gsd-path-define/scripts/archive_milestone.py +2157 -0
  176. package/skills/gsd-path-define/scripts/build_recovery.py +287 -0
  177. package/skills/gsd-path-define/scripts/check_handoffs.py +1 -1
  178. package/skills/gsd-path-define/scripts/detect_project.py +11 -2
  179. package/skills/gsd-path-define/scripts/discussion_validate.py +1321 -0
  180. package/skills/gsd-path-define/scripts/integration.py +1439 -0
  181. package/skills/gsd-path-define/scripts/isolation.py +9 -1
  182. package/skills/gsd-path-define/scripts/pipeline_state.py +114 -186
  183. package/skills/gsd-path-define/scripts/promote_lookahead.py +283 -0
  184. package/skills/gsd-path-define/scripts/review_panel.py +553 -0
  185. package/skills/gsd-path-define/scripts/roadmap.py +321 -0
  186. package/skills/gsd-path-define/scripts/state_checkpoint.py +109 -72
  187. package/skills/gsd-path-define/scripts/state_promote.py +42 -44
  188. package/skills/gsd-path-docs-audit/references/dispatch.md +6 -0
  189. package/skills/gsd-path-docs-audit/references/docs-auditor.md +14 -0
  190. package/skills/gsd-path-docs-audit/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  191. package/skills/gsd-path-docs-audit/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  192. package/skills/gsd-path-docs-audit/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
  193. package/skills/gsd-path-docs-audit/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  194. package/skills/gsd-path-docs-audit/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  195. package/skills/gsd-path-docs-audit/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  196. package/skills/gsd-path-docs-audit/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  197. package/skills/gsd-path-docs-audit/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  198. package/skills/gsd-path-docs-audit/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  199. package/skills/gsd-path-docs-audit/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  200. package/skills/gsd-path-docs-audit/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  201. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +2157 -0
  202. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +287 -0
  203. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +5 -3
  204. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1 -1
  205. package/skills/gsd-path-docs-audit/scripts/detect_project.py +1526 -0
  206. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +1321 -0
  207. package/skills/gsd-path-docs-audit/scripts/integration.py +1439 -0
  208. package/skills/gsd-path-docs-audit/scripts/isolation.py +9 -1
  209. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +114 -186
  210. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +283 -0
  211. package/skills/gsd-path-docs-audit/scripts/review_panel.py +553 -0
  212. package/skills/gsd-path-docs-audit/scripts/roadmap.py +321 -0
  213. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +109 -72
  214. package/skills/gsd-path-docs-audit/scripts/state_promote.py +42 -44
  215. package/skills/gsd-path-forensics/scripts/__pycache__/pipeline_undo.cpython-312.pyc +0 -0
  216. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +12 -3
  217. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +11 -7
  218. package/skills/gsd-path-inspect/SKILL.md +30 -8
  219. package/skills/gsd-path-inspect/references/dispatch.md +6 -0
  220. package/skills/gsd-path-inspect/references/docs-auditor.md +14 -0
  221. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  222. package/skills/gsd-path-inspect/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  223. package/skills/gsd-path-inspect/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
  224. package/skills/gsd-path-inspect/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  225. package/skills/gsd-path-inspect/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  226. package/skills/gsd-path-inspect/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  227. package/skills/gsd-path-inspect/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  228. package/skills/gsd-path-inspect/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  229. package/skills/gsd-path-inspect/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  230. package/skills/gsd-path-inspect/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  231. package/skills/gsd-path-inspect/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  232. package/skills/gsd-path-inspect/scripts/archive_milestone.py +2157 -0
  233. package/skills/gsd-path-inspect/scripts/build_recovery.py +287 -0
  234. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +5 -3
  235. package/skills/gsd-path-inspect/scripts/check_handoffs.py +1 -1
  236. package/skills/gsd-path-inspect/scripts/detect_project.py +11 -2
  237. package/skills/gsd-path-inspect/scripts/discussion_validate.py +1321 -0
  238. package/skills/gsd-path-inspect/scripts/integration.py +1439 -0
  239. package/skills/gsd-path-inspect/scripts/isolation.py +9 -1
  240. package/skills/gsd-path-inspect/scripts/pipeline_state.py +114 -186
  241. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +283 -0
  242. package/skills/gsd-path-inspect/scripts/review_panel.py +553 -0
  243. package/skills/gsd-path-inspect/scripts/roadmap.py +321 -0
  244. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +109 -72
  245. package/skills/gsd-path-inspect/scripts/state_promote.py +42 -44
  246. package/skills/gsd-path-inspect/scripts/workflow_run.py +226 -0
  247. package/skills/gsd-path-plan/SKILL.md +25 -17
  248. package/skills/gsd-path-plan/references/build-recovery.md +78 -0
  249. package/skills/gsd-path-plan/references/dispatch.md +6 -0
  250. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  251. package/skills/gsd-path-plan/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  252. package/skills/gsd-path-plan/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  253. package/skills/gsd-path-plan/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  254. package/skills/gsd-path-plan/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  255. package/skills/gsd-path-plan/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  256. package/skills/gsd-path-plan/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  257. package/skills/gsd-path-plan/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  258. package/skills/gsd-path-plan/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  259. package/skills/gsd-path-plan/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  260. package/skills/gsd-path-plan/scripts/archive_milestone.py +2157 -0
  261. package/skills/gsd-path-plan/scripts/build_recovery.py +287 -0
  262. package/skills/gsd-path-plan/scripts/check_handoffs.py +1 -1
  263. package/skills/gsd-path-plan/scripts/detect_project.py +1526 -0
  264. package/skills/gsd-path-plan/scripts/discussion_validate.py +1321 -0
  265. package/skills/gsd-path-plan/scripts/integration.py +1439 -0
  266. package/skills/gsd-path-plan/scripts/isolation.py +9 -1
  267. package/skills/gsd-path-plan/scripts/pipeline_state.py +114 -186
  268. package/skills/gsd-path-plan/scripts/promote_lookahead.py +283 -0
  269. package/skills/gsd-path-plan/scripts/roadmap.py +321 -0
  270. package/skills/gsd-path-plan/scripts/state_checkpoint.py +109 -72
  271. package/skills/gsd-path-plan/scripts/state_promote.py +42 -44
  272. package/skills/gsd-path-plan/scripts/workflow_run.py +116 -7
  273. package/skills/gsd-path-research/SKILL.md +3 -6
  274. package/skills/gsd-path-research/references/dispatch.md +6 -0
  275. package/skills/gsd-path-research/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  276. package/skills/gsd-path-research/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  277. package/skills/gsd-path-research/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  278. package/skills/gsd-path-research/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  279. package/skills/gsd-path-research/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  280. package/skills/gsd-path-research/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  281. package/skills/gsd-path-research/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  282. package/skills/gsd-path-research/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  283. package/skills/gsd-path-research/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  284. package/skills/gsd-path-research/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  285. package/skills/gsd-path-research/scripts/archive_milestone.py +2157 -0
  286. package/skills/gsd-path-research/scripts/build_recovery.py +287 -0
  287. package/skills/gsd-path-research/scripts/check_handoffs.py +1 -1
  288. package/skills/gsd-path-research/scripts/detect_project.py +1526 -0
  289. package/skills/gsd-path-research/scripts/discussion_validate.py +1321 -0
  290. package/skills/gsd-path-research/scripts/integration.py +1439 -0
  291. package/skills/gsd-path-research/scripts/isolation.py +9 -1
  292. package/skills/gsd-path-research/scripts/pipeline_state.py +114 -186
  293. package/skills/gsd-path-research/scripts/promote_lookahead.py +283 -0
  294. package/skills/gsd-path-research/scripts/review_panel.py +553 -0
  295. package/skills/gsd-path-research/scripts/roadmap.py +321 -0
  296. package/skills/gsd-path-research/scripts/state_checkpoint.py +109 -72
  297. package/skills/gsd-path-research/scripts/state_promote.py +42 -44
  298. package/skills/gsd-path-roadmap/SKILL.md +22 -58
  299. package/skills/gsd-path-roadmap/references/dispatch.md +6 -0
  300. package/skills/gsd-path-roadmap/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  301. package/skills/gsd-path-roadmap/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  302. package/skills/gsd-path-roadmap/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  303. package/skills/gsd-path-roadmap/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  304. package/skills/gsd-path-roadmap/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  305. package/skills/gsd-path-roadmap/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  306. package/skills/gsd-path-roadmap/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  307. package/skills/gsd-path-roadmap/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  308. package/skills/gsd-path-roadmap/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  309. package/skills/gsd-path-roadmap/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  310. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +10 -118
  311. package/skills/gsd-path-roadmap/scripts/build_recovery.py +287 -0
  312. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1 -1
  313. package/skills/gsd-path-roadmap/scripts/detect_project.py +11 -2
  314. package/skills/gsd-path-roadmap/scripts/integration.py +28 -22
  315. package/skills/gsd-path-roadmap/scripts/isolation.py +9 -1
  316. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +114 -186
  317. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +30 -162
  318. package/skills/gsd-path-roadmap/scripts/roadmap.py +321 -0
  319. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +109 -72
  320. package/skills/gsd-path-roadmap/scripts/state_promote.py +42 -44
  321. package/skills/gsd-path-ship/SKILL.md +5 -4
  322. package/skills/gsd-path-ship/references/dispatch.md +6 -0
  323. package/skills/gsd-path-ship/references/reviewer-final.md +21 -0
  324. package/skills/gsd-path-ship/references/reviewer-panel.md +17 -0
  325. package/skills/gsd-path-ship/references/reviewer-skeptic.md +20 -0
  326. package/skills/gsd-path-ship/references/reviewer-wave.md +89 -0
  327. package/skills/gsd-path-ship/references/reviewer.md +12 -150
  328. package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  329. package/skills/gsd-path-ship/scripts/__pycache__/archive_milestone.cpython-312.pyc +0 -0
  330. package/skills/gsd-path-ship/scripts/__pycache__/build_recovery.cpython-312.pyc +0 -0
  331. package/skills/gsd-path-ship/scripts/__pycache__/build_state.cpython-312.pyc +0 -0
  332. package/skills/gsd-path-ship/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  333. package/skills/gsd-path-ship/scripts/__pycache__/check_task_briefs.cpython-312.pyc +0 -0
  334. package/skills/gsd-path-ship/scripts/__pycache__/detect_project.cpython-312.pyc +0 -0
  335. package/skills/gsd-path-ship/scripts/__pycache__/discussion_validate.cpython-312.pyc +0 -0
  336. package/skills/gsd-path-ship/scripts/__pycache__/isolation.cpython-312.pyc +0 -0
  337. package/skills/gsd-path-ship/scripts/__pycache__/pipeline_git.cpython-312.pyc +0 -0
  338. package/skills/gsd-path-ship/scripts/__pycache__/pipeline_state.cpython-312.pyc +0 -0
  339. package/skills/gsd-path-ship/scripts/__pycache__/review_panel.cpython-312.pyc +0 -0
  340. package/skills/gsd-path-ship/scripts/__pycache__/roadmap.cpython-312.pyc +0 -0
  341. package/skills/gsd-path-ship/scripts/__pycache__/state_checkpoint.cpython-312.pyc +0 -0
  342. package/skills/gsd-path-ship/scripts/archive_milestone.py +10 -118
  343. package/skills/gsd-path-ship/scripts/build_recovery.py +287 -0
  344. package/skills/gsd-path-ship/scripts/check_handoffs.py +1 -1
  345. package/skills/gsd-path-ship/scripts/detect_project.py +1526 -0
  346. package/skills/gsd-path-ship/scripts/integration.py +28 -22
  347. package/skills/gsd-path-ship/scripts/isolation.py +9 -1
  348. package/skills/gsd-path-ship/scripts/lean_verification.py +1 -1
  349. package/skills/gsd-path-ship/scripts/pipeline_state.py +114 -186
  350. package/skills/gsd-path-ship/scripts/promote_lookahead.py +283 -0
  351. package/skills/gsd-path-ship/scripts/roadmap.py +321 -0
  352. package/skills/gsd-path-ship/scripts/state_checkpoint.py +109 -72
  353. package/skills/gsd-path-ship/scripts/state_promote.py +42 -44
  354. package/skills/gsd-path-ship/scripts/workflow_run.py +116 -7
  355. package/skills/gsd-path-ship/templates/wave-review.md +1 -1
  356. package/skills/gsd-path-undo/scripts/pipeline_undo.py +11 -7
  357. package/skills/path/BUILD.md +47 -575
  358. package/skills/path/DECIDE.md +2 -6
  359. package/skills/path/DEFINE.md +10 -14
  360. package/skills/path/INSPECT.md +30 -8
  361. package/skills/path/PLAN.md +25 -17
  362. package/skills/path/RESEARCH.md +3 -6
  363. package/skills/path/ROADMAP.md +22 -58
  364. package/skills/path/SHIP.md +5 -4
  365. package/skills/path/SKILL.md +24 -14
  366. package/skills/path/references/build-abandon.md +33 -0
  367. package/skills/path/references/build-native.md +494 -0
  368. package/skills/path/references/build-recovery.md +78 -0
  369. package/skills/path/references/coder.md +8 -3
  370. package/skills/path/references/dispatch.md +6 -0
  371. package/skills/path/references/docs-auditor.md +14 -0
  372. package/skills/path/references/reviewer-final.md +21 -0
  373. package/skills/path/references/reviewer-panel.md +17 -0
  374. package/skills/path/references/reviewer-skeptic.md +20 -0
  375. package/skills/path/references/reviewer-wave.md +89 -0
  376. package/skills/path/references/reviewer.md +12 -150
  377. package/skills/path/scripts/archive_milestone.py +10 -118
  378. package/skills/path/scripts/bootstrap_repository.py +25 -3
  379. package/skills/path/scripts/build_recovery.py +287 -0
  380. package/skills/path/scripts/check_docs_audit.py +5 -3
  381. package/skills/path/scripts/check_handoffs.py +1 -1
  382. package/skills/path/scripts/detect_project.py +11 -2
  383. package/skills/path/scripts/dispatch_driver.py +4 -2
  384. package/skills/path/scripts/integration.py +28 -22
  385. package/skills/path/scripts/isolation.py +9 -1
  386. package/skills/path/scripts/lean_verification.py +1 -1
  387. package/skills/path/scripts/pipeline_state.py +114 -186
  388. package/skills/path/scripts/promote_lookahead.py +30 -162
  389. package/skills/path/scripts/roadmap.py +321 -0
  390. package/skills/path/scripts/state_checkpoint.py +109 -72
  391. package/skills/path/scripts/state_promote.py +42 -44
  392. package/skills/path/scripts/task_context.py +73 -0
  393. package/skills/path/scripts/token_budget.py +40 -1
  394. package/skills/path/scripts/workflow_run.py +116 -7
  395. package/skills/path/templates/wave-review.md +1 -1
@@ -0,0 +1,1526 @@
1
+ #!/usr/bin/env python3
2
+ # gsd-path project runtime
3
+ """Classify or initialize a working tree.
4
+
5
+ The router, define, and inspect phases call this instead of inferring
6
+ brownfield vs greenfield from a directory listing. classify is read-only.
7
+ initialize creates .project/STATE.md after classifying a new brownfield or
8
+ greenfield project.
9
+
10
+ Verdicts:
11
+
12
+ - owned — `.project/` is a real directory with a regular `STATE.md` and no
13
+ unsafe project paths; route by that file, not detection
14
+ - orphan — `.project/` is unsafe, or it contains an entry without a safe
15
+ regular `STATE.md`; the initializer's regular temporary state file is
16
+ recoverable and does not make the project orphaned
17
+ - brownfield — no owned state, `.project/` empty or absent, and at least one
18
+ in-scope signal (package/build manifest, source file, tracked signal in
19
+ git, or substantive system documentation)
20
+ - greenfield — no owned state, `.project/` empty or absent, and no signal
21
+
22
+ Ignored path components: `.git`, `node_modules`, vendored/generated trees,
23
+ build output, and managed GSD Path installation artifacts. `.project/` is
24
+ never a brownfield signal; it only decides owned vs orphan. A title-only root
25
+ README, with or without a Markdown suffix, is not substantive documentation.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import re
34
+ import stat
35
+ import subprocess
36
+ import sys
37
+ from datetime import date
38
+ from pathlib import Path, PurePosixPath, PureWindowsPath
39
+ from typing import Iterable, NamedTuple, Optional, Sequence
40
+
41
+ try:
42
+ import fcntl
43
+
44
+ def lock_exclusive(fd: int) -> None:
45
+ fcntl.flock(fd, fcntl.LOCK_EX)
46
+
47
+ except ImportError: # Windows does not provide POSIX file locks.
48
+ import msvcrt
49
+
50
+ def lock_exclusive(fd: int) -> None:
51
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
52
+
53
+
54
+ IGNORE_DIRS = {
55
+ ".git",
56
+ "node_modules",
57
+ "dist",
58
+ "build",
59
+ "out",
60
+ "target",
61
+ "vendor",
62
+ ".venv",
63
+ "venv",
64
+ "__pycache__",
65
+ ".next",
66
+ "coverage",
67
+ ".turbo",
68
+ ".cache",
69
+ "generated",
70
+ ".tox",
71
+ ".mypy_cache",
72
+ ".pytest_cache",
73
+ "bower_components",
74
+ }
75
+
76
+ MANIFEST_NAMES = {
77
+ "package.json",
78
+ "pnpm-workspace.yaml",
79
+ "lerna.json",
80
+ "pyproject.toml",
81
+ "setup.py",
82
+ "setup.cfg",
83
+ "requirements.txt",
84
+ "Pipfile",
85
+ "Cargo.toml",
86
+ "go.mod",
87
+ "Gemfile",
88
+ "pom.xml",
89
+ "build.gradle",
90
+ "build.gradle.kts",
91
+ "settings.gradle",
92
+ "settings.gradle.kts",
93
+ "CMakeLists.txt",
94
+ "Dockerfile",
95
+ "Makefile",
96
+ "makefile",
97
+ "meson.build",
98
+ "composer.json",
99
+ "mix.exs",
100
+ "Package.swift",
101
+ "deno.json",
102
+ "deno.jsonc",
103
+ "flake.nix",
104
+ "WORKSPACE",
105
+ "WORKSPACE.bazel",
106
+ "MODULE.bazel",
107
+ "pubspec.yaml",
108
+ }
109
+
110
+ MANIFEST_SUFFIXES = {".csproj", ".fsproj", ".vbproj", ".sln"}
111
+
112
+ SOURCE_SUFFIXES = {
113
+ ".py",
114
+ ".pyi",
115
+ ".ts",
116
+ ".tsx",
117
+ ".js",
118
+ ".jsx",
119
+ ".mjs",
120
+ ".cjs",
121
+ ".go",
122
+ ".rs",
123
+ ".java",
124
+ ".kt",
125
+ ".kts",
126
+ ".scala",
127
+ ".rb",
128
+ ".php",
129
+ ".cs",
130
+ ".fs",
131
+ ".swift",
132
+ ".m",
133
+ ".mm",
134
+ ".c",
135
+ ".cc",
136
+ ".cpp",
137
+ ".cxx",
138
+ ".h",
139
+ ".hpp",
140
+ ".hxx",
141
+ ".lua",
142
+ ".ex",
143
+ ".exs",
144
+ ".erl",
145
+ ".hs",
146
+ ".ml",
147
+ ".mli",
148
+ ".clj",
149
+ ".cljs",
150
+ ".dart",
151
+ ".html",
152
+ ".css",
153
+ ".sh",
154
+ ".tf",
155
+ ".zig",
156
+ ".nim",
157
+ ".vue",
158
+ ".svelte",
159
+ ".r",
160
+ ".jl",
161
+ ".sql",
162
+ }
163
+
164
+ MARKDOWN_SUFFIXES = {".md", ".mdx", ".markdown"}
165
+
166
+ GREENFIELD_DOC_STEMS = {
167
+ "license",
168
+ "licence",
169
+ "copying",
170
+ }
171
+
172
+ MANAGED_EXACT_FILES = {
173
+ "AGENTS.md",
174
+ "WORKFLOW.md",
175
+ ".claude/CLAUDE.md",
176
+ }
177
+
178
+ VERIFIED_INSTALLER_SKILL_ROOTS = {
179
+ (".agents", "skills"),
180
+ (".claude", "skills"),
181
+ (".cursor", "skills"),
182
+ (".github", "skills"),
183
+ (".grok", "skills"),
184
+ (".kiro", "skills"),
185
+ (".kimi-code", "skills"),
186
+ (".opencode", "skills"),
187
+ (".qwen", "skills"),
188
+ }
189
+
190
+ ATX_TITLE = re.compile(r" {0,3}#{1,6}(?:[ \t]+.*)?")
191
+ SETEXT_UNDERLINE = re.compile(r" {0,3}(?:=+|-+)[ \t]*")
192
+ SETEXT_BLOCK_START = re.compile(
193
+ r" {0,3}(?:>|[-+*](?:[ \t]+|$)|\d{1,9}[.)](?:[ \t]+|$)|"
194
+ r"`{3,}|~{3,}|\[[^]]+\]:)"
195
+ )
196
+ HTML_BLOCK_START = re.compile(
197
+ r" {0,3}(?:"
198
+ r"<(?:script|pre|style|textarea)(?:[ \t]+|>|$)"
199
+ r"|<!--|<\?|<![A-Z]|<!\[CDATA\["
200
+ r"|</?(?:address|article|aside|base|basefont|blockquote|body|caption|center|"
201
+ r"col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|"
202
+ r"footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|"
203
+ r"link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|"
204
+ r"section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)"
205
+ r"(?:[ \t]+|/?>|$)"
206
+ r"|</?[A-Za-z][A-Za-z0-9-]*(?:[ \t]+[^>]*)?/?>[ \t]*$)",
207
+ re.IGNORECASE,
208
+ )
209
+ REPARSE_NAME_SURROGATE = 0x20000000
210
+ ANCHORED_EVIDENCE_SUPPORTED = (
211
+ hasattr(os, "O_DIRECTORY")
212
+ and hasattr(os, "O_NOFOLLOW")
213
+ and os.open in os.supports_dir_fd
214
+ and os.stat in os.supports_dir_fd
215
+ and os.stat in os.supports_follow_symlinks
216
+ )
217
+ LISTDIR_DIR_FD_SUPPORTED = os.listdir in getattr(os, "supports_fd", ())
218
+ ANCHORED_STATE_CREATE_SUPPORTED = (
219
+ ANCHORED_EVIDENCE_SUPPORTED
220
+ and LISTDIR_DIR_FD_SUPPORTED
221
+ and os.mkdir in getattr(os, "supports_dir_fd", ())
222
+ and os.unlink in getattr(os, "supports_dir_fd", ())
223
+ and os.link in getattr(os, "supports_dir_fd", ())
224
+ and os.link in getattr(os, "supports_follow_symlinks", ())
225
+ )
226
+ GIT_OVERRIDE_VARS = (
227
+ "GIT_DIR",
228
+ "GIT_WORK_TREE",
229
+ "GIT_INDEX_FILE",
230
+ "GIT_OBJECT_DIRECTORY",
231
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
232
+ "GIT_COMMON_DIR",
233
+ "GIT_PREFIX",
234
+ "GIT_NAMESPACE",
235
+ )
236
+ FRONTMATTER_FIELD_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$")
237
+ STATE_TEMP_NAME = ".STATE.md.gsd-path-tmp"
238
+
239
+
240
+ class DetectError(RuntimeError):
241
+ pass
242
+
243
+
244
+ class GitIndexEntry(NamedTuple):
245
+ path: str
246
+ mode: int
247
+ oid: str
248
+ stage: int
249
+
250
+
251
+ def is_link_like_status(status: os.stat_result) -> bool:
252
+ if stat.S_ISLNK(status.st_mode):
253
+ return True
254
+ if getattr(status, "st_reparse_tag", 0) & REPARSE_NAME_SURROGATE:
255
+ return True
256
+ return False
257
+
258
+
259
+ def is_link_like(path: Path, status: os.stat_result) -> bool:
260
+ if is_link_like_status(status):
261
+ return True
262
+ isjunction = getattr(os.path, "isjunction", None)
263
+ if os.name != "nt" or isjunction is None:
264
+ return False
265
+ try:
266
+ return bool(isjunction(path))
267
+ except OSError as error:
268
+ raise DetectError(f"cannot inspect link-like evidence: {path}: {error}") from error
269
+
270
+
271
+ def posix_relative(path: Path, root: Path) -> str:
272
+ return path.relative_to(root).as_posix()
273
+
274
+
275
+ def is_ignored(relative: str) -> bool:
276
+ return any(part in IGNORE_DIRS for part in PurePosixPath(relative).parts)
277
+
278
+
279
+ def is_skill_bundle_name(name: str) -> bool:
280
+ folded = name.casefold()
281
+ return folded in {"ogsd", "gsd-path"} or folded.startswith(("ogsd-", "gsd-path-"))
282
+
283
+
284
+ def is_verified_installer_bundle(parts: tuple[str, ...]) -> bool:
285
+ parent = tuple(part.casefold() for part in parts[:-1])
286
+ return parent in VERIFIED_INSTALLER_SKILL_ROOTS
287
+
288
+
289
+ def has_safe_bundle_path(root: Path, parts: tuple[str, ...]) -> bool:
290
+ current = root
291
+ for part in parts:
292
+ current /= part
293
+ status = lstat_evidence(current, missing_ok=True)
294
+ if (
295
+ status is None
296
+ or is_link_like(current, status)
297
+ or not stat.S_ISDIR(status.st_mode)
298
+ ):
299
+ return False
300
+ return True
301
+
302
+
303
+ def is_verified_skill_bundle(relative: str, root: Path) -> bool:
304
+ parts = PurePosixPath(relative).parts
305
+ if not parts:
306
+ return False
307
+ name = parts[-1]
308
+ if not (is_skill_bundle_name(name) or (
309
+ name.casefold() == "path" and is_verified_installer_bundle(parts)
310
+ )):
311
+ return False
312
+ if not has_safe_bundle_path(root, parts):
313
+ return False
314
+ if is_verified_installer_bundle(parts):
315
+ return True
316
+ skill = root.joinpath(*parts) / "SKILL.md"
317
+ status = lstat_evidence(skill, missing_ok=True)
318
+ return status is not None and stat.S_ISREG(status.st_mode)
319
+
320
+
321
+ def is_managed_pipeline_directory(relative: str, root: Path) -> bool:
322
+ return is_verified_skill_bundle(relative, root)
323
+
324
+
325
+ def is_verified_skill_bundle_at(relative: str, directory_fd: int) -> bool:
326
+ parts = PurePosixPath(relative).parts
327
+ if not parts:
328
+ return False
329
+ if not (is_skill_bundle_name(parts[-1]) or (
330
+ parts[-1].casefold() == "path" and is_verified_installer_bundle(parts)
331
+ )):
332
+ return False
333
+ if is_verified_installer_bundle(parts):
334
+ return True
335
+ try:
336
+ status = os.stat(
337
+ "SKILL.md",
338
+ dir_fd=directory_fd,
339
+ follow_symlinks=False,
340
+ )
341
+ except FileNotFoundError:
342
+ return False
343
+ except OSError as error:
344
+ raise DetectError(
345
+ f"cannot inspect managed skill bundle: {relative}: {error}"
346
+ ) from error
347
+ return not is_link_like_status(status) and stat.S_ISREG(status.st_mode)
348
+
349
+
350
+ def is_fixed_managed_pipeline_artifact(relative: str) -> bool:
351
+ if relative in MANAGED_EXACT_FILES:
352
+ return True
353
+ parts = PurePosixPath(relative).parts
354
+ if (
355
+ len(parts) >= 2
356
+ and parts[-2].casefold() == "agents"
357
+ and parts[-1].casefold() == "gsd-path.md"
358
+ ):
359
+ return True
360
+ if (
361
+ (len(parts) == 2 or (len(parts) == 3 and parts[1] == "runtime"))
362
+ and parts[0] == ".gsd-path"
363
+ and PurePosixPath(parts[-1]).suffix.casefold() == ".py"
364
+ ):
365
+ return True
366
+ return False
367
+
368
+
369
+ def is_managed_pipeline_artifact(relative: str, root: Path) -> bool:
370
+ if is_fixed_managed_pipeline_artifact(relative):
371
+ return True
372
+ parts = PurePosixPath(relative).parts
373
+ return any(
374
+ is_verified_skill_bundle("/".join(parts[:end]), root)
375
+ for end in range(1, len(parts))
376
+ )
377
+
378
+
379
+ def git_environment() -> dict[str, str]:
380
+ env = os.environ.copy()
381
+ for key in GIT_OVERRIDE_VARS:
382
+ env.pop(key, None)
383
+ env["GIT_NO_LAZY_FETCH"] = "1"
384
+ env["GIT_NO_REPLACE_OBJECTS"] = "1"
385
+ return env
386
+
387
+
388
+ def lstat_evidence(path: Path, *, missing_ok: bool) -> Optional[os.stat_result]:
389
+ try:
390
+ return os.lstat(path)
391
+ except FileNotFoundError as error:
392
+ if missing_ok:
393
+ return None
394
+ raise DetectError(f"filesystem evidence disappeared: {path}") from error
395
+ except OSError as error:
396
+ raise DetectError(
397
+ f"cannot inspect filesystem evidence: {path}: {error}"
398
+ ) from error
399
+
400
+
401
+ def read_regular_evidence(
402
+ path: Path,
403
+ root: Path,
404
+ *,
405
+ missing_ok: bool,
406
+ evidence_name: str,
407
+ ) -> Optional[str]:
408
+ try:
409
+ relative = path.relative_to(root)
410
+ except ValueError as error:
411
+ raise DetectError(
412
+ f"{evidence_name} evidence escapes repo: {path}"
413
+ ) from error
414
+ if not relative.parts:
415
+ raise DetectError(f"{evidence_name} evidence path is empty: {path}")
416
+ if ANCHORED_EVIDENCE_SUPPORTED:
417
+ return read_anchored_evidence(
418
+ relative,
419
+ root,
420
+ missing_ok=missing_ok,
421
+ evidence_name=evidence_name,
422
+ )
423
+ return read_lstat_open_evidence(
424
+ relative,
425
+ root,
426
+ missing_ok=missing_ok,
427
+ evidence_name=evidence_name,
428
+ )
429
+
430
+
431
+ def directory_flags() -> int:
432
+ return (
433
+ os.O_RDONLY
434
+ | getattr(os, "O_DIRECTORY", 0)
435
+ | getattr(os, "O_NOFOLLOW", 0)
436
+ | getattr(os, "O_NONBLOCK", 0)
437
+ )
438
+
439
+
440
+ def file_flags() -> int:
441
+ return (
442
+ os.O_RDONLY
443
+ | getattr(os, "O_NOFOLLOW", 0)
444
+ | getattr(os, "O_BINARY", 0)
445
+ | getattr(os, "O_NONBLOCK", 0)
446
+ )
447
+
448
+
449
+ def read_anchored_evidence(
450
+ relative: Path,
451
+ root: Path,
452
+ *,
453
+ missing_ok: bool,
454
+ evidence_name: str,
455
+ ) -> Optional[str]:
456
+ directory_open = directory_flags()
457
+ file_open = file_flags()
458
+ descriptors = []
459
+ try:
460
+ descriptors.append(os.open(root, directory_open))
461
+ if not stat.S_ISDIR(os.fstat(descriptors[-1]).st_mode):
462
+ raise DetectError(f"repo evidence is not a directory: {root}")
463
+ for index, part in enumerate(relative.parts):
464
+ current = root.joinpath(*relative.parts[: index + 1])
465
+ expected = os.stat(
466
+ part,
467
+ dir_fd=descriptors[-1],
468
+ follow_symlinks=False,
469
+ )
470
+ if is_link_like(current, expected):
471
+ raise DetectError(
472
+ f"link-like {evidence_name} evidence is not allowed: {current}"
473
+ )
474
+ final = index == len(relative.parts) - 1
475
+ if not final and not stat.S_ISDIR(expected.st_mode):
476
+ raise DetectError(
477
+ f"{evidence_name} evidence parent is not a directory: {current}"
478
+ )
479
+ if final and not stat.S_ISREG(expected.st_mode):
480
+ raise DetectError(
481
+ f"{evidence_name} evidence is not a regular file: {current}"
482
+ )
483
+ flags = file_open if final else directory_open
484
+ descriptor = os.open(part, flags, dir_fd=descriptors[-1])
485
+ descriptors.append(descriptor)
486
+ actual = os.fstat(descriptor)
487
+ expected_kind = stat.S_ISREG if final else stat.S_ISDIR
488
+ if not expected_kind(actual.st_mode) or not os.path.samestat(
489
+ expected,
490
+ actual,
491
+ ):
492
+ raise DetectError(
493
+ f"{evidence_name} evidence changed while reading: {current}"
494
+ )
495
+ with os.fdopen(descriptors.pop(), "rb") as stream:
496
+ data = stream.read()
497
+ except FileNotFoundError as error:
498
+ if missing_ok:
499
+ return None
500
+ raise DetectError(f"filesystem evidence disappeared: {root / relative}") from error
501
+ except DetectError:
502
+ raise
503
+ except OSError as error:
504
+ raise DetectError(
505
+ f"cannot read {evidence_name} evidence: {root / relative}"
506
+ ) from error
507
+ finally:
508
+ for descriptor in reversed(descriptors):
509
+ os.close(descriptor)
510
+ try:
511
+ return data.decode("utf-8")
512
+ except UnicodeDecodeError as error:
513
+ raise DetectError(
514
+ f"cannot read {evidence_name} evidence: {root / relative}"
515
+ ) from error
516
+
517
+
518
+ def read_lstat_open_evidence(
519
+ relative: Path,
520
+ root: Path,
521
+ *,
522
+ missing_ok: bool,
523
+ evidence_name: str,
524
+ ) -> Optional[str]:
525
+ current = root
526
+ expected = None
527
+ try:
528
+ for index, part in enumerate(relative.parts):
529
+ current = current / part
530
+ expected = os.stat(current, follow_symlinks=False)
531
+ if is_link_like(current, expected):
532
+ raise DetectError(
533
+ f"link-like {evidence_name} evidence is not allowed: {current}"
534
+ )
535
+ final = index == len(relative.parts) - 1
536
+ if not final and not stat.S_ISDIR(expected.st_mode):
537
+ raise DetectError(
538
+ f"{evidence_name} evidence parent is not a directory: {current}"
539
+ )
540
+ if final and not stat.S_ISREG(expected.st_mode):
541
+ raise DetectError(
542
+ f"{evidence_name} evidence is not a regular file: {current}"
543
+ )
544
+ descriptor = os.open(current, file_flags())
545
+ try:
546
+ actual = os.fstat(descriptor)
547
+ if not stat.S_ISREG(actual.st_mode) or not os.path.samestat(
548
+ expected,
549
+ actual,
550
+ ):
551
+ raise DetectError(
552
+ f"{evidence_name} evidence changed while reading: {current}"
553
+ )
554
+ with os.fdopen(descriptor, "rb") as stream:
555
+ descriptor = None
556
+ data = stream.read()
557
+ finally:
558
+ if descriptor is not None:
559
+ os.close(descriptor)
560
+ except FileNotFoundError as error:
561
+ if missing_ok:
562
+ return None
563
+ raise DetectError(f"filesystem evidence disappeared: {current}") from error
564
+ except DetectError:
565
+ raise
566
+ except OSError as error:
567
+ raise DetectError(f"cannot read {evidence_name} evidence: {current}") from error
568
+ try:
569
+ return data.decode("utf-8")
570
+ except UnicodeDecodeError as error:
571
+ raise DetectError(f"cannot read {evidence_name} evidence: {current}") from error
572
+
573
+
574
+ def is_thematic_break(line: str) -> bool:
575
+ leading = len(line) - len(line.lstrip(" "))
576
+ if leading > 3:
577
+ return False
578
+ compact = re.sub(r"[ \t]", "", line[leading:])
579
+ return (
580
+ len(compact) >= 3
581
+ and compact[0] in "*-_"
582
+ and all(character == compact[0] for character in compact)
583
+ )
584
+
585
+
586
+ def is_setext_title(line: str) -> bool:
587
+ indentation = line[: len(line) - len(line.lstrip(" \t"))]
588
+ return not (
589
+ len(indentation) > 3
590
+ or "\t" in indentation
591
+ or ATX_TITLE.fullmatch(line)
592
+ or SETEXT_UNDERLINE.fullmatch(line)
593
+ or SETEXT_BLOCK_START.match(line)
594
+ or HTML_BLOCK_START.match(line)
595
+ or is_thematic_break(line)
596
+ )
597
+
598
+
599
+ def normalize_html_comments(text: str) -> tuple[str, bool]:
600
+ output = []
601
+ in_comment = False
602
+ shares_content_line = False
603
+ for line in text.splitlines(keepends=True):
604
+ content = line.rstrip("\r\n")
605
+ ending = line[len(content) :]
606
+ indentation = content[: len(content) - len(content.lstrip(" \t"))]
607
+ if not in_comment and (len(indentation) > 3 or "\t" in indentation):
608
+ output.append(line)
609
+ continue
610
+ visible = []
611
+ cursor = 0
612
+ line_has_comment = in_comment
613
+ while cursor < len(content):
614
+ if in_comment:
615
+ closing = content.find("-->", cursor)
616
+ if closing < 0:
617
+ cursor = len(content)
618
+ continue
619
+ cursor = closing + 3
620
+ in_comment = False
621
+ continue
622
+ opening = content.find("<!--", cursor)
623
+ if opening < 0:
624
+ visible.append(content[cursor:])
625
+ cursor = len(content)
626
+ continue
627
+ line_has_comment = True
628
+ visible.append(content[cursor:opening])
629
+ cursor = opening + 4
630
+ in_comment = True
631
+ visible_line = "".join(visible)
632
+ if line_has_comment and visible_line.strip():
633
+ shares_content_line = True
634
+ output.append(visible_line)
635
+ if ending:
636
+ output.append(ending)
637
+ elif line_has_comment and not visible_line:
638
+ output.append("\n")
639
+ return "".join(output), shares_content_line
640
+
641
+
642
+ def markdown_has_body(text: str) -> bool:
643
+ normalized, comment_shares_content = normalize_html_comments(text)
644
+ if comment_shares_content:
645
+ return True
646
+ lines = [line.rstrip() for line in normalized.splitlines()]
647
+ while lines and not lines[0].strip():
648
+ lines.pop(0)
649
+ while lines and not lines[-1].strip():
650
+ lines.pop()
651
+ if not lines:
652
+ return False
653
+ if len(lines) == 1 and ATX_TITLE.fullmatch(lines[0]):
654
+ return False
655
+ if (
656
+ len(lines) >= 2
657
+ and all(line.strip() for line in lines)
658
+ and SETEXT_UNDERLINE.fullmatch(lines[-1])
659
+ and all(is_setext_title(line) for line in lines[:-1])
660
+ ):
661
+ return False
662
+ return True
663
+
664
+
665
+ def file_kind(
666
+ relative: str,
667
+ root: Path,
668
+ *,
669
+ tracked: bool,
670
+ index_oid: Optional[str] = None,
671
+ ) -> Optional[str]:
672
+ if (
673
+ is_ignored(relative)
674
+ or relative == ".project"
675
+ or relative.startswith(".project/")
676
+ ):
677
+ return None
678
+ relative_path = PurePosixPath(relative)
679
+ path = root.joinpath(*relative_path.parts)
680
+ name = relative_path.name
681
+ stem = PurePosixPath(name).stem.casefold()
682
+ suffix = PurePosixPath(name).suffix
683
+ if name in MANIFEST_NAMES or suffix in MANIFEST_SUFFIXES:
684
+ return "manifest"
685
+ if suffix.casefold() in SOURCE_SUFFIXES:
686
+ return "source"
687
+ if (
688
+ suffix.casefold() in MARKDOWN_SUFFIXES or name.casefold() == "readme"
689
+ ) and stem not in GREENFIELD_DOC_STEMS:
690
+ text = read_regular_evidence(
691
+ path,
692
+ root,
693
+ missing_ok=tracked,
694
+ evidence_name="Markdown",
695
+ )
696
+ if text is None and tracked:
697
+ if index_oid is None:
698
+ raise DetectError(f"missing staged Markdown blob identity: {relative}")
699
+ text = read_git_index_blob(root, relative, index_oid)
700
+ if text is None:
701
+ return None
702
+ if not markdown_has_body(text):
703
+ return None
704
+ return "docs"
705
+ return None
706
+
707
+
708
+ def read_git_index_blob(root: Path, relative: str, oid: str) -> str:
709
+ try:
710
+ result = subprocess.run(
711
+ ["git", "-C", str(root), "cat-file", "blob", oid],
712
+ capture_output=True,
713
+ check=False,
714
+ env=git_environment(),
715
+ )
716
+ except OSError as error:
717
+ raise DetectError(f"git cat-file failed: {error}") from error
718
+ if result.returncode != 0:
719
+ detail = result.stderr.decode("utf-8", "replace").strip()
720
+ suffix = f": {detail}" if detail else ""
721
+ raise DetectError(
722
+ f"cannot read staged Markdown evidence: {relative}{suffix}"
723
+ )
724
+ try:
725
+ return result.stdout.decode("utf-8")
726
+ except UnicodeDecodeError as error:
727
+ raise DetectError(f"cannot read Markdown evidence: {relative}") from error
728
+
729
+
730
+ def parse_git_index_record(raw: bytes) -> GitIndexEntry:
731
+ try:
732
+ metadata, encoded_path = raw.split(b"\t", 1)
733
+ encoded_mode, encoded_oid, encoded_stage = metadata.split(b" ")
734
+ except ValueError as error:
735
+ raise DetectError("git ls-files returned malformed staged data") from error
736
+ if (
737
+ re.fullmatch(rb"[0-7]{6}", encoded_mode) is None
738
+ or re.fullmatch(rb"[0-9a-fA-F]+", encoded_oid) is None
739
+ or re.fullmatch(rb"[0-3]", encoded_stage) is None
740
+ ):
741
+ raise DetectError("git ls-files returned malformed staged data")
742
+ try:
743
+ relative = encoded_path.decode("utf-8")
744
+ except UnicodeDecodeError as error:
745
+ raise DetectError("git ls-files returned non-UTF-8 paths") from error
746
+ return GitIndexEntry(
747
+ path=validated_git_path(relative),
748
+ mode=int(encoded_mode, 8),
749
+ oid=encoded_oid.decode("ascii").lower(),
750
+ stage=int(encoded_stage),
751
+ )
752
+
753
+
754
+ def raise_walk_error(error: OSError) -> None:
755
+ raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
756
+
757
+
758
+ def iter_worktree_files(root: Path) -> Iterable[str]:
759
+ if ANCHORED_EVIDENCE_SUPPORTED and LISTDIR_DIR_FD_SUPPORTED:
760
+ yield from iter_worktree_files_anchored(root)
761
+ return
762
+ for dirpath, dirnames, filenames in os.walk(
763
+ root,
764
+ followlinks=False,
765
+ onerror=raise_walk_error,
766
+ ):
767
+ current = Path(dirpath)
768
+ relative_dir = "" if current == root else posix_relative(current, root)
769
+ kept_directories = []
770
+ for name in dirnames:
771
+ relative = f"{relative_dir}/{name}" if relative_dir else name
772
+ if (
773
+ is_ignored(relative)
774
+ or name == ".project"
775
+ or is_managed_pipeline_directory(relative, root)
776
+ ):
777
+ continue
778
+ path = current / name
779
+ status = lstat_evidence(path, missing_ok=False)
780
+ if is_link_like(path, status):
781
+ continue
782
+ if not stat.S_ISDIR(status.st_mode):
783
+ raise DetectError(f"walk entry is not a directory: {path}")
784
+ kept_directories.append(name)
785
+ dirnames[:] = kept_directories
786
+ for name in filenames:
787
+ path = current / name
788
+ relative = f"{relative_dir}/{name}" if relative_dir else name
789
+ if is_ignored(relative) or is_managed_pipeline_artifact(relative, root):
790
+ continue
791
+ status = lstat_evidence(path, missing_ok=False)
792
+ if is_link_like(path, status) or not stat.S_ISREG(status.st_mode):
793
+ continue
794
+ yield relative
795
+
796
+
797
+ def iter_worktree_files_anchored(root: Path) -> Iterable[str]:
798
+ flags = directory_flags()
799
+ try:
800
+ root_fd = os.open(root, flags)
801
+ except OSError as error:
802
+ raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
803
+ try:
804
+ try:
805
+ root_status = os.fstat(root_fd)
806
+ except OSError as error:
807
+ raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
808
+ if not stat.S_ISDIR(root_status.st_mode):
809
+ raise DetectError(f"repo evidence is not a directory: {root}")
810
+ yield from iter_from_dir_fd(root_fd, "", root)
811
+ finally:
812
+ os.close(root_fd)
813
+
814
+
815
+ def iter_from_dir_fd(
816
+ dir_fd: int, relative_dir: str, root: Path
817
+ ) -> Iterable[str]:
818
+ try:
819
+ names = os.listdir(dir_fd)
820
+ except OSError as error:
821
+ raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
822
+ for name in names:
823
+ relative = f"{relative_dir}/{name}" if relative_dir else name
824
+ if is_ignored(relative) or name == ".project":
825
+ continue
826
+ path = root.joinpath(*PurePosixPath(relative).parts)
827
+ try:
828
+ status = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
829
+ except OSError as error:
830
+ raise DetectError(
831
+ f"cannot inspect filesystem evidence: {relative}: {error}"
832
+ ) from error
833
+ if is_link_like(path, status):
834
+ continue
835
+ if stat.S_ISDIR(status.st_mode):
836
+ try:
837
+ child = os.open(name, directory_flags(), dir_fd=dir_fd)
838
+ except OSError as error:
839
+ raise DetectError(
840
+ f"cannot traverse filesystem evidence: {relative}: {error}"
841
+ ) from error
842
+ try:
843
+ try:
844
+ actual = os.fstat(child)
845
+ except OSError as error:
846
+ raise DetectError(
847
+ f"cannot traverse filesystem evidence: {relative}: {error}"
848
+ ) from error
849
+ if not stat.S_ISDIR(actual.st_mode) or not os.path.samestat(
850
+ status, actual
851
+ ):
852
+ raise DetectError(
853
+ f"filesystem evidence changed while traversing: {relative}"
854
+ )
855
+ if is_verified_skill_bundle_at(relative, child):
856
+ continue
857
+ yield from iter_from_dir_fd(child, relative, root)
858
+ finally:
859
+ os.close(child)
860
+ continue
861
+ if not stat.S_ISREG(status.st_mode):
862
+ continue
863
+ if is_fixed_managed_pipeline_artifact(relative):
864
+ continue
865
+ yield relative
866
+
867
+
868
+ def validated_git_path(relative: str) -> str:
869
+ path = PurePosixPath(relative)
870
+ if (
871
+ relative == "."
872
+ or path.is_absolute()
873
+ or ".." in path.parts
874
+ or (
875
+ os.sep != "/"
876
+ and (os.sep in relative or PureWindowsPath(relative).drive)
877
+ )
878
+ ):
879
+ raise DetectError(f"git ls-files returned unsafe path: {relative!r}")
880
+ return relative
881
+
882
+
883
+ def git_tracked_files(root: Path) -> tuple[GitIndexEntry, ...]:
884
+ git_dir = root / ".git"
885
+ git_status = lstat_evidence(git_dir, missing_ok=True)
886
+ if git_status is None:
887
+ return ()
888
+ if is_link_like(git_dir, git_status):
889
+ raise DetectError(f".git must be a real directory or pointer file: {git_dir}")
890
+ if stat.S_ISREG(git_status.st_mode):
891
+ pointer = read_regular_evidence(
892
+ git_dir,
893
+ root,
894
+ missing_ok=False,
895
+ evidence_name=".git pointer",
896
+ )
897
+ if pointer is None or re.fullmatch(
898
+ r"gitdir: ([^\0\r\n]+)(?:\r?\n)?", pointer
899
+ ) is None:
900
+ raise DetectError(f".git must be a valid gitdir pointer file: {git_dir}")
901
+ elif not stat.S_ISDIR(git_status.st_mode):
902
+ raise DetectError(f".git must be a real directory or pointer file: {git_dir}")
903
+ try:
904
+ result = subprocess.run(
905
+ ["git", "-C", str(root), "ls-files", "--stage", "-z"],
906
+ capture_output=True,
907
+ check=False,
908
+ env=git_environment(),
909
+ )
910
+ except OSError as error:
911
+ raise DetectError(f"git ls-files failed: {error}") from error
912
+ if result.returncode != 0:
913
+ detail = result.stderr.decode("utf-8", "replace").strip()
914
+ suffix = f": {detail}" if detail else ""
915
+ raise DetectError(f"git ls-files failed{suffix}")
916
+ entries = []
917
+ for raw in result.stdout.split(b"\0"):
918
+ if not raw:
919
+ continue
920
+ entry = parse_git_index_record(raw)
921
+ if is_ignored(entry.path):
922
+ continue
923
+ if entry.stage != 0:
924
+ raise DetectError(f"git index contains unmerged entry: {entry.path}")
925
+ if not stat.S_ISREG(entry.mode):
926
+ continue
927
+ entries.append(entry)
928
+ return tuple(entries)
929
+
930
+
931
+ def staged_skill_bundle_prefixes(
932
+ entries: Sequence[GitIndexEntry],
933
+ ) -> frozenset[str]:
934
+ prefixes = set()
935
+ for entry in entries:
936
+ parts = PurePosixPath(entry.path).parts
937
+ for end in range(1, len(parts)):
938
+ bundle_parts = parts[:end]
939
+ if is_skill_bundle_name(
940
+ bundle_parts[-1]
941
+ ) and is_verified_installer_bundle(bundle_parts):
942
+ prefixes.add("/".join(bundle_parts))
943
+ if (
944
+ len(parts) >= 2
945
+ and parts[-1] == "SKILL.md"
946
+ and is_skill_bundle_name(parts[-2])
947
+ ):
948
+ prefixes.add("/".join(parts[:-1]))
949
+ return frozenset(prefixes)
950
+
951
+
952
+ def is_staged_skill_bundle_artifact(
953
+ relative: str, prefixes: frozenset[str]
954
+ ) -> bool:
955
+ parts = PurePosixPath(relative).parts
956
+ return any(
957
+ "/".join(parts[:end]) in prefixes for end in range(1, len(parts))
958
+ )
959
+
960
+
961
+ def project_path_inventory(
962
+ project: Path, root: Path
963
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
964
+ project_status = lstat_evidence(project, missing_ok=True)
965
+ if project_status is None:
966
+ return (), ()
967
+ if is_link_like(project, project_status) or not stat.S_ISDIR(
968
+ project_status.st_mode
969
+ ):
970
+ relative = posix_relative(project, root)
971
+ return (relative,), (relative,)
972
+ try:
973
+ next(project.iterdir())
974
+ except StopIteration:
975
+ return (), ()
976
+ except OSError:
977
+ relative = posix_relative(project, root)
978
+ return (relative,), (relative,)
979
+ paths = []
980
+ unsafe_paths = []
981
+ for dirpath, dirnames, filenames in os.walk(
982
+ project,
983
+ followlinks=False,
984
+ onerror=raise_walk_error,
985
+ ):
986
+ current = Path(dirpath)
987
+ linked_directories = []
988
+ for name in dirnames:
989
+ path = current / name
990
+ status = lstat_evidence(path, missing_ok=False)
991
+ if is_link_like(path, status):
992
+ linked_directories.append(name)
993
+ unsafe_paths.append(posix_relative(path, root))
994
+ elif not stat.S_ISDIR(status.st_mode):
995
+ linked_directories.append(name)
996
+ unsafe_paths.append(posix_relative(path, root))
997
+ paths.extend(
998
+ posix_relative(current / name, root) for name in linked_directories
999
+ )
1000
+ dirnames[:] = [name for name in dirnames if name not in linked_directories]
1001
+ for name in filenames:
1002
+ path = current / name
1003
+ relative = posix_relative(path, root)
1004
+ status = lstat_evidence(path, missing_ok=False)
1005
+ paths.append(relative)
1006
+ if is_link_like(path, status) or not stat.S_ISREG(status.st_mode):
1007
+ unsafe_paths.append(relative)
1008
+ if not filenames and not dirnames and current != project:
1009
+ paths.append(posix_relative(current, root))
1010
+ if not paths:
1011
+ try:
1012
+ names = os.listdir(project)
1013
+ except OSError as error:
1014
+ raise DetectError(
1015
+ f"cannot list project evidence: {project}: {error}"
1016
+ ) from error
1017
+ paths = [posix_relative(project / name, root) for name in names]
1018
+ return tuple(sorted(paths)), tuple(sorted(unsafe_paths))
1019
+
1020
+
1021
+ def frontmatter_scalar(raw: str) -> str:
1022
+ value = raw.strip()
1023
+ if value.startswith('"'):
1024
+ try:
1025
+ decoded = json.loads(value)
1026
+ except json.JSONDecodeError as error:
1027
+ raise DetectError("STATE.md has malformed frontmatter") from error
1028
+ if not isinstance(decoded, str):
1029
+ raise DetectError("STATE.md has malformed frontmatter")
1030
+ value = decoded
1031
+ elif value.startswith("'"):
1032
+ quoted = re.fullmatch(r"'((?:[^']|'')*)'", value)
1033
+ if quoted is None:
1034
+ raise DetectError("STATE.md has malformed frontmatter")
1035
+ value = quoted.group(1).replace("''", "'")
1036
+ else:
1037
+ value = re.sub(r"[ \t]+#.*$", "", value).rstrip()
1038
+ if not re.fullmatch(r"[^\s#\[\]{},]+", value):
1039
+ raise DetectError("STATE.md has malformed frontmatter")
1040
+ if not value:
1041
+ raise DetectError("STATE.md has malformed frontmatter")
1042
+ return value
1043
+
1044
+
1045
+ def state_frontmatter(text: str) -> Optional[dict[str, str]]:
1046
+ lines = text.splitlines()
1047
+ if not lines or lines[0] != "---":
1048
+ return None
1049
+ try:
1050
+ end = lines.index("---", 1)
1051
+ except ValueError as error:
1052
+ raise DetectError("STATE.md frontmatter is not closed") from error
1053
+ fields = {}
1054
+ for line in lines[1:end]:
1055
+ if not line.strip() or line.lstrip().startswith("#"):
1056
+ continue
1057
+ match = FRONTMATTER_FIELD_RE.fullmatch(line)
1058
+ if match is None:
1059
+ raise DetectError("STATE.md has malformed frontmatter")
1060
+ key = match.group(1)
1061
+ if key in fields:
1062
+ raise DetectError(f"STATE.md has duplicate frontmatter field: {key}")
1063
+ fields[key] = frontmatter_scalar(match.group(2))
1064
+ return fields
1065
+
1066
+
1067
+ def pipeline_marker(state: Path, root: Path) -> Optional[str]:
1068
+ text = read_regular_evidence(
1069
+ state,
1070
+ root,
1071
+ missing_ok=False,
1072
+ evidence_name="state",
1073
+ )
1074
+ if text is None:
1075
+ raise DetectError(f"filesystem evidence disappeared: {state}")
1076
+ fields = state_frontmatter(text)
1077
+ return fields.get("pipeline") if fields is not None else None
1078
+
1079
+
1080
+ def classify(repo: Path) -> dict:
1081
+ root = repo.resolve()
1082
+ root_status = lstat_evidence(root, missing_ok=True)
1083
+ if root_status is None or not stat.S_ISDIR(root_status.st_mode):
1084
+ raise DetectError(f"repo is not a directory: {root}")
1085
+ project = root / ".project"
1086
+ state = project / "STATE.md"
1087
+ orphan_paths, unsafe_paths = project_path_inventory(project, root)
1088
+ temporary_state_status = lstat_evidence(
1089
+ project / STATE_TEMP_NAME,
1090
+ missing_ok=True,
1091
+ )
1092
+ if (
1093
+ ANCHORED_STATE_CREATE_SUPPORTED
1094
+ and orphan_paths == (f".project/{STATE_TEMP_NAME}",)
1095
+ and not unsafe_paths
1096
+ and temporary_state_status is not None
1097
+ and not is_link_like(project / STATE_TEMP_NAME, temporary_state_status)
1098
+ and stat.S_ISREG(temporary_state_status.st_mode)
1099
+ and temporary_state_status.st_nlink == 1
1100
+ ):
1101
+ orphan_paths = ()
1102
+ if unsafe_paths:
1103
+ return {
1104
+ "verdict": "orphan",
1105
+ "pipeline": None,
1106
+ "signals": [],
1107
+ "orphan_paths": list(unsafe_paths),
1108
+ "route": "recover-orphan",
1109
+ }
1110
+ project_status = lstat_evidence(project, missing_ok=True)
1111
+ if (
1112
+ project_status is not None
1113
+ and not is_link_like(project, project_status)
1114
+ and stat.S_ISDIR(project_status.st_mode)
1115
+ ):
1116
+ state_status = lstat_evidence(state, missing_ok=True)
1117
+ if (
1118
+ state_status is not None
1119
+ and not is_link_like(state, state_status)
1120
+ and stat.S_ISREG(state_status.st_mode)
1121
+ ):
1122
+ return {
1123
+ "verdict": "owned",
1124
+ "pipeline": pipeline_marker(state, root),
1125
+ "signals": [],
1126
+ "orphan_paths": [],
1127
+ "route": "existing-state",
1128
+ }
1129
+ if orphan_paths:
1130
+ return {
1131
+ "verdict": "orphan",
1132
+ "pipeline": None,
1133
+ "signals": [],
1134
+ "orphan_paths": list(orphan_paths),
1135
+ "route": "recover-orphan",
1136
+ }
1137
+ signals = []
1138
+ seen = set()
1139
+ for relative in iter_worktree_files(root):
1140
+ kind = file_kind(relative, root, tracked=False)
1141
+ if kind is None:
1142
+ continue
1143
+ item = (kind, relative)
1144
+ if item in seen:
1145
+ continue
1146
+ seen.add(item)
1147
+ signals.append({"kind": kind, "path": relative})
1148
+ tracked_entries = git_tracked_files(root)
1149
+ staged_bundles = staged_skill_bundle_prefixes(tracked_entries)
1150
+ for entry in tracked_entries:
1151
+ relative = entry.path
1152
+ if is_managed_pipeline_artifact(
1153
+ relative, root
1154
+ ) or is_staged_skill_bundle_artifact(relative, staged_bundles):
1155
+ continue
1156
+ kind = file_kind(
1157
+ relative,
1158
+ root,
1159
+ tracked=True,
1160
+ index_oid=entry.oid,
1161
+ )
1162
+ if kind is None:
1163
+ continue
1164
+ item = ("git", relative)
1165
+ if item in seen or (kind, relative) in seen:
1166
+ continue
1167
+ seen.add(item)
1168
+ signals.append({"kind": "git", "path": relative})
1169
+ signals.sort(key=lambda item: (item["kind"], item["path"]))
1170
+ if signals:
1171
+ return {
1172
+ "verdict": "brownfield",
1173
+ "pipeline": None,
1174
+ "signals": signals,
1175
+ "orphan_paths": [],
1176
+ "route": "inspect",
1177
+ }
1178
+ return {
1179
+ "verdict": "greenfield",
1180
+ "pipeline": None,
1181
+ "signals": [],
1182
+ "orphan_paths": [],
1183
+ "route": "define",
1184
+ }
1185
+
1186
+
1187
+ def project_slug(root: Path) -> str:
1188
+ slug = re.sub(r"[^a-z0-9]+", "-", root.name.casefold()).strip("-")
1189
+ return slug or "project"
1190
+
1191
+
1192
+ def filled_state_template(template: str, slug: str, phase: str) -> str:
1193
+ if "<slug>" not in template:
1194
+ raise DetectError("state template missing slug placeholder")
1195
+ text = template.replace("<slug>", slug)
1196
+ text, count = re.subn(r"(?m)^phase: define\b", f"phase: {phase}", text, count=1)
1197
+ if count != 1:
1198
+ raise DetectError("state template missing phase: define line")
1199
+ stamp = f"{date.today().isoformat()} — {phase} — project initialized"
1200
+ text = text.replace("YYYY-MM-DD — <phase> — project initialized", stamp)
1201
+ if "<slug>" in text or "YYYY-MM-DD" in text:
1202
+ raise DetectError("state template placeholder remains")
1203
+ return text
1204
+
1205
+
1206
+ def write_all(descriptor: int, payload: bytes) -> None:
1207
+ remaining = memoryview(payload)
1208
+ while remaining:
1209
+ written = os.write(descriptor, remaining)
1210
+ if written <= 0:
1211
+ raise DetectError("cannot create STATE.md: write made no progress")
1212
+ remaining = remaining[written:]
1213
+
1214
+
1215
+ def rollback_created_state(
1216
+ project_fd: int,
1217
+ created_status: Optional[os.stat_result],
1218
+ name: str,
1219
+ ) -> None:
1220
+ if created_status is None:
1221
+ try:
1222
+ os.unlink(name, dir_fd=project_fd)
1223
+ except FileNotFoundError:
1224
+ return
1225
+ except OSError as error:
1226
+ raise DetectError(f"cannot roll back {name}: {error}") from error
1227
+ return
1228
+ try:
1229
+ current = os.stat(name, dir_fd=project_fd, follow_symlinks=False)
1230
+ except FileNotFoundError:
1231
+ return
1232
+ except OSError as error:
1233
+ raise DetectError(f"cannot roll back {name}: {error}") from error
1234
+ if (
1235
+ is_link_like(Path(name), current)
1236
+ or not stat.S_ISREG(current.st_mode)
1237
+ or not os.path.samestat(created_status, current)
1238
+ ):
1239
+ return
1240
+ try:
1241
+ os.unlink(name, dir_fd=project_fd)
1242
+ except OSError as error:
1243
+ raise DetectError(f"cannot roll back {name}: {error}") from error
1244
+
1245
+
1246
+ def close_file_descriptors(
1247
+ *descriptors: Optional[int],
1248
+ ) -> None:
1249
+ for descriptor in descriptors:
1250
+ if descriptor is None:
1251
+ continue
1252
+ try:
1253
+ os.close(descriptor)
1254
+ except OSError:
1255
+ pass
1256
+
1257
+
1258
+ def write_state_anchored(
1259
+ root: Path,
1260
+ content: str,
1261
+ expected_root: os.stat_result,
1262
+ expected_project: Optional[os.stat_result],
1263
+ ) -> None:
1264
+ if not ANCHORED_STATE_CREATE_SUPPORTED:
1265
+ raise DetectError("anchored no-follow STATE.md creation is unavailable")
1266
+ payload = content.encode("utf-8")
1267
+ flags = directory_flags()
1268
+ root_fd = None
1269
+ project_fd = None
1270
+ state_fd = None
1271
+ state_created = False
1272
+ created_status = None
1273
+ created_name = STATE_TEMP_NAME
1274
+ try:
1275
+ try:
1276
+ root_fd = os.open(root, flags)
1277
+ except OSError as error:
1278
+ raise DetectError(
1279
+ f"cannot open repo for STATE.md creation: {error}"
1280
+ ) from error
1281
+ opened_root = os.fstat(root_fd)
1282
+ if (
1283
+ is_link_like(root, opened_root)
1284
+ or not stat.S_ISDIR(opened_root.st_mode)
1285
+ or not os.path.samestat(expected_root, opened_root)
1286
+ ):
1287
+ raise DetectError("repo changed after classification")
1288
+ if expected_project is None:
1289
+ try:
1290
+ os.mkdir(".project", dir_fd=root_fd)
1291
+ except FileExistsError as error:
1292
+ raise DetectError(".project changed after classification") from error
1293
+ except OSError as error:
1294
+ raise DetectError(f"cannot create .project: {error}") from error
1295
+ try:
1296
+ named_project = os.stat(
1297
+ ".project",
1298
+ dir_fd=root_fd,
1299
+ follow_symlinks=False,
1300
+ )
1301
+ except OSError as error:
1302
+ raise DetectError(
1303
+ f"cannot inspect .project for STATE.md creation: {error}"
1304
+ ) from error
1305
+ if is_link_like(root / ".project", named_project) or not stat.S_ISDIR(
1306
+ named_project.st_mode
1307
+ ):
1308
+ raise DetectError("cannot create state through a non-directory .project")
1309
+ try:
1310
+ project_fd = os.open(".project", flags, dir_fd=root_fd)
1311
+ except OSError as error:
1312
+ raise DetectError(
1313
+ f"cannot open .project for STATE.md creation: {error}"
1314
+ ) from error
1315
+ opened_project = os.fstat(project_fd)
1316
+ if (
1317
+ not stat.S_ISDIR(opened_project.st_mode)
1318
+ or not os.path.samestat(named_project, opened_project)
1319
+ or (
1320
+ expected_project is not None
1321
+ and not os.path.samestat(expected_project, opened_project)
1322
+ )
1323
+ ):
1324
+ raise DetectError(".project changed after classification")
1325
+ lock_exclusive(project_fd)
1326
+ entries = set(os.listdir(project_fd))
1327
+ if entries not in (set(), {STATE_TEMP_NAME}):
1328
+ raise DetectError(".project changed after classification")
1329
+ create = (
1330
+ os.O_CREAT
1331
+ | os.O_RDWR
1332
+ | os.O_NOFOLLOW
1333
+ | getattr(os, "O_BINARY", 0)
1334
+ )
1335
+ try:
1336
+ state_fd = os.open(STATE_TEMP_NAME, create, 0o644, dir_fd=project_fd)
1337
+ lock_exclusive(state_fd)
1338
+ except OSError as error:
1339
+ raise DetectError(f"cannot reserve temporary STATE.md: {error}") from error
1340
+ state_created = True
1341
+ created_status = os.fstat(state_fd)
1342
+ if is_link_like(
1343
+ root / ".project" / STATE_TEMP_NAME, created_status
1344
+ ) or not stat.S_ISREG(created_status.st_mode):
1345
+ raise DetectError("temporary STATE.md is not a regular file")
1346
+ if created_status.st_nlink != 1:
1347
+ raise DetectError("temporary STATE.md has an unexpected link count")
1348
+ try:
1349
+ named_temporary = os.stat(
1350
+ STATE_TEMP_NAME,
1351
+ dir_fd=project_fd,
1352
+ follow_symlinks=False,
1353
+ )
1354
+ except FileNotFoundError as error:
1355
+ if "STATE.md" in os.listdir(project_fd):
1356
+ raise DetectError("STATE.md already exists") from error
1357
+ raise DetectError("temporary STATE.md disappeared") from error
1358
+ if not os.path.samestat(created_status, named_temporary):
1359
+ raise DetectError("temporary STATE.md changed before writing")
1360
+ if "STATE.md" in os.listdir(project_fd):
1361
+ raise DetectError("STATE.md already exists")
1362
+ os.ftruncate(state_fd, 0)
1363
+ write_all(state_fd, payload)
1364
+ os.fsync(state_fd)
1365
+ current_root = lstat_evidence(root, missing_ok=False)
1366
+ current_project = os.stat(
1367
+ ".project",
1368
+ dir_fd=root_fd,
1369
+ follow_symlinks=False,
1370
+ )
1371
+ current_project_fd = os.fstat(project_fd)
1372
+ current_state = os.stat(
1373
+ STATE_TEMP_NAME,
1374
+ dir_fd=project_fd,
1375
+ follow_symlinks=False,
1376
+ )
1377
+ if (
1378
+ is_link_like(root, current_root)
1379
+ or not os.path.samestat(opened_root, current_root)
1380
+ or is_link_like(root / ".project", current_project)
1381
+ or not stat.S_ISDIR(current_project.st_mode)
1382
+ or not os.path.samestat(opened_project, current_project)
1383
+ or not os.path.samestat(opened_project, current_project_fd)
1384
+ ):
1385
+ raise DetectError("project identity changed while creating STATE.md")
1386
+ if (
1387
+ is_link_like(root / ".project" / STATE_TEMP_NAME, current_state)
1388
+ or not stat.S_ISREG(current_state.st_mode)
1389
+ or current_state.st_nlink != 1
1390
+ or not os.path.samestat(created_status, current_state)
1391
+ ):
1392
+ raise DetectError("temporary STATE.md changed while writing")
1393
+ if set(os.listdir(project_fd)) != {STATE_TEMP_NAME}:
1394
+ raise DetectError(".project contents changed while creating STATE.md")
1395
+ try:
1396
+ os.link(
1397
+ STATE_TEMP_NAME,
1398
+ "STATE.md",
1399
+ src_dir_fd=project_fd,
1400
+ dst_dir_fd=project_fd,
1401
+ follow_symlinks=False,
1402
+ )
1403
+ except FileExistsError as error:
1404
+ raise DetectError("STATE.md already exists") from error
1405
+ created_name = "STATE.md"
1406
+ os.unlink(
1407
+ STATE_TEMP_NAME,
1408
+ dir_fd=project_fd,
1409
+ )
1410
+ published = os.stat("STATE.md", dir_fd=project_fd, follow_symlinks=False)
1411
+ if published.st_nlink != 1 or not os.path.samestat(created_status, published):
1412
+ raise DetectError("STATE.md changed while publishing")
1413
+ if set(os.listdir(project_fd)) != {"STATE.md"}:
1414
+ raise DetectError(".project contents changed while publishing STATE.md")
1415
+ os.fsync(project_fd)
1416
+ closing_state_fd = state_fd
1417
+ state_fd = None
1418
+ try:
1419
+ os.close(closing_state_fd)
1420
+ except OSError as error:
1421
+ raise DetectError(f"cannot close STATE.md: {error}") from error
1422
+ except BaseException as error:
1423
+ if state_created and project_fd is not None:
1424
+ try:
1425
+ rollback_created_state(
1426
+ project_fd,
1427
+ created_status,
1428
+ created_name,
1429
+ )
1430
+ except DetectError as rollback_error:
1431
+ raise rollback_error from error
1432
+ if isinstance(error, DetectError):
1433
+ raise
1434
+ if isinstance(error, OSError):
1435
+ raise DetectError(f"cannot create STATE.md: {error}") from error
1436
+ raise
1437
+ finally:
1438
+ close_file_descriptors(state_fd, project_fd, root_fd)
1439
+
1440
+
1441
+ def initialize(
1442
+ repo: Path, template: Path, phase: Optional[str] = None, *, require_git: bool = False
1443
+ ) -> dict:
1444
+ if phase is not None and phase not in {"inspect", "define"}:
1445
+ raise DetectError(f"initialize phase must be inspect or define: {phase}")
1446
+ try:
1447
+ raw = template.read_text(encoding="utf-8")
1448
+ except OSError as error:
1449
+ raise DetectError(f"cannot read state template: {error}") from error
1450
+ root = repo.resolve()
1451
+ root_status = lstat_evidence(root, missing_ok=True)
1452
+ if root_status is None or is_link_like(root, root_status) or not stat.S_ISDIR(
1453
+ root_status.st_mode
1454
+ ):
1455
+ raise DetectError(f"repo is not a directory: {root}")
1456
+ project_status = lstat_evidence(root / ".project", missing_ok=True)
1457
+ payload = classify(root)
1458
+ if payload["verdict"] not in {"brownfield", "greenfield"}:
1459
+ payload["wrote_state"] = False
1460
+ return payload
1461
+ if require_git:
1462
+ git = subprocess.run(
1463
+ ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
1464
+ env=git_environment(), capture_output=True, text=True, check=False,
1465
+ )
1466
+ if git.returncode != 0 or git.stdout.strip() != "true":
1467
+ return {**payload, "route": "setup-repository", "wrote_state": False}
1468
+ expected = "inspect" if payload["verdict"] == "brownfield" else "define"
1469
+ if phase is None:
1470
+ phase = expected
1471
+ elif phase != expected:
1472
+ raise DetectError(
1473
+ f"initialize phase {phase} does not match verdict {payload['verdict']}"
1474
+ )
1475
+ if not ANCHORED_STATE_CREATE_SUPPORTED:
1476
+ payload["wrote_state"] = False
1477
+ payload["error"] = "anchored no-follow STATE.md creation is unavailable"
1478
+ return payload
1479
+ write_state_anchored(
1480
+ root,
1481
+ filled_state_template(raw, project_slug(root), phase),
1482
+ root_status,
1483
+ project_status,
1484
+ )
1485
+ payload["wrote_state"] = True
1486
+ return payload
1487
+
1488
+
1489
+ def emit(payload: dict, exit_code: int = 0) -> int:
1490
+ json.dump(payload, sys.stdout, indent=2, sort_keys=True)
1491
+ sys.stdout.write("\n")
1492
+ return exit_code
1493
+
1494
+
1495
+ def parser() -> argparse.ArgumentParser:
1496
+ result = argparse.ArgumentParser(description=__doc__)
1497
+ result.add_argument("command", choices=("classify", "initialize"))
1498
+ result.add_argument("--repo", type=Path, required=True)
1499
+ result.add_argument("--template", type=Path)
1500
+ result.add_argument("--phase", choices=("inspect", "define"))
1501
+ result.add_argument("--require-git", action="store_true")
1502
+ return result
1503
+
1504
+
1505
+ def main(argv: Optional[Sequence[str]] = None) -> int:
1506
+ arguments = parser().parse_args(argv)
1507
+ try:
1508
+ if arguments.command == "classify":
1509
+ return emit(classify(arguments.repo))
1510
+ if arguments.command == "initialize":
1511
+ if arguments.template is None:
1512
+ raise DetectError("initialize requires --template")
1513
+ payload = initialize(
1514
+ arguments.repo, arguments.template, arguments.phase,
1515
+ require_git=arguments.require_git
1516
+ )
1517
+ return emit(payload, 2 if payload.get("error") else 0)
1518
+ raise DetectError(f"unknown command: {arguments.command}")
1519
+ except DetectError as error:
1520
+ json.dump({"status": "error", "error": str(error)}, sys.stdout, indent=2)
1521
+ sys.stdout.write("\n")
1522
+ return 2
1523
+
1524
+
1525
+ if __name__ == "__main__":
1526
+ raise SystemExit(main())