@hunter-harness/workflow-harness 0.2.5 → 0.2.7

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 (270) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +169 -0
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +7 -2
  4. package/harness/bundles/general/claude-code/harness-archive/reference.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +78 -155
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +4 -2
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +5 -3
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  10. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +6 -2
  11. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +8 -3
  12. package/harness/bundles/general/claude-code/harness-review/SKILL.md +8 -3
  13. package/harness/bundles/general/claude-code/harness-run/SKILL.md +13 -7
  14. package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
  15. package/harness/bundles/general/claude-code/harness-run/reference.md +6 -3
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +11 -6
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +5 -3
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +12 -3
  19. package/harness/bundles/general/claude-code/harness-test/reference.md +1 -1
  20. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +1 -1
  21. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +11 -4
  22. package/harness/bundles/general/claude-code/scripts/harness_apidoc.py +53 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_archive.py +231 -48
  24. package/harness/bundles/general/claude-code/scripts/harness_change.py +854 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_events.py +134 -40
  26. package/harness/bundles/general/claude-code/scripts/harness_gate.py +1038 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_preflight.py +46 -3
  28. package/harness/bundles/general/claude-code/scripts/harness_profile.py +2 -2
  29. package/harness/bundles/general/claude-code/scripts/harness_service.py +34 -1
  30. package/harness/bundles/general/claude-code/scripts/harness_state.py +148 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +210 -14
  32. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +160 -0
  33. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  34. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +169 -0
  35. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +7 -2
  36. package/harness/bundles/general/codebuddy/harness-archive/reference.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +78 -155
  38. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +4 -2
  39. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +5 -3
  40. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  41. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  42. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +6 -2
  43. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +8 -3
  44. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +8 -3
  45. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +13 -7
  46. package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
  47. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -3
  48. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +11 -6
  49. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +5 -3
  50. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +12 -3
  51. package/harness/bundles/general/codebuddy/harness-test/reference.md +1 -1
  52. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +1 -1
  53. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +11 -4
  54. package/harness/bundles/general/codebuddy/scripts/harness_apidoc.py +53 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +231 -48
  56. package/harness/bundles/general/codebuddy/scripts/harness_change.py +854 -0
  57. package/harness/bundles/general/codebuddy/scripts/harness_events.py +134 -40
  58. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1038 -0
  59. package/harness/bundles/general/codebuddy/scripts/harness_preflight.py +46 -3
  60. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +2 -2
  61. package/harness/bundles/general/codebuddy/scripts/harness_service.py +34 -1
  62. package/harness/bundles/general/codebuddy/scripts/harness_state.py +148 -0
  63. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +210 -14
  64. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +160 -0
  65. package/harness/bundles/general/codex/.harness-build.json +1 -1
  66. package/harness/bundles/general/codex/contracts/workflow-policy.json +169 -0
  67. package/harness/bundles/general/codex/harness-archive/SKILL.md +7 -2
  68. package/harness/bundles/general/codex/harness-archive/reference.md +1 -1
  69. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +78 -155
  70. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +4 -2
  71. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +5 -3
  72. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  73. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  74. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +6 -2
  75. package/harness/bundles/general/codex/harness-plan/SKILL.md +8 -3
  76. package/harness/bundles/general/codex/harness-review/SKILL.md +7 -2
  77. package/harness/bundles/general/codex/harness-run/SKILL.md +13 -7
  78. package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
  79. package/harness/bundles/general/codex/harness-run/reference.md +6 -3
  80. package/harness/bundles/general/codex/harness-submit/SKILL.md +11 -6
  81. package/harness/bundles/general/codex/harness-sync/SKILL.md +5 -3
  82. package/harness/bundles/general/codex/harness-test/SKILL.md +12 -3
  83. package/harness/bundles/general/codex/harness-test/reference.md +1 -1
  84. package/harness/bundles/general/codex/protocols/ledger-protocol.md +1 -1
  85. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +11 -4
  86. package/harness/bundles/general/codex/scripts/harness_apidoc.py +53 -0
  87. package/harness/bundles/general/codex/scripts/harness_archive.py +231 -48
  88. package/harness/bundles/general/codex/scripts/harness_change.py +854 -0
  89. package/harness/bundles/general/codex/scripts/harness_events.py +134 -40
  90. package/harness/bundles/general/codex/scripts/harness_gate.py +1038 -0
  91. package/harness/bundles/general/codex/scripts/harness_preflight.py +46 -3
  92. package/harness/bundles/general/codex/scripts/harness_profile.py +2 -2
  93. package/harness/bundles/general/codex/scripts/harness_service.py +34 -1
  94. package/harness/bundles/general/codex/scripts/harness_state.py +148 -0
  95. package/harness/bundles/general/codex/scripts/harness_test_guard.py +210 -14
  96. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +160 -0
  97. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  98. package/harness/bundles/general/cursor/contracts/workflow-policy.json +169 -0
  99. package/harness/bundles/general/cursor/harness-archive/SKILL.md +7 -2
  100. package/harness/bundles/general/cursor/harness-archive/reference.md +1 -1
  101. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +78 -155
  102. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +4 -2
  103. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +5 -3
  104. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  105. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  106. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +6 -2
  107. package/harness/bundles/general/cursor/harness-plan/SKILL.md +8 -3
  108. package/harness/bundles/general/cursor/harness-review/SKILL.md +7 -2
  109. package/harness/bundles/general/cursor/harness-run/SKILL.md +13 -7
  110. package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
  111. package/harness/bundles/general/cursor/harness-run/reference.md +6 -3
  112. package/harness/bundles/general/cursor/harness-submit/SKILL.md +11 -6
  113. package/harness/bundles/general/cursor/harness-sync/SKILL.md +5 -3
  114. package/harness/bundles/general/cursor/harness-test/SKILL.md +12 -3
  115. package/harness/bundles/general/cursor/harness-test/reference.md +1 -1
  116. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +1 -1
  117. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +11 -4
  118. package/harness/bundles/general/cursor/scripts/harness_apidoc.py +53 -0
  119. package/harness/bundles/general/cursor/scripts/harness_archive.py +231 -48
  120. package/harness/bundles/general/cursor/scripts/harness_change.py +854 -0
  121. package/harness/bundles/general/cursor/scripts/harness_events.py +134 -40
  122. package/harness/bundles/general/cursor/scripts/harness_gate.py +1038 -0
  123. package/harness/bundles/general/cursor/scripts/harness_preflight.py +46 -3
  124. package/harness/bundles/general/cursor/scripts/harness_profile.py +2 -2
  125. package/harness/bundles/general/cursor/scripts/harness_service.py +34 -1
  126. package/harness/bundles/general/cursor/scripts/harness_state.py +148 -0
  127. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +210 -14
  128. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +160 -0
  129. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  130. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +169 -0
  131. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +11 -2
  132. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +7 -2
  133. package/harness/bundles/java/claude-code/harness-archive/reference.md +1 -1
  134. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +78 -155
  135. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +4 -2
  136. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +5 -3
  137. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  138. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  139. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +6 -2
  140. package/harness/bundles/java/claude-code/harness-package/SKILL.md +10 -4
  141. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +8 -3
  142. package/harness/bundles/java/claude-code/harness-review/SKILL.md +8 -3
  143. package/harness/bundles/java/claude-code/harness-run/SKILL.md +13 -7
  144. package/harness/bundles/java/claude-code/harness-run/reference.md +1 -1
  145. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +11 -6
  146. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +5 -3
  147. package/harness/bundles/java/claude-code/harness-test/SKILL.md +12 -3
  148. package/harness/bundles/java/claude-code/harness-test/reference.md +1 -1
  149. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +1 -1
  150. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +11 -4
  151. package/harness/bundles/java/claude-code/scripts/harness_apidoc.py +53 -0
  152. package/harness/bundles/java/claude-code/scripts/harness_archive.py +231 -48
  153. package/harness/bundles/java/claude-code/scripts/harness_change.py +854 -0
  154. package/harness/bundles/java/claude-code/scripts/harness_events.py +134 -40
  155. package/harness/bundles/java/claude-code/scripts/harness_gate.py +1038 -0
  156. package/harness/bundles/java/claude-code/scripts/harness_preflight.py +46 -3
  157. package/harness/bundles/java/claude-code/scripts/harness_profile.py +2 -2
  158. package/harness/bundles/java/claude-code/scripts/harness_service.py +34 -1
  159. package/harness/bundles/java/claude-code/scripts/harness_state.py +148 -0
  160. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +210 -14
  161. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +160 -0
  162. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  163. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +169 -0
  164. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +11 -2
  165. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +7 -2
  166. package/harness/bundles/java/codebuddy/harness-archive/reference.md +1 -1
  167. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +78 -155
  168. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +4 -2
  169. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +5 -3
  170. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  171. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  172. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +6 -2
  173. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +10 -4
  174. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +8 -3
  175. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +8 -3
  176. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +13 -7
  177. package/harness/bundles/java/codebuddy/harness-run/reference.md +1 -1
  178. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +11 -6
  179. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +5 -3
  180. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +12 -3
  181. package/harness/bundles/java/codebuddy/harness-test/reference.md +1 -1
  182. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +1 -1
  183. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +11 -4
  184. package/harness/bundles/java/codebuddy/scripts/harness_apidoc.py +53 -0
  185. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +231 -48
  186. package/harness/bundles/java/codebuddy/scripts/harness_change.py +854 -0
  187. package/harness/bundles/java/codebuddy/scripts/harness_events.py +134 -40
  188. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1038 -0
  189. package/harness/bundles/java/codebuddy/scripts/harness_preflight.py +46 -3
  190. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +2 -2
  191. package/harness/bundles/java/codebuddy/scripts/harness_service.py +34 -1
  192. package/harness/bundles/java/codebuddy/scripts/harness_state.py +148 -0
  193. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +210 -14
  194. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +160 -0
  195. package/harness/bundles/java/codex/.harness-build.json +1 -1
  196. package/harness/bundles/java/codex/contracts/workflow-policy.json +169 -0
  197. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +11 -2
  198. package/harness/bundles/java/codex/harness-archive/SKILL.md +7 -2
  199. package/harness/bundles/java/codex/harness-archive/reference.md +1 -1
  200. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +78 -155
  201. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +4 -2
  202. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +5 -3
  203. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  204. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  205. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +6 -2
  206. package/harness/bundles/java/codex/harness-package/SKILL.md +10 -4
  207. package/harness/bundles/java/codex/harness-plan/SKILL.md +8 -3
  208. package/harness/bundles/java/codex/harness-review/SKILL.md +7 -2
  209. package/harness/bundles/java/codex/harness-run/SKILL.md +13 -7
  210. package/harness/bundles/java/codex/harness-run/reference.md +1 -1
  211. package/harness/bundles/java/codex/harness-submit/SKILL.md +11 -6
  212. package/harness/bundles/java/codex/harness-sync/SKILL.md +5 -3
  213. package/harness/bundles/java/codex/harness-test/SKILL.md +12 -3
  214. package/harness/bundles/java/codex/harness-test/reference.md +1 -1
  215. package/harness/bundles/java/codex/protocols/ledger-protocol.md +1 -1
  216. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +11 -4
  217. package/harness/bundles/java/codex/scripts/harness_apidoc.py +53 -0
  218. package/harness/bundles/java/codex/scripts/harness_archive.py +231 -48
  219. package/harness/bundles/java/codex/scripts/harness_change.py +854 -0
  220. package/harness/bundles/java/codex/scripts/harness_events.py +134 -40
  221. package/harness/bundles/java/codex/scripts/harness_gate.py +1038 -0
  222. package/harness/bundles/java/codex/scripts/harness_preflight.py +46 -3
  223. package/harness/bundles/java/codex/scripts/harness_profile.py +2 -2
  224. package/harness/bundles/java/codex/scripts/harness_service.py +34 -1
  225. package/harness/bundles/java/codex/scripts/harness_state.py +148 -0
  226. package/harness/bundles/java/codex/scripts/harness_test_guard.py +210 -14
  227. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +160 -0
  228. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  229. package/harness/bundles/java/cursor/contracts/workflow-policy.json +169 -0
  230. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +11 -2
  231. package/harness/bundles/java/cursor/harness-archive/SKILL.md +7 -2
  232. package/harness/bundles/java/cursor/harness-archive/reference.md +1 -1
  233. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +78 -155
  234. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +4 -2
  235. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +5 -3
  236. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  237. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  238. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +6 -2
  239. package/harness/bundles/java/cursor/harness-package/SKILL.md +10 -4
  240. package/harness/bundles/java/cursor/harness-plan/SKILL.md +8 -3
  241. package/harness/bundles/java/cursor/harness-review/SKILL.md +7 -2
  242. package/harness/bundles/java/cursor/harness-run/SKILL.md +13 -7
  243. package/harness/bundles/java/cursor/harness-run/reference.md +1 -1
  244. package/harness/bundles/java/cursor/harness-submit/SKILL.md +11 -6
  245. package/harness/bundles/java/cursor/harness-sync/SKILL.md +5 -3
  246. package/harness/bundles/java/cursor/harness-test/SKILL.md +12 -3
  247. package/harness/bundles/java/cursor/harness-test/reference.md +1 -1
  248. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +1 -1
  249. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +11 -4
  250. package/harness/bundles/java/cursor/scripts/harness_apidoc.py +53 -0
  251. package/harness/bundles/java/cursor/scripts/harness_archive.py +231 -48
  252. package/harness/bundles/java/cursor/scripts/harness_change.py +854 -0
  253. package/harness/bundles/java/cursor/scripts/harness_events.py +134 -40
  254. package/harness/bundles/java/cursor/scripts/harness_gate.py +1038 -0
  255. package/harness/bundles/java/cursor/scripts/harness_preflight.py +46 -3
  256. package/harness/bundles/java/cursor/scripts/harness_profile.py +2 -2
  257. package/harness/bundles/java/cursor/scripts/harness_service.py +34 -1
  258. package/harness/bundles/java/cursor/scripts/harness_state.py +148 -0
  259. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +210 -14
  260. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +160 -0
  261. package/harness/manifests/general/claude-code.json +48 -28
  262. package/harness/manifests/general/codebuddy.json +48 -28
  263. package/harness/manifests/general/codex.json +48 -28
  264. package/harness/manifests/general/cursor.json +48 -28
  265. package/harness/manifests/java/claude-code.json +49 -29
  266. package/harness/manifests/java/codebuddy.json +49 -29
  267. package/harness/manifests/java/codex.json +49 -29
  268. package/harness/manifests/java/cursor.json +49 -29
  269. package/hunter-workflow-family.json +2 -2
  270. package/package.json +1 -1
