@opengsd/gsd-path 1.0.0 → 1.2.0

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