@hunter-harness/workflow-harness 0.2.8 → 0.2.9

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 (222) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +3 -3
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -3
  4. package/harness/bundles/general/claude-code/harness-archive/reference.md +18 -10
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +5 -1
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  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 +1 -1
  10. package/harness/bundles/general/claude-code/harness-plan/checklist.md +6 -0
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/checklist.md +5 -0
  14. package/harness/bundles/general/claude-code/harness-run/reference.md +31 -1
  15. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-test/checklist.md +9 -0
  19. package/harness/bundles/general/claude-code/harness-test/pitfalls-java.md +14 -0
  20. package/harness/bundles/general/claude-code/harness-test/reference.md +36 -0
  21. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +3 -1
  22. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +2 -0
  23. package/harness/bundles/general/claude-code/protocols/powershell-protocol.md +16 -0
  24. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +17 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_archive.py +568 -58
  26. package/harness/bundles/general/claude-code/scripts/harness_events.py +70 -19
  27. package/harness/bundles/general/claude-code/scripts/harness_gate.py +178 -25
  28. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +17 -0
  29. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  30. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +3 -3
  31. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -3
  32. package/harness/bundles/general/codebuddy/harness-archive/reference.md +18 -10
  33. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +5 -1
  34. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +6 -0
  39. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-run/checklist.md +5 -0
  42. package/harness/bundles/general/codebuddy/harness-run/reference.md +31 -1
  43. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  45. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-test/checklist.md +9 -0
  47. package/harness/bundles/general/codebuddy/harness-test/pitfalls-java.md +14 -0
  48. package/harness/bundles/general/codebuddy/harness-test/reference.md +36 -0
  49. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +3 -1
  50. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +2 -0
  51. package/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +16 -0
  52. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +17 -0
  53. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +568 -58
  54. package/harness/bundles/general/codebuddy/scripts/harness_events.py +70 -19
  55. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +178 -25
  56. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +17 -0
  57. package/harness/bundles/general/codex/.harness-build.json +1 -1
  58. package/harness/bundles/general/codex/contracts/workflow-policy.json +3 -3
  59. package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -3
  60. package/harness/bundles/general/codex/harness-archive/reference.md +18 -10
  61. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +5 -1
  62. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-plan/checklist.md +6 -0
  67. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  69. package/harness/bundles/general/codex/harness-run/checklist.md +5 -0
  70. package/harness/bundles/general/codex/harness-run/reference.md +31 -1
  71. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  72. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-test/checklist.md +9 -0
  75. package/harness/bundles/general/codex/harness-test/pitfalls-java.md +14 -0
  76. package/harness/bundles/general/codex/harness-test/reference.md +36 -0
  77. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +3 -1
  78. package/harness/bundles/general/codex/protocols/ledger-protocol.md +2 -0
  79. package/harness/bundles/general/codex/protocols/powershell-protocol.md +16 -0
  80. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +17 -0
  81. package/harness/bundles/general/codex/scripts/harness_archive.py +568 -58
  82. package/harness/bundles/general/codex/scripts/harness_events.py +70 -19
  83. package/harness/bundles/general/codex/scripts/harness_gate.py +178 -25
  84. package/harness/bundles/general/codex/scripts/harness_ledger.py +17 -0
  85. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  86. package/harness/bundles/general/cursor/contracts/workflow-policy.json +3 -3
  87. package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -3
  88. package/harness/bundles/general/cursor/harness-archive/reference.md +18 -10
  89. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +5 -1
  90. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  91. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  94. package/harness/bundles/general/cursor/harness-plan/checklist.md +6 -0
  95. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  97. package/harness/bundles/general/cursor/harness-run/checklist.md +5 -0
  98. package/harness/bundles/general/cursor/harness-run/reference.md +31 -1
  99. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  100. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  101. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  102. package/harness/bundles/general/cursor/harness-test/checklist.md +9 -0
  103. package/harness/bundles/general/cursor/harness-test/pitfalls-java.md +14 -0
  104. package/harness/bundles/general/cursor/harness-test/reference.md +36 -0
  105. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +3 -1
  106. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +2 -0
  107. package/harness/bundles/general/cursor/protocols/powershell-protocol.md +16 -0
  108. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +17 -0
  109. package/harness/bundles/general/cursor/scripts/harness_archive.py +568 -58
  110. package/harness/bundles/general/cursor/scripts/harness_events.py +70 -19
  111. package/harness/bundles/general/cursor/scripts/harness_gate.py +178 -25
  112. package/harness/bundles/general/cursor/scripts/harness_ledger.py +17 -0
  113. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  114. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +3 -3
  115. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -3
  117. package/harness/bundles/java/claude-code/harness-archive/reference.md +18 -10
  118. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +5 -1
  119. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  120. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-plan/checklist.md +6 -0
  125. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  126. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  127. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  128. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  129. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +3 -1
  131. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +2 -0
  132. package/harness/bundles/java/claude-code/protocols/powershell-protocol.md +16 -0
  133. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +17 -0
  134. package/harness/bundles/java/claude-code/scripts/harness_archive.py +568 -58
  135. package/harness/bundles/java/claude-code/scripts/harness_events.py +70 -19
  136. package/harness/bundles/java/claude-code/scripts/harness_gate.py +178 -25
  137. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +17 -0
  138. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  139. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +3 -3
  140. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  141. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -3
  142. package/harness/bundles/java/codebuddy/harness-archive/reference.md +18 -10
  143. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +5 -1
  144. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  145. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  147. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  148. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +6 -0
  150. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  153. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  154. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  155. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +3 -1
  156. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +2 -0
  157. package/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +16 -0
  158. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +17 -0
  159. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +568 -58
  160. package/harness/bundles/java/codebuddy/scripts/harness_events.py +70 -19
  161. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +178 -25
  162. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +17 -0
  163. package/harness/bundles/java/codex/.harness-build.json +1 -1
  164. package/harness/bundles/java/codex/contracts/workflow-policy.json +3 -3
  165. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  166. package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -3
  167. package/harness/bundles/java/codex/harness-archive/reference.md +18 -10
  168. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +5 -1
  169. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  170. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  171. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  173. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  174. package/harness/bundles/java/codex/harness-plan/checklist.md +6 -0
  175. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  176. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  180. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +3 -1
  181. package/harness/bundles/java/codex/protocols/ledger-protocol.md +2 -0
  182. package/harness/bundles/java/codex/protocols/powershell-protocol.md +16 -0
  183. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +17 -0
  184. package/harness/bundles/java/codex/scripts/harness_archive.py +568 -58
  185. package/harness/bundles/java/codex/scripts/harness_events.py +70 -19
  186. package/harness/bundles/java/codex/scripts/harness_gate.py +178 -25
  187. package/harness/bundles/java/codex/scripts/harness_ledger.py +17 -0
  188. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  189. package/harness/bundles/java/cursor/contracts/workflow-policy.json +3 -3
  190. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  191. package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -3
  192. package/harness/bundles/java/cursor/harness-archive/reference.md +18 -10
  193. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +5 -1
  194. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  195. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  196. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  197. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  198. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  199. package/harness/bundles/java/cursor/harness-plan/checklist.md +6 -0
  200. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  201. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  202. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  203. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  204. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  205. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +3 -1
  206. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +2 -0
  207. package/harness/bundles/java/cursor/protocols/powershell-protocol.md +16 -0
  208. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +17 -0
  209. package/harness/bundles/java/cursor/scripts/harness_archive.py +568 -58
  210. package/harness/bundles/java/cursor/scripts/harness_events.py +70 -19
  211. package/harness/bundles/java/cursor/scripts/harness_gate.py +178 -25
  212. package/harness/bundles/java/cursor/scripts/harness_ledger.py +17 -0
  213. package/harness/manifests/general/claude-code.json +32 -28
  214. package/harness/manifests/general/codebuddy.json +32 -28
  215. package/harness/manifests/general/codex.json +32 -28
  216. package/harness/manifests/general/cursor.json +32 -28
  217. package/harness/manifests/java/claude-code.json +26 -26
  218. package/harness/manifests/java/codebuddy.json +26 -26
  219. package/harness/manifests/java/codex.json +26 -26
  220. package/harness/manifests/java/cursor.json +26 -26
  221. package/hunter-workflow-family.json +2 -2
  222. package/package.json +1 -1