@@ -313,7 +313,23 @@ def _parse_scalar(val: str) -> Any:
313
313
  return val
314
314
 
315
315
 
316
- def cmd_check_agents(skills_root: Path, agent: str) -> dict[str, Any]:
316
+ def _resolve_agents_root(skills_root: Path, agents_root: Path | None) -> tuple[Path | None, str | None]:
317
+ """Resolve agent definitions without confusing a skills root with its sibling."""
318
+ if agents_root is not None:
319
+ return agents_root.resolve(), None
320
+ if skills_root.name == "skills" and skills_root.parent.name in (".claude", ".codebuddy"):
321
+ return (skills_root.parent / "agents").resolve(), None
322
+ if skills_root.name == "skills" and skills_root.parent.name in (".agents", ".cursor"):
323
+ return None, "CUSTOM_AGENTS_UNSUPPORTED"
324
+ return (skills_root / "agents").resolve(), None
325
+
326
+
327
+ def cmd_check_agents(
328
+ skills_root: Path,
329
+ agent: str,
330
+ *,
331
+ agents_root: Path | None = None,
332
+ ) -> dict[str, Any]:
317
333
  skills_root = skills_root.resolve()
318
334
  agent_name = agent.strip()
319
335
  if not agent_name:
@@ -325,9 +341,23 @@ def cmd_check_agents(skills_root: Path, agent: str) -> dict[str, Any]:
325
341
  "reason": "agent name is empty",
