@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,1726 @@
1
+ #!/usr/bin/env python3
2
+ """Phase reconciliation, trace projection, and normalized CI metrics.
3
+
4
+ The reconciler is deliberately an evidence orchestrator: it decides REUSE,
5
+ RUN, or BLOCK for the required gate DAG. It never executes user test commands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import datetime as dt
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ import uuid
19
+ import xml.etree.ElementTree as ET
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ SCRIPTS_DIR = Path(__file__).resolve().parent
26
+ if str(SCRIPTS_DIR) not in sys.path:
27
+ sys.path.insert(0, str(SCRIPTS_DIR))
28
+
29
+ import harness_events as he # noqa: E402
30
+ import harness_ledger as hl # noqa: E402
31
+ import harness_paths as hpaths # noqa: E402
32
+
33
+
34
+ SCHEMA_VERSION = 1
35
+ CI_METRICS_SCHEMA_VERSION = 1
36
+ # 唯一权威清单在 harness_paths,harness_context 引的是同一个对象。此前这里另有
37
+ # 一份拷贝,少了 merge,worktree 变更走到 merge 就在 target_required_dag 里硬 raise。
38
+ PHASE_ORDER = hpaths.WORKFLOW_PHASES
39
+ VALIDATION_PHASES = {
40
+ "compile": "execute",
41
+ "unitTest": "execute",
42
+ "unitTestFull": "execute",
43
+ "apiTest": "execute",
44
+ "browserTest": "execute",
45
+ "dbCompatibility": "execute",
46
+ "package": "package",
47
+ }
48
+ FINAL_SEQUENCE_RECEIPTS_REL = (
49
+ Path("evidence") / "final-sequence-receipts.json"
50
+ )
51
+ ENVIRONMENT_EXECUTIONS_REL = (
52
+ Path("evidence") / "environment-executions.json"
53
+ )
54
+
55
+
56
+ def _read_json(path: Path) -> dict[str, Any]:
57
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
58
+ if not isinstance(value, dict):
59
+ raise ValueError(f"JSON object required: {path}")
60
+ return value
61
+
62
+
63
+ def _write_json(path: Path, value: Any) -> None:
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ temp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
66
+ try:
67
+ temp.write_text(
68
+ json.dumps(value, ensure_ascii=False, indent=2) + "\n",
69
+ encoding="utf-8",
70
+ newline="\n",
71
+ )
72
+ temp.replace(path)
73
+ except BaseException:
74
+ temp.unlink(missing_ok=True)
75
+ raise
76
+
77
+
78
+ def _final_sequence_contract(change_dir: Path) -> dict[str, Any] | None:
79
+ # 权威切换(2026-08):v2 发布快照优先,classify 工作副本回退。
80
+ loaded = hpaths.load_change_gate_policy(change_dir)
81
+ policy = loaded.get("policy")
82
+ if policy is None:
83
+ return None
84
+ declared = policy.get("finalSequence")
85
+ if isinstance(declared, dict) and isinstance(declared.get("nodeIds"), list):
86
+ return declared
87
+ dag = policy.get("requiredGateDag")
88
+ if not isinstance(dag, dict):
89
+ return None
90
+ sequence_nodes = [
91
+ node
92
+ for node in (dag.get("nodes") or [])
93
+ if isinstance(node, dict) and node.get("kind") == "sequence"
94
+ ]
95
+ if not sequence_nodes:
96
+ return None
97
+ sequence_nodes.sort(key=lambda node: int(node.get("sequenceIndex") or 0))
98
+ node_ids = [str(node.get("id") or "") for node in sequence_nodes]
99
+ return {
100
+ "schemaVersion": 1,
101
+ "nodeIds": node_ids,
102
+ "freezeNodeId": (
103
+ "sequence:code-freeze"
104
+ if "sequence:code-freeze" in node_ids
105
+ else None
106
+ ),
107
+ "terminalNodeId": node_ids[-1],
108
+ }
109
+
110
+
111
+ def _load_sequence_receipts(change_dir: Path) -> dict[str, Any]:
112
+ path = change_dir / FINAL_SEQUENCE_RECEIPTS_REL
113
+ if not path.is_file():
114
+ return {
115
+ "schemaVersion": 1,
116
+ "changeId": change_dir.name,
117
+ "receipts": {},
118
+ "attempts": [],
119
+ }
120
+ value = _read_json(path)
121
+ if value.get("schemaVersion") != 1:
122
+ raise ValueError("unsupported final-sequence receipt schema")
123
+ receipts = value.get("receipts")
124
+ if not isinstance(receipts, dict):
125
+ raise ValueError("final-sequence receipts must be an object")
126
+ attempts = value.get("attempts")
127
+ if attempts is not None and not isinstance(attempts, list):
128
+ raise ValueError("final-sequence attempts must be an array")
129
+ value.setdefault("attempts", [])
130
+ return value
131
+
132
+
133
+ def _validated_sequence_subject(subject: dict[str, Any]) -> dict[str, str]:
134
+ required = ("productCommit", "productTreeHash", "environmentHash")
135
+ missing = [
136
+ field
137
+ for field in required
138
+ if not isinstance(subject.get(field), str)
139
+ or not str(subject[field]).strip()
140
+ ]
141
+ if missing:
142
+ raise ValueError(
143
+ "final-sequence subject missing: " + ", ".join(missing)
144
+ )
145
+ return {field: str(subject[field]).strip() for field in required}
146
+
147
+
148
+ def _record_sequence_attempt(
149
+ change_dir: Path,
150
+ journal: dict[str, Any],
151
+ *,
152
+ node_id: str,
153
+ ok: bool,
154
+ code: str,
155
+ subject: dict[str, str],
156
+ ) -> None:
157
+ attempts = journal.setdefault("attempts", [])
158
+ attempts.append(
159
+ {
160
+ "nodeId": node_id,
161
+ "ok": ok,
162
+ "code": code,
163
+ "subject": subject,
164
+ "recordedAt": he.now_iso(),
165
+ }
166
+ )
167
+ _write_json(change_dir / FINAL_SEQUENCE_RECEIPTS_REL, journal)
168
+
169
+
170
+ def record_sequence_receipt(
171
+ change_dir: Path,
172
+ node_id: str,
173
+ subject: dict[str, Any],
174
+ *,
175
+ status: str = "OK",
176
+ evidence: dict[str, Any] | None = None,
177
+ ) -> dict[str, Any]:
178
+ """Record one final-sequence transition after validating its predecessor."""
179
+ change_dir = change_dir.resolve()
180
+ contract = _final_sequence_contract(change_dir)
181
+ if contract is None:
182
+ return {
183
+ "ok": False,
184
+ "code": "FINAL_SEQUENCE_NOT_CONFIGURED",
185
+ "message": "gate policy does not declare finalSequence",
186
+ }
187
+ node_ids = [
188
+ str(item)
189
+ for item in contract.get("nodeIds") or []
190
+ if isinstance(item, str)
191
+ ]
192
+ if node_id not in node_ids:
193
+ return {
194
+ "ok": False,
195
+ "code": "SEQUENCE_NODE_UNKNOWN",
196
+ "message": f"node is not in finalSequence: {node_id}",
197
+ }
198
+ try:
199
+ normalized_subject = _validated_sequence_subject(subject)
200
+ journal = _load_sequence_receipts(change_dir)
201
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
202
+ return {
203
+ "ok": False,
204
+ "code": "SEQUENCE_RECEIPT_INVALID",
205
+ "message": str(exc),
206
+ }
207
+ receipts = journal["receipts"]
208
+ index = node_ids.index(node_id)
209
+ predecessor_id = node_ids[index - 1] if index else None
210
+ if predecessor_id is not None:
211
+ predecessor = receipts.get(predecessor_id)
212
+ if (
213
+ not isinstance(predecessor, dict)
214
+ or str(predecessor.get("status") or "").upper() != "OK"
215
+ ):
216
+ code = "SEQUENCE_PREDECESSOR_MISSING"
217
+ _record_sequence_attempt(
218
+ change_dir,
219
+ journal,
220
+ node_id=node_id,
221
+ ok=False,
222
+ code=code,
223
+ subject=normalized_subject,
224
+ )
225
+ return {
226
+ "ok": False,
227
+ "code": code,
228
+ "message": f"required predecessor is not complete: {predecessor_id}",
229
+ "predecessor": predecessor_id,
230
+ }
231
+ if predecessor.get("subject") != normalized_subject:
232
+ code = "SEQUENCE_SUBJECT_CHANGED"
233
+ _record_sequence_attempt(
234
+ change_dir,
235
+ journal,
236
+ node_id=node_id,
237
+ ok=False,
238
+ code=code,
239
+ subject=normalized_subject,
240
+ )
241
+ return {
242
+ "ok": False,
243
+ "code": code,
244
+ "message": "candidate subject differs from predecessor receipt",
245
+ "expected": predecessor.get("subject"),
246
+ "actual": normalized_subject,
247
+ }
248
+
249
+ freeze_node_id = str(contract.get("freezeNodeId") or "")
250
+ freeze_receipt = receipts.get(freeze_node_id)
251
+ if (
252
+ freeze_node_id
253
+ and isinstance(freeze_receipt, dict)
254
+ and freeze_receipt.get("subject") != normalized_subject
255
+ ):
256
+ code = "SEQUENCE_SUBJECT_CHANGED"
257
+ _record_sequence_attempt(
258
+ change_dir,
259
+ journal,
260
+ node_id=node_id,
261
+ ok=False,
262
+ code=code,
263
+ subject=normalized_subject,
264
+ )
265
+ return {
266
+ "ok": False,
267
+ "code": code,
268
+ "message": "candidate subject changed after code-freeze",
269
+ "expected": freeze_receipt.get("subject"),
270
+ "actual": normalized_subject,
271
+ }
272
+
273
+ normalized_status = status.strip().upper()
274
+ if normalized_status not in {"OK", "WARN", "FAIL"}:
275
+ return {
276
+ "ok": False,
277
+ "code": "SEQUENCE_STATUS_INVALID",
278
+ "message": f"unsupported final-sequence status: {status}",
279
+ }
280
+ normalized_evidence = evidence if isinstance(evidence, dict) else {}
281
+ if node_id == "sequence:unit-test-full":
282
+ full_execution_id = str(
283
+ normalized_evidence.get("fullExecutionId") or ""
284
+ ).strip()
285
+ try:
286
+ environment_journal = load_environment_execution_journal(
287
+ change_dir
288
+ )
289
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
290
+ environment_journal = {"fullExecutions": []}
291
+ environment_error = str(exc)
292
+ else:
293
+ environment_error = ""
294
+ full_execution = next(
295
+ (
296
+ item
297
+ for item in environment_journal.get("fullExecutions") or []
298
+ if isinstance(item, dict)
299
+ and item.get("fullExecutionId") == full_execution_id
300
+ ),
301
+ None,
302
+ )
303
+ if (
304
+ not full_execution_id
305
+ or not isinstance(full_execution, dict)
306
+ or full_execution.get("subject") != normalized_subject
307
+ or full_execution.get("status") != "OK"
308
+ or full_execution.get("assertionBearing") is not True
309
+ ):
310
+ code = "FULL_EXECUTION_EVIDENCE_REQUIRED"
311
+ _record_sequence_attempt(
312
+ change_dir,
313
+ journal,
314
+ node_id=node_id,
315
+ ok=False,
316
+ code=code,
317
+ subject=normalized_subject,
318
+ )
319
+ return {
320
+ "ok": False,
321
+ "code": code,
322
+ "message": (
323
+ "unit-test-full requires a successful assertion-bearing "
324
+ "Full execution bound to the frozen subject"
325
+ ),
326
+ "fullExecutionId": full_execution_id or None,
327
+ "journalError": environment_error or None,
328
+ }
329
+ receipt = {
330
+ "schemaVersion": 1,
331
+ "nodeId": node_id,
332
+ "status": normalized_status,
333
+ "subject": normalized_subject,
334
+ "evidence": normalized_evidence,
335
+ "recordedAt": he.now_iso(),
336
+ }
337
+ existing = receipts.get(node_id)
338
+ reused = (
339
+ isinstance(existing, dict)
340
+ and existing.get("status") == normalized_status
341
+ and existing.get("subject") == normalized_subject
342
+ and existing.get("evidence") == receipt["evidence"]
343
+ )
344
+ if not reused:
345
+ receipts[node_id] = receipt
346
+ _record_sequence_attempt(
347
+ change_dir,
348
+ journal,
349
+ node_id=node_id,
350
+ ok=normalized_status == "OK",
351
+ code=(
352
+ "SEQUENCE_RECEIPT_REUSED"
353
+ if reused
354
+ else (
355
+ "SEQUENCE_STEP_RECORDED"
356
+ if normalized_status == "OK"
357
+ else "SEQUENCE_STEP_FAILED"
358
+ )
359
+ ),
360
+ subject=normalized_subject,
361
+ )
362
+ ok = normalized_status == "OK"
363
+ return {
364
+ "ok": ok,
365
+ "code": (
366
+ "SEQUENCE_RECEIPT_REUSED"
367
+ if reused
368
+ else ("SEQUENCE_STEP_RECORDED" if ok else "SEQUENCE_STEP_FAILED")
369
+ ),
370
+ "nodeId": node_id,
371
+ "receipt": receipts.get(node_id),
372
+ "reused": reused,
373
+ }
374
+
375
+
376
+ def evaluate_final_sequence(
377
+ change_dir: Path,
378
+ subject: dict[str, Any],
379
+ *,
380
+ exclude_nodes: set[str] | None = None,
381
+ ) -> dict[str, Any]:
382
+ """Evaluate all required sequence receipts against one candidate subject."""
383
+ change_dir = change_dir.resolve()
384
+ try:
385
+ contract = _final_sequence_contract(change_dir)
386
+ if contract is None:
387
+ return {
388
+ "ok": True,
389
+ "code": "FINAL_SEQUENCE_NOT_CONFIGURED",
390
+ "skipped": True,
391
+ "missing": [],
392
+ "invalid": [],
393
+ }
394
+ normalized_subject = _validated_sequence_subject(subject)
395
+ journal = _load_sequence_receipts(change_dir)
396
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
397
+ return {
398
+ "ok": False,
399
+ "code": "FINAL_SEQUENCE_INVALID",
400
+ "message": str(exc),
401
+ }
402
+ excluded = exclude_nodes or set()
403
+ required = [
404
+ str(item)
405
+ for item in contract.get("nodeIds") or []
406
+ if isinstance(item, str) and item not in excluded
407
+ ]
408
+ receipts = journal["receipts"]
409
+ missing = [node_id for node_id in required if node_id not in receipts]
410
+ invalid = []
411
+ for node_id in required:
412
+ receipt = receipts.get(node_id)
413
+ if not isinstance(receipt, dict):
414
+ continue
415
+ if (
416
+ str(receipt.get("status") or "").upper() != "OK"
417
+ or receipt.get("subject") != normalized_subject
418
+ ):
419
+ invalid.append(
420
+ {
421
+ "nodeId": node_id,
422
+ "status": receipt.get("status"),
423
+ "subject": receipt.get("subject"),
424
+ }
425
+ )
426
+ ok = not missing and not invalid
427
+ return {
428
+ "ok": ok,
429
+ "code": (
430
+ "FINAL_SEQUENCE_COMPLETE"
431
+ if ok
432
+ else "FINAL_SEQUENCE_INCOMPLETE"
433
+ ),
434
+ "message": (
435
+ "all required final-sequence receipts match the candidate"
436
+ if ok
437
+ else f"missing={len(missing)} invalid={len(invalid)}"
438
+ ),
439
+ "subject": normalized_subject,
440
+ "required": required,
441
+ "missing": missing,
442
+ "invalid": invalid,
443
+ "receiptsPath": str(change_dir / FINAL_SEQUENCE_RECEIPTS_REL),
444
+ }
445
+
446
+
447
+ def load_environment_execution_journal(
448
+ change_dir: Path,
449
+ ) -> dict[str, Any]:
450
+ path = change_dir.resolve() / ENVIRONMENT_EXECUTIONS_REL
451
+ if not path.is_file():
452
+ return {
453
+ "schemaVersion": 1,
454
+ "changeId": change_dir.name,
455
+ "environmentAttempts": [],
456
+ "fullExecutions": [],
457
+ }
458
+ journal = _read_json(path)
459
+ if journal.get("schemaVersion") != 1:
460
+ raise ValueError("unsupported environment execution journal schema")
461
+ for field in ("environmentAttempts", "fullExecutions"):
462
+ if not isinstance(journal.get(field), list):
463
+ raise ValueError(f"environment journal {field} must be an array")
464
+ return journal
465
+
466
+
467
+ def record_environment_attempt(
468
+ change_dir: Path,
469
+ subject: dict[str, Any],
470
+ *,
471
+ stage: str,
472
+ status: str,
473
+ duration_ms: int,
474
+ failure_signature: str | None = None,
475
+ ) -> dict[str, Any]:
476
+ """Record environment lifecycle work without manufacturing Full results."""
477
+ change_dir = change_dir.resolve()
478
+ if stage not in {"prepare", "verify", "release"}:
479
+ return {
480
+ "ok": False,
481
+ "code": "ENVIRONMENT_STAGE_INVALID",
482
+ "message": f"unsupported environment stage: {stage}",
483
+ }
484
+ normalized_status = status.strip().upper()
485
+ if normalized_status not in {
486
+ "OK",
487
+ "ENVIRONMENT_ERROR",
488
+ "INTERRUPTED",
489
+ "FAIL",
490
+ }:
491
+ return {
492
+ "ok": False,
493
+ "code": "ENVIRONMENT_STATUS_INVALID",
494
+ "message": f"unsupported environment status: {status}",
495
+ }
496
+ if not isinstance(duration_ms, int) or duration_ms < 0:
497
+ return {
498
+ "ok": False,
499
+ "code": "ENVIRONMENT_DURATION_INVALID",
500
+ "message": "duration_ms must be a non-negative integer",
501
+ }
502
+ try:
503
+ normalized_subject = _validated_sequence_subject(subject)
504
+ journal = load_environment_execution_journal(change_dir)
505
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
506
+ return {
507
+ "ok": False,
508
+ "code": "ENVIRONMENT_JOURNAL_INVALID",
509
+ "message": str(exc),
510
+ }
511
+ attempt = {
512
+ "attemptId": "env-" + uuid.uuid4().hex,
513
+ "stage": stage,
514
+ "status": normalized_status,
515
+ "subject": normalized_subject,
516
+ "durationMs": duration_ms,
517
+ "failureSignature": failure_signature,
518
+ "recordedAt": he.now_iso(),
519
+ }
520
+ journal["environmentAttempts"].append(attempt)
521
+ _write_json(change_dir / ENVIRONMENT_EXECUTIONS_REL, journal)
522
+ ok = normalized_status == "OK"
523
+ return {
524
+ "ok": ok,
525
+ "code": (
526
+ "ENVIRONMENT_STAGE_RECORDED"
527
+ if ok
528
+ else "ENVIRONMENT_ERROR_RECORDED"
529
+ ),
530
+ "attempt": attempt,
531
+ "fullExecutionCreated": False,
532
+ }
533
+
534
+
535
+ def record_full_test_execution(
536
+ change_dir: Path,
537
+ subject: dict[str, Any],
538
+ *,
539
+ status: str,
540
+ prepare_ms: int,
541
+ test_ms: int,
542
+ cleanup_ms: int,
543
+ result_digest: str,
544
+ supersedes: str | None = None,
545
+ ) -> dict[str, Any]:
546
+ """Record one assertion-bearing Full execution after environment success."""
547
+ change_dir = change_dir.resolve()
548
+ normalized_status = status.strip().upper()
549
+ if normalized_status not in {"OK", "FAIL"}:
550
+ return {
551
+ "ok": False,
552
+ "code": "FULL_STATUS_INVALID",
553
+ "message": "Full status must be OK or FAIL",
554
+ }
555
+ durations = (prepare_ms, test_ms, cleanup_ms)
556
+ if any(not isinstance(value, int) or value < 0 for value in durations):
557
+ return {
558
+ "ok": False,
559
+ "code": "FULL_DURATION_INVALID",
560
+ "message": "prepare/test/cleanup durations must be non-negative",
561
+ }
562
+ if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", result_digest):
563
+ return {
564
+ "ok": False,
565
+ "code": "FULL_RESULT_DIGEST_INVALID",
566
+ "message": "result_digest must be a sha256 digest",
567
+ }
568
+ try:
569
+ normalized_subject = _validated_sequence_subject(subject)
570
+ journal = load_environment_execution_journal(change_dir)
571
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
572
+ return {
573
+ "ok": False,
574
+ "code": "ENVIRONMENT_JOURNAL_INVALID",
575
+ "message": str(exc),
576
+ }
577
+ successful_environment = {
578
+ str(item.get("stage") or ""): item
579
+ for item in journal["environmentAttempts"]
580
+ if isinstance(item, dict)
581
+ and item.get("subject") == normalized_subject
582
+ and str(item.get("status") or "").upper() == "OK"
583
+ }
584
+ missing = [
585
+ stage
586
+ for stage in ("prepare", "verify")
587
+ if stage not in successful_environment
588
+ ]
589
+ if missing:
590
+ return {
591
+ "ok": False,
592
+ "code": "ENVIRONMENT_NOT_READY",
593
+ "message": (
594
+ "Full execution requires successful environment stages: "
595
+ + ", ".join(missing)
596
+ ),
597
+ "missing": missing,
598
+ }
599
+ prior_success = next(
600
+ (
601
+ item
602
+ for item in reversed(journal["fullExecutions"])
603
+ if isinstance(item, dict)
604
+ and item.get("subject") == normalized_subject
605
+ and item.get("status") == "OK"
606
+ ),
607
+ None,
608
+ )
609
+ if prior_success is not None:
610
+ if prior_success.get("resultDigest") == result_digest:
611
+ sequence_receipt = record_sequence_receipt(
612
+ change_dir,
613
+ "sequence:unit-test-full",
614
+ normalized_subject,
615
+ evidence={
616
+ "fullExecutionId": prior_success.get("fullExecutionId"),
617
+ "resultDigest": result_digest,
618
+ "assertionBearing": True,
619
+ },
620
+ )
621
+ return {
622
+ "ok": True,
623
+ "code": "FULL_EXECUTION_REUSED",
624
+ "execution": prior_success,
625
+ "reused": True,
626
+ "sequenceReceipt": sequence_receipt,
627
+ }
628
+ return {
629
+ "ok": False,
630
+ "code": "FULL_ALREADY_COMPLETED",
631
+ "message": (
632
+ "a successful assertion-bearing Full already exists for this identity"
633
+ ),
634
+ "existingExecutionId": prior_success.get("fullExecutionId"),
635
+ }
636
+ prior_failed_ids = {
637
+ str(item.get("fullExecutionId") or "")
638
+ for item in journal["fullExecutions"]
639
+ if isinstance(item, dict)
640
+ and item.get("subject") == normalized_subject
641
+ and item.get("status") == "FAIL"
642
+ }
643
+ if prior_failed_ids and supersedes not in prior_failed_ids:
644
+ return {
645
+ "ok": False,
646
+ "code": "FULL_SUPERSESSION_REQUIRED",
647
+ "message": "Full rerun after assertion failure must name supersedes",
648
+ "priorFailedExecutionIds": sorted(prior_failed_ids),
649
+ }
650
+ execution = {
651
+ "fullExecutionId": "full-" + uuid.uuid4().hex,
652
+ "status": normalized_status,
653
+ "subject": normalized_subject,
654
+ "environmentAttemptIds": [
655
+ successful_environment[stage]["attemptId"]
656
+ for stage in ("prepare", "verify")
657
+ ],
658
+ "prepareMs": prepare_ms,
659
+ "testExecutionMs": test_ms,
660
+ "cleanupMs": cleanup_ms,
661
+ "totalMs": prepare_ms + test_ms + cleanup_ms,
662
+ "resultDigest": result_digest,
663
+ "supersedes": supersedes,
664
+ "assertionBearing": True,
665
+ "recordedAt": he.now_iso(),
666
+ }
667
+ journal["fullExecutions"].append(execution)
668
+ _write_json(change_dir / ENVIRONMENT_EXECUTIONS_REL, journal)
669
+ sequence_receipt = None
670
+ if normalized_status == "OK":
671
+ sequence_receipt = record_sequence_receipt(
672
+ change_dir,
673
+ "sequence:unit-test-full",
674
+ normalized_subject,
675
+ evidence={
676
+ "fullExecutionId": execution["fullExecutionId"],
677
+ "resultDigest": result_digest,
678
+ "assertionBearing": True,
679
+ },
680
+ )
681
+ return {
682
+ "ok": normalized_status == "OK",
683
+ "code": (
684
+ "FULL_EXECUTION_RECORDED"
685
+ if normalized_status == "OK"
686
+ else "FULL_ASSERTIONS_FAILED"
687
+ ),
688
+ "execution": execution,
689
+ "reused": False,
690
+ "sequenceReceipt": sequence_receipt,
691
+ }
692
+
693
+
694
+ def _stable_hex(*parts: Any, length: int) -> str:
695
+ raw = "\x1f".join(str(part) for part in parts).encode("utf-8")
696
+ return hashlib.sha256(raw).hexdigest()[:length]
697
+
698
+
699
+ def target_required_dag(policy: dict[str, Any], target_phase: str) -> dict[str, Any]:
700
+ """Return only the required predecessors needed to close ``target_phase``.
701
+
702
+ Validations owned by the target phase are prerequisites to closing it;
703
+ stage nodes for the target itself are deliberately excluded because their
704
+ ``phase.end`` is the result of close, not evidence required before close.
705
+ """
706
+ # 未知阶段名先过读时映射:阶段改名后,历史 change 的 gate-policy.json 与
707
+ # plannedPhases 里仍然是旧名,靠 LEGACY_PHASE_ALIASES 继续可读,不必迁移已落盘
708
+ # 的 change(本仓库没有 change 级 schema 迁移机制,硬 raise 等于让在途变更报废)。
709
+ resolved_target = hpaths.resolve_phase_name(target_phase)
710
+ if resolved_target is None:
711
+ raise ValueError(
712
+ f"unsupported reconcile target phase: {target_phase}; "
713
+ f"已知阶段 {', '.join(PHASE_ORDER)}。"
714
+ "若这是改名前的旧阶段,在 harness_paths.LEGACY_PHASE_ALIASES 登记映射;"
715
+ "若这份 gate-policy.json 本身过期,重跑 harness_gate.py classify 重建"
716
+ )
717
+ target_phase = resolved_target
718
+ dag = policy.get("requiredGateDag")
719
+ if not isinstance(dag, dict) or not isinstance(dag.get("nodes"), list):
720
+ raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
721
+ target_rank = PHASE_ORDER.index(target_phase)
722
+ selected: list[dict[str, Any]] = []
723
+ for raw in dag["nodes"]:
724
+ if not isinstance(raw, dict):
725
+ raise ValueError("requiredGateDag node must be an object")
726
+ node = dict(raw)
727
+ node_id = str(node.get("id") or "")
728
+ node_kind, _, node_name = node_id.partition(":")
729
+ inferred_phase = (
730
+ VALIDATION_PHASES.get(node_name)
731
+ if node_kind == "validation"
732
+ else node_name
733
+ )
734
+ raw_phase = str(node.get("phase") or inferred_phase or "")
735
+ node_phase = hpaths.resolve_phase_name(raw_phase)
736
+ if node_phase is None:
737
+ raise ValueError(
738
+ f"requiredGateDag node has unsupported phase: {node_id} (phase={raw_phase!r});"
739
+ "旧阶段名在 harness_paths.LEGACY_PHASE_ALIASES 登记映射,"
740
+ "或重跑 harness_gate.py classify 重建这份 gate-policy.json"
741
+ )
742
+ node["phase"] = node_phase
743
+ node_rank = PHASE_ORDER.index(node_phase)
744
+ is_validation = node.get("kind") == "validation" or node_id.startswith(
745
+ "validation:"
746
+ )
747
+ if node_rank < target_rank or (is_validation and node_rank == target_rank):
748
+ selected.append(node)
749
+ selected_ids = {str(node.get("id")) for node in selected}
750
+ for node in selected:
751
+ excluded_dependencies = [
752
+ dependency
753
+ for dependency in (node.get("dependsOn") or [])
754
+ if dependency not in selected_ids
755
+ ]
756
+ if excluded_dependencies:
757
+ raise ValueError(
758
+ f"target DAG for {target_phase} excludes dependencies of "
759
+ f"{node.get('id')}: {', '.join(excluded_dependencies)}"
760
+ )
761
+ return {
762
+ "schemaVersion": dag.get("schemaVersion", 1),
763
+ "nodes": selected,
764
+ "edges": [
765
+ edge
766
+ for edge in (dag.get("edges") or [])
767
+ if isinstance(edge, dict)
768
+ and edge.get("from") in selected_ids
769
+ and edge.get("to") in selected_ids
770
+ ],
771
+ "targetPhase": target_phase,
772
+ }
773
+
774
+
775
+ def select_phase_capsule(
776
+ change_dir: Path,
777
+ project_root: Path,
778
+ *,
779
+ phase: str,
780
+ run_id: str | None,
781
+ ) -> dict[str, Any]:
782
+ """Select a phase capsule by its exact phase/run identity, never by mtime."""
783
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", phase):
784
+ return {
785
+ "ok": False,
786
+ "code": "PHASE_INVALID",
787
+ "message": f"invalid phase selector: {phase}",
788
+ "remediation": "pass the phase name emitted by harness_gate.py begin",
789
+ }
790
+ # 在途 change 的 capsule 可能按旧名(run/test)写入:归一后按 canonical
791
+ # 优先、旧名兜底查找。新写一律 canonical。
792
+ phase = hpaths.resolve_phase_name(phase) or phase
793
+ phase_names = [phase] + [
794
+ legacy for legacy, canonical in hpaths.LEGACY_PHASE_ALIASES.items()
795
+ if canonical == phase
796
+ ]
797
+ state_dir = hpaths.resolve_state_dir_for_contract(change_dir, project_root)
798
+ capsule_dir = state_dir / "runtime" / "phase-context"
799
+ candidates = (
800
+ [
801
+ path
802
+ for path in capsule_dir.iterdir()
803
+ if path.is_file()
804
+ and any(path.name.startswith(f"{name}-") for name in phase_names)
805
+ and path.suffix == ".json"
806
+ ]
807
+ if capsule_dir.is_dir()
808
+ else []
809
+ )
810
+ if not run_id:
811
+ if not candidates:
812
+ return {
813
+ "ok": True,
814
+ "code": "PHASE_CAPSULE_ABSENT",
815
+ "capsule": None,
816
+ "path": None,
817
+ }
818
+ return {
819
+ "ok": False,
820
+ "code": "PHASE_RUN_ID_REQUIRED",
821
+ "message": f"{len(candidates)} capsule(s) exist for phase {phase}",
822
+ "remediation": f"rerun reconcile with --phase {phase} --run-id <run-id>",
823
+ }
824
+ path: Path | None = None
825
+ for name in phase_names:
826
+ key = hashlib.sha256(f"{name}\0{run_id}".encode("utf-8")).hexdigest()[:20]
827
+ candidate = capsule_dir / f"{name}-{key}.json"
828
+ if candidate.is_file():
829
+ path = candidate
830
+ break
831
+ if path is None:
832
+ key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
833
+ return {
834
+ "ok": False,
835
+ "code": "PHASE_CAPSULE_NOT_FOUND",
836
+ "message": f"no capsule for phase {phase} and run-id {run_id}",
837
+ "expectedPath": str(capsule_dir / f"{phase}-{key}.json"),
838
+ "remediation": f"begin/resume the phase or verify --phase {phase} --run-id {run_id}",
839
+ }
840
+ try:
841
+ capsule = _read_json(path)
842
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
843
+ return {
844
+ "ok": False,
845
+ "code": "PHASE_CAPSULE_INVALID",
846
+ "message": str(exc),
847
+ "path": str(path),
848
+ "remediation": f"repair or resume the capsule for --phase {phase} --run-id {run_id}",
849
+ }
850
+ if (
851
+ hpaths.resolve_phase_name(capsule.get("phase")) != phase
852
+ or capsule.get("runId") != run_id
853
+ ):
854
+ return {
855
+ "ok": False,
856
+ "code": "PHASE_CAPSULE_IDENTITY_MISMATCH",
857
+ "message": "capsule content does not match the requested phase/run-id",
858
+ "path": str(path),
859
+ "remediation": f"resume the exact phase with --phase {phase} --run-id {run_id}",
860
+ }
861
+ return {
862
+ "ok": True,
863
+ "code": "PHASE_CAPSULE_SELECTED",
864
+ "capsule": capsule,
865
+ "path": str(path),
866
+ }
867
+
868
+
869
+ def reconcile_dag(
870
+ dag: dict[str, Any],
871
+ evidence: dict[str, dict[str, Any]],
872
+ *,
873
+ identity: dict[str, Any],
874
+ ) -> dict[str, Any]:
875
+ """Reduce a required DAG to deterministic REUSE/RUN/BLOCK decisions."""
876
+ raw_nodes = dag.get("nodes")
877
+ if not isinstance(raw_nodes, list):
878
+ raise ValueError("requiredGateDag.nodes must be an array")
879
+ if not raw_nodes:
880
+ raise ValueError("requiredGateDag.nodes must not be empty")
881
+ nodes_by_id: dict[str, dict[str, Any]] = {}
882
+ for raw in raw_nodes:
883
+ if not isinstance(raw, dict) or not str(raw.get("id") or "").strip():
884
+ raise ValueError("requiredGateDag node requires a non-empty id")
885
+ node_id = str(raw["id"])
886
+ if node_id in nodes_by_id:
887
+ raise ValueError(f"duplicate DAG node: {node_id}")
888
+ depends = raw.get("dependsOn") or []
889
+ if not isinstance(depends, list) or any(not isinstance(item, str) for item in depends):
890
+ raise ValueError(f"invalid dependsOn for {node_id}")
891
+ nodes_by_id[node_id] = dict(raw)
892
+ for node_id, node in nodes_by_id.items():
893
+ missing = [dep for dep in node.get("dependsOn") or [] if dep not in nodes_by_id]
894
+ if missing:
895
+ raise ValueError(f"unknown dependency for {node_id}: {', '.join(missing)}")
896
+
897
+ if not identity.get("ok"):
898
+ blocker = {
899
+ "code": str(identity.get("code") or "IDENTITY_MISMATCH"),
900
+ "message": str(identity.get("message") or "identity validation failed"),
901
+ }
902
+ return {
903
+ "nodes": [
904
+ {
905
+ **node,
906
+ "decision": "BLOCK",
907
+ "reason": blocker["code"],
908
+ "evidence": evidence.get(node_id) or {},
909
+ }
910
+ for node_id, node in nodes_by_id.items()
911
+ ],
912
+ "blockers": [blocker],
913
+ "canClose": False,
914
+ }
915
+
916
+ pending = set(nodes_by_id)
917
+ decided: dict[str, dict[str, Any]] = {}
918
+ while pending:
919
+ progressed = False
920
+ for node_id in list(pending):
921
+ node = nodes_by_id[node_id]
922
+ dependencies = list(node.get("dependsOn") or [])
923
+ if any(dep not in decided for dep in dependencies):
924
+ continue
925
+ facts = evidence.get(node_id) or {}
926
+ upstream = [decided[dep] for dep in dependencies]
927
+ if any(item["decision"] == "BLOCK" for item in upstream):
928
+ decision, reason = "BLOCK", "upstream-blocked"
929
+ elif any(item["decision"] != "REUSE" for item in upstream):
930
+ decision, reason = "RUN", "upstream-invalidated"
931
+ elif bool(facts.get("reusable")):
932
+ decision, reason = "REUSE", str(facts.get("reason") or "evidence-current")
933
+ elif str(facts.get("decision") or "").upper() == "BLOCK":
934
+ decision, reason = "BLOCK", str(facts.get("reason") or "evidence-blocked")
935
+ else:
936
+ decision, reason = "RUN", str(facts.get("reason") or "evidence-missing")
937
+ decided[node_id] = {
938
+ **node,
939
+ "decision": decision,
940
+ "reason": reason,
941
+ "evidence": facts,
942
+ }
943
+ pending.remove(node_id)
944
+ progressed = True
945
+ if not progressed:
946
+ raise ValueError("requiredGateDag contains a cycle")
947
+
948
+ ordered = [decided[str(raw["id"])] for raw in raw_nodes]
949
+ blockers = [
950
+ {"code": "NODE_BLOCKED", "nodeId": node["id"], "message": node["reason"]}
951
+ for node in ordered
952
+ if node["decision"] == "BLOCK"
953
+ ]
954
+ return {
955
+ "nodes": ordered,
956
+ "blockers": blockers,
957
+ "canClose": bool(ordered) and all(node["decision"] == "REUSE" for node in ordered),
958
+ }
959
+
960
+
961
+ def _event_groups(events: list[dict[str, Any]]) -> list[tuple[str, int, list[dict[str, Any]]]]:
962
+ order: list[tuple[str, int]] = []
963
+ groups: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list)
964
+ for event in events:
965
+ key = (str(event.get("phase") or "unknown"), int(event.get("attempt") or 1))
966
+ if key not in groups:
967
+ order.append(key)
968
+ groups[key].append(event)
969
+ return [(phase, attempt, groups[(phase, attempt)]) for phase, attempt in order]
970
+
971
+
972
+ def timing_dimensions(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
973
+ """Keep runner, orchestration-active, wall-clock, and user wait distinct."""
974
+ output: list[dict[str, Any]] = []
975
+ for phase, attempt, grouped in _event_groups(events):
976
+ timing = he.canonical_phase_timing(grouped)
977
+ runner_values = [
978
+ event.get("duration_ms", event.get("durationMs"))
979
+ for event in grouped
980
+ if event.get("type") == "command"
981
+ and isinstance(event.get("duration_ms", event.get("durationMs")), int)
982
+ ]
983
+ wait_values = [
984
+ event.get("user_wait_ms", event.get("userWaitMs"))
985
+ for event in grouped
986
+ if isinstance(event.get("user_wait_ms", event.get("userWaitMs")), int)
987
+ ]
988
+ output.append(
989
+ {
990
+ "phase": phase,
991
+ "attempt": attempt,
992
+ "runnerMs": sum(runner_values) if runner_values else None,
993
+ "orchestrationActiveMs": timing.get("activeExecutionMs"),
994
+ "wallClockMs": timing.get("wallClockSpanMs"),
995
+ "userWaitMs": sum(wait_values) if wait_values else None,
996
+ "lateEventCount": timing.get("lateEventCount"),
997
+ }
998
+ )
999
+ return output
1000
+
1001
+
1002
+ def build_trace(events: list[dict[str, Any]], *, change_id: str) -> dict[str, Any]:
1003
+ """Project legacy or trace-aware events into stable phase/attempt/tool spans."""
1004
+ event_ids = [str(event.get("id") or index) for index, event in enumerate(events)]
1005
+ explicit_trace = next(
1006
+ (
1007
+ str(event.get("traceId") or event.get("trace_id"))
1008
+ for event in events
1009
+ if event.get("traceId") or event.get("trace_id")
1010
+ ),
1011
+ "",
1012
+ )
1013
+ trace_id = explicit_trace if len(explicit_trace) == 32 else _stable_hex(change_id, *event_ids, length=32)
1014
+ root_span = _stable_hex(trace_id, "root", length=16)
1015
+ spans: list[dict[str, Any]] = [
1016
+ {
1017
+ "traceId": trace_id,
1018
+ "spanId": root_span,
1019
+ "parentSpanId": None,
1020
+ "kind": "change",
1021
+ "changeId": change_id,
1022
+ }
1023
+ ]
1024
+ for phase, attempt, grouped in _event_groups(events):
1025
+ phase_span = _stable_hex(trace_id, phase, attempt, length=16)
1026
+ spans.append(
1027
+ {
1028
+ "traceId": trace_id,
1029
+ "spanId": phase_span,
1030
+ "parentSpanId": root_span,
1031
+ "kind": "phase-attempt",
1032
+ "phase": phase,
1033
+ "attempt": attempt,
1034
+ }
1035
+ )
1036
+ tools: list[str] = []
1037
+ for event in grouped:
1038
+ tool = str(event.get("executor_tool") or event.get("executorTool") or "").strip()
1039
+ if tool and tool not in tools:
1040
+ tools.append(tool)
1041
+ for tool in tools:
1042
+ spans.append(
1043
+ {
1044
+ "traceId": trace_id,
1045
+ "spanId": _stable_hex(trace_id, phase, attempt, tool, length=16),
1046
+ "parentSpanId": phase_span,
1047
+ "kind": "tool",
1048
+ "phase": phase,
1049
+ "attempt": attempt,
1050
+ "tool": tool,
1051
+ }
1052
+ )
1053
+ return {"schemaVersion": 1, "traceId": trace_id, "spans": spans}
1054
+
1055
+
1056
+ def _number(value: Any, *, integer: bool = True) -> int | float | None:
1057
+ if value is None or isinstance(value, bool):
1058
+ return None
1059
+ try:
1060
+ number = float(value)
1061
+ except (TypeError, ValueError):
1062
+ return None
1063
+ return int(number) if integer else number
1064
+
1065
+
1066
+ def _junit_metrics(text: str) -> dict[str, Any]:
1067
+ root = ET.fromstring(text)
1068
+ suites = [root] if root.tag.endswith("testsuite") else list(root.findall(".//testsuite"))
1069
+ aggregate = root if root.tag.endswith(("testsuite", "testsuites")) else None
1070
+ attrs = aggregate.attrib if aggregate is not None else {}
1071
+ def attribute_total(name: str, fallback: str | None = None) -> int | None:
1072
+ direct = _number(attrs.get(name, attrs.get(fallback) if fallback else None))
1073
+ if direct is not None:
1074
+ return int(direct)
1075
+ values = [
1076
+ _number(item.attrib.get(name, item.attrib.get(fallback) if fallback else None))
1077
+ for item in suites
1078
+ ]
1079
+ return (
1080
+ sum(int(value) for value in values if value is not None)
1081
+ if any(value is not None for value in values)
1082
+ else None
1083
+ )
1084
+
1085
+ total = attribute_total("tests")
1086
+ failures = attribute_total("failures")
1087
+ errors = attribute_total("errors")
1088
+ skipped = attribute_total("skipped", "disabled")
1089
+ duration_seconds = _number(attrs.get("time"), integer=False)
1090
+ if duration_seconds is None:
1091
+ durations = [_number(item.attrib.get("time"), integer=False) for item in suites]
1092
+ duration_seconds = (
1093
+ sum(value for value in durations if value is not None)
1094
+ if any(value is not None for value in durations)
1095
+ else None
1096
+ )
1097
+ passed = None
1098
+ if all(value is not None for value in (total, failures, errors, skipped)):
1099
+ passed = max(int(total) - int(failures) - int(errors) - int(skipped), 0)
1100
+ return {
1101
+ "suites": len(suites) if suites else None,
1102
+ "passed": passed,
1103
+ "failed": failures,
1104
+ "skipped": skipped,
1105
+ "setupErrors": errors,
1106
+ "durationMs": int(duration_seconds * 1000) if duration_seconds is not None else None,
1107
+ }
1108
+
1109
+
1110
+ def normalize_ci_metrics(
1111
+ raw: str | dict[str, Any],
1112
+ *,
1113
+ runner: str,
1114
+ head_sha: str | None,
1115
+ source: str | None = None,
1116
+ ) -> dict[str, Any]:
1117
+ """Normalize JUnit, Vitest, or Playwright machine output without inventing zeros."""
1118
+ normalized_runner = runner.lower()
1119
+ if normalized_runner == "junit":
1120
+ if not isinstance(raw, str):
1121
+ raise ValueError("JUnit input must be XML text")
1122
+ metrics = _junit_metrics(raw)
1123
+ else:
1124
+ if isinstance(raw, str):
1125
+ value = json.loads(raw)
1126
+ else:
1127
+ value = raw
1128
+ if not isinstance(value, dict):
1129
+ raise ValueError("runner metrics must be a JSON object")
1130
+ if normalized_runner == "vitest":
1131
+ runtimes = [
1132
+ _number((item.get("perfStats") or {}).get("runtime"))
1133
+ for item in (value.get("testResults") or [])
1134
+ if isinstance(item, dict)
1135
+ ]
1136
+ metrics = {
1137
+ "suites": _number(value.get("numTotalTestSuites")),
1138
+ "passed": _number(value.get("numPassedTests")),
1139
+ "failed": _number(value.get("numFailedTests")),
1140
+ "skipped": _number(value.get("numPendingTests")),
1141
+ "setupErrors": _number(value.get("numRuntimeErrorTestSuites")),
1142
+ "durationMs": sum(item for item in runtimes if item is not None)
1143
+ if any(item is not None for item in runtimes)
1144
+ else None,
1145
+ }
1146
+ elif normalized_runner == "playwright":
1147
+ stats = value.get("stats") if isinstance(value.get("stats"), dict) else {}
1148
+ suites = value.get("suites")
1149
+ error_counts = []
1150
+ for raw_errors in (value.get("errors"), stats.get("errors")):
1151
+ count = len(raw_errors) if isinstance(raw_errors, list) else _number(raw_errors)
1152
+ if count is not None:
1153
+ error_counts.append(count)
1154
+ setup_errors = max(error_counts) if error_counts else None
1155
+ metrics = {
1156
+ "suites": len(suites) if isinstance(suites, list) else None,
1157
+ "passed": _number(stats.get("expected")),
1158
+ "failed": _number(stats.get("unexpected")),
1159
+ "skipped": _number(stats.get("skipped")),
1160
+ "setupErrors": _number(setup_errors),
1161
+ "durationMs": _number(stats.get("duration")),
1162
+ }
1163
+ else:
1164
+ raise ValueError(f"unsupported runner: {runner}")
1165
+ result = {
1166
+ "schemaVersion": CI_METRICS_SCHEMA_VERSION,
1167
+ "runner": normalized_runner,
1168
+ "source": source,
1169
+ **metrics,
1170
+ "headSha": head_sha,
1171
+ }
1172
+ for field in ("suites", "passed", "failed", "skipped", "setupErrors", "durationMs"):
1173
+ value = result.get(field)
1174
+ if value is not None and (not isinstance(value, int) or value < 0):
1175
+ raise ValueError(f"invalid CI metric {field}: expected nonnegative integer or null")
1176
+ if head_sha is not None and not re.fullmatch(r"[0-9a-f]{40}(?:[0-9a-f]{24})?", head_sha):
1177
+ raise ValueError("headSha must be a lowercase 40- or 64-character hex commit id")
1178
+ return result
1179
+
1180
+
1181
+ def _git_head(project_root: Path) -> str | None:
1182
+ try:
1183
+ proc = subprocess.run(
1184
+ ["git", "rev-parse", "--verify", "HEAD"],
1185
+ cwd=str(project_root),
1186
+ capture_output=True,
1187
+ text=True,
1188
+ encoding="utf-8",
1189
+ errors="replace",
1190
+ check=False,
1191
+ )
1192
+ except OSError:
1193
+ return None
1194
+ return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
1195
+
1196
+
1197
+ def assess_identity(
1198
+ change_dir: Path,
1199
+ project_root: Path,
1200
+ ledger: dict[str, Any] | None,
1201
+ *,
1202
+ target_phase: str | None = None,
1203
+ run_id: str | None = None,
1204
+ ) -> dict[str, Any]:
1205
+ current_head = _git_head(project_root)
1206
+ capsule: dict[str, Any] | None = None
1207
+ capsule_selection: dict[str, Any] | None = None
1208
+ if target_phase is not None:
1209
+ capsule_selection = select_phase_capsule(
1210
+ change_dir,
1211
+ project_root,
1212
+ phase=target_phase,
1213
+ run_id=run_id,
1214
+ )
1215
+ if not capsule_selection.get("ok"):
1216
+ return {
1217
+ "ok": False,
1218
+ "code": capsule_selection.get("code"),
1219
+ "message": capsule_selection.get("message"),
1220
+ "remediation": capsule_selection.get("remediation"),
1221
+ "capsuleSelection": {
1222
+ key: value
1223
+ for key, value in capsule_selection.items()
1224
+ if key != "capsule"
1225
+ },
1226
+ }
1227
+ selected = capsule_selection.get("capsule")
1228
+ capsule = selected if isinstance(selected, dict) else None
1229
+ if capsule is not None:
1230
+ stored_head = capsule.get("currentHead")
1231
+ if stored_head and current_head and stored_head != current_head:
1232
+ return {
1233
+ "ok": False,
1234
+ "code": "HEAD_MISMATCH",
1235
+ "message": f"phase capsule head {stored_head} != current {current_head}",
1236
+ "storedHead": stored_head,
1237
+ "currentHead": current_head,
1238
+ "capsuleSelection": capsule_selection,
1239
+ }
1240
+ if not ledger or ledger.get("schemaVersion") != 3:
1241
+ return {
1242
+ "ok": True,
1243
+ "code": "IDENTITY_NOT_REUSABLE",
1244
+ "message": "no ledger v3 identity; validations must RUN unless their own evidence is current",
1245
+ "currentHead": current_head,
1246
+ "capsuleSelection": capsule_selection,
1247
+ }
1248
+ missing = hl.validate_ledger_identity(ledger)
1249
+ if missing:
1250
+ return {
1251
+ "ok": False,
1252
+ "code": "LEDGER_IDENTITY_INVALID",
1253
+ "message": "missing: " + ", ".join(missing),
1254
+ }
1255
+ if capsule is not None and capsule.get("baseCommit") not in {
1256
+ None,
1257
+ ledger.get("baseCommit"),
1258
+ }:
1259
+ return {
1260
+ "ok": False,
1261
+ "code": "BASE_MISMATCH",
1262
+ "message": "phase capsule and ledger base commits differ",
1263
+ "capsuleBase": capsule.get("baseCommit"),
1264
+ "ledgerBase": ledger.get("baseCommit"),
1265
+ }
1266
+ try:
1267
+ contract = hpaths.load_change_contract(change_dir)
1268
+ current_repository = hpaths.repository_identity(project_root)
1269
+ current_ownership = hl.ownership_hash(contract)
1270
+ current_diff = hl.compute_ownership_diff(
1271
+ project_root,
1272
+ base=str(ledger["baseCommit"]),
1273
+ change_dir=change_dir,
1274
+ )["diffHash"]
1275
+ except (OSError, ValueError, RuntimeError) as exc:
1276
+ return {"ok": False, "code": "IDENTITY_RESOLUTION_FAILED", "message": str(exc)}
1277
+ identity_dimensions = {
1278
+ "repositoryId": (ledger.get("repositoryId"), current_repository),
1279
+ "ownershipHash": (ledger.get("ownershipHash"), current_ownership),
1280
+ "currentHead": (ledger.get("currentHead"), current_head),
1281
+ }
1282
+ changed = {
1283
+ key: {"stored": stored, "current": current}
1284
+ for key, (stored, current) in identity_dimensions.items()
1285
+ if stored != current
1286
+ }
1287
+ if changed:
1288
+ return {
1289
+ "ok": False,
1290
+ "code": "LEDGER_IDENTITY_MISMATCH",
1291
+ "message": "ledger identity does not match current change",
1292
+ "mismatches": changed,
1293
+ }
1294
+ if ledger.get("diffHash") != current_diff:
1295
+ return {
1296
+ "ok": True,
1297
+ "code": "DIFF_CHANGED",
1298
+ "message": "ownership diff changed; reuse is decided per node inputs",
1299
+ "storedDiffHash": ledger.get("diffHash"),
1300
+ "currentDiffHash": current_diff,
1301
+ "currentHead": current_head,
1302
+ }
1303
+ return {
1304
+ "ok": True,
1305
+ "code": "IDENTITY_OK",
1306
+ "currentHead": current_head,
1307
+ "capsuleSelection": capsule_selection,
1308
+ }
1309
+
1310
+
1311
+ def _latest_phase_status(events: list[dict[str, Any]], phase: str) -> str | None:
1312
+ status = None
1313
+ for event in he.apply_event_corrections(events):
1314
+ if event.get("type") == "phase.end" and event.get("phase") == phase:
1315
+ status = str(event.get("status") or "").upper() or None
1316
+ return status
1317
+
1318
+
1319
+ def collect_node_evidence(
1320
+ dag: dict[str, Any],
1321
+ change_dir: Path,
1322
+ events: list[dict[str, Any]],
1323
+ ledger: dict[str, Any] | None,
1324
+ ) -> dict[str, dict[str, Any]]:
1325
+ result: dict[str, dict[str, Any]] = {}
1326
+ validations = (ledger or {}).get("validations") or {}
1327
+ sequence_receipts = _load_sequence_receipts(change_dir).get("receipts") or {}
1328
+ for node in dag.get("nodes") or []:
1329
+ node_id = str(node.get("id") or "")
1330
+ if node.get("kind") == "sequence" or node_id.startswith("sequence:"):
1331
+ receipt = (
1332
+ sequence_receipts.get(node_id)
1333
+ if isinstance(sequence_receipts, dict)
1334
+ else None
1335
+ )
1336
+ status = (
1337
+ str(receipt.get("status") or "").upper()
1338
+ if isinstance(receipt, dict)
1339
+ else ""
1340
+ )
1341
+ result[node_id] = {
1342
+ "reusable": status == "OK",
1343
+ "reason": f"sequence-status:{status or 'missing'}",
1344
+ "receipt": receipt,
1345
+ }
1346
+ elif node.get("kind") == "validation" or node_id.startswith("validation:"):
1347
+ verification = node_id.split(":", 1)[1]
1348
+ entry = validations.get(verification) if isinstance(validations, dict) else None
1349
+ if not isinstance(entry, dict):
1350
+ result[node_id] = {"reusable": False, "reason": "validation-missing"}
1351
+ continue
1352
+ files = entry.get("inputsFiles") if isinstance(entry.get("inputsFiles"), list) else []
1353
+ if not files:
1354
+ result[node_id] = {"reusable": False, "reason": "inputs-missing"}
1355
+ continue
1356
+ reuse = hl.decide_can_reuse(
1357
+ change_dir=change_dir,
1358
+ verification=verification,
1359
+ files=[str(item) for item in files],
1360
+ requested_scope=entry.get("scope") if isinstance(entry.get("scope"), str) else None,
1361
+ requested_command=str(entry.get("command") or "") or None,
1362
+ )
1363
+ result[node_id] = {
1364
+ "reusable": bool(reuse.get("reuse")),
1365
+ "reason": str(reuse.get("code") or reuse.get("reason") or "not-reusable"),
1366
+ "reuse": reuse,
1367
+ }
1368
+ else:
1369
+ phase = str(node.get("phase") or node_id.split(":", 1)[-1])
1370
+ status = _latest_phase_status(events, phase)
1371
+ result[node_id] = {
1372
+ "reusable": status in {"OK", "WARN"},
1373
+ "reason": f"phase-status:{status or 'missing'}",
1374
+ "status": status,
1375
+ }
1376
+ return result
1377
+
1378
+
1379
+ def reconcile(
1380
+ change_dir: Path,
1381
+ project_root: Path,
1382
+ *,
1383
+ target_phase: str | None = None,
1384
+ run_id: str | None = None,
1385
+ ) -> dict[str, Any]:
1386
+ change_dir = change_dir.resolve()
1387
+ project_root = project_root.resolve()
1388
+ # 权威切换(2026-08):v2 发布快照优先,classify 工作副本回退。
1389
+ loaded = hpaths.load_change_gate_policy(change_dir)
1390
+ policy = loaded.get("policy")
1391
+ if policy is None:
1392
+ raise ValueError(
1393
+ "gate policy missing; run harness_gate.py classify "
1394
+ "or publish via plan finalize"
1395
+ )
1396
+ dag = (
1397
+ target_required_dag(policy, target_phase)
1398
+ if target_phase is not None
1399
+ else policy.get("requiredGateDag")
1400
+ )
1401
+ if not isinstance(dag, dict):
1402
+ raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
1403
+ ledger, ledger_path = hl.load_ledger(change_dir)
1404
+ events_file = he.events_path(change_dir)
1405
+ events = he.load_events(events_file) if events_file.is_file() else []
1406
+ identity = assess_identity(
1407
+ change_dir,
1408
+ project_root,
1409
+ ledger,
1410
+ target_phase=target_phase,
1411
+ run_id=run_id,
1412
+ )
1413
+ evidence = collect_node_evidence(dag, change_dir, events, ledger)
1414
+ if identity.get("code") not in {"IDENTITY_OK", "DIFF_CHANGED"}:
1415
+ for facts in evidence.values():
1416
+ facts["reusable"] = False
1417
+ facts["reason"] = "identity-not-reusable"
1418
+ if dag.get("nodes"):
1419
+ decision = reconcile_dag(dag, evidence, identity=identity)
1420
+ elif identity.get("ok"):
1421
+ decision = {"nodes": [], "blockers": [], "canClose": True}
1422
+ else:
1423
+ decision = {
1424
+ "nodes": [],
1425
+ "blockers": [
1426
+ {
1427
+ "code": str(identity.get("code") or "IDENTITY_MISMATCH"),
1428
+ "message": str(identity.get("message") or "identity validation failed"),
1429
+ }
1430
+ ],
1431
+ "canClose": False,
1432
+ }
1433
+ guard_path = change_dir / "evidence" / "test-guard-snapshot.json"
1434
+ guard = _read_json(guard_path) if guard_path.is_file() else None
1435
+ return {
1436
+ "ok": not decision["blockers"],
1437
+ "action": "reconcile",
1438
+ "schemaVersion": SCHEMA_VERSION,
1439
+ "generatedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
1440
+ "changeId": change_dir.name,
1441
+ "projectRoot": str(project_root),
1442
+ "changeDir": str(change_dir),
1443
+ "targetPhase": target_phase,
1444
+ "runId": run_id,
1445
+ "identity": identity,
1446
+ **decision,
1447
+ "guard": guard,
1448
+ "ledgerPath": str(ledger_path) if ledger_path else None,
1449
+ "trace": build_trace(events, change_id=change_dir.name),
1450
+ "timing": timing_dimensions(events),
1451
+ }
1452
+
1453
+
1454
+ def format_compact(result: dict[str, Any]) -> str:
1455
+ lines: list[str] = []
1456
+ for node in result.get("nodes") or []:
1457
+ decision = str(node.get("decision") or "")
1458
+ if decision == "REUSE":
1459
+ continue
1460
+ lines.append(f"{decision} {node.get('id')}: {node.get('reason')}")
1461
+ for blocker in result.get("blockers") or []:
1462
+ lines.append(f"BLOCK {blocker.get('code')}: {blocker.get('message')}")
1463
+ if not lines:
1464
+ nodes = result.get("nodes") or []
1465
+ if not result.get("ok") or not nodes:
1466
+ lines.append(
1467
+ f"BLOCK {result.get('code') or 'EMPTY_RECONCILIATION'}: "
1468
+ f"{result.get('error') or 'no required nodes were reconciled'}"
1469
+ )
1470
+ else:
1471
+ lines.append(f"REUSED {len(nodes)} required nodes; safe to close")
1472
+ return "\n".join(lines)
1473
+
1474
+
1475
+ def _close_gate(args: argparse.Namespace) -> dict[str, Any]:
1476
+ command = [
1477
+ sys.executable,
1478
+ str(SCRIPTS_DIR / "harness_gate.py"),
1479
+ "close",
1480
+ "--json",
1481
+ "--phase",
1482
+ args.phase,
1483
+ "--change",
1484
+ Path(args.change_dir).name,
1485
+ "--project",
1486
+ str(Path(args.project).resolve()),
1487
+ "--status",
1488
+ "OK",
1489
+ ]
1490
+ if args.run_id:
1491
+ command.extend(["--run-id", args.run_id])
1492
+ proc = subprocess.run(
1493
+ command,
1494
+ capture_output=True,
1495
+ text=True,
1496
+ encoding="utf-8",
1497
+ errors="replace",
1498
+ check=False,
1499
+ )
1500
+ try:
1501
+ payload = json.loads(proc.stdout)
1502
+ except json.JSONDecodeError:
1503
+ payload = {"ok": False, "code": "GATE_CLOSE_INVALID_OUTPUT", "stderr": proc.stderr}
1504
+ payload["exitCode"] = proc.returncode
1505
+ return payload
1506
+
1507
+
1508
+ def _cmd_reconcile(args: argparse.Namespace) -> int:
1509
+ try:
1510
+ result = reconcile(
1511
+ Path(args.change_dir),
1512
+ Path(args.project),
1513
+ target_phase=args.phase,
1514
+ run_id=args.run_id,
1515
+ )
1516
+ if args.close:
1517
+ if not result.get("canClose"):
1518
+ result["close"] = {
1519
+ "ok": False,
1520
+ "code": "RECONCILE_NOT_CLOSABLE",
1521
+ "message": "required nodes still need RUN or are BLOCKED",
1522
+ }
1523
+ result["ok"] = False
1524
+ result.setdefault("blockers", []).append(
1525
+ {
1526
+ "code": "RECONCILE_NOT_CLOSABLE",
1527
+ "message": "explicit close refused because required nodes are not reusable",
1528
+ }
1529
+ )
1530
+ else:
1531
+ result["close"] = _close_gate(args)
1532
+ if not result["close"].get("ok"):
1533
+ result["ok"] = False
1534
+ if args.output:
1535
+ _write_json(Path(args.output).resolve(), result)
1536
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1537
+ result = {"ok": False, "action": "reconcile", "code": "RECONCILE_FAILED", "error": str(exc)}
1538
+ if args.json:
1539
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1540
+ else:
1541
+ print(format_compact(result))
1542
+ return 0 if result.get("ok") else 2
1543
+
1544
+
1545
+ def _cmd_metrics(args: argparse.Namespace) -> int:
1546
+ input_path = Path(args.input).resolve()
1547
+ try:
1548
+ text = input_path.read_text(encoding="utf-8-sig")
1549
+ runner = args.runner
1550
+ if runner == "auto":
1551
+ if input_path.suffix.lower() == ".xml":
1552
+ runner = "junit"
1553
+ raw: str | dict[str, Any] = text
1554
+ else:
1555
+ raw = json.loads(text)
1556
+ runner = (
1557
+ "playwright"
1558
+ if isinstance(raw, dict) and "stats" in raw and "suites" in raw
1559
+ else "vitest"
1560
+ )
1561
+ else:
1562
+ raw = text if runner == "junit" else json.loads(text)
1563
+ result = normalize_ci_metrics(
1564
+ raw,
1565
+ runner=runner,
1566
+ head_sha=args.head_sha,
1567
+ source=str(input_path),
1568
+ )
1569
+ if args.output:
1570
+ _write_json(Path(args.output).resolve(), result)
1571
+ except (OSError, ValueError, ET.ParseError, json.JSONDecodeError) as exc:
1572
+ result = {"ok": False, "code": "CI_METRICS_FAILED", "error": str(exc)}
1573
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1574
+ return 2
1575
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1576
+ return 0
1577
+
1578
+
1579
+ def _cmd_record_sequence(args: argparse.Namespace) -> int:
1580
+ result = record_sequence_receipt(
1581
+ Path(args.change_dir),
1582
+ args.node,
1583
+ {
1584
+ "productCommit": args.product_commit,
1585
+ "productTreeHash": args.product_tree_hash,
1586
+ "environmentHash": args.environment_hash,
1587
+ },
1588
+ status=args.status,
1589
+ )
1590
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1591
+ return 0 if result.get("ok") else 2
1592
+
1593
+
1594
+ def _cmd_sequence_status(args: argparse.Namespace) -> int:
1595
+ result = evaluate_final_sequence(
1596
+ Path(args.change_dir),
1597
+ {
1598
+ "productCommit": args.product_commit,
1599
+ "productTreeHash": args.product_tree_hash,
1600
+ "environmentHash": args.environment_hash,
1601
+ },
1602
+ exclude_nodes={"sequence:archive"} if args.pre_archive else set(),
1603
+ )
1604
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1605
+ return 0 if result.get("ok") else 2
1606
+
1607
+
1608
+ def _subject_from_args(args: argparse.Namespace) -> dict[str, str]:
1609
+ return {
1610
+ "productCommit": args.product_commit,
1611
+ "productTreeHash": args.product_tree_hash,
1612
+ "environmentHash": args.environment_hash,
1613
+ }
1614
+
1615
+
1616
+ def _cmd_record_environment(args: argparse.Namespace) -> int:
1617
+ result = record_environment_attempt(
1618
+ Path(args.change_dir),
1619
+ _subject_from_args(args),
1620
+ stage=args.stage,
1621
+ status=args.status,
1622
+ duration_ms=args.duration_ms,
1623
+ failure_signature=args.failure_signature,
1624
+ )
1625
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1626
+ return 0 if result.get("ok") else 2
1627
+
1628
+
1629
+ def _cmd_record_full(args: argparse.Namespace) -> int:
1630
+ result = record_full_test_execution(
1631
+ Path(args.change_dir),
1632
+ _subject_from_args(args),
1633
+ status=args.status,
1634
+ prepare_ms=args.prepare_ms,
1635
+ test_ms=args.test_ms,
1636
+ cleanup_ms=args.cleanup_ms,
1637
+ result_digest=args.result_digest,
1638
+ supersedes=args.supersedes,
1639
+ )
1640
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1641
+ return 0 if result.get("ok") else 2
1642
+
1643
+
1644
+ def _add_subject_arguments(parser: argparse.ArgumentParser) -> None:
1645
+ parser.add_argument("--change-dir", required=True)
1646
+ parser.add_argument("--product-commit", required=True)
1647
+ parser.add_argument("--product-tree-hash", required=True)
1648
+ parser.add_argument("--environment-hash", required=True)
1649
+
1650
+
1651
+ def build_parser() -> argparse.ArgumentParser:
1652
+ parser = argparse.ArgumentParser(prog="harness_phase.py")
1653
+ sub = parser.add_subparsers(dest="command", required=True)
1654
+ reconcile_parser = sub.add_parser("reconcile")
1655
+ reconcile_parser.add_argument("--change-dir", required=True)
1656
+ reconcile_parser.add_argument("--project", required=True)
1657
+ reconcile_parser.add_argument("--json", action="store_true")
1658
+ reconcile_parser.add_argument("--output")
1659
+ reconcile_parser.add_argument("--close", action="store_true")
1660
+ reconcile_parser.add_argument("--phase", default="execute")
1661
+ reconcile_parser.add_argument("--run-id")
1662
+ reconcile_parser.set_defaults(func=_cmd_reconcile)
1663
+
1664
+ metrics_parser = sub.add_parser("metrics")
1665
+ metrics_parser.add_argument("--input", required=True)
1666
+ metrics_parser.add_argument("--runner", choices=("auto", "junit", "vitest", "playwright"), default="auto")
1667
+ metrics_parser.add_argument("--head-sha")
1668
+ metrics_parser.add_argument("--output")
1669
+ metrics_parser.set_defaults(func=_cmd_metrics)
1670
+
1671
+ record_parser = sub.add_parser("record-sequence")
1672
+ record_parser.add_argument("--change-dir", required=True)
1673
+ record_parser.add_argument("--node", required=True)
1674
+ record_parser.add_argument("--product-commit", required=True)
1675
+ record_parser.add_argument("--product-tree-hash", required=True)
1676
+ record_parser.add_argument("--environment-hash", required=True)
1677
+ record_parser.add_argument(
1678
+ "--status",
1679
+ choices=("OK", "WARN", "FAIL"),
1680
+ default="OK",
1681
+ )
1682
+ record_parser.set_defaults(func=_cmd_record_sequence)
1683
+
1684
+ sequence_parser = sub.add_parser("sequence-status")
1685
+ sequence_parser.add_argument("--change-dir", required=True)
1686
+ sequence_parser.add_argument("--product-commit", required=True)
1687
+ sequence_parser.add_argument("--product-tree-hash", required=True)
1688
+ sequence_parser.add_argument("--environment-hash", required=True)
1689
+ sequence_parser.add_argument("--pre-archive", action="store_true")
1690
+ sequence_parser.set_defaults(func=_cmd_sequence_status)
1691
+
1692
+ environment_parser = sub.add_parser("record-environment")
1693
+ _add_subject_arguments(environment_parser)
1694
+ environment_parser.add_argument(
1695
+ "--stage",
1696
+ choices=("prepare", "verify", "release"),
1697
+ required=True,
1698
+ )
1699
+ environment_parser.add_argument(
1700
+ "--status",
1701
+ choices=("OK", "ENVIRONMENT_ERROR", "INTERRUPTED", "FAIL"),
1702
+ required=True,
1703
+ )
1704
+ environment_parser.add_argument("--duration-ms", type=int, required=True)
1705
+ environment_parser.add_argument("--failure-signature")
1706
+ environment_parser.set_defaults(func=_cmd_record_environment)
1707
+
1708
+ full_parser = sub.add_parser("record-full")
1709
+ _add_subject_arguments(full_parser)
1710
+ full_parser.add_argument("--status", choices=("OK", "FAIL"), required=True)
1711
+ full_parser.add_argument("--prepare-ms", type=int, required=True)
1712
+ full_parser.add_argument("--test-ms", type=int, required=True)
1713
+ full_parser.add_argument("--cleanup-ms", type=int, required=True)
1714
+ full_parser.add_argument("--result-digest", required=True)
1715
+ full_parser.add_argument("--supersedes")
1716
+ full_parser.set_defaults(func=_cmd_record_full)
1717
+ return parser
1718
+
1719
+
1720
+ def main(argv: list[str] | None = None) -> int:
1721
+ args = build_parser().parse_args(argv)
1722
+ return int(args.func(args))
1723
+
1724
+
1725
+ if __name__ == "__main__":
1726
+ raise SystemExit(main())