@@ -57,6 +57,12 @@ MANIFEST_COMPARE_EXCLUDE = frozenset(
57
57
  SCHEMA_VERSION = "2.2"
58
58
  NOT_AVAILABLE = "not_available"
59
59
 
60
+ # Compiled once for evidence-text count fallbacks in _ledger_unit_tests / _ledger_api_tests.
61
+ _RE_UNIT_COUNTS = re.compile(
62
+ r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)"
63
+ )
64
+ _RE_API_PASSED = re.compile(r"(\d+)/(\d+)\s*passed", re.I)
65
+
60
66
  # Ensure sibling harness_events is importable.
61
67
  if str(SCRIPTS_DIR) not in sys.path:
62
68
  sys.path.insert(0, str(SCRIPTS_DIR))
@@ -429,7 +435,11 @@ def worktree_requested(change_dir: Path) -> bool:
429
435
  # ---------------------------------------------------------------------------
430
436
 
431
437
 
432
- def check_status(change_dir: Path) -> dict[str, Any]:
438
+ def check_status(
439
+ change_dir: Path,
440
+ *,
441
+ allow_missing_review: bool = False,
442
+ ) -> dict[str, Any]:
433
443
  """Read-only archive preconditions. Never mutates."""
434
444
  blockers: list[dict[str, str]] = []
435
445
  warnings: list[dict[str, str]] = []
@@ -574,6 +584,10 @@ def check_status(change_dir: Path) -> dict[str, Any]:
574
584
  }