326
342
  }
327
343
 
344
+ resolved_agents_root, capability_error = _resolve_agents_root(skills_root, agents_root)
345
+ if capability_error is not None:
346
+ return {
347
+ "ok": True,
348
+ "action": "check-agents",
349
+ "agent": agent_name,
350
+ "usable": False,
351
+ "definitionValid": False,
352
+ "runtimeSupported": False,
353
+ "reasonCode": capability_error,
354
+ "reason": "current adapter runtime does not support custom agent definitions; use main-session fallback",
355
+ }
356
+
357
+ assert resolved_agents_root is not None
328
358
  # Accept bare name or with .md
329
359
  filename = agent_name if agent_name.endswith(".md") else f"{agent_name}.md"
330
- agent_path = skills_root / "agents" / filename
360
+ agent_path = resolved_agents_root / filename
331
361
 
332
362
  if not agent_path.is_file():
333
363
  return {
@@ -335,7 +365,11 @@ def cmd_check_agents(skills_root: Path, agent: str) -> dict[str, Any]:
335
365
  "action": "check-agents",
336
366
  "agent": agent_name,
337
367
  "path": str(agent_path),
368
+ "agentsRoot": str(resolved_agents_root),
338
369
  "usable": False,
370
+ "definitionValid": False,
371
+ "runtimeSupported": True,
372
+ "reasonCode": "AGENT_DEFINITION_NOT_FOUND",
339
373
  "reason": f"agent file not found: {agent_path}",
340
374
  }
