@hunter-harness/workflow-harness 0.4.0 → 0.4.1

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 (386) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
  33. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  34. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
  50. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  51. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
  67. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  68. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
  69. package/harness/bundles/general/pi/.harness-build.json +6 -0
  70. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  71. package/harness/bundles/general/pi/README.md +478 -0
  72. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  73. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  74. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  75. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  76. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  77. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  78. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  79. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  80. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  81. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  82. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  83. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  84. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  85. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  86. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  87. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  88. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  89. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  90. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  91. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  92. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  93. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  94. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  99. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  100. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  101. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  102. package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
  103. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  104. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  105. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  106. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  107. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  108. package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
  109. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  110. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  111. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  112. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  113. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  114. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  115. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  116. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  117. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  118. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  119. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  120. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  121. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  122. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  123. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  124. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  125. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  126. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  127. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  128. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  129. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  130. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  132. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  133. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  134. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  135. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  136. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  137. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  138. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  139. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  140. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  141. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  142. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  144. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  145. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  146. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  147. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  148. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  149. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  150. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  151. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  152. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  153. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  155. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  156. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  157. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  158. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  159. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  160. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  161. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  162. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  163. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  164. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  165. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  166. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  167. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  168. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  169. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  170. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  171. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  172. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  173. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  174. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  175. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  176. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  177. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  178. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  179. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  180. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  181. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  182. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  184. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  185. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  190. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  199. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  200. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  201. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  202. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  203. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  204. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
  217. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  218. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  219. package/harness/bundles/java/codex/.harness-build.json +1 -1
  220. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  221. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  222. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  223. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  225. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  226. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  227. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  228. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  229. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  230. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  231. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  232. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  235. package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
  236. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  237. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
  255. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  256. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
  257. package/harness/bundles/java/pi/.harness-build.json +6 -0
  258. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  259. package/harness/bundles/java/pi/README.md +478 -0
  260. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  261. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  262. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  263. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  264. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  265. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  266. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  267. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  268. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  269. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  270. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  271. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  272. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  273. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  274. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  275. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  276. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  277. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  278. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  279. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  280. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  281. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  282. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  283. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  284. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  285. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  286. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  287. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  288. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  289. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  290. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  291. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  292. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  293. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  294. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  295. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  296. package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
  297. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  298. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  299. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  300. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  301. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  302. package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
  303. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  304. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  305. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  306. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  307. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  308. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  309. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  310. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  311. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  312. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  313. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  314. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  315. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  316. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  317. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  318. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  319. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  320. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  321. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  322. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  323. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  324. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  325. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  326. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  327. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  328. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  329. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  330. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  331. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  332. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  333. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  334. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  335. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  336. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  337. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  338. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  339. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  340. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  341. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  342. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  343. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  344. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  345. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  346. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  347. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  348. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  349. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  350. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  351. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  352. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  353. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  354. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  355. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  356. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  357. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  358. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  359. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  360. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  361. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  362. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  363. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  364. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  365. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  366. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  367. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  368. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  369. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  370. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  371. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  372. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  373. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  374. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  375. package/harness/manifests/general/claude-code.json +17 -17
  376. package/harness/manifests/general/codebuddy.json +17 -17
  377. package/harness/manifests/general/codex.json +17 -17
  378. package/harness/manifests/general/cursor.json +17 -17
  379. package/harness/manifests/general/pi.json +475 -0
  380. package/harness/manifests/java/claude-code.json +19 -19
  381. package/harness/manifests/java/codebuddy.json +19 -19
  382. package/harness/manifests/java/codex.json +19 -19
  383. package/harness/manifests/java/cursor.json +19 -19
  384. package/harness/manifests/java/pi.json +499 -0
  385. package/hunter-workflow-family.json +2 -2
  386. package/package.json +1 -1
