@hunter-harness/workflow-harness 0.2.9 → 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 (282) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/reference.md +12 -2
  4. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +6 -2
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +7 -0
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  9. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/reference.md +2 -0
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +15 -10
  15. package/harness/bundles/general/claude-code/harness-submit/checklist.md +40 -76
  16. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +17 -2
  17. package/harness/bundles/general/claude-code/harness-sync/reference.md +2 -2
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  19. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -0
  20. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +24 -4
  21. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +36 -0
  22. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +15 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_archive.py +803 -274
  24. package/harness/bundles/general/claude-code/scripts/harness_change.py +18 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -44
  26. package/harness/bundles/general/claude-code/scripts/harness_events.py +104 -6
  27. package/harness/bundles/general/claude-code/scripts/harness_gate.py +25 -4
  28. package/harness/bundles/general/claude-code/scripts/harness_integration.py +878 -0
  29. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +440 -10
  30. package/harness/bundles/general/claude-code/scripts/harness_paths.py +315 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_profile.py +45 -0
  32. package/harness/bundles/general/claude-code/scripts/harness_review.py +326 -0
  33. package/harness/bundles/general/claude-code/scripts/harness_sync.py +407 -0
  34. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +244 -49
  35. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  36. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-archive/reference.md +12 -2
  38. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
  39. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
  41. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +7 -0
  42. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  43. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  45. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  46. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  47. package/harness/bundles/general/codebuddy/harness-run/reference.md +2 -0
  48. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +15 -10
  49. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +40 -76
  50. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +17 -2
  51. package/harness/bundles/general/codebuddy/harness-sync/reference.md +2 -2
  52. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  53. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -0
  54. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +24 -4
  55. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +36 -0
  56. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +15 -0
  57. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +803 -274
  58. package/harness/bundles/general/codebuddy/scripts/harness_change.py +18 -0
  59. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -44
  60. package/harness/bundles/general/codebuddy/scripts/harness_events.py +104 -6
  61. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +25 -4
  62. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +878 -0
  63. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +440 -10
  64. package/harness/bundles/general/codebuddy/scripts/harness_paths.py +315 -0
  65. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +45 -0
  66. package/harness/bundles/general/codebuddy/scripts/harness_review.py +326 -0
  67. package/harness/bundles/general/codebuddy/scripts/harness_sync.py +407 -0
  68. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +244 -49
  69. package/harness/bundles/general/codex/.harness-build.json +1 -1
  70. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  71. package/harness/bundles/general/codex/harness-archive/reference.md +12 -2
  72. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +6 -2
  73. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -1
  75. package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +7 -0
  76. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  77. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  78. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  79. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  80. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  81. package/harness/bundles/general/codex/harness-run/reference.md +2 -0
  82. package/harness/bundles/general/codex/harness-submit/SKILL.md +15 -10
  83. package/harness/bundles/general/codex/harness-submit/checklist.md +40 -76
  84. package/harness/bundles/general/codex/harness-sync/SKILL.md +17 -2
  85. package/harness/bundles/general/codex/harness-sync/reference.md +2 -2
  86. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  87. package/harness/bundles/general/codex/harness-test/reference.md +2 -0
  88. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +24 -4
  89. package/harness/bundles/general/codex/protocols/ledger-protocol.md +36 -0
  90. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +15 -0
  91. package/harness/bundles/general/codex/scripts/harness_archive.py +803 -274
  92. package/harness/bundles/general/codex/scripts/harness_change.py +18 -0
  93. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -44
  94. package/harness/bundles/general/codex/scripts/harness_events.py +104 -6
  95. package/harness/bundles/general/codex/scripts/harness_gate.py +25 -4
  96. package/harness/bundles/general/codex/scripts/harness_integration.py +878 -0
  97. package/harness/bundles/general/codex/scripts/harness_ledger.py +440 -10
  98. package/harness/bundles/general/codex/scripts/harness_paths.py +315 -0
  99. package/harness/bundles/general/codex/scripts/harness_profile.py +45 -0
  100. package/harness/bundles/general/codex/scripts/harness_review.py +326 -0
  101. package/harness/bundles/general/codex/scripts/harness_sync.py +407 -0
  102. package/harness/bundles/general/codex/scripts/harness_test_guard.py +244 -49
  103. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  104. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  105. package/harness/bundles/general/cursor/harness-archive/reference.md +12 -2
  106. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +6 -2
  107. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  108. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -1
  109. package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +7 -0
  110. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  111. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  112. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  113. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  114. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  115. package/harness/bundles/general/cursor/harness-run/reference.md +2 -0
  116. package/harness/bundles/general/cursor/harness-submit/SKILL.md +15 -10
  117. package/harness/bundles/general/cursor/harness-submit/checklist.md +40 -76
  118. package/harness/bundles/general/cursor/harness-sync/SKILL.md +17 -2
  119. package/harness/bundles/general/cursor/harness-sync/reference.md +2 -2
  120. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  121. package/harness/bundles/general/cursor/harness-test/reference.md +2 -0
  122. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +24 -4
  123. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +36 -0
  124. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +15 -0
  125. package/harness/bundles/general/cursor/scripts/harness_archive.py +803 -274
  126. package/harness/bundles/general/cursor/scripts/harness_change.py +18 -0
  127. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -44
  128. package/harness/bundles/general/cursor/scripts/harness_events.py +104 -6
  129. package/harness/bundles/general/cursor/scripts/harness_gate.py +25 -4
  130. package/harness/bundles/general/cursor/scripts/harness_integration.py +878 -0
  131. package/harness/bundles/general/cursor/scripts/harness_ledger.py +440 -10
  132. package/harness/bundles/general/cursor/scripts/harness_paths.py +315 -0
  133. package/harness/bundles/general/cursor/scripts/harness_profile.py +45 -0
  134. package/harness/bundles/general/cursor/scripts/harness_review.py +326 -0
  135. package/harness/bundles/general/cursor/scripts/harness_sync.py +407 -0
  136. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +244 -49
  137. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  138. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  140. package/harness/bundles/java/claude-code/harness-archive/reference.md +12 -2
  141. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +6 -2
  142. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
  144. package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +7 -0
  145. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  146. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  147. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  148. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  149. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  150. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  151. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +15 -10
  152. package/harness/bundles/java/claude-code/harness-submit/checklist.md +40 -76
  153. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +17 -2
  154. package/harness/bundles/java/claude-code/harness-sync/reference.md +2 -2
  155. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  156. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +24 -4
  157. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +36 -0
  158. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +15 -0
  159. package/harness/bundles/java/claude-code/scripts/harness_archive.py +803 -274
  160. package/harness/bundles/java/claude-code/scripts/harness_change.py +18 -0
  161. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -44
  162. package/harness/bundles/java/claude-code/scripts/harness_events.py +104 -6
  163. package/harness/bundles/java/claude-code/scripts/harness_gate.py +25 -4
  164. package/harness/bundles/java/claude-code/scripts/harness_integration.py +878 -0
  165. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +440 -10
  166. package/harness/bundles/java/claude-code/scripts/harness_paths.py +315 -0
  167. package/harness/bundles/java/claude-code/scripts/harness_profile.py +45 -0
  168. package/harness/bundles/java/claude-code/scripts/harness_review.py +326 -0
  169. package/harness/bundles/java/claude-code/scripts/harness_sync.py +407 -0
  170. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +244 -49
  171. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  172. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  173. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  174. package/harness/bundles/java/codebuddy/harness-archive/reference.md +12 -2
  175. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
  176. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  177. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
  178. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +7 -0
  179. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  180. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  181. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  182. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  183. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  184. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  185. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +15 -10
  186. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +40 -76
  187. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +17 -2
  188. package/harness/bundles/java/codebuddy/harness-sync/reference.md +2 -2
  189. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  190. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +24 -4
  191. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +36 -0
  192. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +15 -0
  193. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +803 -274
  194. package/harness/bundles/java/codebuddy/scripts/harness_change.py +18 -0
  195. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -44
  196. package/harness/bundles/java/codebuddy/scripts/harness_events.py +104 -6
  197. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +25 -4
  198. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +878 -0
  199. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +440 -10
  200. package/harness/bundles/java/codebuddy/scripts/harness_paths.py +315 -0
  201. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +45 -0
  202. package/harness/bundles/java/codebuddy/scripts/harness_review.py +326 -0
  203. package/harness/bundles/java/codebuddy/scripts/harness_sync.py +407 -0
  204. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +244 -49
  205. package/harness/bundles/java/codex/.harness-build.json +1 -1
  206. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  207. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  208. package/harness/bundles/java/codex/harness-archive/reference.md +12 -2
  209. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +6 -2
  210. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  211. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -1
  212. package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +7 -0
  213. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  214. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  215. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  216. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  217. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  218. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  219. package/harness/bundles/java/codex/harness-submit/SKILL.md +15 -10
  220. package/harness/bundles/java/codex/harness-submit/checklist.md +40 -76
  221. package/harness/bundles/java/codex/harness-sync/SKILL.md +17 -2
  222. package/harness/bundles/java/codex/harness-sync/reference.md +2 -2
  223. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +24 -4
  225. package/harness/bundles/java/codex/protocols/ledger-protocol.md +36 -0
  226. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +15 -0
  227. package/harness/bundles/java/codex/scripts/harness_archive.py +803 -274
  228. package/harness/bundles/java/codex/scripts/harness_change.py +18 -0
  229. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -44
  230. package/harness/bundles/java/codex/scripts/harness_events.py +104 -6
  231. package/harness/bundles/java/codex/scripts/harness_gate.py +25 -4
  232. package/harness/bundles/java/codex/scripts/harness_integration.py +878 -0
  233. package/harness/bundles/java/codex/scripts/harness_ledger.py +440 -10
  234. package/harness/bundles/java/codex/scripts/harness_paths.py +315 -0
  235. package/harness/bundles/java/codex/scripts/harness_profile.py +45 -0
  236. package/harness/bundles/java/codex/scripts/harness_review.py +326 -0
  237. package/harness/bundles/java/codex/scripts/harness_sync.py +407 -0
  238. package/harness/bundles/java/codex/scripts/harness_test_guard.py +244 -49
  239. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  240. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-archive/reference.md +12 -2
  243. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +6 -2
  244. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -1
  246. package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +7 -0
  247. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
  248. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-submit/SKILL.md +15 -10
  254. package/harness/bundles/java/cursor/harness-submit/checklist.md +40 -76
  255. package/harness/bundles/java/cursor/harness-sync/SKILL.md +17 -2
  256. package/harness/bundles/java/cursor/harness-sync/reference.md +2 -2
  257. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  258. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +24 -4
  259. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +36 -0
  260. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +15 -0
  261. package/harness/bundles/java/cursor/scripts/harness_archive.py +803 -274
  262. package/harness/bundles/java/cursor/scripts/harness_change.py +18 -0
  263. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -44
  264. package/harness/bundles/java/cursor/scripts/harness_events.py +104 -6
  265. package/harness/bundles/java/cursor/scripts/harness_gate.py +25 -4
  266. package/harness/bundles/java/cursor/scripts/harness_integration.py +878 -0
  267. package/harness/bundles/java/cursor/scripts/harness_ledger.py +440 -10
  268. package/harness/bundles/java/cursor/scripts/harness_paths.py +315 -0
  269. package/harness/bundles/java/cursor/scripts/harness_profile.py +45 -0
  270. package/harness/bundles/java/cursor/scripts/harness_review.py +326 -0
  271. package/harness/bundles/java/cursor/scripts/harness_sync.py +407 -0
  272. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +244 -49
  273. package/harness/manifests/general/claude-code.json +47 -31
  274. package/harness/manifests/general/codebuddy.json +47 -31
  275. package/harness/manifests/general/codex.json +47 -31
  276. package/harness/manifests/general/cursor.json +47 -31
  277. package/harness/manifests/java/claude-code.json +47 -31
  278. package/harness/manifests/java/codebuddy.json +47 -31
  279. package/harness/manifests/java/codex.json +47 -31
  280. package/harness/manifests/java/cursor.json +47 -31
  281. package/hunter-workflow-family.json +2 -2
  282. package/package.json +1 -1
