@hunter-harness/workflow-harness 0.2.33 → 0.2.35

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 (243) hide show
  1. package/README.md +25 -25
  2. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  3. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +1 -0
  4. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +215 -106
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +8 -0
  7. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  10. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -3
  12. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  13. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +4 -4
  17. package/harness/bundles/general/claude-code/harness-test/checklist.md +1 -0
  18. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -1
  19. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +2 -2
  20. package/harness/bundles/general/claude-code/scripts/harness_archive.py +415 -15
  21. package/harness/bundles/general/claude-code/scripts/harness_context.py +717 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_gate.py +1 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +77 -9
  24. package/harness/bundles/general/claude-code/scripts/harness_phase.py +1 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_plan_aggregate.py +249 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_profile.py +272 -15
  27. package/harness/bundles/general/claude-code/scripts/harness_report_model.py +420 -0
  28. package/harness/bundles/general/claude-code/scripts/harness_review.py +16 -1
  29. package/harness/bundles/general/claude-code/scripts/harness_verification.py +114 -0
  30. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  31. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +1 -0
  32. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +215 -106
  34. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +8 -0
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  38. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -3
  40. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  41. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  42. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  43. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +4 -4
  45. package/harness/bundles/general/codebuddy/harness-test/checklist.md +1 -0
  46. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -1
  47. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +2 -2
  48. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +415 -15
  49. package/harness/bundles/general/codebuddy/scripts/harness_context.py +717 -0
  50. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1 -0
  51. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +77 -9
  52. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +1 -0
  53. package/harness/bundles/general/codebuddy/scripts/harness_plan_aggregate.py +249 -0
  54. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +272 -15
  55. package/harness/bundles/general/codebuddy/scripts/harness_report_model.py +420 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_review.py +16 -1
  57. package/harness/bundles/general/codebuddy/scripts/harness_verification.py +114 -0
  58. package/harness/bundles/general/codex/.harness-build.json +1 -1
  59. package/harness/bundles/general/codex/contracts/workflow-policy.json +1 -0
  60. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +215 -106
  62. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +8 -0
  63. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  66. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -3
  68. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  69. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  70. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  71. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  72. package/harness/bundles/general/codex/harness-test/SKILL.md +4 -4
  73. package/harness/bundles/general/codex/harness-test/checklist.md +1 -0
  74. package/harness/bundles/general/codex/harness-test/reference.md +2 -1
  75. package/harness/bundles/general/codex/protocols/ledger-protocol.md +2 -2
  76. package/harness/bundles/general/codex/scripts/harness_archive.py +415 -15
  77. package/harness/bundles/general/codex/scripts/harness_context.py +717 -0
  78. package/harness/bundles/general/codex/scripts/harness_gate.py +1 -0
  79. package/harness/bundles/general/codex/scripts/harness_ledger.py +77 -9
  80. package/harness/bundles/general/codex/scripts/harness_phase.py +1 -0
  81. package/harness/bundles/general/codex/scripts/harness_plan_aggregate.py +249 -0
  82. package/harness/bundles/general/codex/scripts/harness_profile.py +272 -15
  83. package/harness/bundles/general/codex/scripts/harness_report_model.py +420 -0
  84. package/harness/bundles/general/codex/scripts/harness_review.py +16 -1
  85. package/harness/bundles/general/codex/scripts/harness_verification.py +114 -0
  86. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  87. package/harness/bundles/general/cursor/contracts/workflow-policy.json +1 -0
  88. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +215 -106
  90. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +8 -0
  91. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  94. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -3
  96. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  97. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  98. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  99. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  100. package/harness/bundles/general/cursor/harness-test/SKILL.md +4 -4
  101. package/harness/bundles/general/cursor/harness-test/checklist.md +1 -0
  102. package/harness/bundles/general/cursor/harness-test/reference.md +2 -1
  103. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +2 -2
  104. package/harness/bundles/general/cursor/scripts/harness_archive.py +415 -15
  105. package/harness/bundles/general/cursor/scripts/harness_context.py +717 -0
  106. package/harness/bundles/general/cursor/scripts/harness_gate.py +1 -0
  107. package/harness/bundles/general/cursor/scripts/harness_ledger.py +77 -9
  108. package/harness/bundles/general/cursor/scripts/harness_phase.py +1 -0
  109. package/harness/bundles/general/cursor/scripts/harness_plan_aggregate.py +249 -0
  110. package/harness/bundles/general/cursor/scripts/harness_profile.py +272 -15
  111. package/harness/bundles/general/cursor/scripts/harness_report_model.py +420 -0
  112. package/harness/bundles/general/cursor/scripts/harness_review.py +16 -1
  113. package/harness/bundles/general/cursor/scripts/harness_verification.py +114 -0
  114. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  115. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +1 -0
  116. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  118. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +215 -106
  119. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +8 -0
  120. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  123. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -3
  126. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  127. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  128. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  129. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/harness-test/SKILL.md +4 -4
  131. package/harness/bundles/java/claude-code/harness-test/checklist.md +1 -0
  132. package/harness/bundles/java/claude-code/harness-test/reference.md +2 -0
  133. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +2 -2
  134. package/harness/bundles/java/claude-code/scripts/harness_archive.py +415 -15
  135. package/harness/bundles/java/claude-code/scripts/harness_context.py +717 -0
  136. package/harness/bundles/java/claude-code/scripts/harness_gate.py +1 -0
  137. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +77 -9
  138. package/harness/bundles/java/claude-code/scripts/harness_phase.py +1 -0
  139. package/harness/bundles/java/claude-code/scripts/harness_plan_aggregate.py +249 -0
  140. package/harness/bundles/java/claude-code/scripts/harness_profile.py +272 -15
  141. package/harness/bundles/java/claude-code/scripts/harness_report_model.py +420 -0
  142. package/harness/bundles/java/claude-code/scripts/harness_review.py +16 -1
  143. package/harness/bundles/java/claude-code/scripts/harness_verification.py +114 -0
  144. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  145. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +1 -0
  146. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  147. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  148. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +215 -106
  149. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +8 -0
  150. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  153. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  154. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  155. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -3
  156. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  157. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  158. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  159. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  160. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +4 -4
  161. package/harness/bundles/java/codebuddy/harness-test/checklist.md +1 -0
  162. package/harness/bundles/java/codebuddy/harness-test/reference.md +2 -0
  163. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +2 -2
  164. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +415 -15
  165. package/harness/bundles/java/codebuddy/scripts/harness_context.py +717 -0
  166. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1 -0
  167. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +77 -9
  168. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +1 -0
  169. package/harness/bundles/java/codebuddy/scripts/harness_plan_aggregate.py +249 -0
  170. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +272 -15
  171. package/harness/bundles/java/codebuddy/scripts/harness_report_model.py +420 -0
  172. package/harness/bundles/java/codebuddy/scripts/harness_review.py +16 -1
  173. package/harness/bundles/java/codebuddy/scripts/harness_verification.py +114 -0
  174. package/harness/bundles/java/codex/.harness-build.json +1 -1
  175. package/harness/bundles/java/codex/contracts/workflow-policy.json +1 -0
  176. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +215 -106
  179. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +8 -0
  180. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  181. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  182. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  183. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  184. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  185. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -3
  186. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  187. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  188. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  189. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  190. package/harness/bundles/java/codex/harness-test/SKILL.md +4 -4
  191. package/harness/bundles/java/codex/harness-test/checklist.md +1 -0
  192. package/harness/bundles/java/codex/harness-test/reference.md +2 -0
  193. package/harness/bundles/java/codex/protocols/ledger-protocol.md +2 -2
  194. package/harness/bundles/java/codex/scripts/harness_archive.py +415 -15
  195. package/harness/bundles/java/codex/scripts/harness_context.py +717 -0
  196. package/harness/bundles/java/codex/scripts/harness_gate.py +1 -0
  197. package/harness/bundles/java/codex/scripts/harness_ledger.py +77 -9
  198. package/harness/bundles/java/codex/scripts/harness_phase.py +1 -0
  199. package/harness/bundles/java/codex/scripts/harness_plan_aggregate.py +249 -0
  200. package/harness/bundles/java/codex/scripts/harness_profile.py +272 -15
  201. package/harness/bundles/java/codex/scripts/harness_report_model.py +420 -0
  202. package/harness/bundles/java/codex/scripts/harness_review.py +16 -1
  203. package/harness/bundles/java/codex/scripts/harness_verification.py +114 -0
  204. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  205. package/harness/bundles/java/cursor/contracts/workflow-policy.json +1 -0
  206. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  207. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +215 -106
  209. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +8 -0
  210. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  211. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  212. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  213. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  214. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  215. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -3
  216. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  217. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  218. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  219. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  220. package/harness/bundles/java/cursor/harness-test/SKILL.md +4 -4
  221. package/harness/bundles/java/cursor/harness-test/checklist.md +1 -0
  222. package/harness/bundles/java/cursor/harness-test/reference.md +2 -0
  223. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +2 -2
  224. package/harness/bundles/java/cursor/scripts/harness_archive.py +415 -15
  225. package/harness/bundles/java/cursor/scripts/harness_context.py +717 -0
  226. package/harness/bundles/java/cursor/scripts/harness_gate.py +1 -0
  227. package/harness/bundles/java/cursor/scripts/harness_ledger.py +77 -9
  228. package/harness/bundles/java/cursor/scripts/harness_phase.py +1 -0
  229. package/harness/bundles/java/cursor/scripts/harness_plan_aggregate.py +249 -0
  230. package/harness/bundles/java/cursor/scripts/harness_profile.py +272 -15
  231. package/harness/bundles/java/cursor/scripts/harness_report_model.py +420 -0
  232. package/harness/bundles/java/cursor/scripts/harness_review.py +16 -1
  233. package/harness/bundles/java/cursor/scripts/harness_verification.py +114 -0
  234. package/harness/manifests/general/claude-code.json +42 -26
  235. package/harness/manifests/general/codebuddy.json +42 -26
  236. package/harness/manifests/general/codex.json +42 -26
  237. package/harness/manifests/general/cursor.json +42 -26
  238. package/harness/manifests/java/claude-code.json +44 -28
  239. package/harness/manifests/java/codebuddy.json +44 -28
  240. package/harness/manifests/java/codex.json +44 -28
  241. package/harness/manifests/java/cursor.json +44 -28
  242. package/hunter-workflow-family.json +4 -4
  243. package/package.json +1 -1