341
375
 
@@ -390,7 +424,11 @@ def cmd_check_agents(skills_root: Path, agent: str) -> dict[str, Any]:
390
424
  "action": "check-agents",
391
425
  "agent": agent_name,
392
426
  "path": str(agent_path),
427
+ "agentsRoot": str(resolved_agents_root),
393
428
  "usable": True,
429
+ "definitionValid": True,
430
+ "runtimeSupported": True,
431
+ "reasonCode": "READY",
394
432
  "reason": "agent file exists; frontmatter parsed; tools declared",
395
433
  "name": name_field,
396
434
  "tools": tools,
@@ -431,6 +469,7 @@ def build_parser() -> argparse.ArgumentParser:
431
469
 
432
470
  p_agents = sub.add_parser("check-agents", help="Validate agent definition usability")
433
471
  p_agents.add_argument("--skills-root", required=True, type=Path)
472
+ p_agents.add_argument("--agents-root", type=Path)
434
473
  p_agents.add_argument("--agent", required=True)
435
474
  p_agents.add_argument("--json", action="store_true")
436
475
 
@@ -459,7 +498,11 @@ def main(argv: list[str] | None = None) -> int:
459
498
  )
460
499
  return emit_json(result, ok=bool(result.get("ok", False)))
461
500
  if args.command == "check-agents":
462
- result = cmd_check_agents(args.skills_root, args.agent)
501
+ result = cmd_check_agents(
502
+ args.skills_root,
503
+ args.agent,
504
+ agents_root=args.agents_root,
505
+ )
463
506
  return emit_json(result, ok=True)
464
507
 
465
508
  parser.error(f"unknown command: {args.command}")
@@ -223,8 +223,8 @@ def _java_commands(reactor_modules: list[str], pom_hash: str) -> dict[str, Any]:
223
223
  "basis": dict(basis),
224
224
  },