575
585
  )
576
586
 
587
+ gate_policy = load_gate_policy(change_dir)
588
+ risk_tier = str((gate_policy or {}).get("tier") or "unknown")
589
+ checks["riskTier"] = risk_tier
590
+
577
591
  if review_reports:
578
592
  checks["review_report_status"] = "present"
579
593
  elif review_phase_completed(events):
@@ -589,12 +603,23 @@ def check_status(change_dir: Path) -> dict[str, Any]:
589
603
  )
590
604
  else:
591
605
  checks["review_report_status"] = "not-run"
592
- warnings.append(
593
- {
594
- "code": "review-not-run",
595
- "message": "no review report; mark reviewSummary as ADVISORY_NOT_RUN",
606
+ review_msg = {
607
+ "code": "review-not-run",
608
+ "message": "no review report; mark reviewSummary as ADVISORY_NOT_RUN",
609
+ }
610
+ warnings.append(review_msg)
611
+ if risk_tier == "full" and not review_ran:
612
+ tier_issue = {
613
+ "code": "review-required-on-full-tier",
614
+ "message": (
615
+ "gate-policy tier=full requires review evidence; "
616
+ "pass --allow-missing-review to override"
617
+ ),
596
618
  }
597
- )
619
+ if allow_missing_review:
620
+ warnings.append(tier_issue)
621
+ else:
622
+ blockers.append(tier_issue)
598
623
 
599
624
  archivable = len(blockers) == 0
600
625
  return {
@@ -637,33 +662,80 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
637
662
  if not ledger:
638
663
  return empty
639
664
  validations = ledger.get("validations") or ledger.get("verification") or {}
640
- unit = validations.get("unitTest") or validations.get("unitTests") or {}
665
+ unit = (
666
+ validations.get("unitTestFull")
667
+ or validations.get("unitTest")
668
+ or validations.get("unitTests")
669
+ or {}
670
+ )
641
671
  if not isinstance(unit, dict):
642
672
  return empty
643
673
  status = str(unit.get("status") or "").upper()
644
- evidence = unit.get("evidence") or {}
645
- if isinstance(evidence, dict):
646
- run = evidence.get(
647
- "run",
648
- evidence.get("testsRun", unit.get("run", unit.get("testsRun", 0))),
674
+
675
+ run = failures = errors = skipped = 0
676
+ pass_rate: Any = None
677
+ source = "committed"
678
+ counted = False
679
+
680
+ metrics = unit.get("metrics")
681
+ if isinstance(metrics, dict) and any(
682
+ k in metrics for k in ("run", "testsRun", "failures", "errors", "skipped")
683
+ ):
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)
688
+ pass_rate = metrics.get("passRate")
689
+ source = "committed"
690
+ counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
691
+
692
+ evidence = unit.get("evidence")
693
+ if not counted and isinstance(evidence, dict):
694
+ run = int(
695
+ evidence.get(
696
+ "run",
697
+ evidence.get("testsRun", unit.get("run", unit.get("testsRun", 0))),
698
+ )
699
+ or 0
649
700
  )
650
- failures = evidence.get("failures", unit.get("failures", 0))
651
- errors = evidence.get("errors", unit.get("errors", 0))
652
- skipped = evidence.get("skipped", unit.get("skipped", 0))
701
+ failures = int(evidence.get("failures", unit.get("failures", 0)) or 0)
702
+ errors = int(evidence.get("errors", unit.get("errors", 0)) or 0)
703
+ skipped = int(evidence.get("skipped", unit.get("skipped", 0)) or 0)
653
704
  pass_rate = evidence.get("passRate") or unit.get("passRate")
654
- else:
655
- run = unit.get("run", unit.get("testsRun", 0))
656
- failures = unit.get("failures", 0)
657
- errors = unit.get("errors", 0)
658
- skipped = unit.get("skipped", 0)
705
+ source = "committed"
706
+ counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
707
+
708
+ evidence_text = evidence if isinstance(evidence, str) else ""
709
+ if not counted and evidence_text:
710
+ matches = list(_RE_UNIT_COUNTS.finditer(evidence_text))
711
+ if matches:
712
+ m = matches[-1]
713
+ run = int(m.group(1))
714
+ failures = int(m.group(2))
715
+ errors = int(m.group(3))
716
+ skipped = int(m.group(4))
717
+ source = "evidence-text"
718
+ counted = True
719
+
720
+ if not counted:
721
+ run = int(unit.get("run", unit.get("testsRun", 0)) or 0)
722
+ failures = int(unit.get("failures", 0) or 0)
723
+ errors = int(unit.get("errors", 0) or 0)
724
+ skipped = int(unit.get("skipped", 0) or 0)
659
725
  pass_rate = unit.get("passRate")
726
+ source = "committed"
660
727
 
661
- source = "committed"
662
728
  if status in {"NOT_RUN", "SKIPPED", "USER_SKIPPED"}:
663
729
  source = "not-run"
664
730
  elif str(unit.get("reused") or "").lower() in {"true", "1"} or "REUSED" in status:
665
731
  source = "committed"
666
732
 
733
+ if pass_rate is None and run > 0:
734
+ passed = max(run - failures - errors, 0)
735
+ pass_rate = f"{passed / run:.0%}"
736
+ elif pass_rate is None:
737
+ pass_rate = NOT_AVAILABLE
738
+
667
739
  result = {
668
740
  "run": int(run or 0),
669
741
  "failures": int(failures or 0),
@@ -677,7 +749,11 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
677
749
  return result
678
750
 
679
751
 
680
- def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
752
+ def _ledger_api_tests(
753
+ ledger: dict[str, Any] | None,
754
+ *,
755
+ change_dir: Path | None = None,
756
+ ) -> dict[str, Any]:
681
757
  empty = {
682
758
  "status": "NOT_RUN",
683
759
  "total": 0,
@@ -685,6 +761,7 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
685
761
  "failed": 0,
686
762
  "blocked": 0,
687
763
  "passRate": NOT_AVAILABLE,
764
+ "source": "not-run",
688
765
  }
689
766
  if not ledger:
690
767
  return empty
@@ -697,12 +774,76 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
697
774
  status = "OK"
698
775
  elif status in {"SKIP", "SKIPPED"}:
699
776
  status = "USER_SKIPPED"
700
- evidence = api.get("evidence") if isinstance(api.get("evidence"), dict) else {}
701
- total = evidence.get("total", api.get("total", 0))
702
- passed = evidence.get("passed", api.get("passed", 0))
703
- failed = evidence.get("failed", api.get("failed", 0))
704
- blocked = evidence.get("blocked", api.get("blocked", 0))
705
- pass_rate = evidence.get("passRate") or api.get("passRate")
777
+
778
+ total = passed = failed = blocked = 0
779
+ pass_rate: Any = None
780
+ source = "committed"
781
+ counted = False
782
+
783
+ metrics = api.get("metrics")
784
+ if isinstance(metrics, dict) and any(
785
+ k in metrics for k in ("total", "passed", "failed", "blocked")
786
+ ):
787
+ total = int(metrics.get("total", 0) or 0)
788
+ passed = int(metrics.get("passed", 0) or 0)
789
+ failed = int(metrics.get("failed", 0) or 0)
790
+ blocked = int(metrics.get("blocked", 0) or 0)
791
+ pass_rate = metrics.get("passRate")
792
+ source = "committed"
793
+ counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
794
+
795
+ evidence_raw = api.get("evidence")
796
+ if not counted and isinstance(evidence_raw, dict):
797
+ total = int(evidence_raw.get("total", api.get("total", 0)) or 0)
798
+ passed = int(evidence_raw.get("passed", api.get("passed", 0)) or 0)
799
+ failed = int(evidence_raw.get("failed", api.get("failed", 0)) or 0)
800
+ blocked = int(evidence_raw.get("blocked", api.get("blocked", 0)) or 0)
801
+ pass_rate = evidence_raw.get("passRate") or api.get("passRate")
802
+ source = "committed"
803
+ counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
804
+
805
+ evidence_text = evidence_raw if isinstance(evidence_raw, str) else ""
806
+ if not counted and evidence_text:
807
+ matches = list(_RE_API_PASSED.finditer(evidence_text))
808
+ if matches:
809
+ m = matches[-1]
810
+ passed = int(m.group(1))
811
+ total = int(m.group(2))
812
+ failed = max(total - passed, 0)
813
+ blocked = 0
814
+ source = "evidence-text"
815
+ counted = True
816
+
817
+ if not counted and change_dir is not None:
818
+ results_path = change_dir / "runtime" / "api-test-results.json"
819
+ if results_path.is_file():
820
+ try:
821
+ raw = read_json(results_path)
822
+ except (OSError, json.JSONDecodeError):
823
+ raw = None
824
+ if isinstance(raw, dict) and all(
825
+ isinstance(raw.get(k), int) for k in ("total", "passed", "failed", "blocked")
826
+ ):
827
+ total = int(raw["total"])
828
+ passed = int(raw["passed"])
829
+ failed = int(raw["failed"])
830
+ blocked = int(raw["blocked"])
831
+ source = "api-test-results"
832
+ counted = True
833
+
834
+ if not counted:
835
+ total = int(api.get("total", 0) or 0)
836
+ passed = int(api.get("passed", 0) or 0)
837
+ failed = int(api.get("failed", 0) or 0)
838
+ blocked = int(api.get("blocked", 0) or 0)
839
+ pass_rate = api.get("passRate")
840
+ source = "committed"
841
+
842
+ if pass_rate is None and total > 0:
843
+ pass_rate = f"{passed / total:.0%}"
844
+ elif pass_rate is None:
845
+ pass_rate = NOT_AVAILABLE
846
+
706
847
  return {
707
848
  "status": status,
708
849
  "total": int(total or 0),
@@ -710,6 +851,7 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
710
851
  "failed": int(failed or 0),
711
852
  "blocked": int(blocked or 0),
712
853
  "passRate": pass_rate if pass_rate is not None else NOT_AVAILABLE,
854
+ "source": source,
713
855
  }
714
856
 
715
857
 
@@ -977,7 +1119,7 @@ def _stage_status_from_sources(
977
1119
  elif phase in status and sev in {"warn", "warning"} and status[phase] == "OK":
978
1120
  status[phase] = "WARN"
979
1121
 
980
- api = _ledger_api_tests(ledger)
1122
+ api = _ledger_api_tests(ledger, change_dir=change_dir)
981
1123
  db = _ledger_db_compat(ledger)
982
1124
  api_status = str(api.get("status") or "").upper()
983
1125
  if api_status in {"FAIL", "FAILED", "ERROR"} or int(api.get("failed") or 0) > 0:
@@ -1002,28 +1144,266 @@ def _stage_status_from_sources(
1002
1144
  def _compute_final_status(
1003
1145
  stage_status: dict[str, str],
1004
1146
  verification: dict[str, Any],
1005
- ) -> str:
1147
+ ) -> tuple[str, list[str]]:
1006
1148
  api = verification.get("apiTests") or {}
1007
1149
  db = str(verification.get("dbCompatibility") or "")
1008
1150
  api_status = str(api.get("status") or "")
1009
- for v in stage_status.values():
1151
+ reasons: list[str] = []
1152
+ for phase, v in stage_status.items():
1010
1153
  if v == "FAIL":
1011
- return "FAIL"
1154
+ return "FAIL", [f"stage {phase}=FAIL"]
1012
1155
  unit = verification.get("unitTests") or {}
1013
- if int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0:
1014
- return "FAIL"
1156
+ if int(unit.get("failures") or 0) > 0:
1157
+ return "FAIL", [f"unitTests.failures={unit.get('failures')}"]
1158
+ if int(unit.get("errors") or 0) > 0:
1159
+ return "FAIL", [f"unitTests.errors={unit.get('errors')}"]
1015
1160
  if int(api.get("failed") or 0) > 0:
1016
- return "FAIL"
1161
+ return "FAIL", [f"apiTests.failed={api.get('failed')}"]
1017
1162
  conditional = {
1018
1163
  "USER_SKIPPED", "BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA",
1019
1164
  "NOT_RUN", "PARTIAL",
1020
1165
  }
1021
- if api_status in conditional or db in conditional:
1022
- return "CONDITIONAL_OK"
1023
- for v in stage_status.values():
1024
- if v in {"WARN", *conditional}:
1025
- return "WARN" if v == "WARN" else "CONDITIONAL_OK"
1026
- return "OK"
1166
+ if api_status in conditional:
1167
+ reasons.append(f"apiTests.status={api_status}")
1168
+ if db in conditional:
1169
+ reasons.append(f"dbCompatibility={db}")
1170
+ if reasons:
1171
+ return "CONDITIONAL_OK", reasons
1172
+ for phase, v in stage_status.items():
1173
+ if v == "WARN":
1174
+ return "WARN", [f"stage {phase}=WARN"]
1175
+ if v in conditional:
1176
+ return "CONDITIONAL_OK", [f"stage {phase}={v}"]
1177
+ return "OK", []
1178
+
1179
+
1180
+ def load_gate_policy(change_dir: Path) -> dict[str, Any] | None:
1181
+ path = change_dir / "meta" / "gate-policy.json"
1182
+ if not path.is_file():
1183
+ return None
1184
+ try:
1185
+ data = read_json(path)
1186
+ except (OSError, json.JSONDecodeError):
1187
+ return None
1188
+ if not isinstance(data, dict) or data.get("schemaVersion") != 1:
1189
+ return None
1190
+ return data
1191
+
1192
+
1193
+ _RISK_SEVERITIES = frozenset({"warning", "error", "critical"})
1194
+
1195
+
1196
+ def _cleanup_transients(work_dir: Path) -> dict[str, Any]:
1197
+ """Delete lock/pid/launcher/credential files; truncate oversized logs."""
1198
+ deleted: list[str] = []
1199
+ truncated: list[dict[str, Any]] = []
1200
+
1201
+ def _rel(path: Path) -> str:
1202
+ try:
1203
+ return path.relative_to(work_dir).as_posix()
1204
+ except ValueError:
1205
+ return str(path)
1206
+
1207
+ lock = work_dir / "events.ndjson.lock"
1208
+ if lock.is_file():
1209
+ try:
1210
+ lock.unlink()
1211
+ deleted.append(_rel(lock))
1212
+ except OSError:
1213
+ pass
1214
+
1215
+ runtime = work_dir / "runtime"
1216
+ if runtime.is_dir():
1217
+ for path in sorted(runtime.iterdir()):
1218
+ if not path.is_file():
1219
+ continue
1220
+ name = path.name
1221
+ drop = False
1222
+ if name.endswith(".pid"):
1223
+ drop = True
1224
+ elif name in {
1225
+ "_harness_service_launcher.py",
1226
+ "_harness_service.command.txt",
1227
+ }:
1228
+ drop = True
1229
+ elif re.search(r"credential|token|secret", name, re.I):
1230
+ drop = True
1231
+ if drop:
1232
+ try:
1233
+ path.unlink()
1234
+ deleted.append(_rel(path))
1235
+ except OSError:
1236
+ pass
1237
+
1238
+ logs_root = work_dir / "logs"
1239
+ if logs_root.is_dir():
1240
+ for path in sorted(logs_root.rglob("*.log")):
1241
+ if not path.is_file():
1242
+ continue
1243
+ try:
1244
+ size = path.stat().st_size
1245
+ except OSError:
1246
+ continue
1247
+ if size <= 65536:
1248
+ continue
1249
+ try:
1250
+ with path.open("rb") as handle:
1251
+ handle.seek(-65536, 2)
1252
+ tail = handle.read()
1253
+ header = (
1254
+ f"# [truncated by harness-archive finalize: original {size} bytes]\n"
1255
+ ).encode("utf-8")
1256
+ path.write_bytes(header + tail)
1257
+ truncated.append({"path": _rel(path), "originalBytes": size})
1258
+ except OSError:
1259
+ pass
1260
+
1261
+ return {"deleted": deleted, "truncated": truncated}
1262
+
1263
+
1264
+ def write_archive_meta(work_dir: Path, summary: dict[str, Any]) -> Path:
1265
+ """Generate meta/archive-meta.md from summary-data (single ownership)."""
1266
+ archive_id = work_dir.name
1267
+ change_name = str(summary.get("changeName") or work_dir.name)
1268
+ archived_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M")
1269
+ final_status = str(summary.get("finalStatus") or "UNKNOWN")
1270
+ lines = [
1271
+ "---",
1272
+ f"archive-id: {archive_id}",
1273
+ f"change-name: {change_name}",
1274
+ f"archived-at: {archived_at}",
1275
+ f"final-commit: {summary.get('finalCommit') or ''}",
1276
+ f"base-commit: {summary.get('baseCommit') or ''}",
1277
+ f"final-status: {final_status}",
1278
+ "source: harness-archive",
1279
+ "---",
1280
+ f"# 归档元数据 — {change_name}",
1281
+ "",
1282
+ "## 阶段状态",
1283
+ "",
1284
+ "| 阶段 | 状态 |",
1285
+ "|---|---|",
1286
+ ]
1287
+ for stage, status in (summary.get("stageStatus") or {}).items():
1288
+ lines.append(f"| {stage} | {status} |")
1289
+ lines.extend(["", "## 变更文件", "", "| 路径 | + | - |", "|---|---|---|"])
1290
+ changed = summary.get("changedFiles") or []
1291
+ if changed:
1292
+ for item in changed:
1293
+ lines.append(
1294
+ f"| {item.get('path') or ''} | "
1295
+ f"{item.get('insertions', 0)} | {item.get('deletions', 0)} |"
1296
+ )
1297
+ else:
1298
+ lines.append("| (无) | | |")
1299
+ lines.extend(["", "## 已知风险", ""])
1300
+ risks = summary.get("knownRisks") or []
1301
+ if risks:
1302
+ for risk in risks:
1303
+ if isinstance(risk, dict):
1304
+ lines.append(
1305
+ f"- [{risk.get('severity') or 'unknown'}] "
1306
+ f"{risk.get('message') or risk}"
1307
+ )
1308
+ else:
1309
+ lines.append(f"- {risk}")
1310
+ else:
1311
+ lines.append("无")
1312
+ lines.append("")
1313
+ out = work_dir / "meta" / "archive-meta.md"
1314
+ out.parent.mkdir(parents=True, exist_ok=True)
1315
+ out.write_text("\n".join(lines), encoding="utf-8", newline="\n")
1316
+ return out
1317
+
1318
+
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)
1027
1407
 
1028
1408
 
1029
1409
  def _changed_files_from_git(
@@ -1298,7 +1678,7 @@ def collect_summary_data(
1298
1678
  # verification
1299
1679
  if not for_replay or not isinstance(data.get("verification"), dict):
1300
1680
  unit = _ledger_unit_tests(ledger)
1301
- api = _ledger_api_tests(ledger)
1681
+ api = _ledger_api_tests(ledger, change_dir=change_dir)
1302
1682
  db = _ledger_db_compat(ledger)
1303
1683
  if not find_test_reports(change_dir) and unit.get("run", 0) == 0:
1304
1684
  if "status" not in unit:
@@ -1326,10 +1706,17 @@ def collect_summary_data(
1326
1706
  if not for_replay or not isinstance(data.get("stageStatus"), dict):
1327
1707
  data["stageStatus"] = _stage_status_from_sources(events, ledger, change_dir)
1328
1708
  if not for_replay or not data.get("finalStatus") or str(data.get("finalStatus")).startswith("OK |"):
1329
- data["finalStatus"] = _compute_final_status(
1709
+ final_status, final_reasons = _compute_final_status(
1330
1710
  data.get("stageStatus") or {},
1331
1711
  data.get("verification") or {},
1332
1712
  )
1713
+ data["finalStatus"] = final_status
1714
+ data["finalStatusReasons"] = list(final_reasons)
1715
+ else:
1716
+ data.setdefault("finalStatusReasons", [])
1717
+
1718
+ gate_policy = load_gate_policy(change_dir)
1719
+ data["riskTier"] = str((gate_policy or {}).get("tier") or "unknown")
1333
1720
 
1334
1721
  # durations / skillCalls
1335
1722
  if events:
@@ -1397,24 +1784,33 @@ def collect_summary_data(
1397
1784
 
1398
1785
  # Derive risks/actions from evidence. These fields are facts, not model prose.
1399
1786
  if not for_replay:
1400
- data["maintenanceNotes"] = [
1787
+ maintenance_notes: list[str] = [
1401
1788
  str(event.get("note") or event.get("message") or "")
1402
1789
  for event in events
1403
1790
  if event.get("type") == "decision" and (event.get("note") or event.get("message"))
1404
1791
  ]
1405
- data["knownRisks"] = [
1406
- {
1407
- "phase": event.get("phase"),
1408
- "severity": event.get("severity") or "unknown",
1409
- "message": event.get("message") or event.get("note") or event.get("code") or "",
1410
- }
1411
- for event in events
1412
- if event.get("type") == "issue"
1413
- and not (
1414
- event.get("phase") == "archive"
1415
- and event.get("code") == "missing-command"
1416
- )
1417
- ]
1792
+ known_risks: list[dict[str, Any]] = []
1793
+ for event in events:
1794
+ if event.get("type") != "issue":
1795
+ continue
1796
+ if event.get("phase") == "archive" and event.get("code") == "missing-command":
1797
+ continue
1798
+ sev = str(event.get("severity") or "").strip().lower()
1799
+ message = event.get("message") or event.get("note") or event.get("code") or ""
1800
+ if sev in _RISK_SEVERITIES:
1801
+ known_risks.append(
1802
+ {
1803
+ "phase": event.get("phase"),
1804
+ "severity": sev,
1805
+ "message": message,
1806
+ }
1807
+ )
1808
+ else:
1809
+ note = str(message).strip()
1810
+ if note:
1811
+ maintenance_notes.append(note)
1812
+ data["maintenanceNotes"] = maintenance_notes
1813
+ data["knownRisks"] = known_risks
1418
1814
  data["manualActions"] = []
1419
1815
  for name, value in (data.get("stageStatus") or {}).items():
1420
1816
  if value in {"BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA", "NOT_RUN", "USER_SKIPPED"}:
@@ -1427,6 +1823,8 @@ def collect_summary_data(
1427
1823
  data.setdefault("maintenanceNotes", [])
1428
1824
  data.setdefault("knownRisks", [])
1429
1825
  data.setdefault("manualActions", [])
1826
+ data.setdefault("finalStatusReasons", [])
1827
+ data.setdefault("riskTier", "unknown")
1430
1828
 
1431
1829
  # archiveManifest
1432
1830
  am = {
@@ -1521,7 +1919,13 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
1521
1919
  f'<h2 id="changeName">{esc(summary.get("changeName"))}</h2>',
1522
1920
  f'<p><strong>finalStatus</strong>: '
1523
1921
  f'<span id="finalStatus">{esc(summary.get("finalStatus"))}</span></p>',
1922
+ f'<div id="finalStatusReasons"><strong>finalStatusReasons</strong><ul>',
1524
1923
  ]
1924
+ for reason in summary.get("finalStatusReasons") or []:
1925
+ parts.append(f"<li>{esc(reason)}</li>")
1926
+ parts.append("</ul></div>")
1927
+ if summary.get("riskTier"):
1928
+ parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
1525
1929
 
1526
1930
  pipeline = summary.get("reportPipeline") or {}
1527
1931
  cmds = pipeline.get("commands") or []
@@ -2006,6 +2410,7 @@ def cmd_finalize(
2006
2410
  archive_root: Path,
2007
2411
  *,
2008
2412
  skip_ingest: bool = False,
2413
+ allow_missing_review: bool = False,
2009
2414
  ) -> tuple[int, dict[str, Any]]:
2010
2415
  """Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
2011
2416
  warnings: list[str] = []
@@ -2035,6 +2440,7 @@ def cmd_finalize(
2035
2440
  return 1, payload
2036
2441
 
2037
2442
  work_dir = original_change_dir
2443
+ before_manifest: dict[str, Any] | None = None
2038
2444
 
2039
2445
  def _safe_append(**kwargs: Any) -> None:
2040
2446
  nonlocal work_dir
@@ -2046,6 +2452,23 @@ def cmd_finalize(
2046
2452
  # Step 9 starts here: phase.start
2047
2453
  _safe_append(phase="archive", type_="phase.start", note="finalize start")
2048
2454
 
2455
+ # --- 0. cleanup transients (before before-manifest) ---
2456
+ try:
2457
+ cleanup_result = _cleanup_transients(work_dir)
2458
+ payload["steps"]["cleanup"] = cleanup_result
2459
+ deleted_n = len(cleanup_result.get("deleted") or [])
2460
+ trunc_n = len(cleanup_result.get("truncated") or [])
2461
+ _safe_append(
2462
+ phase="archive",
2463
+ type_="command",
2464
+ command="cleanup-transients",
2465
+ exit_code=0,
2466
+ note=f"deleted={deleted_n} truncated={trunc_n}",
2467
+ )
2468
+ except OSError as exc:
2469
+ warnings.append(f"cleanup failed: {exc}")
2470
+ payload["steps"]["cleanup"] = {"ok": False, "error": str(exc)}
2471
+
2049
2472
  # --- 1. before-manifest ---
2050
2473
  before_path = work_dir / "evidence" / "archive-manifest-before.json"
2051
2474
  try:
@@ -2119,6 +2542,18 @@ def cmd_finalize(
2119
2542
  for_replay=False,
2120
2543
  )
2121
2544
  summary_path = work_dir / "reports" / "final" / "summary-data.json"
2545
+ if allow_missing_review:
2546
+ reasons = list(summary.get("finalStatusReasons") or [])
2547
+ reason = "review missing on full tier (allowed by user)"
2548
+ if reason not in reasons:
2549
+ reasons.append(reason)
2550
+ summary["finalStatusReasons"] = reasons
2551
+ write_json(summary_path, summary)
2552
+ _safe_append(
2553
+ phase="archive",
2554
+ type_="decision",
2555
+ note=reason,
2556
+ )
2122
2557
  payload["steps"]["collect"] = {"ok": True, "path": str(summary_path)}
2123
2558
  _safe_append(
2124
2559
  phase="archive",
@@ -2346,6 +2781,23 @@ def cmd_finalize(
2346
2781
  if service_result.get("warning"):
2347
2782
  warnings.append(str(service_result["warning"]))
2348
2783
 
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
+
2349
2801
  _safe_append(
2350
2802
  phase="archive",
2351
2803
  type_="phase.end",
@@ -2353,6 +2805,50 @@ def cmd_finalize(
2353
2805
  note="finalize complete",
2354
2806
  )
2355
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
+
2356
2852
  payload["ok"] = True
2357
2853
  payload["warnings"] = warnings
2358
2854
  payload["summary_data"] = str(summary_path)
@@ -2455,7 +2951,10 @@ def cmd_replay(
2455
2951
 
2456
2952
  def cmd_status_cli(args: argparse.Namespace) -> int:
2457
2953
  change_dir = resolve_path(args.change_dir)
2458
- result = check_status(change_dir)
2954
+ result = check_status(
2955
+ change_dir,
2956
+ allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
2957
+ )
2459
2958
  emit_json(result)
2460
2959
  # Checks completed → exit 0; archivable flag conveys the gate result.
2461
2960
  return 0 if result.get("ok") else 1
@@ -2468,6 +2967,7 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
2468
2967
  change_dir,
2469
2968
  archive_root,
2470
2969
  skip_ingest=bool(args.skip_ingest),
2970
+ allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
2471
2971
  )
2472
2972
  emit_json(payload)
2473
2973
  return code
@@ -2491,12 +2991,22 @@ def build_parser() -> argparse.ArgumentParser:
2491
2991
  p_status = sub.add_parser("status", help="pre-archive gate checks (read-only)")
2492
2992
  p_status.add_argument("--change-dir", required=True)
2493
2993
  p_status.add_argument("--json", action="store_true", default=True)
2994
+ p_status.add_argument(
2995
+ "--allow-missing-review",
2996
+ action="store_true",
2997
+ help="downgrade full-tier missing review from blocker to warning",
2998
+ )
2494
2999
  p_status.set_defaults(func=cmd_status_cli)
2495
3000
 
2496
3001
  p_fin = sub.add_parser("finalize", help="single-process archive finalize")
2497
3002
  p_fin.add_argument("--change-dir", required=True)
2498
3003
  p_fin.add_argument("--archive-root", required=True)
2499
3004
  p_fin.add_argument("--skip-ingest", action="store_true")
3005
+ p_fin.add_argument(
3006
+ "--allow-missing-review",
3007
+ action="store_true",
3008
+ help="record override when full-tier review evidence is missing",
3009
+ )
2500
3010
  p_fin.add_argument("--json", action="store_true", default=True)
2501
3011
  p_fin.set_defaults(func=cmd_finalize_cli)
2502
3012