@hunter-harness/workflow-harness 0.2.11 → 0.2.13

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 (250) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/ci-metrics.schema.json +36 -0
  3. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +30 -0
  4. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -4
  9. package/harness/bundles/general/claude-code/harness-plan/checklist.md +6 -0
  10. package/harness/bundles/general/claude-code/harness-plan/reference.md +7 -2
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/checklist.md +3 -2
  14. package/harness/bundles/general/claude-code/harness-run/reference.md +6 -4
  15. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +2 -2
  17. package/harness/bundles/general/claude-code/harness-sync/reference.md +5 -4
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +282 -120
  20. package/harness/bundles/general/claude-code/scripts/harness_events.py +400 -24
  21. package/harness/bundles/general/claude-code/scripts/harness_gate.py +585 -21
  22. package/harness/bundles/general/claude-code/scripts/harness_integration.py +403 -18
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +225 -29
  24. package/harness/bundles/general/claude-code/scripts/harness_phase.py +926 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +361 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_profile.py +50 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +248 -0
  28. package/harness/bundles/general/claude-code/scripts/harness_state.py +15 -2
  29. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +188 -0
  30. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +19 -0
  31. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  32. package/harness/bundles/general/codebuddy/contracts/ci-metrics.schema.json +36 -0
  33. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +30 -0
  34. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
  39. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +6 -0
  40. package/harness/bundles/general/codebuddy/harness-plan/reference.md +7 -2
  41. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-run/checklist.md +3 -2
  44. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -4
  45. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +2 -2
  47. package/harness/bundles/general/codebuddy/harness-sync/reference.md +5 -4
  48. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +282 -120
  50. package/harness/bundles/general/codebuddy/scripts/harness_events.py +400 -24
  51. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +585 -21
  52. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +403 -18
  53. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +225 -29
  54. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +926 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +361 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +50 -0
  57. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +248 -0
  58. package/harness/bundles/general/codebuddy/scripts/harness_state.py +15 -2
  59. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +188 -0
  60. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +19 -0
  61. package/harness/bundles/general/codex/.harness-build.json +1 -1
  62. package/harness/bundles/general/codex/contracts/ci-metrics.schema.json +36 -0
  63. package/harness/bundles/general/codex/contracts/workflow-policy.json +30 -0
  64. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
  69. package/harness/bundles/general/codex/harness-plan/checklist.md +6 -0
  70. package/harness/bundles/general/codex/harness-plan/reference.md +7 -2
  71. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  72. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-run/checklist.md +3 -2
  74. package/harness/bundles/general/codex/harness-run/reference.md +6 -4
  75. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  76. package/harness/bundles/general/codex/harness-sync/SKILL.md +2 -2
  77. package/harness/bundles/general/codex/harness-sync/reference.md +5 -4
  78. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  79. package/harness/bundles/general/codex/scripts/harness_archive.py +282 -120
  80. package/harness/bundles/general/codex/scripts/harness_events.py +400 -24
  81. package/harness/bundles/general/codex/scripts/harness_gate.py +585 -21
  82. package/harness/bundles/general/codex/scripts/harness_integration.py +403 -18
  83. package/harness/bundles/general/codex/scripts/harness_ledger.py +225 -29
  84. package/harness/bundles/general/codex/scripts/harness_phase.py +926 -0
  85. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +361 -0
  86. package/harness/bundles/general/codex/scripts/harness_profile.py +50 -0
  87. package/harness/bundles/general/codex/scripts/harness_runtime.py +248 -0
  88. package/harness/bundles/general/codex/scripts/harness_state.py +15 -2
  89. package/harness/bundles/general/codex/scripts/harness_test_guard.py +188 -0
  90. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +19 -0
  91. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  92. package/harness/bundles/general/cursor/contracts/ci-metrics.schema.json +36 -0
  93. package/harness/bundles/general/cursor/contracts/workflow-policy.json +30 -0
  94. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  97. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  98. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
  99. package/harness/bundles/general/cursor/harness-plan/checklist.md +6 -0
  100. package/harness/bundles/general/cursor/harness-plan/reference.md +7 -2
  101. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  102. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  103. package/harness/bundles/general/cursor/harness-run/checklist.md +3 -2
  104. package/harness/bundles/general/cursor/harness-run/reference.md +6 -4
  105. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  106. package/harness/bundles/general/cursor/harness-sync/SKILL.md +2 -2
  107. package/harness/bundles/general/cursor/harness-sync/reference.md +5 -4
  108. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  109. package/harness/bundles/general/cursor/scripts/harness_archive.py +282 -120
  110. package/harness/bundles/general/cursor/scripts/harness_events.py +400 -24
  111. package/harness/bundles/general/cursor/scripts/harness_gate.py +585 -21
  112. package/harness/bundles/general/cursor/scripts/harness_integration.py +403 -18
  113. package/harness/bundles/general/cursor/scripts/harness_ledger.py +225 -29
  114. package/harness/bundles/general/cursor/scripts/harness_phase.py +926 -0
  115. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +361 -0
  116. package/harness/bundles/general/cursor/scripts/harness_profile.py +50 -0
  117. package/harness/bundles/general/cursor/scripts/harness_runtime.py +248 -0
  118. package/harness/bundles/general/cursor/scripts/harness_state.py +15 -2
  119. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +188 -0
  120. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +19 -0
  121. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  122. package/harness/bundles/java/claude-code/contracts/ci-metrics.schema.json +36 -0
  123. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +30 -0
  124. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  126. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  127. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  128. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  129. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
  131. package/harness/bundles/java/claude-code/harness-plan/checklist.md +6 -0
  132. package/harness/bundles/java/claude-code/harness-plan/reference.md +7 -2
  133. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  134. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  135. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  136. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +2 -2
  137. package/harness/bundles/java/claude-code/harness-sync/reference.md +5 -4
  138. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  139. package/harness/bundles/java/claude-code/scripts/harness_archive.py +282 -120
  140. package/harness/bundles/java/claude-code/scripts/harness_events.py +400 -24
  141. package/harness/bundles/java/claude-code/scripts/harness_gate.py +585 -21
  142. package/harness/bundles/java/claude-code/scripts/harness_integration.py +403 -18
  143. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +225 -29
  144. package/harness/bundles/java/claude-code/scripts/harness_phase.py +926 -0
  145. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +361 -0
  146. package/harness/bundles/java/claude-code/scripts/harness_profile.py +50 -0
  147. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +248 -0
  148. package/harness/bundles/java/claude-code/scripts/harness_state.py +15 -2
  149. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +188 -0
  150. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +19 -0
  151. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  152. package/harness/bundles/java/codebuddy/contracts/ci-metrics.schema.json +36 -0
  153. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +30 -0
  154. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  155. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  156. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  157. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  158. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  159. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  160. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
  161. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +6 -0
  162. package/harness/bundles/java/codebuddy/harness-plan/reference.md +7 -2
  163. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  164. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  165. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  166. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +2 -2
  167. package/harness/bundles/java/codebuddy/harness-sync/reference.md +5 -4
  168. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  169. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +282 -120
  170. package/harness/bundles/java/codebuddy/scripts/harness_events.py +400 -24
  171. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +585 -21
  172. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +403 -18
  173. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +225 -29
  174. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +926 -0
  175. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +361 -0
  176. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +50 -0
  177. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +248 -0
  178. package/harness/bundles/java/codebuddy/scripts/harness_state.py +15 -2
  179. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +188 -0
  180. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +19 -0
  181. package/harness/bundles/java/codex/.harness-build.json +1 -1
  182. package/harness/bundles/java/codex/contracts/ci-metrics.schema.json +36 -0
  183. package/harness/bundles/java/codex/contracts/workflow-policy.json +30 -0
  184. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  185. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  186. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  187. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  188. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  189. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  190. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
  191. package/harness/bundles/java/codex/harness-plan/checklist.md +6 -0
  192. package/harness/bundles/java/codex/harness-plan/reference.md +7 -2
  193. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  194. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  195. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  196. package/harness/bundles/java/codex/harness-sync/SKILL.md +2 -2
  197. package/harness/bundles/java/codex/harness-sync/reference.md +5 -4
  198. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  199. package/harness/bundles/java/codex/scripts/harness_archive.py +282 -120
  200. package/harness/bundles/java/codex/scripts/harness_events.py +400 -24
  201. package/harness/bundles/java/codex/scripts/harness_gate.py +585 -21
  202. package/harness/bundles/java/codex/scripts/harness_integration.py +403 -18
  203. package/harness/bundles/java/codex/scripts/harness_ledger.py +225 -29
  204. package/harness/bundles/java/codex/scripts/harness_phase.py +926 -0
  205. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +361 -0
  206. package/harness/bundles/java/codex/scripts/harness_profile.py +50 -0
  207. package/harness/bundles/java/codex/scripts/harness_runtime.py +248 -0
  208. package/harness/bundles/java/codex/scripts/harness_state.py +15 -2
  209. package/harness/bundles/java/codex/scripts/harness_test_guard.py +188 -0
  210. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +19 -0
  211. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  212. package/harness/bundles/java/cursor/contracts/ci-metrics.schema.json +36 -0
  213. package/harness/bundles/java/cursor/contracts/workflow-policy.json +30 -0
  214. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  215. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  216. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  217. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  218. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  219. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  220. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
  221. package/harness/bundles/java/cursor/harness-plan/checklist.md +6 -0
  222. package/harness/bundles/java/cursor/harness-plan/reference.md +7 -2
  223. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  224. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  225. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  226. package/harness/bundles/java/cursor/harness-sync/SKILL.md +2 -2
  227. package/harness/bundles/java/cursor/harness-sync/reference.md +5 -4
  228. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  229. package/harness/bundles/java/cursor/scripts/harness_archive.py +282 -120
  230. package/harness/bundles/java/cursor/scripts/harness_events.py +400 -24
  231. package/harness/bundles/java/cursor/scripts/harness_gate.py +585 -21
  232. package/harness/bundles/java/cursor/scripts/harness_integration.py +403 -18
  233. package/harness/bundles/java/cursor/scripts/harness_ledger.py +225 -29
  234. package/harness/bundles/java/cursor/scripts/harness_phase.py +926 -0
  235. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +361 -0
  236. package/harness/bundles/java/cursor/scripts/harness_profile.py +50 -0
  237. package/harness/bundles/java/cursor/scripts/harness_runtime.py +248 -0
  238. package/harness/bundles/java/cursor/scripts/harness_state.py +15 -2
  239. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +188 -0
  240. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +19 -0
  241. package/harness/manifests/general/claude-code.json +43 -27
  242. package/harness/manifests/general/codebuddy.json +43 -27
  243. package/harness/manifests/general/codex.json +43 -27
  244. package/harness/manifests/general/cursor.json +43 -27
  245. package/harness/manifests/java/claude-code.json +43 -27
  246. package/harness/manifests/java/codebuddy.json +43 -27
  247. package/harness/manifests/java/codex.json +43 -27
  248. package/harness/manifests/java/cursor.json +43 -27
  249. package/hunter-workflow-family.json +2 -2
  250. package/package.json +1 -1
