@hunter-harness/workflow-harness 0.2.10 → 0.2.12

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 (250) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/ci-metrics.schema.json +36 -0
  3. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +30 -0
  4. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  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 +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -4
  10. package/harness/bundles/general/claude-code/harness-plan/checklist.md +6 -0
  11. package/harness/bundles/general/claude-code/harness-plan/reference.md +7 -2
  12. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-run/checklist.md +3 -2
  15. package/harness/bundles/general/claude-code/harness-run/reference.md +6 -4
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +282 -120
  20. package/harness/bundles/general/claude-code/scripts/harness_events.py +400 -24
  21. package/harness/bundles/general/claude-code/scripts/harness_gate.py +585 -21
  22. package/harness/bundles/general/claude-code/scripts/harness_integration.py +403 -18
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +225 -29
  24. package/harness/bundles/general/claude-code/scripts/harness_phase.py +926 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +361 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_profile.py +50 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +248 -0
  28. package/harness/bundles/general/claude-code/scripts/harness_state.py +15 -2
  29. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +188 -0
  30. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +19 -0
  31. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  32. package/harness/bundles/general/codebuddy/contracts/ci-metrics.schema.json +36 -0
  33. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +30 -0
  34. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  38. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
  40. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +6 -0
  41. package/harness/bundles/general/codebuddy/harness-plan/reference.md +7 -2
  42. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-run/checklist.md +3 -2
  45. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -4
  46. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +282 -120
  50. package/harness/bundles/general/codebuddy/scripts/harness_events.py +400 -24
  51. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +585 -21
  52. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +403 -18
  53. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +225 -29
  54. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +926 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +361 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +50 -0
  57. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +248 -0
  58. package/harness/bundles/general/codebuddy/scripts/harness_state.py +15 -2
  59. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +188 -0
  60. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +19 -0
  61. package/harness/bundles/general/codex/.harness-build.json +1 -1
  62. package/harness/bundles/general/codex/contracts/ci-metrics.schema.json +36 -0
  63. package/harness/bundles/general/codex/contracts/workflow-policy.json +30 -0
  64. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  68. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  69. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
  70. package/harness/bundles/general/codex/harness-plan/checklist.md +6 -0
  71. package/harness/bundles/general/codex/harness-plan/reference.md +7 -2
  72. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-run/checklist.md +3 -2
  75. package/harness/bundles/general/codex/harness-run/reference.md +6 -4
  76. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  77. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  78. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  79. package/harness/bundles/general/codex/scripts/harness_archive.py +282 -120
  80. package/harness/bundles/general/codex/scripts/harness_events.py +400 -24
  81. package/harness/bundles/general/codex/scripts/harness_gate.py +585 -21
  82. package/harness/bundles/general/codex/scripts/harness_integration.py +403 -18
  83. package/harness/bundles/general/codex/scripts/harness_ledger.py +225 -29
  84. package/harness/bundles/general/codex/scripts/harness_phase.py +926 -0
  85. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +361 -0
  86. package/harness/bundles/general/codex/scripts/harness_profile.py +50 -0
  87. package/harness/bundles/general/codex/scripts/harness_runtime.py +248 -0
  88. package/harness/bundles/general/codex/scripts/harness_state.py +15 -2
  89. package/harness/bundles/general/codex/scripts/harness_test_guard.py +188 -0
  90. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +19 -0
  91. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  92. package/harness/bundles/general/cursor/contracts/ci-metrics.schema.json +36 -0
  93. package/harness/bundles/general/cursor/contracts/workflow-policy.json +30 -0
  94. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  97. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  98. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  99. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
  100. package/harness/bundles/general/cursor/harness-plan/checklist.md +6 -0
  101. package/harness/bundles/general/cursor/harness-plan/reference.md +7 -2
  102. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  103. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  104. package/harness/bundles/general/cursor/harness-run/checklist.md +3 -2
  105. package/harness/bundles/general/cursor/harness-run/reference.md +6 -4
  106. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  107. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  108. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  109. package/harness/bundles/general/cursor/scripts/harness_archive.py +282 -120
  110. package/harness/bundles/general/cursor/scripts/harness_events.py +400 -24
  111. package/harness/bundles/general/cursor/scripts/harness_gate.py +585 -21
  112. package/harness/bundles/general/cursor/scripts/harness_integration.py +403 -18
  113. package/harness/bundles/general/cursor/scripts/harness_ledger.py +225 -29
  114. package/harness/bundles/general/cursor/scripts/harness_phase.py +926 -0
  115. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +361 -0
  116. package/harness/bundles/general/cursor/scripts/harness_profile.py +50 -0
  117. package/harness/bundles/general/cursor/scripts/harness_runtime.py +248 -0
  118. package/harness/bundles/general/cursor/scripts/harness_state.py +15 -2
  119. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +188 -0
  120. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +19 -0
  121. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  122. package/harness/bundles/java/claude-code/contracts/ci-metrics.schema.json +36 -0
  123. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +30 -0
  124. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  126. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  127. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  128. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  129. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  131. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
  132. package/harness/bundles/java/claude-code/harness-plan/checklist.md +6 -0
  133. package/harness/bundles/java/claude-code/harness-plan/reference.md +7 -2
  134. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  135. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  136. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  137. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  138. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  139. package/harness/bundles/java/claude-code/scripts/harness_archive.py +282 -120
  140. package/harness/bundles/java/claude-code/scripts/harness_events.py +400 -24
  141. package/harness/bundles/java/claude-code/scripts/harness_gate.py +585 -21
  142. package/harness/bundles/java/claude-code/scripts/harness_integration.py +403 -18
  143. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +225 -29
  144. package/harness/bundles/java/claude-code/scripts/harness_phase.py +926 -0
  145. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +361 -0
  146. package/harness/bundles/java/claude-code/scripts/harness_profile.py +50 -0
  147. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +248 -0
  148. package/harness/bundles/java/claude-code/scripts/harness_state.py +15 -2
  149. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +188 -0
  150. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +19 -0
  151. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  152. package/harness/bundles/java/codebuddy/contracts/ci-metrics.schema.json +36 -0
  153. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +30 -0
  154. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  155. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  156. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  157. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  158. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  159. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  160. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  161. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
  162. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +6 -0
  163. package/harness/bundles/java/codebuddy/harness-plan/reference.md +7 -2
  164. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  165. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  166. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  167. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  168. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  169. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +282 -120
  170. package/harness/bundles/java/codebuddy/scripts/harness_events.py +400 -24
  171. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +585 -21
  172. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +403 -18
  173. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +225 -29
  174. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +926 -0
  175. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +361 -0
  176. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +50 -0
  177. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +248 -0
  178. package/harness/bundles/java/codebuddy/scripts/harness_state.py +15 -2
  179. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +188 -0
  180. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +19 -0
  181. package/harness/bundles/java/codex/.harness-build.json +1 -1
  182. package/harness/bundles/java/codex/contracts/ci-metrics.schema.json +36 -0
  183. package/harness/bundles/java/codex/contracts/workflow-policy.json +30 -0
  184. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  185. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  186. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  187. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  188. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  189. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  190. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  191. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
  192. package/harness/bundles/java/codex/harness-plan/checklist.md +6 -0
  193. package/harness/bundles/java/codex/harness-plan/reference.md +7 -2
  194. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  195. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  196. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  197. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  198. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  199. package/harness/bundles/java/codex/scripts/harness_archive.py +282 -120
  200. package/harness/bundles/java/codex/scripts/harness_events.py +400 -24
  201. package/harness/bundles/java/codex/scripts/harness_gate.py +585 -21
  202. package/harness/bundles/java/codex/scripts/harness_integration.py +403 -18
  203. package/harness/bundles/java/codex/scripts/harness_ledger.py +225 -29
  204. package/harness/bundles/java/codex/scripts/harness_phase.py +926 -0
  205. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +361 -0
  206. package/harness/bundles/java/codex/scripts/harness_profile.py +50 -0
  207. package/harness/bundles/java/codex/scripts/harness_runtime.py +248 -0
  208. package/harness/bundles/java/codex/scripts/harness_state.py +15 -2
  209. package/harness/bundles/java/codex/scripts/harness_test_guard.py +188 -0
  210. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +19 -0
  211. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  212. package/harness/bundles/java/cursor/contracts/ci-metrics.schema.json +36 -0
  213. package/harness/bundles/java/cursor/contracts/workflow-policy.json +30 -0
  214. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  215. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  216. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  217. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  218. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +24 -2
  219. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  220. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  221. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
  222. package/harness/bundles/java/cursor/harness-plan/checklist.md +6 -0
  223. package/harness/bundles/java/cursor/harness-plan/reference.md +7 -2
  224. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  225. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  226. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  227. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  228. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  229. package/harness/bundles/java/cursor/scripts/harness_archive.py +282 -120
  230. package/harness/bundles/java/cursor/scripts/harness_events.py +400 -24
  231. package/harness/bundles/java/cursor/scripts/harness_gate.py +585 -21
  232. package/harness/bundles/java/cursor/scripts/harness_integration.py +403 -18
  233. package/harness/bundles/java/cursor/scripts/harness_ledger.py +225 -29
  234. package/harness/bundles/java/cursor/scripts/harness_phase.py +926 -0
  235. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +361 -0
  236. package/harness/bundles/java/cursor/scripts/harness_profile.py +50 -0
  237. package/harness/bundles/java/cursor/scripts/harness_runtime.py +248 -0
  238. package/harness/bundles/java/cursor/scripts/harness_state.py +15 -2
  239. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +188 -0
  240. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +19 -0
  241. package/harness/manifests/general/claude-code.json +43 -27
  242. package/harness/manifests/general/codebuddy.json +43 -27
  243. package/harness/manifests/general/codex.json +43 -27
  244. package/harness/manifests/general/cursor.json +43 -27
  245. package/harness/manifests/java/claude-code.json +43 -27
  246. package/harness/manifests/java/codebuddy.json +43 -27
  247. package/harness/manifests/java/codex.json +43 -27
  248. package/harness/manifests/java/cursor.json +43 -27
  249. package/hunter-workflow-family.json +2 -2
  250. package/package.json +1 -1