@@ -74,6 +74,7 @@ import harness_gate as hgate # noqa: E402
74
74
  import harness_ledger as hl # noqa: E402
75
75
  import harness_paths as hp # noqa: E402
76
76
  import harness_phase as hphase # noqa: E402
77
+ import harness_report_model as hrm # noqa: E402
77
78
  import harness_review as hr # noqa: E402
78
79
 
79
80
 
@@ -620,6 +621,7 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
620
621
 
621
622
  if assurance == "local-reproducible" and provider == "local-harness":
622
623
  local_required = {
624
+ "subject.environmentHash": environment_hash,
623
625
  "verification.commandSetHash": command_set_hash,
624
626
  "verification.ledgerHash": ledger_hash,
625
627
  "verification.toolchainHashes": verification.get("toolchainHashes"),
@@ -1046,6 +1048,14 @@ def certify_local_candidate(
1046
1048
  f"ledger={recorded_tree_hash} current={current_tree_hash}"
1047
1049
  )
1048
1050
  product_tree_hash = current_tree_hash
1051
+ environment_hashes = sorted(
1052
+ {str(item["environmentHash"]) for item in selected}
1053
+ )
1054
+ subject_environment_hash = (
1055
+ environment_hashes[0]
1056
+ if len(environment_hashes) == 1
1057
+ else _candidate_value_hash(environment_hashes)
1058
+ )
1049
1059
 
1050
1060
  receipt = {
1051
1061
  "schemaVersion": 2,
@@ -1056,6 +1066,7 @@ def certify_local_candidate(
1056
1066
  "subject": {
1057
1067
  "productCommit": product_commit,
1058
1068
  "productTreeHash": product_tree_hash,
1069
+ "environmentHash": subject_environment_hash,
1059
1070
  },
1060
1071
  "verification": {
1061
1072
  "requiredValidations": required,
@@ -1067,9 +1078,7 @@ def certify_local_candidate(
1067
1078
  "toolchainHashes": sorted(
1068
1079
  {str(item["toolchainHash"]) for item in selected}
1069
1080
  ),
1070
- "environmentHashes": sorted(
1071
- {str(item["environmentHash"]) for item in selected}
1072
- ),
1081
+ "environmentHashes": environment_hashes,
1073
1082
  "dependencyHashes": sorted(
1074
1083
  {str(item["inputsHash"]) for item in selected}
1075
1084
  ),
@@ -1643,6 +1652,12 @@ def build_workflow_timing(
1643
1652
  if started_dt is not None and cutoff_dt is not None
1644
1653
  else 0
1645
1654
  )
1655
+ # Segment-level integer conversion can lose sub-millisecond fractions at
1656
+ # each boundary. Attribute only that positive rounding residue to the
1657
+ # otherwise-unattributed bucket so the partition remains exact.
1658
+ rounding_residue = workflow_wall - sum(categories.values())
1659
+ if rounding_residue > 0:
1660
+ categories["unattributed"] += rounding_residue
1646
1661
  stage_wall = union_ms(active_intervals)
1647
1662
  sessions: list[dict[str, Any]] = []
1648
1663
  session_start = started_dt
@@ -2645,10 +2660,9 @@ def _typed_test_metrics(entry: dict[str, Any], *, total_key: str) -> dict[str, A
2645
2660
  "executionRate": f"{executed / total:.0%}" if total > 0 else NOT_AVAILABLE,
2646
2661
  "source": "committed" if total > 0 else "not-run",
2647
2662
  }
2648
- if "blocked" in metrics:
2649
- out["blocked"] = int(metrics.get("blocked", 0) or 0)
2650
- if "skipped" in metrics:
2651
- out["skipped"] = int(metrics.get("skipped", 0) or 0)
2663
+ for optional_count in ("blocked", "skipped", "retries"):
2664
+ if optional_count in metrics:
2665
+ out[optional_count] = int(metrics.get(optional_count, 0) or 0)
2652
2666
  applicability = entry.get("applicability")
2653
2667
  if isinstance(applicability, dict):
2654
2668
  out["applicability"] = applicability
@@ -2665,18 +2679,22 @@ def build_verification_projection(
2665
2679
  apiContract and browserE2E are distinct typed projections; the legacy
2666
2680
  apiTests mapping is retained for schema compatibility.
2667
2681
  """
2668
- validations = (ledger or {}).get("validations") or {}
2682
+ effective_ledger = _deepcopy_json(ledger or {})
2683
+ projected_validations = hrm.latest_terminal_validations(effective_ledger)
2684
+ if projected_validations:
2685
+ effective_ledger["validations"] = projected_validations
2686
+ validations = effective_ledger.get("validations") or {}
2669
2687
  projection: dict[str, Any] = {
2670
- "unitTests": _ledger_unit_tests(ledger),
2671
- "apiTests": _ledger_api_tests(ledger, change_dir=change_dir),
2672
- "dbCompatibility": _ledger_db_compat(ledger),
2688
+ "unitTests": _ledger_unit_tests(effective_ledger),
2689
+ "apiTests": _ledger_api_tests(effective_ledger, change_dir=change_dir),
2690
+ "dbCompatibility": _ledger_db_compat(effective_ledger),
2673
2691
  }
2674
2692
  api_contract = validations.get("apiContract")
2675
2693
  if isinstance(api_contract, dict):
2676
2694
  projection["apiContract"] = _typed_test_metrics(
2677
2695
  api_contract, total_key="scenariosTotal"
2678
2696
  )
2679
- browser_e2e = validations.get("browserE2E")
2697
+ browser_e2e = validations.get("browserTest") or validations.get("browserE2E")
2680
2698
  if isinstance(browser_e2e, dict):
2681
2699
  projection["browserE2E"] = _typed_test_metrics(browser_e2e, total_key="total")
2682
2700
  performance = validations.get("performance")
@@ -3023,8 +3041,10 @@ def _compute_final_status(
3023
3041
  verification: dict[str, Any],
3024
3042
  ) -> tuple[str, list[str]]:
3025
3043
  api = verification.get("apiTests") or {}
3044
+ browser = verification.get("browserE2E") or {}
3026
3045
  db = str(verification.get("dbCompatibility") or "")
3027
3046
  api_status = str(api.get("status") or "")
3047
+ browser_status = str(browser.get("status") or "")
3028
3048
  reasons: list[str] = []
3029
3049
  for phase, v in stage_status.items():
3030
3050
  if v == "FAIL":
@@ -3036,12 +3056,18 @@ def _compute_final_status(
3036
3056
  return "FAIL", [f"unitTests.errors={unit.get('errors')}"]
3037
3057
  if int(api.get("failed") or 0) > 0:
3038
3058
  return "FAIL", [f"apiTests.failed={api.get('failed')}"]
3059
+ if int(browser.get("failed") or 0) > 0:
3060
+ return "FAIL", [f"browserE2E.failed={browser.get('failed')}"]
3061
+ if browser_status == "FAIL":
3062
+ return "FAIL", ["browserE2E.status=FAIL"]
3039
3063
  conditional = {
3040
3064
  "USER_SKIPPED", "BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA",
3041
3065
  "NOT_RUN", "PARTIAL",
3042
3066
  }
3043
3067
  if api_status in conditional:
3044
3068
  reasons.append(f"apiTests.status={api_status}")
3069
+ if browser_status in conditional:
3070
+ reasons.append(f"browserE2E.status={browser_status}")
3045
3071
  if db in conditional:
3046
3072
  reasons.append(f"dbCompatibility={db}")
3047
3073
  if reasons:
@@ -3258,6 +3284,7 @@ def _review_summary(
3258
3284
  "redConfirmed": 0,
3259
3285
  "yellowFixed": 0,
3260
3286
  "yellowDeferred": 0,
3287
+ "currentRiskCount": 0,
3261
3288
  "summary": "",
3262
3289
  }
3263
3290
  sidecar = hr.findings_path(change_dir)
@@ -3287,6 +3314,8 @@ def _review_summary(
3287
3314
  for item in yellow_items
3288
3315
  if item.get("disposition") in {"DEFERRED", "ACCEPTED_RISK"}
3289
3316
  ),
3317
+ "currentRiskCount": int(status.get("currentRiskCount") or 0),
3318
+ "currentRisks": list(status.get("currentRisks") or []),
3290
3319
  "summary": f"structured review run {status.get('runId') or 'unknown'}",
3291
3320
  }
3292
3321
  )
@@ -3759,6 +3788,7 @@ def collect_summary_data(
3759
3788
  data["archiveCommit"] = identity.get("archiveCommit") or data.get("finalCommit")
3760
3789
  data["environmentHash"] = identity.get("environmentHash") or NOT_AVAILABLE
3761
3790
  data["changeIdentity"] = identity
3791
+ data = hrm.apply_identity_mirrors(data)
3762
3792
 
3763
3793
  # diffStat / changedFiles
3764
3794
  if not for_replay or not data.get("changedFiles"):
@@ -3823,8 +3853,11 @@ def collect_summary_data(
3823
3853
  data["gitFacts"] = {
3824
3854
  "baseCommit": data.get("baseCommit") or "",
3825
3855
  "finalCommit": data.get("finalCommit") or "",
3856
+ "productCommit": data.get("productCommit") or "",
3826
3857
  "featureMergeHash": data.get("featureMergeHash") or "",
3827
3858
  "releaseTipHash": data.get("releaseTipHash") or "",
3859
+ "productTreeHash": data.get("productTreeHash") or "",
3860
+ "environmentHash": data.get("environmentHash") or "",
3828
3861
  "filesChanged": int(diff_for_facts.get("filesChanged") or 0),
3829
3862
  "insertions": int(diff_for_facts.get("insertions") or 0),
3830
3863
  "deletions": int(diff_for_facts.get("deletions") or 0),
@@ -3854,10 +3887,12 @@ def collect_summary_data(
3854
3887
  data["artifactStorage"] = (
3855
3888
  storage if isinstance(storage, dict) else {}
3856
3889
  )
3890
+ data["artifactStorage"]["available"] = True
3857
3891
  sources.append("evidence/artifact-storage.json")
3858
3892
  else:
3859
3893
  data["artifactStorage"] = {
3860
3894
  "schemaVersion": 1,
3895
+ "available": False,
3861
3896
  "artifactCount": 0,
3862
3897
  "bytesAdded": 0,
3863
3898
  "bytesReused": 0,
@@ -3885,6 +3920,8 @@ def collect_summary_data(
3885
3920
  existing if for_replay else None,
3886
3921
  projected_events,
3887
3922
  )
3923
+ review_status = hr.status(change_dir)
3924
+ data["reviewFindings"] = list(review_status.get("items") or [])
3888
3925
  if not for_replay:
3889
3926
  data["timeline"] = _timeline_from_events(event_summary, projected_events)
3890
3927
  else:
@@ -3934,6 +3971,8 @@ def collect_summary_data(
3934
3971
  data.setdefault("finalStatusReasons", [])
3935
3972
  data.setdefault("riskTier", "unknown")
3936
3973
 
3974
+ data["normalizedReport"] = hrm.normalize_report(data)
3975
+
3937
3976
  # archiveManifest
3938
3977
  am = {
3939
3978
  "movedFiles": 0,
@@ -4040,7 +4079,7 @@ def resolve_node_path(project_root: Path) -> str | None:
4040
4079
  return shutil.which("node")
4041
4080
 
4042
4081
 
4043
- def render_fallback_html(summary: dict[str, Any]) -> str:
4082
+ def _render_legacy_fallback_html(summary: dict[str, Any]) -> str:
4044
4083
  """Render escaped, deterministic HTML with every validate-required fact.
4045
4084
 
4046
4085
  Used when the Node renderer is unavailable or fails. No timestamps / random
@@ -4217,6 +4256,7 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
4217
4256
  ver = summary.get("verification") or {}
4218
4257
  unit = ver.get("unitTests") or {}
4219
4258
  api = ver.get("apiTests") or {}
4259
+ browser = ver.get("browserE2E") or {}
4220
4260
  parts.append("<h3>Verification</h3>")
4221
4261
  parts.append(
4222
4262
  "<p>unitTests: run={run} failures={failures} errors={errors} "
@@ -4239,6 +4279,17 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
4239
4279
  blocked=esc(api.get("blocked")),
4240
4280
  )
4241
4281
  )
4282
+ parts.append(
4283
+ "<p>browserE2E: status={status} total={total} passed={passed} "
4284
+ "failed={failed} skipped={skipped} retries={retries}</p>".format(
4285
+ status=esc(browser.get("status")),
4286
+ total=esc(browser.get("total")),
4287
+ passed=esc(browser.get("passed")),
4288
+ failed=esc(browser.get("failed")),
4289
+ skipped=esc(browser.get("skipped")),
4290
+ retries=esc(browser.get("retries")),
4291
+ )
4292
+ )
4242
4293
  parts.append(f"<p>dbCompatibility: {esc(ver.get('dbCompatibility'))}</p>")
4243
4294
 
4244
4295
  parts.append("<h3>Changed Files</h3><ul>")
@@ -4275,6 +4326,235 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
4275
4326
  return "\n".join(parts) + "\n"
4276
4327
 
4277
4328
 
4329
+ def render_fallback_html(summary: dict[str, Any]) -> str:
4330
+ """Render the same executive information architecture without Node."""
4331
+
4332
+ def esc(value: Any) -> str:
4333
+ return _html_escape("" if value is None else str(value))
4334
+
4335
+ def rec(value: Any) -> dict[str, Any]:
4336
+ return value if isinstance(value, dict) else {}
4337
+
4338
+ def seq(value: Any) -> list[Any]:
4339
+ return value if isinstance(value, list) else []
4340
+
4341
+ normalized = rec(summary.get("normalizedReport"))
4342
+ outcomes = rec(normalized.get("outcomes"))
4343
+ current = rec(outcomes.get("current")) or {
4344
+ "status": summary.get("finalStatus"),
4345
+ "reasons": seq(summary.get("finalStatusReasons")),
4346
+ "stages": rec(summary.get("stageStatus")),
4347
+ "knownRisks": seq(summary.get("knownRisks")),
4348
+ }
4349
+ release = rec(outcomes.get("release")) or {
4350
+ "intent": summary.get("archiveIntent"),
4351
+ "decision": "NOT_REQUESTED"
4352
+ if summary.get("archiveIntent") == "record-only"
4353
+ else rec(summary.get("releaseDecision")).get("code"),
4354
+ "eligible": rec(summary.get("releaseDecision")).get("releaseEligible"),
4355
+ }
4356
+ identity = rec(normalized.get("identity")) or rec(summary.get("changeIdentity"))
4357
+ verification = rec(normalized.get("verification")) or rec(
4358
+ summary.get("verification")
4359
+ )
4360
+ timing = rec(normalized.get("timing")) or rec(summary.get("timing"))
4361
+ measurements = rec(normalized.get("measurements"))
4362
+ record_only = (
4363
+ release.get("intent") == "record-only"
4364
+ or summary.get("archiveIntent") == "record-only"
4365
+ )
4366
+ product_commit = (
4367
+ identity.get("productCommit")
4368
+ or summary.get("productCommit")
4369
+ or summary.get("finalCommit")
4370
+ or "N/A"
4371
+ )
4372
+ labels = [
4373
+ ("后端", ("unitTests", "dbCompatibility")),
4374
+ ("Geo", ("geo",)),
4375
+ ("前端", ("frontend",)),
4376
+ ("浏览器", ("browserE2E",)),
4377
+ ("API", ("apiTests",)),
4378
+ ]
4379
+ status_labels = {
4380
+ "OK": "通过",
4381
+ "PASS": "通过",
4382
+ "PASSED": "通过",
4383
+ "CONDITIONAL_OK": "有条件通过",
4384
+ "WARN": "警告",
4385
+ "ADVISORY": "建议",
4386
+ "FAIL": "失败",
4387
+ "FAILED": "失败",
4388
+ "ERROR": "错误",
4389
+ "BLOCKED": "阻塞",
4390
+ "NOT_RUN": "未运行",
4391
+ "SKIPPED": "已跳过",
4392
+ "NOT_APPLICABLE": "不适用",
4393
+ "UNKNOWN": "未知",
4394
+ }
4395
+
4396
+ def status_label(value: Any) -> str:
4397
+ status = str(value or "UNKNOWN").upper()
4398
+ return status_labels.get(status, status)
4399
+
4400
+ def verification_status(value: Any) -> str:
4401
+ if isinstance(value, str):
4402
+ return value
4403
+ item = rec(value)
4404
+ if item.get("status"):
4405
+ return str(item["status"])
4406
+ if int(item.get("failures") or 0) + int(item.get("errors") or 0):
4407
+ return "FAIL"
4408
+ if int(item.get("run") or item.get("total") or 0):
4409
+ return "OK"
4410
+ return "NOT_RUN"
4411
+
4412
+ def describe(value: Any) -> str:
4413
+ if isinstance(value, str):
4414
+ return value
4415
+ item = rec(value)
4416
+ return str(
4417
+ item.get("title")
4418
+ or item.get("message")
4419
+ or item.get("summary")
4420
+ or item.get("action")
4421
+ or item.get("remediation")
4422
+ or item
4423
+ )
4424
+
4425
+ def measurement_label(value: Any) -> str:
4426
+ item = rec(value)
4427
+ if item.get("state") in {"unknown", "not_applicable"}:
4428
+ return "N/A"
4429
+ if item.get("state") == "zero":
4430
+ return "0"
4431
+ if item.get("state") == "known":
4432
+ return str(item.get("value"))
4433
+ return "N/A" if value is None or value == "" else str(value)
4434
+
4435
+ risks = (
4436
+ seq(current.get("findings"))
4437
+ or seq(current.get("knownRisks"))
4438
+ or seq(summary.get("knownRisks"))
4439
+ )
4440
+ actions = seq(summary.get("manualActions"))
4441
+ groups: list[str] = []
4442
+ passed = 0
4443
+ for label, keys in labels:
4444
+ statuses = [
4445
+ verification_status(verification[key])
4446
+ for key in keys
4447
+ if key in verification
4448
+ ]
4449
+ status = (
4450
+ "FAIL"
4451
+ if any(value in {"FAIL", "ERROR", "BLOCKED"} for value in statuses)
4452
+ else "WARN"
4453
+ if any("WARN" in value or "CONDITIONAL" in value for value in statuses)
4454
+ else "NOT_APPLICABLE"
4455
+ if statuses and all(value == "NOT_APPLICABLE" for value in statuses)
4456
+ else "OK"
4457
+ if statuses
4458
+ and all(
4459
+ value in {"OK", "PASS", "PASSED", "NOT_APPLICABLE"}
4460
+ for value in statuses
4461
+ )
4462
+ else "NOT_RUN"
4463
+ )
4464
+ if status == "OK":
4465
+ passed += 1
4466
+ details = " · ".join(
4467
+ str(rec(verification.get(key)).get("passRate") or status)
4468
+ for key in keys
4469
+ if key in verification
4470
+ ) or "未配置该组验证"
4471
+ raw_details = " · ".join(
4472
+ f"{key} · status={verification_status(verification[key])}"
4473
+ + (
4474
+ f" · failed={rec(verification[key]).get('failed')}"
4475
+ if rec(verification[key]).get("failed") is not None
4476
+ else ""
4477
+ )
4478
+ for key in keys
4479
+ if key in verification
4480
+ )
4481
+ groups.append(
4482
+ f'<article class="verify" title="{esc(raw_details)}"><strong>{esc(label)}</strong>'
4483
+ f'<span title="{esc(status)}">{esc(status_label(status))}</span><small>{esc(details)}</small></article>'
4484
+ )
4485
+
4486
+ commands = seq(rec(summary.get("reportPipeline")).get("commands"))
4487
+ command_rows = "".join(
4488
+ f"<tr><td>{esc(item.get('phase') or '-')}</td>"
4489
+ f"<td><code>{esc(item.get('command'))}</code></td>"
4490
+ f"<td>{esc('OK' if int(item.get('exit_code', item.get('exitCode', 1))) == 0 else 'FAIL')}</td></tr>"
4491
+ for item in commands
4492
+ if isinstance(item, dict)
4493
+ ) or '<tr><td colspan="3">没有命令证据</td></tr>'
4494
+ files = seq(summary.get("changedFiles"))
4495
+ file_rows = "".join(
4496
+ f"<tr><td><code>{esc(item.get('path') or item.get('file'))}</code></td>"
4497
+ f"<td>+{esc(item.get('insertions') or 0)}</td>"
4498
+ f"<td>-{esc(item.get('deletions') or 0)}</td></tr>"
4499
+ for item in files
4500
+ if isinstance(item, dict)
4501
+ ) or '<tr><td colspan="3">没有变更文件证据</td></tr>'
4502
+ risk_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in risks) or "<li>当前没有未处置风险</li>"
4503
+ action_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in actions) or "<li>无需人工后续动作</li>"
4504
+ release_card = (
4505
+ ""
4506
+ if record_only
4507
+ else "<article class=\"card\"><h2>发布与候选</h2>"
4508
+ f"<p>候选证明 / 发布资格:{esc(release.get('decision') or 'NOT_EVALUATED')}</p></article>"
4509
+ )
4510
+ remote = rec(measurements.get("remoteCost"))
4511
+ storage = rec(measurements.get("artifactStorage"))
4512
+ remote_totals = rec(remote.get("totals"))
4513
+ projection = rec(summary.get("projection"))
4514
+ archive_integrity = rec(summary.get("archiveIntegrity"))
4515
+ total_files = rec(summary.get("archiveManifest")).get("totalArchiveFiles")
4516
+ total_files_text = "" if total_files is None else str(total_files)
4517
+
4518
+ return f"""<!doctype html>
4519
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4520
+ <title>Harness 最终报告 · {esc(summary.get("changeName"))}</title>
4521
+ <style>
4522
+ :root{{--bg:#f4f6f8;--card:#fff;--text:#172033;--muted:#667085;--line:#dfe5ec}}
4523
+ @media(prefers-color-scheme:dark){{:root{{--bg:#0b1119;--card:#121a26;--text:#eef3f9;--muted:#9aa8ba;--line:#2a384a}}}}
4524
+ *{{box-sizing:border-box}}html,body{{max-width:100%;overflow-x:hidden}}body{{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif}}main{{width:min(1120px,calc(100% - 32px));margin:24px auto}}.hero,.card,.metric,.verify,details{{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}}.hero h1{{margin:4px 0}}.metrics,.groups{{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin:10px 0}}.metric strong,.verify strong{{display:block;font-size:17px}}.verify small{{display:block;color:var(--muted)}}.risk{{display:grid;grid-template-columns:1fr 1fr;gap:10px}}.card{{margin:10px 0}}details{{margin:9px 0}}summary{{cursor:pointer;font-weight:700}}details>div{{overflow:auto}}table{{width:100%;border-collapse:collapse}}td,th{{padding:8px;border-bottom:1px solid var(--line);text-align:left}}code{{overflow-wrap:anywhere}}@media(max-width:600px){{main{{width:calc(100% - 18px);margin:9px auto}}.metrics,.groups,.risk{{grid-template-columns:1fr}}}}
4525
+ </style></head><body><main>
4526
+ <section class="hero"><small>HARNESS · 管理结论</small><h1>{esc(summary.get("changeName"))}</h1>
4527
+ <p>{esc(summary.get("businessGoal") or "未记录业务目标")}</p>
4528
+ <strong title="{esc(current.get("status"))}">{esc(status_label(current.get("status")))}</strong>
4529
+ <p id="finalStatusReasons">{esc(" · ".join(str(item) for item in seq(current.get("reasons"))))}</p>
4530
+ {"<p>归档意图:仅记录 · 未请求发布</p>" if record_only else ""}</section>
4531
+ <section class="metrics"><article class="metric"><small>产品提交</small><strong><code>{esc(str(product_commit)[:10])}</code></strong></article>
4532
+ <article class="metric"><small>验证概览</small><strong>{passed}/5 组通过</strong></article>
4533
+ <article class="metric"><small>风险与动作</small><strong>{len(risks)} / {len(actions)}</strong></article>
4534
+ <article class="metric"><small>全流程耗时</small><strong>{esc(timing.get("workflowWallClockMs") or "N/A")}</strong></article>
4535
+ <article class="metric"><small>归档文件</small><strong>{esc(total_files_text or "N/A")}</strong></article></section>
4536
+ <article class="card"><h2>验证概览</h2><div class="groups">{"".join(groups)}</div></article>
4537
+ <article class="card"><h2>风险与动作</h2><div class="risk"><section><h3>当前风险</h3><ul>{risk_rows}</ul></section><section><h3>人工动作</h3><ul>{action_rows}</ul></section></div></article>
4538
+ {release_card}
4539
+ <details><summary>技术证据 · 时间守恒</summary><div><p>conservationDeltaMs={esc(timing.get("conservationDeltaMs") if "conservationDeltaMs" in timing else "N/A")}</p>
4540
+ <p>workflowWallClock={esc(timing.get("workflowWallClockMs") if "workflowWallClockMs" in timing else "N/A")}</p>
4541
+ <p>stageActiveExecution={esc(timing.get("stageActiveExecutionMs") if "stageActiveExecutionMs" in timing else "N/A")}</p>
4542
+ <p>stageWallClockSpan={esc(timing.get("stageWallClockSpanMs") if "stageWallClockSpanMs" in timing else "N/A")}</p>
4543
+ <p>reportCutoffAt={esc(timing.get("reportCutoffAt") or "N/A")}</p>
4544
+ <p>远端 runner 成本:{esc(measurement_label(remote_totals.get("runnerMinutes") or remote.get("runnerMinutes")))}</p>
4545
+ <p>新增制品字节:{esc(measurement_label(storage.get("bytesAdded")))}</p></div></details>
4546
+ <details><summary>技术证据 · 发布与归档治理</summary><div>
4547
+ <p>归档完整性:{esc(archive_integrity.get("code") or rec(summary.get("archiveManifest")).get("checksumStatus") or "N/A")}</p>
4548
+ <p>历史质量:未闭合尝试 {esc(timing.get("unclosedAttemptCount") if "unclosedAttemptCount" in timing else "N/A")}</p>
4549
+ <p>投影状态:{esc(projection.get("code") or projection.get("mode") or "N/A")}</p></div></details>
4550
+ <details><summary>技术证据 · 变更文件</summary><div><table><tbody>{file_rows}</tbody></table></div></details>
4551
+ <details><summary>技术证据 · 命令</summary><div><table><tbody>{command_rows}</tbody></table></div></details>
4552
+ <details><summary>技术元数据</summary><div><p>productCommit=<code>{esc(product_commit)}</code></p>
4553
+ <p>schemaVersion={esc(summary.get("schemaVersion"))}</p></div></details>
4554
+ </main></body></html>
4555
+ """
4556
+
4557
+
4278
4558
  def render_final_summary(
4279
4559
  change_dir: Path,
4280
4560
  summary_path: Path,
@@ -4725,6 +5005,7 @@ def validate_summary(
4725
5005
  ver = summary.get("verification") or {}
4726
5006
  unit = ver.get("unitTests") or {}
4727
5007
  api = ver.get("apiTests") or {}
5008
+ browser = ver.get("browserE2E") or {}
4728
5009
  pass_rate = unit.get("passRate")
4729
5010
  if pass_rate and pass_rate != NOT_AVAILABLE and str(pass_rate) not in html:
4730
5011
  issues.append(
@@ -4745,6 +5026,22 @@ def validate_summary(
4745
5026
  "message": f"apiTests.status '{api_status}' not visible in final-summary",
4746
5027
  }
4747
5028
  )
5029
+ browser_status = str(browser.get("status") or "")
5030
+ if (
5031
+ browser_status
5032
+ and browser_status not in {"", NOT_AVAILABLE}
5033
+ and browser_status not in html
5034
+ ):
5035
+ issues.append(
5036
+ {
5037
+ "code": "missing-verification",
5038
+ "severity": "warning",
5039
+ "message": (
5040
+ f"browserE2E.status '{browser_status}' not visible "
5041
+ "in final-summary"
5042
+ ),
5043
+ }
5044
+ )
4748
5045
 
4749
5046
  # artifacts / summary-data path hints
4750
5047
  am = summary.get("archiveManifest") or {}
@@ -4777,11 +5074,23 @@ def validate_summary(
4777
5074
 
4778
5075
  # status contradiction
4779
5076
  final_status = str(summary.get("finalStatus") or "")
4780
- has_skip = api_status == "USER_SKIPPED" or str(
5077
+ browser_conditional = {
5078
+ "USER_SKIPPED",
5079
+ "BLOCKED",
5080
+ "BLOCKED_BY_ENV",
5081
+ "NOT_RUN",
5082
+ "PARTIAL",
5083
+ }
5084
+ has_skip = api_status == "USER_SKIPPED" or browser_status in browser_conditional or str(
4781
5085
  ver.get("dbCompatibility") or ""
4782
5086
  ) == "BLOCKED_BY_DBA"
4783
5087
  has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
4784
5088
  has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
5089
+ has_fail_ver = (
5090
+ has_fail_ver
5091
+ or int(browser.get("failed") or 0) > 0
5092
+ or browser_status == "FAIL"
5093
+ )
4785
5094
  stage = summary.get("stageStatus") or {}
4786
5095
  has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
4787
5096
 
@@ -4811,13 +5120,26 @@ def validate_summary(
4811
5120
  ver = summary.get("verification") or {}
4812
5121
  unit = ver.get("unitTests") or {}
4813
5122
  api = ver.get("apiTests") or {}
5123
+ browser = ver.get("browserE2E") or {}
4814
5124
  api_status = str(api.get("status") or "")
5125
+ browser_status = str(browser.get("status") or "")
4815
5126
  final_status = str(summary.get("finalStatus") or "")
4816
- has_skip = api_status == "USER_SKIPPED" or str(
5127
+ has_skip = api_status == "USER_SKIPPED" or browser_status in {
5128
+ "USER_SKIPPED",
5129
+ "BLOCKED",
5130
+ "BLOCKED_BY_ENV",
5131
+ "NOT_RUN",
5132
+ "PARTIAL",
5133
+ } or str(
4817
5134
  ver.get("dbCompatibility") or ""
4818
5135
  ) == "BLOCKED_BY_DBA"
4819
5136
  has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
4820
5137
  has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
5138
+ has_fail_ver = (
5139
+ has_fail_ver
5140
+ or int(browser.get("failed") or 0) > 0
5141
+ or browser_status == "FAIL"
5142
+ )
4821
5143
  stage = summary.get("stageStatus") or {}
4822
5144
  has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
4823
5145
  if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
@@ -4946,6 +5268,84 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
4946
5268
  "message": f"stageStatus.{phase}={status} but event reducer says {event_status}",
4947
5269
  })
4948
5270
 
5271
+ # All compatibility mirrors must be derived from the canonical identity.
5272
+ identity_doc = summary.get("changeIdentity")
5273
+ identity_doc = identity_doc if isinstance(identity_doc, dict) else {}
5274
+ canonical = hrm.canonical_identity(summary)
5275
+ mirror_sources = {
5276
+ "productCommit": summary.get("productCommit"),
5277
+ "featureMergeHash": summary.get("featureMergeHash"),
5278
+ "releaseTipHash": summary.get("releaseTipHash"),
5279
+ "productTreeHash": summary.get("productTreeHash"),
5280
+ "environmentHash": summary.get("environmentHash"),
5281
+ }
5282
+ for field, mirror in mirror_sources.items():
5283
+ expected = str(canonical.get(field) or "")
5284
+ actual = str(mirror or "")
5285
+ if field in hrm.CONTENT_HASH_FIELDS:
5286
+ expected = expected.removeprefix("sha256:")
5287
+ actual = actual.removeprefix("sha256:")
5288
+ if identity_doc.get(field) and actual and expected != actual:
5289
+ issues.append(
5290
+ {
5291
+ "code": "IDENTITY_MIRROR_MISMATCH",
5292
+ "severity": "error",
5293
+ "message": f"{field} mirror differs from changeIdentity",
5294
+ "expected": str(canonical.get(field) or ""),
5295
+ "actual": str(mirror or ""),
5296
+ }
5297
+ )
5298
+
5299
+ # Timing categories form a disjoint, wall-clock-conserving partition.
5300
+ timing = summary.get("timing")
5301
+ timing = timing if isinstance(timing, dict) else {}
5302
+ if timing:
5303
+ wall = int(timing.get("workflowWallClockMs") or 0)
5304
+ parts = sum(
5305
+ int(timing.get(key) or 0)
5306
+ for key in (
5307
+ "attributedStageUnionMs",
5308
+ "externalWaitMs",
5309
+ "pausedMs",
5310
+ "agentOrToolUnattributedMs",
5311
+ )
5312
+ )
5313
+ declared_delta = int(timing.get("conservationDeltaMs") or 0)
5314
+ if parts != wall or declared_delta != wall - parts:
5315
+ issues.append(
5316
+ {
5317
+ "code": "TIMING_CONSERVATION_MISMATCH",
5318
+ "severity": "error",
5319
+ "message": (
5320
+ f"timing categories total {parts}ms, wall clock is "
5321
+ f"{wall}ms, declared delta is {declared_delta}ms"
5322
+ ),
5323
+ }
5324
+ )
5325
+
5326
+ candidate_gate_for_release = summary.get("candidateVerification")
5327
+ candidate_gate_for_release = (
5328
+ candidate_gate_for_release
5329
+ if isinstance(candidate_gate_for_release, dict)
5330
+ else {}
5331
+ )
5332
+ candidate_status = str(
5333
+ candidate_gate_for_release.get("status")
5334
+ or candidate_gate_for_release.get("code")
5335
+ or ""
5336
+ ).upper()
5337
+ if bool(summary.get("releaseEligible")) and (
5338
+ candidate_gate_for_release.get("ok") is False
5339
+ or candidate_status in {"FAIL", "FAILED", "ERROR", "BLOCKED"}
5340
+ ):
5341
+ issues.append(
5342
+ {
5343
+ "code": "RELEASE_ELIGIBILITY_CONTRADICTION",
5344
+ "severity": "error",
5345
+ "message": "releaseEligible=true while candidate verification is not successful",
5346
+ }
5347
+ )
5348
+
4949
5349
  # Candidate receipt and summary must describe the same immutable subject.
4950
5350
  candidate_gate = summary.get("candidateVerification")
4951
5351
  candidate_gate = candidate_gate if isinstance(candidate_gate, dict) else {}