@@ -70,6 +70,16 @@ CAPABILITY_MARKERS: dict[str, tuple[str, ...]] = {
70
70
  "integration-lock": ("harness_change.py integration-lock",),
71
71
  }
72
72
 
73
+ DESIGN_GATE_CAPABILITIES = frozenset({"deployment", "container", "api", "database"})
74
+ VALIDATION_DEPENDENCIES: dict[str, tuple[str, ...]] = {
75
+ "compile": (),
76
+ "unitTest": ("compile",),
77
+ "unitTestFull": ("unitTest",),
78
+ "apiTest": ("unitTest",),
79
+ "dbCompatibility": ("unitTest",),
80
+ "package": ("unitTestFull", "apiTest", "dbCompatibility"),
81
+ }
82
+
73
83
 
74
84
  def _load_workflow_policy(
75
85
  *, project: Path | None = None, skills_root: Path | None = None
@@ -124,6 +134,133 @@ def _write_json(path: Path, data: Any) -> None:
124
134
  raise
125
135
 
126
136
 
137
+ def _git_text(cwd: Path, *args: str) -> str | None:
138
+ proc = subprocess.run(
139
+ ["git", *args],
140
+ cwd=str(cwd),
141
+ capture_output=True,
142
+ text=True,
143
+ encoding="utf-8",
144
+ errors="replace",
145
+ check=False,
146
+ )
147
+ return proc.stdout.strip() if proc.returncode == 0 else None
148
+
149
+
150
+ def _git_is_ancestor(cwd: Path, ancestor: str, descendant: str) -> bool:
151
+ proc = subprocess.run(
152
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant],
153
+ cwd=str(cwd),
154
+ capture_output=True,
155
+ text=True,
156
+ encoding="utf-8",
157
+ errors="replace",
158
+ check=False,
159
+ )
160
+ return proc.returncode == 0
161
+
162
+
163
+ def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
164
+ candidate = Path(raw).expanduser().resolve() if raw else main_project.resolve()
165
+ if not candidate.is_dir():
166
+ raise ValueError(f"execution root not found: {candidate}")
167
+ top = _git_text(candidate, "rev-parse", "--show-toplevel")
168
+ if not top:
169
+ raise ValueError(f"execution root is not a git worktree: {candidate}")
170
+ root = Path(top).resolve()
171
+ if hp.repository_identity(root) != hp.repository_identity(main_project):
172
+ raise ValueError("execution root belongs to a different repository")
173
+ return root
174
+
175
+
176
+ def _phase_capsule_path(change_dir: Path, phase: str, run_id: str) -> Path:
177
+ key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
178
+ return (
179
+ Path(hp.resolve_state_dir_for_contract(change_dir))
180
+ / "runtime"
181
+ / "phase-context"
182
+ / f"{phase}-{key}.json"
183
+ )
184
+
185
+
186
+ def load_phase_capsule(
187
+ change_dir: Path, phase: str, run_id: str
188
+ ) -> dict[str, Any] | None:
189
+ path = _phase_capsule_path(change_dir, phase, run_id)
190
+ if not path.is_file():
191
+ return None
192
+ try:
193
+ data = _read_json(path)
194
+ except (OSError, json.JSONDecodeError) as exc:
195
+ raise ValueError(f"phase capsule is unreadable: {path}: {exc}") from exc
196
+ if not isinstance(data, dict) or data.get("schemaVersion") != 1:
197
+ raise ValueError(f"phase capsule has an unsupported schema: {path}")
198
+ return data
199
+
200
+
201
+ def write_phase_capsule(
202
+ change_dir: Path, phase: str, run_id: str, capsule: dict[str, Any]
203
+ ) -> Path:
204
+ path = _phase_capsule_path(change_dir, phase, run_id)
205
+ _write_json(path, capsule)
206
+ return path
207
+
208
+
209
+ def validate_phase_capsule(
210
+ capsule: dict[str, Any],
211
+ *,
212
+ change_dir: Path,
213
+ change_id: str,
214
+ phase: str,
215
+ run_id: str,
216
+ project: Path,
217
+ execution_root: Path,
218
+ skills_root: Path | None = None,
219
+ allow_head_advance: bool = False,
220
+ ) -> None:
221
+ """Fail closed when a resume/close capsule no longer identifies this run."""
222
+ required = (
223
+ "changeId", "phase", "runId", "projectRoot", "stateRoot",
224
+ "executionRoot", "skillsRoot", "repositoryId", "baseCommit", "currentHead",
225
+ )
226
+ missing = [
227
+ field for field in required
228
+ if not isinstance(capsule.get(field), str) or not str(capsule[field]).strip()
229
+ ]
230
+ if missing:
231
+ raise ValueError("phase capsule missing: " + ", ".join(missing))
232
+ expected = {
233
+ "changeId": change_id,
234
+ "phase": phase,
235
+ "runId": run_id,
236
+ "projectRoot": str(project.resolve()),
237
+ "stateRoot": str(Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()),
238
+ "executionRoot": str(execution_root.resolve()),
239
+ }
240
+ if skills_root is not None:
241
+ expected["skillsRoot"] = str(skills_root.resolve())
242
+ for field, value in expected.items():
243
+ if capsule.get(field) != value:
244
+ raise ValueError(
245
+ f"phase capsule {field} mismatch: expected {value}, found {capsule.get(field)}"
246
+ )
247
+ current_repository = hp.repository_identity(execution_root)
248
+ if capsule.get("repositoryId") != current_repository:
249
+ raise ValueError("phase capsule repositoryId mismatch")
250
+ current_head = _git_text(execution_root, "rev-parse", "--verify", "HEAD")
251
+ capsule_head = str(capsule["currentHead"])
252
+ head_advanced = (
253
+ allow_head_advance
254
+ and isinstance(current_head, str)
255
+ and _git_is_ancestor(execution_root, capsule_head, current_head)
256
+ )
257
+ if capsule_head != current_head and not head_advanced:
258
+ raise ValueError(
259
+ f"phase capsule currentHead mismatch: expected {capsule_head}, "
260
+ f"found {current_head}"
261
+ )
262
+
263
+
127
264
  def load_checkpoints(change_dir: Path) -> dict[str, Any] | None:
128
265
  path = change_dir / CHECKPOINTS_REL
129
266
  if not path.is_file():
@@ -218,6 +355,8 @@ def validate_ledger_for_phase_close(
218
355
  change_dir: Path,
219
356
  phase: str,
220
357
  policy: dict[str, Any],
358
+ *,
359
+ execution_root: Path | None = None,
221
360
  ) -> dict[str, Any]:
222
361
  """Validate ledger v2 fields required for phase close (UT-026)."""
223
362
  required = policy.get("requiredValidations", {}).get(phase, [])
@@ -243,6 +382,65 @@ def validate_ledger_for_phase_close(
243
382
  "ledgerPath": str(ledger_path) if ledger_path else None,
244
383
  }
245
384
 
385
+ try:
386
+ contract = hp.load_change_contract(change_dir)
387
+ except (OSError, ValueError, json.JSONDecodeError):
388
+ contract = {}
389
+ contract_version = contract.get("schemaVersion")
390
+ contract_is_v2 = hp.contract_layout_kind(contract) == "split-v1" or (
391
+ isinstance(contract_version, int) and contract_version >= 2
392
+ )
393
+ if contract_is_v2:
394
+ missing_identity = hl.validate_ledger_identity(ledger)
395
+ if missing_identity:
396
+ return {
397
+ "ok": False,
398
+ "code": "LEDGER_IDENTITY_INVALID",
399
+ "message": "ledger identity is incomplete",
400
+ "phase": phase,
401
+ "missing": missing_identity,
402
+ "ledgerPath": str(ledger_path) if ledger_path else None,
403
+ }
404
+ root = Path(execution_root or hp.resolve_worktree_root(change_dir)).resolve()
405
+ try:
406
+ current_repository = hp.repository_identity(root)
407
+ current_ownership = hl.ownership_hash(contract)
408
+ current_diff = hl.compute_ownership_diff(
409
+ root,
410
+ base=str(ledger["baseCommit"]),
411
+ change_dir=change_dir,
412
+ )["diffHash"]
413
+ current_head = _git_text(root, "rev-parse", "--verify", "HEAD")
414
+ except (OSError, ValueError, RuntimeError) as exc:
415
+ return {
416
+ "ok": False,
417
+ "code": "LEDGER_IDENTITY_INVALID",
418
+ "message": f"cannot resolve current ledger identity: {exc}",
419
+ "phase": phase,
420
+ "ledgerPath": str(ledger_path) if ledger_path else None,
421
+ }
422
+ identity_mismatch = (
423
+ ledger.get("repositoryId") != current_repository
424
+ or ledger.get("ownershipHash") != current_ownership
425
+ or ledger.get("diffHash") != current_diff
426
+ )
427
+ if identity_mismatch:
428
+ return {
429
+ "ok": False,
430
+ "code": "LEDGER_IDENTITY_MISMATCH",
431
+ "message": "verification ledger does not match the current change",
432
+ "phase": phase,
433
+ "storedRepositoryId": ledger.get("repositoryId"),
434
+ "currentRepositoryId": current_repository,
435
+ "storedOwnershipHash": ledger.get("ownershipHash"),
436
+ "currentOwnershipHash": current_ownership,
437
+ "storedDiffHash": ledger.get("diffHash"),
438
+ "currentDiffHash": current_diff,
439
+ "storedHead": ledger.get("currentHead"),
440
+ "currentHead": current_head,
441
+ "ledgerPath": str(ledger_path) if ledger_path else None,
442
+ }
443
+
246
444
  problems: list[dict[str, Any]] = []