@@ -318,6 +318,19 @@ def load_ledger(change_dir: Path) -> dict[str, Any] | None:
318
318
  return None
319
319
 
320
320
 
321
+ def load_ci_metrics(change_dir: Path) -> tuple[dict[str, Any] | None, str | None]:
322
+ """Load schema-versioned runner metrics without parsing human CI logs."""
323
+ for relative in ("evidence/ci-metrics.json", "runtime/ci-metrics.json"):
324
+ path = change_dir / relative
325
+ if not path.is_file():
326
+ continue
327
+ value = read_json(path)
328
+ if value.get("schemaVersion") != 1:
329
+ raise ValueError(f"unsupported ci-metrics schema: {path}")
330
+ return value, relative
331
+ return None, None
332
+
333
+
321
334
  def load_execution_log(change_dir: Path) -> str:
322
335
  for rel in ("logs/execution-log.md", "execution-log.md"):
323
336
  path = change_dir / rel
@@ -433,6 +446,42 @@ def worktree_requested(change_dir: Path) -> bool:
433
446
  return False
434
447
 
435
448
 
449
+ def check_archive_exact_byte_policy(project_root: Path) -> dict[str, Any]:
450
+ """Verify frozen archive paths are exempt from Git text conversion."""
451
+ required_rule = ".harness/archive/** -text"
452
+ attributes_path = project_root / ".gitattributes"
453
+ rules: list[str] = []
454
+ if attributes_path.is_file():
455
+ try:
456
+ rules = [
457
+ line.strip()
458
+ for line in attributes_path.read_text(encoding="utf-8-sig").splitlines()
459
+ if line.strip() and not line.lstrip().startswith("#")
460
+ ]
461
+ except OSError:
462
+ rules = []
463
+ matching = []
464
+ for rule in rules:
465
+ fields = rule.split()
466
+ if not fields or fields[0] != ".harness/archive/**":
467
+ continue
468
+ matching.append(rule)
469
+ if "-text" in fields[1:] or "binary" in fields[1:]:
470
+ return {
471
+ "ok": True,
472
+ "path": str(attributes_path),
473
+ "requiredRule": required_rule,
474
+ "matchedRule": rule,
475
+ }
476
+ return {
477
+ "ok": False,
478
+ "path": str(attributes_path),
479
+ "requiredRule": required_rule,
480
+ "matchedRules": matching,
481
+ "remediation": f"add this exact-byte rule to .gitattributes: {required_rule}",
482
+ }
483
+
484
+
436
485
  # ---------------------------------------------------------------------------