@@ -0,0 +1,1899 @@
1
+ #!/usr/bin/env python3
2
+ """Record and force-stage only explicitly touched test files.
3
+
4
+ Python 3.10+ stdlib only. Git is always invoked with an argv list.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import contextlib
11
+ import fnmatch
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ SCRIPTS_DIR = Path(__file__).resolve().parent
25
+ if str(SCRIPTS_DIR) not in sys.path:
26
+ sys.path.insert(0, str(SCRIPTS_DIR))
27
+
28
+ import harness_paths # noqa: E402
29
+
30
+ SCHEMA_VERSION = 1
31
+ MODE = "force-track-touched"
32
+ MANIFEST_REL = Path("evidence") / "test-tracking.json"
33
+ SNAPSHOT_REL = Path("evidence") / "test-guard-snapshot.json"
34
+ PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
35
+ MANDATORY_EXCLUDED_ROOTS = (
36
+ ".git",
37
+ ".harness",
38
+ ".worktrees",
39
+ ".claude/worktrees",
40
+ ".codex/worktrees",
41
+ ".cursor/worktrees",
42
+ ".codebuddy/worktrees",
43
+ ".codeium/worktrees",
44
+ "target",
45
+ "build",
46
+ "dist",
47
+ "node_modules",
48
+ ".gradle",
49
+ "__pycache__",
50
+ ".pytest_cache",
51
+ ".cache",
52
+ )
53
+ REASONS = ("tdd-created", "stale-test-repair", "test-updated")
54
+
55
+
56
+ class LockUnavailable(RuntimeError):
57
+ """A compatible lock file is already held by another process."""
58
+
59
+
60
+ class ProfileConfigInvalid(ValueError):
61
+ """The selected project-owned test-tracking profile is malformed."""
62
+
63
+
64
+ if hasattr(sys.stdout, "reconfigure"):
65
+ sys.stdout.reconfigure(encoding="utf-8")
66
+ if hasattr(sys.stderr, "reconfigure"):
67
+ sys.stderr.reconfigure(encoding="utf-8")
68
+
69
+
70
+ def _result(ok: bool, action: str, code: str, files: list[str], **extra: Any) -> dict[str, Any]:
71
+ return {"ok": ok, "action": action, "code": code, "files": files, **extra}
72
+
73
+
74
+ def _write_json(path: Path, data: Any) -> None:
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
77
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
78
+ try:
79
+ tmp.write_text(text, encoding="utf-8", newline="\n")
80
+ os.replace(tmp, path)
81
+ except BaseException:
82
+ tmp.unlink(missing_ok=True)
83
+ raise
84
+
85
+
86
+ def _read_json(path: Path) -> Any:
87
+ return json.loads(path.read_text(encoding="utf-8-sig"))
88
+
89
+
90
+ def _sha256(path: Path) -> str:
91
+ digest = hashlib.sha256()
92
+ with path.open("rb") as handle:
93
+ for chunk in iter(lambda: handle.read(65536), b""):
94
+ digest.update(chunk)
95
+ return "sha256:" + digest.hexdigest()
96
+
97
+
98
+ def _git(
99
+ project: Path,
100
+ *args: str,
101
+ index_file: Path | None = None,
102
+ ) -> subprocess.CompletedProcess[str]:
103
+ env = os.environ.copy()
104
+ if index_file is not None:
105
+ env["GIT_INDEX_FILE"] = str(index_file)
106
+ return subprocess.run(
107
+ [
108
+ "git",
109
+ "--literal-pathspecs",
110
+ "-c",
111
+ "core.quotepath=false",
112
+ "-C",
113
+ str(project),
114
+ *args,
115
+ ],
116
+ capture_output=True,
117
+ text=True,
118
+ encoding="utf-8",
119
+ errors="replace",
120
+ timeout=30,
121
+ check=False,
122
+ env=env,
123
+ )
124
+
125
+
126
+ def _is_ignored(project: Path, rel: str) -> bool:
127
+ status = _git(
128
+ project,
129
+ "status",
130
+ "--ignored=matching",
131
+ "--untracked-files=all",
132
+ "--porcelain=v1",
133
+ "--",
134
+ rel,
135
+ )
136
+ return status.returncode == 0 and any(
137
+ line.startswith("!! ") for line in status.stdout.splitlines()
138
+ )
139
+
140
+
141
+ def _inside(path: Path, root: Path) -> bool:
142
+ try:
143
+ normalized_path = os.path.normcase(str(path))
144
+ normalized_root = os.path.normcase(str(root))
145
+ return os.path.commonpath((normalized_path, normalized_root)) == normalized_root
146
+ except ValueError:
147
+ return False
148
+
149
+
150
+ @contextlib.contextmanager
151
+ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ deadline = time.monotonic() + wait_seconds
154
+ descriptor: int | None = None
155
+ while descriptor is None:
156
+ try:
157
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
158
+ except (FileExistsError, PermissionError) as exc:
159
+ if time.monotonic() >= deadline:
160
+ raise LockUnavailable(str(path)) from exc
161
+ time.sleep(0.01)
162
+ try:
163
+ os.write(descriptor, f"{os.getpid()}\n".encode("ascii"))
164
+ yield
165
+ finally:
166
+ os.close(descriptor)
167
+ path.unlink(missing_ok=True)
168
+
169
+
170
+ def _state_project_root(project: Path) -> Path:
171
+ result = _git(project, "rev-parse", "--git-common-dir")
172
+ if result.returncode != 0 or not result.stdout.strip():
173
+ return project
174
+ common = Path(result.stdout.strip())
175
+ if not common.is_absolute():
176
+ common = project / common
177
+ resolved = common.resolve()
178
+ return resolved.parent if resolved.name == ".git" else project
179
+
180
+
181
+ def _invalid_project_root(
182
+ action: str, project: Path | str, resolved: Path
183
+ ) -> dict[str, Any] | None:
184
+ """Reject a --project that is not an existing directory.
185
+
186
+ Passing the *project name* (``--project udp``) instead of its path used to
187
+ resolve to ``<cwd>/udp`` and then surface as SNAPSHOT_MISSING — an error
188
+ that points at the wrong thing entirely. Fail here with the resolved path
189
+ so the real mistake is visible.
190
+ """
191
+ if resolved.is_dir():
192
+ return None
193
+ return _result(
194
+ False,
195
+ action,
196
+ "PROJECT_ROOT_INVALID",
197
+ [],
198
+ project=str(project),
199
+ resolvedProject=str(resolved),
200
+ hint=(
201
+ "--project takes a filesystem path to the project root "
202
+ "(use '.' when running from it), not the project name"
203
+ ),
204
+ )
205
+
206
+
207
+ def _change_dir(project: Path, change_dir: Path | str) -> Path | None:
208
+ state_project = _state_project_root(project)
209
+ candidate = Path(change_dir)
210
+ if not candidate.is_absolute():
211
+ candidate = (
212
+ state_project / candidate
213
+ if candidate.parts[:2] == (".harness", "changes")
214
+ else project / candidate
215
+ )
216
+ resolved = candidate.resolve()
217
+ allowed_roots = {
218
+ (project / ".harness" / "changes").resolve(),
219
+ (state_project / ".harness" / "changes").resolve(),
220
+ }
221
+ return resolved if any(_inside(resolved, root) for root in allowed_roots) else None
222
+
223
+
224
+ def _state_root(change_root: Path) -> Path:
225
+ return Path(harness_paths.resolve_state_dir_for_contract(change_root))
226
+
227
+
228
+ def _manifest_path(change_root: Path) -> Path | None:
229
+ state_root = _state_root(change_root)
230
+ evidence = state_root / MANIFEST_REL.parent
231
+ manifest = state_root / MANIFEST_REL
232
+ expected_evidence = evidence.absolute()
233
+ resolved_evidence = evidence.resolve()
234
+ if (
235
+ not _inside(resolved_evidence, state_root.resolve())
236
+ or os.path.normcase(str(resolved_evidence))
237
+ != os.path.normcase(str(expected_evidence))
238
+ ):
239
+ return None
240
+ return manifest
241
+
242
+
243
+ def _manifest_target_inside(change_root: Path, manifest: Path) -> bool:
244
+ state_root = _state_root(change_root)
245
+ resolved = manifest.resolve()
246
+ return (
247
+ _inside(resolved, state_root.resolve())
248
+ and _inside(resolved, manifest.parent.absolute())
249
+ )
250
+
251
+
252
+ def _profile_config(
253
+ project: Path,
254
+ *,
255
+ strict: bool = False,
256
+ ) -> tuple[list[str], list[str]] | None:
257
+ def invalid(message: str) -> tuple[list[str], list[str]]:
258
+ if strict:
259
+ raise ProfileConfigInvalid(message)
260
+ return [], []
261
+
262
+ local_profile = project / PROFILE_REL
263
+ if os.path.lexists(local_profile):
264
+ profile_path = local_profile
265
+ if not profile_path.is_file():
266
+ return invalid(f"{PROFILE_REL.as_posix()} is not a regular file")
267
+ else:
268
+ state_project = _state_project_root(project)
269
+ common_root = harness_paths.common_root(project)
270
+ common_profile = state_project / PROFILE_REL
271
+ if (
272
+ state_project == project
273
+ or state_project != common_root
274
+ or not os.path.lexists(common_profile)
275
+ ):
276
+ return None
277
+ if not common_profile.is_file():
278
+ return invalid(
279
+ f"common {PROFILE_REL.as_posix()} is not a regular file"
280
+ )
281
+ profile_path = common_profile
282
+ try:
283
+ profile = _read_json(profile_path)
284
+ except (OSError, json.JSONDecodeError) as exc:
285
+ return invalid(f"cannot read {profile_path}: {exc}")
286
+ if not isinstance(profile, dict):
287
+ return invalid(f"{profile_path} must contain a JSON object")
288
+ tracking = profile.get("testTracking")
289
+ if not isinstance(tracking, dict):
290
+ return invalid(f"{profile_path} testTracking must be an object")
291
+ paths = tracking.get("paths") if isinstance(tracking, dict) else None
292
+ if (
293
+ not isinstance(paths, list)
294
+ or not paths
295
+ or any(not isinstance(item, str) or not item.strip() for item in paths)
296
+ ):
297
+ return invalid(
298
+ f"{profile_path} testTracking.paths must be a non-empty string list"
299
+ )
300
+ excluded = profile.get("excludedRoots")
301
+ if excluded is not None and (
302
+ not isinstance(excluded, list)
303
+ or any(not isinstance(item, str) or not item.strip() for item in excluded)
304
+ ):
305
+ return invalid(f"{profile_path} excludedRoots must be a string list")
306
+ configured_roots = (
307
+ [item.replace("\\", "/").strip("/") for item in excluded]
308
+ if isinstance(excluded, list)
309
+ else []
310
+ )
311
+ excluded_roots = list(
312
+ dict.fromkeys([*configured_roots, *MANDATORY_EXCLUDED_ROOTS])
313
+ )
314
+ patterns = [item.replace("\\", "/") for item in paths]
315
+ return patterns, excluded_roots
316
+
317
+
318
+ def _matches_pattern(rel: str, pattern: str) -> bool:
319
+ # fnmatch does not give **/ its usual zero-or-more-directory meaning, so
320
+ # also try the form with every **/ removed.
321
+ if fnmatch.fnmatchcase(rel, pattern):
322
+ return True
323
+ compact = pattern
324
+ while "**/" in compact:
325
+ compact = compact.replace("**/", "", 1)
326
+ if fnmatch.fnmatchcase(rel, compact):
327
+ return True
328
+ if pattern.endswith("/**"):
329
+ prefix = pattern[:-3].rstrip("/")
330
+ return rel == prefix or rel.startswith(prefix + "/")
331
+ return False
332
+
333
+
334
+ def _standard_test_path(rel: str) -> bool:
335
+ """路径中出现 test/tests 目录段即视为标准测试路径。
336
+
337
+ 此前只认 parts[0],于是 monorepo(packages/cli/tests/…)与"仓库根 + 同名
338
+ 子包"(kld-sdd/test/…)这类常见布局全被拒,调用方被迫先补 build-profile
339
+ 才能记录一次 stale-test-repair。段级匹配不放宽语义:testing/ 与
340
+ tests-helper/ 仍然不算,目录本身(末段)也不算文件。
341
+ """
342
+ parts = rel.split("/")
343
+ return any(part in ("test", "tests") for part in parts[:-1])
344
+
345
+
346
+ def _allowed_test_path(project: Path, rel: str) -> bool:
347
+ config = _profile_config(project)
348
+ if config is None:
349
+ return _standard_test_path(rel)
350
+ patterns, excluded_roots = config
351
+ if _path_is_excluded(rel, excluded_roots):
352
+ return False
353
+ return any(_matches_pattern(rel, pattern) for pattern in patterns)
354
+
355
+
356
+ def _validate_file(project: Path, raw: str) -> tuple[Path | None, str | None, str | None]:
357
+ candidate = Path(raw)
358
+ if not candidate.is_absolute():
359
+ candidate = project / candidate
360
+ resolved = candidate.resolve()
361
+ if not _inside(resolved, project):
362
+ return None, None, "PATH_OUTSIDE_PROJECT"
363
+ if not resolved.exists():
364
+ return None, None, "FILE_NOT_FOUND"
365
+ if not resolved.is_file():
366
+ return None, None, "NOT_REGULAR_FILE"
367
+ rel = resolved.relative_to(project).as_posix()
368
+ if not _allowed_test_path(project, rel):
369
+ return None, rel, "TEST_PATH_NOT_ALLOWED"
370
+ return resolved, rel, None
371
+
372
+
373
+ def _validate_snapshot_file(
374
+ project: Path,
375
+ raw: str,
376
+ ) -> tuple[Path | None, str | None, str | None]:
377
+ """Validate a path captured under the begin-time profile.
378
+
379
+ The active profile may legitimately change before close. Snapshot paths
380
+ remain constrained to the repository but are not reclassified against the
381
+ newer allowlist.
382
+ """
383
+ candidate = Path(raw)
384
+ if not candidate.is_absolute():
385
+ candidate = project / candidate
386
+ resolved = candidate.resolve()
387
+ if not _inside(resolved, project):
388
+ return None, None, "PATH_OUTSIDE_PROJECT"
389
+ if not resolved.exists():
390
+ return None, None, "FILE_NOT_FOUND"
391
+ if not resolved.is_file():
392
+ return None, None, "NOT_REGULAR_FILE"
393
+ return resolved, resolved.relative_to(project).as_posix(), None
394
+
395
+
396
+ def _contract_is_v2(change_root: Path) -> bool:
397
+ try:
398
+ contract = harness_paths.load_change_contract(change_root)
399
+ except (OSError, ValueError):
400
+ return False
401
+ if harness_paths.contract_layout_kind(contract) == "split-v1":
402
+ return True
403
+ version = contract.get("schemaVersion")
404
+ return isinstance(version, int) and version >= 2
405
+
406
+
407
+ def _byte_hash(path: Path) -> str:
408
+ return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
409
+
410
+
411
+ def logical_file_hash(repo_root: Path, rel: str) -> str:
412
+ """Logical identity for a tracked file (RET-10).
413
+
414
+ Text files hash with git blob semantics (path filters/attributes applied,
415
+ so LF/CRLF spellings of one logical text agree). Binary files keep byte
416
+ hash. Indeterminate content falls back to byte hash.
417
+ """
418
+ path = repo_root / rel
419
+ content = path.read_bytes()
420
+ attr = _git(repo_root, "check-attr", "text", "--", rel)
421
+ attr_out = attr.stdout.strip() if attr.returncode == 0 else ""
422
+ if attr_out.endswith(": unset"):
423
+ return _byte_hash(path)
424
+ if b"\x00" in content:
425
+ return _byte_hash(path)
426
+ try:
427
+ content.decode("utf-8")
428
+ except UnicodeDecodeError:
429
+ return _byte_hash(path)
430
+ proc = subprocess.run(
431
+ ["git", "hash-object", "--path", rel, "--stdin"],
432
+ input=content,
433
+ capture_output=True,
434
+ cwd=str(repo_root),
435
+ check=False,
436
+ )
437
+ if proc.returncode != 0:
438
+ return _byte_hash(path)
439
+ return "gitblob:" + proc.stdout.decode("ascii").strip()
440
+
441
+
442
+ def _entry_shape_valid(item: Any) -> bool:
443
+ return (
444
+ isinstance(item, dict)
445
+ and isinstance(item.get("path"), str)
446
+ and bool(item["path"])
447
+ and isinstance(item.get("sha256"), str)
448
+ and re.fullmatch(r"sha256:[0-9a-f]{64}", item["sha256"]) is not None
449
+ and item.get("reason") in REASONS
450
+ and type(item.get("ignored")) is bool
451
+ and type(item.get("trackedBefore")) is bool
452
+ )
453
+
454
+
455
+ def _entry_shape_valid_v2(item: Any) -> bool:
456
+ if not isinstance(item, dict):
457
+ return False
458
+ if not isinstance(item.get("path"), str) or not item["path"]:
459
+ return False
460
+ logical = item.get("logicalHash")
461
+ binary = item.get("binaryHash")
462
+ logical_ok = isinstance(logical, str) and (
463
+ logical.startswith("gitblob:") or logical.startswith("sha256:")
464
+ )
465
+ binary_ok = binary is None or (
466
+ isinstance(binary, str) and binary.startswith("sha256:")
467
+ )
468
+ if not (logical_ok or binary_ok):
469
+ return False
470
+ if item.get("reason") not in REASONS:
471
+ return False
472
+ if type(item.get("ignored")) is not bool:
473
+ return False
474
+ if not isinstance(item.get("introducedBy"), str) or not item["introducedBy"]:
475
+ return False
476
+ touched = item.get("touchedBy")
477
+ if not isinstance(touched, list) or not all(isinstance(t, str) for t in touched):
478
+ return False
479
+ return item.get("commitScope") in ("current-change", "foreign-change")
480
+
481
+
482
+ def _validate_existing_manifest_v2(
483
+ project: Path,
484
+ manifest: Any,
485
+ *,
486
+ allow_hash_drift: set[str] | None = None,
487
+ require_files: bool,
488
+ ) -> tuple[str | None, dict[str, dict[str, Any]]]:
489
+ if (
490
+ not isinstance(manifest, dict)
491
+ or manifest.get("schemaVersion") != 2
492
+ or manifest.get("mode") != MODE
493
+ ):
494
+ return "MANIFEST_INVALID", {}
495
+ repository_id = manifest.get("repositoryId")
496
+ if not isinstance(repository_id, str) or not repository_id.startswith("sha256:"):
497
+ return "MANIFEST_INVALID", {}
498
+ if repository_id != harness_paths.repository_identity(project):
499
+ return "MANIFEST_PROJECT_MISMATCH", {}
500
+ entries = manifest.get("files")
501
+ if not isinstance(entries, list):
502
+ return "MANIFEST_INVALID", {}
503
+ if require_files and not entries:
504
+ return "EMPTY_MANIFEST", {}
505
+
506
+ allowed_drift = allow_hash_drift or set()
507
+ validated: dict[str, dict[str, Any]] = {}
508
+ for item in entries:
509
+ if not _entry_shape_valid_v2(item):
510
+ return "MANIFEST_INVALID", {}
511
+ rel = item["path"]
512
+ if rel in validated:
513
+ return "MANIFEST_INVALID", {}
514
+ path, normalized, error = _validate_file(project, rel)
515
+ if error or normalized != rel or path is None:
516
+ return error or "MANIFEST_INVALID", {}
517
+ expected = item.get("logicalHash") or item.get("binaryHash")
518
+ if expected != logical_file_hash(project, rel) and rel not in allowed_drift:
519
+ return "HASH_DRIFT" if require_files else "MANIFEST_INVALID", {}
520
+ ignored_now = _is_ignored(project, rel)
521
+ if item["ignored"] != ignored_now:
522
+ return "MANIFEST_INVALID", {}
523
+ validated[rel] = dict(item)
524
+ return None, validated
525
+
526
+
527
+ def _validate_existing_manifest(
528
+ project: Path,
529
+ manifest: Any,
530
+ *,
531
+ allow_hash_drift: set[str] | None = None,
532
+ require_files: bool,
533
+ ) -> tuple[str | None, dict[str, dict[str, Any]]]:
534
+ if (
535
+ not isinstance(manifest, dict)
536
+ or manifest.get("schemaVersion") != SCHEMA_VERSION
537
+ or manifest.get("mode") != MODE
538
+ ):
539
+ return "MANIFEST_INVALID", {}
540
+ if manifest.get("projectRoot") != str(project):
541
+ return "MANIFEST_PROJECT_MISMATCH", {}
542
+ entries = manifest.get("files")
543
+ if not isinstance(entries, list):
544
+ return "MANIFEST_INVALID", {}
545
+ if require_files and not entries:
546
+ return "EMPTY_MANIFEST", {}
547
+
548
+ allowed_drift = allow_hash_drift or set()
549
+ validated: dict[str, dict[str, Any]] = {}
550
+ for item in entries:
551
+ if not _entry_shape_valid(item):
552
+ return "MANIFEST_INVALID", {}
553
+ rel = item["path"]
554
+ if rel in validated:
555
+ return "MANIFEST_INVALID", {}
556
+ path, normalized, error = _validate_file(project, rel)
557
+ if error or normalized != rel or path is None:
558
+ return error or "MANIFEST_INVALID", {}
559
+ if item["sha256"] != _sha256(path) and rel not in allowed_drift:
560
+ return "HASH_DRIFT" if require_files else "MANIFEST_INVALID", {}
561
+ ignored_now = _is_ignored(project, rel)
562
+ tracked_now = _git(
563
+ project, "ls-files", "--error-unmatch", "--", rel
564
+ ).returncode == 0
565
+ checkpointed = not item["trackedBefore"] and tracked_now
566
+ if (
567
+ item["trackedBefore"] != tracked_now and not checkpointed
568
+ ) or (
569
+ item["ignored"] != ignored_now and not checkpointed
570
+ ):
571
+ return "MANIFEST_INVALID", {}
572
+ normalized_item = dict(item)
573
+ if checkpointed:
574
+ normalized_item["trackedBefore"] = True
575
+ normalized_item["ignored"] = ignored_now
576
+ validated[rel] = normalized_item
577
+ return None, validated
578
+
579
+
580
+ def record(
581
+ project: Path | str,
582
+ change_dir: Path | str,
583
+ files: list[str],
584
+ reason: str,
585
+ ) -> dict[str, Any]:
586
+ action = "record"
587
+ project_root = Path(project).resolve()
588
+ invalid_root = _invalid_project_root(action, project, project_root)
589
+ if invalid_root is not None:
590
+ return invalid_root
591
+ if not files:
592
+ return _result(False, action, "EMPTY_FILES", [])
593
+ if reason not in REASONS:
594
+ return _result(False, action, "INVALID_REASON", [])
595
+ change_root = _change_dir(project_root, change_dir)
596
+ if change_root is None:
597
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
598
+
599
+ validated: list[tuple[Path, str]] = []
600
+ for raw in files:
601
+ path, rel, error = _validate_file(project_root, raw)
602
+ if error:
603
+ return _result(False, action, error, [rel or str(raw)])
604
+ assert path is not None and rel is not None
605
+ validated.append((path, rel))
606
+
607
+ manifest_path = _manifest_path(change_root)
608
+ if manifest_path is None:
609
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
610
+ lock_path = manifest_path.with_name(manifest_path.name + ".lock")
611
+ try:
612
+ with _exclusive_lock(lock_path, wait_seconds=5.0):
613
+ if not _manifest_target_inside(change_root, manifest_path):
614
+ return _result(
615
+ False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", []
616
+ )
617
+ existing_files: dict[str, dict[str, Any]] = {}
618
+ if manifest_path.is_file():
619
+ try:
620
+ existing = _read_json(manifest_path)
621
+ except (OSError, json.JSONDecodeError) as exc:
622
+ return _result(
623
+ False, action, "MANIFEST_INVALID", [], error=str(exc)
624
+ )
625
+ validator = (
626
+ _validate_existing_manifest_v2
627
+ if isinstance(existing, dict) and existing.get("schemaVersion") == 2
628
+ else _validate_existing_manifest
629
+ )
630
+ error, existing_files = validator(
631
+ project_root,
632
+ existing,
633
+ allow_hash_drift={rel for _, rel in validated},
634
+ require_files=False,
635
+ )
636
+ if error:
637
+ return _result(False, action, error, [])
638
+
639
+ if _contract_is_v2(change_root):
640
+ change_id = change_root.name
641
+ for path, rel in validated:
642
+ ignored = _is_ignored(project_root, rel)
643
+ previous = existing_files.get(rel, {})
644
+ touched = [
645
+ item for item in previous.get("touchedBy", [])
646
+ if isinstance(item, str)
647
+ ]
648
+ if change_id not in touched:
649
+ touched.append(change_id)
650
+ digest = logical_file_hash(project_root, rel)
651
+ existing_files[rel] = {
652
+ "path": rel,
653
+ "logicalHash": digest,
654
+ "binaryHash": None if digest.startswith("gitblob:") else digest,
655
+ "reason": reason,
656
+ "ignored": ignored,
657
+ "introducedBy": previous.get("introducedBy", change_id),
658
+ "touchedBy": touched,
659
+ "commitScope": "current-change",
660
+ }
661
+ manifest = {
662
+ "schemaVersion": 2,
663
+ "repositoryId": harness_paths.repository_identity(project_root),
664
+ "mode": MODE,
665
+ "files": [existing_files[key] for key in sorted(existing_files)],
666
+ }
667
+ else:
668
+ for path, rel in validated:
669
+ ignored = _is_ignored(project_root, rel)
670
+ tracked = _git(
671
+ project_root, "ls-files", "--error-unmatch", "--", rel
672
+ ).returncode == 0
673
+ existing_files[rel] = {
674
+ "path": rel,
675
+ "sha256": _sha256(path),
676
+ "reason": reason,
677
+ "ignored": ignored,
678
+ "trackedBefore": tracked,
679
+ }
680
+
681
+ manifest = {
682
+ "schemaVersion": SCHEMA_VERSION,
683
+ "mode": MODE,
684
+ "projectRoot": str(project_root),
685
+ "files": [existing_files[key] for key in sorted(existing_files)],
686
+ }
687
+ _write_json(manifest_path, manifest)
688
+ except LockUnavailable:
689
+ return _result(False, action, "MANIFEST_LOCKED", [])
690
+ return _result(True, action, "RECORDED", [rel for _, rel in validated], manifestPath=str(manifest_path))
691
+
692
+
693
+ def rehome(
694
+ from_project: Path | str,
695
+ to_project: Path | str,
696
+ change_dir: Path | str,
697
+ expected_head: str,
698
+ ) -> dict[str, Any]:
699
+ """Atomically hand test ownership from a merged feature worktree to target."""
700
+ action = "rehome"
701
+ from_root = Path(from_project).resolve()
702
+ to_root = Path(to_project).resolve()
703
+ if not from_root.is_dir() or not to_root.is_dir():
704
+ return _result(False, action, "PROJECT_ROOT_MISSING", [])
705
+ if harness_paths.repository_identity(from_root) != harness_paths.repository_identity(to_root):
706
+ return _result(False, action, "REPOSITORY_MISMATCH", [])
707
+ to_head_result = _git(to_root, "rev-parse", "--verify", "HEAD")
708
+ to_head = to_head_result.stdout.strip() if to_head_result.returncode == 0 else ""
709
+ if not expected_head or to_head != expected_head:
710
+ return _result(
711
+ False, action, "EXPECTED_HEAD_MISMATCH", [],
712
+ expectedHead=expected_head, actualHead=to_head,
713
+ )
714
+ from_head_result = _git(from_root, "rev-parse", "--verify", "HEAD")
715
+ from_head = from_head_result.stdout.strip() if from_head_result.returncode == 0 else ""
716
+ from_tree_result = _git(from_root, "rev-parse", "HEAD^{tree}")
717
+ to_tree_result = _git(to_root, "rev-parse", f"{expected_head}^{{tree}}")
718
+ from_tree = from_tree_result.stdout.strip() if from_tree_result.returncode == 0 else ""
719
+ to_tree = to_tree_result.stdout.strip() if to_tree_result.returncode == 0 else ""
720
+ if not from_tree or from_tree != to_tree:
721
+ return _result(
722
+ False, action, "TREE_MISMATCH", [],
723
+ fromHead=from_head, toHead=to_head, fromTree=from_tree, toTree=to_tree,
724
+ )
725
+
726
+ change_root = _change_dir(from_root, change_dir)
727
+ if change_root is None:
728
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
729
+ manifest_path = _manifest_path(change_root)
730
+ if manifest_path is None:
731
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
732
+ lock_path = manifest_path.with_name(manifest_path.name + ".lock")
733
+ try:
734
+ with _exclusive_lock(lock_path, wait_seconds=5.0):
735
+ try:
736
+ manifest = _read_json(manifest_path)
737
+ except FileNotFoundError:
738
+ return _result(False, action, "MANIFEST_MISSING", [])
739
+ except (OSError, json.JSONDecodeError) as exc:
740
+ return _result(False, action, "MANIFEST_INVALID", [], error=str(exc))
741
+ is_v2 = isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
742
+ entries = manifest.get("files") if isinstance(manifest, dict) else None
743
+ if not isinstance(entries, list):
744
+ return _result(False, action, "MANIFEST_INVALID", [])
745
+ if not is_v2 and (
746
+ manifest.get("schemaVersion") != SCHEMA_VERSION
747
+ or manifest.get("mode") != MODE
748
+ or manifest.get("projectRoot") != str(from_root)
749
+ ):
750
+ return _result(False, action, "MANIFEST_PROJECT_MISMATCH", [])
751
+ if is_v2:
752
+ error, validated = _validate_existing_manifest_v2(
753
+ from_root, manifest, require_files=False
754
+ )
755
+ if error:
756
+ return _result(False, action, error, [])
757
+ else:
758
+ validated: dict[str, dict[str, Any]] = {}
759
+ for item in entries:
760
+ if not _entry_shape_valid(item):
761
+ return _result(False, action, "MANIFEST_INVALID", [])
762
+ rel = item["path"]
763
+ source, normalized, error = _validate_file(from_root, rel)
764
+ if error or normalized != rel or source is None:
765
+ return _result(False, action, error or "MANIFEST_INVALID", [rel])
766
+ if item["sha256"] != _sha256(source):
767
+ return _result(False, action, "HASH_DRIFT", [rel])
768
+ validated[rel] = dict(item)
769
+
770
+ updated = json.loads(json.dumps(manifest))
771
+ updated_entries: list[dict[str, Any]] = []
772
+ for rel, item in sorted(validated.items()):
773
+ target, normalized, error = _validate_file(to_root, rel)
774
+ if error or normalized != rel or target is None:
775
+ return _result(False, action, "TARGET_CONTENT_MISMATCH", [rel])
776
+ replacement = dict(item)
777
+ if is_v2:
778
+ digest = logical_file_hash(to_root, rel)
779
+ source_digest = item.get("logicalHash") or item.get("binaryHash")
780
+ if digest != source_digest:
781
+ return _result(False, action, "TARGET_CONTENT_MISMATCH", [rel])
782
+ replacement["logicalHash"] = digest
783
+ replacement["binaryHash"] = (
784
+ None if digest.startswith("gitblob:") else digest
785
+ )
786
+ replacement["ignored"] = _is_ignored(to_root, rel)
787
+ else:
788
+ digest = _sha256(target)
789
+ if digest != item["sha256"]:
790
+ return _result(False, action, "TARGET_CONTENT_MISMATCH", [rel])
791
+ replacement["sha256"] = digest
792
+ replacement["ignored"] = _is_ignored(to_root, rel)
793
+ replacement["trackedBefore"] = (
794
+ _git(to_root, "ls-files", "--error-unmatch", "--", rel).returncode == 0
795
+ )
796
+ updated_entries.append(replacement)
797
+
798
+ before_hash = _sha256(manifest_path)
799
+ handoff_at = time.strftime("%Y-%m-%dT%H:%M:%S")
800
+ handoff_id = "handoff-" + hashlib.sha256(
801
+ f"{from_root}\0{to_root}\0{from_head}\0{to_head}".encode("utf-8")
802
+ ).hexdigest()[:20]
803
+ handoffs = updated.get("handoffs")
804
+ if handoffs is None:
805
+ handoffs = []
806
+ if not isinstance(handoffs, list):
807
+ return _result(False, action, "MANIFEST_INVALID", [])
808
+ handoff = {
809
+ "id": handoff_id,
810
+ "fromRoot": str(from_root),
811
+ "toRoot": str(to_root),
812
+ "fromHead": from_head,
813
+ "toHead": to_head,
814
+ "expectedHead": expected_head,
815
+ "treeHash": to_tree,
816
+ "at": handoff_at,
817
+ "manifestHashBefore": before_hash,
818
+ }
819
+ if not any(isinstance(item, dict) and item.get("id") == handoff_id for item in handoffs):
820
+ handoffs.append(handoff)
821
+ updated["files"] = updated_entries
822
+ updated["projectRoot"] = str(to_root)
823
+ updated["head"] = to_head
824
+ updated["handoffs"] = handoffs
825
+ _write_json(manifest_path, updated)
826
+ after_hash = _sha256(manifest_path)
827
+ except LockUnavailable:
828
+ return _result(False, action, "MANIFEST_LOCKED", [])
829
+ return _result(
830
+ True,
831
+ action,
832
+ "REHOMED",
833
+ sorted(validated),
834
+ fromRoot=str(from_root),
835
+ toRoot=str(to_root),
836
+ fromHead=from_head,
837
+ toHead=to_head,
838
+ treeHash=to_tree,
839
+ handoffId=handoff_id,
840
+ manifestHashBefore=before_hash,
841
+ manifestHashAfter=after_hash,
842
+ manifestPath=str(manifest_path),
843
+ )
844
+
845
+
846
+ def rebind_snapshot(
847
+ from_project: Path | str,
848
+ to_project: Path | str,
849
+ change_dir: Path | str,
850
+ expected_base: str,
851
+ ) -> dict[str, Any]:
852
+ """Rebind an unchanged pre-change snapshot to a descendant worktree.
853
+
854
+ This is the pre-merge counterpart to ``rehome``. It is intentionally
855
+ narrow: the source root must still be exactly at ``expected_base``, every
856
+ snapshotted file must still match its captured byte hash there, and the
857
+ target HEAD must descend from that base. A target-owned manifest, when
858
+ present, is validated but never rewritten.
859
+ """
860
+
861
+ action = "rebind-snapshot"
862
+ from_root = Path(from_project).resolve()
863
+ to_root = Path(to_project).resolve()
864
+ if not from_root.is_dir() or not to_root.is_dir():
865
+ return _result(False, action, "PROJECT_ROOT_MISSING", [])
866
+ if harness_paths.repository_identity(from_root) != harness_paths.repository_identity(to_root):
867
+ return _result(False, action, "REPOSITORY_MISMATCH", [])
868
+
869
+ from_head_result = _git(from_root, "rev-parse", "--verify", "HEAD")
870
+ from_head = from_head_result.stdout.strip() if from_head_result.returncode == 0 else ""
871
+ if not expected_base or from_head != expected_base:
872
+ return _result(
873
+ False,
874
+ action,
875
+ "EXPECTED_BASE_MISMATCH",
876
+ [],
877
+ expectedBase=expected_base,
878
+ actualBase=from_head,
879
+ )
880
+
881
+ to_head_result = _git(to_root, "rev-parse", "--verify", "HEAD")
882
+ to_head = to_head_result.stdout.strip() if to_head_result.returncode == 0 else ""
883
+ descendant = _git(
884
+ to_root,
885
+ "merge-base",
886
+ "--is-ancestor",
887
+ expected_base,
888
+ to_head,
889
+ )
890
+ if not to_head or descendant.returncode != 0:
891
+ return _result(
892
+ False,
893
+ action,
894
+ "TARGET_NOT_DESCENDANT",
895
+ [],
896
+ expectedBase=expected_base,
897
+ targetHead=to_head,
898
+ )
899
+
900
+ change_root = _change_dir(from_root, change_dir)
901
+ if change_root is None:
902
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
903
+ snapshot_path = _state_root(change_root) / SNAPSHOT_REL
904
+ if not _manifest_target_inside(change_root, snapshot_path):
905
+ return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
906
+ manifest_path = _manifest_path(change_root)
907
+ if manifest_path is None:
908
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
909
+
910
+ lock_path = snapshot_path.with_name(snapshot_path.name + ".lock")
911
+ try:
912
+ with _exclusive_lock(lock_path, wait_seconds=5.0):
913
+ try:
914
+ snapshot = _read_json(snapshot_path)
915
+ except FileNotFoundError:
916
+ return _result(False, action, "SNAPSHOT_MISSING", [])
917
+ except (OSError, json.JSONDecodeError) as exc:
918
+ return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
919
+
920
+ entries = snapshot.get("files") if isinstance(snapshot, dict) else None
921
+ snapshot_root = snapshot.get("projectRoot") if isinstance(snapshot, dict) else None
922
+ if (
923
+ not isinstance(snapshot, dict)
924
+ or snapshot.get("schemaVersion") != SCHEMA_VERSION
925
+ or snapshot.get("mode") != MODE
926
+ or snapshot_root not in {str(from_root), str(to_root)}
927
+ or not isinstance(entries, list)
928
+ ):
929
+ return _result(False, action, "SNAPSHOT_INVALID", [])
930
+
931
+ validated_paths: list[str] = []
932
+ for item in entries:
933
+ if (
934
+ not isinstance(item, dict)
935
+ or not isinstance(item.get("path"), str)
936
+ or not isinstance(item.get("sha256"), str)
937
+ ):
938
+ return _result(False, action, "SNAPSHOT_INVALID", [])
939
+ rel = item["path"]
940
+ source, normalized, error = _validate_file(from_root, rel)
941
+ if error or normalized != rel or source is None:
942
+ return _result(False, action, error or "SNAPSHOT_INVALID", [rel])
943
+ if item["sha256"] != _sha256(source):
944
+ return _result(False, action, "SOURCE_SNAPSHOT_DRIFT", [rel])
945
+ item["logicalHash"] = logical_file_hash(from_root, rel)
946
+ validated_paths.append(rel)
947
+
948
+ manifest = None
949
+ if manifest_path.is_file():
950
+ try:
951
+ manifest = _read_json(manifest_path)
952
+ except (OSError, json.JSONDecodeError) as exc:
953
+ return _result(False, action, "MANIFEST_INVALID", [], error=str(exc))
954
+ validator = (
955
+ _validate_existing_manifest_v2
956
+ if isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
957
+ else _validate_existing_manifest
958
+ )
959
+ error, _ = validator(to_root, manifest, require_files=False)
960
+ if error:
961
+ return _result(False, action, error, [])
962
+
963
+ before_hash = _sha256(snapshot_path)
964
+ rebound_at = time.strftime("%Y-%m-%dT%H:%M:%S")
965
+ rebind_id = "rebind-" + hashlib.sha256(
966
+ f"{from_root}\0{to_root}\0{expected_base}\0{to_head}".encode("utf-8")
967
+ ).hexdigest()[:20]
968
+ updated = json.loads(json.dumps(snapshot))
969
+ updated["files"] = entries
970
+ rebinds = updated.get("rebinds")
971
+ if rebinds is None:
972
+ rebinds = []
973
+ if not isinstance(rebinds, list):
974
+ return _result(False, action, "SNAPSHOT_INVALID", [])
975
+ record = {
976
+ "id": rebind_id,
977
+ "fromRoot": str(from_root),
978
+ "toRoot": str(to_root),
979
+ "expectedBase": expected_base,
980
+ "targetHead": to_head,
981
+ "at": rebound_at,
982
+ "snapshotHashBefore": before_hash,
983
+ }
984
+ if not any(
985
+ isinstance(item, dict) and item.get("id") == rebind_id
986
+ for item in rebinds
987
+ ):
988
+ rebinds.append(record)
989
+ updated["projectRoot"] = str(to_root)
990
+ updated["rebinds"] = rebinds
991
+ _write_json(snapshot_path, updated)
992
+ after_hash = _sha256(snapshot_path)
993
+
994
+ removed_manifest_paths: list[str] = []
995
+ if isinstance(manifest, dict):
996
+ manifest_entries = manifest.get("files")
997
+ if not isinstance(manifest_entries, list):
998
+ return _result(False, action, "MANIFEST_INVALID", [])
999
+ baseline = {
1000
+ item["path"]: item["logicalHash"]
1001
+ for item in entries
1002
+ if isinstance(item, dict)
1003
+ and isinstance(item.get("path"), str)
1004
+ and isinstance(item.get("logicalHash"), str)
1005
+ }
1006
+ kept_entries: list[dict[str, Any]] = []
1007
+ for item in manifest_entries:
1008
+ rel = item.get("path") if isinstance(item, dict) else None
1009
+ if (
1010
+ isinstance(rel, str)
1011
+ and rel in baseline
1012
+ and item.get("reason") in REASONS
1013
+ and logical_file_hash(to_root, rel) == baseline[rel]
1014
+ ):
1015
+ removed_manifest_paths.append(rel)
1016
+ continue
1017
+ kept_entries.append(item)
1018
+ if removed_manifest_paths:
1019
+ cleaned = json.loads(json.dumps(manifest))
1020
+ cleaned["files"] = kept_entries
1021
+ cleanups = cleaned.get("reconciliations")
1022
+ if cleanups is None:
1023
+ cleanups = []
1024
+ if not isinstance(cleanups, list):
1025
+ return _result(False, action, "MANIFEST_INVALID", [])
1026
+ cleanups.append(
1027
+ {
1028
+ "rebindId": rebind_id,
1029
+ "at": rebound_at,
1030
+ "reason": "logical-baseline-match",
1031
+ "removedPaths": sorted(removed_manifest_paths),
1032
+ }
1033
+ )
1034
+ cleaned["reconciliations"] = cleanups
1035
+ _write_json(manifest_path, cleaned)
1036
+ except LockUnavailable:
1037
+ return _result(False, action, "SNAPSHOT_LOCKED", [])
1038
+
1039
+ return _result(
1040
+ True,
1041
+ action,
1042
+ "SNAPSHOT_REBOUND",
1043
+ sorted(validated_paths),
1044
+ fromRoot=str(from_root),
1045
+ toRoot=str(to_root),
1046
+ expectedBase=expected_base,
1047
+ targetHead=to_head,
1048
+ rebindId=rebind_id,
1049
+ snapshotHashBefore=before_hash,
1050
+ snapshotHashAfter=after_hash,
1051
+ manifestEntriesRemoved=len(removed_manifest_paths),
1052
+ removedManifestPaths=sorted(removed_manifest_paths),
1053
+ snapshotPath=str(snapshot_path),
1054
+ )
1055
+
1056
+
1057
+ def _stage_locked(
1058
+ project_root: Path, change_root: Path, manifest_path: Path, index_path: Path
1059
+ ) -> dict[str, Any]:
1060
+ action = "stage"
1061
+ if not _manifest_target_inside(change_root, manifest_path):
1062
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
1063
+ try:
1064
+ manifest = _read_json(manifest_path)
1065
+ except FileNotFoundError:
1066
+ return _result(False, action, "MANIFEST_MISSING", [])
1067
+ except (OSError, json.JSONDecodeError) as exc:
1068
+ return _result(False, action, "MANIFEST_INVALID", [], error=str(exc))
1069
+ is_v2 = isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
1070
+ validator = _validate_existing_manifest_v2 if is_v2 else _validate_existing_manifest
1071
+ error, entries = validator(project_root, manifest, require_files=True)
1072
+ if error:
1073
+ return _result(False, action, error, [])
1074
+ rels: list[str] = []
1075
+ for rel, entry in entries.items():
1076
+ if is_v2:
1077
+ if entry.get("commitScope") == "current-change":
1078
+ rels.append(rel)
1079
+ continue
1080
+ if not entry["trackedBefore"]:
1081
+ rels.append(rel)
1082
+ continue
1083
+ changed = _git(project_root, "diff", "--quiet", "HEAD", "--", rel)
1084
+ if changed.returncode == 1:
1085
+ rels.append(rel)
1086
+ elif changed.returncode != 0:
1087
+ return _result(
1088
+ False,
1089
+ action,
1090
+ "GIT_DIFF_FAILED",
1091
+ [rel],
1092
+ error=changed.stderr.strip(),
1093
+ )
1094
+ if not rels:
1095
+ return _result(True, action, "STAGED", [])
1096
+
1097
+ before_cached_result = _git(project_root, "diff", "--cached", "--name-only")
1098
+ if before_cached_result.returncode != 0:
1099
+ return _result(False, action, "CACHED_DIFF_FAILED", rels)
1100
+ before_cached = set(before_cached_result.stdout.splitlines())
1101
+
1102
+ handle, temp_name = tempfile.mkstemp(
1103
+ prefix=f".{index_path.name}.test-guard.", dir=index_path.parent
1104
+ )
1105
+ os.close(handle)
1106
+ temp_index = Path(temp_name)
1107
+ temp_index.unlink(missing_ok=True)
1108
+ try:
1109
+ if index_path.is_file():
1110
+ shutil.copy2(index_path, temp_index)
1111
+ added = _git(project_root, "add", "-f", "--", *rels, index_file=temp_index)
1112
+ if added.returncode != 0:
1113
+ return _result(
1114
+ False,
1115
+ action,
1116
+ "GIT_ADD_FAILED",
1117
+ rels,
1118
+ error=added.stderr.strip(),
1119
+ )
1120
+ cached = _git(
1121
+ project_root,
1122
+ "diff",
1123
+ "--cached",
1124
+ "--name-only",
1125
+ index_file=temp_index,
1126
+ )
1127
+ if cached.returncode != 0:
1128
+ return _result(False, action, "CACHED_DIFF_FAILED", rels)
1129
+ after_cached = set(cached.stdout.splitlines())
1130
+ missing = [rel for rel in rels if rel not in after_cached]
1131
+ unexpected = sorted((after_cached - before_cached) - set(rels))
1132
+ if missing or unexpected:
1133
+ return _result(
1134
+ False,
1135
+ action,
1136
+ "CACHED_DIFF_MISMATCH",
1137
+ missing or unexpected,
1138
+ )
1139
+ os.replace(temp_index, index_path)
1140
+ return _result(True, action, "STAGED", rels)
1141
+ finally:
1142
+ temp_index.unlink(missing_ok=True)
1143
+
1144
+
1145
+ def _path_is_excluded(rel: str, excluded_roots: list[str]) -> bool:
1146
+ rel_parts = tuple(os.path.normcase(part) for part in rel.split("/") if part)
1147
+ for excluded in excluded_roots:
1148
+ excluded_parts = tuple(
1149
+ os.path.normcase(part) for part in excluded.split("/") if part
1150
+ )
1151
+ if not excluded_parts:
1152
+ continue
1153
+ width = len(excluded_parts)
1154
+ for index in range(len(rel_parts) - width + 1):
1155
+ if rel_parts[index : index + width] == excluded_parts:
1156
+ return True
1157
+ return False
1158
+
1159
+
1160
+ def _walk_matching_files(
1161
+ base: Path, patterns: list[str], excluded_roots: list[str]
1162
+ ) -> list[Path]:
1163
+ """Walk once and prune excluded roots before matching recursive globs."""
1164
+ matches: list[Path] = []
1165
+ for root_raw, dir_names, file_names in os.walk(base, followlinks=False):
1166
+ root = Path(root_raw)
1167
+ root_rel = root.relative_to(base).as_posix()
1168
+ prefix = "" if root_rel == "." else root_rel + "/"
1169
+ dir_names[:] = [
1170
+ name
1171
+ for name in dir_names
1172
+ if not _path_is_excluded(prefix + name, excluded_roots)
1173
+ ]
1174
+ for name in file_names:
1175
+ rel = prefix + name
1176
+ if _path_is_excluded(rel, excluded_roots):
1177
+ continue
1178
+ if any(_matches_pattern(rel, pattern) for pattern in patterns):
1179
+ matches.append(root / name)
1180
+ return matches
1181
+
1182
+
1183
+ def _enumerate_allowed_test_files(
1184
+ project_root: Path,
1185
+ *,
1186
+ strict_profile: bool = False,
1187
+ ) -> dict[str, str]:
1188
+ """Map repo-relative test path -> sha256 for all allowed existing files."""
1189
+ found: dict[str, str] = {}
1190
+ config = _profile_config(project_root, strict=strict_profile)
1191
+ if config is None:
1192
+ patterns = ["test/**", "tests/**", "src/test/**"]
1193
+ excluded_roots = [
1194
+ ".git", ".harness", "node_modules", "dist", "build", "__pycache__"
1195
+ ]
1196
+ else:
1197
+ patterns, excluded_roots = config
1198
+ base = project_root.resolve()
1199
+ seen: set[str] = set()
1200
+ for match in _walk_matching_files(base, patterns, excluded_roots):
1201
+ resolved = match.resolve()
1202
+ if not _inside(resolved, base):
1203
+ continue
1204
+ rel = resolved.relative_to(base).as_posix()
1205
+ if (
1206
+ rel in seen
1207
+ or _path_is_excluded(rel, excluded_roots)
1208
+ or not any(_matches_pattern(rel, pattern) for pattern in patterns)
1209
+ ):
1210
+ continue
1211
+ seen.add(rel)
1212
+ found[rel] = _sha256(resolved)
1213
+ return found
1214
+
1215
+
1216
+ def _current_head(project_root: Path) -> str | None:
1217
+ result = _git(project_root, "rev-parse", "--verify", "HEAD")
1218
+ value = result.stdout.strip() if result.returncode == 0 else ""
1219
+ return value if re.fullmatch(r"[0-9a-fA-F]{40,64}", value) else None
1220
+
1221
+
1222
+ def _snapshot_baseline_commit(
1223
+ project_root: Path,
1224
+ change_root: Path,
1225
+ snapshot: dict[str, Any],
1226
+ ) -> str | None:
1227
+ candidates: list[Any] = [snapshot.get("headCommit")]
1228
+ for state_snapshot_path in dict.fromkeys(
1229
+ [
1230
+ change_root / "meta" / "state-snapshot.json",
1231
+ _state_root(change_root) / "meta" / "state-snapshot.json",
1232
+ ]
1233
+ ):
1234
+ if not state_snapshot_path.is_file():
1235
+ continue
1236
+ try:
1237
+ state_snapshot = _read_json(state_snapshot_path)
1238
+ except (OSError, json.JSONDecodeError):
1239
+ continue
1240
+ if isinstance(state_snapshot, dict):
1241
+ git_state = state_snapshot.get("git")
1242
+ if isinstance(git_state, dict):
1243
+ candidates.extend([git_state.get("base"), git_state.get("head")])
1244
+ for candidate in candidates:
1245
+ if not isinstance(candidate, str) or re.fullmatch(
1246
+ r"[0-9a-fA-F]{40,64}", candidate
1247
+ ) is None:
1248
+ continue
1249
+ exists = _git(project_root, "cat-file", "-e", f"{candidate}^{{commit}}")
1250
+ if exists.returncode == 0:
1251
+ return candidate
1252
+ return None
1253
+
1254
+
1255
+ def _classify_against_baseline(
1256
+ project_root: Path,
1257
+ rel: str,
1258
+ baseline_commit: str | None,
1259
+ ) -> tuple[str | None, str | None]:
1260
+ """Return touched reason (or None when unchanged) and an optional error."""
1261
+ if baseline_commit is None:
1262
+ return None, "BASELINE_COMMIT_MISSING"
1263
+ listed = _git(
1264
+ project_root,
1265
+ "ls-tree",
1266
+ "-r",
1267
+ "--name-only",
1268
+ "-z",
1269
+ baseline_commit,
1270
+ "--",
1271
+ rel,
1272
+ )
1273
+ if listed.returncode != 0:
1274
+ return None, "BASELINE_LOOKUP_FAILED"
1275
+ tracked_at_baseline = rel in {
1276
+ item for item in listed.stdout.split("\0") if item
1277
+ }
1278
+ if not tracked_at_baseline:
1279
+ return "tdd-created", None
1280
+ changed = _git(
1281
+ project_root,
1282
+ "diff",
1283
+ "--quiet",
1284
+ baseline_commit,
1285
+ "--",
1286
+ rel,
1287
+ )
1288
+ if changed.returncode == 0:
1289
+ return None, None
1290
+ if changed.returncode == 1:
1291
+ return "test-updated", None
1292
+ return None, "BASELINE_DIFF_FAILED"
1293
+
1294
+
1295
+ def _profile_fingerprint(
1296
+ project_root: Path,
1297
+ *,
1298
+ strict_profile: bool = False,
1299
+ ) -> str:
1300
+ config = _profile_config(project_root, strict=strict_profile)
1301
+ payload = json.dumps(
1302
+ {"mode": "fallback"} if config is None else {"mode": "profile", "value": config},
1303
+ ensure_ascii=False,
1304
+ sort_keys=True,
1305
+ separators=(",", ":"),
1306
+ ).encode("utf-8")
1307
+ return "sha256:" + hashlib.sha256(payload).hexdigest()
1308
+
1309
+
1310
+ def _reconcile_close_manifest(
1311
+ project_root: Path,
1312
+ change_root: Path,
1313
+ recorded: set[str],
1314
+ baseline_commit: str | None,
1315
+ ) -> dict[str, Any]:
1316
+ manifest_path = _manifest_path(change_root)
1317
+ if manifest_path is None:
1318
+ return {"ok": False, "code": "MANIFEST_PATH_OUTSIDE_PROJECT"}
1319
+ if not manifest_path.is_file():
1320
+ return {
1321
+ "ok": True,
1322
+ "code": "NO_MANIFEST",
1323
+ "removedCurrentTouches": 0,
1324
+ "deletedEntries": 0,
1325
+ "preservedForeignEntries": 0,
1326
+ }
1327
+
1328
+ lock_path = manifest_path.with_name(manifest_path.name + ".lock")
1329
+ try:
1330
+ with _exclusive_lock(lock_path, wait_seconds=5.0):
1331
+ try:
1332
+ manifest = _read_json(manifest_path)
1333
+ except (OSError, json.JSONDecodeError) as exc:
1334
+ return {"ok": False, "code": "MANIFEST_INVALID", "error": str(exc)}
1335
+ is_v2 = isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
1336
+ validator = (
1337
+ _validate_existing_manifest_v2
1338
+ if is_v2
1339
+ else _validate_existing_manifest
1340
+ )
1341
+ head_before = _current_head(project_root)
1342
+ if head_before is None:
1343
+ return {"ok": False, "code": "HEAD_MISSING"}
1344
+ allow_hash_drift: set[str] = set()
1345
+ raw_entries = (
1346
+ manifest.get("files")
1347
+ if isinstance(manifest, dict)
1348
+ else None
1349
+ )
1350
+ if isinstance(raw_entries, list):
1351
+ for raw_item in raw_entries:
1352
+ if not isinstance(raw_item, dict):
1353
+ continue
1354
+ rel = raw_item.get("path")
1355
+ if (
1356
+ not isinstance(rel, str)
1357
+ or not rel
1358
+ or rel in recorded
1359
+ or raw_item.get("reason") == "stale-test-repair"
1360
+ ):
1361
+ continue
1362
+ touched_reason, classify_error = _classify_against_baseline(
1363
+ project_root,
1364
+ rel,
1365
+ baseline_commit,
1366
+ )
1367
+ if classify_error:
1368
+ return {
1369
+ "ok": False,
1370
+ "code": classify_error,
1371
+ "files": [rel],
1372
+ }
1373
+ if touched_reason is None:
1374
+ allow_hash_drift.add(rel)
1375
+ error, validated = validator(
1376
+ project_root,
1377
+ manifest,
1378
+ allow_hash_drift=allow_hash_drift,
1379
+ require_files=False,
1380
+ )
1381
+ if error:
1382
+ return {"ok": False, "code": error}
1383
+
1384
+ before_hash = _sha256(manifest_path)
1385
+ change_id = change_root.name
1386
+ reconciled: list[dict[str, Any]] = []
1387
+ removed_paths: list[str] = []
1388
+ removed_current_touches = 0
1389
+ deleted_entries = 0
1390
+ preserved_foreign_entries = 0
1391
+
1392
+ for rel in sorted(validated):
1393
+ item = dict(validated[rel])
1394
+ if rel in recorded or item.get("reason") == "stale-test-repair":
1395
+ reconciled.append(item)
1396
+ continue
1397
+ touched_reason, classify_error = _classify_against_baseline(
1398
+ project_root,
1399
+ rel,
1400
+ baseline_commit,
1401
+ )
1402
+ if classify_error:
1403
+ return {"ok": False, "code": classify_error, "files": [rel]}
1404
+ if touched_reason is not None:
1405
+ reconciled.append(item)
1406
+ continue
1407
+
1408
+ if not is_v2:
1409
+ removed_paths.append(rel)
1410
+ deleted_entries += 1
1411
+ continue
1412
+
1413
+ touched_by = list(item.get("touchedBy") or [])
1414
+ if change_id not in touched_by:
1415
+ reconciled.append(item)
1416
+ preserved_foreign_entries += 1
1417
+ continue
1418
+ remaining = [owner for owner in touched_by if owner != change_id]
1419
+ removed_current_touches += 1
1420
+ if item.get("introducedBy") == change_id:
1421
+ if remaining:
1422
+ return {
1423
+ "ok": False,
1424
+ "code": "FOREIGN_PROVENANCE_CONFLICT",
1425
+ "files": [rel],
1426
+ }
1427
+ removed_paths.append(rel)
1428
+ deleted_entries += 1
1429
+ continue
1430
+ item["touchedBy"] = remaining
1431
+ item["commitScope"] = "foreign-change"
1432
+ current_hash = logical_file_hash(project_root, rel)
1433
+ item["logicalHash"] = current_hash
1434
+ item["binaryHash"] = (
1435
+ None if current_hash.startswith("gitblob:") else current_hash
1436
+ )
1437
+ reconciled.append(item)
1438
+ preserved_foreign_entries += 1
1439
+
1440
+ for rel in removed_paths:
1441
+ touched_reason, classify_error = _classify_against_baseline(
1442
+ project_root,
1443
+ rel,
1444
+ baseline_commit,
1445
+ )
1446
+ if classify_error or touched_reason is not None:
1447
+ return {
1448
+ "ok": False,
1449
+ "code": classify_error or "FILE_CHANGED_DURING_RECONCILE",
1450
+ "files": [rel],
1451
+ }
1452
+ if _current_head(project_root) != head_before:
1453
+ return {"ok": False, "code": "HEAD_MOVED"}
1454
+
1455
+ updated = json.loads(json.dumps(manifest))
1456
+ updated["files"] = reconciled
1457
+ if reconciled:
1458
+ recheck_error, _ = validator(
1459
+ project_root,
1460
+ updated,
1461
+ require_files=False,
1462
+ )
1463
+ if recheck_error:
1464
+ return {"ok": False, "code": recheck_error}
1465
+ _write_json(manifest_path, updated)
1466
+ after_hash: str | None = _sha256(manifest_path)
1467
+ else:
1468
+ manifest_path.unlink()
1469
+ after_hash = None
1470
+ except LockUnavailable:
1471
+ return {"ok": False, "code": "MANIFEST_LOCKED"}
1472
+
1473
+ return {
1474
+ "ok": True,
1475
+ "code": "RECONCILED",
1476
+ "manifestHashBefore": before_hash,
1477
+ "manifestHashAfter": after_hash,
1478
+ "removedCurrentTouches": removed_current_touches,
1479
+ "deletedEntries": deleted_entries,
1480
+ "preservedForeignEntries": preserved_foreign_entries,
1481
+ }
1482
+
1483
+
1484
+ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1485
+ action = "begin"
1486
+ project_root = Path(project).resolve()
1487
+ invalid_root = _invalid_project_root(action, project, project_root)
1488
+ if invalid_root is not None:
1489
+ return invalid_root
1490
+ change_root = _change_dir(project_root, change_dir)
1491
+ if change_root is None:
1492
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
1493
+ manifest_path = _manifest_path(change_root)
1494
+ if manifest_path is None:
1495
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
1496
+ snapshot_path = _state_root(change_root) / SNAPSHOT_REL
1497
+ if not _manifest_target_inside(change_root, snapshot_path):
1498
+ return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
1499
+ try:
1500
+ current_profile_fingerprint = _profile_fingerprint(
1501
+ project_root,
1502
+ strict_profile=True,
1503
+ )
1504
+ except ProfileConfigInvalid as exc:
1505
+ return _result(
1506
+ False,
1507
+ action,
1508
+ "PROFILE_INVALID",
1509
+ [],
1510
+ error=str(exc),
1511
+ )
1512
+
1513
+ if snapshot_path.is_file():
1514
+ try:
1515
+ snapshot = _read_json(snapshot_path)
1516
+ except (OSError, json.JSONDecodeError) as exc:
1517
+ return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
1518
+ entries = snapshot.get("files") if isinstance(snapshot, dict) else None
1519
+ if (
1520
+ not isinstance(snapshot, dict)
1521
+ or snapshot.get("schemaVersion") != SCHEMA_VERSION
1522
+ or snapshot.get("mode") != MODE
1523
+ or snapshot.get("projectRoot") != str(project_root)
1524
+ or not isinstance(entries, list)
1525
+ or any(not isinstance(item, dict) or not isinstance(item.get("path"), str)
1526
+ for item in entries)
1527
+ ):
1528
+ return _result(False, action, "SNAPSHOT_INVALID", [])
1529
+ if (
1530
+ isinstance(snapshot.get("profileFingerprint"), str)
1531
+ and snapshot["profileFingerprint"] != current_profile_fingerprint
1532
+ ):
1533
+ return _result(False, action, "PROFILE_CHANGED", [])
1534
+ return _result(
1535
+ True,
1536
+ action,
1537
+ "SNAPSHOT_REUSED",
1538
+ [item["path"] for item in entries],
1539
+ snapshotPath=str(snapshot_path),
1540
+ fileCount=len(entries),
1541
+ )
1542
+
1543
+ try:
1544
+ files = _enumerate_allowed_test_files(
1545
+ project_root,
1546
+ strict_profile=True,
1547
+ )
1548
+ except ProfileConfigInvalid as exc:
1549
+ return _result(
1550
+ False,
1551
+ action,
1552
+ "PROFILE_INVALID",
1553
+ [],
1554
+ error=str(exc),
1555
+ )
1556
+ snapshot = {
1557
+ "schemaVersion": SCHEMA_VERSION,
1558
+ "mode": MODE,
1559
+ "projectRoot": str(project_root),
1560
+ "repositoryId": harness_paths.repository_identity(project_root),
1561
+ "headCommit": _current_head(project_root),
1562
+ "profileFingerprint": current_profile_fingerprint,
1563
+ "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
1564
+ "files": [
1565
+ {"path": rel, "sha256": digest, "ignored": _is_ignored(project_root, rel)}
1566
+ for rel, digest in sorted(files.items())
1567
+ ],
1568
+ }
1569
+ _write_json(snapshot_path, snapshot)
1570
+ return _result(
1571
+ True,
1572
+ action,
1573
+ "SNAPSHOT_CAPTURED",
1574
+ list(files),
1575
+ snapshotPath=str(snapshot_path),
1576
+ fileCount=len(files),
1577
+ )
1578
+
1579
+
1580
+ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1581
+ action = "close"
1582
+ project_root = Path(project).resolve()
1583
+ invalid_root = _invalid_project_root(action, project, project_root)
1584
+ if invalid_root is not None:
1585
+ return invalid_root
1586
+ change_root = _change_dir(project_root, change_dir)
1587
+ if change_root is None:
1588
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
1589
+ snapshot_path = _state_root(change_root) / SNAPSHOT_REL
1590
+ if not snapshot_path.is_file():
1591
+ # Name the exact path: this snapshot is evidence/test-guard-snapshot.json,
1592
+ # not meta/state-snapshot.json, and the two are easy to confuse.
1593
+ return _result(
1594
+ False,
1595
+ action,
1596
+ "SNAPSHOT_MISSING",
1597
+ [],
1598
+ expectedSnapshot=str(snapshot_path),
1599
+ hint="run `harness_test_guard.py begin` for this change first",
1600
+ )
1601
+ try:
1602
+ snapshot = _read_json(snapshot_path)
1603
+ except (OSError, json.JSONDecodeError) as exc:
1604
+ return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
1605
+ if (
1606
+ not isinstance(snapshot, dict)
1607
+ or snapshot.get("schemaVersion") != SCHEMA_VERSION
1608
+ ):
1609
+ return _result(False, action, "SNAPSHOT_INVALID", [])
1610
+ # Execution-root contract (retro §5.10): a snapshot captured against a
1611
+ # different project root must fail with EXECUTION_ROOT_MISMATCH before
1612
+ # the generic SNAPSHOT_INVALID, so callers can distinguish "wrong root"
1613
+ # from "corrupt snapshot".
1614
+ snapshot_root = snapshot.get("projectRoot")
1615
+ if snapshot_root is not None and snapshot_root != str(project_root):
1616
+ return _result(
1617
+ False,
1618
+ action,
1619
+ "EXECUTION_ROOT_MISMATCH",
1620
+ [],
1621
+ expectedRoot=snapshot_root,
1622
+ actualRoot=str(project_root),
1623
+ )
1624
+ snapshot_repository = snapshot.get("repositoryId")
1625
+ if (
1626
+ snapshot_repository is not None
1627
+ and snapshot_repository != harness_paths.repository_identity(project_root)
1628
+ ):
1629
+ return _result(False, action, "REPOSITORY_MISMATCH", [])
1630
+ try:
1631
+ current_profile_fingerprint = _profile_fingerprint(
1632
+ project_root,
1633
+ strict_profile=True,
1634
+ )
1635
+ except ProfileConfigInvalid as exc:
1636
+ return _result(
1637
+ False,
1638
+ action,
1639
+ "PROFILE_INVALID",
1640
+ [],
1641
+ error=str(exc),
1642
+ )
1643
+ profile_changed = bool(
1644
+ isinstance(snapshot.get("profileFingerprint"), str)
1645
+ and snapshot["profileFingerprint"] != current_profile_fingerprint
1646
+ )
1647
+ baseline_commit = _snapshot_baseline_commit(
1648
+ project_root,
1649
+ change_root,
1650
+ snapshot,
1651
+ )
1652
+ before_entries = snapshot.get("files")
1653
+ if not isinstance(before_entries, list):
1654
+ return _result(False, action, "SNAPSHOT_INVALID", [])
1655
+ before: dict[str, dict[str, Any]] = {}
1656
+ for item in before_entries:
1657
+ if not isinstance(item, dict):
1658
+ return _result(False, action, "SNAPSHOT_INVALID", [])
1659
+ rel = item.get("path")
1660
+ digest = item.get("sha256")
1661
+ if not isinstance(rel, str) or not rel or not isinstance(digest, str):
1662
+ return _result(False, action, "SNAPSHOT_INVALID", [])
1663
+ path, normalized, error = _validate_snapshot_file(project_root, rel)
1664
+ if error:
1665
+ return _result(False, action, error, [rel])
1666
+ if normalized != rel or path is None:
1667
+ return _result(False, action, "PATH_ESCAPE", [rel])
1668
+ before[rel] = item
1669
+
1670
+ try:
1671
+ current = _enumerate_allowed_test_files(
1672
+ project_root,
1673
+ strict_profile=True,
1674
+ )
1675
+ except ProfileConfigInvalid as exc:
1676
+ return _result(
1677
+ False,
1678
+ action,
1679
+ "PROFILE_INVALID",
1680
+ [],
1681
+ error=str(exc),
1682
+ )
1683
+ for rel in current:
1684
+ path, normalized, error = _validate_file(project_root, rel)
1685
+ if error:
1686
+ return _result(False, action, error, [rel])
1687
+ if normalized != rel or path is None:
1688
+ return _result(False, action, "PATH_ESCAPE", [rel])
1689
+
1690
+ touched: list[tuple[str, str]] = []
1691
+ for rel, digest in current.items():
1692
+ if rel not in before:
1693
+ reason, classify_error = _classify_against_baseline(
1694
+ project_root,
1695
+ rel,
1696
+ baseline_commit,
1697
+ )
1698
+ if classify_error:
1699
+ return _result(False, action, classify_error, [rel])
1700
+ if reason is not None:
1701
+ touched.append((rel, reason))
1702
+ continue
1703
+ before_digest = before[rel].get("logicalHash") or before[rel]["sha256"]
1704
+ current_digest = (
1705
+ logical_file_hash(project_root, rel)
1706
+ if before[rel].get("logicalHash")
1707
+ else digest
1708
+ )
1709
+ if before_digest != current_digest:
1710
+ touched.append((rel, "test-updated"))
1711
+
1712
+ for reason in ("tdd-created", "test-updated"):
1713
+ rels = [rel for rel, item_reason in touched if item_reason == reason]
1714
+ if not rels:
1715
+ continue
1716
+ result = record(
1717
+ project_root,
1718
+ change_root,
1719
+ [str(project_root / rel) for rel in rels],
1720
+ reason,
1721
+ )
1722
+ if not result.get("ok"):
1723
+ code = result.get("code", "RECORD_FAILED")
1724
+ if code == "TEST_PATH_NOT_ALLOWED":
1725
+ return _result(False, action, "UNCLASSIFIABLE_TEST", rels)
1726
+ return result
1727
+
1728
+ recorded = [rel for rel, _ in touched]
1729
+ reconciliation = (
1730
+ {
1731
+ "ok": True,
1732
+ "code": "PROFILE_REBASED_MANIFEST_PRESERVED",
1733
+ "removedCurrentTouches": 0,
1734
+ "deletedEntries": 0,
1735
+ "preservedForeignEntries": 0,
1736
+ }
1737
+ if profile_changed
1738
+ else _reconcile_close_manifest(
1739
+ project_root,
1740
+ change_root,
1741
+ set(recorded),
1742
+ baseline_commit,
1743
+ )
1744
+ )
1745
+ if not reconciliation.get("ok"):
1746
+ return _result(
1747
+ False,
1748
+ action,
1749
+ str(reconciliation.get("code") or "MANIFEST_RECONCILE_FAILED"),
1750
+ list(reconciliation.get("files") or []),
1751
+ )
1752
+
1753
+ # A manifest is change-wide and intentionally survives phase boundaries.
1754
+ # Therefore pre-existing entries with no new diff are consistent and must
1755
+ # not force a test-guard reset. When the build profile changes mid-phase,
1756
+ # retain the diff result and rebaseline the reusable snapshot atomically.
1757
+ if profile_changed:
1758
+ snapshot.update(
1759
+ {
1760
+ "profileFingerprint": current_profile_fingerprint,
1761
+ "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
1762
+ "files": [
1763
+ {
1764
+ "path": rel,
1765
+ "sha256": digest,
1766
+ "ignored": _is_ignored(project_root, rel),
1767
+ }
1768
+ for rel, digest in sorted(current.items())
1769
+ ],
1770
+ }
1771
+ )
1772
+ _write_json(snapshot_path, snapshot)
1773
+
1774
+ return _result(
1775
+ True,
1776
+ action,
1777
+ "CLOSED",
1778
+ recorded,
1779
+ recordedCount=len(recorded),
1780
+ unchangedPreexisting=len(before) - sum(1 for rel, _ in touched if rel in before),
1781
+ baselineCommit=baseline_commit,
1782
+ profileChanged=profile_changed,
1783
+ manifestReconciliation={
1784
+ key: value
1785
+ for key, value in reconciliation.items()
1786
+ if key != "ok"
1787
+ },
1788
+ )
1789
+
1790
+
1791
+ def mark(
1792
+ project: Path | str,
1793
+ change_dir: Path | str,
1794
+ files: list[str],
1795
+ ) -> dict[str, Any]:
1796
+ return record(project, change_dir, files, "stale-test-repair")
1797
+
1798
+
1799
+ def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1800
+ action = "stage"
1801
+ project_root = Path(project).resolve()
1802
+ invalid_root = _invalid_project_root(action, project, project_root)
1803
+ if invalid_root is not None:
1804
+ return invalid_root
1805
+ change_root = _change_dir(project_root, change_dir)
1806
+ if change_root is None:
1807
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
1808
+ manifest_path = _manifest_path(change_root)
1809
+ if manifest_path is None:
1810
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
1811
+
1812
+ index_result = _git(project_root, "rev-parse", "--git-path", "index")
1813
+ if index_result.returncode != 0 or not index_result.stdout.strip():
1814
+ return _result(False, action, "GIT_INDEX_NOT_FOUND", [])
1815
+ index_path = Path(index_result.stdout.strip())
1816
+ if not index_path.is_absolute():
1817
+ index_path = (project_root / index_path).resolve()
1818
+ index_path.parent.mkdir(parents=True, exist_ok=True)
1819
+
1820
+ index_lock = index_path.with_name(index_path.name + ".lock")
1821
+ try:
1822
+ with _exclusive_lock(index_lock, wait_seconds=0.0):
1823
+ manifest_lock = manifest_path.with_name(manifest_path.name + ".lock")
1824
+ try:
1825
+ with _exclusive_lock(manifest_lock, wait_seconds=0.0):
1826
+ return _stage_locked(
1827
+ project_root, change_root, manifest_path, index_path
1828
+ )
1829
+ except LockUnavailable:
1830
+ return _result(False, action, "MANIFEST_LOCKED", [])
1831
+ except LockUnavailable:
1832
+ return _result(False, action, "INDEX_LOCKED", [])
1833
+
1834
+
1835
+ def main(argv: list[str] | None = None) -> int:
1836
+ parser = argparse.ArgumentParser(description=__doc__)
1837
+ sub = parser.add_subparsers(dest="action", required=True)
1838
+ record_parser = sub.add_parser("record")
1839
+ record_parser.add_argument("--project", required=True)
1840
+ record_parser.add_argument("--change-dir", required=True)
1841
+ record_parser.add_argument("--files", required=True)
1842
+ record_parser.add_argument("--reason", required=True, choices=REASONS)
1843
+ record_parser.add_argument("--json", action="store_true")
1844
+ stage_parser = sub.add_parser("stage")
1845
+ stage_parser.add_argument("--project", required=True)
1846
+ stage_parser.add_argument("--change-dir", required=True)
1847
+ stage_parser.add_argument("--json", action="store_true")
1848
+ begin_parser = sub.add_parser("begin")
1849
+ begin_parser.add_argument("--project", required=True)
1850
+ begin_parser.add_argument("--change-dir", required=True)
1851
+ begin_parser.add_argument("--json", action="store_true")
1852
+ close_parser = sub.add_parser("close")
1853
+ close_parser.add_argument("--project", required=True)
1854
+ close_parser.add_argument("--change-dir", required=True)
1855
+ close_parser.add_argument("--json", action="store_true")
1856
+ rehome_parser = sub.add_parser("rehome")
1857
+ rehome_parser.add_argument("--from", dest="from_project", required=True)
1858
+ rehome_parser.add_argument("--to", dest="to_project", required=True)
1859
+ rehome_parser.add_argument("--change-dir", required=True)
1860
+ rehome_parser.add_argument("--expected-head", required=True)
1861
+ rehome_parser.add_argument("--json", action="store_true")
1862
+ rebind_parser = sub.add_parser("rebind-snapshot")
1863
+ rebind_parser.add_argument("--from", dest="from_project", required=True)
1864
+ rebind_parser.add_argument("--to", dest="to_project", required=True)
1865
+ rebind_parser.add_argument("--change-dir", required=True)
1866
+ rebind_parser.add_argument("--expected-base", required=True)
1867
+ rebind_parser.add_argument("--json", action="store_true")
1868
+ mark_parser = sub.add_parser("mark")
1869
+ mark_parser.add_argument("--project", required=True)
1870
+ mark_parser.add_argument("--change-dir", required=True)
1871
+ mark_parser.add_argument("--files", required=True)
1872
+ mark_parser.add_argument("--json", action="store_true")
1873
+ args = parser.parse_args(argv)
1874
+ if args.action == "record":
1875
+ files = [item.strip() for item in args.files.split(",") if item.strip()]
1876
+ result = record(args.project, args.change_dir, files, args.reason)
1877
+ elif args.action == "begin":
1878
+ result = begin(args.project, args.change_dir)
1879
+ elif args.action == "close":
1880
+ result = close(args.project, args.change_dir)
1881
+ elif args.action == "mark":
1882
+ files = [item.strip() for item in args.files.split(",") if item.strip()]
1883
+ result = mark(args.project, args.change_dir, files)
1884
+ elif args.action == "rehome":
1885
+ result = rehome(
1886
+ args.from_project, args.to_project, args.change_dir, args.expected_head
1887
+ )
1888
+ elif args.action == "rebind-snapshot":
1889
+ result = rebind_snapshot(
1890
+ args.from_project, args.to_project, args.change_dir, args.expected_base
1891
+ )
1892
+ else:
1893
+ result = stage(args.project, args.change_dir)
1894
+ print(json.dumps(result, ensure_ascii=False, indent=2 if args.json else None))
1895
+ return 0 if result["ok"] else 1
1896
+
1897
+
1898
+ if __name__ == "__main__":
1899
+ raise SystemExit(main())