247
445
  degraded: list[str] = []
248
446
  for verification in required:
@@ -309,6 +507,34 @@ def validate_ledger_for_phase_close(
309
507
  }
310
508
 
311
509
 
510
+ def effective_workflow_policy(
511
+ workflow: dict[str, Any], change_dir: Path
512
+ ) -> dict[str, Any]:
513
+ """Overlay a classified change's per-phase gate requirements."""
514
+ path = change_dir / "meta" / "gate-policy.json"
515
+ if not path.is_file():
516
+ return workflow
517
+ document = json.loads(path.read_text(encoding="utf-8"))
518
+ if not isinstance(document, dict) or document.get("schemaVersion") != 1:
519
+ raise ValueError("gate-policy.json must be a schemaVersion 1 object")
520
+ by_phase = document.get("requiredValidationsByPhase")
521
+ if by_phase is None:
522
+ return workflow
523
+ if not isinstance(by_phase, dict):
524
+ raise ValueError("gate-policy.requiredValidationsByPhase must be an object")
525
+ known = set(workflow.get("validationPhases") or {})
526
+ required = dict(workflow.get("requiredValidations") or {})
527
+ for phase, validations in by_phase.items():
528
+ if not isinstance(phase, str) or not isinstance(validations, list):
529
+ raise ValueError("gate-policy phase requirements must be string arrays")
530
+ if any(not isinstance(item, str) or item not in known for item in validations):
531
+ raise ValueError(f"gate-policy contains unknown validation for phase {phase}")
532
+ required[phase] = _ordered_unique(validations)
533
+ effective = dict(workflow)
534
+ effective["requiredValidations"] = required
535
+ return effective
536
+
537
+
312
538
  def _sha256_file(path: Path) -> str:
313
539
  digest = hashlib.sha256()
314
540
  with path.open("rb") as handle:
@@ -448,6 +674,145 @@ def change_code_root(change_dir: Path) -> Path:
448
674
  return project
449
675
 
450
676
 