@@ -54,7 +54,7 @@ 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
60
  # Compiled once for evidence-text count fallbacks in _ledger_unit_tests / _ledger_api_tests.
@@ -68,6 +68,9 @@ if str(SCRIPTS_DIR) not in sys.path:
68
68
  sys.path.insert(0, str(SCRIPTS_DIR))
69
69
 
70
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
71
74
 
72
75
 
73
76
  # ---------------------------------------------------------------------------
@@ -679,12 +682,19 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
679
682
 
680
683
  metrics = unit.get("metrics")
681
684
  if isinstance(metrics, dict) and any(
682
- k in metrics for k in ("run", "testsRun", "failures", "errors", "skipped")
685
+ k in metrics for k in ("run", "testsRun", "total", "failures", "errors", "skipped")
683
686
  ):
684
- run = int(metrics.get("run", metrics.get("testsRun", 0)) or 0)
685
- failures = int(metrics.get("failures", 0) or 0)
686
- errors = int(metrics.get("errors", 0) or 0)
687
- skipped = int(metrics.get("skipped", 0) or 0)
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)
688
698
  pass_rate = metrics.get("passRate")
689
699
  source = "committed"
690
700
  counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
@@ -855,6 +865,53 @@ def _ledger_api_tests(
855
865
  }
