@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,2564 @@
1
+ #!/usr/bin/env python3
2
+ """Harness integration transaction.
3
+
4
+ Reentrant, journaled submit pipeline that never stashes, moves or overwrites
5
+ the primary worktree. All target-branch mutation happens inside a temporary
6
+ integration worktree; the journal is the single contract for step order,
7
+ identities and recovery.
8
+
9
+ Steps: preflight → prepare → merge → verify → push → cleanup.
10
+ Journal: ``.harness/state/integration/<transaction-id>/journal.json``.
11
+
12
+ Python 3.10+, stdlib only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ import shlex
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import uuid
29
+ from pathlib import Path
30
+ from typing import Any, Callable, Sequence
31
+
32
+ SCRIPTS_DIR = Path(__file__).resolve().parent
33
+ if str(SCRIPTS_DIR) not in sys.path:
34
+ sys.path.insert(0, str(SCRIPTS_DIR))
35
+
36
+ import harness_change # noqa: E402
37
+ import harness_ledger as hl # noqa: E402
38
+ import harness_paths # noqa: E402
39
+ import harness_process # noqa: E402
40
+ import harness_service # noqa: E402
41
+
42
+ if hasattr(sys.stdout, "reconfigure"):
43
+ sys.stdout.reconfigure(encoding="utf-8")
44
+ if hasattr(sys.stderr, "reconfigure"):
45
+ sys.stderr.reconfigure(encoding="utf-8")
46
+
47
+ STEP_ORDER = ["preflight", "prepare", "merge", "verify", "push", "cleanup"]
48
+ DEFAULT_VERIFY_TIMEOUT_SECONDS = 1800
49
+ HEAVY_WORKTREE_ROOTS = (
50
+ "node_modules", ".venv", "venv", "build", "dist", "target", ".cache", "__pycache__"
51
+ )
52
+ _FOREIGN_PATTERNS = (
53
+ re.compile(r"^\.harness/changes/([^/]+)(/|$)"),
54
+ re.compile(r"^\.harness/state/changes/([^/]+)(/|$)"),
55
+ )
56
+ WORKTREE_LIFECYCLE_STATES = (
57
+ "REGISTERED",
58
+ "MATERIALIZED",
59
+ "AGENT_ATTACHED",
60
+ "ACTIVE",
61
+ "QUIESCING",
62
+ "AGENT_DETACHED",
63
+ "STATE_FINALIZED",
64
+ "REGISTRATION_REMOVED",
65
+ "RESIDUAL_CLEANED",
66
+ "RETIRED",
67
+ )
68
+
69
+
70
+ def now_iso() -> str:
71
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
72
+
73
+
74
+ def _worktree_registrations(project_root: Path) -> dict[str, dict[str, Any]]:
75
+ proc = subprocess.run(
76
+ ["git", "worktree", "list", "--porcelain"],
77
+ cwd=str(project_root),
78
+ capture_output=True,
79
+ text=True,
80
+ encoding="utf-8",
81
+ errors="replace",
82
+ check=False,
83
+ )
84
+ registrations: dict[str, dict[str, Any]] = {}
85
+ current: dict[str, Any] | None = None
86
+ for line in proc.stdout.splitlines() if proc.returncode == 0 else []:
87
+ if line.startswith("worktree "):
88
+ path = str(Path(line.removeprefix("worktree ")).resolve())
89
+ current = {"path": path, "branch": None, "head": None}
90
+ registrations[path] = current
91
+ elif current is not None and line.startswith("branch "):
92
+ current["branch"] = line.removeprefix("branch ")
93
+ elif current is not None and line.startswith("HEAD "):
94
+ current["head"] = line.removeprefix("HEAD ")
95
+ return registrations
96
+
97
+
98
+ def _active_capsules_for_root(
99
+ project_root: Path,
100
+ target_root: Path,
101
+ change_dir: Path | None,
102
+ ) -> list[dict[str, Any]]:
103
+ candidates: list[Path] = []
104
+ change_dirs = [change_dir.resolve()] if change_dir is not None else []
105
+ if not change_dirs:
106
+ for root in (
107
+ project_root / ".harness" / "changes",
108
+ project_root / ".harness" / "state" / "changes",
109
+ ):
110
+ if root.is_dir():
111
+ change_dirs.extend(path for path in root.iterdir() if path.is_dir())
112
+ for root in change_dirs:
113
+ for capsule_root in (
114
+ root / "runtime" / "phase-context",
115
+ root / "phase-context",
116
+ ):
117
+ if capsule_root.is_dir():
118
+ candidates.extend(sorted(capsule_root.glob("*.json")))
119
+ active: list[dict[str, Any]] = []
120
+ for path in candidates:
121
+ try:
122
+ capsule = json.loads(path.read_text(encoding="utf-8-sig"))
123
+ except (OSError, json.JSONDecodeError):
124
+ continue
125
+ if not isinstance(capsule, dict):
126
+ continue
127
+ raw_root = str(capsule.get("executionRoot") or "").strip()
128
+ if not raw_root:
129
+ continue
130
+ try:
131
+ same_root = Path(raw_root).resolve() == target_root
132
+ except OSError:
133
+ same_root = False
134
+ transaction = capsule.get("closeTransaction")
135
+ transaction_status = (
136
+ str(transaction.get("status") or "").upper()
137
+ if isinstance(transaction, dict)
138
+ else ""
139
+ )
140
+ closed = transaction_status == "CLOSED" or bool(
141
+ capsule.get("closedAt") and capsule.get("closeStatus")
142
+ )
143
+ if same_root and not closed:
144
+ active.append(
145
+ {
146
+ "path": str(path),
147
+ "phase": capsule.get("phase"),
148
+ "runId": capsule.get("runId"),
149
+ "transactionStatus": transaction_status or "OPEN",
150
+ }
151
+ )
152
+ return active
153
+
154
+
155
+ def evaluate_worktree_retirement(
156
+ project_root: Path,
157
+ target_root: Path,
158
+ *,
159
+ change_dir: Path | None = None,
160
+ agent_root: Path | None = None,
161
+ junction_dependencies: list[dict[str, Any]] | None = None,
162
+ ) -> dict[str, Any]:
163
+ """Journal a unified, read-only preflight for safe worktree retirement."""
164
+ project_root = project_root.resolve()
165
+ target_root = target_root.resolve()
166
+ registrations = _worktree_registrations(project_root)
167
+ registration = registrations.get(str(target_root))
168
+ active_capsules = _active_capsules_for_root(
169
+ project_root,
170
+ target_root,
171
+ change_dir,
172
+ )
173
+ if junction_dependencies is None:
174
+ junction_path = (
175
+ project_root
176
+ / ".harness"
177
+ / "config"
178
+ / "junction-dependencies.json"
179
+ )
180
+ try:
181
+ loaded = json.loads(junction_path.read_text(encoding="utf-8-sig"))
182
+ except (OSError, json.JSONDecodeError):
183
+ loaded = []
184
+ junction_dependencies = loaded if isinstance(loaded, list) else []
185
+ matching_junctions = []
186
+ for item in junction_dependencies:
187
+ if not isinstance(item, dict):
188
+ continue
189
+ try:
190
+ matches = Path(str(item.get("target") or "")).resolve() == target_root
191
+ except OSError:
192
+ matches = False
193
+ if matches:
194
+ matching_junctions.append(item)
195
+ attached = False
196
+ if agent_root is not None:
197
+ try:
198
+ resolved_agent = agent_root.resolve()
199
+ attached = (
200
+ resolved_agent == target_root
201
+ or target_root in resolved_agent.parents
202
+ )
203
+ except OSError:
204
+ attached = True
205
+ blockers: list[dict[str, Any]] = []
206
+ if active_capsules:
207
+ blockers.append(
208
+ {
209
+ "code": "ACTIVE_PHASE_CAPSULE",
210
+ "message": "phase capsules still reference the worktree",
211
+ "capsules": active_capsules,
212
+ }
213
+ )
214
+ if attached:
215
+ blockers.append(
216
+ {
217
+ "code": "AGENT_STILL_ATTACHED",
218
+ "message": "Agent root/cwd still points into the worktree",
219
+ "agentRoot": str(agent_root.resolve()) if agent_root else None,
220
+ }
221
+ )
222
+ if matching_junctions:
223
+ blockers.append(
224
+ {
225
+ "code": "JUNCTION_DEPENDENCY_ACTIVE",
226
+ "message": "junction dependencies must be removed or migrated",
227
+ "junctions": matching_junctions,
228
+ }
229
+ )
230
+ receipt = {
231
+ "schemaVersion": 1,
232
+ "lifecycleId": hashlib.sha256(
233
+ str(target_root).encode("utf-8")
234
+ ).hexdigest()[:20],
235
+ "projectRoot": str(project_root),
236
+ "targetRoot": str(target_root),
237
+ "state": "ACTIVE" if blockers else "QUIESCING",
238
+ "allowedStates": list(WORKTREE_LIFECYCLE_STATES),
239
+ "registration": registration,
240
+ "diskPathPresent": target_root.exists(),
241
+ "agentAttached": attached,
242
+ "activeCapsules": active_capsules,
243
+ "junctionDependencies": matching_junctions,
244
+ "blockers": blockers,
245
+ "ok": not blockers,
246
+ "code": (
247
+ "WORKTREE_RETIREMENT_READY"
248
+ if not blockers
249
+ else "WORKTREE_RETIREMENT_BLOCKED"
250
+ ),
251
+ "updatedAt": now_iso(),
252
+ }
253
+ receipt_path = (
254
+ project_root
255
+ / ".harness"
256
+ / "runtime"
257
+ / "worktree-lifecycle"
258
+ / f"{receipt['lifecycleId']}.json"
259
+ )
260
+ receipt_path.parent.mkdir(parents=True, exist_ok=True)
261
+ temporary = receipt_path.with_name(
262
+ f".{receipt_path.name}.{os.getpid()}.tmp"
263
+ )
264
+ try:
265
+ temporary.write_text(
266
+ json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
267
+ encoding="utf-8",
268
+ newline="\n",
269
+ )
270
+ os.replace(temporary, receipt_path)
271
+ finally:
272
+ temporary.unlink(missing_ok=True)
273
+ receipt["receiptPath"] = str(receipt_path)
274
+ return receipt
275
+
276
+
277
+ def _split_verify_command(raw: str, *, platform: str = sys.platform) -> list[str]:
278
+ # Harness profile/CLI commands use one portable quoting contract. Keep
279
+ # POSIX tokenization on Windows too; executable normalization happens later.
280
+ return shlex.split(raw, posix=True)
281
+
282
+
283
+ def _dedupe_verify_commands(
284
+ commands: Sequence[Sequence[str]],
285
+ ) -> list[list[str]]:
286
+ """Preserve order while preventing explicit/profile duplicate execution."""
287
+ result: list[list[str]] = []
288
+ seen: set[tuple[str, ...]] = set()
289
+ for command in commands:
290
+ normalized = tuple(str(item) for item in command)
291
+ if normalized in seen:
292
+ continue
293
+ seen.add(normalized)
294
+ result.append(list(normalized))
295
+ return result
296
+
297
+
298
+ _HEAVY_VERIFICATIONS = frozenset({"unitTestFull", "apiTest", "browserTest", "package"})
299
+
300
+
301
+ def _merge_command_kind(item: Any) -> tuple[str, str | None]:
302
+ """Classify typed merge commands; legacy strings remain smoke."""
303
+ if not isinstance(item, dict):
304
+ return "smoke", None
305
+ verification = str(item.get("verification") or item.get("target") or "").strip() or None
306
+ declared = str(item.get("kind") or item.get("class") or "").strip().lower()
307
+ if declared in {"integrity", "smoke", "heavyweight"}:
308
+ return declared, verification
309
+ return ("heavyweight" if verification in _HEAVY_VERIFICATIONS else "smoke"), verification
310
+
311
+
312
+ def _reusable_merge_evidence(
313
+ ledger_path: Path | None,
314
+ *,
315
+ verification: str | None,
316
+ command: Sequence[str],
317
+ product_tree_hash: str | None,
318
+ reuse_context: dict[str, str],
319
+ ) -> dict[str, Any] | None:
320
+ """Return feature-tip evidence when merge content and execution key match."""
321
+ if ledger_path is None or verification is None or not product_tree_hash:
322
+ return None
323
+ try:
324
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
325
+ except (OSError, json.JSONDecodeError):
326
+ return None
327
+ raw = ledger.get("verificationTargets")
328
+ entries = raw.values() if isinstance(raw, dict) else raw if isinstance(raw, list) else []
329
+ command_hash = hl.command_set_hash(" ".join(str(part) for part in command))
330
+ for entry in reversed(list(entries)):
331
+ if not isinstance(entry, dict) or entry.get("verification") != verification:
332
+ continue
333
+ if entry.get("status") != "OK":
334
+ continue
335
+ if str(entry.get("productTreeHash") or "").removeprefix("sha256:") != product_tree_hash.removeprefix("sha256:"):
336
+ continue
337
+ if str(entry.get("commandSetHash") or "") != command_hash:
338
+ continue
339
+ # Context identity is fail-closed: an evidence field may only be
340
+ # reused when the merge policy supplies the same current value.
341
+ if any(
342
+ str(entry.get(field) or "").strip()
343
+ and str(entry.get(field) or "").strip()
344
+ != str(reuse_context.get(field) or "").strip()
345
+ for field in ("environmentHash", "toolchainHash", "lockHash", "dbSchemaHash")
346
+ ):
347
+ continue
348
+ return {
349
+ "reusedFrom": {
350
+ "targetId": entry.get("id"),
351
+ "sourceCommit": entry.get("productCommit") or entry.get("currentHead"),
352
+ "sourceCandidate": entry.get("candidateId"),
353
+ },
354
+ "reuseReason": "product tree and canonical execution key matched",
355
+ "reuseKey": hl.canonical_reuse_key({**entry, "verification": verification}),
356
+ }
357
+ return None
358
+
359
+
360
+ def _normalize_verify_command(
361
+ command: Sequence[str],
362
+ *,
363
+ platform: str = sys.platform,
364
+ resolver: Callable[[str], str | None] = shutil.which,
365
+ ) -> list[str]:
366
+ """Resolve Windows package-manager wrappers without enabling shell mode."""
367
+ result = [str(item) for item in command]
368
+ if not result or not platform.lower().startswith("win"):
369
+ return result
370
+ executable = Path(result[0]).name.lower()
371
+ if Path(executable).suffix:
372
+ return result
373
+ if executable not in {"npm", "npx", "pnpm", "yarn", "corepack"}:
374
+ return result
375
+ resolved = resolver(executable + ".cmd") or resolver(executable)
376
+ if resolved:
377
+ result[0] = resolved
378
+ return result
379
+
380
+
381
+ # 合并态验证的兜底优先级:先跑覆盖面最广的,再退到窄的。profile 里这些命令
382
+ # 本来就是 ledger 一直在用的规范命令,没有理由让调用方在合并时另行手敲一遍。
383
+ MERGE_VERIFICATION_FALLBACK_ORDER = ("unitTestFull", "unitTest", "apiTest", "compile")
384
+
385
+
386
+ def verify_plan_recovery_action() -> str:
387
+ """把 VERIFY_PLAN_MISSING 补成一句能照做的话。
388
+
389
+ 此前它只说 "requires at least one executable command"。于是调用方要么去读
390
+ 实现,要么随手塞一条命令让门过去——两条路都走过,后者更糟:它让"验证通过"
391
+ 这句话失去含义。
392
+ """
393
+ return (
394
+ "合并态验证没有可执行计划。两条正规出路:"
395
+ "(1) 在 .harness/config/build-profile.json 增加 mergeVerification.requiredOnMerge "
396
+ "声明合并时必跑的命令(推荐,一次配置长期生效);"
397
+ "(2) 本次用 --command 传入。注意 --command 可重复,每次只接**一条**命令,"
398
+ "且按 argv 直接执行(不经过 shell)——`&&`、`|`、`cd x && y` 都不成立,"
399
+ "需要拆成多个 --command。"
400
+ "示例:--command \"npm test\" --command \"npm run lint\"。"
401
+ )
402
+
403
+
404
+ def _fallback_merge_commands(profile: Any) -> list[dict[str, Any]]:
405
+ """从 profile 已有的验证目标推导合并态计划。"""
406
+ commands = profile.get("commands") if isinstance(profile, dict) else None
407
+ if not isinstance(commands, dict):
408
+ return []
409
+ for name in MERGE_VERIFICATION_FALLBACK_ORDER:
410
+ entry = commands.get(name)
411
+ if not isinstance(entry, dict):
412
+ continue
413
+ argv = entry.get("argvTemplate")
414
+ if isinstance(argv, list) and argv and all(
415
+ isinstance(item, str) and item for item in argv
416
+ ):
417
+ return [{
418
+ "command": [str(item) for item in argv],
419
+ "kind": "heavyweight",
420
+ "verification": name,
421
+ "source": "profile-fallback",
422
+ "reuseContext": {},
423
+ }]
424
+ raw = entry.get("command")
425
+ if isinstance(raw, str) and raw.strip():
426
+ return [{
427
+ "command": _split_verify_command(raw.strip()),
428
+ "kind": "heavyweight",
429
+ "verification": name,
430
+ "source": "profile-fallback",
431
+ "reuseContext": {},
432
+ }]
433
+ return []
434
+
435
+
436
+ def merge_verification_plan(profile: Any) -> list[dict[str, Any]]:
437
+ """声明优先,推导兜底;两者都没有才返回空计划。"""
438
+ merge = profile.get("mergeVerification") if isinstance(profile, dict) else None
439
+ required = merge.get("requiredOnMerge") if isinstance(merge, dict) else None
440
+ if isinstance(required, list) and required:
441
+ plan: list[dict[str, Any]] = []
442
+ for index, item in enumerate(required):
443
+ if isinstance(item, str) and item.strip():
444
+ plan.append({
445
+ "command": _split_verify_command(item.strip()),
446
+ "kind": "smoke",
447
+ "verification": None,
448
+ "source": "profile",
449
+ "reuseContext": {},
450
+ })
451
+ continue
452
+ if not isinstance(item, dict):
453
+ raise ValueError(f"requiredOnMerge[{index}] must be string or object")
454
+ raw = item.get("command")
455
+ if isinstance(raw, list) and raw and all(
456
+ isinstance(value, str) and value for value in raw
457
+ ):
458
+ command = [str(value) for value in raw]
459
+ elif isinstance(raw, str) and raw.strip():
460
+ command = _split_verify_command(raw.strip())
461
+ else:
462
+ raise ValueError(f"requiredOnMerge[{index}].command missing")
463
+ kind = str(item.get("kind") or "smoke")
464
+ plan.append({
465
+ "command": command,
466
+ "kind": kind,
467
+ "verification": item.get("verification"),
468
+ "source": "profile",
469
+ "reuseContext": {
470
+ field: str(item.get(field)).strip()
471
+ for field in (
472
+ "environmentHash",
473
+ "toolchainHash",
474
+ "lockHash",
475
+ "dbSchemaHash",
476
+ )
477
+ if str(item.get(field) or "").strip()
478
+ },
479
+ })
480
+ return plan
481
+ return _fallback_merge_commands(profile)
482
+
483
+
484
+ def verification_depth(plan: Sequence[dict[str, Any]]) -> str:
485
+ """一条临时探测命令通过,不该和跑完整条链读起来一样。
486
+
487
+ 2026-08-19 那次合并,verify 只跑了一个测试文件就 DONE,报告里看不出任何差别。
488
+ 真正的全链验证是人工在事务外补跑的——账面与事实脱节,正是这种静默造成的。
489
+ """
490
+ entries = [item for item in plan if isinstance(item, dict)]
491
+ if len(entries) == 1 and entries[0].get("source") not in {"profile", "profile-fallback"}:
492
+ return "thin"
493
+ return "declared"
494
+
495
+
496
+ class IntegrationError(Exception):
497
+ code = "INTEGRATION_ERROR"
498
+
499
+
500
+ class IntegrationLockHeldError(IntegrationError):
501
+ code = "INTEGRATION_LOCK_HELD"
502
+
503
+
504
+ class MergeFailedError(IntegrationError):
505
+ code = "MERGE_FAILED"
506
+
507
+
508
+ class ForeignChangePathsError(IntegrationError):
509
+ code = "FOREIGN_CHANGE_PATHS"
510
+
511
+
512
+ class VerificationFailedError(IntegrationError):
513
+ code = "VERIFICATION_FAILED"
514
+
515
+
516
+ class VerifyPlanMissingError(IntegrationError):
517
+ code = "VERIFY_PLAN_MISSING"
518
+
519
+
520
+ class TargetMovedError(IntegrationError):
521
+ code = "TARGET_MOVED"
522
+
523
+
524
+ class RemoteProbeFailedError(IntegrationError):
525
+ """Remote probe failed (network/auth/process error), not target moved (retro §5.28)."""
526
+
527
+ code = "REMOTE_PROBE_FAILED"
528
+
529
+
530
+ class LedgerSyncError(IntegrationError):
531
+ code = "LEDGER_SYNC_PENDING"
532
+
533
+
534
+ class EvidenceRebindRefusedError(IntegrationError):
535
+ code = "EVIDENCE_REBIND_REFUSED"
536
+
537
+
538
+ class CleanupRefusedError(IntegrationError):
539
+ code = "CLEANUP_REFUSED"
540
+
541
+
542
+ class CleanupResidualError(IntegrationError):
543
+ code = "CLEANUP_RESIDUAL"
544
+
545
+ def __init__(self, result: dict[str, Any]):
546
+ self.result = result
547
+ super().__init__(
548
+ "worktree cleanup retained paths: "
549
+ + ", ".join(result.get("residualPaths") or ["<registration>"])
550
+ )
551
+
552
+
553
+ class AbandonRefusedError(IntegrationError):
554
+ code = "ABANDON_REFUSED"
555
+
556
+
557
+ class JournalConflictError(IntegrationError):
558
+ code = "JOURNAL_CONFLICT"
559
+
560
+
561
+ JOURNAL_LOCK_STALE_SECONDS = 300
562
+
563
+
564
+ class GitRunner:
565
+ """Recordable git invocation wrapper (command spy for audits)."""
566
+
567
+ def __init__(self) -> None:
568
+ self.history: list[tuple[str, tuple[str, ...]]] = []
569
+
570
+ def run(self, cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
571
+ self.history.append((str(cwd), tuple(args)))
572
+ proc = subprocess.run(
573
+ ["git", *args],
574
+ cwd=str(cwd),
575
+ capture_output=True,
576
+ text=True,
577
+ encoding="utf-8",
578
+ errors="replace",
579
+ check=False,
580
+ )
581
+ if check and proc.returncode != 0:
582
+ raise IntegrationError(
583
+ f"git {' '.join(args)} failed ({proc.returncode}): {proc.stderr.strip()}"
584
+ )
585
+ return proc
586
+
587
+ def text(self, cwd: Path, *args: str) -> str | None:
588
+ proc = self.run(cwd, *args, check=False)
589
+ if proc.returncode != 0:
590
+ return None
591
+ return proc.stdout.strip()
592
+
593
+ def remote_probe(self, cwd: Path, *args: str) -> dict[str, Any]:
594
+ """Typed remote probe result (retro §5.28).
595
+
596
+ Returns:
597
+ {
598
+ "exitCode": int,
599
+ "stdoutHash": str | None, # first whitespace-delimited token
600
+ "redactedStderr": str, # stderr with credentials redacted
601
+ "category": "ok" | "target-moved" | "probe-failed" | "auth-failed",
602
+ }
603
+
604
+ `category` distinguishes:
605
+ - `ok`: exit=0, hash available
606
+ - `target-moved`: only meaningful when caller compares hash to expected
607
+ - `probe-failed`: exit!=0, network/process error (allow bounded retry)
608
+ - `auth-failed`: exit!=0, stderr contains auth/credential indicators
609
+
610
+ `None` must never enter the "found head" field; callers must branch on
611
+ `exitCode` before comparing hashes.
612
+ """
613
+ proc = self.run(cwd, *args, check=False)
614
+ stderr = (proc.stderr or "").strip()
615
+ # Redact potential credentials in stderr (URLs with user:pass@, tokens)
616
+ redacted = re.sub(
617
+ r"(https?|ssh)://[^\s]+@[^\s]+", r"\1://***@***", stderr
618
+ )
619
+ redacted = re.sub(r"(token|password|secret|key)\s*[:=]\s*\S+", r"\1=***", redacted, flags=re.I)
620
+ stdout_hash: str | None = None
621
+ if proc.returncode == 0:
622
+ stdout = (proc.stdout or "").strip()
623
+ if stdout:
624
+ stdout_hash = stdout.split()[0]
625
+ # Classify category
626
+ if proc.returncode == 0:
627
+ category = "ok"
628
+ elif re.search(r"auth|credential|permission|forbidden|unauthorized", stderr, re.I):
629
+ category = "auth-failed"
630
+ else:
631
+ category = "probe-failed"
632
+ return {
633
+ "exitCode": proc.returncode,
634
+ "stdoutHash": stdout_hash,
635
+ "redactedStderr": redacted,
636
+ "category": category,
637
+ }
638
+
639
+
640
+ def _sanitize(value: str) -> str:
641
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-") or "txn"
642
+
643
+
644
+ def _compact_transaction_id(change_id: str, run_id: str, *, limit: int = 48) -> str:
645
+ readable = f"{_sanitize(change_id)}-{_sanitize(run_id)}"
646
+ if len(readable) <= limit:
647
+ return readable
648
+ digest = hashlib.sha256(readable.encode("utf-8")).hexdigest()[:12]
649
+ prefix = _sanitize(change_id)[: max(8, limit - len(digest) - 1)]
650
+ return f"{prefix}-{digest}"
651
+
652
+
653
+ def default_temp_root(project_root: Path) -> Path:
654
+ """Use a deliberately short Windows worktree root to protect MAX_PATH."""
655
+ configured = os.environ.get("HUNTER_HARNESS_INTEGRATION_ROOT")
656
+ if configured:
657
+ return Path(configured).expanduser().resolve()
658
+ resolved = Path(project_root).resolve()
659
+ if os.name == "nt" and resolved.drive:
660
+ return Path(resolved.drive + os.sep) / "hh-int"
661
+ return resolved / ".harness" / "runtime" / "i"
662
+
663
+
664
+ def journal_dir(project_root: Path, transaction_id: str) -> Path:
665
+ return (
666
+ Path(project_root)
667
+ / ".harness"
668
+ / "state"
669
+ / "integration"
670
+ / transaction_id
671
+ )
672
+
673
+
674
+ def journal_path(project_root: Path, transaction_id: str) -> Path:
675
+ return journal_dir(project_root, transaction_id) / "journal.json"
676
+
677
+
678
+ def _write_verify_log(
679
+ project_root: Path,
680
+ transaction_id: str,
681
+ index: int,
682
+ *,
683
+ command: Sequence[str],
684
+ resolved_command: Sequence[str],
685
+ started: dt.datetime,
686
+ finished: dt.datetime,
687
+ exit_code: int | None,
688
+ stdout: str | bytes | None = None,
689
+ stderr: str | bytes | None = None,
690
+ error: str | None = None,
691
+ ) -> str:
692
+ def as_text(value: str | bytes | None) -> str:
693
+ if isinstance(value, bytes):
694
+ return value.decode("utf-8", errors="replace")
695
+ return value or ""
696
+
697
+ path = journal_dir(project_root, transaction_id) / "logs" / f"verify-{index + 1}.log"
698
+ path.parent.mkdir(parents=True, exist_ok=True)
699
+ lines = [
700
+ f"command: {json.dumps(list(command), ensure_ascii=False)}",
701
+ f"resolvedCommand: {json.dumps(list(resolved_command), ensure_ascii=False)}",
702
+ f"startedAt: {started.isoformat(timespec='milliseconds')}",
703
+ f"finishedAt: {finished.isoformat(timespec='milliseconds')}",
704
+ f"durationMs: {int((finished - started).total_seconds() * 1000)}",
705
+ f"exitCode: {exit_code if exit_code is not None else 'null'}",
706
+ ]
707
+ if error:
708
+ lines.append(f"error: {error}")
709
+ lines.extend(["--- stdout ---", as_text(stdout), "--- stderr ---", as_text(stderr)])
710
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
711
+ return path.relative_to(project_root).as_posix()
712
+
713
+
714
+ def load_journal(project_root: Path, transaction_id: str) -> dict[str, Any]:
715
+ path = journal_path(project_root, transaction_id)
716
+ if not path.is_file():
717
+ raise FileNotFoundError(f"journal not found: {path}")
718
+ return json.loads(path.read_text(encoding="utf-8-sig"))
719
+
720
+
721
+ def _pid_alive(pid: int) -> bool:
722
+ return harness_process.is_pid_alive(pid)
723
+
724
+
725
+ def _acquire_journal_lock(lock_path: Path, journal_path: Path) -> int:
726
+ for _attempt in range(2):
727
+ try:
728
+ descriptor = os.open(
729
+ str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
730
+ )
731
+ payload = json.dumps(
732
+ {"pid": os.getpid(), "createdAtEpoch": time.time()},
733
+ separators=(",", ":"),
734
+ ).encode("utf-8")
735
+ try:
736
+ os.write(descriptor, payload)
737
+ os.fsync(descriptor)
738
+ except BaseException:
739
+ os.close(descriptor)
740
+ lock_path.unlink(missing_ok=True)
741
+ raise
742
+ return descriptor
743
+ except FileExistsError as exc:
744
+ stale = False
745
+ try:
746
+ owner = json.loads(lock_path.read_text(encoding="utf-8-sig"))
747
+ owner_pid = int(owner.get("pid") or 0)
748
+ created = float(owner.get("createdAtEpoch") or 0)
749
+ stale = not _pid_alive(owner_pid) and (
750
+ created <= 0
751
+ or time.time() - created > JOURNAL_LOCK_STALE_SECONDS
752
+ )
753
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
754
+ try:
755
+ stale = (
756
+ time.time() - lock_path.stat().st_mtime
757
+ > JOURNAL_LOCK_STALE_SECONDS
758
+ )
759
+ except OSError:
760
+ stale = True
761
+ if stale:
762
+ try:
763
+ lock_path.unlink()
764
+ except FileNotFoundError:
765
+ pass
766
+ continue
767
+ raise JournalConflictError(
768
+ f"journal writer already active: {journal_path}"
769
+ ) from exc
770
+ raise JournalConflictError(f"could not reclaim stale journal lock: {journal_path}")
771
+
772
+
773
+ def _write_journal(project_root: Path, journal: dict[str, Any]) -> None:
774
+ path = journal_path(project_root, journal["transactionId"])
775
+ path.parent.mkdir(parents=True, exist_ok=True)
776
+ lock_path = path.with_name(path.name + ".lock")
777
+ lock_fd = _acquire_journal_lock(lock_path, path)
778
+ os.close(lock_fd)
779
+ expected_revision = int(journal.get("revision") or 0)
780
+ try:
781
+ if path.is_file():
782
+ current = json.loads(path.read_text(encoding="utf-8-sig"))
783
+ current_revision = int(current.get("revision") or 0)
784
+ if current_revision != expected_revision:
785
+ raise JournalConflictError(
786
+ f"stale journal revision: expected {expected_revision}, "
787
+ f"current {current_revision}"
788
+ )
789
+ elif expected_revision != 0:
790
+ raise JournalConflictError(
791
+ f"journal disappeared at revision {expected_revision}: {path}"
792
+ )
793
+ journal["revision"] = expected_revision + 1
794
+ text = json.dumps(journal, ensure_ascii=False, indent=2) + "\n"
795
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
796
+ try:
797
+ tmp.write_text(text, encoding="utf-8", newline="\n")
798
+ os.replace(tmp, path)
799
+ except BaseException:
800
+ tmp.unlink(missing_ok=True)
801
+ raise
802
+ finally:
803
+ lock_path.unlink(missing_ok=True)
804
+
805
+
806
+ class IntegrationTransaction:
807
+ def __init__(
808
+ self,
809
+ *,
810
+ project_root: Path,
811
+ change_id: str,
812
+ run_id: str,
813
+ target_branch: str,
814
+ feature_branch: str,
815
+ temp_root: Path,
816
+ runner: GitRunner | None = None,
817
+ ) -> None:
818
+ self.project_root = harness_paths.resolve_main_project_root(Path(project_root))
819
+ self.change_id = change_id
820
+ self.run_id = run_id
821
+ self.target_branch = target_branch
822
+ self.feature_branch = feature_branch
823
+ self.temp_root = Path(temp_root).resolve()
824
+ self.runner = runner or GitRunner()
825
+ self.transaction_id = _compact_transaction_id(change_id, run_id)
826
+ self.temp_branch = f"harness/integration/{self.transaction_id}"
827
+
828
+ # ------------------------------------------------------------- journal
829
+
830
+ def _new_journal(self, base: str, feature_head: str) -> dict[str, Any]:
831
+ return {
832
+ "schemaVersion": 1,
833
+ "transactionId": self.transaction_id,
834
+ "changeName": self.change_id,
835
+ "evidenceChangeName": self.change_id,
836
+ "runId": self.run_id,
837
+ "repositoryId": harness_paths.repository_identity(self.project_root),
838
+ "primaryRoot": str(self.project_root),
839
+ "targetBranch": self.target_branch,
840
+ "featureBranch": self.feature_branch,
841
+ "base": base,
842
+ "featureHead": feature_head,
843
+ "evidenceIdentity": self._evidence_identity(),
844
+ "mergeCommit": None,
845
+ "pushedHead": None,
846
+ "integrationRoot": str(self.temp_root / self.transaction_id),
847
+ "allowedCleanupRoot": str(self.temp_root / self.transaction_id),
848
+ "protectionRefs": {
849
+ "base": f"refs/harness/integration/{self.transaction_id}/base",
850
+ "head": f"refs/harness/integration/{self.transaction_id}/head",
851
+ },
852
+ "steps": [
853
+ {"name": name, "status": "PENDING", "attempt": 0}
854
+ for name in STEP_ORDER
855
+ ],
856
+ "createdAt": now_iso(),
857
+ }
858
+
859
+ def _change_roots(self, change_id: str) -> tuple[Path, Path]:
860
+ if re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", change_id) is None:
861
+ raise EvidenceRebindRefusedError(
862
+ "evidence change must be a non-empty kebab-case path segment"
863
+ )
864
+ contract_base = (self.project_root / ".harness" / "changes").resolve()
865
+ state_base = (
866
+ self.project_root / ".harness" / "state" / "changes"
867
+ ).resolve()
868
+ raw_change_dir = contract_base / change_id
869
+ raw_state_dir = state_base / change_id
870
+ change_dir = raw_change_dir.resolve()
871
+ state_dir = raw_state_dir.resolve()
872
+ direct_contract_path = os.path.normcase(str(raw_change_dir.absolute()))
873
+ direct_state_path = os.path.normcase(str(raw_state_dir.absolute()))
874
+ if (
875
+ change_dir.parent != contract_base
876
+ or state_dir.parent != state_base
877
+ or os.path.normcase(str(change_dir)) != direct_contract_path
878
+ or os.path.normcase(str(state_dir)) != direct_state_path
879
+ ):
880
+ raise EvidenceRebindRefusedError(
881
+ "evidence change must remain inside the direct Change roots"
882
+ )
883
+ return change_dir, state_dir
884
+
885
+ def _bound_evidence_change(self, journal: dict[str, Any]) -> str:
886
+ if journal.get("changeName") != self.change_id:
887
+ raise JournalConflictError(
888
+ "transaction Change does not match the journal"
889
+ )
890
+ if "evidenceChangeName" not in journal:
891
+ return self.change_id
892
+ raw_change_id = journal.get("evidenceChangeName")
893
+ if not isinstance(raw_change_id, str) or not raw_change_id:
894
+ raise EvidenceRebindRefusedError(
895
+ "journal evidence Change binding is invalid"
896
+ )
897
+ change_id = raw_change_id
898
+ self._change_roots(change_id)
899
+ if change_id == self.change_id:
900
+ return change_id
901
+ rebind = journal.get("evidenceRebind") or {}
902
+ if (
903
+ rebind.get("status") != "DONE"
904
+ or rebind.get("from") != self.change_id
905
+ or rebind.get("to") != change_id
906
+ or rebind.get("reason")
907
+ != "REMOTE_CONFIRMED_DEFAULT_LEDGER_MISSING"
908
+ or rebind.get("remoteHead") != journal.get("mergeCommit")
909
+ ):
910
+ raise EvidenceRebindRefusedError(
911
+ "journal evidence Change override has no valid recovery receipt"
912
+ )
913
+ return change_id
914
+
915
+ def _evidence_identity(self, change_id: str | None = None) -> dict[str, Any]:
916
+ change_dir, state_dir = self._change_roots(change_id or self.change_id)
917
+ state_candidates = [
918
+ state_dir,
919
+ change_dir,
920
+ ]
921
+ events_file = next(
922
+ (root / "events.ndjson" for root in state_candidates if (root / "events.ndjson").is_file()),
923
+ None,
924
+ )
925
+ event_count = 0
926
+ last_event_id = None
927
+ event_hash = None
928
+ if events_file is not None:
929
+ raw = events_file.read_bytes()
930
+ event_hash = "sha256:" + hashlib.sha256(raw).hexdigest()
931
+ for line in raw.decode("utf-8", "replace").splitlines():
932
+ if not line.strip():
933
+ continue
934
+ event_count += 1
935
+ try:
936
+ item = json.loads(line)
937
+ except json.JSONDecodeError:
938
+ continue
939
+ last_event_id = item.get("id") or item.get("eventId") or last_event_id
940
+
941
+ artifact_hash = None
942
+ ledger_identity: dict[str, Any] = {}
943
+ for root in state_candidates:
944
+ for rel in (
945
+ Path("evidence/artifact-manifest.json"),
946
+ Path("evidence/archive-manifest-before.json"),
947
+ Path("evidence/test-tracking.json"),
948
+ ):
949
+ candidate = root / rel
950
+ if candidate.is_file():
951
+ artifact_hash = "sha256:" + hashlib.sha256(candidate.read_bytes()).hexdigest()
952
+ break
953
+ if artifact_hash:
954
+ break
955
+ ledger_path = self._ledger_path(change_id or self.change_id)
956
+ if ledger_path is not None:
957
+ try:
958
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
959
+ except (OSError, json.JSONDecodeError):
960
+ ledger = {}
961
+ if isinstance(ledger, dict):
962
+ for key in (
963
+ "schemaVersion",
964
+ "repositoryId",
965
+ "changeName",
966
+ "baseCommit",
967
+ "currentHead",
968
+ "diffHash",
969
+ "ownershipHash",
970
+ ):
971
+ ledger_identity[key] = ledger.get(key)
972
+ return {
973
+ "eventHighWater": {
974
+ "count": event_count,
975
+ "lastEventId": last_event_id,
976
+ "sha256": event_hash,
977
+ },
978
+ "artifactManifestHash": artifact_hash,
979
+ "ledgerIdentity": ledger_identity,
980
+ }
981
+
982
+ def _ledger_path(self, change_id: str) -> Path | None:
983
+ change_dir, state_dir = self._change_roots(change_id)
984
+ candidates = (
985
+ state_dir / "evidence" / "verification-ledger.json",
986
+ change_dir / "evidence" / "verification-ledger.json",
987
+ state_dir / "verification-ledger.json",
988
+ )
989
+ for path in candidates:
990
+ if not path.is_file():
991
+ continue
992
+ resolved = path.resolve()
993
+ if path.is_symlink() or not any(
994
+ resolved.is_relative_to(root)
995
+ for root in (state_dir, change_dir)
996
+ ):
997
+ raise EvidenceRebindRefusedError(
998
+ "verification ledger must remain inside its direct Change root"
999
+ )
1000
+ return path
1001
+ return None
1002
+
1003
+ def _finalize_remote_push(
1004
+ self,
1005
+ journal: dict[str, Any],
1006
+ *,
1007
+ merge_commit: str,
1008
+ remote_head: str,
1009
+ ) -> None:
1010
+ evidence_change = self._bound_evidence_change(journal)
1011
+ journal.update({
1012
+ "evidenceChangeName": evidence_change,
1013
+ "pushedHead": merge_commit,
1014
+ "mergeFinalHash": merge_commit,
1015
+ "ciExpectedHead": merge_commit,
1016
+ "remoteHead": remote_head,
1017
+ "pushIntent": {
1018
+ "status": "REMOTE_CONFIRMED",
1019
+ "expectedRemoteHead": journal.get("remoteTargetHead") or journal.get("base"),
1020
+ "mergeCommit": merge_commit,
1021
+ "observedRemoteHead": remote_head,
1022
+ "updatedAt": now_iso(),
1023
+ },
1024
+ "ledgerSync": {"status": "PENDING", "updatedAt": now_iso()},
1025
+ })
1026
+ self._save(journal)
1027
+ ledger_path = self._ledger_path(evidence_change)
1028
+ if ledger_path is None:
1029
+ result = {"ok": False, "code": "LEDGER_MISSING"}
1030
+ else:
1031
+ result = hl.record_integration_hashes(
1032
+ ledger_path,
1033
+ change_dir=(
1034
+ self.project_root / ".harness" / "changes" / evidence_change
1035
+ ),
1036
+ repository_id=str(journal["repositoryId"]),
1037
+ merge_final_hash=merge_commit,
1038
+ ci_expected_head=merge_commit,
1039
+ remote_head=remote_head,
1040
+ )
1041
+ journal = self._load()
1042
+ journal["ledgerSync"] = {
1043
+ "status": "DONE" if result.get("ok") else "FAILED",
1044
+ "result": result,
1045
+ "updatedAt": now_iso(),
1046
+ }
1047
+ self._save(journal)
1048
+ if not result.get("ok"):
1049
+ raise LedgerSyncError(
1050
+ f"remote push is confirmed but ledger sync requires recovery: {result.get('code')}"
1051
+ )
1052
+
1053
+ def _assert_cleanup_consistency(self, journal: dict[str, Any]) -> None:
1054
+ evidence_change = self._bound_evidence_change(journal)
1055
+ push_step = self._step(journal, "push")
1056
+ merge_commit = str(journal.get("mergeCommit") or "")
1057
+ remote_line = self.runner.text(
1058
+ self.project_root, "ls-remote", "origin", self.target_branch
1059
+ )
1060
+ observed_remote = (
1061
+ remote_line.split()[0] if remote_line and remote_line.split() else ""
1062
+ )
1063
+ remote_contains_merge = bool(merge_commit and observed_remote == merge_commit)
1064
+ if push_step.get("status") != "DONE" and not remote_contains_merge:
1065
+ if self._step(journal, "prepare").get("status") == "DONE":
1066
+ raise CleanupRefusedError(
1067
+ "push is not complete and remote does not contain the merge commit"
1068
+ )
1069
+ return
1070
+ final_values = [
1071
+ journal.get("pushedHead"),
1072
+ journal.get("mergeFinalHash"),
1073
+ journal.get("ciExpectedHead"),
1074
+ journal.get("remoteHead"),
1075
+ observed_remote,
1076
+ ]
1077
+ if any(not isinstance(value, str) or not value for value in final_values) or len(
1078
+ set(final_values)
1079
+ ) != 1:
1080
+ raise CleanupRefusedError(
1081
+ "remote/journal final hashes are missing or inconsistent"
1082
+ )
1083
+ ledger_path = self._ledger_path(evidence_change)
1084
+ if ledger_path is None:
1085
+ raise CleanupRefusedError("verification ledger missing before cleanup")
1086
+ try:
1087
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
1088
+ except (OSError, json.JSONDecodeError) as exc:
1089
+ raise CleanupRefusedError(f"verification ledger unreadable: {exc}") from exc
1090
+ ledger_values = [
1091
+ ledger.get("mergeFinalHash"),
1092
+ ledger.get("ciExpectedHead"),
1093
+ ledger.get("remoteHead"),
1094
+ ]
1095
+ if len(set(ledger_values + [observed_remote])) != 1:
1096
+ raise CleanupRefusedError(
1097
+ "remote/journal/ledger final hashes are inconsistent"
1098
+ )
1099
+ ancestor = self.runner.run(
1100
+ self.project_root,
1101
+ "merge-base",
1102
+ "--is-ancestor",
1103
+ str(journal.get("featureHead") or ""),
1104
+ observed_remote,
1105
+ check=False,
1106
+ )
1107
+ if ancestor.returncode != 0:
1108
+ raise CleanupRefusedError(
1109
+ "feature head is not an ancestor of the pushed target"
1110
+ )
1111
+
1112
+ def _load(self) -> dict[str, Any]:
1113
+ return load_journal(self.project_root, self.transaction_id)
1114
+
1115
+ def _save(self, journal: dict[str, Any]) -> dict[str, Any]:
1116
+ _write_journal(self.project_root, journal)
1117
+ return journal
1118
+
1119
+ def _step(self, journal: dict[str, Any], name: str) -> dict[str, Any]:
1120
+ return next(s for s in journal["steps"] if s["name"] == name)
1121
+
1122
+ def _run_step(
1123
+ self,
1124
+ name: str,
1125
+ action: Callable[[dict[str, Any]], None],
1126
+ ) -> dict[str, Any]:
1127
+ journal = self._load()
1128
+ step = self._step(journal, name)
1129
+ if step["status"] == "DONE":
1130
+ step["reentry"] = "REUSED"
1131
+ return self._save(journal)
1132
+ step["status"] = "RUNNING"
1133
+ step["attempt"] = int(step.get("attempt", 0)) + 1
1134
+ step["startedAt"] = now_iso()
1135
+ step.pop("error", None)
1136
+ self._save(journal)
1137
+ try:
1138
+ action(journal)
1139
+ except IntegrationError as exc:
1140
+ journal = self._load()
1141
+ step = self._step(journal, name)
1142
+ step["status"] = "FAILED"
1143
+ step["error"] = f"{exc.code}: {exc}"
1144
+ step["finishedAt"] = now_iso()
1145
+ self._save(journal)
1146
+ raise
1147
+ except Exception as exc: # unexpected: still journal the failure
1148
+ journal = self._load()
1149
+ step = self._step(journal, name)
1150
+ step["status"] = "FAILED"
1151
+ step["error"] = f"UNEXPECTED: {exc}"
1152
+ step["finishedAt"] = now_iso()
1153
+ self._save(journal)
1154
+ raise
1155
+ journal = self._load()
1156
+ step = self._step(journal, name)
1157
+ step["status"] = "DONE"
1158
+ step["finishedAt"] = now_iso()
1159
+ return self._save(journal)
1160
+
1161
+ # -------------------------------------------------------------- steps
1162
+
1163
+ def preflight(self) -> dict[str, Any]:
1164
+ def action(journal: dict[str, Any]) -> None:
1165
+ self.runner.run(
1166
+ self.project_root,
1167
+ "fetch",
1168
+ "origin",
1169
+ self.target_branch,
1170
+ check=False,
1171
+ )
1172
+ local_target = self.runner.text(
1173
+ self.project_root, "rev-parse", self.target_branch
1174
+ )
1175
+ remote_line = self.runner.text(
1176
+ self.project_root, "ls-remote", "origin", self.target_branch
1177
+ )
1178
+ remote_target = (
1179
+ remote_line.split()[0] if remote_line and remote_line.split() else None
1180
+ )
1181
+ feature_head = self.runner.text(
1182
+ self.project_root, "rev-parse", self.feature_branch
1183
+ )
1184
+ if not local_target or not feature_head:
1185
+ raise IntegrationError("target or feature branch not found")
1186
+ reconciliation_required = False
1187
+ base = local_target
1188
+ base_source = "local"
1189
+ if remote_target:
1190
+ if remote_target == local_target:
1191
+ base = remote_target
1192
+ base_source = "remote"
1193
+ else:
1194
+ local_behind = self.runner.run(
1195
+ self.project_root,
1196
+ "merge-base",
1197
+ "--is-ancestor",
1198
+ local_target,
1199
+ remote_target,
1200
+ check=False,
1201
+ )
1202
+ remote_behind = self.runner.run(
1203
+ self.project_root,
1204
+ "merge-base",
1205
+ "--is-ancestor",
1206
+ remote_target,
1207
+ local_target,
1208
+ check=False,
1209
+ )
1210
+ if local_behind.returncode == 0:
1211
+ base = remote_target
1212
+ base_source = "remote-ahead"
1213
+ reconciliation_required = True
1214
+ elif remote_behind.returncode == 0:
1215
+ base = local_target
1216
+ base_source = "local-ahead"
1217
+ else:
1218
+ raise TargetMovedError(
1219
+ f"local and remote {self.target_branch} diverged: "
1220
+ f"local={local_target}, remote={remote_target}"
1221
+ )
1222
+ fresh = self._new_journal(base, feature_head)
1223
+ fresh["baseSource"] = base_source
1224
+ fresh["localTargetHead"] = local_target
1225
+ fresh["remoteTargetHead"] = remote_target
1226
+ fresh["primaryReconciliationRequired"] = reconciliation_required
1227
+ fresh["revision"] = int(journal.get("revision") or 0)
1228
+ journal.clear()
1229
+ journal.update(fresh)
1230
+ # Re-mark preflight RUNNING on the fresh journal.
1231
+ step = self._step(journal, "preflight")
1232
+ step["status"] = "RUNNING"
1233
+ step["attempt"] = 1
1234
+ step["startedAt"] = now_iso()
1235
+ refs = journal["protectionRefs"]
1236
+ self.runner.run(
1237
+ self.project_root, "update-ref", refs["base"], base
1238
+ )
1239
+ self.runner.run(
1240
+ self.project_root, "update-ref", refs["head"], feature_head
1241
+ )
1242
+ self._save(journal)
1243
+
1244
+ try:
1245
+ existing = self._load()
1246
+ step = self._step(existing, "preflight")
1247
+ if step["status"] == "DONE":
1248
+ step["reentry"] = "REUSED"
1249
+ return self._save(existing)
1250
+ except FileNotFoundError:
1251
+ pass
1252
+
1253
+ # Acquire the target lock BEFORE writing any state: a rejected
1254
+ # contender must leave the worktree and state files untouched.
1255
+ lock = harness_change.integration_lock_acquire(
1256
+ self.project_root, run_id=self.run_id
1257
+ )
1258
+ if not lock.get("ok"):
1259
+ raise IntegrationLockHeldError(
1260
+ f"integration lock held: {lock.get('holder')}"
1261
+ )
1262
+
1263
+ # Bootstrap: write an initial journal so _run_step has something to load.
1264
+ initial = {
1265
+ "schemaVersion": 1,
1266
+ "transactionId": self.transaction_id,
1267
+ "changeName": self.change_id,
1268
+ "runId": self.run_id,
1269
+ "steps": [
1270
+ {"name": name, "status": "PENDING", "attempt": 0}
1271
+ for name in STEP_ORDER
1272
+ ],
1273
+ }
1274
+ self._save(initial)
1275
+ return self._run_step("preflight", action)
1276
+
1277
+ def prepare(self) -> dict[str, Any]:
1278
+ def action(journal: dict[str, Any]) -> None:
1279
+ # Best-effort fetch; absence of a remote is tolerated.
1280
+ self.runner.run(
1281
+ self.project_root,
1282
+ "fetch",
1283
+ "origin",
1284
+ self.target_branch,
1285
+ check=False,
1286
+ )
1287
+ base = journal["base"]
1288
+ self.runner.run(
1289
+ self.project_root, "branch", "-f", self.temp_branch, base
1290
+ )
1291
+ intg_root = Path(journal["integrationRoot"])
1292
+ intg_root.parent.mkdir(parents=True, exist_ok=True)
1293
+ self.runner.run(
1294
+ self.project_root,
1295
+ "worktree",
1296
+ "add",
1297
+ str(intg_root),
1298
+ self.temp_branch,
1299
+ )
1300
+ # H-5: LF-stable hashing in the integration worktree.
1301
+ self.runner.run(intg_root, "config", "core.autocrlf", "false")
1302
+
1303
+ return self._run_step("prepare", action)
1304
+
1305
+ def _foreign_paths(self, journal: dict[str, Any]) -> list[str]:
1306
+ feature_base = self.runner.text(
1307
+ self.project_root,
1308
+ "merge-base",
1309
+ journal["base"],
1310
+ journal["featureHead"],
1311
+ ) or journal["base"]
1312
+ diff = self.runner.text(
1313
+ self.project_root,
1314
+ "diff",
1315
+ "--name-only",
1316
+ feature_base,
1317
+ journal["featureHead"],
1318
+ )
1319
+ contract: dict[str, Any] = {}
1320
+ contract_path = (
1321
+ self.project_root
1322
+ / ".harness"
1323
+ / "changes"
1324
+ / self.change_id
1325
+ / "meta"
1326
+ / "change-context.json"
1327
+ )
1328
+ if contract_path.is_file():
1329
+ try:
1330
+ contract = json.loads(contract_path.read_text(encoding="utf-8-sig"))
1331
+ except (OSError, json.JSONDecodeError):
1332
+ contract = {}
1333
+ if not contract:
1334
+ shown = self.runner.text(
1335
+ self.project_root,
1336
+ "show",
1337
+ f"{journal['featureHead']}:.harness/changes/{self.change_id}/meta/change-context.json",
1338
+ )
1339
+ if shown:
1340
+ try:
1341
+ contract = json.loads(shown)
1342
+ except json.JSONDecodeError:
1343
+ contract = {}
1344
+ ownership = contract.get("ownership") or {}
1345
+ scoped = bool(
1346
+ ownership.get("productPaths") or ownership.get("staticEvidencePaths")
1347
+ )
1348
+ foreign: list[str] = []
1349
+ for line in (diff or "").splitlines():
1350
+ line = line.strip()
1351
+ if not line:
1352
+ continue
1353
+ if scoped:
1354
+ verdict = hl._classify_ownership_path(line, self.change_id, ownership)
1355
+ if verdict == "foreign":
1356
+ foreign.append(line)
1357
+ continue
1358
+ for pattern in _FOREIGN_PATTERNS:
1359
+ match = pattern.match(line)
1360
+ if match and match.group(1) != self.change_id:
1361
+ foreign.append(line)
1362
+ break
1363
+ return foreign
1364
+
1365
+ def merge(self) -> dict[str, Any]:
1366
+ def action(journal: dict[str, Any]) -> None:
1367
+ current_target = self.runner.text(
1368
+ self.project_root, "rev-parse", self.target_branch
1369
+ )
1370
+ expected_local = journal.get("localTargetHead") or journal["base"]
1371
+ if current_target != expected_local:
1372
+ raise TargetMovedError(
1373
+ f"local {self.target_branch} moved: expected "
1374
+ f"{expected_local}, found {current_target}"
1375
+ )
1376
+ if journal.get("remoteTargetHead"):
1377
+ # Use typed remote probe (retro §5.28): distinguish probe failure
1378
+ # (network/auth/process) from genuine target movement. `None`
1379
+ # must never enter the "found head" field.
1380
+ probe = self.runner.remote_probe(
1381
+ self.project_root, "ls-remote", "origin", self.target_branch
1382
+ )
1383
+ expected_remote = journal.get("remoteTargetHead")
1384
+ if probe["exitCode"] != 0:
1385
+ # Probe failed: network/auth/process error, not target moved.
1386
+ raise RemoteProbeFailedError(
1387
+ f"remote probe failed (exit={probe['exitCode']}, "
1388
+ f"category={probe['category']}): {probe['redactedStderr']}"
1389
+ )
1390
+ current_remote = probe["stdoutHash"]
1391
+ if current_remote is not None and current_remote != expected_remote:
1392
+ raise TargetMovedError(
1393
+ f"remote {self.target_branch} moved: expected "
1394
+ f"{expected_remote}, found {current_remote}"
1395
+ )
1396
+ foreign = self._foreign_paths(journal)
1397
+ if foreign:
1398
+ raise ForeignChangePathsError(
1399
+ "merge diff touches other changes: " + ", ".join(sorted(foreign))
1400
+ )
1401
+ intg_root = Path(journal["integrationRoot"])
1402
+ proc = self.runner.run(
1403
+ intg_root,
1404
+ "merge",
1405
+ "--no-ff",
1406
+ "-m",
1407
+ f"harness: integrate {self.change_id} into {self.target_branch}",
1408
+ self.feature_branch,
1409
+ check=False,
1410
+ )
1411
+ if proc.returncode != 0:
1412
+ raise MergeFailedError(proc.stderr.strip() or "merge conflict")
1413
+ journal["mergeCommit"] = self.runner.text(intg_root, "rev-parse", "HEAD")
1414
+ self._save(journal)
1415
+
1416
+ return self._run_step("merge", action)
1417
+
1418
+ def _required_on_merge_commands(self) -> list[list[str]]:
1419
+ """Load build-profile mergeVerification.requiredOnMerge (Wave-2 H-9)."""
1420
+ import harness_profile
1421
+
1422
+ profile = harness_profile.load_profile(self.project_root)
1423
+ if not isinstance(profile, dict):
1424
+ return []
1425
+ merge = profile.get("mergeVerification")
1426
+ if not isinstance(merge, dict):
1427
+ return []
1428
+ required = merge.get("requiredOnMerge")
1429
+ if not isinstance(required, list):
1430
+ return []
1431
+ commands: list[list[str]] = []
1432
+ for index, item in enumerate(required):
1433
+ if isinstance(item, str) and item.strip():
1434
+ commands.append(_split_verify_command(item.strip()))
1435
+ continue
1436
+ if not isinstance(item, dict):
1437
+ raise ValueError(f"requiredOnMerge[{index}] must be string or object")
1438
+ raw = item.get("command")
1439
+ if isinstance(raw, list) and raw and all(isinstance(x, str) and x for x in raw):
1440
+ commands.append([str(x) for x in raw])
1441
+ elif isinstance(raw, str) and raw.strip():
1442
+ commands.append(_split_verify_command(raw.strip()))
1443
+ else:
1444
+ raise ValueError(f"requiredOnMerge[{index}].command missing")
1445
+ return commands
1446
+
1447
+ def _required_on_merge_plan(self) -> list[dict[str, Any]]:
1448
+ """合并态验证计划:profile 声明优先,缺声明时从既有验证目标推导。
1449
+
1450
+ 此前只读 mergeVerification.requiredOnMerge,没配就是空计划 → VERIFY_PLAN_MISSING。
1451
+ 但 profile 里本来就有 ledger 一直在用的规范命令;让调用方在合并时另行手敲一遍,
1452
+ 换来的通常是一条随手写的探测命令。
1453
+ """
1454
+ import harness_profile
1455
+
1456
+ profile = harness_profile.load_profile(self.project_root)
1457
+ return merge_verification_plan(profile)
1458
+
1459
+ def verify(
1460
+ self,
1461
+ commands: Sequence[Sequence[str]] | None = None,
1462
+ *,
1463
+ timeout_seconds: float = DEFAULT_VERIFY_TIMEOUT_SECONDS,
1464
+ ) -> dict[str, Any]:
1465
+ commands = list(commands or [])
1466
+ try:
1467
+ profile_plan = self._required_on_merge_plan()
1468
+ except Exception as exc: # noqa: BLE001 — surface as verify plan error
1469
+ raise VerifyPlanMissingError(f"mergeVerification load failed: {exc}") from exc
1470
+ plan = [
1471
+ {
1472
+ "command": list(command),
1473
+ "kind": "smoke",
1474
+ "verification": None,
1475
+ "source": "cli",
1476
+ }
1477
+ for command in commands
1478
+ ]
1479
+ plan.extend(profile_plan)
1480
+ commands = _dedupe_verify_commands([item["command"] for item in plan])
1481
+ if timeout_seconds <= 0:
1482
+ raise VerifyPlanMissingError("verification timeout must be greater than zero")
1483
+
1484
+ def action(journal: dict[str, Any]) -> None:
1485
+ if not commands or any(
1486
+ not isinstance(command, (list, tuple)) or not command or not command[0]
1487
+ for command in commands
1488
+ ):
1489
+ raise VerifyPlanMissingError(
1490
+ "integration verification requires at least one executable command"
1491
+ " — " + verify_plan_recovery_action()
1492
+ )
1493
+ # 计划先落盘:verify 失败时也要看得见"打算跑什么",
1494
+ # 而不是只剩一个 FAILED 状态。
1495
+ journal["verifyPlan"] = [
1496
+ {
1497
+ "command": list(item["command"]),
1498
+ "kind": item.get("kind"),
1499
+ "verification": item.get("verification"),
1500
+ "source": item.get("source"),
1501
+ }
1502
+ for item in plan
1503
+ ]
1504
+ journal["verificationDepth"] = verification_depth(plan)
1505
+ self._save(journal)
1506
+ evidence_change = self._bound_evidence_change(journal)
1507
+ intg_root = Path(journal["integrationRoot"]).resolve()
1508
+ results: list[dict[str, Any]] = []
1509
+ merge_tree = self.runner.text(intg_root, "rev-parse", "--verify", "HEAD^{tree}")
1510
+ merge_tree_hash = "sha256:" + merge_tree if merge_tree else None
1511
+ policies = {tuple(item["command"]): item for item in plan}
1512
+ for index, command in enumerate(commands):
1513
+ policy = policies.get(
1514
+ tuple(command), {"kind": "smoke", "verification": None}
1515
+ )
1516
+ reused = (
1517
+ _reusable_merge_evidence(
1518
+ self._ledger_path(evidence_change),
1519
+ verification=policy.get("verification"),
1520
+ command=command,
1521
+ product_tree_hash=merge_tree_hash,
1522
+ reuse_context=policy.get("reuseContext") or {},
1523
+ )
1524
+ if policy.get("kind") == "heavyweight"
1525
+ else None
1526
+ )
1527
+ if reused is not None:
1528
+ results.append(
1529
+ {
1530
+ "command": list(command),
1531
+ "exitCode": 0,
1532
+ "reused": True,
1533
+ "kind": "heavyweight",
1534
+ **reused,
1535
+ }
1536
+ )
1537
+ continue
1538
+ executable_command = _normalize_verify_command(command)
1539
+ started = dt.datetime.now().astimezone()
1540
+ try:
1541
+ proc = subprocess.run(
1542
+ executable_command,
1543
+ cwd=str(intg_root),
1544
+ capture_output=True,
1545
+ text=True,
1546
+ encoding="utf-8",
1547
+ errors="replace",
1548
+ check=False,
1549
+ timeout=timeout_seconds,
1550
+ )
1551
+ except subprocess.TimeoutExpired as exc:
1552
+ finished = dt.datetime.now().astimezone()
1553
+ log_path = _write_verify_log(
1554
+ self.project_root,
1555
+ self.transaction_id,
1556
+ index,
1557
+ command=command,
1558
+ resolved_command=executable_command,
1559
+ started=started,
1560
+ finished=finished,
1561
+ exit_code=None,
1562
+ stdout=exc.stdout,
1563
+ stderr=exc.stderr,
1564
+ error=f"timed out after {timeout_seconds}s",
1565
+ )
1566
+ results.append(
1567
+ {
1568
+ "command": list(command),
1569
+ "resolvedCommand": executable_command,
1570
+ "exitCode": None,
1571
+ "timedOut": True,
1572
+ "timeoutSeconds": timeout_seconds,
1573
+ "startedAt": started.isoformat(timespec="milliseconds"),
1574
+ "finishedAt": finished.isoformat(timespec="milliseconds"),
1575
+ "durationMs": int(
1576
+ (finished - started).total_seconds() * 1000
1577
+ ),
1578
+ "logPath": log_path,
1579
+ }
1580
+ )
1581
+ journal["verifyResults"] = results
1582
+ self._save(journal)
1583
+ raise VerificationFailedError(
1584
+ f"command timed out after {timeout_seconds}s: {' '.join(command)}"
1585
+ )
1586
+ except FileNotFoundError as exc:
1587
+ finished = dt.datetime.now().astimezone()
1588
+ log_path = _write_verify_log(
1589
+ self.project_root,
1590
+ self.transaction_id,
1591
+ index,
1592
+ command=command,
1593
+ resolved_command=executable_command,
1594
+ started=started,
1595
+ finished=finished,
1596
+ exit_code=None,
1597
+ error=str(exc),
1598
+ )
1599
+ results.append(
1600
+ {
1601
+ "command": list(command),
1602
+ "resolvedCommand": executable_command,
1603
+ "exitCode": None,
1604
+ "error": str(exc),
1605
+ "startedAt": started.isoformat(timespec="milliseconds"),
1606
+ "finishedAt": finished.isoformat(timespec="milliseconds"),
1607
+ "durationMs": int(
1608
+ (finished - started).total_seconds() * 1000
1609
+ ),
1610
+ "logPath": log_path,
1611
+ }
1612
+ )
1613
+ journal["verifyResults"] = results
1614
+ self._save(journal)
1615
+ raise VerifyPlanMissingError(
1616
+ f"verification executable not found: {executable_command[0]}"
1617
+ ) from exc
1618
+ finished = dt.datetime.now().astimezone()
1619
+ log_path = _write_verify_log(
1620
+ self.project_root,
1621
+ self.transaction_id,
1622
+ index,
1623
+ command=command,
1624
+ resolved_command=executable_command,
1625
+ started=started,
1626
+ finished=finished,
1627
+ exit_code=proc.returncode,
1628
+ stdout=proc.stdout,
1629
+ stderr=proc.stderr,
1630
+ )
1631
+ results.append(
1632
+ {
1633
+ "command": list(command),
1634
+ "resolvedCommand": executable_command,
1635
+ "exitCode": proc.returncode,
1636
+ "timedOut": False,
1637
+ "startedAt": started.isoformat(timespec="milliseconds"),
1638
+ "finishedAt": finished.isoformat(timespec="milliseconds"),
1639
+ "durationMs": int(
1640
+ (finished - started).total_seconds() * 1000
1641
+ ),
1642
+ "logPath": log_path,
1643
+ }
1644
+ )
1645
+ if proc.returncode != 0:
1646
+ journal["verifyResults"] = results
1647
+ self._save(journal)
1648
+ raise VerificationFailedError(
1649
+ f"command failed ({proc.returncode}): {' '.join(command)}"
1650
+ )
1651
+ journal["verifyResults"] = results
1652
+ journal["verifyCwd"] = str(intg_root)
1653
+ basis = json.dumps(
1654
+ {
1655
+ "mergeCommit": journal.get("mergeCommit"),
1656
+ "results": results,
1657
+ "evidenceIdentity": journal.get("evidenceIdentity"),
1658
+ },
1659
+ ensure_ascii=False,
1660
+ sort_keys=True,
1661
+ )
1662
+ journal["verificationIdentity"] = {
1663
+ "sha256": "sha256:" + hashlib.sha256(basis.encode("utf-8")).hexdigest(),
1664
+ "ledgerIdentity": (journal.get("evidenceIdentity") or {}).get("ledgerIdentity") or {},
1665
+ }
1666
+ self._save(journal)
1667
+
1668
+ return self._run_step("verify", action)
1669
+
1670
+ def push(self) -> dict[str, Any]:
1671
+ def action(journal: dict[str, Any]) -> None:
1672
+ self._bound_evidence_change(journal)
1673
+ verify_step = self._step(journal, "verify")
1674
+ verify_results = journal.get("verifyResults")
1675
+ if (
1676
+ verify_step.get("status") != "DONE"
1677
+ or not isinstance(verify_results, list)
1678
+ or not verify_results
1679
+ or any(
1680
+ not isinstance(item, dict)
1681
+ or item.get("exitCode") != 0
1682
+ or not isinstance(item.get("command"), list)
1683
+ or not item.get("command")
1684
+ for item in verify_results
1685
+ )
1686
+ or not isinstance(journal.get("verificationIdentity"), dict)
1687
+ ):
1688
+ raise VerifyPlanMissingError(
1689
+ "push requires a completed, non-empty verification plan"
1690
+ )
1691
+ remote_head = self.runner.text(
1692
+ self.project_root, "ls-remote", "origin", self.target_branch
1693
+ )
1694
+ remote_head = (remote_head or "").split()[0] if remote_head else ""
1695
+ if not remote_head:
1696
+ raise IntegrationError(
1697
+ f"remote branch not found: origin/{self.target_branch}"
1698
+ )
1699
+ merge_commit = journal.get("mergeCommit")
1700
+ if not merge_commit:
1701
+ raise IntegrationError("merge step not completed")
1702
+ if remote_head == merge_commit:
1703
+ # The previous push may have reached the remote before the local
1704
+ # process could persist pushedHead/step=DONE. Reconcile that
1705
+ # successful outcome instead of misclassifying it as drift.
1706
+ self._finalize_remote_push(
1707
+ journal, merge_commit=merge_commit, remote_head=remote_head
1708
+ )
1709
+ return
1710
+ expected_remote = journal.get("remoteTargetHead") or journal["base"]
1711
+ if remote_head != expected_remote:
1712
+ raise TargetMovedError(
1713
+ f"remote {self.target_branch} moved: expected "
1714
+ f"{expected_remote}, found {remote_head}"
1715
+ )
1716
+ journal["pushIntent"] = {
1717
+ "status": "PENDING",
1718
+ "expectedRemoteHead": expected_remote,
1719
+ "mergeCommit": merge_commit,
1720
+ "updatedAt": now_iso(),
1721
+ }
1722
+ self._save(journal)
1723
+ self.runner.run(
1724
+ self.project_root,
1725
+ "push",
1726
+ "origin",
1727
+ f"{merge_commit}:refs/heads/{self.target_branch}",
1728
+ )
1729
+ remote_after_line = self.runner.text(
1730
+ self.project_root, "ls-remote", "origin", self.target_branch
1731
+ )
1732
+ remote_after = (
1733
+ remote_after_line.split()[0]
1734
+ if remote_after_line and remote_after_line.split()
1735
+ else ""
1736
+ )
1737
+ if remote_after != merge_commit:
1738
+ journal = self._load()
1739
+ journal["pushIntent"] = {
1740
+ "status": "REMOTE_UNCONFIRMED",
1741
+ "expectedRemoteHead": expected_remote,
1742
+ "mergeCommit": merge_commit,
1743
+ "observedRemoteHead": remote_after,
1744
+ "updatedAt": now_iso(),
1745
+ }
1746
+ self._save(journal)
1747
+ raise IntegrationError(
1748
+ "push returned but the remote target does not equal the merge commit"
1749
+ )
1750
+ journal = self._load()
1751
+ self._finalize_remote_push(
1752
+ journal, merge_commit=merge_commit, remote_head=remote_after
1753
+ )
1754
+
1755
+ return self._run_step("push", action)
1756
+
1757
+ # ------------------------------------------------------------ cleanup
1758
+
1759
+ def cleanup_target(self, target: Path) -> dict[str, Any]:
1760
+ """Boundary-checked removal of this transaction's exact worktree."""
1761
+ resolved = harness_paths.assert_path_within(target, self.temp_root)
1762
+ try:
1763
+ journal = self._load()
1764
+ except FileNotFoundError as exc:
1765
+ raise CleanupRefusedError(
1766
+ f"no journal for transaction {self.transaction_id}; "
1767
+ f"refusing cleanup of {resolved}"
1768
+ ) from exc
1769
+ allowed = journal.get("allowedCleanupRoot")
1770
+ if not allowed or resolved != Path(allowed).resolve():
1771
+ raise CleanupRefusedError(
1772
+ f"refusing to clean non-transaction path: {resolved}"
1773
+ )
1774
+ listed_before = self.runner.text(
1775
+ self.project_root, "worktree", "list", "--porcelain"
1776
+ )
1777
+ registered_before = {
1778
+ str(Path(line.removeprefix("worktree ")).resolve())
1779
+ for line in (listed_before or "").splitlines()
1780
+ if line.startswith("worktree ")
1781
+ }
1782
+ if resolved.exists() and str(resolved) not in registered_before:
1783
+ raise CleanupRefusedError(
1784
+ f"refusing to clean unregistered worktree path: {resolved}"
1785
+ )
1786
+ lifecycle = evaluate_worktree_retirement(
1787
+ self.project_root,
1788
+ resolved,
1789
+ agent_root=(
1790
+ Path(os.environ["HUNTER_HARNESS_AGENT_ROOT"])
1791
+ if os.environ.get("HUNTER_HARNESS_AGENT_ROOT")
1792
+ else None
1793
+ ),
1794
+ )
1795
+ if not lifecycle.get("ok"):
1796
+ codes = ", ".join(
1797
+ str(item.get("code") or "UNKNOWN")
1798
+ for item in lifecycle.get("blockers") or []
1799
+ )
1800
+ raise CleanupRefusedError(
1801
+ f"worktree lifecycle blocks cleanup: {codes}"
1802
+ )
1803
+
1804
+ heavy_removed: list[str] = []
1805
+ heavy_failures: list[dict[str, str]] = []
1806
+ if resolved.is_dir():
1807
+ for name in HEAVY_WORKTREE_ROOTS:
1808
+ heavy = resolved / name
1809
+ if not heavy.exists():
1810
+ continue
1811
+ harness_paths.assert_path_within(heavy, resolved)
1812
+ try:
1813
+ if heavy.is_dir() and not heavy.is_symlink():
1814
+ shutil.rmtree(heavy)
1815
+ else:
1816
+ heavy.unlink()
1817
+ heavy_removed.append(name)
1818
+ except OSError as exc:
1819
+ heavy_failures.append({"path": name, "error": str(exc)})
1820
+ remove_result = self.runner.run(
1821
+ self.project_root, "worktree", "remove", "--force", str(resolved),
1822
+ check=False,
1823
+ )
1824
+ remove_returncode = getattr(remove_result, "returncode", 0)
1825
+ listed = self.runner.text(self.project_root, "worktree", "list", "--porcelain")
1826
+ registered_after = {
1827
+ str(Path(line.removeprefix("worktree ")).resolve())
1828
+ for line in (listed or "").splitlines()
1829
+ if line.startswith("worktree ")
1830
+ }
1831
+ still_registered = str(resolved) in registered_after
1832
+ residual_paths: list[str] = []
1833
+ disk_path_present = resolved.exists()
1834
+ if disk_path_present:
1835
+ if resolved.is_file():
1836
+ residual_paths.append(resolved.name)
1837
+ else:
1838
+ for root, dirs, files in os.walk(resolved):
1839
+ rel_root = Path(root).relative_to(resolved)
1840
+ for name in sorted(dirs + files):
1841
+ rel = (rel_root / name).as_posix()
1842
+ if rel not in residual_paths:
1843
+ residual_paths.append(rel)
1844
+ if len(residual_paths) >= 200:
1845
+ break
1846
+ if len(residual_paths) >= 200:
1847
+ break
1848
+ # §5.30: Windows half-success — git worktree remove returned non-zero
1849
+ # but registration is already deleted and disk path remains. Return a
1850
+ # structured status distinct from generic CLEANUP_RESIDUAL so callers
1851
+ # can apply the allowlisted residual cleaner.
1852
+ if (
1853
+ remove_returncode != 0
1854
+ and not still_registered
1855
+ and disk_path_present
1856
+ and residual_paths
1857
+ and not heavy_failures
1858
+ ):
1859
+ return {
1860
+ "ok": False,
1861
+ "code": "REGISTRATION_REMOVED_RESIDUAL_PRESENT",
1862
+ "target": str(resolved),
1863
+ "heavyRootsRemoved": heavy_removed,
1864
+ "heavyRootFailures": heavy_failures,
1865
+ "worktreeRegistered": False,
1866
+ "diskPathPresent": True,
1867
+ "residualPaths": residual_paths,
1868
+ }
1869
+ ok = not heavy_failures and not still_registered and not residual_paths
1870
+ return {
1871
+ "ok": ok,
1872
+ "code": "CLEANUP_COMPLETE" if ok else "CLEANUP_RESIDUAL",
1873
+ "target": str(resolved),
1874
+ "heavyRootsRemoved": heavy_removed,
1875
+ "heavyRootFailures": heavy_failures,
1876
+ "worktreeRegistered": still_registered,
1877
+ "diskPathPresent": disk_path_present,
1878
+ "residualPaths": residual_paths,
1879
+ }
1880
+
1881
+ def _persist_cleanup_snapshot(
1882
+ self, journal: dict[str, Any], execution_root: Path
1883
+ ) -> dict[str, Any]:
1884
+ """Persist all evidence needed to close cleanup after the root is gone."""
1885
+ snapshot = {
1886
+ "schemaVersion": 1,
1887
+ "createdAt": now_iso(),
1888
+ "changeId": self.change_id,
1889
+ "attemptId": self.run_id,
1890
+ "worktreeRoot": str(execution_root.resolve()),
1891
+ "executionRoot": str(execution_root.resolve()),
1892
+ "mergeEvidence": {
1893
+ key: journal.get(key)
1894
+ for key in (
1895
+ "mergeCommit",
1896
+ "pushedHead",
1897
+ "mergeFinalHash",
1898
+ "ciExpectedHead",
1899
+ "remoteHead",
1900
+ )
1901
+ },
1902
+ "evidenceIdentity": journal.get("evidenceIdentity"),
1903
+ }
1904
+ journal["cleanupSnapshot"] = snapshot
1905
+ self._save(journal)
1906
+ return snapshot
1907
+
1908
+ def _stop_owned_services(
1909
+ self, journal: dict[str, Any], execution_root: Path
1910
+ ) -> dict[str, Any]:
1911
+ """Stop only sessions explicitly owned by this transaction worktree."""
1912
+ result = harness_service.stop_owned_sessions(
1913
+ self.project_root,
1914
+ change_id=self.change_id,
1915
+ execution_root=execution_root,
1916
+ worktree_root=execution_root,
1917
+ )
1918
+ journal["serviceStop"] = result
1919
+ self._save(journal)
1920
+ if not result["ok"]:
1921
+ raise CleanupRefusedError(
1922
+ "owned service could not be identity-verified and stopped"
1923
+ )
1924
+ return result
1925
+
1926
+ def cleanup(self) -> dict[str, Any]:
1927
+ try:
1928
+ existing = self._load()
1929
+ step = self._step(existing, "cleanup")
1930
+ if step["status"] == "DONE":
1931
+ step["reentry"] = "REUSED"
1932
+ return self._save(existing)
1933
+ except FileNotFoundError:
1934
+ raise IntegrationError("cannot cleanup before preflight")
1935
+
1936
+ def action(journal: dict[str, Any]) -> None:
1937
+ self._assert_cleanup_consistency(journal)
1938
+ intg_root = Path(journal["integrationRoot"])
1939
+ self._persist_cleanup_snapshot(journal, intg_root)
1940
+ service_stop = self._stop_owned_services(journal, intg_root)
1941
+ cleanup_result: dict[str, Any] = {
1942
+ "ok": True,
1943
+ "code": "ALREADY_ABSENT",
1944
+ "target": str(intg_root.resolve()),
1945
+ "heavyRootsRemoved": [],
1946
+ "heavyRootFailures": [],
1947
+ "worktreeRegistered": False,
1948
+ "residualPaths": [],
1949
+ }
1950
+ if intg_root.exists():
1951
+ cleanup_result = self.cleanup_target(intg_root)
1952
+ journal["cleanupResult"] = cleanup_result
1953
+ self._save(journal)
1954
+ if not cleanup_result["ok"]:
1955
+ raise CleanupResidualError(cleanup_result)
1956
+ self.runner.run(
1957
+ self.project_root, "branch", "-D", self.temp_branch, check=False
1958
+ )
1959
+ if self._step(journal, "push")["status"] == "DONE":
1960
+ refs = journal["protectionRefs"]
1961
+ for ref in (refs["base"], refs["head"]):
1962
+ self.runner.run(
1963
+ self.project_root, "update-ref", "-d", ref, check=False
1964
+ )
1965
+ worktrees = self.runner.text(
1966
+ self.project_root, "worktree", "list", "--porcelain"
1967
+ ) or ""
1968
+ feature_ref = f"refs/heads/{self.feature_branch}"
1969
+ feature_branch_retained = self.runner.run(
1970
+ self.project_root,
1971
+ "show-ref",
1972
+ "--verify",
1973
+ feature_ref,
1974
+ check=False,
1975
+ ).returncode == 0
1976
+ local_target = self.runner.text(
1977
+ self.project_root, "rev-parse", self.target_branch
1978
+ )
1979
+ remote_line = self.runner.text(
1980
+ self.project_root, "ls-remote", "origin", self.target_branch
1981
+ )
1982
+ remote_target = (
1983
+ remote_line.split()[0] if remote_line and remote_line.split() else None
1984
+ )
1985
+ journal["cleanupSummary"] = {
1986
+ "transactionArtifactsRemoved": {
1987
+ "integrationWorktree": not intg_root.exists(),
1988
+ "temporaryBranch": self.runner.run(
1989
+ self.project_root,
1990
+ "show-ref",
1991
+ "--verify",
1992
+ f"refs/heads/{self.temp_branch}",
1993
+ check=False,
1994
+ ).returncode != 0,
1995
+ "protectionRefs": self._step(journal, "push")["status"] == "DONE",
1996
+ "heavyRoots": cleanup_result.get("heavyRootsRemoved") or [],
1997
+ },
1998
+ "sourceWorktreeRetained": f"branch {feature_ref}" in worktrees,
1999
+ "featureBranchRetained": feature_branch_retained,
2000
+ "primaryWorktreeUpdated": bool(
2001
+ local_target and remote_target and local_target == remote_target
2002
+ ),
2003
+ "primaryHead": local_target,
2004
+ "remoteHead": remote_target,
2005
+ "residualPaths": cleanup_result.get("residualPaths") or [],
2006
+ "serviceStop": service_stop,
2007
+ }
2008
+ journal["cleanupReceipt"] = {
2009
+ "schemaVersion": 1,
2010
+ "completedAt": now_iso(),
2011
+ "snapshot": journal.get("cleanupSnapshot"),
2012
+ "worktreeRemoval": cleanup_result,
2013
+ "serviceStop": service_stop,
2014
+ "worktreeGone": not intg_root.exists(),
2015
+ }
2016
+ self._save(journal)
2017
+
2018
+ journal = self._run_step("cleanup", action)
2019
+ harness_change.integration_lock_release(self.project_root, run_id=self.run_id)
2020
+ return journal
2021
+
2022
+ # ------------------------------------------------------------ abandon
2023
+
2024
+ def _remote_contains_merge(self, journal: dict[str, Any]) -> bool:
2025
+ merge_commit = str(journal.get("mergeCommit") or "")
2026
+ if not merge_commit:
2027
+ return False
2028
+ remote_line = self.runner.text(
2029
+ self.project_root, "ls-remote", "origin", self.target_branch
2030
+ )
2031
+ observed_remote = (
2032
+ remote_line.split()[0] if remote_line and remote_line.split() else ""
2033
+ )
2034
+ return bool(observed_remote and observed_remote == merge_commit)
2035
+
2036
+ def _force_remove_integration_root(
2037
+ self, journal: dict[str, Any], intg_root: Path
2038
+ ) -> dict[str, Any]:
2039
+ """Best-effort remove of the integration worktree for abandon only."""
2040
+ allowed = Path(str(journal.get("allowedCleanupRoot") or "")).resolve()
2041
+ resolved = harness_paths.assert_path_within(intg_root, self.temp_root)
2042
+ if not allowed or resolved != allowed:
2043
+ raise CleanupRefusedError(
2044
+ f"refusing to abandon non-transaction path: {resolved}"
2045
+ )
2046
+ self.runner.run(
2047
+ self.project_root,
2048
+ "worktree",
2049
+ "remove",
2050
+ "--force",
2051
+ str(resolved),
2052
+ check=False,
2053
+ )
2054
+ residual: list[str] = []
2055
+ if resolved.exists():
2056
+ for name in HEAVY_WORKTREE_ROOTS:
2057
+ heavy = resolved / name
2058
+ if heavy.exists():
2059
+ harness_paths.assert_path_within(heavy, resolved)
2060
+ try:
2061
+ if heavy.is_dir() and not heavy.is_symlink():
2062
+ shutil.rmtree(heavy)
2063
+ else:
2064
+ heavy.unlink()
2065
+ except OSError:
2066
+ residual.append(name)
2067
+ if resolved.exists():
2068
+ try:
2069
+ shutil.rmtree(resolved)
2070
+ except OSError:
2071
+ residual.append(str(resolved))
2072
+ listed = self.runner.text(self.project_root, "worktree", "list", "--porcelain")
2073
+ still_registered = str(resolved) in {
2074
+ str(Path(line.removeprefix("worktree ")).resolve())
2075
+ for line in (listed or "").splitlines()
2076
+ if line.startswith("worktree ")
2077
+ }
2078
+ ok = not still_registered and not residual and not resolved.exists()
2079
+ return {
2080
+ "ok": ok,
2081
+ "code": "CLEANUP_COMPLETE" if ok else "CLEANUP_RESIDUAL",
2082
+ "target": str(resolved),
2083
+ "worktreeRegistered": still_registered,
2084
+ "diskPathPresent": resolved.exists(),
2085
+ "residualPaths": residual,
2086
+ }
2087
+
2088
+ def abandon(self) -> dict[str, Any]:
2089
+ """Official recovery for a failed integration transaction (H-6).
2090
+
2091
+ Allowed only when push is not DONE and the remote does not already
2092
+ contain ``mergeCommit``. Removes the integration worktree, temp branch,
2093
+ protection refs, and integration lock. Never deletes the feature
2094
+ worktree or feature branch.
2095
+ """
2096
+ try:
2097
+ journal = self._load()
2098
+ except FileNotFoundError as exc:
2099
+ raise AbandonRefusedError(
2100
+ f"no journal for transaction {self.transaction_id}"
2101
+ ) from exc
2102
+
2103
+ push_status = str(self._step(journal, "push").get("status") or "")
2104
+ push_intent = journal.get("pushIntent") or {}
2105
+ push_confirmed = (
2106
+ push_status == "DONE"
2107
+ or (
2108
+ isinstance(push_intent, dict)
2109
+ and push_intent.get("status") == "REMOTE_CONFIRMED"
2110
+ )
2111
+ or bool(journal.get("pushedHead"))
2112
+ or bool(journal.get("mergeFinalHash"))
2113
+ )
2114
+ remote_has_merge = self._remote_contains_merge(journal)
2115
+ if push_confirmed or remote_has_merge:
2116
+ raise AbandonRefusedError(
2117
+ "refuse abandon: push succeeded or remote already contains "
2118
+ "the merge commit; use cleanup instead"
2119
+ )
2120
+
2121
+ intg_root = Path(str(journal.get("integrationRoot") or ""))
2122
+ cleanup_result: dict[str, Any] = {
2123
+ "ok": True,
2124
+ "code": "ALREADY_ABSENT",
2125
+ "target": str(intg_root) if intg_root else "",
2126
+ "worktreeRegistered": False,
2127
+ "diskPathPresent": False,
2128
+ "residualPaths": [],
2129
+ }
2130
+ prepare_done = self._step(journal, "prepare").get("status") == "DONE"
2131
+ if prepare_done and str(journal.get("integrationRoot") or "").strip():
2132
+ try:
2133
+ cleanup_result = self.cleanup_target(intg_root)
2134
+ except CleanupRefusedError:
2135
+ cleanup_result = self._force_remove_integration_root(journal, intg_root)
2136
+
2137
+ self.runner.run(
2138
+ self.project_root, "branch", "-D", self.temp_branch, check=False
2139
+ )
2140
+ refs = journal.get("protectionRefs") or {}
2141
+ if isinstance(refs, dict):
2142
+ for ref in (refs.get("base"), refs.get("head")):
2143
+ if ref:
2144
+ self.runner.run(
2145
+ self.project_root, "update-ref", "-d", str(ref), check=False
2146
+ )
2147
+
2148
+ harness_change.integration_lock_release(self.project_root, run_id=self.run_id)
2149
+
2150
+ journal = self._load()
2151
+ journal["abandonedAt"] = now_iso()
2152
+ journal["abandonResult"] = cleanup_result
2153
+ self._save(journal)
2154
+
2155
+ return {
2156
+ "ok": True,
2157
+ "code": "ABANDON_COMPLETE",
2158
+ "transactionId": self.transaction_id,
2159
+ "cleanup": cleanup_result,
2160
+ "featureBranchRetained": True,
2161
+ "featureWorktreeRetained": True,
2162
+ }
2163
+
2164
+ # ------------------------------------------------------------ recover
2165
+
2166
+ def _rebind_evidence_change_for_recovery(
2167
+ self,
2168
+ journal: dict[str, Any],
2169
+ evidence_change_id: str,
2170
+ ) -> dict[str, Any]:
2171
+ self._change_roots(evidence_change_id)
2172
+ current_change = self._bound_evidence_change(journal)
2173
+ if current_change == evidence_change_id:
2174
+ return journal
2175
+ if current_change != self.change_id:
2176
+ raise EvidenceRebindRefusedError(
2177
+ "transaction evidence is already bound to another Change"
2178
+ )
2179
+
2180
+ push_step = self._step(journal, "push")
2181
+ ledger_sync = journal.get("ledgerSync") or {}
2182
+ ledger_result = ledger_sync.get("result") or {}
2183
+ merge_commit = str(journal.get("mergeCommit") or "")
2184
+ push_intent = journal.get("pushIntent") or {}
2185
+ if (
2186
+ push_step.get("status") != "FAILED"
2187
+ or ledger_sync.get("status") != "FAILED"
2188
+ or ledger_result.get("code") != "LEDGER_MISSING"
2189
+ or push_intent.get("status") != "REMOTE_CONFIRMED"
2190
+ or push_intent.get("mergeCommit") != merge_commit
2191
+ or push_intent.get("observedRemoteHead") != merge_commit
2192
+ ):
2193
+ raise EvidenceRebindRefusedError(
2194
+ "evidence rebind requires a remote-confirmed push blocked only by "
2195
+ "the default LEDGER_MISSING result"
2196
+ )
2197
+
2198
+ probe = self.runner.remote_probe(
2199
+ self.project_root,
2200
+ "ls-remote",
2201
+ "origin",
2202
+ self.target_branch,
2203
+ )
2204
+ if probe["exitCode"] != 0:
2205
+ raise RemoteProbeFailedError(
2206
+ "cannot confirm the remote target before evidence rebind: "
2207
+ f"{probe['category']}: {probe['redactedStderr']}"
2208
+ )
2209
+ if not merge_commit or probe["stdoutHash"] != merge_commit:
2210
+ raise EvidenceRebindRefusedError(
2211
+ "remote target no longer equals the transaction merge commit"
2212
+ )
2213
+
2214
+ ledger_path = self._ledger_path(evidence_change_id)
2215
+ if ledger_path is None:
2216
+ raise EvidenceRebindRefusedError(
2217
+ "requested evidence Change has no verification ledger"
2218
+ )
2219
+ try:
2220
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
2221
+ except (OSError, json.JSONDecodeError) as exc:
2222
+ raise EvidenceRebindRefusedError(
2223
+ f"requested evidence ledger is unreadable: {exc}"
2224
+ ) from exc
2225
+ missing = hl.validate_ledger_identity(ledger)
2226
+ if missing:
2227
+ raise EvidenceRebindRefusedError(
2228
+ "requested evidence ledger identity is invalid: "
2229
+ + ", ".join(missing)
2230
+ )
2231
+ if ledger.get("changeName") != evidence_change_id:
2232
+ raise EvidenceRebindRefusedError(
2233
+ "requested evidence ledger Change identity does not match"
2234
+ )
2235
+ if ledger.get("repositoryId") != journal.get("repositoryId"):
2236
+ raise EvidenceRebindRefusedError(
2237
+ "requested evidence ledger repository identity does not match"
2238
+ )
2239
+
2240
+ expected_handoff = str(
2241
+ journal.get("remoteTargetHead") or journal.get("base") or ""
2242
+ )
2243
+ handoff_values = [
2244
+ ledger.get("mergeFinalHash"),
2245
+ ledger.get("ciExpectedHead"),
2246
+ ledger.get("remoteHead"),
2247
+ ]
2248
+ handoff_valid = all(
2249
+ isinstance(value, str)
2250
+ and re.fullmatch(r"[0-9a-f]{40}", value) is not None
2251
+ for value in handoff_values
2252
+ )
2253
+ handoff_hashes = set(handoff_values) if handoff_valid else set()
2254
+ if (
2255
+ not handoff_valid
2256
+ or len(handoff_hashes) != 1
2257
+ or next(iter(handoff_hashes), None)
2258
+ not in {expected_handoff, merge_commit}
2259
+ ):
2260
+ raise EvidenceRebindRefusedError(
2261
+ "requested evidence ledger has no matching prior integration handoff"
2262
+ )
2263
+
2264
+ current_head = str(ledger.get("currentHead") or "")
2265
+ feature_head = str(journal.get("featureHead") or "")
2266
+ current_in_feature = self.runner.run(
2267
+ self.project_root,
2268
+ "merge-base",
2269
+ "--is-ancestor",
2270
+ current_head,
2271
+ feature_head,
2272
+ check=False,
2273
+ ).returncode == 0
2274
+ feature_in_merge = self.runner.run(
2275
+ self.project_root,
2276
+ "merge-base",
2277
+ "--is-ancestor",
2278
+ feature_head,
2279
+ merge_commit,
2280
+ check=False,
2281
+ ).returncode == 0
2282
+ if not current_in_feature or not feature_in_merge:
2283
+ raise EvidenceRebindRefusedError(
2284
+ "requested evidence ledger head is not in the integrated feature lineage"
2285
+ )
2286
+
2287
+ previous_identity = journal.get("evidenceIdentity")
2288
+ previous_verification_identity = journal.get("verificationIdentity")
2289
+ target_identity = self._evidence_identity(evidence_change_id)
2290
+ journal["evidenceChangeName"] = evidence_change_id
2291
+ journal["evidenceIdentity"] = target_identity
2292
+ verification_basis = json.dumps(
2293
+ {
2294
+ "mergeCommit": journal.get("mergeCommit"),
2295
+ "results": journal.get("verifyResults"),
2296
+ "evidenceIdentity": target_identity,
2297
+ },
2298
+ ensure_ascii=False,
2299
+ sort_keys=True,
2300
+ )
2301
+ journal["verificationIdentity"] = {
2302
+ "sha256": (
2303
+ "sha256:"
2304
+ + hashlib.sha256(verification_basis.encode("utf-8")).hexdigest()
2305
+ ),
2306
+ "ledgerIdentity": target_identity.get("ledgerIdentity") or {},
2307
+ }
2308
+ journal["evidenceRebind"] = {
2309
+ "status": "DONE",
2310
+ "from": current_change,
2311
+ "to": evidence_change_id,
2312
+ "reason": "REMOTE_CONFIRMED_DEFAULT_LEDGER_MISSING",
2313
+ "remoteHead": merge_commit,
2314
+ "handoffHash": next(iter(handoff_hashes)),
2315
+ "previousEvidenceIdentity": previous_identity,
2316
+ "previousVerificationIdentity": previous_verification_identity,
2317
+ "targetLedgerIdentity": target_identity.get("ledgerIdentity") or {},
2318
+ "updatedAt": now_iso(),
2319
+ }
2320
+ return self._save(journal)
2321
+
2322
+ def recover(
2323
+ self,
2324
+ verify_commands: Sequence[Sequence[str]] | None = None,
2325
+ *,
2326
+ evidence_change_id: str | None = None,
2327
+ ) -> dict[str, Any]:
2328
+ journal = self._load()
2329
+ if evidence_change_id is not None:
2330
+ journal = self._rebind_evidence_change_for_recovery(
2331
+ journal,
2332
+ evidence_change_id,
2333
+ )
2334
+ actions: dict[str, Callable[[], dict[str, Any]]] = {
2335
+ "preflight": self.preflight,
2336
+ "prepare": self.prepare,
2337
+ "merge": self.merge,
2338
+ "verify": lambda: self.verify(commands=verify_commands),
2339
+ "push": self.push,
2340
+ "cleanup": self.cleanup,
2341
+ }
2342
+ for name in STEP_ORDER:
2343
+ step = self._step(journal, name)
2344
+ if step["status"] == "DONE":
2345
+ step["reentry"] = "REUSED"
2346
+ self._save(journal)
2347
+ continue
2348
+ journal = actions[name]()
2349
+ return self._load()
2350
+
2351
+ def status(self) -> dict[str, Any]:
2352
+ journal = self._load()
2353
+ return {
2354
+ "ok": True,
2355
+ "transactionId": self.transaction_id,
2356
+ "steps": journal["steps"],
2357
+ "mergeCommit": journal.get("mergeCommit"),
2358
+ "pushedHead": journal.get("pushedHead"),
2359
+ # "verify = DONE" 单独一句什么也没说明。把计划、来源和退出码一起回显,
2360
+ # 调用方才看得出这次合并到底验证了什么。
2361
+ "verifyPlan": journal.get("verifyPlan") or [],
2362
+ "verificationDepth": journal.get("verificationDepth"),
2363
+ "verifyResults": [
2364
+ {
2365
+ "command": item.get("command"),
2366
+ "exitCode": item.get("exitCode"),
2367
+ "reused": bool(item.get("reused")),
2368
+ "durationMs": item.get("durationMs"),
2369
+ }
2370
+ for item in (journal.get("verifyResults") or [])
2371
+ if isinstance(item, dict)
2372
+ ],
2373
+ }
2374
+
2375
+
2376
+ # ------------------------------------------------------------------- CLI
2377
+
2378
+
2379
+ def _txn_from_args(args: argparse.Namespace) -> IntegrationTransaction:
2380
+ project_root = Path.cwd()
2381
+ return IntegrationTransaction(
2382
+ project_root=project_root,
2383
+ change_id=args.change,
2384
+ run_id=args.run_id,
2385
+ target_branch=args.target_branch,
2386
+ feature_branch=args.feature_branch,
2387
+ temp_root=(
2388
+ Path(args.temp_root)
2389
+ if getattr(args, "temp_root", None)
2390
+ else default_temp_root(project_root)
2391
+ ),
2392
+ )
2393
+
2394
+
2395
+ def _emit(payload: Any) -> int:
2396
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
2397
+ return 0
2398
+
2399
+
2400
+ def cmd_preflight(args: argparse.Namespace) -> int:
2401
+ return _emit(_txn_from_args(args).preflight())
2402
+
2403
+
2404
+ def cmd_prepare(args: argparse.Namespace) -> int:
2405
+ return _emit(_txn_from_args(args).prepare())
2406
+
2407
+
2408
+ def cmd_merge(args: argparse.Namespace) -> int:
2409
+ return _emit(_txn_from_args(args).merge())
2410
+
2411
+
2412
+ def _parse_verify_commands(values: Sequence[str] | None) -> list[list[str]]:
2413
+ """Split ``--command`` strings honoring quotes (shlex, POSIX mode).
2414
+
2415
+ Plain ``str.split(" ")`` breaks quoted arguments (e.g. ``-k 'a b'``).
2416
+ POSIX shlex semantics: single/double quotes group tokens, backslash
2417
+ escapes the next character (quote Windows paths).
2418
+ """
2419
+ return [_split_verify_command(value) for value in (values or [])]
2420
+
2421
+
2422
+ def cmd_verify(args: argparse.Namespace) -> int:
2423
+ return _emit(
2424
+ _txn_from_args(args).verify(
2425
+ commands=_parse_verify_commands(args.command),
2426
+ timeout_seconds=float(args.timeout_seconds),
2427
+ )
2428
+ )
2429
+
2430
+
2431
+ def cmd_push(args: argparse.Namespace) -> int:
2432
+ return _emit(_txn_from_args(args).push())
2433
+
2434
+
2435
+ def cmd_cleanup(args: argparse.Namespace) -> int:
2436
+ return _emit(_txn_from_args(args).cleanup())
2437
+
2438
+
2439
+ def cmd_abandon(args: argparse.Namespace) -> int:
2440
+ return _emit(_txn_from_args(args).abandon())
2441
+
2442
+
2443
+ def cmd_recover(args: argparse.Namespace) -> int:
2444
+ return _emit(
2445
+ _txn_from_args(args).recover(
2446
+ evidence_change_id=getattr(args, "evidence_change", None)
2447
+ )
2448
+ )
2449
+
2450
+
2451
+ def cmd_status(args: argparse.Namespace) -> int:
2452
+ return _emit(_txn_from_args(args).status())
2453
+
2454
+
2455
+ def cmd_journal(args: argparse.Namespace) -> int:
2456
+ """C5: journal 子命令 — 默认 compact (transactionId/currentStep/status),--verbose 全量。"""
2457
+ txn = _txn_from_args(args)
2458
+ full = txn.status()
2459
+ if getattr(args, "verbose", False):
2460
+ return _emit(full)
2461
+ # Derive currentStep: first non-DONE step, or last step name if all DONE.
2462
+ current_step = None
2463
+ for step in full.get("steps", []):
2464
+ if step.get("status") != "DONE":
2465
+ current_step = step.get("name")
2466
+ break
2467
+ if current_step is None and full.get("steps"):
2468
+ current_step = full["steps"][-1].get("name")
2469
+ overall_status = "DONE" if all(
2470
+ step.get("status") == "DONE" for step in full.get("steps", [])
2471
+ ) else "IN_PROGRESS"
2472
+ compact = {
2473
+ "transactionId": full.get("transactionId"),
2474
+ "currentStep": current_step,
2475
+ "status": overall_status,
2476
+ }
2477
+ return _emit(compact)
2478
+
2479
+
2480
+ def build_parser() -> argparse.ArgumentParser:
2481
+ parser = argparse.ArgumentParser(prog="harness_integration.py")
2482
+ sub = parser.add_subparsers(dest="command_name", required=True)
2483
+
2484
+ def add(
2485
+ name: str,
2486
+ func: Callable[[argparse.Namespace], int],
2487
+ ) -> argparse.ArgumentParser:
2488
+ p = sub.add_parser(name)
2489
+ p.add_argument("--change", required=True)
2490
+ p.add_argument("--run-id", required=True)
2491
+ p.add_argument("--target-branch", default="main")
2492
+ p.add_argument("--feature-branch", required=True)
2493
+ p.add_argument("--temp-root")
2494
+ p.set_defaults(func=func)
2495
+ return p
2496
+
2497
+ add("preflight", cmd_preflight)
2498
+ add("prepare", cmd_prepare)
2499
+ add("merge", cmd_merge)
2500
+ add("push", cmd_push)
2501
+ add("cleanup", cmd_cleanup)
2502
+ add("abandon", cmd_abandon)
2503
+ p_recover = add("recover", cmd_recover)
2504
+ p_recover.add_argument(
2505
+ "--evidence-change",
2506
+ help=(
2507
+ "one-time recovery binding after a remote-confirmed "
2508
+ "default LEDGER_MISSING failure"
2509
+ ),
2510
+ )
2511
+ add("status", cmd_status)
2512
+
2513
+ p_journal = sub.add_parser("journal")
2514
+ p_journal.add_argument("--change", required=True)
2515
+ p_journal.add_argument("--run-id", required=True)
2516
+ p_journal.add_argument("--target-branch", default="main")
2517
+ p_journal.add_argument("--feature-branch", required=True)
2518
+ p_journal.add_argument("--temp-root")
2519
+ p_journal.add_argument(
2520
+ "--verbose",
2521
+ action="store_true",
2522
+ help="emit full journal payload (default: compact transactionId/currentStep/status)",
2523
+ )
2524
+ p_journal.set_defaults(func=cmd_journal)
2525
+
2526
+ p_verify = sub.add_parser("verify")
2527
+ p_verify.add_argument("--change", required=True)
2528
+ p_verify.add_argument("--run-id", required=True)
2529
+ p_verify.add_argument("--target-branch", default="main")
2530
+ p_verify.add_argument("--feature-branch", required=True)
2531
+ p_verify.add_argument("--temp-root")
2532
+ p_verify.add_argument("--command", action="append")
2533
+ p_verify.add_argument(
2534
+ "--timeout-seconds",
2535
+ type=float,
2536
+ default=DEFAULT_VERIFY_TIMEOUT_SECONDS,
2537
+ )
2538
+ p_verify.set_defaults(func=cmd_verify)
2539
+
2540
+ return parser
2541
+
2542
+
2543
+ def main(argv: list[str] | None = None) -> int:
2544
+ parser = build_parser()
2545
+ args = parser.parse_args(argv)
2546
+ try:
2547
+ return int(args.func(args))
2548
+ except IntegrationError as exc:
2549
+ extra = getattr(exc, "result", None)
2550
+ payload = {"ok": False, "code": exc.code, "message": str(exc)}
2551
+ if isinstance(extra, dict):
2552
+ payload["cleanup"] = extra
2553
+ sys.stderr.write(
2554
+ json.dumps(
2555
+ payload,
2556
+ ensure_ascii=False,
2557
+ )
2558
+ + "\n"
2559
+ )
2560
+ return 1
2561
+
2562
+
2563
+ if __name__ == "__main__":
2564
+ raise SystemExit(main())