677
+ def _design_capabilities(change_dir: Path) -> list[str]:
678
+ """Read explicit capability tags from design/plan YAML frontmatter."""
679
+ capabilities: set[str] = set()
680
+ candidates: list[Path] = []
681
+ for directory in (change_dir / "spec", change_dir / "plans"):
682
+ if directory.is_dir():
683
+ candidates.extend(sorted(directory.glob("*.md")))
684
+ for candidate in candidates:
685
+ text = candidate.read_text(encoding="utf-8", errors="replace")
686
+ if not text.startswith("---"):
687
+ continue
688
+ end = text.find("\n---", 3)
689
+ if end < 0:
690
+ continue
691
+ frontmatter = text[3:end]
692
+ inline = re.search(r"^capabilities\s*:\s*\[([^\]]*)\]\s*$", frontmatter, re.MULTILINE)
693
+ if inline:
694
+ values = (item.strip().strip("'\"") for item in inline.group(1).split(","))
695
+ capabilities.update(item for item in values if item in DESIGN_GATE_CAPABILITIES)
696
+ continue
697
+ block = re.search(
698
+ r"^capabilities\s*:\s*$((?:\n\s*-\s*[^\n]+)+)",
699
+ frontmatter,
700
+ re.MULTILINE,
701
+ )
702
+ if block:
703
+ for item in re.findall(r"^\s*-\s*([^\n]+)$", block.group(1), re.MULTILINE):
704
+ value = item.strip().strip("'\"")
705
+ if value in DESIGN_GATE_CAPABILITIES:
706
+ capabilities.add(value)
707
+ return sorted(capabilities)
708
+
709
+
710
+ def _diff_capabilities(changed: list[str]) -> list[str]:
711
+ """Conservatively infer capabilities from the owned post-run diff."""
712
+ lowered = "\n".join(path.lower().replace("\\", "/") for path in changed)
713
+ found: set[str] = set()
714
+ if any(marker in lowered for marker in ("deploy", "helm", "k8s", "kubernetes")):
715
+ found.add("deployment")
716
+ if any(marker in lowered for marker in ("dockerfile", "container", "compose.y")):
717
+ found.add("container")
718
+ if any(marker in lowered for marker in ("/api/", "openapi", "swagger", "controller")):
719
+ found.add("api")
720
+ if any(marker in lowered for marker in ("migration", "/sql/", ".sql", "schema")):
721
+ found.add("database")
722
+ return sorted(found)
723
+
724
+
725
+ def _ordered_unique(items: list[str]) -> list[str]:
726
+ return list(dict.fromkeys(items))
727
+
728
+
729
+ def _apply_required_gate_contract(
730
+ payload: dict[str, Any],
731
+ workflow: dict[str, Any],
732
+ capabilities: list[str],
733
+ ) -> dict[str, Any]:
734
+ """Expand tier + capability facts into the persisted required-gate DAG."""
735
+ result = dict(payload)
736
+ tier = str(result["tier"])
737
+ tier_policy = workflow["riskTiers"][tier]
738
+ selected = sorted(set(capabilities) & set(workflow["capabilityGates"]))
739
+ signals = list(result.get("signals") or [])
740
+ required = list(tier_policy["requiredValidations"])
741
+ required_stages: set[str] = set()
742
+ for capability in selected:
743
+ contract = workflow["capabilityGates"][capability]
744
+ signals.extend(contract["signals"])
745
+ required.extend(contract["requiredValidations"])
746
+ required_stages.update(contract["requiredStages"])
747
+ signals = sorted(set(signals))
748
+ required = _ordered_unique(required)
749
+
750
+ stage_decisions = _stage_decisions_for_tier(workflow, tier, signals)
751
+ for stage_name in required_stages:
752
+ decision = stage_decisions.setdefault(
753
+ stage_name,
754
+ {"required": False, "reason": "not-triggered", "matchedSignals": []},
755
+ )
756
+ decision["required"] = True
757
+ if decision["reason"] == "not-triggered":
758
+ decision["reason"] = "capability"
759
+
760
+ validation_phases = workflow["validationPhases"]
761
+ by_phase: dict[str, list[str]] = {}
762
+ for verification in required:
763
+ phase = validation_phases.get(verification)
764
+ if isinstance(phase, str) and phase:
765
+ by_phase.setdefault(phase, []).append(verification)
766
+
767
+ nodes: list[dict[str, Any]] = []
768
+ edges: list[dict[str, str]] = []
769
+ required_set = set(required)
770
+ for verification in required:
771
+ dependencies = [
772
+ item for item in VALIDATION_DEPENDENCIES.get(verification, ())
773
+ if item in required_set
774
+ ]
775
+ node_id = f"validation:{verification}"
776
+ nodes.append({
777
+ "id": node_id,
778
+ "kind": "validation",
779
+ "phase": validation_phases.get(verification),
780
+ "dependsOn": [f"validation:{item}" for item in dependencies],
781
+ })
782
+ edges.extend(
783
+ {"from": f"validation:{item}", "to": node_id}
784
+ for item in dependencies
785
+ )
786
+ for stage_name, decision in sorted(stage_decisions.items()):
787
+ if not decision.get("required"):
788
+ continue
789
+ if stage_name == "package":
790
+ dependencies = [
791
+ f"validation:{item}" for item in required
792
+ if validation_phases.get(item) in {"run", "test"}
793
+ ]
794
+ elif stage_name == "apidoc" and "apiTest" in required_set:
795
+ dependencies = ["validation:apiTest"]
796
+ else:
797
+ dependencies = []
798
+ node_id = f"stage:{stage_name}"
799
+ nodes.append({
800
+ "id": node_id,
801
+ "kind": "stage",
802
+ "phase": stage_name,
803
+ "dependsOn": dependencies,
804
+ })
805
+ edges.extend({"from": item, "to": node_id} for item in dependencies)
806
+
807
+ result["capabilities"] = selected
808
+ result["signals"] = signals
809
+ result["requiredValidations"] = required
810
+ result["requiredValidationsByPhase"] = by_phase
811
+ result["stageDecisions"] = stage_decisions
812
+ result["requiredGateDag"] = {"schemaVersion": 1, "nodes": nodes, "edges": edges}
813
+ return result
814
+
815
+
451
816
  def classify_risk(
452
817
  change_dir: Path,
453
818
  stage: str,
@@ -455,6 +820,7 @@ def classify_risk(
455
820
  ) -> dict[str, Any]:
456
821
  tier = "full"
457
822
  source = "default-full"
823
+ capabilities = _design_capabilities(change_dir)
458
824
  plan_path = change_dir / "plans"
459
825
  for candidate in sorted(plan_path.glob("*.md")) if plan_path.is_dir() else []:
460
826
  text = candidate.read_text(encoding="utf-8", errors="replace")
@@ -486,6 +852,7 @@ def classify_risk(
486
852
  if not raw or raw.startswith(".harness/"):
487
853
  continue
488
854
  changed.append(raw)
855
+ capabilities = sorted(set(capabilities) | set(_diff_capabilities(changed)))
489
856
  lowered = "\n".join(changed).lower()
490
857
  full_markers = {
491
858
  "auth": ("auth", "token", "credential", "permission"),
@@ -538,7 +905,7 @@ def classify_risk(
538
905
  }
539
906
  if stage == "post-run":
540
907
  _write_json(change_dir / "meta" / "risk-classification.json", payload)
541
- return payload
908
+ return _apply_required_gate_contract(payload, workflow, capabilities)
542
909
 
543
910
 
544
911
  def _stage_decisions_for_tier(
@@ -582,7 +949,9 @@ def apply_tier_override(
582
949
  payload["conditionalStages"] = list(tier_policy["conditionalStages"])
583
950
  payload["stageDecisions"] = _stage_decisions_for_tier(workflow, tier, signals)
584
951
  payload["tierOverride"] = {"tier": tier, "by": override_by, "at": now}
585
- return payload
952
+ return _apply_required_gate_contract(
953
+ payload, workflow, list(payload.get("capabilities") or [])
954
+ )
586
955
 
587
956
 
588
957
  def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
@@ -593,6 +962,13 @@ def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
593
962
  "source": payload["source"],
594
963
  "defaultPhases": list(payload.get("defaultPhases") or []),
595
964
  "requiredValidations": list(payload.get("requiredValidations") or []),
965
+ "requiredValidationsByPhase": dict(
966
+ payload.get("requiredValidationsByPhase") or {}
967
+ ),
968
+ "capabilities": list(payload.get("capabilities") or []),
969
+ "signals": list(payload.get("signals") or []),
970
+ "stageDecisions": dict(payload.get("stageDecisions") or {}),
971
+ "requiredGateDag": dict(payload.get("requiredGateDag") or {}),
596
972
  "classifiedAt": payload.get("classifiedAt") or hc.now_iso(),
597
973
  "tierOverride": payload.get("tierOverride"),
598
974
  }
@@ -607,7 +983,7 @@ def classify_defaults(
607
983
  tier = "full"
608
984
  source = "default-full"
609
985
  tier_policy = workflow["riskTiers"][tier]
610
- return {
986
+ payload = {
611
987
  "ok": True,
612
988
  "code": "CLASSIFIED",
613
989
  "stage": stage,
@@ -620,6 +996,7 @@ def classify_defaults(
620
996
  "conditionalStages": list(tier_policy["conditionalStages"]),
621
997
  "stageDecisions": _stage_decisions_for_tier(workflow, tier, []),
622
998
  }
999
+ return _apply_required_gate_contract(payload, workflow, [])
623
1000
 
624
1001
 
625
1002
  def lint_skill_tree(skills_root: Path) -> dict[str, Any]:
@@ -803,6 +1180,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
803
1180
 
804
1181
  try:
805
1182
  policy = _load_workflow_policy(project=project)
1183
+ policy = effective_workflow_policy(policy, change_dir)
806
1184
  except (OSError, ValueError, hwp.PolicyValidationError) as exc:
807
1185
  return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
808
1186
 
@@ -827,6 +1205,31 @@ def cmd_begin(args: argparse.Namespace) -> int:
827
1205
  if isinstance(current_lease, dict) and current_lease.get("phase") == args.phase
828
1206
  else "run-" + uuid.uuid4().hex
829
1207
  )
1208
+ try:
1209
+ capsule = load_phase_capsule(change_dir, args.phase, run_id)
1210
+ except ValueError as exc:
1211
+ return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
1212
+ execution_hint = args.project
1213
+ if not execution_hint and capsule is not None:
1214
+ execution_hint = str(capsule.get("executionRoot") or "")
1215
+ try:
1216
+ execution_root = resolve_execution_root(project, execution_hint)
1217
+ except ValueError as exc:
1218
+ return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
1219
+ if capsule is not None:
1220
+ try:
1221
+ validate_phase_capsule(
1222
+ capsule,
1223
+ change_dir=change_dir,
1224
+ change_id=str(resolved["changeId"]),
1225
+ phase=args.phase,
1226
+ run_id=run_id,
1227
+ project=project,
1228
+ execution_root=execution_root,
1229
+ skills_root=Path(args.skills_root).expanduser(),
1230
+ )
1231
+ except ValueError as exc:
1232
+ return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
830
1233
  claim = hc.claim_lease(
831
1234
  project,
832
1235
  change_id=resolved["changeId"],
@@ -842,7 +1245,45 @@ def cmd_begin(args: argparse.Namespace) -> int:
842
1245
  extra={k: v for k, v in claim.items() if k not in {"ok", "message", "code"}},
843
1246
  )
844
1247
 
1248
+ guard_result: dict[str, Any] | None = None
845
1249
  try:
1250
+ if capsule is not None:
1251
+ guard_result = {"ok": True, "code": "SNAPSHOT_REUSED"}
1252
+ else:
1253
+ if args.phase in {"run", "test"}:
1254
+ guard_result = htg.begin(execution_root, change_dir)
1255
+ if not guard_result.get("ok"):
1256
+ hc.release_lease(
1257
+ project,
1258
+ change_id=resolved["changeId"],
1259
+ phase=args.phase,
1260
+ run_id=run_id,
1261
+ )
1262
+ return emit_error(
1263
+ str(guard_result.get("code", "TEST_GUARD_BEGIN_FAILED")),
1264
+ "test guard begin failed",
1265
+ as_json=as_json,
1266
+ extra=guard_result,
1267
+ )
1268
+ state_root = Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()
1269
+ current_head = _git_text(execution_root, "rev-parse", "--verify", "HEAD")
1270
+ ledger, _ = hl.load_ledger(change_dir)
1271
+ base_commit = ledger.get("baseCommit") if isinstance(ledger, dict) else None
1272
+ capsule = {
1273
+ "schemaVersion": 1,
1274
+ "changeId": resolved["changeId"],
1275
+ "phase": args.phase,
1276
+ "runId": run_id,
1277
+ "projectRoot": str(project.resolve()),
1278
+ "stateRoot": str(state_root),
1279
+ "executionRoot": str(execution_root),
1280
+ "skillsRoot": str(Path(args.skills_root).expanduser().resolve()),
1281
+ "repositoryId": hp.repository_identity(execution_root),
1282
+ "baseCommit": base_commit or current_head,
1283
+ "currentHead": current_head,
1284
+ "createdAt": he.now_iso(),
1285
+ }
1286
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
846
1287
  event_result = {"ok": True, "skipped": True, "reason": "already-recorded"} \
847
1288
  if _phase_event_exists(change_dir, args.phase, "phase.start", run_id) \
848
1289
  else append_phase_event(
@@ -869,9 +1310,13 @@ def cmd_begin(args: argparse.Namespace) -> int:
869
1310
  "changeId": resolved["changeId"],
870
1311
  "changeDir": str(change_dir),
871
1312
  "projectRoot": str(project),
1313
+ "stateRoot": capsule.get("stateRoot") if capsule else None,
1314
+ "executionRoot": capsule.get("executionRoot") if capsule else str(execution_root),
1315
+ "skillsRoot": capsule.get("skillsRoot") if capsule else str(Path(args.skills_root).resolve()),
872
1316
  "lease": claim.get("lease"),
873
1317
  "identity": identity,
874
1318
  "event": event_result,
1319
+ "testGuard": guard_result,
875
1320
  "policySchemaVersion": policy.get("schemaVersion"),
876
1321
  }
877
1322
  emit(payload, as_json=as_json)
@@ -901,6 +1346,7 @@ def cmd_close(args: argparse.Namespace) -> int:
901
1346
 
902
1347
  try:
903
1348
  policy = _load_workflow_policy(project=project)
1349
+ policy = effective_workflow_policy(policy, change_dir)
904
1350
  except (OSError, ValueError, hwp.PolicyValidationError) as exc:
905
1351
  return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
906
1352
 
@@ -917,7 +1363,52 @@ def cmd_close(args: argparse.Namespace) -> int:
917
1363
  extra={"holder": current_lease},
918
1364
  )
919
1365
 
920
- ledger_result = validate_ledger_for_phase_close(change_dir, args.phase, policy)
1366
+ try:
1367
+ capsule = load_phase_capsule(change_dir, args.phase, run_id)
1368
+ except ValueError as exc:
1369
+ return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
1370
+ if capsule is not None:
1371
+ try:
1372
+ execution_root = resolve_execution_root(
1373
+ project, str(capsule.get("executionRoot") or "")
1374
+ )
1375
+ if args.project:
1376
+ requested_root = resolve_execution_root(project, args.project)
1377
+ if requested_root != execution_root:
1378
+ return emit_error(
1379
+ "EXECUTION_ROOT_MISMATCH",
1380
+ "close must use the execution root captured at begin",
1381
+ as_json=as_json,
1382
+ extra={
1383
+ "storedExecutionRoot": str(execution_root),
1384
+ "requestedExecutionRoot": str(requested_root),
1385
+ },
1386
+ )
1387
+ except ValueError as exc:
1388
+ return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
1389
+ else:
1390
+ try:
1391
+ execution_root = resolve_execution_root(project, args.project)
1392
+ except ValueError as exc:
1393
+ return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
1394
+ if capsule is not None:
1395
+ try:
1396
+ validate_phase_capsule(
1397
+ capsule,
1398
+ change_dir=change_dir,
1399
+ change_id=str(resolved["changeId"]),
1400
+ phase=args.phase,
1401
+ run_id=run_id,
1402
+ project=project,
1403
+ execution_root=execution_root,
1404
+ allow_head_advance=args.phase in {"run", "submit", "merge"},
1405
+ )
1406
+ except ValueError as exc:
1407
+ return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
1408
+
1409
+ ledger_result = validate_ledger_for_phase_close(
1410
+ change_dir, args.phase, policy, execution_root=execution_root
1411
+ )
921
1412
  if not ledger_result.get("ok") and args.phase in {"run", "test", "package"}:
922
1413
  return emit_error(
923
1414
  str(ledger_result.get("code", "LEDGER_INVALID")),
@@ -934,28 +1425,79 @@ def cmd_close(args: argparse.Namespace) -> int:
934
1425
  if close_status == "OK":
935
1426
  close_status = "WARN"
936
1427
 
1428
+ close_transaction: dict[str, Any] = {}
1429
+ if capsule is not None:
1430
+ existing_transaction = capsule.get("closeTransaction")
1431
+ if isinstance(existing_transaction, dict):
1432
+ close_transaction.update(existing_transaction)
1433
+ close_transaction.update({
1434
+ "status": "CLOSING",
1435
+ "retryable": True,
1436
+ "guardClosed": bool(close_transaction.get("guardClosed")),
1437
+ "phaseEndRecorded": bool(close_transaction.get("phaseEndRecorded")),
1438
+ "leaseReleased": False,
1439
+ "updatedAt": he.now_iso(),
1440
+ })
1441
+ capsule["closeTransaction"] = close_transaction
1442
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
1443
+
937
1444
  guard_result = None
938
1445
  if args.phase in {"run", "test"}:
939
- project_root = args.project or str(project)
940
- guard_result = htg.close(project_root, change_dir)
941
- if not guard_result.get("ok"):
1446
+ if close_transaction.get("guardClosed"):
1447
+ guard_result = {"ok": True, "code": "ALREADY_CLOSED", "reused": True}
1448
+ else:
1449
+ guard_result = htg.close(execution_root, change_dir)
1450
+ if not guard_result.get("ok"):
1451
+ if capsule is not None:
1452
+ close_transaction.update({
1453
+ "status": "GUARD_CLOSE_FAILED",
1454
+ "lastError": guard_result,
1455
+ "updatedAt": he.now_iso(),
1456
+ })
1457
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
1458
+ return emit_error(
1459
+ str(guard_result.get("code", "TEST_GUARD_CLOSE_FAILED")),
1460
+ "test guard close failed",
1461
+ as_json=as_json,
1462
+ extra=guard_result,
1463
+ )
1464
+ if capsule is not None:
1465
+ close_transaction["guardClosed"] = True
1466
+ close_transaction["updatedAt"] = he.now_iso()
1467
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
1468
+
1469
+ if close_transaction.get("phaseEndRecorded") or _phase_event_exists(
1470
+ change_dir, args.phase, "phase.end", run_id
1471
+ ):
1472
+ event_result = {"ok": True, "skipped": True, "reason": "already-recorded"}
1473
+ else:
1474
+ try:
1475
+ event_result = append_phase_event(
1476
+ change_dir,
1477
+ phase=args.phase,
1478
+ type_="phase.end",
1479
+ status=close_status,
1480
+ note=args.note or "",
1481
+ run_id=run_id,
1482
+ )
1483
+ except BaseException as exc:
1484
+ if capsule is not None:
1485
+ close_transaction.update({
1486
+ "status": "PHASE_END_FAILED",
1487
+ "lastError": {"type": type(exc).__name__, "message": str(exc)},
1488
+ "updatedAt": he.now_iso(),
1489
+ })
1490
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
942
1491
  return emit_error(
943
- str(guard_result.get("code", "TEST_GUARD_CLOSE_FAILED")),
944
- "test guard close failed",
1492
+ "PHASE_END_FAILED",
1493
+ str(exc),
945
1494
  as_json=as_json,
946
- extra=guard_result,
1495
+ extra={"retryable": True},
947
1496
  )
948
-
949
- event_result = append_phase_event(
950
- change_dir,
951
- phase=args.phase,
952
- type_="phase.end",
953
- status=close_status,
954
- note=args.note or "",
955
- run_id=run_id,
956
- ) if not _phase_event_exists(change_dir, args.phase, "phase.end", run_id) else {
957
- "ok": True, "skipped": True, "reason": "already-recorded"
958
- }
1497
+ if capsule is not None:
1498
+ close_transaction["phaseEndRecorded"] = True
1499
+ close_transaction["updatedAt"] = he.now_iso()
1500
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
959
1501
 
960
1502
  release = hc.release_lease(
961
1503
  project,
@@ -964,6 +1506,14 @@ def cmd_close(args: argparse.Namespace) -> int:
964
1506
  run_id=run_id,
965
1507
  )
966
1508
  if not release.get("ok"):
1509
+ if capsule is not None:
1510
+ close_transaction.update({
1511
+ "status": "RELEASE_PENDING",
1512
+ "retryable": True,
1513
+ "lastError": release,
1514
+ "updatedAt": he.now_iso(),
1515
+ })
1516
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
967
1517
  return emit_error(
968
1518
  str(release.get("code", "LEASE_RELEASE_FAILED")),
969
1519
  str(release.get("message", "lease release failed")),
@@ -971,12 +1521,26 @@ def cmd_close(args: argparse.Namespace) -> int:
971
1521
  extra={k: v for k, v in release.items() if k not in {"ok", "message", "code"}},
972
1522
  )
973
1523
 
1524
+ if capsule is not None:
1525
+ close_transaction.update({
1526
+ "status": "CLOSED",
1527
+ "retryable": False,
1528
+ "leaseReleased": True,
1529
+ "updatedAt": he.now_iso(),
1530
+ })
1531
+ capsule["closedAt"] = he.now_iso()
1532
+ capsule["closeStatus"] = close_status
1533
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
1534
+
974
1535
  payload = {
975
1536
  "ok": True,
976
1537
  "code": close_code,
977
1538
  "phase": args.phase,
978
1539
  "status": close_status,
979
1540
  "changeId": resolved["changeId"],
1541
+ "stateRoot": capsule.get("stateRoot") if capsule else str(Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()),
1542
+ "executionRoot": str(execution_root),
1543
+ "skillsRoot": capsule.get("skillsRoot") if capsule else None,
980
1544
  "ledger": ledger_result,
981
1545
  "testGuard": guard_result,
982
1546
  "event": event_result,