@hunter-harness/workflow-harness 0.2.8 → 0.2.10

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 (334) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +3 -3
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -3
  4. package/harness/bundles/general/claude-code/harness-archive/reference.md +30 -12
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +5 -1
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +6 -2
  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 +3 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +7 -0
  10. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  11. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-plan/checklist.md +6 -0
  14. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  15. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-run/checklist.md +5 -0
  17. package/harness/bundles/general/claude-code/harness-run/reference.md +33 -1
  18. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +15 -10
  19. package/harness/bundles/general/claude-code/harness-submit/checklist.md +40 -76
  20. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +17 -2
  21. package/harness/bundles/general/claude-code/harness-sync/reference.md +2 -2
  22. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  23. package/harness/bundles/general/claude-code/harness-test/checklist.md +9 -0
  24. package/harness/bundles/general/claude-code/harness-test/pitfalls-java.md +14 -0
  25. package/harness/bundles/general/claude-code/harness-test/reference.md +38 -0
  26. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +26 -4
  27. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +38 -0
  28. package/harness/bundles/general/claude-code/protocols/powershell-protocol.md +16 -0
  29. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +17 -0
  30. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +15 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_archive.py +1209 -170
  32. package/harness/bundles/general/claude-code/scripts/harness_change.py +18 -0
  33. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -44
  34. package/harness/bundles/general/claude-code/scripts/harness_events.py +174 -25
  35. package/harness/bundles/general/claude-code/scripts/harness_gate.py +203 -29
  36. package/harness/bundles/general/claude-code/scripts/harness_integration.py +878 -0
  37. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +457 -10
  38. package/harness/bundles/general/claude-code/scripts/harness_paths.py +315 -0
  39. package/harness/bundles/general/claude-code/scripts/harness_profile.py +45 -0
  40. package/harness/bundles/general/claude-code/scripts/harness_review.py +326 -0
  41. package/harness/bundles/general/claude-code/scripts/harness_sync.py +407 -0
  42. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +244 -49
  43. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  44. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +3 -3
  45. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -3
  46. package/harness/bundles/general/codebuddy/harness-archive/reference.md +30 -12
  47. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +5 -1
  48. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
  49. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  50. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
  51. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +7 -0
  52. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  53. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  54. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  55. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +6 -0
  56. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  57. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  58. package/harness/bundles/general/codebuddy/harness-run/checklist.md +5 -0
  59. package/harness/bundles/general/codebuddy/harness-run/reference.md +33 -1
  60. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +15 -10
  61. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +40 -76
  62. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +17 -2
  63. package/harness/bundles/general/codebuddy/harness-sync/reference.md +2 -2
  64. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  65. package/harness/bundles/general/codebuddy/harness-test/checklist.md +9 -0
  66. package/harness/bundles/general/codebuddy/harness-test/pitfalls-java.md +14 -0
  67. package/harness/bundles/general/codebuddy/harness-test/reference.md +38 -0
  68. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +26 -4
  69. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +38 -0
  70. package/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +16 -0
  71. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +17 -0
  72. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +15 -0
  73. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +1209 -170
  74. package/harness/bundles/general/codebuddy/scripts/harness_change.py +18 -0
  75. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -44
  76. package/harness/bundles/general/codebuddy/scripts/harness_events.py +174 -25
  77. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +203 -29
  78. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +878 -0
  79. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +457 -10
  80. package/harness/bundles/general/codebuddy/scripts/harness_paths.py +315 -0
  81. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +45 -0
  82. package/harness/bundles/general/codebuddy/scripts/harness_review.py +326 -0
  83. package/harness/bundles/general/codebuddy/scripts/harness_sync.py +407 -0
  84. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +244 -49
  85. package/harness/bundles/general/codex/.harness-build.json +1 -1
  86. package/harness/bundles/general/codex/contracts/workflow-policy.json +3 -3
  87. package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -3
  88. package/harness/bundles/general/codex/harness-archive/reference.md +30 -12
  89. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +5 -1
  90. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +6 -2
  91. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  92. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -1
  93. package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +7 -0
  94. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  95. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  96. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  97. package/harness/bundles/general/codex/harness-plan/checklist.md +6 -0
  98. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  99. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  100. package/harness/bundles/general/codex/harness-run/checklist.md +5 -0
  101. package/harness/bundles/general/codex/harness-run/reference.md +33 -1
  102. package/harness/bundles/general/codex/harness-submit/SKILL.md +15 -10
  103. package/harness/bundles/general/codex/harness-submit/checklist.md +40 -76
  104. package/harness/bundles/general/codex/harness-sync/SKILL.md +17 -2
  105. package/harness/bundles/general/codex/harness-sync/reference.md +2 -2
  106. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  107. package/harness/bundles/general/codex/harness-test/checklist.md +9 -0
  108. package/harness/bundles/general/codex/harness-test/pitfalls-java.md +14 -0
  109. package/harness/bundles/general/codex/harness-test/reference.md +38 -0
  110. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +26 -4
  111. package/harness/bundles/general/codex/protocols/ledger-protocol.md +38 -0
  112. package/harness/bundles/general/codex/protocols/powershell-protocol.md +16 -0
  113. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +17 -0
  114. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +15 -0
  115. package/harness/bundles/general/codex/scripts/harness_archive.py +1209 -170
  116. package/harness/bundles/general/codex/scripts/harness_change.py +18 -0
  117. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -44
  118. package/harness/bundles/general/codex/scripts/harness_events.py +174 -25
  119. package/harness/bundles/general/codex/scripts/harness_gate.py +203 -29
  120. package/harness/bundles/general/codex/scripts/harness_integration.py +878 -0
  121. package/harness/bundles/general/codex/scripts/harness_ledger.py +457 -10
  122. package/harness/bundles/general/codex/scripts/harness_paths.py +315 -0
  123. package/harness/bundles/general/codex/scripts/harness_profile.py +45 -0
  124. package/harness/bundles/general/codex/scripts/harness_review.py +326 -0
  125. package/harness/bundles/general/codex/scripts/harness_sync.py +407 -0
  126. package/harness/bundles/general/codex/scripts/harness_test_guard.py +244 -49
  127. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  128. package/harness/bundles/general/cursor/contracts/workflow-policy.json +3 -3
  129. package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -3
  130. package/harness/bundles/general/cursor/harness-archive/reference.md +30 -12
  131. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +5 -1
  132. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +6 -2
  133. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  134. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -1
  135. package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +7 -0
  136. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  137. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  138. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  139. package/harness/bundles/general/cursor/harness-plan/checklist.md +6 -0
  140. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  141. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  142. package/harness/bundles/general/cursor/harness-run/checklist.md +5 -0
  143. package/harness/bundles/general/cursor/harness-run/reference.md +33 -1
  144. package/harness/bundles/general/cursor/harness-submit/SKILL.md +15 -10
  145. package/harness/bundles/general/cursor/harness-submit/checklist.md +40 -76
  146. package/harness/bundles/general/cursor/harness-sync/SKILL.md +17 -2
  147. package/harness/bundles/general/cursor/harness-sync/reference.md +2 -2
  148. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  149. package/harness/bundles/general/cursor/harness-test/checklist.md +9 -0
  150. package/harness/bundles/general/cursor/harness-test/pitfalls-java.md +14 -0
  151. package/harness/bundles/general/cursor/harness-test/reference.md +38 -0
  152. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +26 -4
  153. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +38 -0
  154. package/harness/bundles/general/cursor/protocols/powershell-protocol.md +16 -0
  155. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +17 -0
  156. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +15 -0
  157. package/harness/bundles/general/cursor/scripts/harness_archive.py +1209 -170
  158. package/harness/bundles/general/cursor/scripts/harness_change.py +18 -0
  159. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -44
  160. package/harness/bundles/general/cursor/scripts/harness_events.py +174 -25
  161. package/harness/bundles/general/cursor/scripts/harness_gate.py +203 -29
  162. package/harness/bundles/general/cursor/scripts/harness_integration.py +878 -0
  163. package/harness/bundles/general/cursor/scripts/harness_ledger.py +457 -10
  164. package/harness/bundles/general/cursor/scripts/harness_paths.py +315 -0
  165. package/harness/bundles/general/cursor/scripts/harness_profile.py +45 -0
  166. package/harness/bundles/general/cursor/scripts/harness_review.py +326 -0
  167. package/harness/bundles/general/cursor/scripts/harness_sync.py +407 -0
  168. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +244 -49
  169. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  170. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +3 -3
  171. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  172. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -3
  173. package/harness/bundles/java/claude-code/harness-archive/reference.md +30 -12
  174. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +5 -1
  175. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +6 -2
  176. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  177. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
  178. package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +7 -0
  179. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  180. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  181. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  182. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-plan/checklist.md +6 -0
  184. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  185. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +15 -10
  187. package/harness/bundles/java/claude-code/harness-submit/checklist.md +40 -76
  188. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +17 -2
  189. package/harness/bundles/java/claude-code/harness-sync/reference.md +2 -2
  190. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +26 -4
  192. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +38 -0
  193. package/harness/bundles/java/claude-code/protocols/powershell-protocol.md +16 -0
  194. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +17 -0
  195. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +15 -0
  196. package/harness/bundles/java/claude-code/scripts/harness_archive.py +1209 -170
  197. package/harness/bundles/java/claude-code/scripts/harness_change.py +18 -0
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -44
  199. package/harness/bundles/java/claude-code/scripts/harness_events.py +174 -25
  200. package/harness/bundles/java/claude-code/scripts/harness_gate.py +203 -29
  201. package/harness/bundles/java/claude-code/scripts/harness_integration.py +878 -0
  202. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +457 -10
  203. package/harness/bundles/java/claude-code/scripts/harness_paths.py +315 -0
  204. package/harness/bundles/java/claude-code/scripts/harness_profile.py +45 -0
  205. package/harness/bundles/java/claude-code/scripts/harness_review.py +326 -0
  206. package/harness/bundles/java/claude-code/scripts/harness_sync.py +407 -0
  207. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +244 -49
  208. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  209. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +3 -3
  210. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -3
  212. package/harness/bundles/java/codebuddy/harness-archive/reference.md +30 -12
  213. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +5 -1
  214. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
  215. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
  217. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +7 -0
  218. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  219. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  220. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  221. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  222. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +6 -0
  223. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  224. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  225. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +15 -10
  226. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +40 -76
  227. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +17 -2
  228. package/harness/bundles/java/codebuddy/harness-sync/reference.md +2 -2
  229. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  230. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +26 -4
  231. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +38 -0
  232. package/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +16 -0
  233. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +17 -0
  234. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +15 -0
  235. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +1209 -170
  236. package/harness/bundles/java/codebuddy/scripts/harness_change.py +18 -0
  237. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -44
  238. package/harness/bundles/java/codebuddy/scripts/harness_events.py +174 -25
  239. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +203 -29
  240. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +878 -0
  241. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +457 -10
  242. package/harness/bundles/java/codebuddy/scripts/harness_paths.py +315 -0
  243. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +45 -0
  244. package/harness/bundles/java/codebuddy/scripts/harness_review.py +326 -0
  245. package/harness/bundles/java/codebuddy/scripts/harness_sync.py +407 -0
  246. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +244 -49
  247. package/harness/bundles/java/codex/.harness-build.json +1 -1
  248. package/harness/bundles/java/codex/contracts/workflow-policy.json +3 -3
  249. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  250. package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -3
  251. package/harness/bundles/java/codex/harness-archive/reference.md +30 -12
  252. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +5 -1
  253. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +6 -2
  254. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  255. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -1
  256. package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +7 -0
  257. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  258. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  259. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  260. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  261. package/harness/bundles/java/codex/harness-plan/checklist.md +6 -0
  262. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  263. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  264. package/harness/bundles/java/codex/harness-submit/SKILL.md +15 -10
  265. package/harness/bundles/java/codex/harness-submit/checklist.md +40 -76
  266. package/harness/bundles/java/codex/harness-sync/SKILL.md +17 -2
  267. package/harness/bundles/java/codex/harness-sync/reference.md +2 -2
  268. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  269. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +26 -4
  270. package/harness/bundles/java/codex/protocols/ledger-protocol.md +38 -0
  271. package/harness/bundles/java/codex/protocols/powershell-protocol.md +16 -0
  272. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +17 -0
  273. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +15 -0
  274. package/harness/bundles/java/codex/scripts/harness_archive.py +1209 -170
  275. package/harness/bundles/java/codex/scripts/harness_change.py +18 -0
  276. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -44
  277. package/harness/bundles/java/codex/scripts/harness_events.py +174 -25
  278. package/harness/bundles/java/codex/scripts/harness_gate.py +203 -29
  279. package/harness/bundles/java/codex/scripts/harness_integration.py +878 -0
  280. package/harness/bundles/java/codex/scripts/harness_ledger.py +457 -10
  281. package/harness/bundles/java/codex/scripts/harness_paths.py +315 -0
  282. package/harness/bundles/java/codex/scripts/harness_profile.py +45 -0
  283. package/harness/bundles/java/codex/scripts/harness_review.py +326 -0
  284. package/harness/bundles/java/codex/scripts/harness_sync.py +407 -0
  285. package/harness/bundles/java/codex/scripts/harness_test_guard.py +244 -49
  286. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  287. package/harness/bundles/java/cursor/contracts/workflow-policy.json +3 -3
  288. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  289. package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -3
  290. package/harness/bundles/java/cursor/harness-archive/reference.md +30 -12
  291. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +5 -1
  292. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +6 -2
  293. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  294. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -1
  295. package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +7 -0
  296. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  297. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  298. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  299. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  300. package/harness/bundles/java/cursor/harness-plan/checklist.md +6 -0
  301. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  302. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  303. package/harness/bundles/java/cursor/harness-submit/SKILL.md +15 -10
  304. package/harness/bundles/java/cursor/harness-submit/checklist.md +40 -76
  305. package/harness/bundles/java/cursor/harness-sync/SKILL.md +17 -2
  306. package/harness/bundles/java/cursor/harness-sync/reference.md +2 -2
  307. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  308. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +26 -4
  309. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +38 -0
  310. package/harness/bundles/java/cursor/protocols/powershell-protocol.md +16 -0
  311. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +17 -0
  312. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +15 -0
  313. package/harness/bundles/java/cursor/scripts/harness_archive.py +1209 -170
  314. package/harness/bundles/java/cursor/scripts/harness_change.py +18 -0
  315. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -44
  316. package/harness/bundles/java/cursor/scripts/harness_events.py +174 -25
  317. package/harness/bundles/java/cursor/scripts/harness_gate.py +203 -29
  318. package/harness/bundles/java/cursor/scripts/harness_integration.py +878 -0
  319. package/harness/bundles/java/cursor/scripts/harness_ledger.py +457 -10
  320. package/harness/bundles/java/cursor/scripts/harness_paths.py +315 -0
  321. package/harness/bundles/java/cursor/scripts/harness_profile.py +45 -0
  322. package/harness/bundles/java/cursor/scripts/harness_review.py +326 -0
  323. package/harness/bundles/java/cursor/scripts/harness_sync.py +407 -0
  324. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +244 -49
  325. package/harness/manifests/general/claude-code.json +58 -38
  326. package/harness/manifests/general/codebuddy.json +58 -38
  327. package/harness/manifests/general/codex.json +58 -38
  328. package/harness/manifests/general/cursor.json +58 -38
  329. package/harness/manifests/java/claude-code.json +52 -36
  330. package/harness/manifests/java/codebuddy.json +52 -36
  331. package/harness/manifests/java/codex.json +52 -36
  332. package/harness/manifests/java/cursor.json +52 -36
  333. package/hunter-workflow-family.json +2 -2
  334. package/package.json +1 -1
