@hunter-harness/workflow-harness 0.4.0 → 0.4.2

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 +157 -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 +166 -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 +157 -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 +166 -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,3816 @@
1
+ #!/usr/bin/env python3
2
+ """Harness deterministic phase gate: begin/close, checkpoints, classify, lint.
3
+
4
+ Subcommands:
5
+ begin — claim lease, optional identity capture, append phase.start
6
+ close — validate ledger/test-guard/policy, append phase.end, release lease
7
+ classify — plan/post-run risk tier stub
8
+ checkpoint — status|approve foundation-gate (and future checkpoints)
9
+ lint-skills — forbid hand-written ledger patterns in skill trees
10
+
11
+ foundation-gate must block task>=6 until approved (API-012).
12
+ Python 3.10+, stdlib only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ import uuid
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ SCRIPTS_DIR = Path(__file__).resolve().parent
29
+ if str(SCRIPTS_DIR) not in sys.path:
30
+ sys.path.insert(0, str(SCRIPTS_DIR))
31
+
32
+ import harness_change as hc # noqa: E402
33
+ import harness_context as hctx # noqa: E402
34
+ import harness_events as he # noqa: E402
35
+ import harness_events_sync as hes # noqa: E402
36
+ import harness_ledger as hl # noqa: E402
37
+ import harness_paths as hp # noqa: E402
38
+ import harness_plan_finalize as hpf # noqa: E402
39
+ import harness_review as hr # noqa: E402
40
+ import harness_runtime as hruntime # noqa: E402
41
+ import harness_workflow_policy as hwp # noqa: E402
42
+ import harness_test_guard as htg # noqa: E402
43
+
44
+ if hasattr(sys.stdout, "reconfigure"):
45
+ sys.stdout.reconfigure(encoding="utf-8")
46
+ if hasattr(sys.stderr, "reconfigure"):
47
+ sys.stderr.reconfigure(encoding="utf-8")
48
+
49
+
50
+ CHECKPOINTS_REL = Path("meta") / "implementation-checkpoints.json"
51
+ FORBIDDEN_SKILL_PATTERNS = (
52
+ re.compile(r"Write\s+verification-ledger\.json", re.IGNORECASE),
53
+ re.compile(r"Edit\s+verification-ledger", re.IGNORECASE),
54
+ re.compile(r"hand-?write.*verification-ledger", re.IGNORECASE),
55
+ )
56
+
57
+ LEDGER_V2_REQUIRED_ENTRY_FIELDS = (
58
+ "algorithmVersion",
59
+ "coverage",
60
+ "inputsHash",
61
+ "inputsFiles",
62
+ "status",
63
+ "command",
64
+ "evidence",
65
+ )
66
+
67
+ CAPABILITY_MARKERS: dict[str, tuple[str, ...]] = {
68
+ "risk-classify-plan": ("harness_gate.py classify",),
69
+ "gate-begin": ("harness_gate.py begin",),
70
+ "gate-close": ("harness_gate.py close",),
71
+ "ledger-record": ("harness_ledger.py record",),
72
+ "ledger-can-reuse": ("harness_ledger.py can-reuse",),
73
+ "test-guard": ("harness_test_guard.py begin", "harness_test_guard.py close"),
74
+ "test-guard-stage": ("harness_test_guard.py stage",),
75
+ "integration-lock": ("harness_change.py integration-lock",),
76
+ }
77
+
78
+ DESIGN_GATE_CAPABILITIES = frozenset({"deployment", "container", "api", "database"})
79
+ VALIDATION_DEPENDENCIES: dict[str, tuple[str, ...]] = {
80
+ "compile": (),
81
+ "unitTest": ("compile",),
82
+ "unitTestFull": ("unitTest",),
83
+ "apiTest": ("unitTest",),
84
+ "browserTest": ("unitTest",),
85
+ "dbCompatibility": ("unitTest",),
86
+ "package": ("unitTestFull", "apiTest", "dbCompatibility"),
87
+ }
88
+
89
+
90
+ def _load_workflow_policy(
91
+ *, project: Path | None = None, skills_root: Path | None = None
92
+ ) -> dict[str, Any]:
93
+ candidates = []
94
+ if project is not None:
95
+ candidates.append(project / "harness" / "contracts" / "workflow-policy.json")
96
+ if skills_root is not None:
97
+ candidates.append(skills_root / "contracts" / "workflow-policy.json")
98
+ candidates.append(SCRIPTS_DIR.parent / "contracts" / "workflow-policy.json")
99
+ for path in candidates:
100
+ if path.is_file():
101
+ raw = json.loads(path.read_text(encoding="utf-8"))
102
+ return hwp.validate_policy(raw)
103
+ raise FileNotFoundError("workflow-policy.json not found beside project or skills")
104
+
105
+
106
+ def emit(payload: dict[str, Any], *, as_json: bool) -> None:
107
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
108
+
109
+
110
+ def emit_error(
111
+ code: str,
112
+ message: str,
113
+ *,
114
+ as_json: bool,
115
+ extra: dict[str, Any] | None = None,
116
+ ) -> int:
117
+ payload: dict[str, Any] = {"ok": False, "code": code, "message": message}
118
+ if extra:
119
+ payload.update(extra)
120
+ if as_json:
121
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
122
+ else:
123
+ sys.stderr.write(f"error: {message} ({code})\n")
124
+ return 1
125
+
126
+
127
+ def _read_json(path: Path) -> Any:
128
+ return json.loads(path.read_text(encoding="utf-8-sig"))
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Gate severity tiering (P1 controllability)
133
+ #
134
+ # Hard invariants stay fail-closed in every mode:
135
+ # 1. submit/merge identity & final-hash consistency (ledger/integration);
136
+ # 2. archive integrity (manifest/minimal set, enforced in harness_archive);
137
+ # 3. destructive-action protection (cleanup topology / protected paths).
138
+ # The sites listed in SOFT_GATE_SITES guard regenerable bookkeeping
139
+ # (plan-handoff projections, phase capsules, scenario coverage receipts,
140
+ # test-guard tracking). In "lenient" mode a failure at one of those sites is
141
+ # recorded as a WARN receipt (evidence/gate-warnings.ndjson) instead of
142
+ # blocking the phase. Release phases (submit/merge/archive/release/deploy)
143
+ # never downgrade.
144
+ # ---------------------------------------------------------------------------
145
+
146
+ GATE_RELEASE_PHASES = frozenset({"submit", "merge", "archive", "release", "deploy"})
147
+ SOFT_GATE_SITES = frozenset({
148
+ "plan-handoff",
149
+ "capsule",
150
+ "scenario-coverage",
151
+ "test-guard",
152
+ })
153
+ GATE_WARNINGS_REL = Path("evidence") / "gate-warnings.ndjson"
154
+
155
+ # Lifecycle order used to scope C9 scenario coverage by scenario ownerPhase.
156
+ # A scenario owned by a later phase is deferred, not missing, at an earlier
157
+ # phase close. Must stay a superset-ordering of hpf.VALID_OWNER_PHASES.
158
+ SCENARIO_OWNER_PHASE_ORDER = ("plan", "execute", "review", "submit")
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # 阶段能力表:每个阶段在 begin/close 里额外做什么,集中在这里声明。
162
+ #
163
+ # 这些开关以前是 cmd_begin / cmd_close 里散落的 `if args.phase == ...` —— 两个
164
+ # 五百行的函数,想知道"execute 阶段关门时到底跑哪几项"要通读全文。更要紧的是
165
+ # execute/review 的差异被摊平在控制流里,看不出它们其实共用同一套生命周期。
166
+ #
167
+ # 键的含义:
168
+ # plan_handoff begin 时校验 plan 已 finalize(只有进入实现的第一个阶段需要)
169
+ # test_guard begin 建测试基线快照、close 比对(改测试的阶段才需要)
170
+ # scenario_coverage close 时跑 C9 场景覆盖
171
+ # ledger_blocking close 时 ledger 校验失败是否阻断(否则只进 payload)
172
+ # review_outputs close 时校验 review sidecar 与 runId 绑定
173
+ # head_may_advance capsule 校验允许 HEAD 前移(会产生 commit 的阶段)
174
+ # projection_drift close 时 projection receipt 变化即硬失败(外发边界)
175
+ PHASE_GATE_RULES: dict[str, frozenset[str]] = {
176
+ "plan": frozenset(),
177
+ "execute": frozenset({
178
+ "plan_handoff", "test_guard", "scenario_coverage",
179
+ "ledger_blocking", "head_may_advance",
180
+ }),
181
+ "review": frozenset({"review_outputs"}),
182
+ "package": frozenset({"ledger_blocking"}),
183
+ "apidoc": frozenset(),
184
+ "submit": frozenset({"head_may_advance", "projection_drift"}),
185
+ "merge": frozenset({"head_may_advance"}),
186
+ "archive": frozenset({"projection_drift"}),
187
+ }
188
+ # 不在 WORKFLOW_PHASES 里、但 projection 门禁按发布阶段对待的名字。
189
+ _EXTRA_PROJECTION_DRIFT_PHASES = frozenset({"release", "deploy"})
190
+
191
+
192
+ def phase_gate_rule(phase: str | None, rule: str) -> bool:
193
+ """这个阶段是否启用某项门禁能力。未知阶段一律不启用(fail-safe)。"""
194
+ name = str(phase or "")
195
+ if rule == "projection_drift" and name in _EXTRA_PROJECTION_DRIFT_PHASES:
196
+ return True
197
+ resolved = hp.resolve_phase_name(name) or name
198
+ return rule in PHASE_GATE_RULES.get(resolved, frozenset())
199
+
200
+
201
+ def gate_severity_mode(project: Path, change_dir: Path | None = None) -> str:
202
+ """Resolve gate severity mode: env > change gate-policy > project config.
203
+
204
+ Default is ``lenient`` (soft regenerable sites → WARN). Release phases and
205
+ the three hard invariants stay fail-closed; set ``HUNTER_HARNESS_GATE_MODE``
206
+ or ``gate-policy.json`` ``severityMode`` to ``strict`` to force fail-closed
207
+ soft sites as well.
208
+ """
209
+ env_mode = str(os.environ.get("HUNTER_HARNESS_GATE_MODE") or "").strip().lower()
210
+ if env_mode in {"strict", "lenient"}:
211
+ return env_mode
212
+ candidates: list[Path] = []
213
+ if change_dir is not None:
214
+ candidates.append(change_dir / "meta" / "gate-policy.json")
215
+ candidates.append(project / ".harness" / "config" / "gate-policy.json")
216
+ for path in candidates:
217
+ if not path.is_file():
218
+ continue
219
+ try:
220
+ document = _read_json(path)
221
+ except (OSError, json.JSONDecodeError):
222
+ continue
223
+ if not isinstance(document, dict):
224
+ continue
225
+ value = str(document.get("severityMode") or "").strip().lower()
226
+ if value in {"strict", "lenient"}:
227
+ return value
228
+ return "lenient"
229
+
230
+
231
+ def gate_soft_allowed(mode: str, phase: str, site: str) -> bool:
232
+ return (
233
+ mode == "lenient"
234
+ and phase not in GATE_RELEASE_PHASES
235
+ and site in SOFT_GATE_SITES
236
+ )
237
+
238
+
239
+ def record_gate_warning(
240
+ change_dir: Path,
241
+ *,
242
+ phase: str,
243
+ site: str,
244
+ code: str,
245
+ message: str,
246
+ ) -> dict[str, Any]:
247
+ """Append a downgraded-gate receipt; the phase continues with WARN."""
248
+ entry = {
249
+ "ts": he.now_iso(),
250
+ "phase": phase,
251
+ "site": site,
252
+ "code": code,
253
+ "message": message,
254
+ "mode": "lenient",
255
+ }
256
+ path = change_dir / GATE_WARNINGS_REL
257
+ path.parent.mkdir(parents=True, exist_ok=True)
258
+ with path.open("a", encoding="utf-8", newline="\n") as handle:
259
+ handle.write(json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + "\n")
260
+ return entry
261
+
262
+
263
+ def _write_json(path: Path, data: Any) -> None:
264
+ path.parent.mkdir(parents=True, exist_ok=True)
265
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
266
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
267
+ try:
268
+ tmp.write_text(text, encoding="utf-8", newline="\n")
269
+ os.replace(tmp, path)
270
+ except BaseException:
271
+ tmp.unlink(missing_ok=True)
272
+ raise
273
+
274
+
275
+ def _git_text(cwd: Path, *args: str) -> str | None:
276
+ proc = subprocess.run(
277
+ ["git", *args],
278
+ cwd=str(cwd),
279
+ capture_output=True,
280
+ text=True,
281
+ encoding="utf-8",
282
+ errors="replace",
283
+ check=False,
284
+ )
285
+ return proc.stdout.strip() if proc.returncode == 0 else None
286
+
287
+
288
+ def _git_is_ancestor(cwd: Path, ancestor: str, descendant: str) -> bool:
289
+ proc = subprocess.run(
290
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant],
291
+ cwd=str(cwd),
292
+ capture_output=True,
293
+ text=True,
294
+ encoding="utf-8",
295
+ errors="replace",
296
+ check=False,
297
+ )
298
+ return proc.returncode == 0
299
+
300
+
301
+ def evaluate_projection_gate(
302
+ project: Path,
303
+ phase: str,
304
+ ) -> dict[str, Any]:
305
+ """Validate three-stage projection identity and apply phase fallback policy."""
306
+ project = project.resolve()
307
+ path = project / ".harness" / "config" / "projection-receipt.json"
308
+ release_phase = phase in {"submit", "archive", "release", "deploy"}
309
+ if not path.is_file():
310
+ return {
311
+ "ok": True,
312
+ "code": "PROJECTION_RECEIPT_NOT_CONFIGURED",
313
+ "status": "legacy",
314
+ "mode": "legacy",
315
+ "receiptPath": str(path),
316
+ "releasePhase": release_phase,
317
+ }
318
+ try:
319
+ receipt = _read_json(path)
320
+ except (OSError, json.JSONDecodeError) as exc:
321
+ receipt = {}
322
+ parse_error = str(exc)
323
+ else:
324
+ parse_error = None
325
+ source = receipt.get("source")
326
+ transform = receipt.get("transform")
327
+ target = receipt.get("target")
328
+ source = source if isinstance(source, dict) else {}
329
+ transform = transform if isinstance(transform, dict) else {}
330
+ target = target if isinstance(target, dict) else {}
331
+ digest_pattern = re.compile(r"sha256:[0-9a-fA-F]{64}")
332
+ issues = []
333
+ if receipt.get("schemaVersion") != 1:
334
+ issues.append("schemaVersion")
335
+ for label, value in (
336
+ ("source.hash", source.get("hash")),
337
+ ("transform.hash", transform.get("hash")),
338
+ ("target.hash", target.get("hash")),
339
+ ):
340
+ if not isinstance(value, str) or not digest_pattern.fullmatch(value):
341
+ issues.append(label)
342
+ if not str(transform.get("version") or "").strip():
343
+ issues.append("transform.version")
344
+ if not isinstance(receipt.get("projectOwnedExclusions"), list):
345
+ issues.append("projectOwnedExclusions")
346
+ if not str(receipt.get("generatedAt") or "").strip():
347
+ issues.append("generatedAt")
348
+ status = str(receipt.get("status") or "").strip().lower()
349
+ if status not in {"verified", "degraded"}:
350
+ issues.append("status")
351
+ status = "degraded"
352
+ if parse_error:
353
+ issues.append("parse")
354
+ receipt_hash = "sha256:" + hashlib.sha256(
355
+ json.dumps(
356
+ receipt,
357
+ ensure_ascii=False,
358
+ sort_keys=True,
359
+ separators=(",", ":"),
360
+ ).encode("utf-8")
361
+ ).hexdigest()
362
+ degraded = status == "degraded" or bool(issues)
363
+ if degraded and release_phase:
364
+ return {
365
+ "ok": False,
366
+ "code": "PROJECTION_DEGRADED_BLOCKED",
367
+ "message": (
368
+ "adapter/config projection is degraded; refresh the projection "
369
+ "receipt before submit/archive/release"
370
+ ),
371
+ "status": "degraded",
372
+ "mode": "blocked",
373
+ "issues": issues,
374
+ "receipt": receipt,
375
+ "receiptHash": receipt_hash,
376
+ "receiptPath": str(path),
377
+ "releasePhase": True,
378
+ "remediation": (
379
+ "regenerate .harness/config/projection-receipt.json from the "
380
+ "project truth source"
381
+ ),
382
+ }
383
+ return {
384
+ "ok": True,
385
+ "code": (
386
+ "PROJECTION_VERIFIED"
387
+ if not degraded
388
+ else "PROJECTION_DEGRADED_FALLBACK"
389
+ ),
390
+ "message": (
391
+ "projection receipt verified"
392
+ if not degraded
393
+ else "degraded projection allowed only in explicit fallback mode"
394
+ ),
395
+ "status": "verified" if not degraded else "degraded",
396
+ "mode": "normal" if not degraded else "fallback",
397
+ "issues": issues,
398
+ "receipt": receipt,
399
+ "receiptHash": receipt_hash,
400
+ "receiptPath": str(path),
401
+ "releasePhase": release_phase,
402
+ "remediation": (
403
+ None
404
+ if not degraded
405
+ else (
406
+ "regenerate .harness/config/projection-receipt.json from the "
407
+ "project truth source before any release phase"
408
+ )
409
+ ),
410
+ }
411
+
412
+
413
+ def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
414
+ candidate = Path(raw).expanduser().resolve() if raw else main_project.resolve()
415
+ if not candidate.is_dir():
416
+ raise ValueError(
417
+ f"execution root not found: {candidate} — --project takes a "
418
+ "filesystem path to the execution root (use '.' when running from "
419
+ "it), not the project name"
420
+ )
421
+ top = _git_text(candidate, "rev-parse", "--show-toplevel")
422
+ if not top:
423
+ raise ValueError(f"execution root is not a git worktree: {candidate}")
424
+ root = Path(top).resolve()
425
+ if hp.repository_identity(root) != hp.repository_identity(main_project):
426
+ raise ValueError("execution root belongs to a different repository")
427
+ return root
428
+
429
+
430
+ def resolve_begin_execution_hint(
431
+ main_project: Path,
432
+ *,
433
+ requested: str | None,
434
+ capsule: dict[str, Any] | None,
435
+ cwd: Path | None = None,
436
+ ) -> dict[str, str]:
437
+ """Choose an execution root explicitly, from the capsule, or from Git cwd."""
438
+ if requested:
439
+ return {
440
+ "executionRoot": str(Path(requested).expanduser().resolve()),
441
+ "source": "explicit",
442
+ }
443
+ if isinstance(capsule, dict) and str(capsule.get("executionRoot") or "").strip():
444
+ return {
445
+ "executionRoot": str(capsule["executionRoot"]),
446
+ "source": "phase-capsule",
447
+ }
448
+ current = (cwd or Path.cwd()).resolve()
449
+ try:
450
+ top = _git_text(current, "rev-parse", "--show-toplevel")
451
+ detected = Path(top).resolve() if top else None
452
+ if (
453
+ detected is not None
454
+ and detected.is_dir()
455
+ and hp.repository_identity(detected)
456
+ == hp.repository_identity(main_project)
457
+ ):
458
+ return {
459
+ "executionRoot": str(detected),
460
+ "source": "cwd-git-root",
461
+ }
462
+ except (OSError, RuntimeError, ValueError):
463
+ pass
464
+ return {
465
+ "executionRoot": str(main_project.resolve()),
466
+ "source": "main-project-default",
467
+ }
468
+
469
+
470
+ def _phase_capsule_path(change_dir: Path, phase: str, run_id: str) -> Path:
471
+ key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
472
+ return (
473
+ Path(hp.resolve_state_dir_for_contract(change_dir))
474
+ / "runtime"
475
+ / "phase-context"
476
+ / f"{phase}-{key}.json"
477
+ )
478
+
479
+
480
+ def load_phase_capsule(
481
+ change_dir: Path, phase: str, run_id: str
482
+ ) -> dict[str, Any] | None:
483
+ # 在途 change 的 capsule 可能按旧名(run/test)写入:先归一入参,canonical
484
+ # 查不到时对所有解析为该 canonical 的旧名各查一次。新写一律 canonical。
485
+ phase = hp.resolve_phase_name(phase) or phase
486
+ candidates = [phase] + [
487
+ legacy for legacy, canonical in hp.LEGACY_PHASE_ALIASES.items()
488
+ if canonical == phase
489
+ ]
490
+ path: Path | None = None
491
+ for name in candidates:
492
+ candidate = _phase_capsule_path(change_dir, name, run_id)
493
+ if candidate.is_file():
494
+ path = candidate
495
+ break
496
+ if path is None:
497
+ return None
498
+ try:
499
+ data = _read_json(path)
500
+ except (OSError, json.JSONDecodeError) as exc:
501
+ raise ValueError(f"phase capsule is unreadable: {path}: {exc}") from exc
502
+ if not isinstance(data, dict) or data.get("schemaVersion") != 1:
503
+ raise ValueError(f"phase capsule has an unsupported schema: {path}")
504
+ return data
505
+
506
+
507
+ def write_phase_capsule(
508
+ change_dir: Path, phase: str, run_id: str, capsule: dict[str, Any]
509
+ ) -> Path:
510
+ path = _phase_capsule_path(change_dir, phase, run_id)
511
+ _write_json(path, capsule)
512
+ return path
513
+
514
+
515
+ def persist_close_failure(
516
+ change_dir: Path,
517
+ phase: str,
518
+ run_id: str,
519
+ capsule: dict[str, Any] | None,
520
+ *,
521
+ status: str,
522
+ error: dict[str, Any],
523
+ ) -> None:
524
+ """Journal a retryable close failure before returning to the caller."""
525
+ if capsule is None:
526
+ return
527
+ transaction = capsule.get("closeTransaction")
528
+ transaction = dict(transaction) if isinstance(transaction, dict) else {}
529
+ transaction.update(
530
+ {
531
+ "status": status,
532
+ "retryable": True,
533
+ "guardClosed": bool(transaction.get("guardClosed")),
534
+ "phaseEndRecorded": bool(transaction.get("phaseEndRecorded")),
535
+ "leaseReleased": False,
536
+ "lastError": error,
537
+ "updatedAt": he.now_iso(),
538
+ }
539
+ )
540
+ capsule["closeTransaction"] = transaction
541
+ capsule.pop("closeStatus", None)
542
+ capsule.pop("closedAt", None)
543
+ write_phase_capsule(change_dir, phase, run_id, capsule)
544
+
545
+
546
+ def validate_phase_capsule(
547
+ capsule: dict[str, Any],
548
+ *,
549
+ change_dir: Path,
550
+ change_id: str,
551
+ phase: str,
552
+ run_id: str,
553
+ project: Path,
554
+ execution_root: Path,
555
+ skills_root: Path | None = None,
556
+ allow_head_advance: bool = False,
557
+ ) -> None:
558
+ """Fail closed when a resume/close capsule no longer identifies this run."""
559
+ required = (
560
+ "changeId", "phase", "runId", "projectRoot", "stateRoot",
561
+ "executionRoot", "skillsRoot", "repositoryId", "baseCommit", "currentHead",
562
+ )
563
+ missing = [
564
+ field for field in required
565
+ if not isinstance(capsule.get(field), str) or not str(capsule[field]).strip()
566
+ ]
567
+ if missing:
568
+ raise ValueError("phase capsule missing: " + ", ".join(missing))
569
+ expected = {
570
+ "changeId": change_id,
571
+ "phase": phase,
572
+ "runId": run_id,
573
+ "projectRoot": str(project.resolve()),
574
+ "stateRoot": str(Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()),
575
+ "executionRoot": str(execution_root.resolve()),
576
+ }
577
+ if skills_root is not None:
578
+ expected["skillsRoot"] = str(skills_root.resolve())
579
+ for field, value in expected.items():
580
+ if capsule.get(field) != value:
581
+ raise ValueError(
582
+ f"phase capsule {field} mismatch: expected {value}, found {capsule.get(field)}"
583
+ )
584
+ current_repository = hp.repository_identity(execution_root)
585
+ if capsule.get("repositoryId") != current_repository:
586
+ raise ValueError("phase capsule repositoryId mismatch")
587
+ current_head = _git_text(execution_root, "rev-parse", "--verify", "HEAD")
588
+ capsule_head = str(capsule["currentHead"])
589
+ head_advanced = (
590
+ allow_head_advance
591
+ and isinstance(current_head, str)
592
+ and _git_is_ancestor(execution_root, capsule_head, current_head)
593
+ )
594
+ if capsule_head != current_head and not head_advanced:
595
+ raise ValueError(
596
+ f"phase capsule currentHead mismatch: expected {capsule_head}, "
597
+ f"found {current_head}"
598
+ )
599
+
600
+
601
+ def load_checkpoints(change_dir: Path) -> dict[str, Any] | None:
602
+ path = change_dir / CHECKPOINTS_REL
603
+ if not path.is_file():
604
+ return None
605
+ try:
606
+ data = _read_json(path)
607
+ except (OSError, json.JSONDecodeError):
608
+ return None
609
+ # 刻意返回**原样**文档:cmd_checkpoint approve 会把这里的返回值写回盘,
610
+ # 返回归一化结构会用合成内容覆盖 v2 的哈希绑定产物(ARTIFACT_HASH_DRIFT)。
611
+ # 形状差异在只读的 checkpoint_status 里消化。
612
+ return data if isinstance(data, dict) else None
613
+
614
+
615
+ def checkpoint_status(checkpoints: dict[str, Any] | None, checkpoint_id: str) -> str:
616
+ """Read a checkpoint's status across the three shapes this file is written in.
617
+
618
+ `meta/implementation-checkpoints.json` 有三个写入者,形状各不相同:
619
+ `harness_change.migrate` 写 ``checkpoints[{id,status}]``;legacy plan finalize 写
620
+ 顶层 ``foundationGate``;v2 plan finalize 写 artifact 包装体,状态在
621
+ ``content.foundation_gate``。以前只认第一种,另外两种一律返回 "missing"
622
+ ——checkpoint 的状态对整条 v2 路径和 legacy finalize 之后都是读不出来的。
623
+
624
+ 只读归一化:调用方拿到的是状态,不是文档。写回路径(cmd_checkpoint approve)
625
+ 仍然操作原始文档,不能用归一化结构覆盖哈希绑定的 v2 产物。
626
+ """
627
+ if not checkpoints:
628
+ return "missing"
629
+ items = checkpoints.get("checkpoints")
630
+ if isinstance(items, list):
631
+ for item in items:
632
+ if isinstance(item, dict) and item.get("id") == checkpoint_id:
633
+ return str(item.get("status") or "pending")
634
+ return "missing"
635
+ if checkpoint_id != "foundation-gate":
636
+ return "missing"
637
+ unpacked = hpf.unpack_v2_implementation_checkpoints(checkpoints)
638
+ if unpacked is not None:
639
+ return str(unpacked["checkpoints"][0]["status"])
640
+ legacy = checkpoints.get("foundationGate")
641
+ return str(legacy).strip() if str(legacy or "").strip() else "missing"
642
+
643
+
644
+ def foundation_gate_blocks(task_number: int | None, change_dir: Path) -> dict[str, Any] | None:
645
+ checkpoints = load_checkpoints(change_dir)
646
+ status = checkpoint_status(checkpoints, "foundation-gate")
647
+ # missing = checkpoint not enabled for this change (no file / no entry).
648
+ if status in {"approved", "missing"}:
649
+ return None
650
+ if task_number is None:
651
+ return {
652
+ "ok": False,
653
+ "code": "TASK_NUMBER_REQUIRED",
654
+ "message": "--task is required while foundation-gate is pending",
655
+ "checkpointId": "foundation-gate",
656
+ "checkpointStatus": status,
657
+ }
658
+ if task_number < 6:
659
+ return None
660
+ return {
661
+ "ok": False,
662
+ "code": "FOUNDATION_GATE_PENDING",
663
+ "message": (
664
+ "foundation-gate is not approved; tasks 6+ are blocked until "
665
+ "reports/review/foundation-gate-review.md is reviewed and checkpoint approved"
666
+ ),
667
+ "checkpointId": "foundation-gate",
668
+ "checkpointStatus": status,
669
+ "task": task_number,
670
+ }
671
+
672
+
673
+ def is_degraded_ledger_entry(entry: dict[str, Any]) -> bool:
674
+ """NOT_RUN + evidence starting with 'DEGRADED: <non-empty reason>'."""
675
+ if entry.get("status") != "NOT_RUN":
676
+ return False
677
+ evidence = str(entry.get("evidence") or "").strip()
678
+ if not evidence.startswith("DEGRADED:"):
679
+ return False
680
+ reason = evidence.split(":", 1)[1].strip()
681
+ return bool(reason)
682
+
683
+
684
+ def validate_ledger_entry_v2(entry: dict[str, Any], verification: str) -> tuple[list[str], bool]:
685
+ """Internal helper for ledger close validation (not a public API).
686
+
687
+ Returns ``(missing_fields, degraded_ok)`` where ``degraded_ok`` is True only when
688
+ the entry is a valid DEGRADED NOT_RUN record with no other missing fields.
689
+
690
+ Retro §5.14: ``status=OK``/``status=NOT_RUN``/``status=FAIL`` in the
691
+ missing list is a *status value* hint, not a field-completeness defect.
692
+ Callers consuming this under ``phase_status=FAIL`` must filter out
693
+ ``status=*`` entries from the missing list before treating them as
694
+ blocking problems.
695
+ """
696
+ missing: list[str] = []
697
+ degraded = is_degraded_ledger_entry(entry)
698
+ for field in LEDGER_V2_REQUIRED_ENTRY_FIELDS:
699
+ value = entry.get(field)
700
+ if field == "inputsFiles":
701
+ if not isinstance(value, list):
702
+ missing.append(field)
703
+ elif verification == "unitTestFull" and not value:
704
+ missing.append("inputsFiles(non-empty)")
705
+ elif field == "status":
706
+ if value != "OK" and not degraded:
707
+ missing.append(f"status={value}")
708
+ elif not (isinstance(value, str) and value.strip()):
709
+ missing.append(field)
710
+ elif field == "coverage" and str(value).strip() not in hl.COVERAGE_RANK:
711
+ missing.append("coverage(valid)")
712
+ elif field == "algorithmVersion" and str(value).strip() != hl.LEDGER_VERSION:
713
+ missing.append("algorithmVersion(harness-ledger-2)")
714
+ return missing, degraded and not missing
715
+
716
+
717
+ def validate_ledger_for_phase_close(
718
+ change_dir: Path,
719
+ phase: str,
720
+ policy: dict[str, Any],
721
+ *,
722
+ execution_root: Path | None = None,
723
+ phase_status: str = "OK",
724
+ ) -> dict[str, Any]:
725
+ """Validate ledger v2 fields required for phase close (UT-026).
726
+
727
+ Retro §5.14: ``phase_status`` separates phase outcome from promotion gate.
728
+ ``phase_status=OK`` (default) requires all required validations to be OK;
729
+ ``phase_status=FAIL`` validates field completeness and identity but allows
730
+ individual validation status to be FAIL/NOT_RUN, so a failing phase can
731
+ close honestly without polluting evidence or blocking lease release.
732
+ """
733
+ resolved_phase = hp.resolve_phase_name(phase) or phase
734
+ # requiredValidations 的键可能还是合并前的旧名(在途 change 的旧契约),
735
+ # 入参也可能是旧名(手工 --phase run)——两侧都归一后再匹配;旧契约的
736
+ # run/test 两个键都映射到 execute,取并集。
737
+ required: list[str] = []
738
+ for key, value in (policy.get("requiredValidations") or {}).items():
739
+ if (hp.resolve_phase_name(key) or str(key)) == resolved_phase:
740
+ for item in value or []:
741
+ if item not in required:
742
+ required.append(item)
743
+ if not required:
744
+ return {"ok": True, "code": "LEDGER_NOT_REQUIRED", "phase": phase}
745
+
746
+ ledger, ledger_path = hl.load_ledger(change_dir)
747
+ if ledger is None:
748
+ return {
749
+ "ok": False,
750
+ "code": "LEDGER_MISSING",
751
+ "message": "verification ledger missing",
752
+ "phase": phase,
753
+ "required": required,
754
+ }
755
+ validations = ledger.get("validations")
756
+ if not isinstance(validations, dict):
757
+ return {
758
+ "ok": False,
759
+ "code": "VALIDATIONS_MISSING",
760
+ "message": "ledger validations missing",
761
+ "phase": phase,
762
+ "ledgerPath": str(ledger_path) if ledger_path else None,
763
+ }
764
+
765
+ try:
766
+ contract = hp.load_change_contract(change_dir)
767
+ except (OSError, ValueError, json.JSONDecodeError):
768
+ contract = {}
769
+ contract_version = contract.get("schemaVersion")
770
+ contract_is_v2 = hp.contract_layout_kind(contract) == "split-v1" or (
771
+ isinstance(contract_version, int) and contract_version >= 2
772
+ )
773
+ if contract_is_v2:
774
+ missing_identity = hl.validate_ledger_identity(ledger)
775
+ if missing_identity:
776
+ return {
777
+ "ok": False,
778
+ "code": "LEDGER_IDENTITY_INVALID",
779
+ "message": "ledger identity is incomplete",
780
+ "phase": phase,
781
+ "missing": missing_identity,
782
+ "ledgerPath": str(ledger_path) if ledger_path else None,
783
+ }
784
+ root = Path(execution_root or hp.resolve_worktree_root(change_dir)).resolve()
785
+ try:
786
+ current_repository = hp.repository_identity(root)
787
+ current_ownership = hl.ownership_hash(contract)
788
+ current_diff_detail = hl.compute_ownership_diff(
789
+ root,
790
+ base=str(ledger["baseCommit"]),
791
+ change_dir=change_dir,
792
+ )
793
+ current_diff = current_diff_detail["diffHash"]
794
+ current_head = _git_text(root, "rev-parse", "--verify", "HEAD")
795
+ except (OSError, ValueError, RuntimeError) as exc:
796
+ return {
797
+ "ok": False,
798
+ "code": "LEDGER_IDENTITY_INVALID",
799
+ "message": f"cannot resolve current ledger identity: {exc}",
800
+ "phase": phase,
801
+ "ledgerPath": str(ledger_path) if ledger_path else None,
802
+ }
803
+ identity_mismatch = (
804
+ ledger.get("repositoryId") != current_repository
805
+ or ledger.get("ownershipHash") != current_ownership
806
+ or ledger.get("diffHash") != current_diff
807
+ )
808
+ if identity_mismatch:
809
+ return {
810
+ "ok": False,
811
+ "code": "LEDGER_IDENTITY_MISMATCH",
812
+ "message": "verification ledger does not match the current change",
813
+ "phase": phase,
814
+ "storedRepositoryId": ledger.get("repositoryId"),
815
+ "currentRepositoryId": current_repository,
816
+ "storedOwnershipHash": ledger.get("ownershipHash"),
817
+ "currentOwnershipHash": current_ownership,
818
+ "storedDiffHash": ledger.get("diffHash"),
819
+ "currentDiffHash": current_diff,
820
+ "storedHead": ledger.get("currentHead"),
821
+ "currentHead": current_head,
822
+ "ledgerPath": str(ledger_path) if ledger_path else None,
823
+ "pathSummary": {
824
+ "ownedFiles": list(current_diff_detail.get("files") or [])[:20],
825
+ "foreignPaths": list(current_diff_detail.get("foreignPaths") or [])[:20],
826
+ "ownedFileCount": current_diff_detail.get("ownedFileCount", 0),
827
+ "excludedRuntimeCount": current_diff_detail.get(
828
+ "excludedRuntimeCount", 0
829
+ ),
830
+ },
831
+ "recoveryAction": (
832
+ "先确认新增、删除或越界路径是否属于当前变更;更新所有权后,"
833
+ "只重新执行受影响的验证,再原样重试 close。"
834
+ ),
835
+ }
836
+
837
+ problems: list[dict[str, Any]] = []
838
+ degraded: list[str] = []
839
+ fail_status: list[str] = []
840
+ for verification in required:
841
+ entry = validations.get(verification)
842
+ if not isinstance(entry, dict):
843
+ problems.append(
844
+ {
845
+ "verification": verification,
846
+ "missing": ["entry"],
847
+ "code": "VALIDATION_MISSING",
848
+ }
849
+ )
850
+ continue
851
+ missing, is_degraded = validate_ledger_entry_v2(entry, verification)
852
+ # Retro §5.14: under phase_status=FAIL/WARN, status value hints
853
+ # (status=FAIL, status=NOT_RUN) are acceptable; only real field-
854
+ # completeness defects block close. Under phase_status=OK, status
855
+ # value hints are blocking.
856
+ if phase_status.upper() in {"FAIL", "WARN"}:
857
+ missing = [m for m in missing if not m.startswith("status=")]
858
+ if missing:
859
+ code = (
860
+ "MISSING_V2_FIELDS"
861
+ if any(
862
+ field in missing
863
+ for field in (
864
+ "algorithmVersion",
865
+ "algorithmVersion(harness-ledger-2)",
866
+ "coverage",
867
+ "coverage(valid)",
868
+ )
869
+ )
870
+ else "MISSING_FIELDS"
871
+ )
872
+ problems.append(
873
+ {
874
+ "verification": verification,
875
+ "missing": missing,
876
+ "code": code,
877
+ }
878
+ )
879
+ continue
880
+ if is_degraded:
881
+ degraded.append(verification)
882
+ continue
883
+ # Under phase_status=FAIL/WARN, a FAIL/NOT_RUN status is acceptable.
884
+ entry_status = str(entry.get("status") or "").upper()
885
+ if entry_status in {"FAIL", "NOT_RUN"}:
886
+ if phase_status.upper() in {"FAIL", "WARN"}:
887
+ fail_status.append(verification)
888
+ else:
889
+ problems.append({
890
+ "verification": verification,
891
+ "missing": [f"status={entry_status}"],
892
+ "code": "VALIDATION_NOT_OK",
893
+ })
894
+
895
+ if problems:
896
+ return {
897
+ "ok": False,
898
+ "code": problems[0]["code"],
899
+ "message": "ledger validation failed for phase close",
900
+ "phase": phase,
901
+ "problems": problems,
902
+ "ledgerPath": str(ledger_path) if ledger_path else None,
903
+ "detail": "natural-language override is not permitted",
904
+ }
905
+ if fail_status:
906
+ return {
907
+ "ok": True,
908
+ "code": "LEDGER_OK_FAIL",
909
+ "phase": phase,
910
+ "validated": required,
911
+ "failed": fail_status,
912
+ "degraded": degraded,
913
+ "ledgerPath": str(ledger_path) if ledger_path else None,
914
+ }
915
+ if degraded:
916
+ return {
917
+ "ok": True,
918
+ "code": "LEDGER_OK_DEGRADED",
919
+ "phase": phase,
920
+ "validated": required,
921
+ "degraded": degraded,
922
+ "ledgerPath": str(ledger_path) if ledger_path else None,
923
+ }
924
+ return {
925
+ "ok": True,
926
+ "code": "LEDGER_OK",
927
+ "phase": phase,
928
+ "validated": required,
929
+ "ledgerPath": str(ledger_path) if ledger_path else None,
930
+ }
931
+
932
+
933
+ def effective_workflow_policy(
934
+ workflow: dict[str, Any], change_dir: Path
935
+ ) -> dict[str, Any]:
936
+ """Overlay a classified change's per-phase gate requirements.
937
+
938
+ 权威切换(2026-08):经 ``harness_paths.load_change_gate_policy`` 读取——
939
+ v2 发布快照(meta/plan-profile.json)优先,classify 工作副本回退。
940
+ 返回值保持与历史一致的纯净 workflow 形状;source/drift 由需要记录的
941
+ 调用方另行调用 ``load_change_gate_policy`` 获取(见 gate begin 路径)。
942
+ """
943
+ loaded = hp.load_change_gate_policy(change_dir)
944
+ document = loaded.get("policy")
945
+ if document is None:
946
+ return workflow
947
+ if not isinstance(document, dict) or document.get("schemaVersion") != 1:
948
+ raise ValueError("gate-policy.json must be a schemaVersion 1 object")
949
+ by_phase = document.get("requiredValidationsByPhase")
950
+ if by_phase is None:
951
+ return workflow
952
+ if not isinstance(by_phase, dict):
953
+ raise ValueError("gate-policy.requiredValidationsByPhase must be an object")
954
+ known = set(workflow.get("validationPhases") or {})
955
+ required = dict(workflow.get("requiredValidations") or {})
956
+ for phase, validations in by_phase.items():
957
+ if not isinstance(phase, str) or not isinstance(validations, list):
958
+ raise ValueError("gate-policy phase requirements must be string arrays")
959
+ if any(not isinstance(item, str) or item not in known for item in validations):
960
+ raise ValueError(f"gate-policy contains unknown validation for phase {phase}")
961
+ required[phase] = _ordered_unique(validations)
962
+ effective = dict(workflow)
963
+ effective["requiredValidations"] = required
964
+ return effective
965
+
966
+
967
+ def _sha256_file(path: Path) -> str:
968
+ digest = hashlib.sha256()
969
+ with path.open("rb") as handle:
970
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
971
+ digest.update(chunk)
972
+ return digest.hexdigest()
973
+
974
+
975
+ def validate_identity(
976
+ project: Path,
977
+ skills_root: Path,
978
+ executor_tool: str | None,
979
+ ) -> dict[str, Any]:
980
+ skills_root = skills_root.resolve()
981
+ build_path = skills_root / ".harness-build.json"
982
+ if not build_path.is_file():
983
+ raise ValueError("BUNDLE_IDENTITY_MISSING: refresh the selected Harness adapter")
984
+ build = _read_json(build_path)
985
+ if not isinstance(build, dict) or build.get("schemaVersion") != 1:
986
+ raise ValueError("BUNDLE_IDENTITY_INVALID: .harness-build.json schema")
987
+ agent = str(build.get("agent") or "").strip()
988
+ core_hash = str(build.get("coreHash") or "").strip()
989
+ if not agent or not core_hash:
990
+ raise ValueError("BUNDLE_IDENTITY_INVALID: agent/coreHash is required")
991
+
992
+ # 「这个 bundle 是否可信」和「现在是哪个工具在跑」是两件事。以前
993
+ # executor_tool != agent 直接 BUNDLE_IDENTITY_MISMATCH,于是 Codex 换
994
+ # CodeBuddy 接手同一个 change 会被当成 bundle 身份不符挡下来。下面每一项
995
+ # 校验用的都是 bundle 自称的 agent,与当前工具无关;工具名降级为审计字段,
996
+ # 随 phase.start 事件留痕。
997
+ executor_matches_bundle = not executor_tool or executor_tool == agent
998
+
999
+ context = _read_json(project / ".harness" / "context-index.json")
1000
+ installed = _read_json(
1001
+ project / ".harness" / "state" / "local" / "installed-harness-bundle.json"
1002
+ )
1003
+ if not isinstance(context, dict) or not isinstance(installed, dict):
1004
+ raise ValueError("BUNDLE_IDENTITY_INVALID: context or installed state")
1005
+ adapters = context.get("project", {}).get("adapters", {})
1006
+ adapter = adapters.get(agent) if isinstance(adapters, dict) else None
1007
+ if not isinstance(adapter, dict):
1008
+ raise ValueError(f"BUNDLE_IDENTITY_MISMATCH: adapter {agent} is not configured")
1009
+ configured_root = (project / str(adapter.get("skills_root") or "")).resolve()
1010
+ if configured_root != skills_root:
1011
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: skills root differs from context-index")
1012
+ bundle = context.get("skill_bundles", {}).get(agent)
1013
+ if not isinstance(bundle, dict):
1014
+ raise ValueError("BUNDLE_IDENTITY_INVALID: context bundle metadata missing")
1015
+ registry_version = str(bundle.get("registry_version") or "")
1016
+ bundle_hash = str(bundle.get("bundle_hash") or "")
1017
+ profile = str((installed.get("profiles") or {}).get(agent) or "")
1018
+ manifests = installed.get("manifests")
1019
+ manifest = next((item for item in manifests if isinstance(item, dict)
1020
+ and item.get("adapter") == agent and item.get("profile") == profile), None) \
1021
+ if isinstance(manifests, list) else None
1022
+ if not isinstance(manifest, dict) or \
1023
+ str(manifest.get("bundle_version") or "") != registry_version or \
1024
+ str(manifest.get("bundle_manifest_hash") or "") != bundle_hash:
1025
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: installed manifest differs from context-index")
1026
+ try:
1027
+ marker_target = build_path.relative_to(project).as_posix()
1028
+ except ValueError as exc:
1029
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: skills root is outside project") from exc
1030
+ files = installed.get("files")
1031
+ marker = next((item for item in files if isinstance(item, dict)
1032
+ and item.get("owner") == agent
1033
+ and str(item.get("target_path") or "").replace("\\", "/") == marker_target), None) \
1034
+ if isinstance(files, list) else None
1035
+ actual_hash = _sha256_file(build_path)
1036
+ if not isinstance(marker, dict) or str(marker.get("sha256") or "") != actual_hash:
1037
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: installed build marker hash drifted; refresh required")
1038
+ return {
1039
+ "skillsRoot": str(skills_root),
1040
+ "registryVersion": registry_version,
1041
+ "bundleHash": bundle_hash,
1042
+ "coreHash": core_hash,
1043
+ "overlay": str(build.get("overlay") or "none"),
1044
+ "profile": profile,
1045
+ "adapter": agent,
1046
+ "buildMarkerHash": actual_hash,
1047
+ "contextIndexPresent": True,
1048
+ "executorTool": executor_tool or None,
1049
+ "executorMatchesBundle": executor_matches_bundle,
1050
+ }
1051
+
1052
+
1053
+ def read_identity(skills_root: Path) -> dict[str, Any]:
1054
+ """Backward-compatible marker reader used by callers that only need display data."""
1055
+ identity: dict[str, Any] = {"skillsRoot": str(skills_root.resolve())}
1056
+ build_path = skills_root / ".harness-build.json"
1057
+ if build_path.is_file():
1058
+ try:
1059
+ build = _read_json(build_path)
1060
+ if isinstance(build, dict):
1061
+ for key in (
1062
+ "registryVersion",
1063
+ "bundleHash",
1064
+ "coreHash",
1065
+ "overlayHash",
1066
+ "profile",
1067
+ "adapter",
1068
+ ):
1069
+ if key in build:
1070
+ identity[key] = build[key]
1071
+ except (OSError, json.JSONDecodeError):
1072
+ identity["buildReadError"] = True
1073
+ context_path = skills_root / ".harness" / "context-index.json"
1074
+ if not context_path.is_file():
1075
+ alt = skills_root.parent / ".harness" / "context-index.json"
1076
+ if alt.is_file():
1077
+ context_path = alt
1078
+ if context_path.is_file():
1079
+ try:
1080
+ context = _read_json(context_path)
1081
+ if isinstance(context, dict):
1082
+ identity["contextIndexPresent"] = True
1083
+ except (OSError, json.JSONDecodeError):
1084
+ identity["contextIndexPresent"] = False
1085
+ return identity
1086
+
1087
+
1088
+ def resolve_skills_root(raw: str | None) -> Path | None:
1089
+ """Resolve an explicit root or the installed bundle beside this script."""
1090
+ if isinstance(raw, str) and raw.strip():
1091
+ return Path(raw).expanduser().resolve()
1092
+ adjacent = SCRIPTS_DIR.parent.resolve()
1093
+ return adjacent if (adjacent / ".harness-build.json").is_file() else None
1094
+
1095
+
1096
+ def change_code_root(change_dir: Path) -> Path:
1097
+ project = change_dir.parents[2]
1098
+ inferred = hl.infer_execution_project_root(change_dir)
1099
+ if inferred is not None:
1100
+ return inferred
1101
+ if hl.declares_execution_worktree(change_dir):
1102
+ raise ValueError(
1103
+ "EXECUTION_WORKTREE_INVALID: declared worktree is missing, not a Git root, "
1104
+ "or belongs to another repository"
1105
+ )
1106
+ return project
1107
+
1108
+
1109
+ def _design_capabilities(change_dir: Path) -> list[str]:
1110
+ """Read explicit capability tags from design/plan YAML frontmatter."""
1111
+ capabilities: set[str] = set()
1112
+ candidates: list[Path] = []
1113
+ for directory in (change_dir / "spec", change_dir / "plans"):
1114
+ if directory.is_dir():
1115
+ candidates.extend(sorted(directory.glob("*.md")))
1116
+ for candidate in candidates:
1117
+ text = candidate.read_text(encoding="utf-8", errors="replace")
1118
+ if not text.startswith("---"):
1119
+ continue
1120
+ end = text.find("\n---", 3)
1121
+ if end < 0:
1122
+ continue
1123
+ frontmatter = text[3:end]
1124
+ inline = re.search(r"^capabilities\s*:\s*\[([^\]]*)\]\s*$", frontmatter, re.MULTILINE)
1125
+ if inline:
1126
+ values = (item.strip().strip("'\"") for item in inline.group(1).split(","))
1127
+ capabilities.update(item for item in values if item in DESIGN_GATE_CAPABILITIES)
1128
+ continue
1129
+ block = re.search(
1130
+ r"^capabilities\s*:\s*$((?:\n\s*-\s*[^\n]+)+)",
1131
+ frontmatter,
1132
+ re.MULTILINE,
1133
+ )
1134
+ if block:
1135
+ for item in re.findall(r"^\s*-\s*([^\n]+)$", block.group(1), re.MULTILINE):
1136
+ value = item.strip().strip("'\"")
1137
+ if value in DESIGN_GATE_CAPABILITIES:
1138
+ capabilities.add(value)
1139
+ return sorted(capabilities)
1140
+
1141
+
1142
+ def _diff_capabilities(changed: list[str]) -> list[str]:
1143
+ """Conservatively infer capabilities from the owned post-run diff."""
1144
+ lowered = "\n".join(path.lower().replace("\\", "/") for path in changed)
1145
+ found: set[str] = set()
1146
+ if any(marker in lowered for marker in ("deploy", "helm", "k8s", "kubernetes")):
1147
+ found.add("deployment")
1148
+ if any(marker in lowered for marker in ("dockerfile", "container", "compose.y")):
1149
+ found.add("container")
1150
+ if any(marker in lowered for marker in ("/api/", "openapi", "swagger", "controller")):
1151
+ found.add("api")
1152
+ if any(marker in lowered for marker in ("migration", "/sql/", ".sql", "schema")):
1153
+ found.add("database")
1154
+ return sorted(found)
1155
+
1156
+
1157
+ def _ordered_unique(items: list[str]) -> list[str]:
1158
+ return list(dict.fromkeys(items))
1159
+
1160
+
1161
+ FINAL_SEQUENCE_CANONICAL_ORDER = (
1162
+ "complete-review",
1163
+ "code-freeze",
1164
+ "unit-test-full",
1165
+ "delta-review",
1166
+ "submit-reuse",
1167
+ "remote-candidate-ci",
1168
+ "archive",
1169
+ )
1170
+
1171
+
1172
+ def _sequence_slug(value: str) -> str:
1173
+ expanded = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value.strip())
1174
+ return re.sub(r"[^a-z0-9]+", "-", expanded.lower()).strip("-")
1175
+
1176
+
1177
+ def compile_final_sequence_dag(
1178
+ base_dag: dict[str, Any],
1179
+ sequence: list[Any],
1180
+ ) -> dict[str, Any]:
1181
+ """Compile a declared release sequence into an executable, versioned DAG."""
1182
+ raw_nodes = base_dag.get("nodes")
1183
+ raw_edges = base_dag.get("edges")
1184
+ if not isinstance(raw_nodes, list) or not isinstance(raw_edges, list):
1185
+ raise ValueError("base requiredGateDag must contain nodes and edges arrays")
1186
+ if not sequence:
1187
+ raise ValueError("finalSequence must not be empty")
1188
+
1189
+ entries: list[dict[str, Any]] = []
1190
+ seen: set[str] = set()
1191
+ for index, raw in enumerate(sequence):
1192
+ if isinstance(raw, str):
1193
+ name = raw
1194
+ metadata: dict[str, Any] = {}
1195
+ elif isinstance(raw, dict):
1196
+ name = str(raw.get("id") or raw.get("name") or raw.get("step") or "")
1197
+ metadata = {
1198
+ key: value
1199
+ for key, value in raw.items()
1200
+ if key not in {"id", "name", "step", "dependsOn", "kind"}
1201
+ }
1202
+ else:
1203
+ raise ValueError(f"finalSequence[{index}] must be a string or object")
1204
+ slug = _sequence_slug(name)
1205
+ if not slug:
1206
+ raise ValueError(f"finalSequence[{index}] requires a non-empty name")
1207
+ if slug in seen:
1208
+ raise ValueError(f"duplicate finalSequence step: {slug}")
1209
+ seen.add(slug)
1210
+ entries.append({"slug": slug, "metadata": metadata})
1211
+
1212
+ positions = {entry["slug"]: index for index, entry in enumerate(entries)}
1213
+ present_order = [
1214
+ name for name in FINAL_SEQUENCE_CANONICAL_ORDER if name in positions
1215
+ ]
1216
+ for left, right in zip(present_order, present_order[1:]):
1217
+ if positions[left] > positions[right]:
1218
+ raise ValueError(
1219
+ f"finalSequence must place {left} before {right}; "
1220
+ "code-freeze must precede unit-test-full"
1221
+ )
1222
+ if "archive" in positions and positions["archive"] != len(entries) - 1:
1223
+ raise ValueError("finalSequence archive step must be last")
1224
+
1225
+ nodes = [dict(node) for node in raw_nodes if isinstance(node, dict)]
1226
+ edges = [dict(edge) for edge in raw_edges if isinstance(edge, dict)]
1227
+ base_ids = {
1228
+ str(node.get("id") or "")
1229
+ for node in nodes
1230
+ if str(node.get("id") or "")
1231
+ }
1232
+ depended_on = {
1233
+ str(dependency)
1234
+ for node in nodes
1235
+ for dependency in (node.get("dependsOn") or [])
1236
+ if isinstance(dependency, str)
1237
+ }
1238
+ base_terminals = sorted(base_ids - depended_on)
1239
+
1240
+ sequence_ids: list[str] = []
1241
+ predecessor_ids = base_terminals
1242
+ freeze_id = "sequence:code-freeze"
1243
+ for index, entry in enumerate(entries):
1244
+ node_id = f"sequence:{entry['slug']}"
1245
+ sequence_ids.append(node_id)
1246
+ node = {
1247
+ "id": node_id,
1248
+ "kind": "sequence",
1249
+ "phase": "archive",
1250
+ "sequenceIndex": index,
1251
+ "dependsOn": list(predecessor_ids),
1252
+ "subjectBound": True,
1253
+ "invalidatedByProductChange": (
1254
+ freeze_id in sequence_ids or node_id == freeze_id
1255
+ ),
1256
+ **entry["metadata"],
1257
+ }
1258
+ nodes.append(node)
1259
+ edges.extend(
1260
+ {"from": dependency, "to": node_id}
1261
+ for dependency in predecessor_ids
1262
+ )
1263
+ predecessor_ids = [node_id]
1264
+
1265
+ return {
1266
+ "schemaVersion": 2,
1267
+ "nodes": nodes,
1268
+ "edges": edges,
1269
+ "finalSequence": {
1270
+ "schemaVersion": 1,
1271
+ "nodeIds": sequence_ids,
1272
+ "freezeNodeId": (
1273
+ freeze_id if freeze_id in sequence_ids else None
1274
+ ),
1275
+ "terminalNodeId": sequence_ids[-1],
1276
+ },
1277
+ }
1278
+
1279
+
1280
+ def _load_configured_final_sequence(
1281
+ project: Path,
1282
+ change_dir: Path,
1283
+ ) -> list[Any] | None:
1284
+ candidates = (
1285
+ change_dir / "meta" / "final-sequence.json",
1286
+ project / ".harness" / "config" / "final-sequence.json",
1287
+ )
1288
+ for path in candidates:
1289
+ if not path.is_file():
1290
+ continue
1291
+ raw = _read_json(path)
1292
+ if isinstance(raw, list):
1293
+ return raw
1294
+ if isinstance(raw, dict) and isinstance(raw.get("finalSequence"), list):
1295
+ return raw["finalSequence"]
1296
+ raise ValueError(f"final sequence configuration is invalid: {path}")
1297
+ return None
1298
+
1299
+
1300
+ def _apply_required_gate_contract(
1301
+ payload: dict[str, Any],
1302
+ workflow: dict[str, Any],
1303
+ capabilities: list[str],
1304
+ ) -> dict[str, Any]:
1305
+ """Expand tier + capability facts into the persisted required-gate DAG."""
1306
+ result = dict(payload)
1307
+ tier = str(result["tier"])
1308
+ tier_policy = workflow["riskTiers"][tier]
1309
+ selected = sorted(set(capabilities) & set(workflow["capabilityGates"]))
1310
+ signals = list(result.get("signals") or [])
1311
+ required = list(tier_policy["requiredValidations"])
1312
+ required_stages: set[str] = set()
1313
+ for capability in selected:
1314
+ contract = workflow["capabilityGates"][capability]
1315
+ signals.extend(contract["signals"])
1316
+ required.extend(contract["requiredValidations"])
1317
+ required_stages.update(contract["requiredStages"])
1318
+ signals = sorted(set(signals))
1319
+ required = _ordered_unique(required)
1320
+
1321
+ stage_decisions = _stage_decisions_for_tier(workflow, tier, signals)
1322
+ for stage_name in required_stages:
1323
+ decision = stage_decisions.setdefault(
1324
+ stage_name,
1325
+ {"required": False, "reason": "not-triggered", "matchedSignals": []},
1326
+ )
1327
+ decision["required"] = True
1328
+ if decision["reason"] == "not-triggered":
1329
+ decision["reason"] = "capability"
1330
+
1331
+ validation_phases = workflow["validationPhases"]
1332
+ by_phase: dict[str, list[str]] = {}
1333
+ for verification in required:
1334
+ phase = validation_phases.get(verification)
1335
+ if isinstance(phase, str) and phase:
1336
+ by_phase.setdefault(phase, []).append(verification)
1337
+
1338
+ nodes: list[dict[str, Any]] = []
1339
+ edges: list[dict[str, str]] = []
1340
+ required_set = set(required)
1341
+ for verification in required:
1342
+ dependencies = [
1343
+ item for item in VALIDATION_DEPENDENCIES.get(verification, ())
1344
+ if item in required_set
1345
+ ]
1346
+ node_id = f"validation:{verification}"
1347
+ nodes.append({
1348
+ "id": node_id,
1349
+ "kind": "validation",
1350
+ "phase": validation_phases.get(verification),
1351
+ "dependsOn": [f"validation:{item}" for item in dependencies],
1352
+ })
1353
+ edges.extend(
1354
+ {"from": f"validation:{item}", "to": node_id}
1355
+ for item in dependencies
1356
+ )
1357
+ for stage_name, decision in sorted(stage_decisions.items()):
1358
+ if not decision.get("required"):
1359
+ continue
1360
+ if stage_name == "package":
1361
+ dependencies = [
1362
+ f"validation:{item}" for item in required
1363
+ if hp.resolve_phase_name(validation_phases.get(item)) == "execute"
1364
+ ]
1365
+ elif stage_name == "apidoc" and "apiTest" in required_set:
1366
+ dependencies = ["validation:apiTest"]
1367
+ else:
1368
+ dependencies = []
1369
+ node_id = f"stage:{stage_name}"
1370
+ nodes.append({
1371
+ "id": node_id,
1372
+ "kind": "stage",
1373
+ "phase": stage_name,
1374
+ "dependsOn": dependencies,
1375
+ })
1376
+ edges.extend({"from": item, "to": node_id} for item in dependencies)
1377
+
1378
+ result["capabilities"] = selected
1379
+ result["signals"] = signals
1380
+ result["requiredValidations"] = required
1381
+ result["requiredValidationsByPhase"] = by_phase
1382
+ result["stageDecisions"] = stage_decisions
1383
+ result["requiredGateDag"] = {"schemaVersion": 1, "nodes": nodes, "edges": edges}
1384
+ return result
1385
+
1386
+
1387
+ def classify_risk(
1388
+ change_dir: Path,
1389
+ stage: str,
1390
+ workflow: dict[str, Any] | None = None,
1391
+ ) -> dict[str, Any]:
1392
+ # 无风险信号时的起步档。曾经是 full,于是每个普通变更都默认背上
1393
+ # plan→execute→review→submit→archive 五阶段和 apiTest;风险信号推断只在
1394
+ # --stage post-run 下跑,而生产流程没有任何地方调用它,起步值实际就是终值。
1395
+ # standard 保留 compile/unitTest/unitTestFull 的证据闭环,只去掉默认的
1396
+ # review 阶段与 apiTest。下面的单调升级逻辑不变,有信号照样升到 full。
1397
+ tier = "standard"
1398
+ source = "default-standard"
1399
+ capabilities = _design_capabilities(change_dir)
1400
+ plan_path = change_dir / "plans"
1401
+ for candidate in sorted(plan_path.glob("*.md")) if plan_path.is_dir() else []:
1402
+ text = candidate.read_text(encoding="utf-8", errors="replace")
1403
+ match = re.search(r"风险等级[::]\s*(fast|standard|full)", text, re.IGNORECASE)
1404
+ if match:
1405
+ tier = match.group(1).lower()
1406
+ source = f"plan:{candidate.name}"
1407
+ break
1408
+ match = re.search(r"risk[^:]*:\s*(fast|standard|full)", text, re.IGNORECASE)
1409
+ if match:
1410
+ tier = match.group(1).lower()
1411
+ source = f"plan:{candidate.name}"
1412
+ break
1413
+ signals: list[str] = []
1414
+ if stage == "post-run":
1415
+ project = change_code_root(change_dir)
1416
+ proc = subprocess.run(
1417
+ ["git", "status", "--porcelain", "--untracked-files=all"],
1418
+ cwd=project,
1419
+ capture_output=True,
1420
+ text=True,
1421
+ encoding="utf-8",
1422
+ errors="replace",
1423
+ check=False,
1424
+ )
1425
+ changed: list[str] = []
1426
+ for line in proc.stdout.splitlines():
1427
+ raw = line[3:].strip().strip('"').replace("\\", "/")
1428
+ if not raw:
1429
+ continue
1430
+ if " -> " in raw:
1431
+ raw = raw.rsplit(" -> ", 1)[-1].strip().strip('"')
1432
+ changed.append(raw)
1433
+ try:
1434
+ contract = hp.load_change_contract(change_dir)
1435
+ except (OSError, ValueError, json.JSONDecodeError):
1436
+ contract = {}
1437
+ ownership = contract.get("ownership") if isinstance(contract, dict) else None
1438
+ modern_scope = isinstance(ownership, dict) and bool(
1439
+ ownership.get("productPaths") or ownership.get("staticEvidencePaths")
1440
+ )
1441
+ product_paths: list[str] = []
1442
+ static_paths: list[str] = []
1443
+ excluded_paths: list[str] = []
1444
+ maintenance_paths: list[str] = []
1445
+ foreign_paths: list[str] = []
1446
+
1447
+ def is_harness_maintenance(path: str) -> bool:
1448
+ normalized = path.replace("\\", "/")
1449
+ return normalized.startswith(
1450
+ (
1451
+ ".cursor/skills/",
1452
+ ".claude/skills/",
1453
+ ".agents/skills/",
1454
+ ".codebuddy/skills/",
1455
+ )
1456
+ ) or normalized in {
1457
+ ".harness/context-index.json",
1458
+ ".harness/config/build-profile.json",
1459
+ }
1460
+
1461
+ for path in changed:
1462
+ if modern_scope:
1463
+ category = hl._classify_ownership_path(
1464
+ path, change_dir.name, ownership
1465
+ )
1466
+ if category == "owned":
1467
+ product_paths.append(path)
1468
+ elif category == "staticEvidence":
1469
+ static_paths.append(path)
1470
+ elif category == "excludedRuntime":
1471
+ excluded_paths.append(path)
1472
+ elif is_harness_maintenance(path):
1473
+ maintenance_paths.append(path)
1474
+ else:
1475
+ foreign_paths.append(path)
1476
+ elif path.startswith(".harness/") or is_harness_maintenance(path):
1477
+ maintenance_paths.append(path)
1478
+ else:
1479
+ product_paths.append(path)
1480
+ capabilities = sorted(
1481
+ set(capabilities) | set(_diff_capabilities(product_paths))
1482
+ )
1483
+ lowered = "\n".join(product_paths).lower()
1484
+ full_markers = {
1485
+ "auth": ("auth", "token", "credential", "permission"),
1486
+ "security": ("security", "secret", "crypto"),
1487
+ "migration": ("migration", "migrate", "/sql/", ".sql"),
1488
+ "concurrency": ("concurr", "lock", "lease", "transaction"),
1489
+ "artifact-protocol": ("artifact", "protocol", "manifest", "baseline"),
1490
+ "shared-state": ("shared", "state/", "workflow-policy"),
1491
+ "delete": ("delete", "purge", "archive"),
1492
+ }
1493
+ for signal, markers in full_markers.items():
1494
+ if any(marker in lowered for marker in markers):
1495
+ signals.append(signal)
1496
+ if signals:
1497
+ observed = "full"
1498
+ elif product_paths and all(
1499
+ path.lower().endswith((".md", ".txt", ".rst"))
1500
+ or path.lower().startswith("docs/")
1501
+ for path in product_paths
1502
+ ):
1503
+ observed = "fast"
1504
+ signals.append("docs-only")
1505
+ elif product_paths:
1506
+ observed = "standard"
1507
+ signals.append("production-code")
1508
+ else:
1509
+ observed = tier
1510
+ signals.append("no-code-diff")
1511
+ rank = {"fast": 0, "standard": 1, "full": 2}
1512
+ if rank[observed] > rank[tier]:
1513
+ tier = observed
1514
+ source = f"{source}+post-run"
1515
+ main_project = change_dir.parents[2]
1516
+ if workflow is None:
1517
+ workflow = _load_workflow_policy(project=main_project)
1518
+ tier_policy = workflow["riskTiers"][tier]
1519
+ unique_signals = sorted(set(signals))
1520
+ payload = {
1521
+ "ok": True,
1522
+ "code": "CLASSIFIED",
1523
+ "stage": stage,
1524
+ "tier": tier,
1525
+ "source": source,
1526
+ "changeId": change_dir.name,
1527
+ "signals": unique_signals,
1528
+ "defaultPhases": list(tier_policy["defaultPhases"]),
1529
+ "requiredValidations": list(tier_policy["requiredValidations"]),
1530
+ "conditionalStages": list(tier_policy["conditionalStages"]),
1531
+ "stageDecisions": _stage_decisions_for_tier(workflow, tier, unique_signals),
1532
+ }
1533
+ if stage == "post-run":
1534
+ payload["workspaceBreakdown"] = {
1535
+ "productPaths": sorted(set(product_paths)),
1536
+ "staticEvidencePaths": sorted(set(static_paths)),
1537
+ "excludedRuntimePaths": sorted(set(excluded_paths)),
1538
+ "harnessMaintenancePaths": sorted(set(maintenance_paths)),
1539
+ "foreignPaths": sorted(set(foreign_paths)),
1540
+ }
1541
+ if stage == "post-run":
1542
+ _write_json(change_dir / "meta" / "risk-classification.json", payload)
1543
+ result = _apply_required_gate_contract(payload, workflow, capabilities)
1544
+ configured_sequence = _load_configured_final_sequence(main_project, change_dir)
1545
+ if configured_sequence is not None:
1546
+ result["requiredGateDag"] = compile_final_sequence_dag(
1547
+ result["requiredGateDag"],
1548
+ configured_sequence,
1549
+ )
1550
+ result["finalSequence"] = result["requiredGateDag"]["finalSequence"]
1551
+ return result
1552
+
1553
+
1554
+ def _stage_decisions_for_tier(
1555
+ workflow: dict[str, Any], tier: str, signals: list[str]
1556
+ ) -> dict[str, dict[str, Any]]:
1557
+ tier_policy = workflow["riskTiers"][tier]
1558
+ unique_signals = sorted(set(signals))
1559
+ stage_decisions: dict[str, dict[str, Any]] = {}
1560
+ for stage_name, stage_policy in workflow["conditionalStages"].items():
1561
+ default_required = stage_name in tier_policy["defaultPhases"]
1562
+ matched = sorted(set(unique_signals) & set(stage_policy["signals"]))
1563
+ signal_required = tier in stage_policy["tiers"] and bool(matched)
1564
+ stage_decisions[stage_name] = {
1565
+ "required": default_required or signal_required,
1566
+ "reason": (
1567
+ "tier-default" if default_required
1568
+ else "signal:" + ",".join(matched) if signal_required
1569
+ else "not-triggered"
1570
+ ),
1571
+ "matchedSignals": matched,
1572
+ }
1573
+ return stage_decisions
1574
+
1575
+
1576
+ def apply_tier_override(
1577
+ payload: dict[str, Any],
1578
+ workflow: dict[str, Any],
1579
+ *,
1580
+ tier: str,
1581
+ override_by: str,
1582
+ ) -> dict[str, Any]:
1583
+ """Rebind payload to an explicit tier override (source=override)."""
1584
+ tier_policy = workflow["riskTiers"][tier]
1585
+ now = hc.now_iso()
1586
+ signals = list(payload.get("signals") or [])
1587
+ payload = dict(payload)
1588
+ payload["tier"] = tier
1589
+ payload["source"] = "override"
1590
+ payload["defaultPhases"] = list(tier_policy["defaultPhases"])
1591
+ payload["requiredValidations"] = list(tier_policy["requiredValidations"])
1592
+ payload["conditionalStages"] = list(tier_policy["conditionalStages"])
1593
+ payload["stageDecisions"] = _stage_decisions_for_tier(workflow, tier, signals)
1594
+ payload["tierOverride"] = {"tier": tier, "by": override_by, "at": now}
1595
+ result = _apply_required_gate_contract(
1596
+ payload, workflow, list(payload.get("capabilities") or [])
1597
+ )
1598
+ final_sequence = payload.get("finalSequence")
1599
+ node_ids = (
1600
+ final_sequence.get("nodeIds")
1601
+ if isinstance(final_sequence, dict)
1602
+ else None
1603
+ )
1604
+ if isinstance(node_ids, list) and node_ids:
1605
+ sequence = [
1606
+ str(node_id).removeprefix("sequence:")
1607
+ for node_id in node_ids
1608
+ if str(node_id).startswith("sequence:")
1609
+ ]
1610
+ if sequence:
1611
+ result["requiredGateDag"] = compile_final_sequence_dag(
1612
+ result["requiredGateDag"],
1613
+ sequence,
1614
+ )
1615
+ result["finalSequence"] = result["requiredGateDag"][
1616
+ "finalSequence"
1617
+ ]
1618
+ return result
1619
+
1620
+
1621
+ def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
1622
+ """Cross-change contract: meta/gate-policy.json (schemaVersion=1)."""
1623
+ document = {
1624
+ "schemaVersion": 1,
1625
+ "tier": payload["tier"],
1626
+ "source": payload["source"],
1627
+ "defaultPhases": list(payload.get("defaultPhases") or []),
1628
+ "requiredValidations": list(payload.get("requiredValidations") or []),
1629
+ "requiredValidationsByPhase": dict(
1630
+ payload.get("requiredValidationsByPhase") or {}
1631
+ ),
1632
+ "capabilities": list(payload.get("capabilities") or []),
1633
+ "signals": list(payload.get("signals") or []),
1634
+ "stageDecisions": dict(payload.get("stageDecisions") or {}),
1635
+ "requiredGateDag": dict(payload.get("requiredGateDag") or {}),
1636
+ "classifiedAt": payload.get("classifiedAt") or hc.now_iso(),
1637
+ "tierOverride": payload.get("tierOverride"),
1638
+ }
1639
+ if isinstance(payload.get("finalSequence"), dict):
1640
+ document["finalSequence"] = dict(payload["finalSequence"])
1641
+ for optional in ("candidateVerification", "releasePolicy"):
1642
+ if isinstance(payload.get(optional), dict):
1643
+ document[optional] = dict(payload[optional])
1644
+ return document
1645
+
1646
+
1647
+ def classify_defaults(
1648
+ workflow: dict[str, Any],
1649
+ *,
1650
+ change_id: str,
1651
+ stage: str,
1652
+ ) -> dict[str, Any]:
1653
+ # change 目录不存在时的兜底,与 classify_risk 的起步档保持一致。
1654
+ tier = "standard"
1655
+ source = "default-standard"
1656
+ tier_policy = workflow["riskTiers"][tier]
1657
+ payload = {
1658
+ "ok": True,
1659
+ "code": "CLASSIFIED",
1660
+ "stage": stage,
1661
+ "tier": tier,
1662
+ "source": source,
1663
+ "changeId": change_id,
1664
+ "signals": [],
1665
+ "defaultPhases": list(tier_policy["defaultPhases"]),
1666
+ "requiredValidations": list(tier_policy["requiredValidations"]),
1667
+ "conditionalStages": list(tier_policy["conditionalStages"]),
1668
+ "stageDecisions": _stage_decisions_for_tier(workflow, tier, []),
1669
+ }
1670
+ return _apply_required_gate_contract(payload, workflow, [])
1671
+
1672
+
1673
+ def lint_skill_tree(skills_root: Path) -> dict[str, Any]:
1674
+ violations: list[dict[str, Any]] = []
1675
+ if not skills_root.is_dir():
1676
+ return {
1677
+ "ok": False,
1678
+ "code": "SKILLS_ROOT_MISSING",
1679
+ "message": f"skills root not found: {skills_root}",
1680
+ }
1681
+ for path in sorted(skills_root.rglob("*.md")):
1682
+ try:
1683
+ text = path.read_text(encoding="utf-8", errors="replace")
1684
+ except OSError:
1685
+ continue
1686
+ for line_no, line in enumerate(text.splitlines(), start=1):
1687
+ for pattern in FORBIDDEN_SKILL_PATTERNS:
1688
+ if pattern.search(line):
1689
+ violations.append(
1690
+ {
1691
+ "file": str(path.relative_to(skills_root)),
1692
+ "line": line_no,
1693
+ "pattern": pattern.pattern,
1694
+ "text": line.strip(),
1695
+ }
1696
+ )
1697
+ try:
1698
+ workflow = _load_workflow_policy(skills_root=skills_root)
1699
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1700
+ violations.append(
1701
+ {
1702
+ "file": "contracts/workflow-policy.json",
1703
+ "line": 0,
1704
+ "pattern": "valid workflow policy",
1705
+ "text": str(exc),
1706
+ }
1707
+ )
1708
+ workflow = {"skills": {}}
1709
+ active_profile: str | None = None
1710
+ build_marker = skills_root / ".harness-build.json"
1711
+ if build_marker.is_file():
1712
+ try:
1713
+ marker = json.loads(build_marker.read_text(encoding="utf-8"))
1714
+ active_profile = "java" if marker.get("overlay") == "java" else "general"
1715
+ except (OSError, json.JSONDecodeError):
1716
+ active_profile = None
1717
+ for skill_name, contract in sorted(workflow["skills"].items()):
1718
+ if active_profile is not None and active_profile not in contract.get(
1719
+ "profiles", ["general", "java"]
1720
+ ):
1721
+ continue
1722
+ skill_files = sorted(skills_root.rglob(f"{skill_name}/SKILL.md"))
1723
+ if not skill_files:
1724
+ violations.append(
1725
+ {
1726
+ "file": skill_name,
1727
+ "line": 0,
1728
+ "pattern": "policy skill exists",
1729
+ "text": f"missing {skill_name}/SKILL.md",
1730
+ }
1731
+ )
1732
+ continue
1733
+ required = tuple(
1734
+ marker
1735
+ for capability in contract.get("capabilities", [])
1736
+ for marker in CAPABILITY_MARKERS.get(capability, ())
1737
+ )
1738
+ for skill_file in skill_files:
1739
+ text = skill_file.read_text(encoding="utf-8", errors="replace")
1740
+ for marker in required:
1741
+ if marker not in text:
1742
+ violations.append(
1743
+ {
1744
+ "file": str(skill_file.relative_to(skills_root)),
1745
+ "line": 0,
1746
+ "pattern": marker,
1747
+ "text": f"missing capability command: {marker}",
1748
+ }
1749
+ )
1750
+ return {
1751
+ "ok": len(violations) == 0,
1752
+ "code": "LINT_OK" if not violations else "SKILL_CONTRACT_VIOLATION",
1753
+ "violations": violations,
1754
+ "skillsRoot": str(skills_root.resolve()),
1755
+ }
1756
+
1757
+
1758
+ def append_phase_event(
1759
+ change_dir: Path,
1760
+ *,
1761
+ phase: str,
1762
+ type_: str,
1763
+ status: str | None = None,
1764
+ note: str = "",
1765
+ identity: dict[str, Any] | None = None,
1766
+ run_id: str | None = None,
1767
+ executor_tool: str | None = None,
1768
+ executor_agent: str | None = None,
1769
+ executor_model: str | None = None,
1770
+ code: str | None = None,
1771
+ fixback: bool = False,
1772
+ ) -> dict[str, Any]:
1773
+ """Skill-facing phase lifecycle append (begin/close use this directly).
1774
+
1775
+ HH-WF-20260730-001: shares harness_events' write-path auto-seal helpers
1776
+ so a ``phase.start`` here gets the same guarantee as the CLI's
1777
+ ``append_events.py append`` — a still-open prior attempt for the same
1778
+ phase is auto-sealed (``phase.auto_sealed``) before the new start is
1779
+ appended, atomically under the same lock.
1780
+ """
1781
+ events_file = he.events_path(change_dir)
1782
+ lock_path = events_file.with_name(events_file.name + ".lock")
1783
+ auto_sealed: list[dict[str, Any]] = []
1784
+ with he.event_file_lock(lock_path):
1785
+ existing = he.load_events(events_file)
1786
+ phase_attempts = he.split_phase_attempts(
1787
+ [event for event in existing if event.get("phase") == phase]
1788
+ )
1789
+ latest_attempt = max(
1790
+ (
1791
+ int(item.get("attempt"))
1792
+ for item in phase_attempts
1793
+ if isinstance(item.get("attempt"), int)
1794
+ ),
1795
+ default=0,
1796
+ )
1797
+ event_attempt = latest_attempt + 1 if type_ == "phase.start" else latest_attempt or 1
1798
+ args = argparse.Namespace(
1799
+ phase=phase,
1800
+ type=type_,
1801
+ status=status,
1802
+ note=note,
1803
+ command=None,
1804
+ exit_code=None,
1805
+ duration_ms=None,
1806
+ name=None,
1807
+ path=None,
1808
+ kind=None,
1809
+ code=code,
1810
+ severity=None,
1811
+ message=None,
1812
+ decision=None,
1813
+ reason=None,
1814
+ run_id=run_id,
1815
+ attempt=event_attempt,
1816
+ executor_tool=executor_tool,
1817
+ executor_agent=executor_agent,
1818
+ executor_model=executor_model,
1819
+ handoff_from_tool=None,
1820
+ handoff_reason=None,
1821
+ execution_mode=None,
1822
+ decision_reason_code=None,
1823
+ fallback_reason_code=None,
1824
+ # 显式信号优先。以前只嗅探 note 文本,而真正的 fixback 启动路径
1825
+ # (harness_fixback._default_gate_begin)写的 note 是
1826
+ # "开始处理评审中确认需要修改的代码问题。",一个 "fixback" 字都没有
1827
+ # ——于是 fixback 的 run 事件从来没被打上 trigger/from_phase。
1828
+ # note 嗅探保留为兼容回退:手工传 --note 带 fixback 的老用法仍然有效。
1829
+ trigger=("fixback" if fixback or "fixback" in note.lower() else None),
1830
+ from_phase=(
1831
+ "review"
1832
+ if phase == "execute" and (fixback or "fixback" in note.lower())
1833
+ else None
1834
+ ),
1835
+ result_status=None,
1836
+ )
1837
+ event = he.build_event(args, existing)
1838
+ if identity:
1839
+ for key, value in identity.items():
1840
+ if key not in event and value is not None:
1841
+ event[key] = value
1842
+ if type_ == "phase.start":
1843
+ open_attempts = he.open_attempts_for_phase(existing, phase)
1844
+ if open_attempts:
1845
+ inferred_reason = he.infer_auto_seal_reason(
1846
+ open_attempts[-1].get("events") or []
1847
+ )
1848
+ auto_sealed = he.seal_open_phase_attempts(
1849
+ existing, phase=phase, seal_reason=inferred_reason
1850
+ )
1851
+ for seal_event in auto_sealed:
1852
+ he.atomic_append_line(
1853
+ events_file,
1854
+ json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
1855
+ )
1856
+ line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
1857
+ he.atomic_append_line(events_file, line)
1858
+ rendered = False
1859
+ log_path = None
1860
+ if (type_ == "phase.end" or auto_sealed) and \
1861
+ he.execution_log_render_enabled(change_dir):
1862
+ events = he.load_events(events_file)
1863
+ content = he.render_execution_log(events)
1864
+ log_path = he.write_execution_log(change_dir, content)
1865
+ rendered = True
1866
+ he.nudge_remote_sync(change_dir)
1867
+ return {
1868
+ "ok": True,
1869
+ "eventId": event.get("id"),
1870
+ "path": str(events_file),
1871
+ "rendered": rendered,
1872
+ "executionLogPath": str(log_path) if log_path else None,
1873
+ "autoSealed": auto_sealed,
1874
+ }
1875
+
1876
+
1877
+ def record_blocked_attempt(
1878
+ change_dir: Path,
1879
+ *,
1880
+ phase: str,
1881
+ code: str,
1882
+ message: str,
1883
+ run_id: str | None = None,
1884
+ ) -> dict[str, Any]:
1885
+ """Persist a rejected begin as a complete, zero-work attempt."""
1886
+ blocked_run_id = run_id or "blocked-" + uuid.uuid4().hex[:12]
1887
+ append_phase_event(
1888
+ change_dir,
1889
+ phase=phase,
1890
+ type_="phase.start",
1891
+ note=f"begin rejected: {code}",
1892
+ run_id=blocked_run_id,
1893
+ )
1894
+ return append_phase_event(
1895
+ change_dir,
1896
+ phase=phase,
1897
+ type_="phase.end",
1898
+ status="BLOCKED",
1899
+ note=message,
1900
+ run_id=blocked_run_id,
1901
+ code=code,
1902
+ )
1903
+
1904
+
1905
+ def record_gate_blocked(
1906
+ change_dir: Path,
1907
+ *,
1908
+ phase: str,
1909
+ code: str,
1910
+ message: str,
1911
+ run_id: str | None = None,
1912
+ ) -> dict[str, Any]:
1913
+ """Persist a recoverable gate rejection without manufacturing a phase run."""
1914
+ return append_phase_event(
1915
+ change_dir,
1916
+ phase=phase,
1917
+ type_="gate.blocked",
1918
+ status="BLOCKED",
1919
+ note=message,
1920
+ run_id=run_id or "blocked-" + uuid.uuid4().hex[:12],
1921
+ code=code,
1922
+ )
1923
+
1924
+
1925
+ def record_gate_recovered(
1926
+ change_dir: Path,
1927
+ *,
1928
+ phase: str,
1929
+ run_id: str,
1930
+ ) -> dict[str, Any]:
1931
+ """Close the latest unresolved gate.blocked record for this phase."""
1932
+ blocked: dict[str, Any] | None = None
1933
+ for event in reversed(he.load_events(he.events_path(change_dir))):
1934
+ if event.get("phase") != phase:
1935
+ continue
1936
+ if event.get("type") == "gate.recovered":
1937
+ break
1938
+ if event.get("type") == "gate.blocked":
1939
+ blocked = event
1940
+ break
1941
+ if blocked is None:
1942
+ return {"ok": True, "skipped": True, "reason": "no-unresolved-block"}
1943
+ return append_phase_event(
1944
+ change_dir,
1945
+ phase=phase,
1946
+ type_="gate.recovered",
1947
+ status="OK",
1948
+ note="阶段交接已恢复,门禁现已成功启动。",
1949
+ run_id=run_id,
1950
+ code=str(blocked.get("code") or "GATE_BLOCKED"),
1951
+ )
1952
+
1953
+
1954
+ def validate_context_for_gate_begin(
1955
+ project: Path,
1956
+ change_id: str,
1957
+ phase: str,
1958
+ ) -> dict[str, Any]:
1959
+ """Require a completed Context handoff whenever modern Context state exists."""
1960
+ view = hctx.context_view(project, change_id)
1961
+ if not isinstance(view, dict) or not view.get("ok"):
1962
+ return {"ok": True, "code": "CONTEXT_LEGACY_COMPATIBLE"}
1963
+ current = view.get("current")
1964
+ transitions = view.get("transitions")
1965
+ begins = (view.get("attemptHistory") or {}).get("begins")
1966
+ if not isinstance(current, dict):
1967
+ return {"ok": True, "code": "CONTEXT_LEGACY_COMPATIBLE"}
1968
+ current_phase = str(current.get("phase") or view.get("currentPhase") or "")
1969
+ if hp.resolve_phase_name(current_phase) != phase:
1970
+ return {
1971
+ "ok": False,
1972
+ "code": "CONTEXT_HANDOFF_REQUIRED",
1973
+ "message": "阶段交接尚未完成,门禁未启动;请先完成上下文交接。",
1974
+ "expectedPhase": current_phase or None,
1975
+ "requestedPhase": phase,
1976
+ }
1977
+ if isinstance(transitions, list) and transitions:
1978
+ latest = transitions[-1] if isinstance(transitions[-1], dict) else {}
1979
+ receipt_hash = latest.get("receiptHash")
1980
+ begin_items = begins if isinstance(begins, list) else []
1981
+ acknowledged = any(
1982
+ isinstance(item, dict)
1983
+ and hp.resolve_phase_name(item.get("phase")) == phase
1984
+ and item.get("receiptHash") == receipt_hash
1985
+ for item in begin_items
1986
+ )
1987
+ if hp.resolve_phase_name(latest.get("toPhase")) != phase or not acknowledged:
1988
+ return {
1989
+ "ok": False,
1990
+ "code": "CONTEXT_BEGIN_REQUIRED",
1991
+ "message": "阶段交接收据尚未确认,门禁未启动;请先执行上下文 begin。",
1992
+ "requestedPhase": phase,
1993
+ }
1994
+ return {"ok": True, "code": "CONTEXT_READY"}
1995
+
1996
+
1997
+ def _phase_event_exists(change_dir: Path, phase: str, type_: str, run_id: str) -> bool:
1998
+ return any(
1999
+ hp.resolve_phase_name(event.get("phase")) == phase
2000
+ and event.get("type") == type_
2001
+ and event.get("run_id") == run_id
2002
+ for event in he.load_events(he.events_path(change_dir))
2003
+ )
2004
+
2005
+
2006
+ def _latest_phase_end(
2007
+ change_dir: Path,
2008
+ phase: str,
2009
+ run_id: str | None = None,
2010
+ ) -> dict[str, Any] | None:
2011
+ for event in reversed(he.load_events(he.events_path(change_dir))):
2012
+ if (
2013
+ hp.resolve_phase_name(event.get("phase")) != phase
2014
+ or event.get("type") != "phase.end"
2015
+ ):
2016
+ continue
2017
+ if run_id and event.get("run_id") != run_id:
2018
+ continue
2019
+ return event
2020
+ return None
2021
+
2022
+
2023
+ def _latest_open_run_id(change_dir: Path, phase: str) -> str | None:
2024
+ """Return the newest ``phase.start`` run_id that has no matching ``phase.end``.
2025
+
2026
+ A long phase can outlive its lease TTL; close then reports ``LEASE_ABSENT``
2027
+ with no way back. Resuming needs the *original* run_id, so surface it here
2028
+ instead of making the caller reconstruct it from ``events.ndjson``.
2029
+ """
2030
+ started: list[str] = []
2031
+ ended: set[str] = set()
2032
+ for event in he.load_events(he.events_path(change_dir)):
2033
+ if hp.resolve_phase_name(event.get("phase")) != phase:
2034
+ continue
2035
+ run_id = str(event.get("run_id") or "")
2036
+ if not run_id:
2037
+ continue
2038
+ if event.get("type") == "phase.start":
2039
+ started.append(run_id)
2040
+ elif event.get("type") == "phase.end":
2041
+ ended.add(run_id)
2042
+ for run_id in reversed(started):
2043
+ if run_id not in ended:
2044
+ return run_id
2045
+ return None
2046
+
2047
+
2048
+ def _terminal_matches_context_session(
2049
+ project: Path,
2050
+ change_id: str,
2051
+ phase: str,
2052
+ terminal: dict[str, Any],
2053
+ ) -> bool:
2054
+ view = hctx.context_view(project, change_id)
2055
+ current = view.get("current") if isinstance(view, dict) else None
2056
+ if not isinstance(current, dict) or current.get("phase") != phase:
2057
+ return False
2058
+ prepared_at = he.parse_timestamp(current.get("preparedAt"))
2059
+ terminal_at = he.parse_timestamp(
2060
+ terminal.get("timestamp") or terminal.get("occurred_at")
2061
+ )
2062
+ return (
2063
+ prepared_at is not None
2064
+ and terminal_at is not None
2065
+ and terminal_at >= prepared_at
2066
+ )
2067
+
2068
+
2069
+ def _close_context_handoff(
2070
+ project: Path,
2071
+ change_id: str,
2072
+ args: argparse.Namespace,
2073
+ *,
2074
+ status: str,
2075
+ ) -> dict[str, Any] | None:
2076
+ to_phase = getattr(args, "to_phase", None)
2077
+ if not to_phase:
2078
+ return None
2079
+ # 写边界归一:旧名(--to-phase test)落 canonical;未知名交给
2080
+ # close_transition 的 PHASE_UNKNOWN 报错,不在此吞掉。
2081
+ to_phase = hp.resolve_phase_name(to_phase) or str(to_phase)
2082
+ try:
2083
+ return hctx.close_transition(
2084
+ project,
2085
+ change_id,
2086
+ from_phase=args.phase,
2087
+ to_phase=to_phase,
2088
+ executor=getattr(args, "executor", None),
2089
+ artifacts=list(getattr(args, "artifact", None) or []),
2090
+ status=status,
2091
+ )
2092
+ except Exception as exc: # noqa: BLE001 — phase close remains locally durable
2093
+ return {
2094
+ "ok": False,
2095
+ "code": "CONTEXT_HANDOFF_FAILED",
2096
+ "message": str(exc),
2097
+ }
2098
+
2099
+
2100
+ def _sync_after_phase_close(project: Path, change_dir: Path) -> dict[str, Any]:
2101
+ try:
2102
+ return hes.auto_events_sync(project, change_dir)
2103
+ except Exception as exc: # noqa: BLE001 — remote sync is recoverable
2104
+ return {
2105
+ "ok": False,
2106
+ "code": "PLATFORM_EVENTS_SYNC_WARN",
2107
+ "warning": f"events-sync hook failed: {exc}",
2108
+ }
2109
+
2110
+
2111
+ def _scenario_owner_phase_rank(owner_phase: str | None) -> int | None:
2112
+ """Rank a scenario ownerPhase against the lifecycle order, None when unknown."""
2113
+ if not owner_phase:
2114
+ return None
2115
+ normalized = str(owner_phase).strip().lower()
2116
+ # 旧 manifest 的 ownerPhase=run/test 经别名表归一到 execute 后再定秩。
2117
+ resolved = hp.resolve_phase_name(normalized)
2118
+ if resolved is not None:
2119
+ normalized = resolved
2120
+ if normalized not in hpf.VALID_OWNER_PHASES:
2121
+ return None
2122
+ try:
2123
+ return SCENARIO_OWNER_PHASE_ORDER.index(normalized)
2124
+ except ValueError:
2125
+ return None
2126
+
2127
+
2128
+ def _partition_scenarios_by_owner_phase(
2129
+ scenarios: list[Any],
2130
+ required_ids: set[str],
2131
+ phase: str | None,
2132
+ ) -> tuple[set[str], list[str]]:
2133
+ """Split required scenarios into (due now, deferred to a later phase).
2134
+
2135
+ A scenario is deferred only when it declares an `ownerPhase` that ranks
2136
+ strictly after the phase being closed. Scenarios without a usable
2137
+ `ownerPhase` (legacy/v1 manifests) stay due, preserving old behaviour.
2138
+ """
2139
+ closing_rank = _scenario_owner_phase_rank(phase)
2140
+ if closing_rank is None:
2141
+ return set(required_ids), []
2142
+ due: set[str] = set()
2143
+ deferred: set[str] = set()
2144
+ for scenario in scenarios:
2145
+ if not isinstance(scenario, dict):
2146
+ continue
2147
+ scenario_id = str(scenario.get("id") or "").strip()
2148
+ if scenario_id not in required_ids:
2149
+ continue
2150
+ owner_rank = _scenario_owner_phase_rank(scenario.get("ownerPhase"))
2151
+ if owner_rank is not None and owner_rank > closing_rank:
2152
+ deferred.add(scenario_id)
2153
+ else:
2154
+ due.add(scenario_id)
2155
+ # IDs present in required_ids but absent from the scan stay due (defensive).
2156
+ due |= required_ids - due - deferred
2157
+ # A duplicated ID declaring two ownerPhases must fail closed: if any
2158
+ # occurrence is due now, the scenario is due now.
2159
+ deferred -= due
2160
+ return due, sorted(deferred)
2161
+
2162
+
2163
+ def _deferred_hint(deferred_ids: list[str], phase: str | None) -> str:
2164
+ """Append a note so callers never mistake a deferred scenario for a blocker."""
2165
+ if not deferred_ids:
2166
+ return ""
2167
+ return (
2168
+ f"(另有 {len(deferred_ids)} 个场景按 ownerPhase 移交后续阶段,"
2169
+ f"未计入 {phase or '本'} 阶段要求: " + ", ".join(deferred_ids) + ")"
2170
+ )
2171
+
2172
+
2173
+ _PROJECT_ROOT_HELP = (
2174
+ "project root PATH (defaults to resolving from CWD). Takes a path, not a project name."
2175
+ )
2176
+
2177
+
2178
+ def _resolve_project(args: argparse.Namespace) -> Path:
2179
+ """--project 优先,未给时按 CWD 解析主项目根。
2180
+
2181
+ 其他 harness 脚本都把 --project 当项目根且列为必填,只有 gate 例外——调用方
2182
+ 按惯例传过来会被 argparse 拒掉。注意 begin/close 的 --project 是另一层语义
2183
+ (本阶段执行根/worktree),本函数只服务 classify/checkpoint。
2184
+ """
2185
+ raw = getattr(args, "project", None)
2186
+ if raw:
2187
+ return Path(raw).expanduser().resolve()
2188
+ return hc.resolve_main_project_root()
2189
+
2190
+
2191
+ def _validate_scenario_coverage(
2192
+ change_dir: Path, phase: str | None = None
2193
+ ) -> dict[str, Any]:
2194
+ """C9: validate all ledger-required scenarios are covered by ledger entries.
2195
+
2196
+ Reads meta/scenario-manifest.json and evidence/verification-ledger.json.
2197
+ Returns ok=True when a legacy manifest is missing or all required scenarios
2198
+ are covered. A present but empty manifest is always invalid.
2199
+
2200
+ Phase scoping: when `phase` is given, only scenarios whose `ownerPhase`
2201
+ is due by that phase are required to carry passing receipts. Scenarios
2202
+ owned by a later phase (e.g. `ownerPhase=test` at `run` close) are
2203
+ reported under `deferred` instead of blocking the close — this matches the
2204
+ documented hand-off rule in harness-run/SKILL.md ("ownerPhase=test 按计划移交").
2205
+ """
2206
+ manifest_path = change_dir / "meta" / "scenario-manifest.json"
2207
+ if not manifest_path.is_file():
2208
+ return {"ok": True, "code": "MANIFEST_MISSING", "skipped": True}
2209
+ try:
2210
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
2211
+ except (OSError, json.JSONDecodeError) as exc:
2212
+ return {
2213
+ "ok": False,
2214
+ "code": "SCENARIO_MANIFEST_INVALID",
2215
+ "message": f"scenario-manifest.json unreadable: {exc}",
2216
+ }
2217
+ unpacked = hpf.unpack_v2_scenario_manifest(manifest)
2218
+ if unpacked is not None:
2219
+ if not unpacked.get("ok"):
2220
+ return unpacked
2221
+ # 解包成 legacy 形状后,下面的判定逻辑对 v2 与 legacy 完全一致。
2222
+ manifest = unpacked["manifest"]
2223
+ scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
2224
+ if not isinstance(scenarios, list):
2225
+ return {
2226
+ "ok": False,
2227
+ "code": "SCENARIO_MANIFEST_INVALID",
2228
+ "message": "scenario-manifest.json scenarios must be a list",
2229
+ }
2230
+ if not scenarios:
2231
+ return {
2232
+ "ok": False,
2233
+ "code": "SCENARIO_MANIFEST_EMPTY",
2234
+ "message": "scenario-manifest.json must contain at least one scenario",
2235
+ }
2236
+ invalid_items = [
2237
+ index
2238
+ for index, scenario in enumerate(scenarios)
2239
+ if not isinstance(scenario, dict) or not str(scenario.get("id") or "").strip()
2240
+ ]
2241
+ if invalid_items:
2242
+ return {
2243
+ "ok": False,
2244
+ "code": "SCENARIO_MANIFEST_INVALID",
2245
+ "message": f"scenario entries missing IDs at indexes: {invalid_items}",
2246
+ }
2247
+ required_ids = {
2248
+ str(s.get("id"))
2249
+ for s in scenarios
2250
+ if isinstance(s, dict)
2251
+ and (
2252
+ str(s.get("priority", "")).upper() in {"P0", "P1"}
2253
+ or s.get("requiredEvidenceKind") == "ledger"
2254
+ )
2255
+ }
2256
+ if not required_ids:
2257
+ return {"ok": True, "code": "NO_LEDGER_REQUIRED_SCENARIOS"}
2258
+ due_ids, deferred_ids = _partition_scenarios_by_owner_phase(
2259
+ scenarios, required_ids, phase
2260
+ )
2261
+ raw_schema_version = (
2262
+ manifest.get("schemaVersion") if isinstance(manifest, dict) else None
2263
+ )
2264
+ if (
2265
+ not isinstance(raw_schema_version, int)
2266
+ or isinstance(raw_schema_version, bool)
2267
+ or raw_schema_version < 1
2268
+ ):
2269
+ return {
2270
+ "ok": False,
2271
+ "code": "SCENARIO_MANIFEST_INVALID",
2272
+ "message": "scenario-manifest.json schemaVersion must be a positive integer",
2273
+ }
2274
+ schema_version = raw_schema_version
2275
+ if schema_version >= 2:
2276
+ missing_mappings = sorted(
2277
+ str(s.get("id"))
2278
+ for s in scenarios
2279
+ if isinstance(s, dict)
2280
+ and str(s.get("id")) in required_ids
2281
+ and any(
2282
+ not str(s.get(field) or "").strip()
2283
+ for field in ("executableTestId", "testFile", "testTitle")
2284
+ )
2285
+ )
2286
+ if missing_mappings:
2287
+ return {
2288
+ "ok": False,
2289
+ "code": "SCENARIO_MANIFEST_INVALID",
2290
+ "message": "required scenarios are missing executable test identities",
2291
+ "missingMappings": missing_mappings,
2292
+ }
2293
+
2294
+ if not due_ids:
2295
+ # Every required scenario is owned by a later phase — nothing is due
2296
+ # at this close. Report the hand-off instead of blocking.
2297
+ return {
2298
+ "ok": True,
2299
+ "code": "SCENARIO_COVERAGE_DEFERRED",
2300
+ "covered": [],
2301
+ "deferred": deferred_ids,
2302
+ "ownerPhaseScope": phase,
2303
+ "schemaVersion": schema_version,
2304
+ }
2305
+
2306
+ try:
2307
+ ledger, ledger_path = hl.load_ledger(change_dir)
2308
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
2309
+ return {
2310
+ "ok": False,
2311
+ "code": "SCENARIO_COVERAGE_FAILED",
2312
+ "message": f"ledger unreadable: {exc}",
2313
+ "missing": sorted(due_ids),
2314
+ "deferred": deferred_ids,
2315
+ }
2316
+ if ledger is None or ledger_path is None:
2317
+ return {
2318
+ "ok": False,
2319
+ "code": "SCENARIO_COVERAGE_FAILED",
2320
+ "message": "ledger missing; cannot verify required scenario coverage",
2321
+ "missing": sorted(due_ids),
2322
+ "deferred": deferred_ids,
2323
+ }
2324
+ if schema_version >= 2:
2325
+ coverage_sets: dict[str, set[str]] = {
2326
+ key: set()
2327
+ for key in (
2328
+ "declared",
2329
+ "selected",
2330
+ "collected",
2331
+ "executed",
2332
+ "passed",
2333
+ "skipped",
2334
+ "failed",
2335
+ )
2336
+ }
2337
+ bound: set[str] = set()
2338
+ attempts: dict[str, set[int]] = {}
2339
+ invalid_receipts: list[dict[str, str]] = []
2340
+ validations = ledger.get("validations") or {}
2341
+ if isinstance(validations, dict):
2342
+ for verification, entry in validations.items():
2343
+ if not isinstance(entry, dict):
2344
+ continue
2345
+ ids = entry.get("scenarioIds")
2346
+ if not isinstance(ids, list):
2347
+ continue
2348
+ entry_ids = sorted(
2349
+ {
2350
+ str(item).strip()
2351
+ for item in ids
2352
+ if str(item).strip() in required_ids
2353
+ }
2354
+ )
2355
+ if not entry_ids:
2356
+ continue
2357
+ bound.update(entry_ids)
2358
+ receipt = entry.get("scenarioReceipt")
2359
+ if not isinstance(receipt, dict):
2360
+ invalid_receipts.append(
2361
+ {
2362
+ "verification": str(verification),
2363
+ "code": "SCENARIO_RECEIPT_REQUIRED",
2364
+ }
2365
+ )
2366
+ continue
2367
+ result = hl.validate_scenario_execution_receipt(
2368
+ change_dir=change_dir,
2369
+ scenario_ids=entry_ids,
2370
+ receipt=receipt,
2371
+ )
2372
+ if not result.get("ok"):
2373
+ invalid_receipts.append(
2374
+ {
2375
+ "verification": str(verification),
2376
+ "code": str(
2377
+ result.get("code") or "SCENARIO_RECEIPT_INVALID"
2378
+ ),
2379
+ }
2380
+ )
2381
+ continue
2382
+ receipt_coverage = result["coverage"]
2383
+ for key in coverage_sets:
2384
+ if (
2385
+ key == "passed"
2386
+ and str(entry.get("status") or "").upper() != "OK"
2387
+ ):
2388
+ continue
2389
+ values = receipt_coverage.get(key)
2390
+ if isinstance(values, list):
2391
+ coverage_sets[key].update(
2392
+ str(item) for item in values if str(item) in required_ids
2393
+ )
2394
+ receipt_attempt = result["receipt"].get("attempt")
2395
+ if isinstance(receipt_attempt, int):
2396
+ for scenario_id in receipt_coverage.get("executed") or []:
2397
+ attempts.setdefault(str(scenario_id), set()).add(
2398
+ receipt_attempt
2399
+ )
2400
+
2401
+ passed = coverage_sets["passed"]
2402
+ missing = sorted(due_ids - bound)
2403
+ unexecuted = sorted(due_ids - passed)
2404
+ detail = {
2405
+ key: sorted(values & required_ids)
2406
+ for key, values in coverage_sets.items()
2407
+ }
2408
+ detail.update(
2409
+ {
2410
+ "missing": missing,
2411
+ "unexecuted": unexecuted,
2412
+ "deferred": deferred_ids,
2413
+ "ownerPhaseScope": phase,
2414
+ "attempts": {
2415
+ scenario_id: sorted(values)
2416
+ for scenario_id, values in sorted(attempts.items())
2417
+ },
2418
+ "invalidReceipts": invalid_receipts,
2419
+ "schemaVersion": schema_version,
2420
+ }
2421
+ )
2422
+ if unexecuted:
2423
+ return {
2424
+ "ok": False,
2425
+ "code": "REQUIRED_SCENARIO_NOT_EXECUTED",
2426
+ "message": (
2427
+ "required scenarios without exact passed execution receipts: "
2428
+ + ", ".join(unexecuted)
2429
+ + _deferred_hint(deferred_ids, phase)
2430
+ ),
2431
+ **detail,
2432
+ }
2433
+ return {
2434
+ "ok": True,
2435
+ "code": "SCENARIO_COVERAGE_OK",
2436
+ "covered": sorted(passed),
2437
+ **detail,
2438
+ }
2439
+
2440
+ covered: set[str] = set()
2441
+ validations = ledger.get("validations") or {}
2442
+ if isinstance(validations, dict):
2443
+ for entry in validations.values():
2444
+ if isinstance(entry, dict):
2445
+ ids = entry.get("scenarioIds")
2446
+ if isinstance(ids, list):
2447
+ covered.update(str(i) for i in ids)
2448
+ missing = sorted(due_ids - covered)
2449
+ if missing:
2450
+ return {
2451
+ "ok": False,
2452
+ "code": "SCENARIO_COVERAGE_FAILED",
2453
+ "message": (
2454
+ "ledger-required scenarios without ledger entry: "
2455
+ + ", ".join(missing)
2456
+ + _deferred_hint(deferred_ids, phase)
2457
+ ),
2458
+ "missing": missing,
2459
+ "deferred": deferred_ids,
2460
+ "ownerPhaseScope": phase,
2461
+ }
2462
+ return {
2463
+ "ok": True,
2464
+ "code": "SCENARIO_COVERAGE_OK",
2465
+ "covered": sorted(covered & required_ids),
2466
+ "deferred": deferred_ids,
2467
+ "ownerPhaseScope": phase,
2468
+ }
2469
+
2470
+
2471
+ def validate_plan_handoff(change_dir: Path) -> dict[str, Any]:
2472
+ """Verify a finalized modern plan before the first run-side effect."""
2473
+ receipt_path = change_dir / "meta" / "plan-finalization.json"
2474
+ plans_root = change_dir / "plans"
2475
+ has_plan_artifacts = plans_root.is_dir() and any(
2476
+ plans_root.glob("*-plan.md")
2477
+ )
2478
+ if not receipt_path.is_file() and not has_plan_artifacts:
2479
+ return {
2480
+ "ok": True,
2481
+ "code": "PLAN_HANDOFF_NOT_APPLICABLE",
2482
+ "skipped": True,
2483
+ }
2484
+ return hpf.verify_plan(change_dir)
2485
+
2486
+
2487
+ def validate_review_outputs_for_close(
2488
+ change_dir: Path,
2489
+ run_id: str,
2490
+ ) -> dict[str, Any]:
2491
+ """Require complete, run-bound structured Review sidecars before close."""
2492
+
2493
+ findings_path = hr.findings_path(change_dir)
2494
+ dispositions_path = hr.dispositions_path(change_dir)
2495
+ missing = [
2496
+ str(path)
2497
+ for path in (findings_path, dispositions_path)
2498
+ if not path.is_file()
2499
+ ]
2500
+ if missing:
2501
+ return {
2502
+ "ok": False,
2503
+ "code": "REVIEW_OUTPUTS_INCOMPLETE",
2504
+ "message": "评审的结构化发现或处置记录缺失,尚不能结束评审阶段。",
2505
+ "missing": missing,
2506
+ "recoveryAction": "补写 review-findings.json 与 fixback-dispositions.json 后重试关门",
2507
+ }
2508
+ try:
2509
+ findings = json.loads(findings_path.read_text(encoding="utf-8-sig"))
2510
+ dispositions = json.loads(
2511
+ dispositions_path.read_text(encoding="utf-8-sig")
2512
+ )
2513
+ except (OSError, json.JSONDecodeError) as exc:
2514
+ return {
2515
+ "ok": False,
2516
+ "code": "REVIEW_OUTPUTS_INVALID",
2517
+ "message": f"评审结构化记录无法读取:{exc}",
2518
+ }
2519
+ finding_problems = hr.validate_findings(findings, require_ids=True)
2520
+ known_ids = {
2521
+ str(item.get("id"))
2522
+ for item in findings.get("findings", [])
2523
+ if isinstance(item, dict) and item.get("id")
2524
+ }
2525
+ disposition_problems = hr.validate_dispositions(dispositions, known_ids)
2526
+ disposition_ids = {
2527
+ str(item.get("findingId"))
2528
+ for item in dispositions.get("dispositions", [])
2529
+ if isinstance(item, dict) and item.get("findingId")
2530
+ }
2531
+ problems = [*finding_problems, *disposition_problems]
2532
+ if findings.get("runId") != run_id or dispositions.get("runId") != run_id:
2533
+ problems.append("review sidecars must match the active review runId")
2534
+ missing_dispositions = sorted(known_ids - disposition_ids)
2535
+ if missing_dispositions:
2536
+ problems.append(
2537
+ "findings without dispositions: " + ",".join(missing_dispositions)
2538
+ )
2539
+ if problems:
2540
+ return {
2541
+ "ok": False,
2542
+ "code": "REVIEW_OUTPUTS_INVALID",
2543
+ "message": "评审结构化记录不完整或与当前轮次不一致。",
2544
+ "problems": problems,
2545
+ }
2546
+ return {
2547
+ "ok": True,
2548
+ "code": "REVIEW_OUTPUTS_READY",
2549
+ "findingCount": len(known_ids),
2550
+ }
2551
+
2552
+
2553
+ def _normalize_gate_phase(args: argparse.Namespace, as_json: bool) -> int | None:
2554
+ """cmd_begin/cmd_close 入口归一:旧名(run/test)映射到现阶段名。
2555
+
2556
+ 归一后 args.phase 全链路只流动 canonical 名;未知名报错并列出合法名与别名。
2557
+ """
2558
+ resolved = hp.resolve_phase_name(getattr(args, "phase", None))
2559
+ if resolved is not None:
2560
+ args.phase = resolved
2561
+ return None
2562
+ return emit_error(
2563
+ "PHASE_UNKNOWN",
2564
+ f"未知阶段名 {getattr(args, 'phase', None)!r};合法阶段 "
2565
+ f"{list(hp.WORKFLOW_PHASES)},旧名别名 {hp.LEGACY_PHASE_ALIASES}。",
2566
+ as_json=as_json,
2567
+ extra={
2568
+ "allowedPhases": list(hp.WORKFLOW_PHASES),
2569
+ "legacyAliases": dict(hp.LEGACY_PHASE_ALIASES),
2570
+ },
2571
+ )
2572
+
2573
+
2574
+ def cmd_begin(args: argparse.Namespace) -> int:
2575
+ as_json = bool(args.json)
2576
+ normalized = _normalize_gate_phase(args, as_json)
2577
+ if normalized is not None:
2578
+ return normalized
2579
+ project = hc.resolve_main_project_root()
2580
+ resolved = hc.resolve_change(project, args.change)
2581
+ if not resolved.get("ok"):
2582
+ return emit_error(
2583
+ str(resolved.get("code", "RESOLVE_FAILED")),
2584
+ str(resolved.get("message", "change resolve failed")),
2585
+ as_json=as_json,
2586
+ extra={k: v for k, v in resolved.items() if k not in {"ok", "message"}},
2587
+ )
2588
+ change_dir = Path(resolved["changeDir"])
2589
+ context_check = validate_context_for_gate_begin(
2590
+ project, str(resolved["changeId"]), args.phase
2591
+ )
2592
+ if not context_check.get("ok"):
2593
+ code = str(context_check.get("code") or "CONTEXT_HANDOFF_REQUIRED")
2594
+ message = str(
2595
+ context_check.get("message")
2596
+ or "阶段交接尚未完成,门禁未启动。"
2597
+ )
2598
+ record_gate_blocked(
2599
+ change_dir,
2600
+ phase=args.phase,
2601
+ code=code,
2602
+ message=message,
2603
+ run_id=args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID"),
2604
+ )
2605
+ return emit_error(
2606
+ code,
2607
+ message,
2608
+ as_json=as_json,
2609
+ extra={
2610
+ key: value
2611
+ for key, value in context_check.items()
2612
+ if key not in {"ok", "code", "message"}
2613
+ },
2614
+ )
2615
+ severity_mode = gate_severity_mode(project, change_dir)
2616
+ gate_warnings: list[dict[str, Any]] = []
2617
+ plan_verification: dict[str, Any] | None = None
2618
+ if phase_gate_rule(args.phase, "plan_handoff"):
2619
+ plan_verification = validate_plan_handoff(change_dir)
2620
+ if not plan_verification.get("ok"):
2621
+ code = str(
2622
+ plan_verification.get("code") or "PLAN_HANDOFF_INVALID"
2623
+ )
2624
+ message = str(
2625
+ plan_verification.get("error")
2626
+ or plan_verification.get("message")
2627
+ or "finalized plan verification failed"
2628
+ )
2629
+ if gate_soft_allowed(severity_mode, args.phase, "plan-handoff"):
2630
+ gate_warnings.append(record_gate_warning(
2631
+ change_dir,
2632
+ phase=args.phase,
2633
+ site="plan-handoff",
2634
+ code=code,
2635
+ message=message,
2636
+ ))
2637
+ else:
2638
+ return emit_error(
2639
+ code,
2640
+ message,
2641
+ as_json=as_json,
2642
+ extra={
2643
+ key: value
2644
+ for key, value in plan_verification.items()
2645
+ if key not in {"ok", "code", "error", "message"}
2646
+ },
2647
+ )
2648
+ concurrency_block = hc.check_concurrency_block(project, resolved["changeId"])
2649
+ if concurrency_block is not None:
2650
+ record_blocked_attempt(
2651
+ change_dir,
2652
+ phase=args.phase,
2653
+ code=str(concurrency_block.get("code", "CONCURRENCY_BLOCKED")),
2654
+ message=str(
2655
+ concurrency_block.get("message", "concurrency mode blocked begin")
2656
+ ),
2657
+ run_id=args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID"),
2658
+ )
2659
+ return emit_error(
2660
+ str(concurrency_block.get("code", "CONCURRENCY_BLOCKED")),
2661
+ str(concurrency_block.get("message", "concurrency mode blocked begin")),
2662
+ as_json=as_json,
2663
+ extra={k: v for k, v in concurrency_block.items() if k not in {"ok", "message"}},
2664
+ )
2665
+ blocked = foundation_gate_blocks(getattr(args, "task", None), change_dir)
2666
+ if blocked:
2667
+ return emit_error(
2668
+ blocked["code"],
2669
+ blocked["message"],
2670
+ as_json=as_json,
2671
+ extra={k: v for k, v in blocked.items() if k not in {"ok", "message", "code"}},
2672
+ )
2673
+
2674
+ try:
2675
+ policy = _load_workflow_policy(project=project)
2676
+ policy = effective_workflow_policy(policy, change_dir)
2677
+ except (OSError, ValueError, hwp.PolicyValidationError) as exc:
2678
+ return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
2679
+
2680
+ executor_tool = args.executor_tool or os.environ.get("HUNTER_HARNESS_TOOL")
2681
+ skills_root = resolve_skills_root(args.skills_root)
2682
+ if skills_root is None:
2683
+ return emit_error(
2684
+ "BUNDLE_IDENTITY_REQUIRED",
2685
+ "--skills-root is required; refresh the selected Harness adapter if identity is missing",
2686
+ as_json=as_json,
2687
+ )
2688
+ try:
2689
+ identity = validate_identity(project, skills_root, executor_tool)
2690
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
2691
+ return emit_error("BUNDLE_IDENTITY_INVALID", str(exc), as_json=as_json)
2692
+ executor_tool = executor_tool or str(identity.get("adapter") or "") or None
2693
+ projection = evaluate_projection_gate(project, args.phase)
2694
+ if not projection.get("ok"):
2695
+ return emit_error(
2696
+ str(projection.get("code") or "PROJECTION_BLOCKED"),
2697
+ str(projection.get("message") or "projection gate blocked phase"),
2698
+ as_json=as_json,
2699
+ extra={
2700
+ key: value
2701
+ for key, value in projection.items()
2702
+ if key not in {"ok", "code", "message"}
2703
+ },
2704
+ )
2705
+ explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
2706
+ current_lease = hc.inspect_lease(project, resolved["changeId"])
2707
+ run_id = explicit_run_id or (
2708
+ str(current_lease.get("runId"))
2709
+ if isinstance(current_lease, dict) and (
2710
+ hp.resolve_phase_name(current_lease.get("phase")) == args.phase
2711
+ )
2712
+ else "run-" + uuid.uuid4().hex
2713
+ )
2714
+ try:
2715
+ capsule = load_phase_capsule(change_dir, args.phase, run_id)
2716
+ except ValueError as exc:
2717
+ return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
2718
+ execution_hint = resolve_begin_execution_hint(
2719
+ project,
2720
+ requested=args.project,
2721
+ capsule=capsule,
2722
+ )
2723
+ try:
2724
+ execution_root = resolve_execution_root(
2725
+ project,
2726
+ execution_hint["executionRoot"],
2727
+ )
2728
+ except ValueError as exc:
2729
+ return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
2730
+ if capsule is not None:
2731
+ try:
2732
+ validate_phase_capsule(
2733
+ capsule,
2734
+ change_dir=change_dir,
2735
+ change_id=str(resolved["changeId"]),
2736
+ phase=args.phase,
2737
+ run_id=run_id,
2738
+ project=project,
2739
+ execution_root=execution_root,
2740
+ skills_root=skills_root,
2741
+ )
2742
+ except ValueError as exc:
2743
+ if gate_soft_allowed(severity_mode, args.phase, "capsule"):
2744
+ gate_warnings.append(record_gate_warning(
2745
+ change_dir,
2746
+ phase=args.phase,
2747
+ site="capsule",
2748
+ code="PHASE_CAPSULE_MISMATCH",
2749
+ message=str(exc),
2750
+ ))
2751
+ # Stale capsule is regenerable state: rebuild it below.
2752
+ capsule = None
2753
+ else:
2754
+ return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
2755
+ claim = hc.claim_lease(
2756
+ project,
2757
+ change_id=resolved["changeId"],
2758
+ phase=args.phase,
2759
+ run_id=run_id,
2760
+ ttl_seconds=int(args.ttl_seconds),
2761
+ )
2762
+ if not claim.get("ok"):
2763
+ claim_code = str(claim.get("code", "LEASE_CONFLICT"))
2764
+ claim_extra = {
2765
+ k: v for k, v in claim.items() if k not in {"ok", "message", "code"}
2766
+ }
2767
+ holder = claim.get("holder")
2768
+ if claim_code == "LEASE_CONFLICT" and isinstance(holder, dict):
2769
+ holder_phase = str(holder.get("phase") or "")
2770
+ holder_run_id = str(holder.get("runId") or "")
2771
+ # A lease whose own phase already recorded phase.end is leftover state:
2772
+ # only close releases the lease, so any begin-without-close strands it
2773
+ # for the full TTL and blocks the next phase.
2774
+ holder_closed = (
2775
+ bool(holder_phase)
2776
+ and bool(holder_run_id)
2777
+ and _latest_phase_end(change_dir, holder_phase, holder_run_id) is not None
2778
+ )
2779
+ claim_extra["holderPhaseClosed"] = holder_closed
2780
+ if holder_closed:
2781
+ claim_extra["recoveryAction"] = (
2782
+ f"持有租约的 {holder_phase} 阶段(run-id {holder_run_id})已写入 phase.end,"
2783
+ "这是残留租约,释放后重试 begin 是安全的:"
2784
+ "python <skills-root>/scripts/harness_change.py release "
2785
+ f"--change {resolved['changeId']} --phase {holder_phase} "
2786
+ f"--run-id {holder_run_id} --json"
2787
+ )
2788
+ else:
2789
+ claim_extra["recoveryAction"] = (
2790
+ f"{holder_phase or '另一个'} 阶段(run-id {holder_run_id or '未知'})"
2791
+ "仍在进行中且尚未写入 phase.end。先让该阶段正常关门;"
2792
+ "确认其执行进程已不存在时才可释放租约,不要直接 --steal。"
2793
+ )
2794
+ return emit_error(
2795
+ claim_code,
2796
+ str(claim.get("message", "lease claim failed")),
2797
+ as_json=as_json,
2798
+ extra=claim_extra,
2799
+ )
2800
+
2801
+ guard_result: dict[str, Any] | None = None
2802
+ try:
2803
+ if capsule is not None:
2804
+ guard_result = {"ok": True, "code": "SNAPSHOT_REUSED"}
2805
+ else:
2806
+ if phase_gate_rule(args.phase, "test_guard"):
2807
+ guard_result = htg.begin(execution_root, change_dir)
2808
+ if not guard_result.get("ok"):
2809
+ if gate_soft_allowed(severity_mode, args.phase, "test-guard"):
2810
+ gate_warnings.append(record_gate_warning(
2811
+ change_dir,
2812
+ phase=args.phase,
2813
+ site="test-guard",
2814
+ code=str(guard_result.get("code", "TEST_GUARD_BEGIN_FAILED")),
2815
+ message="test guard begin failed (downgraded to WARN)",
2816
+ ))
2817
+ else:
2818
+ record_gate_blocked(
2819
+ change_dir,
2820
+ phase=args.phase,
2821
+ code=str(
2822
+ guard_result.get("code")
2823
+ or "TEST_GUARD_BEGIN_FAILED"
2824
+ ),
2825
+ message="测试保护快照建立失败,已停止阶段启动。",
2826
+ run_id=run_id,
2827
+ )
2828
+ hc.release_lease(
2829
+ project,
2830
+ change_id=resolved["changeId"],
2831
+ phase=args.phase,
2832
+ run_id=run_id,
2833
+ )
2834
+ return emit_error(
2835
+ str(guard_result.get("code", "TEST_GUARD_BEGIN_FAILED")),
2836
+ "test guard begin failed",
2837
+ as_json=as_json,
2838
+ extra=guard_result,
2839
+ )
2840
+ state_root = Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()
2841
+ current_head = _git_text(execution_root, "rev-parse", "--verify", "HEAD")
2842
+ ledger, _ = hl.load_ledger(change_dir)
2843
+ base_commit = ledger.get("baseCommit") if isinstance(ledger, dict) else None
2844
+ try:
2845
+ begin_contract = hp.load_change_contract(change_dir)
2846
+ begin_ownership_hash = hl.ownership_hash(begin_contract)
2847
+ except (OSError, ValueError, json.JSONDecodeError):
2848
+ begin_ownership_hash = None
2849
+ capsule = {
2850
+ "schemaVersion": 1,
2851
+ "changeId": resolved["changeId"],
2852
+ "phase": args.phase,
2853
+ "runId": run_id,
2854
+ "projectRoot": str(project.resolve()),
2855
+ "stateRoot": str(state_root),
2856
+ "executionRoot": str(execution_root),
2857
+ "skillsRoot": str(skills_root.resolve()),
2858
+ "repositoryId": hp.repository_identity(execution_root),
2859
+ "baseCommit": base_commit or current_head,
2860
+ "currentHead": current_head,
2861
+ "ownershipHash": begin_ownership_hash,
2862
+ "createdAt": he.now_iso(),
2863
+ "projectionReceipt": projection,
2864
+ }
2865
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
2866
+ event_result = {"ok": True, "skipped": True, "reason": "already-recorded"} \
2867
+ if _phase_event_exists(change_dir, args.phase, "phase.start", run_id) \
2868
+ else append_phase_event(
2869
+ change_dir,
2870
+ phase=args.phase,
2871
+ type_="phase.start",
2872
+ note=args.note or "",
2873
+ fixback=bool(getattr(args, "fixback", False)),
2874
+ identity=identity,
2875
+ run_id=run_id,
2876
+ executor_tool=executor_tool,
2877
+ executor_agent=args.executor_agent or os.environ.get("HUNTER_HARNESS_AGENT"),
2878
+ executor_model=args.executor_model or os.environ.get("HUNTER_HARNESS_MODEL"),
2879
+ )
2880
+ recovery_result = record_gate_recovered(
2881
+ change_dir,
2882
+ phase=args.phase,
2883
+ run_id=run_id,
2884
+ )
2885
+ except BaseException:
2886
+ hc.release_lease(
2887
+ project, change_id=resolved["changeId"], phase=args.phase, run_id=run_id
2888
+ )
2889
+ raise
2890
+
2891
+ payload = {
2892
+ "ok": True,
2893
+ "code": "PHASE_BEGUN",
2894
+ "phase": args.phase,
2895
+ "changeId": resolved["changeId"],
2896
+ "changeDir": str(change_dir),
2897
+ "projectRoot": str(project),
2898
+ "stateRoot": capsule.get("stateRoot") if capsule else None,
2899
+ "executionRoot": capsule.get("executionRoot") if capsule else str(execution_root),
2900
+ "skillsRoot": capsule.get("skillsRoot") if capsule else str(skills_root.resolve()),
2901
+ "lease": claim.get("lease"),
2902
+ "identity": identity,
2903
+ "event": event_result,
2904
+ "gateRecovery": recovery_result,
2905
+ "testGuard": guard_result,
2906
+ "policySchemaVersion": policy.get("schemaVersion"),
2907
+ "planVerification": plan_verification,
2908
+ "projection": projection,
2909
+ "executionRootSource": execution_hint["source"],
2910
+ "gateSeverityMode": severity_mode,
2911
+ "gateWarnings": gate_warnings,
2912
+ }
2913
+ # C3: best-effort start platform run monitoring (register + running via heartbeat/events).
2914
+ try:
2915
+ monitor = hes.auto_events_sync(project, change_dir)
2916
+ payload["platformMonitor"] = monitor
2917
+ if monitor.get("warning"):
2918
+ warnings_msg = str(monitor["warning"])
2919
+ gate_warnings.append({
2920
+ "code": "PLATFORM_EVENTS_SYNC_WARN",
2921
+ "message": warnings_msg,
2922
+ })
2923
+ payload["gateWarnings"] = gate_warnings
2924
+ except Exception as exc: # noqa: BLE001 — never block phase begin
2925
+ payload["platformMonitor"] = {
2926
+ "ok": False,
2927
+ "warning": f"events-sync hook failed: {exc}",
2928
+ }
2929
+ emit(payload, as_json=as_json)
2930
+ return 0
2931
+
2932
+
2933
+ def cmd_close(args: argparse.Namespace) -> int:
2934
+ as_json = bool(args.json)
2935
+ normalized = _normalize_gate_phase(args, as_json)
2936
+ if normalized is not None:
2937
+ return normalized
2938
+ project = hc.resolve_main_project_root()
2939
+ resolved = hc.resolve_change(project, args.change)
2940
+ if not resolved.get("ok"):
2941
+ return emit_error(
2942
+ str(resolved.get("code", "RESOLVE_FAILED")),
2943
+ str(resolved.get("message", "change resolve failed")),
2944
+ as_json=as_json,
2945
+ extra={k: v for k, v in resolved.items() if k not in {"ok", "message"}},
2946
+ )
2947
+ change_dir = Path(resolved["changeDir"])
2948
+ severity_mode = gate_severity_mode(project, change_dir)
2949
+ gate_warnings: list[dict[str, Any]] = []
2950
+ blocked = foundation_gate_blocks(getattr(args, "task", None), change_dir)
2951
+ if blocked:
2952
+ return emit_error(
2953
+ blocked["code"],
2954
+ blocked["message"],
2955
+ as_json=as_json,
2956
+ extra={k: v for k, v in blocked.items() if k not in {"ok", "message", "code"}},
2957
+ )
2958
+
2959
+ try:
2960
+ policy = _load_workflow_policy(project=project)
2961
+ policy = effective_workflow_policy(policy, change_dir)
2962
+ except (OSError, ValueError, hwp.PolicyValidationError) as exc:
2963
+ return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
2964
+
2965
+ explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
2966
+ current_lease = hc.inspect_lease(project, resolved["changeId"])
2967
+ lease_lapsed: dict[str, Any] | None = None
2968
+ # 只有在"没有活跃租约"时才需要区分不存在/已过期/已损坏——三者要给的答案
2969
+ # 完全不同,而 inspect_lease 把它们一律压成 None。
2970
+ lease_state = (
2971
+ {"state": "active", "lease": current_lease}
2972
+ if current_lease is not None
2973
+ else hc.inspect_lease_state(project, resolved["changeId"])
2974
+ )
2975
+
2976
+ if lease_state["state"] == "corrupt":
2977
+ # 损坏的租约证明不了"没人抢占过",所以不自动恢复——那正是自动重取唯一
2978
+ # 的安全前提。
2979
+ return emit_error(
2980
+ "LEASE_INVALID",
2981
+ "phase lease file is unreadable; it cannot prove the phase was not taken over",
2982
+ as_json=as_json,
2983
+ extra={
2984
+ "phase": args.phase,
2985
+ "changeId": resolved["changeId"],
2986
+ "recoveryAction": (
2987
+ "租约文件已损坏,无法自动恢复。确认没有其他进程在跑同一个 change 后,"
2988
+ "用本阶段原 run-id 重新取得租约再重试 close:"
2989
+ "python <skills-root>/scripts/harness_change.py claim "
2990
+ f"--change {resolved['changeId']} --phase {args.phase} "
2991
+ "--run-id <原 run-id> --ttl-seconds 3600 --json"
2992
+ ),
2993
+ },
2994
+ )
2995
+
2996
+ if lease_state["state"] == "expired":
2997
+ expired_lease = lease_state["lease"] or {}
2998
+ expired_run_id = str(expired_lease.get("runId") or "")
2999
+ expected_run_id = explicit_run_id or expired_run_id
3000
+ # 租约过期、runId 还是本轮的,恰好证明没有第三方抢占过:抢占会重写
3001
+ # 租约文件把 runId 换掉。所以过期只说明这个阶段跑得比 TTL 久,不说明
3002
+ # 所有权变了——harness_context.close_transition 早就是这么推理的
3003
+ # (只记 leaseLapsed 照常收尾),这里把 Gate 侧对齐过去,不再要求
3004
+ # 用户手工 claim 一遍再原样重试。
3005
+ if (
3006
+ expired_run_id
3007
+ and expired_run_id == expected_run_id
3008
+ and hp.resolve_phase_name(expired_lease.get("phase")) == args.phase
3009
+ ):
3010
+ reacquired = hc.claim_lease(
3011
+ project,
3012
+ change_id=str(resolved["changeId"]),
3013
+ phase=args.phase,
3014
+ run_id=expired_run_id,
3015
+ ttl_seconds=int(getattr(args, "ttl_seconds", 0) or 3600),
3016
+ )
3017
+ if not reacquired.get("ok"):
3018
+ return emit_error(
3019
+ str(reacquired.get("code") or "LEASE_CONFLICT"),
3020
+ "expired phase lease could not be reacquired for close",
3021
+ as_json=as_json,
3022
+ extra={
3023
+ "phase": args.phase,
3024
+ "changeId": resolved["changeId"],
3025
+ "holder": reacquired.get("holder"),
3026
+ },
3027
+ )
3028
+ current_lease = reacquired.get("lease")
3029
+ lease_lapsed = {
3030
+ "acquiredAt": expired_lease.get("acquiredAt"),
3031
+ "expiresAt": expired_lease.get("expiresAt"),
3032
+ "reacquiredAt": (current_lease or {}).get("refreshedAt")
3033
+ or (current_lease or {}).get("acquiredAt"),
3034
+ "runId": expired_run_id,
3035
+ }
3036
+ else:
3037
+ return emit_error(
3038
+ "LEASE_OWNER_MISMATCH",
3039
+ "expired lease belongs to a different run or phase",
3040
+ as_json=as_json,
3041
+ extra={"holder": expired_lease},
3042
+ )
3043
+
3044
+ if current_lease is None:
3045
+ terminal = _latest_phase_end(change_dir, args.phase, explicit_run_id)
3046
+ if (
3047
+ getattr(args, "to_phase", None)
3048
+ and terminal is not None
3049
+ and _terminal_matches_context_session(
3050
+ project, str(resolved["changeId"]), args.phase, terminal
3051
+ )
3052
+ ):
3053
+ terminal_status = str(terminal.get("status") or args.status)
3054
+ handoff = _close_context_handoff(
3055
+ project,
3056
+ str(resolved["changeId"]),
3057
+ args,
3058
+ status=terminal_status,
3059
+ )
3060
+ monitor = _sync_after_phase_close(project, change_dir)
3061
+ if not isinstance(handoff, dict) or not handoff.get("ok"):
3062
+ return emit_error(
3063
+ "PHASE_HANDOFF_PENDING",
3064
+ "phase gate is closed, but context handoff still needs attention",
3065
+ as_json=as_json,
3066
+ extra={
3067
+ "localCloseComplete": True,
3068
+ "retryable": True,
3069
+ "contextHandoff": handoff,
3070
+ "platformMonitor": monitor,
3071
+ },
3072
+ )
3073
+ emit(
3074
+ {
3075
+ "ok": True,
3076
+ "code": "PHASE_CLOSE_RESUMED",
3077
+ "phase": args.phase,
3078
+ "status": terminal_status,
3079
+ "changeId": resolved["changeId"],
3080
+ "localCloseComplete": True,
3081
+ "contextHandoff": handoff,
3082
+ "platformMonitor": monitor,
3083
+ },
3084
+ as_json=as_json,
3085
+ )
3086
+ return 0
3087
+ resume_run_id = explicit_run_id or _latest_open_run_id(change_dir, args.phase)
3088
+ return emit_error(
3089
+ "LEASE_ABSENT",
3090
+ "no active lease for phase close",
3091
+ as_json=as_json,
3092
+ extra={
3093
+ "retryable": True,
3094
+ "phase": args.phase,
3095
+ "changeId": resolved["changeId"],
3096
+ "resumeRunId": resume_run_id,
3097
+ "recoveryAction": (
3098
+ "本阶段没有任何租约记录——过期租约现在由 close 自动重取,所以这里说明的是"
3099
+ "租约从未建立或已被释放。先确认 gate begin 是否真的跑过(events.ndjson 里"
3100
+ f"应有 phase={args.phase} 的 phase.start)。确有本轮 run-id 时用它重新取得"
3101
+ "租约再原样重试 close:"
3102
+ "python <skills-root>/scripts/harness_change.py claim "
3103
+ f"--change {resolved['changeId']} --phase {args.phase} "
3104
+ f"--run-id {resume_run_id or '<原 run-id>'} --ttl-seconds 3600 --json"
3105
+ "。不要重跑 gate begin——那会新开一次 attempt 并丢失本轮 capsule。"
3106
+ ),
3107
+ },
3108
+ )
3109
+ run_id = explicit_run_id or str(current_lease.get("runId") or "")
3110
+ if str(current_lease.get("runId")) != run_id or (
3111
+ hp.resolve_phase_name(current_lease.get("phase")) != args.phase
3112
+ ):
3113
+ return emit_error(
3114
+ "LEASE_OWNER_MISMATCH",
3115
+ "active lease does not match close phase/run-id",
3116
+ as_json=as_json,
3117
+ extra={"holder": current_lease},
3118
+ )
3119
+
3120
+ try:
3121
+ capsule = load_phase_capsule(change_dir, args.phase, run_id)
3122
+ except ValueError as exc:
3123
+ return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
3124
+ if capsule is not None:
3125
+ try:
3126
+ execution_root = resolve_execution_root(
3127
+ project, str(capsule.get("executionRoot") or "")
3128
+ )
3129
+ if args.project:
3130
+ requested_root = resolve_execution_root(project, args.project)
3131
+ if requested_root != execution_root:
3132
+ persist_close_failure(
3133
+ change_dir,
3134
+ args.phase,
3135
+ run_id,
3136
+ capsule,
3137
+ status="ROOT_VALIDATION_FAILED",
3138
+ error={
3139
+ "code": "EXECUTION_ROOT_MISMATCH",
3140
+ "storedExecutionRoot": str(execution_root),
3141
+ "requestedExecutionRoot": str(requested_root),
3142
+ },
3143
+ )
3144
+ return emit_error(
3145
+ "EXECUTION_ROOT_MISMATCH",
3146
+ "close must use the execution root captured at begin",
3147
+ as_json=as_json,
3148
+ extra={
3149
+ "storedExecutionRoot": str(execution_root),
3150
+ "requestedExecutionRoot": str(requested_root),
3151
+ "recoveryAction": (
3152
+ "retry close from the stored execution root or "
3153
+ "pass --project with storedExecutionRoot"
3154
+ ),
3155
+ },
3156
+ )
3157
+ except ValueError as exc:
3158
+ persist_close_failure(
3159
+ change_dir,
3160
+ args.phase,
3161
+ run_id,
3162
+ capsule,
3163
+ status="ROOT_VALIDATION_FAILED",
3164
+ error={
3165
+ "code": "EXECUTION_ROOT_INVALID",
3166
+ "message": str(exc),
3167
+ },
3168
+ )
3169
+ return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
3170
+ else:
3171
+ try:
3172
+ execution_root = resolve_execution_root(project, args.project)
3173
+ except ValueError as exc:
3174
+ return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
3175
+ if capsule is not None:
3176
+ try:
3177
+ validate_phase_capsule(
3178
+ capsule,
3179
+ change_dir=change_dir,
3180
+ change_id=str(resolved["changeId"]),
3181
+ phase=args.phase,
3182
+ run_id=run_id,
3183
+ project=project,
3184
+ execution_root=execution_root,
3185
+ allow_head_advance=phase_gate_rule(args.phase, "head_may_advance"),
3186
+ )
3187
+ except ValueError as exc:
3188
+ if gate_soft_allowed(severity_mode, args.phase, "capsule"):
3189
+ gate_warnings.append(record_gate_warning(
3190
+ change_dir,
3191
+ phase=args.phase,
3192
+ site="capsule",
3193
+ code="PHASE_CAPSULE_MISMATCH",
3194
+ message=str(exc),
3195
+ ))
3196
+ else:
3197
+ persist_close_failure(
3198
+ change_dir,
3199
+ args.phase,
3200
+ run_id,
3201
+ capsule,
3202
+ status="CAPSULE_VALIDATION_FAILED",
3203
+ error={
3204
+ "code": "PHASE_CAPSULE_MISMATCH",
3205
+ "message": str(exc),
3206
+ },
3207
+ )
3208
+ return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
3209
+
3210
+ projection = evaluate_projection_gate(project, args.phase)
3211
+ if not projection.get("ok"):
3212
+ persist_close_failure(
3213
+ change_dir,
3214
+ args.phase,
3215
+ run_id,
3216
+ capsule,
3217
+ status="PROJECTION_VALIDATION_FAILED",
3218
+ error=projection,
3219
+ )
3220
+ return emit_error(
3221
+ str(projection.get("code") or "PROJECTION_BLOCKED"),
3222
+ str(projection.get("message") or "projection gate blocked close"),
3223
+ as_json=as_json,
3224
+ extra={
3225
+ key: value
3226
+ for key, value in projection.items()
3227
+ if key not in {"ok", "code", "message"}
3228
+ },
3229
+ )
3230
+ captured_projection = (
3231
+ capsule.get("projectionReceipt")
3232
+ if isinstance(capsule, dict)
3233
+ else None
3234
+ )
3235
+ if (
3236
+ isinstance(captured_projection, dict)
3237
+ and captured_projection.get("receiptHash")
3238
+ and captured_projection.get("receiptHash")
3239
+ != projection.get("receiptHash")
3240
+ and phase_gate_rule(args.phase, "projection_drift")
3241
+ ):
3242
+ mismatch = {
3243
+ "code": "PROJECTION_CHANGED_DURING_PHASE",
3244
+ "message": "projection receipt changed after phase begin",
3245
+ "capturedReceiptHash": captured_projection.get("receiptHash"),
3246
+ "currentReceiptHash": projection.get("receiptHash"),
3247
+ }
3248
+ persist_close_failure(
3249
+ change_dir,
3250
+ args.phase,
3251
+ run_id,
3252
+ capsule,
3253
+ status="PROJECTION_VALIDATION_FAILED",
3254
+ error=mismatch,
3255
+ )
3256
+ return emit_error(
3257
+ mismatch["code"],
3258
+ mismatch["message"],
3259
+ as_json=as_json,
3260
+ extra=mismatch,
3261
+ )
3262
+
3263
+ ledger_result = validate_ledger_for_phase_close(
3264
+ change_dir, args.phase, policy, execution_root=execution_root,
3265
+ phase_status=args.status,
3266
+ )
3267
+ if not ledger_result.get("ok") and phase_gate_rule(args.phase, "ledger_blocking"):
3268
+ persist_close_failure(
3269
+ change_dir,
3270
+ args.phase,
3271
+ run_id,
3272
+ capsule,
3273
+ status="LEDGER_VALIDATION_FAILED",
3274
+ error=ledger_result,
3275
+ )
3276
+ record_gate_blocked(
3277
+ change_dir,
3278
+ phase=args.phase,
3279
+ code=str(ledger_result.get("code") or "LEDGER_INVALID"),
3280
+ message=(
3281
+ "验证记录与当前变更身份不一致,已保留现场;"
3282
+ "请按返回的路径摘要重新验证受影响范围。"
3283
+ ),
3284
+ run_id=run_id,
3285
+ )
3286
+ return emit_error(
3287
+ str(ledger_result.get("code", "LEDGER_INVALID")),
3288
+ str(ledger_result.get("message", "ledger validation failed")),
3289
+ as_json=as_json,
3290
+ extra={k: v for k, v in ledger_result.items() if k not in {"ok", "message", "code"}},
3291
+ )
3292
+
3293
+ if phase_gate_rule(args.phase, "review_outputs"):
3294
+ review_outputs = validate_review_outputs_for_close(change_dir, run_id)
3295
+ if not review_outputs.get("ok"):
3296
+ persist_close_failure(
3297
+ change_dir,
3298
+ args.phase,
3299
+ run_id,
3300
+ capsule,
3301
+ status="REVIEW_OUTPUTS_INVALID",
3302
+ error=review_outputs,
3303
+ )
3304
+ record_gate_blocked(
3305
+ change_dir,
3306
+ phase=args.phase,
3307
+ code=str(review_outputs.get("code") or "REVIEW_OUTPUTS_INVALID"),
3308
+ message=str(review_outputs.get("message") or "评审记录不完整。"),
3309
+ run_id=run_id,
3310
+ )
3311
+ return emit_error(
3312
+ str(review_outputs.get("code") or "REVIEW_OUTPUTS_INVALID"),
3313
+ str(review_outputs.get("message") or "评审记录不完整。"),
3314
+ as_json=as_json,
3315
+ extra={
3316
+ key: value
3317
+ for key, value in review_outputs.items()
3318
+ if key not in {"ok", "code", "message"}
3319
+ },
3320
+ )
3321
+
3322
+ # C9: scenario coverage check — every P0/ledger scenario *due by this phase*
3323
+ # must have a ledger entry. Scenarios with a later ownerPhase are deferred,
3324
+ # not missing (see _validate_scenario_coverage).
3325
+ if phase_gate_rule(args.phase, "scenario_coverage"):
3326
+ coverage = _validate_scenario_coverage(change_dir, args.phase)
3327
+ if not coverage.get("ok"):
3328
+ if gate_soft_allowed(severity_mode, args.phase, "scenario-coverage"):
3329
+ gate_warnings.append(record_gate_warning(
3330
+ change_dir,
3331
+ phase=args.phase,
3332
+ site="scenario-coverage",
3333
+ code=str(coverage.get("code", "SCENARIO_COVERAGE_FAILED")),
3334
+ message=str(coverage.get("message", "scenario coverage validation failed")),
3335
+ ))
3336
+ else:
3337
+ persist_close_failure(
3338
+ change_dir,
3339
+ args.phase,
3340
+ run_id,
3341
+ capsule,
3342
+ status="SCENARIO_VALIDATION_FAILED",
3343
+ error=coverage,
3344
+ )
3345
+ return emit_error(
3346
+ str(coverage.get("code", "SCENARIO_COVERAGE_FAILED")),
3347
+ str(coverage.get("message", "scenario coverage validation failed")),
3348
+ as_json=as_json,
3349
+ extra={k: v for k, v in coverage.items() if k not in {"ok", "message", "code"}},
3350
+ )
3351
+
3352
+ close_status = args.status
3353
+ close_code = "PHASE_CLOSED"
3354
+ if ledger_result.get("code") == "LEDGER_OK_DEGRADED":
3355
+ close_code = "CLOSED_DEGRADED"
3356
+ # Degraded close: phase.end status must not exceed WARN (OK → WARN).
3357
+ if close_status == "OK":
3358
+ close_status = "WARN"
3359
+ if gate_warnings:
3360
+ # Lenient-mode downgrades: phase closes, but never better than WARN.
3361
+ if close_status == "OK":
3362
+ close_status = "WARN"
3363
+ if close_code == "PHASE_CLOSED":
3364
+ close_code = "CLOSED_WITH_WARNINGS"
3365
+
3366
+ close_transaction: dict[str, Any] = {}
3367
+ if capsule is not None:
3368
+ existing_transaction = capsule.get("closeTransaction")
3369
+ if isinstance(existing_transaction, dict):
3370
+ close_transaction.update(existing_transaction)
3371
+ close_transaction.update({
3372
+ "status": "CLOSING",
3373
+ "retryable": True,
3374
+ "guardClosed": bool(close_transaction.get("guardClosed")),
3375
+ "phaseEndRecorded": bool(close_transaction.get("phaseEndRecorded")),
3376
+ "leaseReleased": False,
3377
+ "updatedAt": he.now_iso(),
3378
+ })
3379
+ capsule["closeTransaction"] = close_transaction
3380
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
3381
+
3382
+ guard_result = None
3383
+ if phase_gate_rule(args.phase, "test_guard"):
3384
+ if close_transaction.get("guardClosed"):
3385
+ guard_result = {"ok": True, "code": "ALREADY_CLOSED", "reused": True}
3386
+ else:
3387
+ guard_result = htg.close(execution_root, change_dir)
3388
+ if not guard_result.get("ok"):
3389
+ if gate_soft_allowed(severity_mode, args.phase, "test-guard"):
3390
+ gate_warnings.append(record_gate_warning(
3391
+ change_dir,
3392
+ phase=args.phase,
3393
+ site="test-guard",
3394
+ code=str(guard_result.get("code", "TEST_GUARD_CLOSE_FAILED")),
3395
+ message="test guard close failed (downgraded to WARN)",
3396
+ ))
3397
+ else:
3398
+ if capsule is not None:
3399
+ close_transaction.update({
3400
+ "status": "GUARD_CLOSE_FAILED",
3401
+ "lastError": guard_result,
3402
+ "updatedAt": he.now_iso(),
3403
+ })
3404
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
3405
+ record_gate_blocked(
3406
+ change_dir,
3407
+ phase=args.phase,
3408
+ code=str(
3409
+ guard_result.get("code")
3410
+ or "TEST_GUARD_CLOSE_FAILED"
3411
+ ),
3412
+ message="测试保护关门失败,已保留现场并等待同一命令恢复。",
3413
+ run_id=run_id,
3414
+ )
3415
+ return emit_error(
3416
+ str(guard_result.get("code", "TEST_GUARD_CLOSE_FAILED")),
3417
+ "test guard close failed",
3418
+ as_json=as_json,
3419
+ extra=guard_result,
3420
+ )
3421
+ if guard_result.get("ok") and capsule is not None:
3422
+ close_transaction["guardClosed"] = True
3423
+ close_transaction["updatedAt"] = he.now_iso()
3424
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
3425
+
3426
+ if close_transaction.get("phaseEndRecorded") or _phase_event_exists(
3427
+ change_dir, args.phase, "phase.end", run_id
3428
+ ):
3429
+ event_result = {"ok": True, "skipped": True, "reason": "already-recorded"}
3430
+ else:
3431
+ try:
3432
+ event_result = append_phase_event(
3433
+ change_dir,
3434
+ phase=args.phase,
3435
+ type_="phase.end",
3436
+ status=close_status,
3437
+ note=args.note or "",
3438
+ fixback=bool(getattr(args, "fixback", False)),
3439
+ run_id=run_id,
3440
+ )
3441
+ except BaseException as exc:
3442
+ if capsule is not None:
3443
+ close_transaction.update({
3444
+ "status": "PHASE_END_FAILED",
3445
+ "lastError": {"type": type(exc).__name__, "message": str(exc)},
3446
+ "updatedAt": he.now_iso(),
3447
+ })
3448
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
3449
+ return emit_error(
3450
+ "PHASE_END_FAILED",
3451
+ str(exc),
3452
+ as_json=as_json,
3453
+ extra={"retryable": True},
3454
+ )
3455
+ if capsule is not None:
3456
+ close_transaction["phaseEndRecorded"] = True
3457
+ close_transaction["updatedAt"] = he.now_iso()
3458
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
3459
+
3460
+ release = hc.release_lease(
3461
+ project,
3462
+ change_id=resolved["changeId"],
3463
+ phase=args.phase,
3464
+ run_id=run_id,
3465
+ )
3466
+ if not release.get("ok"):
3467
+ if capsule is not None:
3468
+ close_transaction.update({
3469
+ "status": "RELEASE_PENDING",
3470
+ "retryable": True,
3471
+ "lastError": release,
3472
+ "updatedAt": he.now_iso(),
3473
+ })
3474
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
3475
+ return emit_error(
3476
+ str(release.get("code", "LEASE_RELEASE_FAILED")),
3477
+ str(release.get("message", "lease release failed")),
3478
+ as_json=as_json,
3479
+ extra={k: v for k, v in release.items() if k not in {"ok", "message", "code"}},
3480
+ )
3481
+
3482
+ if capsule is not None:
3483
+ close_transaction.update({
3484
+ "status": "CLOSED",
3485
+ "retryable": False,
3486
+ "leaseReleased": True,
3487
+ "updatedAt": he.now_iso(),
3488
+ })
3489
+ capsule["closedAt"] = he.now_iso()
3490
+ capsule["closeStatus"] = close_status
3491
+ write_phase_capsule(change_dir, args.phase, run_id, capsule)
3492
+
3493
+ context_handoff = _close_context_handoff(
3494
+ project,
3495
+ str(resolved["changeId"]),
3496
+ args,
3497
+ status=close_status,
3498
+ )
3499
+ platform_monitor = _sync_after_phase_close(project, change_dir)
3500
+ if isinstance(context_handoff, dict) and not context_handoff.get("ok"):
3501
+ record_gate_blocked(
3502
+ change_dir,
3503
+ phase=args.phase,
3504
+ code=str(context_handoff.get("code") or "PHASE_HANDOFF_PENDING"),
3505
+ message="阶段本地关门已完成,但上下文交接尚待恢复。",
3506
+ run_id=run_id,
3507
+ )
3508
+ return emit_error(
3509
+ "PHASE_HANDOFF_PENDING",
3510
+ "phase gate closed locally, but context handoff failed; retry the same close command",
3511
+ as_json=as_json,
3512
+ extra={
3513
+ "localCloseComplete": True,
3514
+ "retryable": True,
3515
+ "phase": args.phase,
3516
+ "status": close_status,
3517
+ "changeId": resolved["changeId"],
3518
+ "contextHandoff": context_handoff,
3519
+ "platformMonitor": platform_monitor,
3520
+ },
3521
+ )
3522
+
3523
+ recovery_result = record_gate_recovered(
3524
+ change_dir,
3525
+ phase=args.phase,
3526
+ run_id=run_id,
3527
+ )
3528
+ # 关门时清掉本阶段的过程草稿(diff、临时输入、命令输出重定向)。没人清它们,
3529
+ # 而且它们会被别的门禁读到——2026-08-19 那次,review 自己生成的两个 diff
3530
+ # 直接把归档挡住了。白名单式删除,证据/报告/运行态一律不碰;失败只记不阻断。
3531
+ try:
3532
+ scratch_swept = hruntime.sweep_scratch(change_dir)
3533
+ except OSError as exc:
3534
+ scratch_swept = {"ok": False, "code": "SCRATCH_SWEEP_FAILED", "error": str(exc)}
3535
+
3536
+ payload = {
3537
+ "ok": True,
3538
+ "code": close_code,
3539
+ "phase": args.phase,
3540
+ "status": close_status,
3541
+ "changeId": resolved["changeId"],
3542
+ "stateRoot": capsule.get("stateRoot") if capsule else str(Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()),
3543
+ "executionRoot": str(execution_root),
3544
+ "skillsRoot": capsule.get("skillsRoot") if capsule else None,
3545
+ "ledger": ledger_result,
3546
+ "testGuard": guard_result,
3547
+ "event": event_result,
3548
+ "lease": release,
3549
+ "gateSeverityMode": severity_mode,
3550
+ "gateWarnings": gate_warnings,
3551
+ "contextHandoff": context_handoff,
3552
+ "platformMonitor": platform_monitor,
3553
+ "gateRecovery": recovery_result,
3554
+ "scratchSwept": scratch_swept,
3555
+ }
3556
+ if lease_lapsed is not None:
3557
+ # 与 context transition receipt 的 leaseLapsed 同名同义:记录这个阶段
3558
+ # 跑过了 TTL 且租约是自动重取的,不隐藏。
3559
+ payload["leaseLapsed"] = lease_lapsed
3560
+ emit(payload, as_json=as_json)
3561
+ return 0
3562
+
3563
+
3564
+ def cmd_classify(args: argparse.Namespace) -> int:
3565
+ as_json = bool(args.json)
3566
+ project = _resolve_project(args)
3567
+ try:
3568
+ workflow = _load_workflow_policy(project=project)
3569
+ except (OSError, ValueError, hwp.PolicyValidationError) as exc:
3570
+ return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
3571
+
3572
+ resolved = hc.resolve_change(project, args.change)
3573
+ change_dir: Path | None = None
3574
+ change_id = str(args.change or "")
3575
+ if resolved.get("ok"):
3576
+ change_dir = Path(resolved["changeDir"])
3577
+ change_id = str(resolved.get("changeId") or change_dir.name)
3578
+ if not change_dir.is_dir():
3579
+ change_dir = None
3580
+
3581
+ if change_dir is not None:
3582
+ payload = classify_risk(change_dir, args.stage, workflow=workflow)
3583
+ else:
3584
+ payload = classify_defaults(workflow, change_id=change_id or "unknown", stage=args.stage)
3585
+ if not resolved.get("ok"):
3586
+ payload["resolveCode"] = resolved.get("code")
3587
+
3588
+ tier_override = getattr(args, "tier_override", None)
3589
+ if tier_override:
3590
+ payload = apply_tier_override(
3591
+ payload,
3592
+ workflow,
3593
+ tier=str(tier_override),
3594
+ override_by=str(getattr(args, "override_by", None) or "user"),
3595
+ )
3596
+ else:
3597
+ payload.setdefault("tierOverride", None)
3598
+
3599
+ classified_at = hc.now_iso()
3600
+ payload["classifiedAt"] = classified_at
3601
+
3602
+ if change_dir is not None and change_dir.is_dir():
3603
+ policy_doc = gate_policy_document(payload)
3604
+ policy_path = change_dir / "meta" / "gate-policy.json"
3605
+ _write_json(policy_path, policy_doc)
3606
+ payload["policyPersisted"] = True
3607
+ payload["policyPath"] = str(policy_path)
3608
+ else:
3609
+ payload["policyPersisted"] = False
3610
+ payload["warning"] = (
3611
+ "change directory does not exist; gate-policy.json not written"
3612
+ )
3613
+
3614
+ emit(payload, as_json=as_json)
3615
+ return 0
3616
+
3617
+ def cmd_checkpoint(args: argparse.Namespace) -> int:
3618
+ as_json = bool(args.json)
3619
+ project = _resolve_project(args)
3620
+ resolved = hc.resolve_change(project, args.change)
3621
+ if not resolved.get("ok"):
3622
+ return emit_error(
3623
+ str(resolved.get("code", "RESOLVE_FAILED")),
3624
+ str(resolved.get("message", "change resolve failed")),
3625
+ as_json=as_json,
3626
+ extra={k: v for k, v in resolved.items() if k not in {"ok", "message"}},
3627
+ )
3628
+ change_dir = Path(resolved["changeDir"])
3629
+ path = change_dir / CHECKPOINTS_REL
3630
+ if args.checkpoint_action == "status":
3631
+ checkpoints = load_checkpoints(change_dir)
3632
+ status = checkpoint_status(checkpoints, args.id)
3633
+ payload = {
3634
+ "ok": True,
3635
+ "code": "CHECKPOINT_STATUS",
3636
+ "checkpointId": args.id,
3637
+ "status": status,
3638
+ "path": str(path) if path.is_file() else None,
3639
+ }
3640
+ emit(payload, as_json=as_json)
3641
+ return 0
3642
+
3643
+ if args.checkpoint_action != "approve":
3644
+ return emit_error("INVALID_CHECKPOINT_ACTION", args.checkpoint_action, as_json=as_json)
3645
+
3646
+ checkpoints = load_checkpoints(change_dir)
3647
+ items = checkpoints.get("checkpoints") if isinstance(checkpoints, dict) else None
3648
+ item = next((candidate for candidate in items if isinstance(candidate, dict)
3649
+ and candidate.get("id") == args.id), None) if isinstance(items, list) else None
3650
+ if item is None:
3651
+ return emit_error("CHECKPOINT_NOT_FOUND", f"checkpoint not found: {args.id}", as_json=as_json)
3652
+ expected_reviewer = str(item.get("reviewerTool") or "")
3653
+ if not args.reviewer or (expected_reviewer and args.reviewer != expected_reviewer):
3654
+ return emit_error(
3655
+ "CHECKPOINT_REVIEWER_MISMATCH",
3656
+ f"checkpoint requires reviewer {expected_reviewer or 'explicit reviewer'}",
3657
+ as_json=as_json,
3658
+ )
3659
+ required_report = str(item.get("requiredReport") or "")
3660
+ report_rel = Path(required_report)
3661
+ if (
3662
+ not required_report
3663
+ or report_rel.is_absolute()
3664
+ or ".." in report_rel.parts
3665
+ ):
3666
+ return emit_error(
3667
+ "CHECKPOINT_REPORT_PATH_INVALID",
3668
+ f"required report path is invalid: {required_report or '<unset>'}",
3669
+ as_json=as_json,
3670
+ )
3671
+ state_dir = hp.resolve_state_dir_for_contract(change_dir, project)
3672
+ report_candidates = [state_dir / report_rel]
3673
+ if state_dir != change_dir:
3674
+ # Compatibility fallback for split-v1 changes created before dynamic
3675
+ # reports were routed to the state root.
3676
+ report_candidates.append(change_dir / report_rel)
3677
+ report_path = next(
3678
+ (candidate for candidate in report_candidates if candidate.is_file()),
3679
+ report_candidates[0],
3680
+ )
3681
+ if not report_path.is_file() or report_path.stat().st_size == 0:
3682
+ return emit_error(
3683
+ "CHECKPOINT_REPORT_MISSING",
3684
+ f"required report is missing: {required_report}",
3685
+ as_json=as_json,
3686
+ )
3687
+ report_text = report_path.read_text(encoding="utf-8", errors="replace")
3688
+ if not re.search(r"(?im)^foundation-gate:\s*approved\s*$", report_text):
3689
+ return emit_error(
3690
+ "CHECKPOINT_REPORT_NOT_APPROVED",
3691
+ "required report does not contain 'foundation-gate: approved'",
3692
+ as_json=as_json,
3693
+ )
3694
+ item["status"] = "approved"
3695
+ item["approvedAt"] = hc.now_iso()
3696
+ item["approvedBy"] = args.reviewer
3697
+ _write_json(path, checkpoints)
3698
+ payload = {
3699
+ "ok": True,
3700
+ "code": "CHECKPOINT_APPROVED",
3701
+ "checkpointId": args.id,
3702
+ "status": "approved",
3703
+ "path": str(path),
3704
+ }
3705
+ emit(payload, as_json=as_json)
3706
+ return 0
3707
+
3708
+
3709
+ def cmd_lint_skills(args: argparse.Namespace) -> int:
3710
+ as_json = bool(args.json)
3711
+ root = Path(args.skills_root).expanduser().resolve()
3712
+ payload = lint_skill_tree(root)
3713
+ if payload.get("ok"):
3714
+ emit(payload, as_json=as_json)
3715
+ return 0
3716
+ return emit_error(
3717
+ str(payload.get("code", "LINT_FAILED")),
3718
+ str(payload.get("message", "skill lint failed")),
3719
+ as_json=as_json,
3720
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message", "code"}},
3721
+ )
3722
+
3723
+
3724
+ def build_parser() -> argparse.ArgumentParser:
3725
+ parser = argparse.ArgumentParser(prog="harness_gate.py")
3726
+ parser.add_argument("--json", action="store_true")
3727
+ sub = parser.add_subparsers(dest="command_name", required=True)
3728
+ shared = argparse.ArgumentParser(add_help=False)
3729
+ shared.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
3730
+
3731
+ p_begin = sub.add_parser("begin", parents=[shared])
3732
+ p_begin.add_argument("--phase", required=True)
3733
+ p_begin.add_argument("--change", default=None)
3734
+ p_begin.add_argument(
3735
+ "--project",
3736
+ default=None,
3737
+ help=(
3738
+ "execution root PATH (worktree) for this phase; defaults to the "
3739
+ "change's main project. Takes a path, not a project name."
3740
+ ),
3741
+ )
3742
+ p_begin.add_argument("--skills-root", default=None)
3743
+ p_begin.add_argument("--run-id", default=None)
3744
+ p_begin.add_argument("--ttl-seconds", type=int, default=3600)
3745
+ p_begin.add_argument("--task", type=int, default=None)
3746
+ p_begin.add_argument("--note", default="")
3747
+ p_begin.add_argument(
3748
+ "--fixback",
3749
+ action="store_true",
3750
+ help="This phase is a review fixback round (tags the lifecycle event).",
3751
+ )
3752
+ p_begin.add_argument("--executor-tool", default=None)
3753
+ p_begin.add_argument("--executor-agent", default=None)
3754
+ p_begin.add_argument("--executor-model", default=None)
3755
+ p_begin.set_defaults(func=cmd_begin)
3756
+
3757
+ p_close = sub.add_parser("close", parents=[shared])
3758
+ p_close.add_argument("--phase", required=True)
3759
+ p_close.add_argument("--change", default=None)
3760
+ p_close.add_argument(
3761
+ "--project",
3762
+ default=None,
3763
+ help=(
3764
+ "execution root PATH (worktree) for this phase; defaults to the "
3765
+ "change's main project. Takes a path, not a project name."
3766
+ ),
3767
+ )
3768
+ p_close.add_argument("--status", required=True)
3769
+ p_close.add_argument("--run-id", default=None)
3770
+ p_close.add_argument("--task", type=int, default=None)
3771
+ p_close.add_argument("--note", default="")
3772
+ p_close.add_argument(
3773
+ "--fixback",
3774
+ action="store_true",
3775
+ help="This phase is a review fixback round (tags the lifecycle event).",
3776
+ )
3777
+ p_close.add_argument("--to-phase", default=None)
3778
+ p_close.add_argument("--executor", default=None)
3779
+ p_close.add_argument("--artifact", action="append", default=[])
3780
+ p_close.set_defaults(func=cmd_close)
3781
+
3782
+ p_classify = sub.add_parser("classify", parents=[shared])
3783
+ p_classify.add_argument("--project", default=None, type=Path, help=_PROJECT_ROOT_HELP)
3784
+ p_classify.add_argument("--change", default=None)
3785
+ p_classify.add_argument("--stage", required=True, choices=["plan", "post-run"])
3786
+ p_classify.add_argument(
3787
+ "--tier-override",
3788
+ default=None,
3789
+ choices=["fast", "standard", "full"],
3790
+ )
3791
+ p_classify.add_argument("--override-by", default="user")
3792
+ p_classify.set_defaults(func=cmd_classify)
3793
+
3794
+ p_checkpoint = sub.add_parser("checkpoint", parents=[shared])
3795
+ p_checkpoint.add_argument("--project", default=None, type=Path, help=_PROJECT_ROOT_HELP)
3796
+ p_checkpoint.add_argument("checkpoint_action", choices=["status", "approve"])
3797
+ p_checkpoint.add_argument("--id", required=True)
3798
+ p_checkpoint.add_argument("--change", default=None)
3799
+ p_checkpoint.add_argument("--reviewer", default=None)
3800
+ p_checkpoint.set_defaults(func=cmd_checkpoint)
3801
+
3802
+ p_lint = sub.add_parser("lint-skills", parents=[shared])
3803
+ p_lint.add_argument("--skills-root", required=True)
3804
+ p_lint.set_defaults(func=cmd_lint_skills)
3805
+
3806
+ return parser
3807
+
3808
+
3809
+ def main(argv: list[str] | None = None) -> int:
3810
+ parser = build_parser()
3811
+ args = parser.parse_args(argv)
3812
+ return int(args.func(args))
3813
+
3814
+
3815
+ if __name__ == "__main__":
3816
+ raise SystemExit(main())