225
225
  "package": {
226
- "command": "mvn -f pom.xml package '-Dmaven.test.skip=true'",
227
- "argvTemplate": ["mvn", "-f", "pom.xml", "package", "-Dmaven.test.skip=true"],
226
+ "command": "mvn -f pom.xml clean package '-Dmaven.test.skip=true'",
227
+ "argvTemplate": ["mvn", "-f", "pom.xml", "clean", "package", "-Dmaven.test.skip=true"],
228
228
  "scope": "module",
229
229
  "inputs": _cmd_inputs(reactor_modules, pom=True, main=True, test=True),
230
230
  "coverage": "package",
@@ -194,6 +194,8 @@ def resolve_service_start(
194
194
  change_name: str | None = None,
195
195
  worktree_root: Path | None = None,
196
196
  overlay_path: str | None = None,
197
+ leased_port: int | None = None,
198
+ lease_owner: str | None = None,
197
199
  ) -> dict[str, Any]:
198
200
  """从模板 serviceStart + runtime context 生成 resolved serviceStart。
199
201
 
@@ -220,6 +222,16 @@ def resolve_service_start(
220
222
  # runtime profile 注入:持久 profile 留空时用 change_name;非空则保留
221
223
  if not str(resolved.get("profile") or "").strip():
222
224
  resolved["profile"] = change_name or "local-dev"
225
+ if leased_port is not None:
226
+ if not lease_owner:
227
+ raise ValueError("lease owner is required when leased port is provided")
228
+ resolved["leasedPort"] = leased_port
229
+ resolved["leaseOwner"] = lease_owner
230
+ resolved["port"] = leased_port
231
+ for field in ("command", "healthUrl", "healthFile", "overlayPath"):
232
+ value = resolved.get(field)
233
+ if isinstance(value, str):
234
+ resolved[field] = value.replace("{leasedPort}", str(leased_port))
223
235
  # worktree_root 预留给未来相对 overlay 路径解析(spec §3.6 state snapshot);
224
236
  # 不写入返回值,避免污染 session。
225
237
  _ = worktree_root
@@ -914,7 +926,7 @@ def build_session(
914
926
  ) -> dict[str, Any]:
915
927
  profile_name = service_start.get("profile") or "local-dev"
916
928
  overlay = service_start.get("overlayPath") or ""
917
- return {
929
+ session = {
918
930
  "pid": pid,
919
931
  "startedBy": "AI",
920
932
  "moduleInputsHash": module_inputs_hash,
@@ -925,6 +937,10 @@ def build_session(
925
937
  "startedAt": started_at or now_iso(),
926
938
  "command": command,
927
939
  }
940
+ if isinstance(service_start.get("leasedPort"), int):
941
+ session["leasedPort"] = service_start["leasedPort"]
942
+ session["leaseOwner"] = service_start.get("leaseOwner")
943
+ return session
928
944
 
929
945
 
930
946
  def compute_module_hash(
@@ -1015,12 +1031,16 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1015
1031
  # 含 worktree/change 陈旧持久值时拒绝(修复输入端陈旧 profile)。
1016
1032
  change_name = getattr(args, "change_name", None) or change_dir.name
1017
1033
  overlay = getattr(args, "overlay", None)
1034
+ leased_port = getattr(args, "leased_port", None)
1035
+ lease_owner = str(getattr(args, "lease_owner", None) or "").strip() or None
1018
1036
  try:
1019
1037
  service_start = resolve_service_start(
1020
1038
  profile,
1021
1039
  change_name=change_name,
1022
1040
  worktree_root=project,
1023
1041
  overlay_path=overlay,
1042
+ leased_port=leased_port,
1043
+ lease_owner=lease_owner,
1024
1044
  )
1025
1045
  except ValueError as exc:
1026
1046
  return emit_error(str(exc), as_json=as_json)
@@ -1084,6 +1104,8 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1084
1104
  and session.get("startCommandHash") == current_cmd_hash
1085
1105
  and session.get("profile") == current_profile
1086
1106
  and session.get("overlayPath") == current_overlay
1107
+ and session.get("leasedPort") == service_start.get("leasedPort")
1108
+ and session.get("leaseOwner") == service_start.get("leaseOwner")
1087
1109
  )
1088
1110
  if fingerprint_match:
1089
1111
  payload = {
@@ -1389,6 +1411,17 @@ def build_parser() -> argparse.ArgumentParser:
1389
1411
  default=None,
1390
1412
  help="runtime overlay path injected into resolved serviceStart",
1391
1413
  )
1414
+ p_ensure.add_argument(
1415
+ "--leased-port",
1416
+ type=int,
1417
+ default=None,
1418
+ help="port allocated by harness_change.py lease-port",
1419
+ )
1420
+ p_ensure.add_argument(
1421
+ "--lease-owner",
1422
+ default=None,
1423
+ help="run id that owns --leased-port",
1424
+ )
1392
1425
  p_ensure.add_argument("--json", action="store_true")
1393
1426
  p_ensure.set_defaults(func=cmd_ensure)
1394
1427
 
@@ -12,9 +12,11 @@ Python 3.10+ stdlib only. UTF-8 without BOM. Windows path friendly.
12
12
  from __future__ import annotations
13
13
 
14
14
  import datetime as dt
15
+ import argparse
15
16
  import hashlib
16
17
  import json
17
18
  import os
19
+ import subprocess
18
20
  import sys
19
21
  from pathlib import Path
20
22
  from typing import Any
@@ -177,3 +179,149 @@ def refresh_segments(
177
179
  refreshed["segments"] = refreshed_segments
178
180
  refreshed["generatedAt"] = now_iso()
179
181
  return refreshed
182
+
183
+
184
+ def _git(project: Path, *args: str) -> str:
185
+ proc = subprocess.run(
186
+ ["git", "-C", str(project), *args],
187
+ capture_output=True,
188
+ text=True,
189
+ encoding="utf-8",
190
+ errors="replace",
191
+ check=False,
192
+ )
193
+ return proc.stdout.strip() if proc.returncode == 0 else ""
194
+
195
+
196
+ def _existing_files(paths: list[Path]) -> list[str]:
197
+ return sorted({str(path.resolve()) for path in paths if path.is_file()})
198
+
199
+
200
+ def discover_segment_files(
201
+ project: Path, change_dir: Path, *, base: str = "", head: str = "HEAD"
202
+ ) -> dict[str, list[str]]:
203
+ """Discover the compact inputs shared by plan/run/test/review/submit."""
204
+ project = project.resolve()
205
+ rules = [project / "AGENTS.md", project / "CLAUDE.md"]
206
+ for pattern in (
207
+ ".claude/rules/**/*",
208
+ ".cursor/rules/**/*",
209
+ ".codebuddy/rules/**/*",
210
+ ".codebuddy/.rules/**/*",
211
+ ):
212
+ rules.extend(project.glob(pattern))
213
+
214
+ profile = [
215
+ project / ".harness/config/build-profile.json",
216
+ project / ".harness/context-index.json",
217
+ project / "project.yaml",
218
+ project / "harness.json",
219
+ ]
220
+ codebase_map = list((project / ".harness/codebase/map").glob("**/*"))
221
+ knowledge = [
222
+ project / ".harness/knowledge/index.json",
223
+ project / ".harness/knowledge/status.json",
224
+ project / ".harness/knowledge/config.json",
225
+ ]
226
+ change_inputs: list[Path] = []
227
+ for folder in ("spec", "plans", "meta"):
228
+ change_inputs.extend((change_dir / folder).glob("**/*"))
229
+ change_inputs = [path for path in change_inputs if path != snapshot_path(change_dir)]
230
+
231
+ changed_names: list[str] = []
232
+ if base:
233
+ raw = _git(project, "diff", "--name-only", "--diff-filter=ACMRT", f"{base}..{head}")
234
+ changed_names = [line for line in raw.splitlines() if line.strip()]
235
+ code = [project / name for name in changed_names]
236
+ return {
237
+ "profile": _existing_files(profile),
238
+ "rules": _existing_files(rules),
239
+ "map": _existing_files(codebase_map),
240
+ "knowledge": _existing_files(knowledge),
241
+ "change": _existing_files(change_inputs),
242
+ "code": _existing_files(code),
243
+ }
244
+
245
+
246
+ def capture_current_state(
247
+ *,
248
+ project: Path,
249
+ change_dir: Path,
250
+ change_name: str,
251
+ worktree_root: Path,
252
+ base: str = "",
253
+ head: str = "HEAD",
254
+ ) -> tuple[dict[str, Any], list[str]]:
255
+ resolved_head = _git(project, "rev-parse", head) or head
256
+ resolved_base = _git(project, "rev-parse", base) if base else ""
257
+ files = discover_segment_files(project, change_dir, base=resolved_base, head=resolved_head)
258
+ previous = load_snapshot(change_dir)
259
+ fresh = capture_snapshot(
260
+ change_dir,
261
+ change_name=change_name,
262
+ project=project,
263
+ worktree_root=worktree_root,
264
+ base=resolved_base,
265
+ head=resolved_head,
266
+ segment_files=files,
267
+ )
268
+ changed: list[str] = []
269
+ if previous:
270
+ old_segments = previous.get("segments") or {}
271
+ for name, segment in fresh["segments"].items():
272
+ old = old_segments.get(name)
273
+ if isinstance(old, dict) and old.get("fingerprint") == segment.get("fingerprint"):
274
+ segment["capturedAt"] = old.get("capturedAt", segment["capturedAt"])
275
+ else:
276
+ changed.append(name)
277
+ else:
278
+ changed = list(fresh["segments"])
279
+ write_snapshot(change_dir, fresh)
280
+ return fresh, changed
281
+
282
+
283
+ def build_parser() -> argparse.ArgumentParser:
284
+ parser = argparse.ArgumentParser(description="Capture reusable Harness state")
285
+ sub = parser.add_subparsers(dest="command", required=True)
286
+ capture = sub.add_parser("capture", help="capture or refresh changed segments")
287
+ capture.add_argument("--project", default=".")
288
+ capture.add_argument("--change-dir", required=True)
289
+ capture.add_argument("--change-name")
290
+ capture.add_argument("--worktree-root")
291
+ capture.add_argument("--base", default="")
292
+ capture.add_argument("--head", default="HEAD")
293
+ capture.add_argument("--json", action="store_true")
294
+ return parser
295
+
296
+
297
+ def main(argv: list[str] | None = None) -> int:
298
+ args = build_parser().parse_args(argv)
299
+ project = Path(args.project).resolve()
300
+ change_dir = Path(args.change_dir).resolve()
301
+ name = args.change_name or change_dir.name
302
+ worktree = Path(args.worktree_root).resolve() if args.worktree_root else project
303
+ snapshot, changed = capture_current_state(
304
+ project=project,
305
+ change_dir=change_dir,
306
+ change_name=name,
307
+ worktree_root=worktree,
308
+ base=args.base,
309
+ head=args.head,
310
+ )
311
+ result = {
312
+ "ok": True,
313
+ "path": str(snapshot_path(change_dir)),
314
+ "changedSegments": changed,
315
+ "reusedSegments": sorted(set(snapshot["segments"]) - set(changed)),
316
+ "git": snapshot["git"],
317
+ }
318
+ if args.json:
319
+ print(json.dumps(result, ensure_ascii=False))
320
+ else:
321
+ print(f"state snapshot: {result['path']}")
322
+ print(f"changed: {', '.join(changed) if changed else 'none'}")
323
+ return 0
324
+
325
+
326
+ if __name__ == "__main__":
327
+ raise SystemExit(main())
@@ -24,6 +24,7 @@ from typing import Any
24
24
  SCHEMA_VERSION = 1
25
25
  MODE = "force-track-touched"
26
26
  MANIFEST_REL = Path("evidence") / "test-tracking.json"
27
+ SNAPSHOT_REL = Path("evidence") / "test-guard-snapshot.json"
27
28
  PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
28
29
  REASONS = ("tdd-created", "stale-test-repair", "test-updated")
29
30
 
@@ -125,7 +126,7 @@ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
125
126
  while descriptor is None:
126
127
  try:
127
128
  descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
128
- except FileExistsError as exc:
129
+ except (FileExistsError, PermissionError) as exc:
129
130
  if time.monotonic() >= deadline:
130
131
  raise LockUnavailable(str(path)) from exc
131
132
  time.sleep(0.01)
@@ -137,22 +138,41 @@ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
137
138
  path.unlink(missing_ok=True)
138
139
 
139
140
 
141
+ def _state_project_root(project: Path) -> Path:
142
+ result = _git(project, "rev-parse", "--git-common-dir")
143
+ if result.returncode != 0 or not result.stdout.strip():
144
+ return project
145
+ common = Path(result.stdout.strip())
146
+ if not common.is_absolute():
147
+ common = project / common
148
+ resolved = common.resolve()
149
+ return resolved.parent if resolved.name == ".git" else project
150
+
151
+
140
152
  def _change_dir(project: Path, change_dir: Path | str) -> Path | None:
153
+ state_project = _state_project_root(project)
141
154
  candidate = Path(change_dir)
142
155
  if not candidate.is_absolute():
143
- candidate = project / candidate
156
+ candidate = (
157
+ state_project / candidate
158
+ if candidate.parts[:2] == (".harness", "changes")
159
+ else project / candidate
160
+ )
144
161
  resolved = candidate.resolve()
145
- return resolved if _inside(resolved, project) else None
162
+ allowed_roots = {
163
+ (project / ".harness" / "changes").resolve(),
164
+ (state_project / ".harness" / "changes").resolve(),
165
+ }
166
+ return resolved if any(_inside(resolved, root) for root in allowed_roots) else None
146
167
 
147
168
 
148
- def _manifest_path(project: Path, change_root: Path) -> Path | None:
169
+ def _manifest_path(change_root: Path) -> Path | None:
149
170
  evidence = change_root / MANIFEST_REL.parent
150
171
  manifest = change_root / MANIFEST_REL
151
172
  expected_evidence = evidence.absolute()
152
173
  resolved_evidence = evidence.resolve()
153
174
  if (
154
- not _inside(change_root.resolve(), project)
155
- or not _inside(resolved_evidence, project)
175
+ not _inside(resolved_evidence, change_root.resolve())
156
176
  or os.path.normcase(str(resolved_evidence))
157
177
  != os.path.normcase(str(expected_evidence))
158
178
  ):
@@ -160,9 +180,12 @@ def _manifest_path(project: Path, change_root: Path) -> Path | None:
160
180
  return manifest
161
181
 
162
182
 
163
- def _manifest_target_inside(project: Path, manifest: Path) -> bool:
183
+ def _manifest_target_inside(change_root: Path, manifest: Path) -> bool:
164
184
  resolved = manifest.resolve()
165
- return _inside(resolved, project) and _inside(resolved, manifest.parent.absolute())
185
+ return (
186
+ _inside(resolved, change_root.resolve())
187
+ and _inside(resolved, manifest.parent.absolute())
188
+ )
166
189
 
167
190
 
168
191
  def _profile_config(project: Path) -> tuple[list[str], list[str]] | None:
@@ -325,13 +348,13 @@ def record(
325
348
  assert path is not None and rel is not None
326
349
  validated.append((path, rel))
327
350
 
328
- manifest_path = _manifest_path(project_root, change_root)
351
+ manifest_path = _manifest_path(change_root)
329
352
  if manifest_path is None:
330
353
  return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
331
354
  lock_path = manifest_path.with_name(manifest_path.name + ".lock")
332
355
  try:
333
356
  with _exclusive_lock(lock_path, wait_seconds=5.0):
334
- if not _manifest_target_inside(project_root, manifest_path):
357
+ if not _manifest_target_inside(change_root, manifest_path):
335
358
  return _result(
336
359
  False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", []
337
360
  )
@@ -378,10 +401,10 @@ def record(
378
401
 
379
402
 
380
403
  def _stage_locked(
381
- project_root: Path, manifest_path: Path, index_path: Path
404
+ project_root: Path, change_root: Path, manifest_path: Path, index_path: Path
382
405
  ) -> dict[str, Any]:
383
406
  action = "stage"
384
- if not _manifest_target_inside(project_root, manifest_path):
407
+ if not _manifest_target_inside(change_root, manifest_path):
385
408
  return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
386
409
  try:
387
410
  manifest = _read_json(manifest_path)
@@ -444,13 +467,164 @@ def _stage_locked(
444
467
  temp_index.unlink(missing_ok=True)
445
468
 
446
469
 
470
+ def _glob_files_for_pattern(base: Path, pattern: str) -> list[Path]:
471
+ normalized = pattern.replace("\\", "/")
472
+ if normalized.endswith("/**"):
473
+ prefix = normalized[:-3].rstrip("/")
474
+ root = base / prefix
475
+ if not root.is_dir():
476
+ return []
477
+ return [path for path in root.rglob("*") if path.is_file()]
478
+ return [path for path in base.glob(normalized) if path.is_file()]
479
+
480
+
481
+ def _enumerate_allowed_test_files(project_root: Path) -> dict[str, str]:
482
+ """Map repo-relative test path -> sha256 for all allowed existing files."""
483
+ found: dict[str, str] = {}
484
+ config = _profile_config(project_root)
485
+ if config is None:
486
+ patterns = ["test/**", "tests/**", "src/test/**"]
487
+ else:
488
+ patterns, _excluded = config
489
+ base = project_root.resolve()
490
+ seen: set[str] = set()
491
+ for pattern in patterns:
492
+ for match in _glob_files_for_pattern(base, pattern):
493
+ resolved = match.resolve()
494
+ if not _inside(resolved, base):
495
+ continue
496
+ rel = resolved.relative_to(base).as_posix()
497
+ if rel in seen or not _allowed_test_path(project_root, rel):
498
+ continue
499
+ seen.add(rel)
500
+ found[rel] = _sha256(resolved)
501
+ return found
502
+
503
+
504
+ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
505
+ action = "begin"
506
+ project_root = Path(project).resolve()
507
+ change_root = _change_dir(project_root, change_dir)
508
+ if change_root is None:
509
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
510
+ manifest_path = _manifest_path(change_root)
511
+ if manifest_path is None:
512
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
513
+ snapshot_path = change_root / SNAPSHOT_REL
514
+ if not _manifest_target_inside(change_root, snapshot_path):
515
+ return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
516
+
517
+ files = _enumerate_allowed_test_files(project_root)
518
+ snapshot = {
519
+ "schemaVersion": SCHEMA_VERSION,
520
+ "mode": MODE,
521
+ "projectRoot": str(project_root),
522
+ "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
523
+ "files": [
524
+ {"path": rel, "sha256": digest, "ignored": _is_ignored(project_root, rel)}
525
+ for rel, digest in sorted(files.items())
526
+ ],
527
+ }
528
+ _write_json(snapshot_path, snapshot)
529
+ return _result(
530
+ True,
531
+ action,
532
+ "SNAPSHOT_CAPTURED",
533
+ list(files),
534
+ snapshotPath=str(snapshot_path),
535
+ fileCount=len(files),
536
+ )
537
+
538
+
539
+ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
540
+ action = "close"
541
+ project_root = Path(project).resolve()
542
+ change_root = _change_dir(project_root, change_dir)
543
+ if change_root is None:
544
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
545
+ snapshot_path = change_root / SNAPSHOT_REL
546
+ if not snapshot_path.is_file():
547
+ return _result(False, action, "SNAPSHOT_MISSING", [])
548
+ try:
549
+ snapshot = _read_json(snapshot_path)
550
+ except (OSError, json.JSONDecodeError) as exc:
551
+ return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
552
+ if (
553
+ not isinstance(snapshot, dict)
554
+ or snapshot.get("schemaVersion") != SCHEMA_VERSION
555
+ or snapshot.get("projectRoot") != str(project_root)
556
+ ):
557
+ return _result(False, action, "SNAPSHOT_INVALID", [])
558
+
559
+ before_entries = snapshot.get("files")
560
+ if not isinstance(before_entries, list):
561
+ return _result(False, action, "SNAPSHOT_INVALID", [])
562
+ before: dict[str, dict[str, Any]] = {}
563
+ for item in before_entries:
564
+ if not isinstance(item, dict):
565
+ return _result(False, action, "SNAPSHOT_INVALID", [])
566
+ rel = item.get("path")
567
+ digest = item.get("sha256")
568
+ if not isinstance(rel, str) or not rel or not isinstance(digest, str):
569
+ return _result(False, action, "SNAPSHOT_INVALID", [])
570
+ path, normalized, error = _validate_file(project_root, rel)
571
+ if error:
572
+ return _result(False, action, error, [rel])
573
+ if normalized != rel or path is None:
574
+ return _result(False, action, "PATH_ESCAPE", [rel])
575
+ before[rel] = item
576
+
577
+ current = _enumerate_allowed_test_files(project_root)
578
+ for rel in current:
579
+ path, normalized, error = _validate_file(project_root, rel)
580
+ if error:
581
+ return _result(False, action, error, [rel])
582
+ if normalized != rel or path is None:
583
+ return _result(False, action, "PATH_ESCAPE", [rel])
584
+
585
+ touched: list[tuple[str, str]] = []
586
+ for rel, digest in current.items():
587
+ if rel not in before:
588
+ touched.append((rel, "tdd-created"))
589
+ continue
590
+ if before[rel]["sha256"] != digest:
591
+ touched.append((rel, "test-updated"))
592
+
593
+ recorded: list[str] = []
594
+ for rel, reason in touched:
595
+ result = record(project_root, change_root, [str(project_root / rel)], reason)
596
+ if not result.get("ok"):
597
+ code = result.get("code", "RECORD_FAILED")
598
+ if code == "TEST_PATH_NOT_ALLOWED":
599
+ return _result(False, action, "UNCLASSIFIABLE_TEST", [rel])
600
+ return result
601
+ recorded.append(rel)
602
+
603
+ return _result(
604
+ True,
605
+ action,
606
+ "CLOSED",
607
+ recorded,
608
+ recordedCount=len(recorded),
609
+ unchangedPreexisting=len(before) - sum(1 for rel, _ in touched if rel in before),
610
+ )
611
+
612
+
613
+ def mark(
614
+ project: Path | str,
615
+ change_dir: Path | str,
616
+ files: list[str],
617
+ ) -> dict[str, Any]:
618
+ return record(project, change_dir, files, "stale-test-repair")
619
+
620
+
447
621
  def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
448
622
  action = "stage"
449
623
  project_root = Path(project).resolve()
450
624
  change_root = _change_dir(project_root, change_dir)
451
625
  if change_root is None:
452
626
  return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
453
- manifest_path = _manifest_path(project_root, change_root)
627
+ manifest_path = _manifest_path(change_root)
454
628
  if manifest_path is None:
455
629
  return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
456
630
 
@@ -468,7 +642,9 @@ def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
468
642
  manifest_lock = manifest_path.with_name(manifest_path.name + ".lock")
469
643
  try:
470
644
  with _exclusive_lock(manifest_lock, wait_seconds=0.0):
471
- return _stage_locked(project_root, manifest_path, index_path)
645
+ return _stage_locked(
646
+ project_root, change_root, manifest_path, index_path
647
+ )
472
648
  except LockUnavailable:
473
649
  return _result(False, action, "MANIFEST_LOCKED", [])
474
650
  except LockUnavailable:
@@ -488,10 +664,30 @@ def main(argv: list[str] | None = None) -> int:
488
664
  stage_parser.add_argument("--project", required=True)
489
665
  stage_parser.add_argument("--change-dir", required=True)
490
666
  stage_parser.add_argument("--json", action="store_true")
667
+ begin_parser = sub.add_parser("begin")
668
+ begin_parser.add_argument("--project", required=True)
669
+ begin_parser.add_argument("--change-dir", required=True)
670
+ begin_parser.add_argument("--json", action="store_true")
671
+ close_parser = sub.add_parser("close")
672
+ close_parser.add_argument("--project", required=True)
673
+ close_parser.add_argument("--change-dir", required=True)
674
+ close_parser.add_argument("--json", action="store_true")
675
+ mark_parser = sub.add_parser("mark")
676
+ mark_parser.add_argument("--project", required=True)
677
+ mark_parser.add_argument("--change-dir", required=True)
678
+ mark_parser.add_argument("--files", required=True)
679
+ mark_parser.add_argument("--json", action="store_true")
491
680
  args = parser.parse_args(argv)
492
681
  if args.action == "record":
493
682
  files = [item.strip() for item in args.files.split(",") if item.strip()]
494
683
  result = record(args.project, args.change_dir, files, args.reason)
684
+ elif args.action == "begin":
685
+ result = begin(args.project, args.change_dir)
686
+ elif args.action == "close":
687
+ result = close(args.project, args.change_dir)
688
+ elif args.action == "mark":
689
+ files = [item.strip() for item in args.files.split(",") if item.strip()]
690
+ result = mark(args.project, args.change_dir, files)
495
691
  else:
496
692
  result = stage(args.project, args.change_dir)
497
693
  print(json.dumps(result, ensure_ascii=False, indent=2 if args.json else None))