437
486
  # status
438
487
  # ---------------------------------------------------------------------------
@@ -461,6 +510,16 @@ def check_status(
461
510
  project = find_project_root(change_dir)
462
511
  checks["project_root"] = str(project)
463
512
 
513
+ exact_byte = check_archive_exact_byte_policy(project)
514
+ checks["archive_exact_byte"] = exact_byte
515
+ if not exact_byte["ok"]:
516
+ warnings.append(
517
+ {
518
+ "code": "archive-exact-byte-policy-missing",
519
+ "message": str(exact_byte["remediation"]),
520
+ }
521
+ )
522
+
464
523
  # --- commit pushed ---
465
524
  code, out, err = git_run(project, "rev-parse", "--is-inside-work-tree")
466
525
  if code != 0:
@@ -676,25 +735,40 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
676
735
  status = str(unit.get("status") or "").upper()
677
736
 
678
737
  run = failures = errors = skipped = 0
738
+ passed_count: int | None = None
679
739
  pass_rate: Any = None
680
740
  source = "committed"
681
741
  counted = False
682
742
 
683
743
  metrics = unit.get("metrics")
684
744
  if isinstance(metrics, dict) and any(
685
- k in metrics for k in ("run", "testsRun", "total", "failures", "errors", "skipped")
745
+ k in metrics
746
+ for k in (
747
+ "run",
748
+ "testsRun",
749
+ "total",
750
+ "passed",
751
+ "failed",
752
+ "failures",
753
+ "errors",
754
+ "skipped",
755
+ )
686
756
  ):
687
757
  if "total" in metrics:
688
758
  # ledger v3 typed metrics (UT-005/RET-15): total/passed/failed.
689
759
  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:
760
+ elif "run" in metrics or "testsRun" in metrics:
694
761
  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)
762
+ else:
763
+ run = sum(
764
+ int(metrics.get(key, 0) or 0)
765
+ for key in ("passed", "failed", "errors", "skipped")
766
+ )
767
+ failures = int(metrics.get("failed", metrics.get("failures", 0)) or 0)
768
+ errors = int(metrics.get("errors", 0) or 0)
769
+ skipped = int(metrics.get("skipped", 0) or 0)
770
+ if "passed" in metrics:
771
+ passed_count = int(metrics.get("passed", 0) or 0)
698
772
  pass_rate = metrics.get("passRate")