@@ -54,14 +54,23 @@ MANIFEST_COMPARE_EXCLUDE = frozenset(
54
54
  }
55
55
  )
56
56
 
57
- SCHEMA_VERSION = "2.2"
57
+ SCHEMA_VERSION = "2.3"
58
58
  NOT_AVAILABLE = "not_available"
59
59
 
60
+ # Compiled once for evidence-text count fallbacks in _ledger_unit_tests / _ledger_api_tests.
61
+ _RE_UNIT_COUNTS = re.compile(
62
+ r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)"
63
+ )
64
+ _RE_API_PASSED = re.compile(r"(\d+)/(\d+)\s*passed", re.I)
65
+
60
66
  # Ensure sibling harness_events is importable.
61
67
  if str(SCRIPTS_DIR) not in sys.path:
62
68
  sys.path.insert(0, str(SCRIPTS_DIR))
63
69
 
64
70
  import harness_events as he # noqa: E402
71
+ import harness_ledger as hl # noqa: E402
72
+ import harness_paths as hp # noqa: E402
73
+ import harness_review as hr # noqa: E402
65
74
 
66
75
 
67
76
  # ---------------------------------------------------------------------------
@@ -429,7 +438,11 @@ def worktree_requested(change_dir: Path) -> bool:
429
438
  # ---------------------------------------------------------------------------
430
439
 
431
440
 
432
- def check_status(change_dir: Path) -> dict[str, Any]:
441
+ def check_status(
442
+ change_dir: Path,
443
+ *,
444
+ allow_missing_review: bool = False,
445
+ ) -> dict[str, Any]:
433
446
  """Read-only archive preconditions. Never mutates."""
434
447
  blockers: list[dict[str, str]] = []
435
448
  warnings: list[dict[str, str]] = []
@@ -574,6 +587,10 @@ def check_status(change_dir: Path) -> dict[str, Any]:
574
587
  }
575
588
  )
576
589
 
590
+ gate_policy = load_gate_policy(change_dir)
591
+ risk_tier = str((gate_policy or {}).get("tier") or "unknown")
592
+ checks["riskTier"] = risk_tier
593
+
577
594
  if review_reports:
578
595
  checks["review_report_status"] = "present"
579
596
  elif review_phase_completed(events):
@@ -589,12 +606,23 @@ def check_status(change_dir: Path) -> dict[str, Any]:
589
606
  )
590
607
  else:
591
608
  checks["review_report_status"] = "not-run"