856
866
 
857
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
+
858
915
  def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
859
916
  if not ledger:
860
917
  return "NOT_RUN"
@@ -870,6 +927,58 @@ def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
870
927
  return "NOT_RUN"
871
928
 
872
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
+
873
982
  def _parse_durations_from_log(log_text: str) -> dict[str, Any]:
874
983
  """Best-effort parse of harness-* sections from execution-log."""
875
984
  stages: list[dict[str, Any]] = []
@@ -1044,7 +1153,15 @@ def _artifacts_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]
1044
1153
  return out
1045
1154
 
1046
1155
 
1047
- 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)
1048
1165
  stages: list[dict[str, Any]] = []
1049
1166
  total_ms = 0
1050
1167
  for name, info in (event_summary.get("phases") or {}).items():
@@ -1058,7 +1175,7 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
1058
1175
  result = "OK"
1059
1176
  elif result in {"FAILED", "ERROR"}:
1060
1177
  result = "FAIL"
1061
- stages.append({
1178
+ stage: dict[str, Any] = {
1062
1179
  "stage": str(name),
1063
1180
  "skill": f"harness-{name}",
1064
1181
  "startedAt": info.get("started_at") or NOT_AVAILABLE,
@@ -1066,7 +1183,13 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
1066
1183
  "minutes": minutes,
1067
1184
  "result": result,
1068
1185
  "attempts": attempts,
1069
- })
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)
1070
1193
  total_min = round(total_ms / 60000, 2)