699
773
  source = "committed"
700
774
  counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
@@ -741,7 +815,11 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
741
815
  source = "committed"
742
816
 
743
817
  if pass_rate is None and run > 0:
744
- passed = max(run - failures - errors, 0)
818
+ passed = (
819
+ passed_count
820
+ if passed_count is not None
821
+ else max(run - failures - errors - skipped, 0)
822
+ )
745
823
  pass_rate = f"{passed / run:.0%}"
746
824
  elif pass_rate is None:
747
825
  pass_rate = NOT_AVAILABLE
@@ -794,10 +872,10 @@ def _ledger_api_tests(
794
872
  if isinstance(metrics, dict) and any(
795
873
  k in metrics for k in ("total", "passed", "failed", "blocked")
796
874
  ):
797
- total = int(metrics.get("total", 0) or 0)
798
875
  passed = int(metrics.get("passed", 0) or 0)
799
876
  failed = int(metrics.get("failed", 0) or 0)
800
877
  blocked = int(metrics.get("blocked", 0) or 0)
878
+ total = int(metrics.get("total", passed + failed + blocked) or 0)
801
879
  pass_rate = metrics.get("passRate")
802
880
  source = "committed"
803
881
  counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
@@ -1211,7 +1289,8 @@ def _stage_status_from_sources(
1211
1289
  "submit": "OK",
1212
1290
  "archive": "OK",
1213
1291
  }
1214
- for event in events:
1292
+ projected_events = he.apply_event_corrections(events)
1293
+ for event in projected_events:
1215
1294
  if event.get("type") != "phase.end":
1216
1295
  continue
1217
1296
  phase = str(event.get("phase") or "").lower()
@@ -1226,9 +1305,7 @@ def _stage_status_from_sources(
1226
1305
  raw = "USER_SKIPPED"
1227
1306
  status[phase] = raw
1228
1307
  # Issues in events can downgrade
1229
- for e in events:
1230
- if e.get("type") != "issue":
1231
- continue
1308
+ for e in he.current_issues(events):
1232
1309
  sev = str(e.get("severity") or "").lower()
1233
1310
  issue_text = " ".join(str(e.get(key) or "") for key in ("message", "note", "code")).lower()
1234
1311
  if not sev:
@@ -1258,7 +1335,7 @@ def _stage_status_from_sources(
1258
1335
  if unit.get("source") == "not-run" and unit.get("run", 0) == 0:
1259
1336
  status["test"] = "NOT_RUN"
1260
1337
 
1261
- if not review_evidence_present(change_dir, events):
1338
+ if not review_evidence_present(change_dir, projected_events):
1262
1339
  status["review"] = "ADVISORY"
1263
1340
 
1264
1341
  return status
@@ -1665,6 +1742,17 @@ def collect_summary_data(
1665
1742
  for_replay: bool = False,
1666
1743
  ) -> dict[str, Any]:
1667
1744
  """Build schema 2.2 summary-data from events/ledger/log/manifest/reports."""
1745
+ if before_manifest is None:
1746
+ frozen_before = change_dir / "evidence" / "archive-manifest-before.json"
1747
+ if frozen_before.is_file():
1748
+ before_manifest = read_json(frozen_before)
1749
+ if after_manifest is None:
1750
+ frozen_after = change_dir / "evidence" / "archive-manifest-after.json"
1751
+ if frozen_after.is_file():
1752
+ after_manifest = read_json(frozen_after)
1753
+ if compare_result is None and before_manifest is not None and after_manifest is not None:
1754
+ compare_result = compare_manifests(before_manifest, after_manifest)
1755
+
1668
1756
  template = load_template()
1669
1757
  data = _deepcopy_json(template)
1670
1758
  # Clear placeholder strings from template
@@ -1682,11 +1770,16 @@ def collect_summary_data(
1682
1770
  sources.append("events.ndjson")
1683
1771
  except ValueError:
1684
1772
  events = []
1773
+ projected_events = he.apply_event_corrections(events) if events else []
1685
1774
 
1686
1775
  ledger = load_ledger(change_dir)
1687
1776
  if ledger:
1688
1777
  sources.append("evidence/verification-ledger.json")
1689
1778
 
1779
+ ci_metrics, ci_metrics_source = load_ci_metrics(change_dir)
1780
+ if ci_metrics_source:
1781
+ sources.append(ci_metrics_source)
1782
+
1690
1783
  log_text = load_execution_log(change_dir)
1691
1784
  if log_text:
1692
1785
  sources.append("logs/execution-log.md")
@@ -1733,13 +1826,13 @@ def collect_summary_data(
1733
1826
  if existing and existing.get("businessGoal"):
1734
1827
  data["businessGoal"] = existing["businessGoal"]
1735
1828
  else:
1736
- inferred_goal = _business_goal_from_sources(change_dir, events)
1829
+ inferred_goal = _business_goal_from_sources(change_dir, projected_events)
1737
1830
  data["businessGoal"] = inferred_goal or (NOT_AVAILABLE if for_replay else "")
1738
1831
 
1739
1832
  # commits
1740
1833
  project = find_project_root(change_dir)
1741
1834
  if not data.get("finalCommit") or str(data.get("finalCommit")).startswith("<"):
1742
- final_commit = _final_commit_from_sources(ledger, events, existing, project)
1835
+ final_commit = _final_commit_from_sources(ledger, projected_events, existing, project)
1743
1836
  data["finalCommit"] = final_commit or (NOT_AVAILABLE if for_replay else "")
1744
1837
 
1745
1838
  if not data.get("baseCommit") or str(data.get("baseCommit")).startswith("<"):
@@ -1782,6 +1875,8 @@ def collect_summary_data(
1782
1875
  for typed_key in ("apiContract", "browserE2E"):
1783
1876
  if typed_key in projection:
1784
1877
  data["verification"][typed_key] = projection[typed_key]
1878
+ if ci_metrics is not None:
1879
+ data["verification"]["ciMetrics"] = _deepcopy_json(ci_metrics)
1785
1880
  else:
1786
1881
  # Ensure nested keys exist for old schema 2.1
1787
1882
  ver = data.setdefault("verification", {})
@@ -1894,15 +1989,15 @@ def collect_summary_data(
1894
1989
  if not isinstance(data.get("artifacts"), list):
1895
1990
  data["artifacts"] = []
1896
1991
  if not for_replay:
1897
- data["artifacts"] = _artifacts_from_events(events)
1992
+ data["artifacts"] = _artifacts_from_events(projected_events)
1898
1993
 
1899
1994
  data["reviewSummary"] = _review_summary(
1900
1995
  change_dir,
1901
1996
  existing if for_replay else None,
1902
- events,
1997
+ projected_events,
1903
1998
  )
1904
1999
  if not for_replay:
1905
- data["timeline"] = _timeline_from_events(event_summary, events)
2000
+ data["timeline"] = _timeline_from_events(event_summary, projected_events)
1906
2001
  else:
1907
2002
  data.setdefault("timeline", [])
1908
2003
  data.setdefault("uncommittedTestEvidence", [])
@@ -1911,13 +2006,11 @@ def collect_summary_data(
1911
2006
  if not for_replay:
1912
2007
  maintenance_notes: list[str] = [
1913
2008
  str(event.get("note") or event.get("message") or "")
1914
- for event in events
2009
+ for event in projected_events
1915
2010
  if event.get("type") == "decision" and (event.get("note") or event.get("message"))
1916
2011
  ]
1917
2012
  known_risks: list[dict[str, Any]] = []
1918
- for event in events:
1919
- if event.get("type") != "issue":
1920
- continue
2013
+ for event in he.current_issues(events):
1921
2014
  if event.get("phase") == "archive" and event.get("code") == "missing-command":
1922
2015
  continue
1923
2016
  sev = str(event.get("severity") or "").strip().lower()
@@ -1973,12 +2066,12 @@ def collect_summary_data(
1973
2066
  data["archiveManifest"] = am
1974
2067
 
1975
2068
  # reportPipeline
1976
- commands = _commands_from_events(events)
2069
+ commands = _commands_from_events(projected_events)
1977
2070
  if not commands and for_replay:
1978
2071
  # Cannot invent commands
1979
2072
  pass
1980
- verification_checks = _verification_checks_from_events(events, ledger)
1981
- pipeline_artifacts = _artifacts_from_events(events)
2073
+ verification_checks = _verification_checks_from_events(projected_events, ledger)
2074
+ pipeline_artifacts = _artifacts_from_events(projected_events)
1982
2075
  if not sources:
1983
2076
  sources = [NOT_AVAILABLE]
1984
2077
 
@@ -2234,26 +2327,28 @@ def _manifest_self_stats(
2234
2327
  }
2235
2328
 
2236
2329
 
2237
- def _append_finalize_failure_issue(
2238
- original_change_dir: Path,
2239
- code: str,
2330
+ def _append_finalize_failure_terminal(
2331
+ authoritative_change_dir: Path,
2240
2332
  message: str,
2333
+ *,
2334
+ operation_id: str,
2241
2335
  ) -> 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():
2336
+ """Persist one failed finalize attempt on the authoritative change source."""
2337
+ if not authoritative_change_dir.is_dir():
2248
2338
  return
2249
2339
  try:
2250
2340
  append_event(
2251
- original_change_dir,
2341
+ authoritative_change_dir,
2252
2342
  phase="archive",
2253
- type_="issue",
2254
- code=code,
2255
- severity="error",
2256
- message=message,
2343
+ type_="phase.start",
2344
+ note=f"finalize operation {operation_id} failed before publish",
2345
+ )
2346
+ append_event(
2347
+ authoritative_change_dir,
2348
+ phase="archive",
2349
+ type_="phase.end",
2350
+ status="FAIL",
2351
+ note=f"finalize operation {operation_id} discarded: {message}",
2257
2352
  )
2258
2353
  except OSError:
2259
2354
  pass
@@ -2849,27 +2944,38 @@ def cmd_finalize(
2849
2944
  if resolved_state_dir.resolve() != original_change_dir.resolve()
2850
2945
  else None
2851
2946
  )
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
- )
2947
+ operation_id = f"archive-{uuid.uuid4().hex}"
2948
+ operation_root = project_root / ".harness" / "archive-operations"
2949
+ operation_temp_dir = operation_root / "staging" / operation_id / change_name
2950
+ operation_record = operation_root / f"{operation_id}.json"
2857
2951
 
2858
2952
  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}")
2953
+ shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
2954
+ payload["original_preserved"] = original_change_dir.is_dir()
2955
+ payload["finalStatus"] = "FAIL"
2956
+ _append_finalize_failure_terminal(
2957
+ split_state_dir
2958
+ if split_state_dir is not None and split_state_dir.is_dir()
2959
+ else original_change_dir,
2960
+ str(payload.get("error") or "archive finalize failed"),
2961
+ operation_id=operation_id,
2962
+ )
2963
+ try:
2964
+ write_json(
2965
+ operation_record,
2966
+ {
2967
+ "schemaVersion": 1,
2968
+ "operationId": operation_id,
2969
+ "changeName": change_name,
2970
+ "sourceDir": str(original_change_dir),
2971
+ "archiveDir": str(archive_dir),
2972
+ "finalStatus": "FAIL",
2973
+ "error": payload.get("error"),
2974
+ "finishedAt": now_iso(),
2975
+ },
2976
+ )
2977
+ except OSError as exc:
2978
+ warnings.append(f"could not persist failed archive operation: {exc}")
2873
2979
 
2874
2980
  payload: dict[str, Any] = {
2875
2981
  "ok": False,
@@ -2877,6 +2983,9 @@ def cmd_finalize(
2877
2983
  "change_dir": str(original_change_dir),
2878
2984
  "archive_dir": str(archive_dir),
2879
2985
  "change_name": change_name,
2986
+ "operationId": operation_id,
2987
+ "operationTempDir": str(operation_temp_dir),
2988
+ "operationRecord": str(operation_record),
2880
2989
  "warnings": warnings,
2881
2990
  "steps": {},
2882
2991
  }
@@ -2889,9 +2998,46 @@ def cmd_finalize(
2889
2998
  payload["error"] = f"archive target already exists: {archive_dir}"
2890
2999
  return 1, payload
2891
3000
 
2892
- work_dir = original_change_dir
3001
+ exact_byte = check_archive_exact_byte_policy(project_root)
3002
+ payload["steps"]["archive_exact_byte"] = exact_byte
3003
+ if not exact_byte["ok"]:
3004
+ warnings.append(str(exact_byte["remediation"]))
3005
+
3006
+ try:
3007
+ operation_temp_dir.parent.mkdir(parents=True, exist_ok=True)
3008
+ shutil.copytree(
3009
+ original_change_dir,
3010
+ operation_temp_dir,
3011
+ copy_function=shutil.copy2,
3012
+ )
3013
+ write_json(
3014
+ operation_record,
3015
+ {
3016
+ "schemaVersion": 1,
3017
+ "operationId": operation_id,
3018
+ "changeName": change_name,
3019
+ "sourceDir": str(original_change_dir),
3020
+ "archiveDir": str(archive_dir),
3021
+ "finalStatus": "RUNNING",
3022
+ "startedAt": now_iso(),
3023
+ },
3024
+ )
3025
+ except OSError as exc:
3026
+ shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
3027
+ payload["error"] = f"operation staging failed: {exc}"
3028
+ _restore_finalize_failure()
3029
+ return 1, payload
3030
+
3031
+ work_dir = operation_temp_dir
2893
3032
  before_manifest: dict[str, Any] | None = None
2894
3033
 
3034
+ if split_state_dir is not None and split_state_dir.is_dir():
3035
+ _merge_runtime_state(split_state_dir, work_dir)
3036
+ payload["steps"]["split_state_merge"] = {
3037
+ "ok": True,
3038
+ "stateDir": str(split_state_dir),
3039
+ }
3040
+
2895
3041
  def _safe_append(**kwargs: Any) -> None:
2896
3042
  nonlocal work_dir
2897
3043
  try:
@@ -2904,7 +3050,7 @@ def cmd_finalize(
2904
3050
 
2905
3051
  # --- 0. cleanup transients (before before-manifest) ---
2906
3052
  try:
2907
- cleanup_result = _cleanup_transients(split_state_dir or work_dir)
3053
+ cleanup_result = _cleanup_transients(work_dir)
2908
3054
  payload["steps"]["cleanup"] = cleanup_result
2909
3055
  deleted_n = len(cleanup_result.get("deleted") or [])
2910
3056
  trunc_n = len(cleanup_result.get("truncated") or [])
@@ -2919,13 +3065,6 @@ def cmd_finalize(
2919
3065
  warnings.append(f"cleanup failed: {exc}")
2920
3066
  payload["steps"]["cleanup"] = {"ok": False, "error": str(exc)}
2921
3067
 
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
-
2929
3068
  # --- 1. before-manifest ---
2930
3069
  before_path = work_dir / "evidence" / "archive-manifest-before.json"
2931
3070
  try:
@@ -2957,41 +3096,7 @@ def cmd_finalize(
2957
3096
  severity="error",
2958
3097
  message=str(exc),
2959
3098
  )
2960
- return 1, payload
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
-
2966
- # --- 2. move ---
2967
- try:
2968
- archive_root.mkdir(parents=True, exist_ok=True)
2969
- shutil.move(str(original_change_dir), str(archive_dir))
2970
- work_dir = archive_dir
2971
- payload["steps"]["move"] = {"ok": True, "to": str(archive_dir)}
2972
- _safe_append(
2973
- phase="archive",
2974
- type_="command",
2975
- command=f"move → {archive_dir.name}",
2976
- exit_code=0,
2977
- note="moved change dir to archive",
2978
- )
2979
- except OSError as exc:
2980
- # Move failed: original intact, stop immediately
2981
- payload["error"] = f"move failed: {exc}"
2982
- payload["steps"]["move"] = {"ok": False, "error": str(exc)}
2983
- payload["original_preserved"] = original_change_dir.is_dir()
2984
- try:
2985
- append_event(
2986
- original_change_dir,
2987
- phase="archive",
2988
- type_="issue",
2989
- code="move-failed",
2990
- severity="error",
2991
- message=str(exc),
2992
- )
2993
- except OSError:
2994
- pass
3099
+ _restore_finalize_failure()
2995
3100
  return 1, payload
2996
3101
 
2997
3102
  # --- 2b. user-flag decision (archive fact, must precede the cutoff) ---
@@ -3002,9 +3107,11 @@ def cmd_finalize(
3002
3107
  note="review missing on full tier (allowed by user)",
3003
3108
  )
3004
3109
 
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.
3110
+ # --- 3. candidate phase.end + freeze cutoff (RET-19 freeze-first) ---
3111
+ # This terminal exists only inside the isolated operation staging tree. It
3112
+ # becomes authoritative atomically at publish; validation failure discards
3113
+ # it and _restore_finalize_failure records the real FAIL attempt instead.
3114
+ # From here on, NO event may be appended to the staged events file.
3008
3115
  _safe_append(
3009
3116
  phase="archive",
3010
3117
  type_="phase.end",
@@ -3056,9 +3163,8 @@ def cmd_finalize(
3056
3163
  warnings.append(f"could not write sourceConsistency: {exc}")
3057
3164
  if not source_result.get("ok"):
3058
3165
  payload["issues"] = source_result.get("issues") or []
3059
- payload["error"] = "source consistency failed; original change dir restored"
3166
+ payload["error"] = "source consistency failed; staged operation discarded"
3060
3167
  _restore_finalize_failure()
3061
- _append_finalize_failure_issue(original_change_dir, "source-consistency-failed", payload["error"])
3062
3168
  payload["warnings"] = warnings
3063
3169
  payload["ok"] = False
3064
3170
  return 1, payload
@@ -3071,7 +3177,6 @@ def cmd_finalize(
3071
3177
  msg = str(render_result.get("fallbackReason") or "render failed")
3072
3178
  payload["error"] = f"final-summary render failed: {msg}"
3073
3179
  _restore_finalize_failure()
3074
- _append_finalize_failure_issue(original_change_dir, "render-failed", msg)
3075
3180
  payload["warnings"] = warnings
3076
3181
  payload["ok"] = False
3077
3182
  return 1, payload
@@ -3170,17 +3275,30 @@ def cmd_finalize(
3170
3275
  }
3171
3276
  )
3172
3277
  payload["issues"] = issues_out
3173
- payload["error"] = "validate or manifest check failed; original change dir restored"
3278
+ payload["error"] = "validate or manifest check failed; staged operation discarded"
3174
3279
  payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
3175
3280
  _restore_finalize_failure()
3176
- _append_finalize_failure_issue(
3177
- original_change_dir, "closure-failed", payload["error"]
3178
- )
3179
3281
  payload["warnings"] = warnings
3180
3282
  payload["ok"] = False
3181
3283
  return 1, payload
3182
3284
 
3183
- # Success path: original already relocated; confirm absence
3285
+ # Publish only after every validator passes. The archive path is never used
3286
+ # as mutable staging, so a failed attempt cannot poison the next retry.
3287
+ try:
3288
+ archive_root.mkdir(parents=True, exist_ok=True)
3289
+ shutil.move(str(operation_temp_dir), str(archive_dir))
3290
+ shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
3291
+ work_dir = archive_dir
3292
+ summary_path = work_dir / "reports" / "final" / "summary-data.json"
3293
+ html_path = work_dir / "reports" / "final" / "final-summary.html"
3294
+ payload["steps"]["move"] = {"ok": True, "to": str(archive_dir)}
3295
+ except OSError as exc:
3296
+ payload["error"] = f"publish failed: {exc}"
3297
+ payload["steps"]["move"] = {"ok": False, "error": str(exc)}
3298
+ _restore_finalize_failure()
3299
+ return 1, payload
3300
+
3301
+ # Success path: the validated copy is published; now retire its source.
3184
3302
  if original_change_dir.exists():
3185
3303
  try:
3186
3304
  shutil.rmtree(original_change_dir)
@@ -3192,7 +3310,7 @@ def cmd_finalize(
3192
3310
  payload["steps"]["delete_original"] = {
3193
3311
  "ok": True,
3194
3312
  "deleted": True,
3195
- "note": "removed by move",
3313
+ "note": "source already absent",
3196
3314
  }
3197
3315
 
3198
3316
  if split_state_dir is not None and split_state_dir.is_dir():
@@ -3222,7 +3340,28 @@ def cmd_finalize(
3222
3340
  if service_result.get("warning"):
3223
3341
  warnings.append(str(service_result["warning"]))
3224
3342
 
3343
+ try:
3344
+ write_json(
3345
+ operation_record,
3346
+ {
3347
+ "schemaVersion": 1,
3348
+ "operationId": operation_id,
3349
+ "changeName": change_name,
3350
+ "sourceDir": str(original_change_dir),
3351
+ "archiveDir": str(archive_dir),
3352
+ "finalStatus": "OK",
3353
+ "publishedAt": now_iso(),
3354
+ "summarySha256": sha256_file(summary_path),
3355
+ "manifestSha256": sha256_file(
3356
+ archive_dir / "evidence" / "archive-manifest-after.json"
3357
+ ),
3358
+ },
3359
+ )
3360
+ except OSError as exc:
3361
+ warnings.append(f"could not persist completed archive operation: {exc}")
3362
+
3225
3363
  payload["ok"] = True
3364
+ payload["finalStatus"] = "OK"
3226
3365
  payload["warnings"] = warnings
3227
3366
  payload["summary_data"] = str(summary_path)
3228
3367
  payload["final_summary"] = str(html_path) if html_path.is_file() else None
@@ -3422,6 +3561,21 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
3422
3561
  if not summary_path.is_file():
3423
3562
  payload["error"] = "summary-data.json missing; cannot repair"
3424
3563
  return 1, payload
3564
+ frozen_manifest_paths = {
3565
+ name: archive_dir / "evidence" / name
3566
+ for name in (
3567
+ "archive-manifest-before.json",
3568
+ "archive-manifest-after.json",
3569
+ )
3570
+ }
3571
+ missing_manifests = [
3572
+ name for name, path in frozen_manifest_paths.items() if not path.is_file()
3573
+ ]
3574
+ if missing_manifests:
3575
+ payload["error"] = (
3576
+ "frozen manifests missing; cannot repair: " + ", ".join(missing_manifests)
3577
+ )
3578
+ return 1, payload
3425
3579
 
3426
3580
  # 1. candidate: fresh collect from frozen sources (read-only on archive).
3427
3581
  try:
@@ -3462,22 +3616,30 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
3462
3616
  ]
3463
3617
  version = f"v{(max(existing) + 1) if existing else 1}"
3464
3618
  version_dir = derived / version
3465
- version_dir.mkdir()
3466
- final_summary = version_dir / "summary-data.json"
3619
+ staged_version_dir = staging / version
3620
+ staged_version_dir.mkdir()
3621
+ final_summary = staged_version_dir / "summary-data.json"
3467
3622
  write_json(final_summary, candidate)
3623
+ frozen_manifest_hashes: dict[str, str] = {}
3624
+ for manifest_name, source_manifest in frozen_manifest_paths.items():
3625
+ target_manifest = staged_version_dir / manifest_name
3626
+ shutil.copy2(source_manifest, target_manifest)
3627
+ frozen_manifest_hashes[manifest_name] = "sha256:" + sha256_file(target_manifest)
3468
3628
  if staged_html.is_file():
3469
- shutil.copy2(staged_html, version_dir / "final-summary.html")
3629
+ shutil.copy2(staged_html, staged_version_dir / "final-summary.html")
3470
3630
  record = {
3471
3631
  "version": version,
3472
3632
  "createdAt": now_iso(),
3473
3633
  "summarySha256": "sha256:" + hashlib.sha256(final_summary.read_bytes()).hexdigest(),
3474
3634
  "baseSummarySha256": "sha256:" + hashlib.sha256(summary_path.read_bytes()).hexdigest(),
3635
+ "frozenManifestHashes": frozen_manifest_hashes,
3475
3636
  "validators": {
3476
3637
  "source": {"ok": bool(source_result.get("ok")), "issues": source_result.get("issues") or []},
3477
3638
  "renderer": {"ok": bool(renderer_result.get("ok")), "issues": renderer_result.get("issues") or []},
3478
3639
  },
3479
3640
  }
3480
- write_json(version_dir / "repair-record.json", record)
3641
+ write_json(staged_version_dir / "repair-record.json", record)
3642
+ shutil.move(str(staged_version_dir), str(version_dir))
3481
3643
 
3482
3644
  # 4. authoritative pointer — only after both validators passed.
3483
3645
  write_json(