592
- warnings.append(
593
- {
594
- "code": "review-not-run",
595
- "message": "no review report; mark reviewSummary as ADVISORY_NOT_RUN",
609
+ review_msg = {
610
+ "code": "review-not-run",
611
+ "message": "no review report; mark reviewSummary as ADVISORY_NOT_RUN",
612
+ }
613
+ warnings.append(review_msg)
614
+ if risk_tier == "full" and not review_ran:
615
+ tier_issue = {
616
+ "code": "review-required-on-full-tier",
617
+ "message": (
618
+ "gate-policy tier=full requires review evidence; "
619
+ "pass --allow-missing-review to override"
620
+ ),
596
621
  }
597
- )
622
+ if allow_missing_review:
623
+ warnings.append(tier_issue)
624
+ else:
625
+ blockers.append(tier_issue)
598
626
 
599
627
  archivable = len(blockers) == 0
600
628
  return {
@@ -637,33 +665,87 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
637
665
  if not ledger:
638
666
  return empty
639
667
  validations = ledger.get("validations") or ledger.get("verification") or {}
640
- unit = validations.get("unitTest") or validations.get("unitTests") or {}
668
+ unit = (
669
+ validations.get("unitTestFull")
670
+ or validations.get("unitTest")
671
+ or validations.get("unitTests")
672
+ or {}
673
+ )
641
674
  if not isinstance(unit, dict):
642
675
  return empty
643
676
  status = str(unit.get("status") or "").upper()
644
- evidence = unit.get("evidence") or {}
645
- if isinstance(evidence, dict):
646
- run = evidence.get(
647
- "run",
648
- evidence.get("testsRun", unit.get("run", unit.get("testsRun", 0))),
677
+
678
+ run = failures = errors = skipped = 0
679
+ pass_rate: Any = None
680
+ source = "committed"
681
+ counted = False
682
+
683
+ metrics = unit.get("metrics")
684
+ if isinstance(metrics, dict) and any(
685
+ k in metrics for k in ("run", "testsRun", "total", "failures", "errors", "skipped")
686
+ ):
687
+ if "total" in metrics:
688
+ # ledger v3 typed metrics (UT-005/RET-15): total/passed/failed.
689
+ run = int(metrics.get("total", 0) or 0)
690
+ failures = int(metrics.get("failed", 0) or 0)
691
+ errors = int(metrics.get("errors", 0) or 0)
692
+ skipped = int(metrics.get("skipped", 0) or 0)
693
+ else:
694
+ run = int(metrics.get("run", metrics.get("testsRun", 0)) or 0)
695
+ failures = int(metrics.get("failures", 0) or 0)
696
+ errors = int(metrics.get("errors", 0) or 0)
697
+ skipped = int(metrics.get("skipped", 0) or 0)
698
+ pass_rate = metrics.get("passRate")
699
+ source = "committed"
700
+ counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
701
+
702
+ evidence = unit.get("evidence")
703
+ if not counted and isinstance(evidence, dict):
704
+ run = int(
705
+ evidence.get(
706
+ "run",
707
+ evidence.get("testsRun", unit.get("run", unit.get("testsRun", 0))),
708
+ )
709
+ or 0
649
710
  )
650
- failures = evidence.get("failures", unit.get("failures", 0))
651
- errors = evidence.get("errors", unit.get("errors", 0))
652
- skipped = evidence.get("skipped", unit.get("skipped", 0))
711
+ failures = int(evidence.get("failures", unit.get("failures", 0)) or 0)
712
+ errors = int(evidence.get("errors", unit.get("errors", 0)) or 0)
713
+ skipped = int(evidence.get("skipped", unit.get("skipped", 0)) or 0)
653
714
  pass_rate = evidence.get("passRate") or unit.get("passRate")
654
- else:
655
- run = unit.get("run", unit.get("testsRun", 0))
656
- failures = unit.get("failures", 0)
657
- errors = unit.get("errors", 0)
658
- skipped = unit.get("skipped", 0)
715
+ source = "committed"
716
+ counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
717
+
718
+ evidence_text = evidence if isinstance(evidence, str) else ""
719
+ if not counted and evidence_text:
720
+ matches = list(_RE_UNIT_COUNTS.finditer(evidence_text))
721
+ if matches:
722
+ m = matches[-1]
723
+ run = int(m.group(1))
724
+ failures = int(m.group(2))
725
+ errors = int(m.group(3))
726
+ skipped = int(m.group(4))
727
+ source = "evidence-text"
728
+ counted = True
729
+
730
+ if not counted:
731
+ run = int(unit.get("run", unit.get("testsRun", 0)) or 0)
732
+ failures = int(unit.get("failures", 0) or 0)
733
+ errors = int(unit.get("errors", 0) or 0)
734
+ skipped = int(unit.get("skipped", 0) or 0)
659
735
  pass_rate = unit.get("passRate")
736
+ source = "committed"
660
737
 
661
- source = "committed"
662
738
  if status in {"NOT_RUN", "SKIPPED", "USER_SKIPPED"}:
663
739
  source = "not-run"
664
740
  elif str(unit.get("reused") or "").lower() in {"true", "1"} or "REUSED" in status:
665
741
  source = "committed"
666
742
 
743
+ if pass_rate is None and run > 0:
744
+ passed = max(run - failures - errors, 0)
745
+ pass_rate = f"{passed / run:.0%}"
746
+ elif pass_rate is None:
747
+ pass_rate = NOT_AVAILABLE
748
+
667
749
  result = {
668
750
  "run": int(run or 0),
669
751
  "failures": int(failures or 0),
@@ -677,7 +759,11 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
677
759
  return result
678
760
 
679
761
 
680
- def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
762
+ def _ledger_api_tests(
763
+ ledger: dict[str, Any] | None,
764
+ *,
765
+ change_dir: Path | None = None,
766
+ ) -> dict[str, Any]:
681
767
  empty = {
682
768
  "status": "NOT_RUN",
683
769
  "total": 0,
@@ -685,6 +771,7 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
685
771
  "failed": 0,
686
772
  "blocked": 0,
687
773
  "passRate": NOT_AVAILABLE,
774
+ "source": "not-run",
688
775
  }
689
776
  if not ledger:
690
777
  return empty
@@ -697,12 +784,76 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
697
784
  status = "OK"
698
785
  elif status in {"SKIP", "SKIPPED"}:
699
786
  status = "USER_SKIPPED"
700
- evidence = api.get("evidence") if isinstance(api.get("evidence"), dict) else {}
701
- total = evidence.get("total", api.get("total", 0))
702
- passed = evidence.get("passed", api.get("passed", 0))
703
- failed = evidence.get("failed", api.get("failed", 0))
704
- blocked = evidence.get("blocked", api.get("blocked", 0))
705
- pass_rate = evidence.get("passRate") or api.get("passRate")
787
+
788
+ total = passed = failed = blocked = 0
789
+ pass_rate: Any = None
790
+ source = "committed"
791
+ counted = False
792
+
793
+ metrics = api.get("metrics")
794
+ if isinstance(metrics, dict) and any(
795
+ k in metrics for k in ("total", "passed", "failed", "blocked")
796
+ ):
797
+ total = int(metrics.get("total", 0) or 0)
798
+ passed = int(metrics.get("passed", 0) or 0)
799
+ failed = int(metrics.get("failed", 0) or 0)
800
+ blocked = int(metrics.get("blocked", 0) or 0)
801
+ pass_rate = metrics.get("passRate")
802
+ source = "committed"
803
+ counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
804
+
805
+ evidence_raw = api.get("evidence")
806
+ if not counted and isinstance(evidence_raw, dict):
807
+ total = int(evidence_raw.get("total", api.get("total", 0)) or 0)
808
+ passed = int(evidence_raw.get("passed", api.get("passed", 0)) or 0)
809
+ failed = int(evidence_raw.get("failed", api.get("failed", 0)) or 0)
810
+ blocked = int(evidence_raw.get("blocked", api.get("blocked", 0)) or 0)
811
+ pass_rate = evidence_raw.get("passRate") or api.get("passRate")
812
+ source = "committed"
813
+ counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
814
+
815
+ evidence_text = evidence_raw if isinstance(evidence_raw, str) else ""
816
+ if not counted and evidence_text:
817
+ matches = list(_RE_API_PASSED.finditer(evidence_text))
818
+ if matches:
819
+ m = matches[-1]
820
+ passed = int(m.group(1))
821
+ total = int(m.group(2))
822
+ failed = max(total - passed, 0)
823
+ blocked = 0
824
+ source = "evidence-text"
825
+ counted = True
826
+
827
+ if not counted and change_dir is not None:
828
+ results_path = change_dir / "runtime" / "api-test-results.json"
829
+ if results_path.is_file():
830
+ try:
831
+ raw = read_json(results_path)
832
+ except (OSError, json.JSONDecodeError):
833
+ raw = None
834
+ if isinstance(raw, dict) and all(
835
+ isinstance(raw.get(k), int) for k in ("total", "passed", "failed", "blocked")
836
+ ):
837
+ total = int(raw["total"])
838
+ passed = int(raw["passed"])
839
+ failed = int(raw["failed"])
840
+ blocked = int(raw["blocked"])
841
+ source = "api-test-results"
842
+ counted = True
843
+
844
+ if not counted:
845
+ total = int(api.get("total", 0) or 0)
846
+ passed = int(api.get("passed", 0) or 0)
847
+ failed = int(api.get("failed", 0) or 0)
848
+ blocked = int(api.get("blocked", 0) or 0)
849
+ pass_rate = api.get("passRate")
850
+ source = "committed"
851
+
852
+ if pass_rate is None and total > 0:
853
+ pass_rate = f"{passed / total:.0%}"
854
+ elif pass_rate is None:
855
+ pass_rate = NOT_AVAILABLE
856
+
706
857
  return {
707
858
  "status": status,
708
859
  "total": int(total or 0),
@@ -710,9 +861,57 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
710
861
  "failed": int(failed or 0),
711
862
  "blocked": int(blocked or 0),
712
863
  "passRate": pass_rate if pass_rate is not None else NOT_AVAILABLE,
864
+ "source": source,
713
865
  }
714
866
 
715
867
 
868
+ def _risks_from_test_results(change_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
869
+ """PARTIAL/OPEN/UNKNOWN/DEFERRED scenarios -> (knownRisks, manualActions).
870
+
871
+ Scenario IDs are preserved so risks stay traceable to the test report
872
+ (UT-013/RET-25); PARTIAL is a fact, not a pass.
873
+ """
874
+ risk_statuses = {"PARTIAL", "OPEN", "UNKNOWN", "DEFERRED", "BLOCKED"}
875
+ results_path = change_dir / "runtime" / "api-test-results.json"
876
+ if not results_path.is_file():
877
+ return [], []
878
+ try:
879
+ raw = read_json(results_path)
880
+ except (OSError, json.JSONDecodeError):
881
+ return [], []
882
+ scenarios = raw.get("scenarios") if isinstance(raw, dict) else None
883
+ if not isinstance(scenarios, list):
884
+ return [], []
885
+ risks: list[dict[str, Any]] = []
886
+ actions: list[dict[str, Any]] = []
887
+ for item in scenarios:
888
+ if not isinstance(item, dict):
889
+ continue
890
+ status = str(item.get("status") or "").upper()
891
+ if status not in risk_statuses:
892
+ continue
893
+ scenario_id = str(item.get("id") or item.get("scenario") or "").strip()
894
+ note = str(item.get("note") or item.get("message") or "").strip()
895
+ risks.append(
896
+ {
897
+ "phase": "test",
898
+ "severity": "medium",
899
+ "scenarioId": scenario_id,
900
+ "status": status,
901
+ "message": f"{scenario_id}: {status}" + (f" — {note}" if note else ""),
902
+ }
903
+ )
904
+ actions.append(
905
+ {
906
+ "stage": "test",
907
+ "status": status,
908
+ "scenarioId": scenario_id,
909
+ "action": "补齐该场景的完整验证或显式接受风险",
910
+ }
911
+ )
912
+ return risks, actions
913
+
914
+
716
915
  def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
717
916
  if not ledger:
718
917
  return "NOT_RUN"
@@ -728,6 +927,58 @@ def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
728
927
  return "NOT_RUN"
729
928
 
730
929
 
930
+ def _typed_test_metrics(entry: dict[str, Any], *, total_key: str) -> dict[str, Any]:
931
+ """Project a ledger v3 typed metrics entry to the canonical view."""
932
+ metrics = entry.get("metrics") if isinstance(entry.get("metrics"), dict) else {}
933
+ total = int(metrics.get(total_key, 0) or 0)
934
+ passed = int(metrics.get("passed", 0) or 0)
935
+ failed = int(metrics.get("failed", 0) or 0)
936
+ status = str(entry.get("status") or "").upper() or "NOT_RUN"
937
+ out: dict[str, Any] = {
938
+ "status": status,
939
+ "total": total,
940
+ "passed": passed,
941
+ "failed": failed,
942
+ "passRate": f"{passed / total:.0%}" if total > 0 else NOT_AVAILABLE,
943
+ "source": "committed" if total > 0 else "not-run",
944
+ }
945
+ if "blocked" in metrics:
946
+ out["blocked"] = int(metrics.get("blocked", 0) or 0)
947
+ if "skipped" in metrics:
948
+ out["skipped"] = int(metrics.get("skipped", 0) or 0)
949
+ applicability = entry.get("applicability")
950
+ if isinstance(applicability, dict):
951
+ out["applicability"] = applicability
952
+ return out
953
+
954
+
955
+ def build_verification_projection(
956
+ ledger: dict[str, Any] | None,
957
+ *,
958
+ change_dir: Path | None = None,
959
+ ) -> dict[str, Any]:
960
+ """Canonical verification view shared by collector and validators (RET-16).
961
+
962
+ apiContract and browserE2E are distinct typed projections; the legacy
963
+ apiTests mapping is retained for schema compatibility.
964
+ """
965
+ validations = (ledger or {}).get("validations") or {}
966
+ projection: dict[str, Any] = {
967
+ "unitTests": _ledger_unit_tests(ledger),
968
+ "apiTests": _ledger_api_tests(ledger, change_dir=change_dir),
969
+ "dbCompatibility": _ledger_db_compat(ledger),
970
+ }
971
+ api_contract = validations.get("apiContract")
972
+ if isinstance(api_contract, dict):
973
+ projection["apiContract"] = _typed_test_metrics(
974
+ api_contract, total_key="scenariosTotal"
975
+ )
976
+ browser_e2e = validations.get("browserE2E")
977
+ if isinstance(browser_e2e, dict):
978
+ projection["browserE2E"] = _typed_test_metrics(browser_e2e, total_key="total")
979
+ return projection
980
+
981
+
731
982
  def _parse_durations_from_log(log_text: str) -> dict[str, Any]:
732
983
  """Best-effort parse of harness-* sections from execution-log."""
733
984
  stages: list[dict[str, Any]] = []
@@ -902,7 +1153,15 @@ def _artifacts_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]
902
1153
  return out
903
1154
 
904
1155
 
905
- def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any]:
1156
+ def _durations_from_event_phases(
1157
+ event_summary: dict[str, Any],
1158
+ events: list[dict[str, Any]] | None = None,
1159
+ ) -> dict[str, Any]:
1160
+ # Canonical per-phase timing (UT-008/RET-20): one reducer feeds every view.
1161
+ canonical: dict[str, dict[str, Any]] = {}
1162
+ if events:
1163
+ for phase_name, phase_events in he.group_events_by_phase(events):
1164
+ canonical[phase_name] = he.canonical_phase_timing(phase_events)
906
1165
  stages: list[dict[str, Any]] = []
907
1166
  total_ms = 0
908
1167
  for name, info in (event_summary.get("phases") or {}).items():
@@ -916,7 +1175,7 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
916
1175
  result = "OK"
917
1176
  elif result in {"FAILED", "ERROR"}:
918
1177
  result = "FAIL"
919
- stages.append({
1178
+ stage: dict[str, Any] = {
920
1179
  "stage": str(name),
921
1180
  "skill": f"harness-{name}",
922
1181
  "startedAt": info.get("started_at") or NOT_AVAILABLE,
@@ -924,7 +1183,13 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
924
1183
  "minutes": minutes,
925
1184
  "result": result,
926
1185
  "attempts": attempts,
927
- })
1186
+ }
1187
+ timing = canonical.get(name)
1188
+ if timing:
1189
+ stage["activeExecutionMs"] = timing.get("activeExecutionMs")
1190
+ stage["wallClockSpanMs"] = timing.get("wallClockSpanMs")
1191
+ stage["lateEventCount"] = timing.get("lateEventCount")
1192
+ stages.append(stage)
928
1193
  total_min = round(total_ms / 60000, 2)
929
1194
  return {
930
1195
  "totalLabel": f"约 {int(round(total_min))} 分",
@@ -977,7 +1242,7 @@ def _stage_status_from_sources(
977
1242
  elif phase in status and sev in {"warn", "warning"} and status[phase] == "OK":
978
1243
  status[phase] = "WARN"
979
1244
 
980
- api = _ledger_api_tests(ledger)
1245
+ api = _ledger_api_tests(ledger, change_dir=change_dir)
981
1246
  db = _ledger_db_compat(ledger)
982
1247
  api_status = str(api.get("status") or "").upper()
983
1248
  if api_status in {"FAIL", "FAILED", "ERROR"} or int(api.get("failed") or 0) > 0:
@@ -1002,28 +1267,178 @@ def _stage_status_from_sources(
1002
1267
  def _compute_final_status(
1003
1268
  stage_status: dict[str, str],
1004
1269
  verification: dict[str, Any],
1005
- ) -> str:
1270
+ ) -> tuple[str, list[str]]:
1006
1271
  api = verification.get("apiTests") or {}
1007
1272
  db = str(verification.get("dbCompatibility") or "")
1008
1273
  api_status = str(api.get("status") or "")
1009
- for v in stage_status.values():
1274
+ reasons: list[str] = []
1275
+ for phase, v in stage_status.items():
1010
1276
  if v == "FAIL":
1011
- return "FAIL"
1277
+ return "FAIL", [f"stage {phase}=FAIL"]
1012
1278
  unit = verification.get("unitTests") or {}
1013
- if int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0:
1014
- return "FAIL"
1279
+ if int(unit.get("failures") or 0) > 0:
1280
+ return "FAIL", [f"unitTests.failures={unit.get('failures')}"]
1281
+ if int(unit.get("errors") or 0) > 0:
1282
+ return "FAIL", [f"unitTests.errors={unit.get('errors')}"]
1015
1283
  if int(api.get("failed") or 0) > 0:
1016
- return "FAIL"
1284
+ return "FAIL", [f"apiTests.failed={api.get('failed')}"]
1017
1285
  conditional = {
1018
1286
  "USER_SKIPPED", "BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA",
1019
1287
  "NOT_RUN", "PARTIAL",
1020
1288
  }
1021
- if api_status in conditional or db in conditional:
1022
- return "CONDITIONAL_OK"
1023
- for v in stage_status.values():
1024
- if v in {"WARN", *conditional}:
1025
- return "WARN" if v == "WARN" else "CONDITIONAL_OK"
1026
- return "OK"
1289
+ if api_status in conditional:
1290
+ reasons.append(f"apiTests.status={api_status}")
1291
+ if db in conditional:
1292
+ reasons.append(f"dbCompatibility={db}")
1293
+ if reasons:
1294
+ return "CONDITIONAL_OK", reasons
1295
+ for phase, v in stage_status.items():
1296
+ if v == "WARN":
1297
+ return "WARN", [f"stage {phase}=WARN"]
1298
+ if v in conditional:
1299
+ return "CONDITIONAL_OK", [f"stage {phase}={v}"]
1300
+ return "OK", []
1301
+
1302
+
1303
+ def load_gate_policy(change_dir: Path) -> dict[str, Any] | None:
1304
+ path = change_dir / "meta" / "gate-policy.json"
1305
+ if not path.is_file():
1306
+ return None
1307
+ try:
1308
+ data = read_json(path)
1309
+ except (OSError, json.JSONDecodeError):
1310
+ return None
1311
+ if not isinstance(data, dict) or data.get("schemaVersion") != 1:
1312
+ return None
1313
+ return data
1314
+
1315
+
1316
+ _RISK_SEVERITIES = frozenset({"warning", "error", "critical"})
1317
+
1318
+
1319
+ def _cleanup_transients(work_dir: Path) -> dict[str, Any]:
1320
+ """Delete lock/pid/launcher/credential files; truncate oversized logs."""
1321
+ deleted: list[str] = []
1322
+ truncated: list[dict[str, Any]] = []
1323
+
1324
+ def _rel(path: Path) -> str:
1325
+ try:
1326
+ return path.relative_to(work_dir).as_posix()
1327
+ except ValueError:
1328
+ return str(path)
1329
+
1330
+ lock = work_dir / "events.ndjson.lock"
1331
+ if lock.is_file():
1332
+ try:
1333
+ lock.unlink()
1334
+ deleted.append(_rel(lock))
1335
+ except OSError:
1336
+ pass
1337
+
1338
+ runtime = work_dir / "runtime"
1339
+ if runtime.is_dir():
1340
+ for path in sorted(runtime.iterdir()):
1341
+ if not path.is_file():
1342
+ continue
1343
+ name = path.name
1344
+ drop = False
1345
+ if name.endswith(".pid"):
1346
+ drop = True
1347
+ elif name in {
1348
+ "_harness_service_launcher.py",
1349
+ "_harness_service.command.txt",
1350
+ }:
1351
+ drop = True
1352
+ elif re.search(r"credential|token|secret", name, re.I):
1353
+ drop = True
1354
+ if drop:
1355
+ try:
1356
+ path.unlink()
1357
+ deleted.append(_rel(path))
1358
+ except OSError:
1359
+ pass
1360
+
1361
+ logs_root = work_dir / "logs"
1362
+ if logs_root.is_dir():
1363
+ for path in sorted(logs_root.rglob("*.log")):
1364
+ if not path.is_file():
1365
+ continue
1366
+ try:
1367
+ size = path.stat().st_size
1368
+ except OSError:
1369
+ continue
1370
+ if size <= 65536:
1371
+ continue
1372
+ try:
1373
+ with path.open("rb") as handle:
1374
+ handle.seek(-65536, 2)
1375
+ tail = handle.read()
1376
+ header = (
1377
+ f"# [truncated by harness-archive finalize: original {size} bytes]\n"
1378
+ ).encode("utf-8")
1379
+ path.write_bytes(header + tail)
1380
+ truncated.append({"path": _rel(path), "originalBytes": size})
1381
+ except OSError:
1382
+ pass
1383
+
1384
+ return {"deleted": deleted, "truncated": truncated}
1385
+
1386
+
1387
+ def write_archive_meta(work_dir: Path, summary: dict[str, Any]) -> Path:
1388
+ """Generate meta/archive-meta.md from summary-data (single ownership)."""
1389
+ archive_id = work_dir.name
1390
+ change_name = str(summary.get("changeName") or work_dir.name)
1391
+ archived_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M")
1392
+ final_status = str(summary.get("finalStatus") or "UNKNOWN")
1393
+ lines = [
1394
+ "---",
1395
+ f"archive-id: {archive_id}",
1396
+ f"change-name: {change_name}",
1397
+ f"archived-at: {archived_at}",
1398
+ f"final-commit: {summary.get('finalCommit') or ''}",
1399
+ f"base-commit: {summary.get('baseCommit') or ''}",
1400
+ f"final-status: {final_status}",
1401
+ "source: harness-archive",
1402
+ "---",
1403
+ f"# 归档元数据 — {change_name}",
1404
+ "",
1405
+ "## 阶段状态",
1406
+ "",
1407
+ "| 阶段 | 状态 |",
1408
+ "|---|---|",
1409
+ ]
1410
+ for stage, status in (summary.get("stageStatus") or {}).items():
1411
+ lines.append(f"| {stage} | {status} |")
1412
+ lines.extend(["", "## 变更文件", "", "| 路径 | + | - |", "|---|---|---|"])
1413
+ changed = summary.get("changedFiles") or []
1414
+ if changed:
1415
+ for item in changed:
1416
+ lines.append(
1417
+ f"| {item.get('path') or ''} | "
1418
+ f"{item.get('insertions', 0)} | {item.get('deletions', 0)} |"
1419
+ )
1420
+ else:
1421
+ lines.append("| (无) | | |")
1422
+ lines.extend(["", "## 已知风险", ""])
1423
+ risks = summary.get("knownRisks") or []
1424
+ if risks:
1425
+ for risk in risks:
1426
+ if isinstance(risk, dict):
1427
+ lines.append(
1428
+ f"- [{risk.get('severity') or 'unknown'}] "
1429
+ f"{risk.get('message') or risk}"
1430
+ )
1431
+ else:
1432
+ lines.append(f"- {risk}")
1433
+ else:
1434
+ lines.append("无")
1435
+ lines.append("")
1436
+ out = work_dir / "meta" / "archive-meta.md"
1437
+ out.parent.mkdir(parents=True, exist_ok=True)
1438
+ out.write_text("\n".join(lines), encoding="utf-8", newline="\n")
1439
+ return out
1440
+
1441
+
1027
1442
 
1028
1443
 
1029
1444
  def _changed_files_from_git(
@@ -1091,6 +1506,37 @@ def _review_summary(
1091
1506
  "yellowDeferred": 0,
1092
1507
  "summary": "",
1093
1508
  }
1509
+ sidecar = hr.findings_path(change_dir)
1510
+ if sidecar.is_file():
1511
+ status = hr.status(change_dir)
1512
+ items = status.get("items") or []
1513
+ red_items = [item for item in items if item.get("severity") == "RED"]
1514
+ yellow_items = [item for item in items if item.get("severity") == "YELLOW"]
1515
+ base.update(
1516
+ {
1517
+ "status": "ADVISORY",
1518
+ "red": len(red_items),
1519
+ "yellow": len(yellow_items),
1520
+ "redFixed": sum(
1521
+ 1 for item in red_items if item.get("disposition") == "FIXED"
1522
+ ),
1523
+ "redConfirmed": sum(
1524
+ 1
1525
+ for item in red_items
1526
+ if item.get("disposition") in {"OPEN", "ACCEPTED_RISK", "DEFERRED"}
1527
+ ),
1528
+ "yellowFixed": sum(
1529
+ 1 for item in yellow_items if item.get("disposition") == "FIXED"
1530
+ ),
1531
+ "yellowDeferred": sum(
1532
+ 1
1533
+ for item in yellow_items
1534
+ if item.get("disposition") in {"DEFERRED", "ACCEPTED_RISK"}
1535
+ ),
1536
+ "summary": f"structured review run {status.get('runId') or 'unknown'}",
1537
+ }
1538
+ )
1539
+ return base
1094
1540
  if existing and isinstance(existing.get("reviewSummary"), dict):
1095
1541
  merged = dict(base)
1096
1542
  merged.update(existing["reviewSummary"])
@@ -1148,6 +1594,24 @@ def _business_goal_from_sources(change_dir: Path, events: list[dict[str, Any]])
1148
1594
  scope = re.search(r"(?im)^\s*>?\s*(?:变更范围|目标)\s*[::]\s*(.+)$", body)
1149
1595
  if scope:
1150
1596
  return scope.group(1).strip()
1597
+ # UT-015/RET-27: structured "## 目标" section body wins over the first
1598
+ # task-table row — the task row is an activity, not the objective.
1599
+ section = re.search(
1600
+ r"(?im)^#{1,4}\s*(?:\d+[\.、]\s*)?(?:目标|业务目标|需求背景)\s*$",
1601
+ body,
1602
+ )
1603
+ if section:
1604
+ lines: list[str] = []
1605
+ for line in body[section.end():].splitlines():
1606
+ if re.match(r"^\s*#{1,4}\s", line):
1607
+ break
1608
+ clean = line.strip()
1609
+ if clean and not clean.startswith(("|", ">", "---")):
1610
+ lines.append(clean)
1611
+ if lines:
1612
+ break
1613
+ if lines:
1614
+ return lines[0]
1151
1615
  first_task = re.search(r"(?m)^\s*\|\s*1\s*\|\s*([^|]+?)\s*\|", body)
1152
1616
  if first_task:
1153
1617
  return first_task.group(1).strip()
@@ -1297,9 +1761,10 @@ def collect_summary_data(
1297
1761
 
1298
1762
  # verification
1299
1763
  if not for_replay or not isinstance(data.get("verification"), dict):
1300
- unit = _ledger_unit_tests(ledger)
1301
- api = _ledger_api_tests(ledger)
1302
- db = _ledger_db_compat(ledger)
1764
+ projection = build_verification_projection(ledger, change_dir=change_dir)
1765
+ unit = projection["unitTests"]
1766
+ api = projection["apiTests"]
1767
+ db = projection["dbCompatibility"]
1303
1768
  if not find_test_reports(change_dir) and unit.get("run", 0) == 0:
1304
1769
  if "status" not in unit:
1305
1770
  unit["status"] = "NOT_RUN"
@@ -1314,6 +1779,9 @@ def collect_summary_data(
1314
1779
  "dbCompatibility": db,
1315
1780
  "coverageDisplay": coverage,
1316
1781
  }
1782
+ for typed_key in ("apiContract", "browserE2E"):
1783
+ if typed_key in projection:
1784
+ data["verification"][typed_key] = projection[typed_key]
1317
1785
  else:
1318
1786
  # Ensure nested keys exist for old schema 2.1
1319
1787
  ver = data.setdefault("verification", {})
@@ -1326,14 +1794,21 @@ def collect_summary_data(
1326
1794
  if not for_replay or not isinstance(data.get("stageStatus"), dict):
1327
1795
  data["stageStatus"] = _stage_status_from_sources(events, ledger, change_dir)
1328
1796
  if not for_replay or not data.get("finalStatus") or str(data.get("finalStatus")).startswith("OK |"):
1329
- data["finalStatus"] = _compute_final_status(
1797
+ final_status, final_reasons = _compute_final_status(
1330
1798
  data.get("stageStatus") or {},
1331
1799
  data.get("verification") or {},
1332
1800
  )
1801
+ data["finalStatus"] = final_status
1802
+ data["finalStatusReasons"] = list(final_reasons)
1803
+ else:
1804
+ data.setdefault("finalStatusReasons", [])
1805
+
1806
+ gate_policy = load_gate_policy(change_dir)
1807
+ data["riskTier"] = str((gate_policy or {}).get("tier") or "unknown")
1333
1808
 
1334
1809
  # durations / skillCalls
1335
1810
  if events:
1336
- data["durations"] = _durations_from_event_phases(event_summary)
1811
+ data["durations"] = _durations_from_event_phases(event_summary, events)
1337
1812
  data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
1338
1813
  elif log_text and (not for_replay or not data.get("durations")):
1339
1814
  data["durations"] = _parse_durations_from_log(log_text)
@@ -1352,6 +1827,32 @@ def collect_summary_data(
1352
1827
  head = data.get("finalCommit")
1353
1828
  if base and head and base != NOT_AVAILABLE and head != NOT_AVAILABLE:
1354
1829
  diff_stat, changed = _changed_files_from_git(project, str(base), str(head))
1830
+ ownership_projection: dict[str, Any] | None = None
1831
+ try:
1832
+ hp.load_change_contract(change_dir)
1833
+ ownership_projection = hl.compute_ownership_diff(
1834
+ project,
1835
+ base=str(base),
1836
+ head=str(head),
1837
+ change_dir=change_dir,
1838
+ )
1839
+ except (OSError, ValueError, RuntimeError):
1840
+ ownership_projection = None
1841
+ if ownership_projection is not None:
1842
+ allowed = set(ownership_projection.get("files") or [])
1843
+ changed = [item for item in changed if item.get("path") in allowed]
1844
+ diff_stat = {
1845
+ **diff_stat,
1846
+ "filesChanged": len(changed),
1847
+ "insertions": sum(int(item.get("insertions") or 0) for item in changed),
1848
+ "deletions": sum(int(item.get("deletions") or 0) for item in changed),
1849
+ }
1850
+ data["ownershipDiff"] = ownership_projection
1851
+ if write:
1852
+ write_json(
1853
+ change_dir / "evidence" / "ownership-diff.json",
1854
+ ownership_projection,
1855
+ )
1355
1856
  if changed:
1356
1857
  data["diffStat"] = diff_stat
1357
1858
  data["changedFiles"] = changed
@@ -1378,6 +1879,17 @@ def collect_summary_data(
1378
1879
  }
1379
1880
  data["changedFiles"] = []
1380
1881
 
1882
+ data.setdefault(
1883
+ "ownershipDiff",
1884
+ {
1885
+ "files": [item.get("path") for item in data.get("changedFiles") or []],
1886
+ "staticEvidenceFiles": [],
1887
+ "foreignPaths": [],
1888
+ "excludedRuntimeCount": 0,
1889
+ "ownedFileCount": len(data.get("changedFiles") or []),
1890
+ },
1891
+ )
1892
+
1381
1893
  # artifacts (build products stay empty unless already known; reportPipeline has event artifacts)
1382
1894
  if not isinstance(data.get("artifacts"), list):
1383
1895
  data["artifacts"] = []
@@ -1397,25 +1909,35 @@ def collect_summary_data(
1397
1909
 
1398
1910
  # Derive risks/actions from evidence. These fields are facts, not model prose.
1399
1911
  if not for_replay:
1400
- data["maintenanceNotes"] = [
1912
+ maintenance_notes: list[str] = [
1401
1913
  str(event.get("note") or event.get("message") or "")
1402
1914
  for event in events
1403
1915
  if event.get("type") == "decision" and (event.get("note") or event.get("message"))
1404
1916
  ]
1405
- data["knownRisks"] = [
1406
- {
1407
- "phase": event.get("phase"),
1408
- "severity": event.get("severity") or "unknown",
1409
- "message": event.get("message") or event.get("note") or event.get("code") or "",
1410
- }
1411
- for event in events
1412
- if event.get("type") == "issue"
1413
- and not (
1414
- event.get("phase") == "archive"
1415
- and event.get("code") == "missing-command"
1416
- )
1417
- ]
1418
- data["manualActions"] = []
1917
+ known_risks: list[dict[str, Any]] = []
1918
+ for event in events:
1919
+ if event.get("type") != "issue":
1920
+ continue
1921
+ if event.get("phase") == "archive" and event.get("code") == "missing-command":
1922
+ continue
1923
+ sev = str(event.get("severity") or "").strip().lower()
1924
+ message = event.get("message") or event.get("note") or event.get("code") or ""
1925
+ if sev in _RISK_SEVERITIES:
1926
+ known_risks.append(
1927
+ {
1928
+ "phase": event.get("phase"),
1929
+ "severity": sev,
1930
+ "message": message,
1931
+ }
1932
+ )
1933
+ else:
1934
+ note = str(message).strip()
1935
+ if note:
1936
+ maintenance_notes.append(note)
1937
+ data["maintenanceNotes"] = maintenance_notes
1938
+ scenario_risks, scenario_actions = _risks_from_test_results(change_dir)
1939
+ data["knownRisks"] = known_risks + scenario_risks
1940
+ data["manualActions"] = scenario_actions
1419
1941
  for name, value in (data.get("stageStatus") or {}).items():
1420
1942
  if value in {"BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA", "NOT_RUN", "USER_SKIPPED"}:
1421
1943
  data["manualActions"].append({
@@ -1427,6 +1949,8 @@ def collect_summary_data(
1427
1949
  data.setdefault("maintenanceNotes", [])
1428
1950
  data.setdefault("knownRisks", [])
1429
1951
  data.setdefault("manualActions", [])
1952
+ data.setdefault("finalStatusReasons", [])
1953
+ data.setdefault("riskTier", "unknown")
1430
1954
 
1431
1955
  # archiveManifest
1432
1956
  am = {
@@ -1521,7 +2045,13 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
1521
2045
  f'<h2 id="changeName">{esc(summary.get("changeName"))}</h2>',
1522
2046
  f'<p><strong>finalStatus</strong>: '
1523
2047
  f'<span id="finalStatus">{esc(summary.get("finalStatus"))}</span></p>',
2048
+ f'<div id="finalStatusReasons"><strong>finalStatusReasons</strong><ul>',
1524
2049
  ]
2050
+ for reason in summary.get("finalStatusReasons") or []:
2051
+ parts.append(f"<li>{esc(reason)}</li>")
2052
+ parts.append("</ul></div>")
2053
+ if summary.get("riskTier"):
2054
+ parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
1525
2055
 
1526
2056
  pipeline = summary.get("reportPipeline") or {}
1527
2057
  cmds = pipeline.get("commands") or []
@@ -1681,6 +2211,288 @@ def render_final_summary(
1681
2211
  # ---------------------------------------------------------------------------
1682
2212
 
1683
2213
 
2214
+ def _manifest_self_stats(
2215
+ work_dir: Path,
2216
+ manifest: dict[str, Any],
2217
+ manifest_path: Path,
2218
+ ) -> dict[str, Any]:
2219
+ """UT-011/RET-23: physical files vs manifest entries (self-exclusion)."""
2220
+ physical = sum(1 for path in work_dir.rglob("*") if path.is_file())
2221
+ entries = int(manifest.get("fileCount") or len(manifest.get("files") or []))
2222
+ rel_manifest = manifest_path.resolve().relative_to(work_dir.resolve()).as_posix()
2223
+ in_entries = any(
2224
+ str(item.get("path") or "").replace("\\", "/") == rel_manifest
2225
+ for item in manifest.get("files") or []
2226
+ if isinstance(item, dict)
2227
+ )
2228
+ coverage = round(entries / physical * 100, 2) if physical else 100.0
2229
+ return {
2230
+ "physicalFileCount": physical,
2231
+ "entryCount": entries,
2232
+ "selfExcluded": not in_entries,
2233
+ "coveragePercent": coverage,
2234
+ }
2235
+
2236
+
2237
+ def _append_finalize_failure_issue(
2238
+ original_change_dir: Path,
2239
+ code: str,
2240
+ message: str,
2241
+ ) -> None:
2242
+ """Record a finalize failure on the restored original as an issue event.
2243
+
2244
+ Never a second phase.end: the frozen cutoff already closed the archive
2245
+ phase exactly once (RET-19/RET-21).
2246
+ """
2247
+ if not original_change_dir.is_dir():
2248
+ return
2249
+ try:
2250
+ append_event(
2251
+ original_change_dir,
2252
+ phase="archive",
2253
+ type_="issue",
2254
+ code=code,
2255
+ severity="error",
2256
+ message=message,
2257
+ )
2258
+ except OSError:
2259
+ pass
2260
+
2261
+
2262
+ def _freeze_evidence_cutoff(work_dir: Path) -> dict[str, Any]:
2263
+ """Freeze the events cutoff: fsync events, write evidence-cutoff.json.
2264
+
2265
+ After this point no event may be appended to the archived events file;
2266
+ the cutoff hash lets any later reader prove that (INT-006/RET-19).
2267
+ """
2268
+ events_file = he.events_path(work_dir)
2269
+ events = he.load_events(events_file) if events_file.is_file() else []
2270
+ if events_file.is_file():
2271
+ # Windows fsync requires a writable handle; O_RDONLY raises EBADF.
2272
+ fd = os.open(str(events_file), os.O_RDWR | os.O_BINARY)
2273
+ try:
2274
+ os.fsync(fd)
2275
+ finally:
2276
+ os.close(fd)
2277
+ raw = events_file.read_bytes()
2278
+ else:
2279
+ raw = b""
2280
+ cutoff = {
2281
+ "eventCount": len(events),
2282
+ "sha256": "sha256:" + hashlib.sha256(raw).hexdigest(),
2283
+ "frozenAt": now_iso(),
2284
+ "path": "events.ndjson",
2285
+ }
2286
+ write_json(work_dir / "evidence" / "evidence-cutoff.json", cutoff)
2287
+ return cutoff
2288
+
2289
+
2290
+ def validate_artifact_immutability(entries: list[dict[str, Any]]) -> dict[str, Any]:
2291
+ """Same artifact path with two different hashes -> conflict (UT-003/RET-07)."""
2292
+ issues: list[dict[str, str]] = []
2293
+ seen: dict[str, str] = {}
2294
+ for item in entries:
2295
+ if not isinstance(item, dict):
2296
+ continue
2297
+ rel = str(item.get("path") or "").strip()
2298
+ digest = str(item.get("sha256") or "").strip()
2299
+ if not rel or not digest:
2300
+ continue
2301
+ prior = seen.get(rel)
2302
+ if prior is not None and prior != digest:
2303
+ issues.append(
2304
+ {
2305
+ "code": "artifact-hash-conflict",
2306
+ "severity": "error",
2307
+ "message": f"immutable artifact conflict at {rel}: {prior[:12]}… != {digest[:12]}…",
2308
+ }
2309
+ )
2310
+ else:
2311
+ seen[rel] = digest
2312
+ errors = [i for i in issues if i.get("severity") == "error"]
2313
+ return {
2314
+ "ok": len(errors) == 0,
2315
+ "issues": issues,
2316
+ "error_count": len(errors),
2317
+ "warning_count": len(issues) - len(errors),
2318
+ }
2319
+
2320
+
2321
+ def validate_source_consistency(
2322
+ change_dir: Path,
2323
+ summary: dict[str, Any],
2324
+ ) -> dict[str, Any]:
2325
+ """Layer 1: summary facts must equal the frozen sources (UT-014/RET-26).
2326
+
2327
+ Checks event count against the cutoff file, verification counts against
2328
+ ledger typed metrics, and review counts against sidecars when present.
2329
+ """
2330
+ issues: list[dict[str, str]] = []
2331
+
2332
+ # 1. event count/hash vs frozen cutoff (fallback: live events file).
2333
+ events_file = he.events_path(change_dir)
2334
+ actual_count = None
2335
+ cutoff_path = change_dir / "evidence" / "evidence-cutoff.json"
2336
+ cutoff: dict[str, Any] | None = None
2337
+ if cutoff_path.is_file():
2338
+ try:
2339
+ cutoff = read_json(cutoff_path)
2340
+ actual_count = cutoff.get("eventCount")
2341
+ except (OSError, json.JSONDecodeError):
2342
+ actual_count = None
2343
+ if actual_count is None and events_file.is_file():
2344
+ actual_count = len(he.load_events(events_file))
2345
+ summary_count = (summary.get("reportPipeline") or {}).get("event_count")
2346
+ if actual_count is not None and summary_count is not None:
2347
+ if int(summary_count) != int(actual_count):
2348
+ issues.append(
2349
+ {
2350
+ "code": "event-count-mismatch",
2351
+ "severity": "error",
2352
+ "message": (
2353
+ f"summary event_count={summary_count} but cutoff has "
2354
+ f"{actual_count} events"
2355
+ ),
2356
+ }
2357
+ )
2358
+
2359
+ if cutoff is not None and events_file.is_file():
2360
+ actual_hash = "sha256:" + hashlib.sha256(events_file.read_bytes()).hexdigest()
2361
+ if cutoff.get("sha256") != actual_hash:
2362
+ issues.append(
2363
+ {
2364
+ "code": "cutoff-hash-mismatch",
2365
+ "severity": "error",
2366
+ "message": "evidence cutoff hash does not match events.ndjson",
2367
+ }
2368
+ )
2369
+
2370
+ # 2. verification vs ledger typed metrics.
2371
+ ledger = load_ledger(change_dir)
2372
+ if ledger:
2373
+ projection = build_verification_projection(ledger, change_dir=change_dir)
2374
+ ver = summary.get("verification") or {}
2375
+ unit_src = projection.get("unitTests") or {}
2376
+ unit_sum = ver.get("unitTests") or {}
2377
+ if unit_src.get("run"):
2378
+ if int(unit_sum.get("run") or 0) != int(unit_src["run"]):
2379
+ issues.append(
2380
+ {
2381
+ "code": "verification-mismatch",
2382
+ "severity": "error",
2383
+ "message": (
2384
+ f"unitTests: summary run={unit_sum.get('run')} but "
2385
+ f"ledger projection run={unit_src['run']}"
2386
+ ),
2387
+ }
2388
+ )
2389
+ for typed_key in ("apiContract", "browserE2E"):
2390
+ src = projection.get(typed_key)
2391
+ if not isinstance(src, dict) or not src.get("total"):
2392
+ continue
2393
+ rendered = ver.get(typed_key) or {}
2394
+ if int(rendered.get("total") or 0) != int(src["total"]):
2395
+ issues.append(
2396
+ {
2397
+ "code": "verification-mismatch",
2398
+ "severity": "error",
2399
+ "message": (
2400
+ f"{typed_key}: summary total={rendered.get('total')} "
2401
+ f"but ledger projection total={src['total']}"
2402
+ ),
2403
+ }
2404
+ )
2405
+
2406
+ # 3. Rebuild canonical projections from frozen sources and compare the
2407
+ # fields that must never come from prose or an existing summary.
2408
+ expected = collect_summary_data(change_dir, write=False, for_replay=False)
2409
+ projection_fields = {
2410
+ "reviewSummary": "review-mismatch",
2411
+ "knownRisks": "risk-mismatch",
2412
+ "manualActions": "manual-actions-mismatch",
2413
+ "durations": "phase-timing-mismatch",
2414
+ "changedFiles": "ownership-diff-mismatch",
2415
+ "ownershipDiff": "ownership-diff-mismatch",
2416
+ "artifacts": "artifact-mismatch",
2417
+ }
2418
+ for field, code in projection_fields.items():
2419
+ if summary.get(field) != expected.get(field):
2420
+ issues.append(
2421
+ {
2422
+ "code": code,
2423
+ "severity": "error",
2424
+ "message": f"summary {field} does not match frozen source projection",
2425
+ }
2426
+ )
2427
+
2428
+ # 4. Manifest structure/checksums and summary semantics.
2429
+ before_path = change_dir / "evidence" / "archive-manifest-before.json"
2430
+ if before_path.is_file():
2431
+ try:
2432
+ manifest = read_json(before_path)
2433
+ entries = manifest.get("files") if isinstance(manifest, dict) else None
2434
+ valid = isinstance(entries, list) and manifest.get("fileCount") == len(entries)
2435
+ seen: set[str] = set()
2436
+ if valid:
2437
+ for entry in entries:
2438
+ rel = str(entry.get("path") or "") if isinstance(entry, dict) else ""
2439
+ digest = str(entry.get("sha256") or "") if isinstance(entry, dict) else ""
2440
+ if (
2441
+ not rel
2442
+ or rel in seen
2443
+ or re.fullmatch(r"[0-9a-f]{64}", digest) is None
2444
+ ):
2445
+ valid = False
2446
+ break
2447
+ seen.add(rel)
2448
+ target = (change_dir / rel).resolve()
2449
+ if not target.is_relative_to(change_dir.resolve()) or not target.is_file():
2450
+ valid = False
2451
+ break
2452
+ if not _manifest_path_excluded(rel) and sha256_file(target) != digest:
2453
+ valid = False
2454
+ break
2455
+ if not valid:
2456
+ issues.append(
2457
+ {
2458
+ "code": "manifest-invalid",
2459
+ "severity": "error",
2460
+ "message": "archive-manifest-before structure or checksum is invalid",
2461
+ }
2462
+ )
2463
+ except (OSError, json.JSONDecodeError):
2464
+ issues.append(
2465
+ {
2466
+ "code": "manifest-invalid",
2467
+ "severity": "error",
2468
+ "message": "archive-manifest-before is unreadable",
2469
+ }
2470
+ )
2471
+
2472
+ # 5. Artifact paths must resolve to immutable files inside the archive.
2473
+ root = change_dir.resolve()
2474
+ for artifact in summary.get("artifacts") or []:
2475
+ raw = str(artifact.get("path") or "") if isinstance(artifact, dict) else ""
2476
+ candidate = (root / raw).resolve() if raw else root
2477
+ if not raw or not candidate.is_relative_to(root) or not candidate.is_file():
2478
+ issues.append(
2479
+ {
2480
+ "code": "artifact-missing",
2481
+ "severity": "error",
2482
+ "message": f"artifact path is missing or outside archive: {raw}",
2483
+ }
2484
+ )
2485
+
2486
+ errors = [i for i in issues if i.get("severity") == "error"]
2487
+ warnings = [i for i in issues if i.get("severity") != "error"]
2488
+ return {
2489
+ "ok": len(errors) == 0,
2490
+ "issues": issues,
2491
+ "error_count": len(errors),
2492
+ "warning_count": len(warnings),
2493
+ }
2494
+
2495
+
1684
2496
  def validate_summary(
1685
2497
  summary: dict[str, Any],
1686
2498
  html_path: Path | None,
@@ -1795,7 +2607,12 @@ def validate_summary(
1795
2607
 
1796
2608
  # risks / manual actions — empty arrays are OK (placeholder)
1797
2609
  for risk in summary.get("knownRisks") or []:
1798
- text = str(risk)
2610
+ # UT-016/RET-28: canonical projection — structured risk objects
2611
+ # project to their message; str(dict) never matches rendered HTML.
2612
+ if isinstance(risk, dict):
2613
+ text = str(risk.get("message") or risk.get("summary") or "").strip()
2614
+ else:
2615
+ text = str(risk)
1799
2616
  if text and text not in html and _html_escape(text) not in html:
1800
2617
  issues.append(
1801
2618
  {
@@ -2006,6 +2823,7 @@ def cmd_finalize(
2006
2823
  archive_root: Path,
2007
2824
  *,
2008
2825
  skip_ingest: bool = False,
2826
+ allow_missing_review: bool = False,
2009
2827
  ) -> tuple[int, dict[str, Any]]:
2010
2828
  """Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
2011
2829
  warnings: list[str] = []
@@ -2015,6 +2833,43 @@ def cmd_finalize(
2015
2833
  archive_root.mkdir(parents=True, exist_ok=True)
2016
2834
  archive_dir = archive_root / f"{today_date()}-{change_name}"
2017
2835
  project_root = find_project_root(original_change_dir)
2836
+ try:
2837
+ resolved_state_dir = hp.resolve_state_dir_for_contract(
2838
+ original_change_dir, project_root
2839
+ )
2840
+ except ValueError as exc:
2841
+ return 1, {
2842
+ "ok": False,
2843
+ "action": "finalize",
2844
+ "change_dir": str(original_change_dir),
2845
+ "error": f"invalid split runtime root: {exc}",
2846
+ }
2847
+ split_state_dir = (
2848
+ resolved_state_dir
2849
+ if resolved_state_dir.resolve() != original_change_dir.resolve()
2850
+ else None
2851
+ )
2852
+ split_materialized_names = (
2853
+ {item.name for item in split_state_dir.iterdir()}
2854
+ if split_state_dir is not None and split_state_dir.is_dir()
2855
+ else set()
2856
+ )
2857
+
2858
+ def _restore_finalize_failure() -> None:
2859
+ _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
2860
+ if split_state_dir is None or not original_change_dir.is_dir():
2861
+ return
2862
+ # The split state remains authoritative on failure. Remove only the
2863
+ # top-level names materialized from that state so retry starts clean.
2864
+ for name in sorted(split_materialized_names):
2865
+ target = original_change_dir / name
2866
+ try:
2867
+ if target.is_dir():
2868
+ shutil.rmtree(target)
2869
+ elif target.exists():
2870
+ target.unlink()
2871
+ except OSError as exc:
2872
+ warnings.append(f"could not dematerialize split state {target}: {exc}")
2018
2873
 
2019
2874
  payload: dict[str, Any] = {
2020
2875
  "ok": False,
@@ -2035,6 +2890,7 @@ def cmd_finalize(
2035
2890
  return 1, payload
2036
2891
 
2037
2892
  work_dir = original_change_dir
2893
+ before_manifest: dict[str, Any] | None = None
2038
2894
 
2039
2895
  def _safe_append(**kwargs: Any) -> None:
2040
2896
  nonlocal work_dir
@@ -2046,6 +2902,30 @@ def cmd_finalize(
2046
2902
  # Step 9 starts here: phase.start
2047
2903
  _safe_append(phase="archive", type_="phase.start", note="finalize start")
2048
2904
 
2905
+ # --- 0. cleanup transients (before before-manifest) ---
2906
+ try:
2907
+ cleanup_result = _cleanup_transients(split_state_dir or work_dir)
2908
+ payload["steps"]["cleanup"] = cleanup_result
2909
+ deleted_n = len(cleanup_result.get("deleted") or [])
2910
+ trunc_n = len(cleanup_result.get("truncated") or [])
2911
+ _safe_append(
2912
+ phase="archive",
2913
+ type_="command",
2914
+ command="cleanup-transients",
2915
+ exit_code=0,
2916
+ note=f"deleted={deleted_n} truncated={trunc_n}",
2917
+ )
2918
+ except OSError as exc:
2919
+ warnings.append(f"cleanup failed: {exc}")
2920
+ payload["steps"]["cleanup"] = {"ok": False, "error": str(exc)}
2921
+
2922
+ if split_state_dir is not None and split_state_dir.is_dir():
2923
+ _merge_runtime_state(split_state_dir, work_dir)
2924
+ payload["steps"]["split_state_merge"] = {
2925
+ "ok": True,
2926
+ "stateDir": str(split_state_dir),
2927
+ }
2928
+
2049
2929
  # --- 1. before-manifest ---
2050
2930
  before_path = work_dir / "evidence" / "archive-manifest-before.json"
2051
2931
  try:
@@ -2079,6 +2959,10 @@ def cmd_finalize(
2079
2959
  )
2080
2960
  return 1, payload
2081
2961
 
2962
+ # Sync events/evidence appended after the first materialization.
2963
+ if split_state_dir is not None and split_state_dir.is_dir():
2964
+ _merge_runtime_state(split_state_dir, work_dir)
2965
+
2082
2966
  # --- 2. move ---
2083
2967
  try:
2084
2968
  archive_root.mkdir(parents=True, exist_ok=True)
@@ -2110,7 +2994,33 @@ def cmd_finalize(
2110
2994
  pass
2111
2995
  return 1, payload
2112
2996
 
2113
- # --- 3. collect ---
2997
+ # --- 2b. user-flag decision (archive fact, must precede the cutoff) ---
2998
+ if allow_missing_review:
2999
+ _safe_append(
3000
+ phase="archive",
3001
+ type_="decision",
3002
+ note="review missing on full tier (allowed by user)",
3003
+ )
3004
+
3005
+ # --- 3. unique phase.end + freeze cutoff (RET-19 freeze-first) ---
3006
+ # From here on, NO event may be appended to the archived events file;
3007
+ # report generation runs as pure functions over the frozen sources.
3008
+ _safe_append(
3009
+ phase="archive",
3010
+ type_="phase.end",
3011
+ status="WARN" if warnings else "OK",
3012
+ note="finalize facts complete",
3013
+ )
3014
+ try:
3015
+ cutoff = _freeze_evidence_cutoff(work_dir)
3016
+ payload["steps"]["freeze"] = {"ok": True, "eventCount": cutoff["eventCount"]}
3017
+ except OSError as exc:
3018
+ payload["error"] = f"freeze failed: {exc}"
3019
+ payload["steps"]["freeze"] = {"ok": False, "error": str(exc)}
3020
+ _restore_finalize_failure()
3021
+ return 1, payload
3022
+
3023
+ # --- 4. collect (pure function of frozen sources) ---
2114
3024
  try:
2115
3025
  summary = collect_summary_data(
2116
3026
  work_dir,
@@ -2119,46 +3029,49 @@ def cmd_finalize(
2119
3029
  for_replay=False,
2120
3030
  )
2121
3031
  summary_path = work_dir / "reports" / "final" / "summary-data.json"
3032
+ if allow_missing_review:
3033
+ reasons = list(summary.get("finalStatusReasons") or [])
3034
+ reason = "review missing on full tier (allowed by user)"
3035
+ if reason not in reasons:
3036
+ reasons.append(reason)
3037
+ summary["finalStatusReasons"] = reasons
3038
+ write_json(summary_path, summary)
2122
3039
  payload["steps"]["collect"] = {"ok": True, "path": str(summary_path)}
2123
- _safe_append(
2124
- phase="archive",
2125
- type_="artifact",
2126
- path="reports/final/summary-data.json",
2127
- kind="summary-data",
2128
- )
2129
3040
  except Exception as exc: # noqa: BLE001 — surface collect failures
2130
3041
  payload["error"] = f"collect failed: {exc}"
2131
3042
  payload["steps"]["collect"] = {"ok": False, "error": str(exc)}
2132
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
3043
+ _restore_finalize_failure()
3044
+ return 1, payload
3045
+
3046
+ # --- 5. source consistency (layer 1; UT-014) ---
3047
+ source_result = validate_source_consistency(work_dir, summary)
3048
+ payload["steps"]["source_consistency"] = source_result
3049
+ summary.setdefault("reportPipeline", {})["sourceConsistency"] = {
3050
+ "ok": bool(source_result.get("ok")),
3051
+ "issues": source_result.get("issues") or [],
3052
+ }
3053
+ try:
3054
+ write_json(summary_path, summary)
3055
+ except OSError as exc:
3056
+ warnings.append(f"could not write sourceConsistency: {exc}")
3057
+ if not source_result.get("ok"):
3058
+ payload["issues"] = source_result.get("issues") or []
3059
+ payload["error"] = "source consistency failed; original change dir restored"
3060
+ _restore_finalize_failure()
3061
+ _append_finalize_failure_issue(original_change_dir, "source-consistency-failed", payload["error"])
3062
+ payload["warnings"] = warnings
3063
+ payload["ok"] = False
2133
3064
  return 1, payload
2134
3065
 
2135
- # --- 4. render (Node, else Python fallback) ---
3066
+ # --- 6. render (Node, else Python fallback) ---
2136
3067
  render_result = render_final_summary(work_dir, summary_path)
2137
3068
  payload["steps"]["render"] = render_result
2138
3069
  if not render_result.get("ok"):
2139
- # Node + fallback both failed: restore + exit non-0 (§4.1 rule 4)。
2140
3070
  # 永不关闭一个没有 final-summary 的归档。
2141
3071
  msg = str(render_result.get("fallbackReason") or "render failed")
2142
3072
  payload["error"] = f"final-summary render failed: {msg}"
2143
- _safe_append(
2144
- phase="archive",
2145
- type_="issue",
2146
- code="render-failed",
2147
- severity="error",
2148
- message=msg,
2149
- )
2150
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
2151
- _restore_target = original_change_dir if original_change_dir.is_dir() else work_dir
2152
- try:
2153
- append_event(
2154
- _restore_target,
2155
- phase="archive",
2156
- type_="phase.end",
2157
- status="FAIL",
2158
- note="finalize aborted; render failed; original preserved",
2159
- )
2160
- except OSError:
2161
- pass
3073
+ _restore_finalize_failure()
3074
+ _append_finalize_failure_issue(original_change_dir, "render-failed", msg)
2162
3075
  payload["warnings"] = warnings
2163
3076
  payload["ok"] = False
2164
3077
  return 1, payload
@@ -2168,24 +3081,10 @@ def cmd_finalize(
2168
3081
  f"node render unavailable; used python-fallback: "
2169
3082
  f"{render_result.get('fallbackReason')}"
2170
3083
  )
2171
- _safe_append(
2172
- phase="archive",
2173
- type_="artifact",
2174
- path="reports/final/final-summary.html",
2175
- kind="final-report",
2176
- )
2177
- _safe_append(
2178
- phase="archive",
2179
- type_="command",
2180
- command=f"render-final-summary ({renderer})",
2181
- exit_code=0,
2182
- note=f"final-summary rendered by {renderer}",
2183
- )
2184
3084
 
2185
3085
  html_path = work_dir / "reports" / "final" / "final-summary.html"
2186
3086
 
2187
- # --- 5. validate (same process, no re-collect) ---
2188
- # Refresh summary from disk (collect already wrote it); do not re-collect.
3087
+ # --- 7. renderer consistency (layer 2) ---
2189
3088
  try:
2190
3089
  summary = read_json(summary_path)
2191
3090
  except (OSError, json.JSONDecodeError):
@@ -2203,20 +3102,19 @@ def cmd_finalize(
2203
3102
  except OSError as exc:
2204
3103
  warnings.append(f"could not write validationIssues: {exc}")
2205
3104
 
2206
- for issue in validate_result.get("issues") or []:
2207
- _safe_append(
2208
- phase="archive",
2209
- type_="issue",
2210
- code=str(issue.get("code") or "validate"),
2211
- severity=str(issue.get("severity") or "warning"),
2212
- message=str(issue.get("message") or ""),
2213
- )
3105
+ # --- 8. archive-meta (before after-manifest so the manifest covers it) ---
3106
+ try:
3107
+ summary = read_json(summary_path)
3108
+ meta_path = write_archive_meta(work_dir, summary)
3109
+ payload["steps"]["archive_meta"] = {"ok": True, "path": str(meta_path)}
3110
+ except Exception as exc: # noqa: BLE001 — meta soft-fail
3111
+ warnings.append(f"archive-meta write failed: {exc}")
3112
+ payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
2214
3113
 
2215
- # --- 6. after-manifest + compare ---
3114
+ # --- 9. after-manifest + compare ---
2216
3115
  after_path = work_dir / "evidence" / "archive-manifest-after.json"
2217
3116
  try:
2218
3117
  after_manifest = generate_manifest(work_dir, after_path)
2219
- # Re-read before from archive (moved with the tree)
2220
3118
  before_in_archive = work_dir / "evidence" / "archive-manifest-before.json"
2221
3119
  if before_in_archive.is_file():
2222
3120
  before_manifest = read_json(before_in_archive)
@@ -2226,46 +3124,34 @@ def cmd_finalize(
2226
3124
  "path": str(after_path),
2227
3125
  "compare": compare_result,
2228
3126
  }
2229
- _safe_append(
2230
- phase="archive",
2231
- type_="artifact",
2232
- path="evidence/archive-manifest-after.json",
2233
- kind="manifest-after",
2234
- )
2235
3127
  except (OSError, json.JSONDecodeError, ValueError) as exc:
2236
3128
  payload["error"] = f"after-manifest failed: {exc}"
2237
3129
  payload["steps"]["after_manifest"] = {"ok": False, "error": str(exc)}
2238
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
3130
+ _restore_finalize_failure()
2239
3131
  return 1, payload
2240
3132
 
2241
- # Update archiveManifest in summary-data with compare stats
3133
+ # --- 10. final summary update + final render/validate (no more events) ---
3134
+ summary = read_json(summary_path)
2242
3135
  summary["archiveManifest"] = {
2243
3136
  "movedFiles": compare_result.get("movedFiles", 0),
2244
3137
  "generatedFiles": compare_result.get("generatedFiles", 0),
2245
3138
  "totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
2246
3139
  "checksumStatus": compare_result.get("checksumStatus", "FAIL"),
3140
+ **_manifest_self_stats(work_dir, after_manifest, after_path),
2247
3141
  }
2248
- try:
2249
- write_json(summary_path, summary)
2250
- except OSError as exc:
2251
- warnings.append(f"could not update archiveManifest: {exc}")
2252
-
2253
- # The first render is needed so the after-manifest includes a final report,
2254
- # but it predates the final manifest statistics. Render and validate once
2255
- # more from the now-final summary so HTML never contradicts summary-data.
3142
+ write_json(summary_path, summary)
2256
3143
  render_result = render_final_summary(work_dir, summary_path)
2257
3144
  if not render_result.get("ok"):
2258
3145
  payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
2259
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
3146
+ _restore_finalize_failure()
2260
3147
  return 1, payload
3148
+ summary = read_json(summary_path)
2261
3149
  validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
2262
3150
  payload["steps"]["validate"] = validate_result
2263
3151
  summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get("issues") or []
2264
3152
  write_json(summary_path, summary)
2265
3153
 
2266
- # --- 7. delete original only if validate+manifest OK ---
2267
- # After move, "original" is gone; on failure we restore. On success, ensure
2268
- # the changes path does not linger.
3154
+ # --- 11. closure: only when both validators and manifest pass ---
2269
3155
  validate_ok = bool(validate_result.get("ok"))
2270
3156
  manifest_ok = bool(compare_result.get("ok"))
2271
3157
  can_close = validate_ok and manifest_ok
@@ -2286,19 +3172,10 @@ def cmd_finalize(
2286
3172
  payload["issues"] = issues_out
2287
3173
  payload["error"] = "validate or manifest check failed; original change dir restored"
2288
3174
  payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
2289
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
2290
- _safe_append_restored = original_change_dir if original_change_dir.is_dir() else None
2291
- if _safe_append_restored:
2292
- try:
2293
- append_event(
2294
- _safe_append_restored,
2295
- phase="archive",
2296
- type_="phase.end",
2297
- status="FAIL",
2298
- note="finalize aborted; original preserved",
2299
- )
2300
- except OSError:
2301
- pass
3175
+ _restore_finalize_failure()
3176
+ _append_finalize_failure_issue(
3177
+ original_change_dir, "closure-failed", payload["error"]
3178
+ )
2302
3179
  payload["warnings"] = warnings
2303
3180
  payload["ok"] = False
2304
3181
  return 1, payload
@@ -2318,16 +3195,15 @@ def cmd_finalize(
2318
3195
  "note": "removed by move",
2319
3196
  }
2320
3197
 
2321
- _safe_append(
2322
- phase="archive",
2323
- type_="verification",
2324
- name="archive-closure",
2325
- status="ok",
2326
- note="manifest+validate passed; original removed",
2327
- )
3198
+ if split_state_dir is not None and split_state_dir.is_dir():
3199
+ try:
3200
+ shutil.rmtree(split_state_dir)
3201
+ payload["steps"]["delete_runtime_state"] = {"ok": True, "deleted": True}
3202
+ except OSError as exc:
3203
+ warnings.append(f"could not remove archived runtime state: {exc}")
3204
+ payload["steps"]["delete_runtime_state"] = {"ok": False, "error": str(exc)}
2328
3205
 
2329
- # --- 8. maintenance outbox + service (§8.2: close no longer runs the four
2330
- # knowledge subprocesses; it enqueues a pending outbox item and returns) ---
3206
+ # --- 12. maintenance outbox + service ---
2331
3207
  if skip_ingest:
2332
3208
  payload["steps"]["knowledge"] = {"skipped": True, "reason": "--skip-ingest"}
2333
3209
  payload["knowledgeMaintenance"] = "SKIPPED"
@@ -2346,13 +3222,6 @@ def cmd_finalize(
2346
3222
  if service_result.get("warning"):
2347
3223
  warnings.append(str(service_result["warning"]))
2348
3224
 
2349
- _safe_append(
2350
- phase="archive",
2351
- type_="phase.end",
2352
- status="WARN" if warnings else "OK",
2353
- note="finalize complete",
2354
- )
2355
-
2356
3225
  payload["ok"] = True
2357
3226
  payload["warnings"] = warnings
2358
3227
  payload["summary_data"] = str(summary_path)
@@ -2360,6 +3229,17 @@ def cmd_finalize(
2360
3229
  return 0, payload
2361
3230
 
2362
3231
 
3232
+ def _merge_runtime_state(state_dir: Path, contract_dir: Path) -> None:
3233
+ """Materialize split-v1 dynamic state into the archive contract tree."""
3234
+ for source in sorted(state_dir.iterdir()):
3235
+ target = contract_dir / source.name
3236
+ if source.is_dir():
3237
+ shutil.copytree(source, target, dirs_exist_ok=True, copy_function=shutil.copy2)
3238
+ elif source.is_file():
3239
+ target.parent.mkdir(parents=True, exist_ok=True)
3240
+ shutil.copy2(source, target)
3241
+
3242
+
2363
3243
  def _restore_on_failure(
2364
3244
  archive_dir: Path,
2365
3245
  original_change_dir: Path,
@@ -2455,7 +3335,10 @@ def cmd_replay(
2455
3335
 
2456
3336
  def cmd_status_cli(args: argparse.Namespace) -> int:
2457
3337
  change_dir = resolve_path(args.change_dir)
2458
- result = check_status(change_dir)
3338
+ result = check_status(
3339
+ change_dir,
3340
+ allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
3341
+ )
2459
3342
  emit_json(result)
2460
3343
  # Checks completed → exit 0; archivable flag conveys the gate result.
2461
3344
  return 0 if result.get("ok") else 1
@@ -2468,6 +3351,7 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
2468
3351
  change_dir,
2469
3352
  archive_root,
2470
3353
  skip_ingest=bool(args.skip_ingest),
3354
+ allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
2471
3355
  )
2472
3356
  emit_json(payload)
2473
3357
  return code
@@ -2481,6 +3365,144 @@ def cmd_replay_cli(args: argparse.Namespace) -> int:
2481
3365
  return code
2482
3366
 
2483
3367
 
3368
+ def _render_html_to(
3369
+ change_dir: Path,
3370
+ summary_path: Path,
3371
+ out_path: Path,
3372
+ ) -> dict[str, Any]:
3373
+ """Render summary HTML to an arbitrary path (node first, python fallback).
3374
+
3375
+ Unlike ``render_final_summary`` this never touches the canonical
3376
+ ``reports/final/final-summary.html`` — repair renders into staging.
3377
+ """
3378
+ project = find_project_root(change_dir)
3379
+ node = resolve_node_path(project)
3380
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3381
+ if node and RENDER_SCRIPT.is_file():
3382
+ try:
3383
+ proc = subprocess.run(
3384
+ [node, str(RENDER_SCRIPT), "--summary", str(summary_path), "--out", str(out_path)],
3385
+ capture_output=True,
3386
+ text=True,
3387
+ encoding="utf-8",
3388
+ errors="replace",
3389
+ timeout=60,
3390
+ check=False,
3391
+ )
3392
+ if proc.returncode == 0 and out_path.is_file():
3393
+ return {"ok": True, "renderer": "node", "out_path": str(out_path)}
3394
+ except (OSError, subprocess.TimeoutExpired):
3395
+ pass
3396
+ try:
3397
+ summary = read_json(summary_path)
3398
+ html = render_fallback_html(summary)
3399
+ out_path.write_text(html, encoding="utf-8", newline="\n")
3400
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
3401
+ return {"ok": False, "renderer": "none", "error": str(exc)}
3402
+ return {"ok": True, "renderer": "python-fallback", "out_path": str(out_path)}
3403
+
3404
+
3405
+ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
3406
+ """Versioned repair (task 11 / RET-40).
3407
+
3408
+ Re-collect a candidate from the frozen sources, run both validators, and
3409
+ only then write an immutable ``derived/v<N>/`` plus a repair record. The
3410
+ original summary/HTML/manifest is never overwritten; the authoritative
3411
+ pointer moves only when both validators pass.
3412
+ """
3413
+ payload: dict[str, Any] = {
3414
+ "ok": False,
3415
+ "action": "repair",
3416
+ "archive_dir": str(archive_dir),
3417
+ }
3418
+ if not archive_dir.is_dir():
3419
+ payload["error"] = f"archive dir not found: {archive_dir}"
3420
+ return 1, payload
3421
+ summary_path = archive_dir / "reports" / "final" / "summary-data.json"
3422
+ if not summary_path.is_file():
3423
+ payload["error"] = "summary-data.json missing; cannot repair"
3424
+ return 1, payload
3425
+
3426
+ # 1. candidate: fresh collect from frozen sources (read-only on archive).
3427
+ try:
3428
+ candidate = collect_summary_data(archive_dir, write=False, for_replay=False)
3429
+ except Exception as exc: # noqa: BLE001
3430
+ payload["error"] = f"repair collect failed: {exc}"
3431
+ return 1, payload
3432
+
3433
+ # 2. stage outside the archive; run both validators on the candidate.
3434
+ staging = Path(tempfile.mkdtemp(prefix="harness-repair-"))
3435
+ try:
3436
+ staged_summary = staging / "summary-data.json"
3437
+ write_json(staged_summary, candidate)
3438
+ source_result = validate_source_consistency(archive_dir, candidate)
3439
+ render_result = _render_html_to(
3440
+ archive_dir, staged_summary, staging / "final-summary.html"
3441
+ )
3442
+ staged_html = staging / "final-summary.html"
3443
+ renderer_result = validate_summary(
3444
+ candidate,
3445
+ staged_html if staged_html.is_file() else None,
3446
+ )
3447
+ payload["validators"] = {
3448
+ "source": source_result,
3449
+ "renderer": renderer_result,
3450
+ }
3451
+ if not (source_result.get("ok") and renderer_result.get("ok")):
3452
+ payload["error"] = "repair validators failed; derived version not written"
3453
+ return 1, payload
3454
+
3455
+ # 3. immutable derived version.
3456
+ derived = archive_dir / "derived"
3457
+ derived.mkdir(exist_ok=True)
3458
+ existing = [
3459
+ int(p.name[1:])
3460
+ for p in derived.iterdir()
3461
+ if p.is_dir() and p.name.startswith("v") and p.name[1:].isdigit()
3462
+ ]
3463
+ version = f"v{(max(existing) + 1) if existing else 1}"
3464
+ version_dir = derived / version
3465
+ version_dir.mkdir()
3466
+ final_summary = version_dir / "summary-data.json"
3467
+ write_json(final_summary, candidate)
3468
+ if staged_html.is_file():
3469
+ shutil.copy2(staged_html, version_dir / "final-summary.html")
3470
+ record = {
3471
+ "version": version,
3472
+ "createdAt": now_iso(),
3473
+ "summarySha256": "sha256:" + hashlib.sha256(final_summary.read_bytes()).hexdigest(),
3474
+ "baseSummarySha256": "sha256:" + hashlib.sha256(summary_path.read_bytes()).hexdigest(),
3475
+ "validators": {
3476
+ "source": {"ok": bool(source_result.get("ok")), "issues": source_result.get("issues") or []},
3477
+ "renderer": {"ok": bool(renderer_result.get("ok")), "issues": renderer_result.get("issues") or []},
3478
+ },
3479
+ }
3480
+ write_json(version_dir / "repair-record.json", record)
3481
+
3482
+ # 4. authoritative pointer — only after both validators passed.
3483
+ write_json(
3484
+ derived / "authoritative.json",
3485
+ {
3486
+ "version": version,
3487
+ "summarySha256": record["summarySha256"],
3488
+ "updatedAt": record["createdAt"],
3489
+ },
3490
+ )
3491
+ payload["ok"] = True
3492
+ payload["version"] = version
3493
+ payload["derived_dir"] = str(version_dir)
3494
+ return 0, payload
3495
+ finally:
3496
+ shutil.rmtree(staging, ignore_errors=True)
3497
+
3498
+
3499
+ def cmd_repair_cli(args: argparse.Namespace) -> int:
3500
+ archive_dir = resolve_path(args.archive_dir)
3501
+ code, payload = cmd_repair(archive_dir)
3502
+ emit_json(payload)
3503
+ return code
3504
+
3505
+
2484
3506
  def build_parser() -> argparse.ArgumentParser:
2485
3507
  parser = argparse.ArgumentParser(
2486
3508
  prog="harness_archive.py",
@@ -2491,12 +3513,22 @@ def build_parser() -> argparse.ArgumentParser:
2491
3513
  p_status = sub.add_parser("status", help="pre-archive gate checks (read-only)")
2492
3514
  p_status.add_argument("--change-dir", required=True)
2493
3515
  p_status.add_argument("--json", action="store_true", default=True)
3516
+ p_status.add_argument(
3517
+ "--allow-missing-review",
3518
+ action="store_true",
3519
+ help="downgrade full-tier missing review from blocker to warning",
3520
+ )
2494
3521
  p_status.set_defaults(func=cmd_status_cli)
2495
3522
 
2496
3523
  p_fin = sub.add_parser("finalize", help="single-process archive finalize")
2497
3524
  p_fin.add_argument("--change-dir", required=True)
2498
3525
  p_fin.add_argument("--archive-root", required=True)
2499
3526
  p_fin.add_argument("--skip-ingest", action="store_true")
3527
+ p_fin.add_argument(
3528
+ "--allow-missing-review",
3529
+ action="store_true",
3530
+ help="record override when full-tier review evidence is missing",
3531
+ )
2500
3532
  p_fin.add_argument("--json", action="store_true", default=True)
2501
3533
  p_fin.set_defaults(func=cmd_finalize_cli)
2502
3534
 
@@ -2506,6 +3538,13 @@ def build_parser() -> argparse.ArgumentParser:
2506
3538
  p_rep.add_argument("--json", action="store_true", default=True)
2507
3539
  p_rep.set_defaults(func=cmd_replay_cli)
2508
3540
 
3541
+ p_repair = sub.add_parser(
3542
+ "repair", help="versioned repair: validated derived version, original untouched"
3543
+ )
3544
+ p_repair.add_argument("--archive-dir", required=True)
3545
+ p_repair.add_argument("--json", action="store_true", default=True)
3546
+ p_repair.set_defaults(func=cmd_repair_cli)
3547
+
2509
3548
  return parser
2510
3549
 
2511
3550