1071
1194
  return {
1072
1195
  "totalLabel": f"约 {int(round(total_min))} 分",
@@ -1316,94 +1439,6 @@ def write_archive_meta(work_dir: Path, summary: dict[str, Any]) -> Path:
1316
1439
  return out
1317
1440
 
1318
1441
 
1319
- def _patch_archive_stage(
1320
- summary_path: Path,
1321
- work_dir: Path,
1322
- *,
1323
- render: bool = True,
1324
- ) -> None:
1325
- """Backfill archive stage duration/status from events (optionally re-render).
1326
-
1327
- When called from finalize step 9, pass ``render=False`` so the caller can
1328
- write archive-meta + remanifest and invoke ``render_final_summary`` once.
1329
- """
1330
- events_file = he.events_path(work_dir)
1331
- events = he.load_events(events_file) if events_file.is_file() else []
1332
- event_summary = he.build_summary(work_dir, events)
1333
- archive_info = (event_summary.get("phases") or {}).get("archive") or {}
1334
- duration_ms = archive_info.get("duration_ms")
1335
- status = str(archive_info.get("status") or "OK").upper()
1336
- if status in {"PASS", "PASSED", "SUCCESS"}:
1337
- status = "OK"
1338
- elif status in {"FAILED", "ERROR"}:
1339
- status = "FAIL"
1340
-
1341
- summary = read_json(summary_path)
1342
- stage_status = summary.setdefault("stageStatus", {})
1343
- if isinstance(stage_status, dict):
1344
- stage_status["archive"] = status
1345
-
1346
- durations = summary.setdefault("durations", {})
1347
- stages = list(durations.get("stages") or [])
1348
- minutes = round((duration_ms or 0) / 60000, 2) if duration_ms is not None else 0
1349
- archive_stage = {
1350
- "stage": "archive",
1351
- "skill": "harness-archive",
1352
- "startedAt": archive_info.get("started_at") or NOT_AVAILABLE,
1353
- "endedAt": archive_info.get("ended_at") or NOT_AVAILABLE,
1354
- "minutes": minutes,
1355
- "result": status,
1356
- "attempts": archive_info.get("attempts") if isinstance(archive_info.get("attempts"), list) else [],
1357
- }
1358
- replaced = False
1359
- for idx, stage in enumerate(stages):
1360
- if str(stage.get("stage") or "").lower() == "archive":
1361
- stages[idx] = archive_stage
1362
- replaced = True
1363
- break
1364
- if not replaced:
1365
- stages.append(archive_stage)
1366
- durations["stages"] = stages
1367
- total_min = round(sum(float(s.get("minutes") or 0) for s in stages), 2)
1368
- durations["totalMinutes"] = total_min
1369
- durations["totalLabel"] = f"约 {int(round(total_min))} 分"
1370
-
1371
- timeline = list(summary.get("timeline") or [])
1372
- for item in timeline:
1373
- if item.get("phase") == "archive" and item.get("type") not in {"decision", "issue"}:
1374
- item["durationMs"] = duration_ms
1375
- item["status"] = status
1376
- item["endedAt"] = archive_info.get("ended_at")
1377
- item["startedAt"] = archive_info.get("started_at")
1378
- # Ensure at least one archive timeline entry with duration
1379
- if duration_ms is not None and not any(
1380
- t.get("phase") == "archive" and t.get("durationMs") is not None for t in timeline
1381
- ):
1382
- timeline.append(
1383
- {
1384
- "phase": "archive",
1385
- "attempt": 1,
1386
- "startedAt": archive_info.get("started_at"),
1387
- "endedAt": archive_info.get("ended_at"),
1388
- "durationMs": duration_ms,
1389
- "status": status,
1390
- }
1391
- )
1392
- summary["timeline"] = timeline
1393
-
1394
- skill_calls = list(summary.get("skillCalls") or [])
1395
- found_skill = False
1396
- for call in skill_calls:
1397
- if str(call.get("skill") or "") in {"harness-archive", "archive"}:
1398
- call["result"] = status
1399
- found_skill = True
1400
- if not found_skill:
1401
- skill_calls.append({"skill": "harness-archive", "count": 1, "result": status})
1402
- summary["skillCalls"] = skill_calls
1403
-
1404
- write_json(summary_path, summary)
1405
- if render:
1406
- render_final_summary(work_dir, summary_path)
1407
1442
 
1408
1443
 
1409
1444
  def _changed_files_from_git(
@@ -1471,6 +1506,37 @@ def _review_summary(
1471
1506
  "yellowDeferred": 0,
1472
1507
  "summary": "",
1473
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
1474
1540
  if existing and isinstance(existing.get("reviewSummary"), dict):
1475
1541
  merged = dict(base)
1476
1542
  merged.update(existing["reviewSummary"])
@@ -1528,6 +1594,24 @@ def _business_goal_from_sources(change_dir: Path, events: list[dict[str, Any]])
1528
1594
  scope = re.search(r"(?im)^\s*>?\s*(?:变更范围|目标)\s*[::]\s*(.+)$", body)
1529
1595
  if scope:
1530
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]
1531
1615
  first_task = re.search(r"(?m)^\s*\|\s*1\s*\|\s*([^|]+?)\s*\|", body)
1532
1616
  if first_task:
1533
1617
  return first_task.group(1).strip()
@@ -1677,9 +1761,10 @@ def collect_summary_data(
1677
1761
 
1678
1762
  # verification
1679
1763
  if not for_replay or not isinstance(data.get("verification"), dict):
1680
- unit = _ledger_unit_tests(ledger)
1681
- api = _ledger_api_tests(ledger, change_dir=change_dir)
1682
- 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"]
1683
1768
  if not find_test_reports(change_dir) and unit.get("run", 0) == 0:
1684
1769
  if "status" not in unit:
1685
1770
  unit["status"] = "NOT_RUN"
@@ -1694,6 +1779,9 @@ def collect_summary_data(
1694
1779
  "dbCompatibility": db,
1695
1780
  "coverageDisplay": coverage,
1696
1781
  }
1782
+ for typed_key in ("apiContract", "browserE2E"):
1783
+ if typed_key in projection:
1784
+ data["verification"][typed_key] = projection[typed_key]
1697
1785
  else:
1698
1786
  # Ensure nested keys exist for old schema 2.1
1699
1787
  ver = data.setdefault("verification", {})
@@ -1720,7 +1808,7 @@ def collect_summary_data(
1720
1808
 
1721
1809
  # durations / skillCalls
1722
1810
  if events:
1723
- data["durations"] = _durations_from_event_phases(event_summary)
1811
+ data["durations"] = _durations_from_event_phases(event_summary, events)
1724
1812
  data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
1725
1813
  elif log_text and (not for_replay or not data.get("durations")):
1726
1814
  data["durations"] = _parse_durations_from_log(log_text)
@@ -1739,6 +1827,32 @@ def collect_summary_data(
1739
1827
  head = data.get("finalCommit")
1740
1828
  if base and head and base != NOT_AVAILABLE and head != NOT_AVAILABLE:
1741
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
+ )
1742
1856
  if changed:
1743
1857
  data["diffStat"] = diff_stat
1744
1858
  data["changedFiles"] = changed
@@ -1765,6 +1879,17 @@ def collect_summary_data(
1765
1879
  }
1766
1880
  data["changedFiles"] = []
1767
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
+
1768
1893
  # artifacts (build products stay empty unless already known; reportPipeline has event artifacts)
1769
1894
  if not isinstance(data.get("artifacts"), list):
1770
1895
  data["artifacts"] = []
@@ -1810,8 +1935,9 @@ def collect_summary_data(
1810
1935
  if note:
1811
1936
  maintenance_notes.append(note)
1812
1937
  data["maintenanceNotes"] = maintenance_notes
1813
- data["knownRisks"] = known_risks
1814
- data["manualActions"] = []
1938
+ scenario_risks, scenario_actions = _risks_from_test_results(change_dir)
1939
+ data["knownRisks"] = known_risks + scenario_risks
1940
+ data["manualActions"] = scenario_actions
1815
1941
  for name, value in (data.get("stageStatus") or {}).items():
1816
1942
  if value in {"BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA", "NOT_RUN", "USER_SKIPPED"}:
1817
1943
  data["manualActions"].append({
@@ -2085,6 +2211,288 @@ def render_final_summary(
2085
2211
  # ---------------------------------------------------------------------------
2086
2212
 
2087
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
+
2088
2496
  def validate_summary(
2089
2497
  summary: dict[str, Any],
2090
2498
  html_path: Path | None,
@@ -2199,7 +2607,12 @@ def validate_summary(
2199
2607
 
2200
2608
  # risks / manual actions — empty arrays are OK (placeholder)
2201
2609
  for risk in summary.get("knownRisks") or []:
2202
- 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)
2203
2616
  if text and text not in html and _html_escape(text) not in html:
2204
2617
  issues.append(
2205
2618
  {
@@ -2420,6 +2833,43 @@ def cmd_finalize(
2420
2833
  archive_root.mkdir(parents=True, exist_ok=True)
2421
2834
  archive_dir = archive_root / f"{today_date()}-{change_name}"
2422
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}")
2423
2873
 
2424
2874
  payload: dict[str, Any] = {
2425
2875
  "ok": False,
@@ -2454,7 +2904,7 @@ def cmd_finalize(
2454
2904
 
2455
2905
  # --- 0. cleanup transients (before before-manifest) ---
2456
2906
  try:
2457
- cleanup_result = _cleanup_transients(work_dir)
2907
+ cleanup_result = _cleanup_transients(split_state_dir or work_dir)
2458
2908
  payload["steps"]["cleanup"] = cleanup_result
2459
2909
  deleted_n = len(cleanup_result.get("deleted") or [])
2460
2910
  trunc_n = len(cleanup_result.get("truncated") or [])
@@ -2469,6 +2919,13 @@ def cmd_finalize(
2469
2919
  warnings.append(f"cleanup failed: {exc}")
2470
2920
  payload["steps"]["cleanup"] = {"ok": False, "error": str(exc)}
2471
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
+
2472
2929
  # --- 1. before-manifest ---
2473
2930
  before_path = work_dir / "evidence" / "archive-manifest-before.json"
2474
2931
  try:
@@ -2502,6 +2959,10 @@ def cmd_finalize(
2502
2959
  )
2503
2960
  return 1, payload
2504
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
+
2505
2966
  # --- 2. move ---
2506
2967
  try:
2507
2968
  archive_root.mkdir(parents=True, exist_ok=True)
@@ -2533,7 +2994,33 @@ def cmd_finalize(
2533
2994
  pass
2534
2995
  return 1, payload
2535
2996
 
2536
- # --- 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) ---
2537
3024
  try:
2538
3025
  summary = collect_summary_data(
2539
3026
  work_dir,
@@ -2549,51 +3036,42 @@ def cmd_finalize(
2549
3036
  reasons.append(reason)
2550
3037
  summary["finalStatusReasons"] = reasons
2551
3038
  write_json(summary_path, summary)
2552
- _safe_append(
2553
- phase="archive",
2554
- type_="decision",
2555
- note=reason,
2556
- )
2557
3039
  payload["steps"]["collect"] = {"ok": True, "path": str(summary_path)}
2558
- _safe_append(
2559
- phase="archive",
2560
- type_="artifact",
2561
- path="reports/final/summary-data.json",
2562
- kind="summary-data",
2563
- )
2564
3040
  except Exception as exc: # noqa: BLE001 — surface collect failures
2565
3041
  payload["error"] = f"collect failed: {exc}"
2566
3042
  payload["steps"]["collect"] = {"ok": False, "error": str(exc)}
2567
- _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
2568
3064
  return 1, payload
2569
3065
 
2570
- # --- 4. render (Node, else Python fallback) ---
3066
+ # --- 6. render (Node, else Python fallback) ---
2571
3067
  render_result = render_final_summary(work_dir, summary_path)
2572
3068
  payload["steps"]["render"] = render_result
2573
3069
  if not render_result.get("ok"):
2574
- # Node + fallback both failed: restore + exit non-0 (§4.1 rule 4)。
2575
3070
  # 永不关闭一个没有 final-summary 的归档。
2576
3071
  msg = str(render_result.get("fallbackReason") or "render failed")
2577
3072
  payload["error"] = f"final-summary render failed: {msg}"
2578
- _safe_append(
2579
- phase="archive",
2580
- type_="issue",
2581
- code="render-failed",
2582
- severity="error",
2583
- message=msg,
2584
- )
2585
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
2586
- _restore_target = original_change_dir if original_change_dir.is_dir() else work_dir
2587
- try:
2588
- append_event(
2589
- _restore_target,
2590
- phase="archive",
2591
- type_="phase.end",
2592
- status="FAIL",
2593
- note="finalize aborted; render failed; original preserved",
2594
- )
2595
- except OSError:
2596
- pass
3073
+ _restore_finalize_failure()
3074
+ _append_finalize_failure_issue(original_change_dir, "render-failed", msg)
2597
3075
  payload["warnings"] = warnings
2598
3076
  payload["ok"] = False
2599
3077
  return 1, payload
@@ -2603,24 +3081,10 @@ def cmd_finalize(
2603
3081
  f"node render unavailable; used python-fallback: "
2604
3082
  f"{render_result.get('fallbackReason')}"
2605
3083
  )
2606
- _safe_append(
2607
- phase="archive",
2608
- type_="artifact",
2609
- path="reports/final/final-summary.html",
2610
- kind="final-report",
2611
- )
2612
- _safe_append(
2613
- phase="archive",
2614
- type_="command",
2615
- command=f"render-final-summary ({renderer})",
2616
- exit_code=0,
2617
- note=f"final-summary rendered by {renderer}",
2618
- )
2619
3084
 
2620
3085
  html_path = work_dir / "reports" / "final" / "final-summary.html"
2621
3086
 
2622
- # --- 5. validate (same process, no re-collect) ---
2623
- # Refresh summary from disk (collect already wrote it); do not re-collect.
3087
+ # --- 7. renderer consistency (layer 2) ---
2624
3088
  try:
2625
3089
  summary = read_json(summary_path)
2626
3090
  except (OSError, json.JSONDecodeError):
@@ -2638,20 +3102,19 @@ def cmd_finalize(
2638
3102
  except OSError as exc:
2639
3103
  warnings.append(f"could not write validationIssues: {exc}")
2640
3104
 
2641
- for issue in validate_result.get("issues") or []:
2642
- _safe_append(
2643
- phase="archive",
2644
- type_="issue",
2645
- code=str(issue.get("code") or "validate"),
2646
- severity=str(issue.get("severity") or "warning"),
2647
- message=str(issue.get("message") or ""),
2648
- )
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)}
2649
3113
 
2650
- # --- 6. after-manifest + compare ---
3114
+ # --- 9. after-manifest + compare ---
2651
3115
  after_path = work_dir / "evidence" / "archive-manifest-after.json"
2652
3116
  try:
2653
3117
  after_manifest = generate_manifest(work_dir, after_path)
2654
- # Re-read before from archive (moved with the tree)
2655
3118
  before_in_archive = work_dir / "evidence" / "archive-manifest-before.json"
2656
3119
  if before_in_archive.is_file():
2657
3120
  before_manifest = read_json(before_in_archive)
@@ -2661,46 +3124,34 @@ def cmd_finalize(
2661
3124
  "path": str(after_path),
2662
3125
  "compare": compare_result,
2663
3126
  }
2664
- _safe_append(
2665
- phase="archive",
2666
- type_="artifact",
2667
- path="evidence/archive-manifest-after.json",
2668
- kind="manifest-after",
2669
- )
2670
3127
  except (OSError, json.JSONDecodeError, ValueError) as exc:
2671
3128
  payload["error"] = f"after-manifest failed: {exc}"
2672
3129
  payload["steps"]["after_manifest"] = {"ok": False, "error": str(exc)}
2673
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
3130
+ _restore_finalize_failure()
2674
3131
  return 1, payload
2675
3132
 
2676
- # 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)
2677
3135
  summary["archiveManifest"] = {
2678
3136
  "movedFiles": compare_result.get("movedFiles", 0),
2679
3137
  "generatedFiles": compare_result.get("generatedFiles", 0),
2680
3138
  "totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
2681
3139
  "checksumStatus": compare_result.get("checksumStatus", "FAIL"),
3140
+ **_manifest_self_stats(work_dir, after_manifest, after_path),
2682
3141
  }
2683
- try:
2684
- write_json(summary_path, summary)
2685
- except OSError as exc:
2686
- warnings.append(f"could not update archiveManifest: {exc}")
2687
-
2688
- # The first render is needed so the after-manifest includes a final report,
2689
- # but it predates the final manifest statistics. Render and validate once
2690
- # more from the now-final summary so HTML never contradicts summary-data.
3142
+ write_json(summary_path, summary)
2691
3143
  render_result = render_final_summary(work_dir, summary_path)
2692
3144
  if not render_result.get("ok"):
2693
3145
  payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
2694
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
3146
+ _restore_finalize_failure()
2695
3147
  return 1, payload
3148
+ summary = read_json(summary_path)
2696
3149
  validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
2697
3150
  payload["steps"]["validate"] = validate_result
2698
3151
  summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get("issues") or []
2699
3152
  write_json(summary_path, summary)
2700
3153
 
2701
- # --- 7. delete original only if validate+manifest OK ---
2702
- # After move, "original" is gone; on failure we restore. On success, ensure
2703
- # the changes path does not linger.
3154
+ # --- 11. closure: only when both validators and manifest pass ---
2704
3155
  validate_ok = bool(validate_result.get("ok"))
2705
3156
  manifest_ok = bool(compare_result.get("ok"))
2706
3157
  can_close = validate_ok and manifest_ok
@@ -2721,19 +3172,10 @@ def cmd_finalize(
2721
3172
  payload["issues"] = issues_out
2722
3173
  payload["error"] = "validate or manifest check failed; original change dir restored"
2723
3174
  payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
2724
- _restore_on_failure(archive_dir, original_change_dir, payload, warnings)
2725
- _safe_append_restored = original_change_dir if original_change_dir.is_dir() else None
2726
- if _safe_append_restored:
2727
- try:
2728
- append_event(
2729
- _safe_append_restored,
2730
- phase="archive",
2731
- type_="phase.end",
2732
- status="FAIL",
2733
- note="finalize aborted; original preserved",
2734
- )
2735
- except OSError:
2736
- pass
3175
+ _restore_finalize_failure()
3176
+ _append_finalize_failure_issue(
3177
+ original_change_dir, "closure-failed", payload["error"]
3178
+ )
2737
3179
  payload["warnings"] = warnings
2738
3180
  payload["ok"] = False
2739
3181
  return 1, payload
@@ -2753,16 +3195,15 @@ def cmd_finalize(
2753
3195
  "note": "removed by move",
2754
3196
  }
2755
3197
 
2756
- _safe_append(
2757
- phase="archive",
2758
- type_="verification",
2759
- name="archive-closure",
2760
- status="ok",
2761
- note="manifest+validate passed; original removed",
2762
- )
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)}
2763
3205
 
2764
- # --- 8. maintenance outbox + service (§8.2: close no longer runs the four
2765
- # knowledge subprocesses; it enqueues a pending outbox item and returns) ---
3206
+ # --- 12. maintenance outbox + service ---
2766
3207
  if skip_ingest:
2767
3208
  payload["steps"]["knowledge"] = {"skipped": True, "reason": "--skip-ingest"}
2768
3209
  payload["knowledgeMaintenance"] = "SKIPPED"
@@ -2781,74 +3222,6 @@ def cmd_finalize(
2781
3222
  if service_result.get("warning"):
2782
3223
  warnings.append(str(service_result["warning"]))
2783
3224
 
2784
- # --- 9a. archive-meta + artifact event BEFORE phase.end ---
2785
- # phase.end must remain the last archive event; patch (9b) still runs after
2786
- # phase.end so it can read duration/status from events.
2787
- try:
2788
- summary = read_json(summary_path)
2789
- meta_path = write_archive_meta(work_dir, summary)
2790
- payload["steps"]["archive_meta"] = {"ok": True, "path": str(meta_path)}
2791
- _safe_append(
2792
- phase="archive",
2793
- type_="artifact",
2794
- path="meta/archive-meta.md",
2795
- kind="archive-meta",
2796
- )
2797
- except Exception as exc: # noqa: BLE001 — meta soft-fail
2798
- warnings.append(f"archive-meta write failed: {exc}")
2799
- payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
2800
-
2801
- _safe_append(
2802
- phase="archive",
2803
- type_="phase.end",
2804
- status="WARN" if warnings else "OK",
2805
- note="finalize complete",
2806
- )
2807
-
2808
- # --- 9b. patch archive stage + remanifest + single render (no more events) ---
2809
- try:
2810
- _patch_archive_stage(summary_path, work_dir, render=False)
2811
- payload["steps"]["patch_archive"] = {"ok": True}
2812
- except Exception as exc: # noqa: BLE001 — patch must not roll back archive
2813
- warnings.append(f"archive stage patch failed: {exc}")
2814
- payload["steps"]["patch_archive"] = {"ok": False, "error": str(exc)}
2815
-
2816
- try:
2817
- summary = read_json(summary_path)
2818
- # Refresh archive-meta with patched stageStatus/durations (no new event).
2819
- meta_path = write_archive_meta(work_dir, summary)
2820
- payload["steps"]["archive_meta"] = {"ok": True, "path": str(meta_path)}
2821
- after_manifest = generate_manifest(work_dir, after_path)
2822
- before_in_archive = work_dir / "evidence" / "archive-manifest-before.json"
2823
- if before_in_archive.is_file():
2824
- before_manifest = read_json(before_in_archive)
2825
- if before_manifest is None:
2826
- raise ValueError(
2827
- "before-manifest unavailable for remanifest compare "
2828
- f"(missing {before_in_archive.as_posix()})"
2829
- )
2830
- compare_result = compare_manifests(before_manifest, after_manifest)
2831
- summary["archiveManifest"] = {
2832
- "movedFiles": compare_result.get("movedFiles", 0),
2833
- "generatedFiles": compare_result.get("generatedFiles", 0),
2834
- "totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
2835
- "checksumStatus": compare_result.get("checksumStatus", "FAIL"),
2836
- }
2837
- write_json(summary_path, summary)
2838
- render_final_summary(work_dir, summary_path)
2839
- payload["steps"]["after_manifest"] = {
2840
- "ok": True,
2841
- "path": str(after_path),
2842
- "compare": compare_result,
2843
- "includesArchiveMeta": True,
2844
- }
2845
- except Exception as exc: # noqa: BLE001 — meta/remanifest soft-fail
2846
- warnings.append(f"archive-meta/remanifest failed: {exc}")
2847
- payload["steps"]["archive_meta"] = {
2848
- "ok": False,
2849
- "error": str(exc),
2850
- }
2851
-
2852
3225
  payload["ok"] = True
2853
3226
  payload["warnings"] = warnings
2854
3227
  payload["summary_data"] = str(summary_path)
@@ -2856,6 +3229,17 @@ def cmd_finalize(
2856
3229
  return 0, payload
2857
3230
 
2858
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
+
2859
3243
  def _restore_on_failure(
2860
3244
  archive_dir: Path,
2861
3245
  original_change_dir: Path,
@@ -2981,6 +3365,144 @@ def cmd_replay_cli(args: argparse.Namespace) -> int:
2981
3365
  return code
2982
3366
 
2983
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
+
2984
3506
  def build_parser() -> argparse.ArgumentParser:
2985
3507
  parser = argparse.ArgumentParser(
2986
3508
  prog="harness_archive.py",
@@ -3016,6 +3538,13 @@ def build_parser() -> argparse.ArgumentParser:
3016
3538
  p_rep.add_argument("--json", action="store_true", default=True)
3017
3539
  p_rep.set_defaults(func=cmd_replay_cli)
3018
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
+
3019
3548
  return parser
3020
3549
 
3021
3550