@hunter-harness/workflow-harness 0.2.21 → 0.2.23

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 (214) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +12 -3
  3. package/harness/bundles/general/claude-code/harness-archive/checklist.md +14 -0
  4. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  5. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +11 -0
  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 +2 -1
  10. package/harness/bundles/general/claude-code/harness-plan/checklist.md +1 -0
  11. package/harness/bundles/general/claude-code/harness-plan/reference.md +17 -15
  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-submit/SKILL.md +3 -1
  15. package/harness/bundles/general/claude-code/harness-submit/checklist.md +7 -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 +6 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +1168 -28
  20. package/harness/bundles/general/claude-code/scripts/harness_environment.py +459 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_events.py +93 -14
  22. package/harness/bundles/general/claude-code/scripts/harness_integration.py +126 -14
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +30 -6
  24. package/harness/bundles/general/claude-code/scripts/harness_profile.py +33 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +30 -8
  26. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  27. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +12 -3
  28. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +14 -0
  29. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  30. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +11 -0
  31. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +2 -1
  35. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +1 -0
  36. package/harness/bundles/general/codebuddy/harness-plan/reference.md +17 -15
  37. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +3 -1
  40. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +7 -1
  41. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-test/checklist.md +6 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +1168 -28
  45. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +459 -0
  46. package/harness/bundles/general/codebuddy/scripts/harness_events.py +93 -14
  47. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +126 -14
  48. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +30 -6
  49. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +33 -0
  50. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +30 -8
  51. package/harness/bundles/general/codex/.harness-build.json +1 -1
  52. package/harness/bundles/general/codex/harness-archive/SKILL.md +12 -3
  53. package/harness/bundles/general/codex/harness-archive/checklist.md +14 -0
  54. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -3
  55. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +11 -0
  56. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-plan/SKILL.md +2 -1
  60. package/harness/bundles/general/codex/harness-plan/checklist.md +1 -0
  61. package/harness/bundles/general/codex/harness-plan/reference.md +17 -15
  62. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-submit/SKILL.md +3 -1
  65. package/harness/bundles/general/codex/harness-submit/checklist.md +7 -1
  66. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-test/checklist.md +6 -0
  69. package/harness/bundles/general/codex/scripts/harness_archive.py +1168 -28
  70. package/harness/bundles/general/codex/scripts/harness_environment.py +459 -0
  71. package/harness/bundles/general/codex/scripts/harness_events.py +93 -14
  72. package/harness/bundles/general/codex/scripts/harness_integration.py +126 -14
  73. package/harness/bundles/general/codex/scripts/harness_ledger.py +30 -6
  74. package/harness/bundles/general/codex/scripts/harness_profile.py +33 -0
  75. package/harness/bundles/general/codex/scripts/harness_test_guard.py +30 -8
  76. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  77. package/harness/bundles/general/cursor/harness-archive/SKILL.md +12 -3
  78. package/harness/bundles/general/cursor/harness-archive/checklist.md +14 -0
  79. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -3
  80. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +11 -0
  81. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  83. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-plan/SKILL.md +2 -1
  85. package/harness/bundles/general/cursor/harness-plan/checklist.md +1 -0
  86. package/harness/bundles/general/cursor/harness-plan/reference.md +17 -15
  87. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-submit/SKILL.md +3 -1
  90. package/harness/bundles/general/cursor/harness-submit/checklist.md +7 -1
  91. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-test/checklist.md +6 -0
  94. package/harness/bundles/general/cursor/scripts/harness_archive.py +1168 -28
  95. package/harness/bundles/general/cursor/scripts/harness_environment.py +459 -0
  96. package/harness/bundles/general/cursor/scripts/harness_events.py +93 -14
  97. package/harness/bundles/general/cursor/scripts/harness_integration.py +126 -14
  98. package/harness/bundles/general/cursor/scripts/harness_ledger.py +30 -6
  99. package/harness/bundles/general/cursor/scripts/harness_profile.py +33 -0
  100. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +30 -8
  101. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  102. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +12 -3
  104. package/harness/bundles/java/claude-code/harness-archive/checklist.md +14 -0
  105. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  106. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +11 -0
  107. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  110. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +2 -1
  112. package/harness/bundles/java/claude-code/harness-plan/checklist.md +1 -0
  113. package/harness/bundles/java/claude-code/harness-plan/reference.md +17 -15
  114. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +3 -1
  117. package/harness/bundles/java/claude-code/harness-submit/checklist.md +7 -1
  118. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  119. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  120. package/harness/bundles/java/claude-code/scripts/harness_archive.py +1168 -28
  121. package/harness/bundles/java/claude-code/scripts/harness_environment.py +459 -0
  122. package/harness/bundles/java/claude-code/scripts/harness_events.py +93 -14
  123. package/harness/bundles/java/claude-code/scripts/harness_integration.py +126 -14
  124. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +30 -6
  125. package/harness/bundles/java/claude-code/scripts/harness_profile.py +33 -0
  126. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +30 -8
  127. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  128. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +12 -3
  130. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +14 -0
  131. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  132. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +11 -0
  133. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  136. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +2 -1
  138. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +1 -0
  139. package/harness/bundles/java/codebuddy/harness-plan/reference.md +17 -15
  140. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  141. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  142. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +3 -1
  143. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +7 -1
  144. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  145. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +1168 -28
  147. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +459 -0
  148. package/harness/bundles/java/codebuddy/scripts/harness_events.py +93 -14
  149. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +126 -14
  150. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +30 -6
  151. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +33 -0
  152. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +30 -8
  153. package/harness/bundles/java/codex/.harness-build.json +1 -1
  154. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-archive/SKILL.md +12 -3
  156. package/harness/bundles/java/codex/harness-archive/checklist.md +14 -0
  157. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -3
  158. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +11 -0
  159. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  160. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  161. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  163. package/harness/bundles/java/codex/harness-plan/SKILL.md +2 -1
  164. package/harness/bundles/java/codex/harness-plan/checklist.md +1 -0
  165. package/harness/bundles/java/codex/harness-plan/reference.md +17 -15
  166. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  168. package/harness/bundles/java/codex/harness-submit/SKILL.md +3 -1
  169. package/harness/bundles/java/codex/harness-submit/checklist.md +7 -1
  170. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  171. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/scripts/harness_archive.py +1168 -28
  173. package/harness/bundles/java/codex/scripts/harness_environment.py +459 -0
  174. package/harness/bundles/java/codex/scripts/harness_events.py +93 -14
  175. package/harness/bundles/java/codex/scripts/harness_integration.py +126 -14
  176. package/harness/bundles/java/codex/scripts/harness_ledger.py +30 -6
  177. package/harness/bundles/java/codex/scripts/harness_profile.py +33 -0
  178. package/harness/bundles/java/codex/scripts/harness_test_guard.py +30 -8
  179. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  180. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  181. package/harness/bundles/java/cursor/harness-archive/SKILL.md +12 -3
  182. package/harness/bundles/java/cursor/harness-archive/checklist.md +14 -0
  183. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -3
  184. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +11 -0
  185. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  186. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/cursor/harness-plan/SKILL.md +2 -1
  190. package/harness/bundles/java/cursor/harness-plan/checklist.md +1 -0
  191. package/harness/bundles/java/cursor/harness-plan/reference.md +17 -15
  192. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/cursor/harness-submit/SKILL.md +3 -1
  195. package/harness/bundles/java/cursor/harness-submit/checklist.md +7 -1
  196. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  197. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  198. package/harness/bundles/java/cursor/scripts/harness_archive.py +1168 -28
  199. package/harness/bundles/java/cursor/scripts/harness_environment.py +459 -0
  200. package/harness/bundles/java/cursor/scripts/harness_events.py +93 -14
  201. package/harness/bundles/java/cursor/scripts/harness_integration.py +126 -14
  202. package/harness/bundles/java/cursor/scripts/harness_ledger.py +30 -6
  203. package/harness/bundles/java/cursor/scripts/harness_profile.py +33 -0
  204. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +30 -8
  205. package/harness/manifests/general/claude-code.json +29 -25
  206. package/harness/manifests/general/codebuddy.json +29 -25
  207. package/harness/manifests/general/codex.json +29 -25
  208. package/harness/manifests/general/cursor.json +29 -25
  209. package/harness/manifests/java/claude-code.json +30 -26
  210. package/harness/manifests/java/codebuddy.json +30 -26
  211. package/harness/manifests/java/codex.json +30 -26
  212. package/harness/manifests/java/cursor.json +30 -26
  213. package/hunter-workflow-family.json +2 -2
  214. package/package.json +1 -1
@@ -331,6 +331,811 @@ def load_ci_metrics(change_dir: Path) -> tuple[dict[str, Any] | None, str | None
331
331
  return None, None
332
332
 
333
333
 
334
+ def _legacy_candidate_path(change_dir: Path) -> Path | None:
335
+ for relative in (
336
+ "evidence/product-candidate-ci.json",
337
+ "meta/product-candidate-ci.json",
338
+ "runtime/product-candidate-ci.json",
339
+ ):
340
+ path = change_dir / relative
341
+ if path.is_file():
342
+ return path
343
+ return None
344
+
345
+
346
+ def _remote_ci_history_present(change_dir: Path) -> bool:
347
+ if _legacy_candidate_path(change_dir) is not None:
348
+ return True
349
+ ledger = load_ledger(change_dir) or {}
350
+ if any(key in ledger for key in ("productCandidateCi", "product_candidate_ci")):
351
+ return True
352
+ validations = (
353
+ ledger.get("validations")
354
+ if isinstance(ledger.get("validations"), dict)
355
+ else {}
356
+ )
357
+ return any(
358
+ key in validations
359
+ for key in ("productCandidateCi", "product_candidate_ci", "candidateCi")
360
+ )
361
+
362
+
363
+ def load_product_candidate_ci(change_dir: Path) -> dict[str, Any] | None:
364
+ """Load platform-neutral candidate evidence, then legacy CI evidence."""
365
+ for relative in (
366
+ "evidence/product-candidate-verification.json",
367
+ "meta/product-candidate-verification.json",
368
+ "runtime/product-candidate-verification.json",
369
+ ):
370
+ path = change_dir / relative
371
+ if not path.is_file():
372
+ continue
373
+ try:
374
+ data = read_json(path)
375
+ except (OSError, json.JSONDecodeError):
376
+ continue
377
+ if isinstance(data, dict) and data.get("schemaVersion") == 2:
378
+ return data
379
+
380
+ # Legacy schema remains readable so existing projects do not need an
381
+ # immediate migration. It represents a remote claim, not an attestation.
382
+ legacy_path = _legacy_candidate_path(change_dir)
383
+ if legacy_path is not None:
384
+ try:
385
+ data = read_json(legacy_path)
386
+ except (OSError, json.JSONDecodeError):
387
+ data = None
388
+ if isinstance(data, dict) and data.get("schemaVersion") == 1:
389
+ return data
390
+ ledger = load_ledger(change_dir) or {}
391
+ for key in ("productCandidateCi", "product_candidate_ci"):
392
+ value = ledger.get(key)
393
+ if isinstance(value, dict):
394
+ return {"schemaVersion": 1, **value}
395
+ validations = ledger.get("validations") if isinstance(ledger.get("validations"), dict) else {}
396
+ for key in ("productCandidateCi", "product_candidate_ci", "candidateCi"):
397
+ value = validations.get(key) if isinstance(validations, dict) else None
398
+ if isinstance(value, dict):
399
+ return {"schemaVersion": 1, **value}
400
+ return None
401
+
402
+
403
+ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
404
+ """Evaluate product-candidate evidence independently of CI availability."""
405
+ evidence = load_product_candidate_ci(change_dir)
406
+ if evidence is None:
407
+ return {
408
+ "ok": False,
409
+ "code": "PRODUCT_CI_NOT_GREEN",
410
+ "message": (
411
+ "missing product-candidate CI evidence "
412
+ "(need evidence/product-candidate-ci.json with conclusion=success, "
413
+ "runUrl, commit)"
414
+ ),
415
+ "evidence": None,
416
+ }
417
+
418
+ if evidence.get("schemaVersion") == 2:
419
+ conclusion = str(evidence.get("conclusion") or "").strip().lower()
420
+ provider = str(evidence.get("provider") or "").strip()
421
+ assurance = str(evidence.get("assurance") or "").strip()
422
+ subject = evidence.get("subject")
423
+ verification = evidence.get("verification")
424
+ subject = subject if isinstance(subject, dict) else {}
425
+ verification = verification if isinstance(verification, dict) else {}
426
+ product_commit = str(subject.get("productCommit") or "").strip()
427
+ product_tree_hash = str(subject.get("productTreeHash") or "").strip()
428
+ command_set_hash = str(verification.get("commandSetHash") or "").strip()
429
+ ledger_hash = str(verification.get("ledgerHash") or "").strip()
430
+ common_required_fields = {
431
+ "provider": provider,
432
+ "assurance": assurance,
433
+ "subject.productCommit": product_commit,
434
+ "subject.productTreeHash": product_tree_hash,
435
+ }
436
+ allowed_assurance = {
437
+ "local-reproducible": 1,
438
+ "remote-claimed": 2,
439
+ "remote-attested": 3,
440
+ }
441
+ policy = load_gate_policy(change_dir) or {}
442
+ candidate_policy = policy.get("candidateVerification")
443
+ candidate_policy = (
444
+ candidate_policy if isinstance(candidate_policy, dict) else {}
445
+ )
446
+ minimum_assurance = str(
447
+ candidate_policy.get("minimumAssurance") or "local-reproducible"
448
+ ).strip()
449
+ if minimum_assurance not in allowed_assurance:
450
+ minimum_assurance = "local-reproducible"
451
+ if (
452
+ provider == "local-harness"
453
+ and _remote_ci_history_present(change_dir)
454
+ and not bool(candidate_policy.get("allowLocalFallback"))
455
+ ):
456
+ return {
457
+ "ok": False,
458
+ "code": "REMOTE_CI_DOWNGRADE_REFUSED",
459
+ "message": (
460
+ "remote CI history exists; local-harness evidence requires "
461
+ "candidateVerification.allowLocalFallback=true"
462
+ ),
463
+ "assurance": assurance,
464
+ "evidence": evidence,
465
+ }
466
+ if provider == "local-harness":
467
+ local_required = {
468
+ "verification.commandSetHash": command_set_hash,
469
+ "verification.ledgerHash": ledger_hash,
470
+ "verification.toolchainHashes": verification.get("toolchainHashes"),
471
+ "verification.environmentHashes": verification.get("environmentHashes"),
472
+ "verification.dependencyHashes": verification.get("dependencyHashes"),
473
+ "verification.logHashes": verification.get("logHashes"),
474
+ }
475
+ required_fields = {**common_required_fields, **local_required}
476
+ elif provider == "remote-ci":
477
+ attestation = evidence.get("attestation")
478
+ attestation = attestation if isinstance(attestation, dict) else {}
479
+ if assurance == "remote-attested":
480
+ remote_identity_name = "verification.attestationDigest"
481
+ remote_identity = verification.get("attestationDigest")
482
+ else:
483
+ remote_identity_name = "verification.remoteIdentity"
484
+ remote_identity = (
485
+ verification.get("legacyEvidenceHash") or ledger_hash
486
+ )
487
+ required_fields = {
488
+ **common_required_fields,
489
+ "attestation.url": attestation.get("url"),
490
+ remote_identity_name: remote_identity,
491
+ }
492
+ else:
493
+ required_fields = common_required_fields
494
+ missing = [name for name, value in required_fields.items() if not value]
495
+ if conclusion not in {
496
+ "success",
497
+ "successful",
498
+ "passed",
499
+ "pass",
500
+ "green",
501
+ "ok",
502
+ }:
503
+ return {
504
+ "ok": False,
505
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
506
+ "message": (
507
+ f"product candidate conclusion={conclusion or 'unknown'} "
508
+ f"provider={provider or 'unknown'}"
509
+ ),
510
+ "assurance": assurance,
511
+ "evidence": evidence,
512
+ }
513
+ if missing or assurance not in allowed_assurance:
514
+ detail = ", ".join(missing) if missing else f"assurance={assurance}"
515
+ return {
516
+ "ok": False,
517
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
518
+ "message": f"candidate evidence missing or invalid: {detail}",
519
+ "assurance": assurance,
520
+ "evidence": evidence,
521
+ }
522
+ if allowed_assurance[assurance] < allowed_assurance[minimum_assurance]:
523
+ return {
524
+ "ok": False,
525
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
526
+ "message": (
527
+ f"candidate assurance={assurance} is below required "
528
+ f"minimumAssurance={minimum_assurance}"
529
+ ),
530
+ "assurance": assurance,
531
+ "evidence": evidence,
532
+ }
533
+ return {
534
+ "ok": True,
535
+ "code": "PRODUCT_CANDIDATE_VERIFIED",
536
+ "message": (
537
+ f"product candidate verified provider={provider} "
538
+ f"assurance={assurance} commit={product_commit}"
539
+ ),
540
+ "assurance": assurance,
541
+ "evidence": evidence,
542
+ }
543
+
544
+ conclusion = str(
545
+ evidence.get("conclusion") or evidence.get("status") or ""
546
+ ).strip().lower()
547
+ run_url = str(evidence.get("runUrl") or evidence.get("url") or "").strip()
548
+ commit = str(evidence.get("commit") or evidence.get("headSha") or "").strip()
549
+ if conclusion in {"success", "successful", "passed", "pass", "green", "ok"}:
550
+ # Review Y1: success alone is insufficient — runUrl + commit are required.
551
+ if not run_url or not commit:
552
+ return {
553
+ "ok": False,
554
+ "code": "PRODUCT_CI_NOT_GREEN",
555
+ "message": (
556
+ "product candidate CI conclusion is success but missing "
557
+ f"runUrl={run_url or 'empty'} commit={commit or 'empty'}"
558
+ ),
559
+ "evidence": evidence,
560
+ }
561
+ return {
562
+ "ok": True,
563
+ "code": "PRODUCT_CI_GREEN",
564
+ "message": f"product candidate CI green commit={commit} url={run_url}",
565
+ "evidence": evidence,
566
+ }
567
+ return {
568
+ "ok": False,
569
+ "code": "PRODUCT_CI_NOT_GREEN",
570
+ "message": (
571
+ f"product candidate CI conclusion={conclusion or 'unknown'} "
572
+ f"commit={commit or 'unknown'} runUrl={run_url or 'unknown'}"
573
+ ),
574
+ "evidence": evidence,
575
+ }
576
+
577
+
578
+ def _candidate_value_hash(value: Any) -> str:
579
+ encoded = json.dumps(
580
+ value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
581
+ ).encode("utf-8")
582
+ return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
583
+
584
+
585
+ def _candidate_evidence_hash(change_dir: Path, value: Any) -> str:
586
+ if isinstance(value, str) and value.strip():
587
+ raw = Path(value.strip())
588
+ candidate = raw if raw.is_absolute() else change_dir / raw
589
+ try:
590
+ resolved = candidate.resolve()
591
+ resolved.relative_to(change_dir.resolve())
592
+ except (OSError, ValueError):
593
+ resolved = None
594
+ if resolved is not None and resolved.is_file():
595
+ return f"sha256:{sha256_file(resolved)}"
596
+ return _candidate_value_hash(value)
597
+
598
+
599
+ def migrate_legacy_candidate_evidence(
600
+ change_dir: Path,
601
+ *,
602
+ project: Path | None = None,
603
+ ) -> dict[str, Any]:
604
+ """Convert legacy CI JSON into an explicit remote-claimed v2 receipt."""
605
+ change_dir = change_dir.resolve()
606
+ legacy_path = _legacy_candidate_path(change_dir)
607
+ if legacy_path is None:
608
+ raise ValueError("legacy product-candidate-ci.json missing")
609
+ legacy = read_json(legacy_path)
610
+ if not isinstance(legacy, dict) or legacy.get("schemaVersion") != 1:
611
+ raise ValueError("legacy product-candidate-ci.json is invalid")
612
+ conclusion = str(
613
+ legacy.get("conclusion") or legacy.get("status") or ""
614
+ ).strip().lower()
615
+ commit = str(legacy.get("commit") or legacy.get("headSha") or "").strip()
616
+ run_url = str(legacy.get("runUrl") or legacy.get("url") or "").strip()
617
+ if conclusion not in {
618
+ "success",
619
+ "successful",
620
+ "passed",
621
+ "pass",
622
+ "green",
623
+ "ok",
624
+ }:
625
+ raise ValueError(f"legacy CI conclusion is not successful: {conclusion}")
626
+ if not commit or not run_url:
627
+ raise ValueError("legacy CI evidence requires commit and runUrl")
628
+ project_root = (project or find_project_root(change_dir)).resolve()
629
+ tree_detail = compute_product_tree_hash_detail(project_root)
630
+ if tree_detail.get("truncated"):
631
+ raise ValueError("product tree hash truncated during legacy migration")
632
+ receipt = {
633
+ "schemaVersion": 2,
634
+ "provider": "remote-ci",
635
+ "conclusion": "success",
636
+ "assurance": "remote-claimed",
637
+ "recordedAt": now_iso(),
638
+ "subject": {
639
+ "productCommit": commit,
640
+ "productTreeHash": f"sha256:{tree_detail['hash']}",
641
+ },
642
+ "attestation": {
643
+ "url": run_url,
644
+ "source": "legacy-product-candidate-ci",
645
+ },
646
+ "verification": {
647
+ "legacyEvidenceHash": f"sha256:{sha256_file(legacy_path)}",
648
+ },
649
+ "migration": {
650
+ "fromSchemaVersion": 1,
651
+ "sourcePath": legacy_path.relative_to(change_dir).as_posix(),
652
+ },
653
+ }
654
+ write_json(
655
+ change_dir / "evidence" / "product-candidate-verification.json",
656
+ receipt,
657
+ )
658
+ return receipt
659
+
660
+
661
+ def certify_local_candidate(
662
+ change_dir: Path,
663
+ *,
664
+ project: Path | None = None,
665
+ ) -> dict[str, Any]:
666
+ """Create a reproducible local receipt from existing ledger evidence.
667
+
668
+ This function never executes verification commands. It deliberately reuses
669
+ only complete, successful ledger entries so submit/archive do not rerun an
670
+ identical full suite merely to produce candidate evidence.
671
+ """
672
+ change_dir = change_dir.resolve()
673
+ project_root = (project or find_project_root(change_dir)).resolve()
674
+ ledger = load_ledger(change_dir)
675
+ if not isinstance(ledger, dict):
676
+ raise ValueError("verification ledger missing")
677
+ validations = ledger.get("validations")
678
+ if not isinstance(validations, dict):
679
+ raise ValueError("verification ledger validations missing")
680
+
681
+ policy = load_gate_policy(change_dir) or {}
682
+ candidate_policy = policy.get("candidateVerification")
683
+ candidate_policy = (
684
+ candidate_policy if isinstance(candidate_policy, dict) else {}
685
+ )
686
+ if (
687
+ _remote_ci_history_present(change_dir)
688
+ and not bool(candidate_policy.get("allowLocalFallback"))
689
+ ):
690
+ raise ValueError(
691
+ "remote CI history exists; refusing silent local fallback "
692
+ "(set candidateVerification.allowLocalFallback=true explicitly)"
693
+ )
694
+ required = candidate_policy.get("requiredValidations")
695
+ if not isinstance(required, list) or not required:
696
+ required = ["unitTestFull"]
697
+ required = [str(item).strip() for item in required if str(item).strip()]
698
+
699
+ selected: list[dict[str, Any]] = []
700
+ for name in required:
701
+ entry = validations.get(name)
702
+ if not isinstance(entry, dict):
703
+ raise ValueError(f"required validation missing: {name}")
704
+ missing: list[str] = []
705
+ if str(entry.get("status") or "").upper() != "OK":
706
+ missing.append("status=OK")
707
+ for field in (
708
+ "command",
709
+ "evidence",
710
+ "inputsHash",
711
+ "toolchainHash",
712
+ "environmentHash",
713
+ ):
714
+ if entry.get(field) in (None, "", [], {}):
715
+ missing.append(field)
716
+ if missing:
717
+ raise ValueError(
718
+ f"required validation {name} is incomplete: {', '.join(missing)}"
719
+ )
720
+ selected.append(
721
+ {
722
+ "name": name,
723
+ "command": entry.get("command"),
724
+ "inputsHash": entry.get("inputsHash"),
725
+ "toolchainHash": entry.get("toolchainHash"),
726
+ "environmentHash": entry.get("environmentHash"),
727
+ "logHash": _candidate_evidence_hash(
728
+ change_dir, entry.get("evidence")
729
+ ),
730
+ }
731
+ )
732
+
733
+ ledger_path = next(
734
+ (
735
+ change_dir / relative
736
+ for relative in (
737
+ "evidence/verification-ledger.json",
738
+ "verification-ledger.json",
739
+ )
740
+ if (change_dir / relative).is_file()
741
+ ),
742
+ None,
743
+ )
744
+ if ledger_path is None:
745
+ raise ValueError("verification ledger path missing")
746
+
747
+ product_commit = str(
748
+ ledger.get("productCommit")
749
+ or ledger.get("currentHead")
750
+ or ledger.get("finalCommit")
751
+ or ""
752
+ ).strip()
753
+ rc, current_head, _stderr = git_run(project_root, "rev-parse", "HEAD")
754
+ if rc == 0 and current_head:
755
+ if product_commit and not (
756
+ current_head.startswith(product_commit)
757
+ or product_commit.startswith(current_head)
758
+ ):
759
+ raise ValueError(
760
+ "product commit is stale: "
761
+ f"ledger={product_commit} current={current_head}"
762
+ )
763
+ product_commit = current_head
764
+ if not product_commit:
765
+ raise ValueError("product commit unavailable")
766
+
767
+ detail = compute_product_tree_hash_detail(project_root)
768
+ if detail.get("truncated"):
769
+ raise ValueError(
770
+ "product tree hash truncated; narrow the project or raise the explicit limit"
771
+ )
772
+ current_tree_hash = f"sha256:{detail['hash']}"
773
+ recorded_tree_hash = str(ledger.get("productTreeHash") or "").strip()
774
+ if recorded_tree_hash and recorded_tree_hash.removeprefix(
775
+ "sha256:"
776
+ ) != current_tree_hash.removeprefix("sha256:"):
777
+ raise ValueError(
778
+ "product tree is stale: "
779
+ f"ledger={recorded_tree_hash} current={current_tree_hash}"
780
+ )
781
+ product_tree_hash = current_tree_hash
782
+
783
+ receipt = {
784
+ "schemaVersion": 2,
785
+ "provider": "local-harness",
786
+ "conclusion": "success",
787
+ "assurance": "local-reproducible",
788
+ "recordedAt": now_iso(),
789
+ "subject": {
790
+ "productCommit": product_commit,
791
+ "productTreeHash": product_tree_hash,
792
+ },
793
+ "verification": {
794
+ "requiredValidations": required,
795
+ "reusedValidations": [item["name"] for item in selected],
796
+ "commandSetHash": _candidate_value_hash(
797
+ [item["command"] for item in selected]
798
+ ),
799
+ "ledgerHash": f"sha256:{sha256_file(ledger_path)}",
800
+ "toolchainHashes": sorted(
801
+ {str(item["toolchainHash"]) for item in selected}
802
+ ),
803
+ "environmentHashes": sorted(
804
+ {str(item["environmentHash"]) for item in selected}
805
+ ),
806
+ "dependencyHashes": sorted(
807
+ {str(item["inputsHash"]) for item in selected}
808
+ ),
809
+ "logHashes": sorted({str(item["logHash"]) for item in selected}),
810
+ },
811
+ }
812
+ write_json(
813
+ change_dir / "evidence" / "product-candidate-verification.json",
814
+ receipt,
815
+ )
816
+ return receipt
817
+
818
+
819
+ PRODUCT_TREE_HASH_FILE_LIMIT = 20_000
820
+
821
+
822
+ def compute_product_tree_hash_detail(
823
+ project: Path,
824
+ *,
825
+ file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
826
+ ) -> dict[str, Any]:
827
+ """Hash product tree excluding .harness/**; report truncation metadata (Y3)."""
828
+ project = project.resolve()
829
+ skip_dirs = {
830
+ ".harness",
831
+ ".git",
832
+ ".worktrees",
833
+ "node_modules",
834
+ ".next",
835
+ "dist",
836
+ "coverage",
837
+ "__pycache__",
838
+ ".venv",
839
+ "venv",
840
+ }
841
+ limit = max(1, int(file_limit))
842
+ lines: list[str] = []
843
+ truncated = False
844
+ for root, dirs, files in os.walk(project, onerror=lambda _exc: None):
845
+ dirs[:] = [name for name in dirs if name not in skip_dirs]
846
+ for name in sorted(files):
847
+ path = Path(root) / name
848
+ try:
849
+ rel = path.relative_to(project).as_posix()
850
+ except ValueError:
851
+ continue
852
+ try:
853
+ digest = sha256_file(path)
854
+ except OSError:
855
+ continue
856
+ lines.append(f"{rel}:{digest}")
857
+ if len(lines) >= limit:
858
+ truncated = True
859
+ break
860
+ if truncated:
861
+ break
862
+ digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
863
+ return {
864
+ "hash": digest,
865
+ "truncated": truncated,
866
+ "fileCount": len(lines),
867
+ "limit": limit,
868
+ }
869
+
870
+
871
+ def compute_product_tree_hash(
872
+ project: Path,
873
+ *,
874
+ file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
875
+ ) -> str:
876
+ """Hash product tree excluding .harness/** governance paths."""
877
+ return str(compute_product_tree_hash_detail(project, file_limit=file_limit)["hash"])
878
+
879
+
880
+ def resolve_product_archive_identity(
881
+ change_dir: Path,
882
+ *,
883
+ project: Path | None = None,
884
+ product_commit: str | None = None,
885
+ archive_commit: str | None = None,
886
+ ) -> dict[str, Any]:
887
+ """Resolve the three-way archive identity fields for summary/meta."""
888
+ change_dir = change_dir.resolve()
889
+ project_root = (project or find_project_root(change_dir)).resolve()
890
+ ledger = load_ledger(change_dir) or {}
891
+ ci = load_product_candidate_ci(change_dir) or {}
892
+ candidate_subject = (
893
+ ci.get("subject") if isinstance(ci.get("subject"), dict) else {}
894
+ )
895
+ product = (
896
+ product_commit
897
+ or ledger.get("productCommit")
898
+ or candidate_subject.get("productCommit")
899
+ or ci.get("commit")
900
+ or ledger.get("finalCommit")
901
+ or ""
902
+ )
903
+ archive = (
904
+ archive_commit
905
+ or ledger.get("archiveCommit")
906
+ or ledger.get("finalCommit")
907
+ or product
908
+ )
909
+ tree = str(ledger.get("productTreeHash") or "")
910
+ under_harness = False
911
+ try:
912
+ change_dir.relative_to(project_root / ".harness")
913
+ under_harness = True
914
+ except ValueError:
915
+ under_harness = False
916
+ tree_meta: dict[str, Any] = {
917
+ "truncated": False,
918
+ "fileCount": 0,
919
+ "limit": PRODUCT_TREE_HASH_FILE_LIMIT,
920
+ }
921
+ if not tree:
922
+ if under_harness:
923
+ try:
924
+ tree_meta = compute_product_tree_hash_detail(project_root)
925
+ tree = str(tree_meta["hash"])
926
+ except OSError:
927
+ tree = hashlib.sha256(b"").hexdigest()
928
+ else:
929
+ # Bare temp fixtures are not real project roots — avoid scanning up-tree.
930
+ tree = hashlib.sha256(b"fixture-no-product-tree").hexdigest()
931
+ identity = {
932
+ "productCommit": str(product),
933
+ "productTreeHash": str(tree),
934
+ "archiveCommit": str(archive),
935
+ "productTreeHashTruncated": bool(tree_meta.get("truncated")),
936
+ "productTreeHashFileCount": int(tree_meta.get("fileCount") or 0),
937
+ }
938
+ expected_tree = tree
939
+ if under_harness:
940
+ try:
941
+ expected_meta = compute_product_tree_hash_detail(project_root)
942
+ expected_tree = str(expected_meta["hash"])
943
+ identity["productTreeHashTruncated"] = bool(expected_meta.get("truncated"))
944
+ identity["productTreeHashFileCount"] = int(expected_meta.get("fileCount") or 0)
945
+ except OSError:
946
+ expected_tree = tree
947
+ identity["validation"] = validate_product_identity(
948
+ product_commit=identity["productCommit"],
949
+ product_tree_hash=identity["productTreeHash"],
950
+ archive_commit=identity["archiveCommit"] or "unknown",
951
+ project=project_root if under_harness else None,
952
+ expected_tree_hash=expected_tree if under_harness else tree,
953
+ )
954
+ return identity
955
+
956
+
957
+ def validate_product_identity(
958
+ *,
959
+ product_commit: str,
960
+ product_tree_hash: str,
961
+ archive_commit: str,
962
+ project: Path | None = None,
963
+ expected_tree_hash: str | None = None,
964
+ ) -> dict[str, Any]:
965
+ """Validate productCommit/productTreeHash/archiveCommit relationship."""
966
+ if not product_commit or not product_tree_hash or not archive_commit:
967
+ return {
968
+ "ok": False,
969
+ "code": "PRODUCT_IDENTITY_INCOMPLETE",
970
+ "message": "productCommit, productTreeHash, and archiveCommit are required",
971
+ }
972
+ expected = expected_tree_hash
973
+ if expected is None and project is not None:
974
+ expected = compute_product_tree_hash(project)
975
+ if expected is not None and str(product_tree_hash) != str(expected):
976
+ return {
977
+ "ok": False,
978
+ "code": "PRODUCT_TREE_HASH_MISMATCH",
979
+ "message": (
980
+ f"productTreeHash={product_tree_hash} does not match "
981
+ f"product tree={expected} for productCommit={product_commit}"
982
+ ),
983
+ "expected": expected,
984
+ "actual": product_tree_hash,
985
+ }
986
+ return {
987
+ "ok": True,
988
+ "code": "PRODUCT_IDENTITY_OK",
989
+ "productCommit": product_commit,
990
+ "productTreeHash": product_tree_hash,
991
+ "archiveCommit": archive_commit,
992
+ }
993
+
994
+
995
+ def evaluate_release_evidence(
996
+ archived_identity: dict[str, Any],
997
+ *,
998
+ current_product_tree_hash: str,
999
+ ) -> dict[str, Any]:
1000
+ """Old archive cannot remain release evidence after product inputs change."""
1001
+ archived_tree = str(archived_identity.get("productTreeHash") or "")
1002
+ if not archived_tree:
1003
+ return {
1004
+ "ok": False,
1005
+ "code": "ARCHIVE_EVIDENCE_REOPEN_REQUIRED",
1006
+ "message": "archived productTreeHash missing; reopen required",
1007
+ }
1008
+ if archived_tree != str(current_product_tree_hash):
1009
+ return {
1010
+ "ok": False,
1011
+ "code": "ARCHIVE_EVIDENCE_REOPEN_REQUIRED",
1012
+ "message": (
1013
+ "product inputs changed since archive; "
1014
+ f"archivedTree={archived_tree} currentTree={current_product_tree_hash}"
1015
+ ),
1016
+ "archivedProductTreeHash": archived_tree,
1017
+ "currentProductTreeHash": current_product_tree_hash,
1018
+ }
1019
+ return {
1020
+ "ok": True,
1021
+ "code": "ARCHIVE_EVIDENCE_CURRENT",
1022
+ "productTreeHash": archived_tree,
1023
+ }
1024
+
1025
+
1026
+ def build_workflow_timing(
1027
+ events: list[dict[str, Any]],
1028
+ *,
1029
+ report_cutoff_at: str | None = None,
1030
+ ) -> dict[str, Any]:
1031
+ """Formal multi-semantic timing object (IA-2 / IA-6)."""
1032
+ stamps = [e.get("timestamp") for e in events if e.get("timestamp")]
1033
+ started = str(stamps[0]) if stamps else None
1034
+ cutoff = report_cutoff_at or (str(stamps[-1]) if stamps else None)
1035
+ stage_active = 0
1036
+ stage_wall = 0
1037
+ unclosed = 0
1038
+ for _phase, phase_events in he.group_events_by_phase(events):
1039
+ timing = he.canonical_phase_timing(phase_events, cutoff_ts=cutoff)
1040
+ stage_active += int(timing.get("activeExecutionMs") or 0)
1041
+ stage_wall += int(timing.get("wallClockSpanMs") or 0)
1042
+ unclosed += int(timing.get("unclosedAttemptCount") or 0)
1043
+ workflow_wall = he.duration_ms_between(started, cutoff) if started and cutoff else 0
1044
+ external = 0
1045
+ for event in events:
1046
+ etype = str(event.get("type") or "").lower()
1047
+ if etype in {"external.wait", "ci.wait", "environment.wait", "env.wait"}:
1048
+ dur = event.get("duration_ms") or event.get("durationMs")
1049
+ if isinstance(dur, int):
1050
+ external += max(0, dur)
1051
+ elif event.get("timestamp") and event.get("endedAt"):
1052
+ gap = he.duration_ms_between(event.get("timestamp"), event.get("endedAt"))
1053
+ if gap:
1054
+ external += gap
1055
+ unattributed = max(0, int(workflow_wall or 0) - stage_active - external)
1056
+ post_archive_excluded = 0
1057
+ if cutoff:
1058
+ for event in events:
1059
+ if str(event.get("phase") or "").lower() != "archive":
1060
+ continue
1061
+ ts = event.get("timestamp")
1062
+ if ts and he.duration_ms_between(cutoff, ts) and he.duration_ms_between(cutoff, ts) > 0:
1063
+ # timestamp after cutoff
1064
+ start_dt = he.parse_timestamp(cutoff)
1065
+ end_dt = he.parse_timestamp(ts)
1066
+ if start_dt and end_dt and end_dt > start_dt:
1067
+ post_archive_excluded += 1
1068
+ return {
1069
+ "workflowStartedAt": started or NOT_AVAILABLE,
1070
+ "reportCutoffAt": cutoff or NOT_AVAILABLE,
1071
+ "workflowWallClockMs": int(workflow_wall or 0),
1072
+ "stageActiveExecutionMs": int(stage_active),
1073
+ "stageWallClockSpanMs": int(stage_wall),
1074
+ "externalWaitMs": int(external),
1075
+ "agentOrToolUnattributedMs": int(unattributed),
1076
+ "unclosedAttemptCount": int(unclosed),
1077
+ "postArchiveEventsExcluded": int(post_archive_excluded),
1078
+ "totalMinutesSemantics": "active-only",
1079
+ }
1080
+
1081
+
1082
+ def verify_manifest_byte_coverage(
1083
+ root: Path,
1084
+ manifest: dict[str, Any],
1085
+ *,
1086
+ exclude_paths: list[str] | None = None,
1087
+ exclusion_reasons: dict[str, str] | None = None,
1088
+ ) -> dict[str, Any]:
1089
+ """Verify every covered manifest entry still matches on-disk bytes (IA-7)."""
1090
+ root = root.resolve()
1091
+ excluded = {
1092
+ p.replace("\\", "/") for p in (exclude_paths or [])
1093
+ }
1094
+ reasons = {
1095
+ k.replace("\\", "/"): v for k, v in (exclusion_reasons or {}).items()
1096
+ }
1097
+ mismatches: list[dict[str, str]] = []
1098
+ checked = 0
1099
+ for item in manifest.get("files") or []:
1100
+ if not isinstance(item, dict):
1101
+ continue
1102
+ rel = str(item.get("path") or "").replace("\\", "/")
1103
+ if not rel or _manifest_path_excluded(rel):
1104
+ continue
1105
+ if rel in excluded:
1106
+ reasons.setdefault(rel, "excluded from checksum coverage")
1107
+ continue
1108
+ path = root / rel
1109
+ if not path.is_file():
1110
+ mismatches.append({"path": rel, "reason": "missing"})
1111
+ continue
1112
+ actual = sha256_file(path)
1113
+ expected = str(item.get("sha256") or "")
1114
+ checked += 1
1115
+ if actual != expected:
1116
+ mismatches.append(
1117
+ {
1118
+ "path": rel,
1119
+ "reason": "hash-mismatch",
1120
+ "expected": expected,
1121
+ "actual": actual,
1122
+ }
1123
+ )
1124
+ ok = not mismatches
1125
+ if ok and reasons:
1126
+ checksum_status = "OK_WITH_EXCLUSIONS"
1127
+ elif ok:
1128
+ checksum_status = "OK"
1129
+ else:
1130
+ checksum_status = "FAIL"
1131
+ return {
1132
+ "ok": ok,
1133
+ "checksumStatus": checksum_status,
1134
+ "checked": checked,
1135
+ "mismatched": mismatches,
1136
+ "exclusionReasons": reasons,
1137
+ }
1138
+
334
1139
  def load_execution_log(change_dir: Path) -> str:
335
1140
  for rel in ("logs/execution-log.md", "execution-log.md"):
336
1141
  path = change_dir / rel
@@ -491,8 +1296,13 @@ def check_status(
491
1296
  change_dir: Path,
492
1297
  *,
493
1298
  allow_missing_review: bool = False,
1299
+ archive_intent: str = "release-candidate",
494
1300
  ) -> dict[str, Any]:
495
1301
  """Read-only archive preconditions. Never mutates."""
1302
+ if archive_intent not in {"release-candidate", "record-only"}:
1303
+ raise ValueError(
1304
+ "archive_intent must be release-candidate or record-only"
1305
+ )
496
1306
  blockers: list[dict[str, str]] = []
497
1307
  warnings: list[dict[str, str]] = []
498
1308
  checks: dict[str, Any] = {}
@@ -501,6 +1311,7 @@ def check_status(
501
1311
  return {
502
1312
  "ok": False,
503
1313
  "archivable": False,
1314
+ "releaseEligible": False,
504
1315
  "change_dir": str(change_dir),
505
1316
  "blockers": [{"code": "missing-change-dir", "message": f"not found: {change_dir}"}],
506
1317
  "warnings": [],
@@ -731,6 +1542,31 @@ def check_status(
731
1542
  else:
732
1543
  blockers.append(tier_issue)
733
1544
 
1545
+ # Candidate verification is a release gate, not an archive-integrity fact.
1546
+ # A record-only archive may persist incomplete work but can never claim
1547
+ # release eligibility.
1548
+ ci_gate = evaluate_product_ci_gate(change_dir)
1549
+ checks["product_candidate_ci"] = {
1550
+ "ok": bool(ci_gate.get("ok")),
1551
+ "code": ci_gate.get("code"),
1552
+ "evidence": ci_gate.get("evidence"),
1553
+ }
1554
+ if not ci_gate.get("ok"):
1555
+ candidate_issue = {
1556
+ "code": (
1557
+ "PRODUCT_CANDIDATE_NOT_VERIFIED"
1558
+ if archive_intent == "record-only"
1559
+ else str(ci_gate.get("code") or "PRODUCT_CANDIDATE_NOT_VERIFIED")
1560
+ ),
1561
+ "message": str(
1562
+ ci_gate.get("message") or "product candidate not verified"
1563
+ ),
1564
+ }
1565
+ if archive_intent == "record-only":
1566
+ warnings.append(candidate_issue)
1567
+ else:
1568
+ blockers.append(candidate_issue)
1569
+
734
1570
  # --- artifact preflight (retro §5.31) ---
735
1571
  # Classify artifact events before destructive finalize: informational (OK),
736
1572
  # canonicalizable (same-change repo-relative path, warning), or blocking
@@ -760,9 +1596,20 @@ def check_status(
760
1596
  })
761
1597
 
762
1598
  archivable = len(blockers) == 0
1599
+ candidate_codes = {
1600
+ "PRODUCT_CI_NOT_GREEN",
1601
+ "PRODUCT_CANDIDATE_NOT_VERIFIED",
1602
+ }
1603
+ archive_integrity_ok = not any(
1604
+ item.get("code") not in candidate_codes for item in blockers
1605
+ )
763
1606
  return {
764
1607
  "ok": True,
765
1608
  "archivable": archivable,
1609
+ "archiveIntent": archive_intent,
1610
+ "archiveIntegrity": {"ok": archive_integrity_ok},
1611
+ "candidateVerification": ci_gate,
1612
+ "releaseEligible": bool(ci_gate.get("ok")) and archivable,
766
1613
  "change_dir": str(change_dir),
767
1614
  "change_name": infer_change_name(change_dir),
768
1615
  "blockers": blockers,
@@ -1315,10 +2162,17 @@ def _durations_from_event_phases(
1315
2162
  events: list[dict[str, Any]] | None = None,
1316
2163
  ) -> dict[str, Any]:
1317
2164
  # Canonical per-phase timing (UT-008/RET-20): one reducer feeds every view.
2165
+ # totalMinutes remains active-only (IA-2); use top-level timing for wall clock.
2166
+ cutoff = None
2167
+ if events:
2168
+ stamps = [e.get("timestamp") for e in events if e.get("timestamp")]
2169
+ cutoff = str(stamps[-1]) if stamps else None
1318
2170
  canonical: dict[str, dict[str, Any]] = {}
1319
2171
  if events:
1320
2172
  for phase_name, phase_events in he.group_events_by_phase(events):
1321
- canonical[phase_name] = he.canonical_phase_timing(phase_events)
2173
+ canonical[phase_name] = he.canonical_phase_timing(
2174
+ phase_events, cutoff_ts=cutoff
2175
+ )
1322
2176
  stages: list[dict[str, Any]] = []
1323
2177
  total_ms = 0
1324
2178
  for name, info in (event_summary.get("phases") or {}).items():
@@ -1338,6 +2192,7 @@ def _durations_from_event_phases(
1338
2192
  "startedAt": info.get("started_at") or NOT_AVAILABLE,
1339
2193
  "endedAt": info.get("ended_at") or NOT_AVAILABLE,
1340
2194
  "minutes": minutes,
2195
+ "minutesSemantics": "active-only",
1341
2196
  "result": result,
1342
2197
  "attempts": attempts,
1343
2198
  }
@@ -1346,11 +2201,13 @@ def _durations_from_event_phases(
1346
2201
  stage["activeExecutionMs"] = timing.get("activeExecutionMs")
1347
2202
  stage["wallClockSpanMs"] = timing.get("wallClockSpanMs")
1348
2203
  stage["lateEventCount"] = timing.get("lateEventCount")
2204
+ stage["unclosedAttemptCount"] = timing.get("unclosedAttemptCount")
1349
2205
  stages.append(stage)
1350
2206
  total_min = round(total_ms / 60000, 2)
1351
2207
  return {
1352
- "totalLabel": f"约 {int(round(total_min))} ",
2208
+ "totalLabel": f"约 {int(round(total_min))} 分(活动执行)",
1353
2209
  "totalMinutes": total_min,
2210
+ "totalMinutesSemantics": "active-only",
1354
2211
  "stages": stages,
1355
2212
  }
1356
2213
 
@@ -1950,6 +2807,10 @@ def collect_summary_data(
1950
2807
  "maintenanceNotes",
1951
2808
  "knownRisks",
1952
2809
  "manualActions",
2810
+ "archiveIntent",
2811
+ "archiveIntegrity",
2812
+ "candidateVerification",
2813
+ "releaseEligible",
1953
2814
  ):
1954
2815
  if key in existing:
1955
2816
  data[key] = _deepcopy_json(existing[key])
@@ -2049,21 +2910,81 @@ def collect_summary_data(
2049
2910
 
2050
2911
  gate_policy = load_gate_policy(change_dir)
2051
2912
  data["riskTier"] = str((gate_policy or {}).get("tier") or "unknown")
2913
+ intent_path = change_dir / "meta" / "archive-intent.json"
2914
+ intent_data: dict[str, Any] = {}
2915
+ if intent_path.is_file():
2916
+ try:
2917
+ loaded_intent = read_json(intent_path)
2918
+ intent_data = loaded_intent if isinstance(loaded_intent, dict) else {}
2919
+ sources.append("meta/archive-intent.json")
2920
+ except (OSError, json.JSONDecodeError):
2921
+ intent_data = {}
2922
+ candidate_gate = evaluate_product_ci_gate(change_dir)
2923
+ data["archiveIntent"] = str(
2924
+ intent_data.get("intent") or "release-candidate"
2925
+ )
2926
+ data["archiveIntegrity"] = {"ok": True}
2927
+ data["candidateVerification"] = candidate_gate
2928
+ data["releaseEligible"] = bool(candidate_gate.get("ok"))
2052
2929
 
2053
2930
  # durations / skillCalls
2054
2931
  if events:
2055
2932
  data["durations"] = _durations_from_event_phases(event_summary, events)
2056
2933
  data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
2934
+ stamps = [e.get("timestamp") for e in projected_events if e.get("timestamp")]
2935
+ cutoff = str(stamps[-1]) if stamps else None
2936
+ data["timing"] = build_workflow_timing(projected_events, report_cutoff_at=cutoff)
2057
2937
  elif log_text and (not for_replay or not data.get("durations")):
2058
2938
  data["durations"] = _parse_durations_from_log(log_text)
2059
2939
  data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
2940
+ data.setdefault(
2941
+ "timing",
2942
+ {
2943
+ "workflowStartedAt": NOT_AVAILABLE,
2944
+ "reportCutoffAt": NOT_AVAILABLE,
2945
+ "workflowWallClockMs": 0,
2946
+ "stageActiveExecutionMs": int(
2947
+ round(float(data["durations"].get("totalMinutes") or 0) * 60000)
2948
+ ),
2949
+ "stageWallClockSpanMs": 0,
2950
+ "externalWaitMs": 0,
2951
+ "agentOrToolUnattributedMs": 0,
2952
+ "unclosedAttemptCount": 0,
2953
+ "postArchiveEventsExcluded": 0,
2954
+ "totalMinutesSemantics": "active-only",
2955
+ },
2956
+ )
2060
2957
  elif not data.get("durations"):
2061
2958
  data["durations"] = {
2062
2959
  "totalLabel": NOT_AVAILABLE,
2063
2960
  "totalMinutes": 0,
2961
+ "totalMinutesSemantics": "active-only",
2064
2962
  "stages": [],
2065
2963
  }
2066
2964
  data["skillCalls"] = []
2965
+ data.setdefault(
2966
+ "timing",
2967
+ {
2968
+ "workflowStartedAt": NOT_AVAILABLE,
2969
+ "reportCutoffAt": NOT_AVAILABLE,
2970
+ "workflowWallClockMs": 0,
2971
+ "stageActiveExecutionMs": 0,
2972
+ "stageWallClockSpanMs": 0,
2973
+ "externalWaitMs": 0,
2974
+ "agentOrToolUnattributedMs": 0,
2975
+ "unclosedAttemptCount": 0,
2976
+ "postArchiveEventsExcluded": 0,
2977
+ "totalMinutesSemantics": "active-only",
2978
+ },
2979
+ )
2980
+
2981
+ # IA-1/4 identity (product vs archive)
2982
+ if not for_replay:
2983
+ identity = resolve_product_archive_identity(change_dir, project=project)
2984
+ data["productCommit"] = identity.get("productCommit") or data.get("finalCommit")
2985
+ data["productTreeHash"] = identity.get("productTreeHash")
2986
+ data["archiveCommit"] = identity.get("archiveCommit") or data.get("finalCommit")
2987
+ data["changeIdentity"] = identity
2067
2988
 
2068
2989
  # diffStat / changedFiles
2069
2990
  if not for_replay or not data.get("changedFiles"):
@@ -2305,6 +3226,33 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
2305
3226
  if summary.get("riskTier"):
2306
3227
  parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
2307
3228
 
3229
+ timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
3230
+ if timing:
3231
+ parts.append("<h3>Timing</h3>")
3232
+ parts.append(
3233
+ "<p id=\"timingColumns\">"
3234
+ f"stageActiveExecution={esc(timing.get('stageActiveExecutionMs'))} · "
3235
+ f"stageWallClockSpan={esc(timing.get('stageWallClockSpanMs'))} · "
3236
+ f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
3237
+ "</p>"
3238
+ )
3239
+ parts.append(
3240
+ f"<p><strong>reportCutoffAt</strong>: "
3241
+ f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
3242
+ )
3243
+ parts.append(
3244
+ "<p><small>durations.totalMinutes is active-only; "
3245
+ "do not treat it as workflow wall clock.</small></p>"
3246
+ )
3247
+
3248
+ if summary.get("productCommit") or summary.get("productTreeHash"):
3249
+ parts.append("<h3>Identity</h3>")
3250
+ parts.append(
3251
+ f"<p>productCommit={esc(summary.get('productCommit'))} "
3252
+ f"productTreeHash={esc(summary.get('productTreeHash'))} "
3253
+ f"archiveCommit={esc(summary.get('archiveCommit'))}</p>"
3254
+ )
3255
+
2308
3256
  pipeline = summary.get("reportPipeline") or {}
2309
3257
  cmds = pipeline.get("commands") or []
2310
3258
  parts.append("<h3>Commands</h3><ul>")
@@ -3240,8 +4188,15 @@ def cmd_finalize(
3240
4188
  *,
3241
4189
  skip_ingest: bool = False,
3242
4190
  allow_missing_review: bool = False,
4191
+ archive_intent: str = "release-candidate",
3243
4192
  ) -> tuple[int, dict[str, Any]]:
3244
4193
  """Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
4194
+ if archive_intent not in {"release-candidate", "record-only"}:
4195
+ return 1, {
4196
+ "ok": False,
4197
+ "action": "finalize",
4198
+ "error": "archive_intent must be release-candidate or record-only",
4199
+ }
3245
4200
  warnings: list[str] = []
3246
4201
  original_change_dir = change_dir.resolve()
3247
4202
  change_name = original_change_dir.name
@@ -3307,6 +4262,8 @@ def cmd_finalize(
3307
4262
  "operationId": operation_id,
3308
4263
  "operationTempDir": str(operation_temp_dir),
3309
4264
  "operationRecord": str(operation_record),
4265
+ "archiveIntent": archive_intent,
4266
+ "releaseEligible": False,
3310
4267
  "warnings": warnings,
3311
4268
  "steps": {},
3312
4269
  }
@@ -3458,6 +4415,65 @@ def cmd_finalize(
3458
4415
  payload["ok"] = False
3459
4416
  return 1, payload
3460
4417
 
4418
+ # --- 2d. product candidate verification / release eligibility ---
4419
+ if (
4420
+ not (
4421
+ work_dir / "evidence" / "product-candidate-verification.json"
4422
+ ).is_file()
4423
+ and _legacy_candidate_path(work_dir) is not None
4424
+ ):
4425
+ try:
4426
+ migrated = migrate_legacy_candidate_evidence(
4427
+ work_dir, project=project_root
4428
+ )
4429
+ payload["steps"]["candidate_evidence_migration"] = {
4430
+ "ok": True,
4431
+ "provider": migrated.get("provider"),
4432
+ "assurance": migrated.get("assurance"),
4433
+ }
4434
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
4435
+ payload["steps"]["candidate_evidence_migration"] = {
4436
+ "ok": False,
4437
+ "error": str(exc),
4438
+ }
4439
+ warnings.append(f"legacy candidate evidence migration failed: {exc}")
4440
+ ci_gate = evaluate_product_ci_gate(work_dir)
4441
+ payload["steps"]["product_candidate_ci"] = {
4442
+ "ok": bool(ci_gate.get("ok")),
4443
+ "code": ci_gate.get("code"),
4444
+ "message": ci_gate.get("message"),
4445
+ }
4446
+ payload["candidateVerification"] = ci_gate
4447
+ payload["releaseEligible"] = bool(ci_gate.get("ok"))
4448
+ if not ci_gate.get("ok") and archive_intent == "release-candidate":
4449
+ payload["error"] = str(ci_gate.get("message") or "PRODUCT_CI_NOT_GREEN")
4450
+ payload["issues"] = [
4451
+ {
4452
+ "code": "PRODUCT_CI_NOT_GREEN",
4453
+ "severity": "error",
4454
+ "message": str(ci_gate.get("message") or "product candidate CI not green"),
4455
+ }
4456
+ ]
4457
+ _restore_finalize_failure()
4458
+ payload["warnings"] = warnings
4459
+ payload["ok"] = False
4460
+ return 1, payload
4461
+ if not ci_gate.get("ok"):
4462
+ warnings.append(
4463
+ "record-only archive is not release eligible: "
4464
+ + str(ci_gate.get("message") or "product candidate not verified")
4465
+ )
4466
+ write_json(
4467
+ work_dir / "meta" / "archive-intent.json",
4468
+ {
4469
+ "schemaVersion": 1,
4470
+ "intent": archive_intent,
4471
+ "candidateVerificationCode": ci_gate.get("code"),
4472
+ "releaseEligible": bool(ci_gate.get("ok")),
4473
+ "recordedAt": now_iso(),
4474
+ },
4475
+ )
4476
+
3461
4477
  # --- 3. candidate phase.end + freeze cutoff (RET-19 freeze-first) ---
3462
4478
  # This terminal exists only inside the isolated operation staging tree. It
3463
4479
  # becomes authoritative atomically at publish; validation failure discards
@@ -3589,7 +4605,31 @@ def cmd_finalize(
3589
4605
  warnings.append(f"archive-meta write failed: {exc}")
3590
4606
  payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
3591
4607
 
3592
- # --- 9. after-manifest + compare ---
4608
+ # --- 9/10. final summary stats + render, then LAST manifest (IA-7) ---
4609
+ # Post-manifest rewrites of covered bytes are forbidden. We update summary /
4610
+ # HTML first, regenerate after-manifest last, then verify on-disk hashes.
4611
+ # If summary/html must still change after that, they are excluded with reasons.
4612
+ summary = read_json(summary_path)
4613
+ summary["archiveManifest"] = {
4614
+ "movedFiles": 0,
4615
+ "generatedFiles": 0,
4616
+ "totalArchiveFiles": 0,
4617
+ "checksumStatus": "PENDING",
4618
+ }
4619
+ write_json(summary_path, summary)
4620
+ render_result = render_final_summary(work_dir, summary_path)
4621
+ if not render_result.get("ok"):
4622
+ payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
4623
+ _restore_finalize_failure()
4624
+ return 1, payload
4625
+ summary = read_json(summary_path)
4626
+ validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
4627
+ payload["steps"]["validate"] = validate_result
4628
+ summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
4629
+ "issues"
4630
+ ) or []
4631
+ write_json(summary_path, summary)
4632
+
3593
4633
  after_path = work_dir / "evidence" / "archive-manifest-after.json"
3594
4634
  try:
3595
4635
  after_manifest = generate_manifest(work_dir, after_path)
@@ -3597,10 +4637,71 @@ def cmd_finalize(
3597
4637
  if before_in_archive.is_file():
3598
4638
  before_manifest = read_json(before_in_archive)
3599
4639
  compare_result = compare_manifests(before_manifest, after_manifest)
4640
+ coverage = verify_manifest_byte_coverage(work_dir, after_manifest)
4641
+ # Embed compare stats into summary AFTER manifest → must exclude those paths.
4642
+ summary = read_json(summary_path)
4643
+ summary["archiveManifest"] = {
4644
+ "movedFiles": compare_result.get("movedFiles", 0),
4645
+ "generatedFiles": compare_result.get("generatedFiles", 0),
4646
+ "totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
4647
+ "checksumStatus": coverage.get("checksumStatus", "FAIL"),
4648
+ "exclusionReasons": {
4649
+ "reports/final/summary-data.json": (
4650
+ "archiveManifest stats written after coverage snapshot"
4651
+ ),
4652
+ "reports/final/final-summary.html": (
4653
+ "re-rendered after coverage snapshot to display manifest stats"
4654
+ ),
4655
+ },
4656
+ **_manifest_self_stats(work_dir, after_manifest, after_path),
4657
+ }
4658
+ write_json(summary_path, summary)
4659
+ render_result = render_final_summary(work_dir, summary_path)
4660
+ if not render_result.get("ok"):
4661
+ payload["error"] = (
4662
+ f"post-manifest summary render failed: {render_result.get('error')}"
4663
+ )
4664
+ _restore_finalize_failure()
4665
+ return 1, payload
4666
+ coverage = verify_manifest_byte_coverage(
4667
+ work_dir,
4668
+ after_manifest,
4669
+ exclude_paths=[
4670
+ "reports/final/summary-data.json",
4671
+ "reports/final/final-summary.html",
4672
+ ],
4673
+ exclusion_reasons=summary["archiveManifest"]["exclusionReasons"],
4674
+ )
4675
+ summary["archiveManifest"]["checksumStatus"] = coverage.get(
4676
+ "checksumStatus", "FAIL"
4677
+ )
4678
+ summary["archiveManifest"]["exclusionReasons"] = coverage.get(
4679
+ "exclusionReasons"
4680
+ ) or summary["archiveManifest"]["exclusionReasons"]
4681
+ summary["archiveManifest"]["coverageChecked"] = coverage.get("checked")
4682
+ write_json(summary_path, summary)
4683
+ # Re-render once more for checksumStatus display; remain excluded.
4684
+ render_final_summary(work_dir, summary_path)
4685
+ summary = read_json(summary_path)
4686
+ validate_result = validate_summary(
4687
+ summary, html_path if html_path.is_file() else None
4688
+ )
4689
+ payload["steps"]["validate"] = validate_result
4690
+ summary.setdefault("reportPipeline", {})["validationIssues"] = (
4691
+ validate_result.get("issues") or []
4692
+ )
4693
+ write_json(summary_path, summary)
3600
4694
  payload["steps"]["after_manifest"] = {
3601
- "ok": True,
4695
+ "ok": bool(coverage.get("ok")) and bool(compare_result.get("ok")),
3602
4696
  "path": str(after_path),
3603
4697
  "compare": compare_result,
4698
+ "coverage": coverage,
4699
+ }
4700
+ compare_result = {
4701
+ **compare_result,
4702
+ "ok": bool(compare_result.get("ok")) and bool(coverage.get("ok")),
4703
+ "checksumStatus": coverage.get("checksumStatus"),
4704
+ "exclusionReasons": coverage.get("exclusionReasons"),
3604
4705
  }
3605
4706
  except (OSError, json.JSONDecodeError, ValueError) as exc:
3606
4707
  payload["error"] = f"after-manifest failed: {exc}"
@@ -3608,42 +4709,25 @@ def cmd_finalize(
3608
4709
  _restore_finalize_failure()
3609
4710
  return 1, payload
3610
4711
 
3611
- # --- 10. final summary update + final render/validate (no more events) ---
3612
- summary = read_json(summary_path)
3613
- summary["archiveManifest"] = {
3614
- "movedFiles": compare_result.get("movedFiles", 0),
3615
- "generatedFiles": compare_result.get("generatedFiles", 0),
3616
- "totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
3617
- "checksumStatus": compare_result.get("checksumStatus", "FAIL"),
3618
- **_manifest_self_stats(work_dir, after_manifest, after_path),
3619
- }
3620
- write_json(summary_path, summary)
3621
- render_result = render_final_summary(work_dir, summary_path)
3622
- if not render_result.get("ok"):
3623
- payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
3624
- _restore_finalize_failure()
3625
- return 1, payload
3626
- summary = read_json(summary_path)
3627
- validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
3628
- payload["steps"]["validate"] = validate_result
3629
- summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get("issues") or []
3630
- write_json(summary_path, summary)
3631
-
3632
4712
  # --- 11. closure: only when both validators and manifest pass ---
3633
4713
  validate_ok = bool(validate_result.get("ok"))
3634
4714
  manifest_ok = bool(compare_result.get("ok"))
3635
- can_close = validate_ok and manifest_ok
4715
+ # Honest checksum: FAIL blocks; OK and OK_WITH_EXCLUSIONS (with reasons) pass.
4716
+ checksum = str(compare_result.get("checksumStatus") or "")
4717
+ checksum_ok = checksum in {"OK", "OK_WITH_EXCLUSIONS"}
4718
+ can_close = validate_ok and manifest_ok and checksum_ok
3636
4719
 
3637
4720
  if not can_close:
3638
4721
  issues_out = list(validate_result.get("issues") or [])
3639
- if not manifest_ok:
4722
+ if not manifest_ok or not checksum_ok:
3640
4723
  issues_out.append(
3641
4724
  {
3642
4725
  "code": "manifest-mismatch",
3643
4726
  "severity": "error",
3644
4727
  "message": (
3645
4728
  f"missing={compare_result.get('missing')} "
3646
- f"mismatched={compare_result.get('mismatched')}"
4729
+ f"mismatched={compare_result.get('mismatched')} "
4730
+ f"checksumStatus={checksum}"
3647
4731
  ),
3648
4732
  }
3649
4733
  )
@@ -3850,12 +4934,46 @@ def cmd_status_cli(args: argparse.Namespace) -> int:
3850
4934
  result = check_status(
3851
4935
  change_dir,
3852
4936
  allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
4937
+ archive_intent=str(getattr(args, "intent", "release-candidate")),
3853
4938
  )
3854
4939
  emit_json(result)
3855
4940
  # Checks completed → exit 0; archivable flag conveys the gate result.
3856
4941
  return 0 if result.get("ok") else 1
3857
4942
 
3858
4943
 
4944
+ def cmd_certify_local_cli(args: argparse.Namespace) -> int:
4945
+ change_dir = resolve_path(args.change_dir)
4946
+ project = (
4947
+ resolve_path(args.project)
4948
+ if getattr(args, "project", None)
4949
+ else None
4950
+ )
4951
+ try:
4952
+ receipt = certify_local_candidate(change_dir, project=project)
4953
+ except (OSError, ValueError) as exc:
4954
+ emit_json(
4955
+ {
4956
+ "ok": False,
4957
+ "action": "certify-local",
4958
+ "error": str(exc),
4959
+ }
4960
+ )
4961
+ return 1
4962
+ emit_json(
4963
+ {
4964
+ "ok": True,
4965
+ "action": "certify-local",
4966
+ "evidence": receipt,
4967
+ "path": str(
4968
+ change_dir
4969
+ / "evidence"
4970
+ / "product-candidate-verification.json"
4971
+ ),
4972
+ }
4973
+ )
4974
+ return 0
4975
+
4976
+
3859
4977
  def cmd_finalize_cli(args: argparse.Namespace) -> int:
3860
4978
  change_dir = resolve_path(args.change_dir)
3861
4979
  archive_root = resolve_path(args.archive_root)
@@ -3864,6 +4982,7 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
3864
4982
  archive_root,
3865
4983
  skip_ingest=bool(args.skip_ingest),
3866
4984
  allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
4985
+ archive_intent=str(getattr(args, "intent", "release-candidate")),
3867
4986
  )
3868
4987
  emit_json(payload)
3869
4988
  return code
@@ -4053,8 +5172,23 @@ def build_parser() -> argparse.ArgumentParser:
4053
5172
  action="store_true",
4054
5173
  help="downgrade full-tier missing review from blocker to warning",
4055
5174
  )
5175
+ p_status.add_argument(
5176
+ "--intent",
5177
+ choices=("release-candidate", "record-only"),
5178
+ default="release-candidate",
5179
+ help="release-candidate enforces candidate evidence; record-only archives facts without release eligibility",
5180
+ )
4056
5181
  p_status.set_defaults(func=cmd_status_cli)
4057
5182
 
5183
+ p_cert = sub.add_parser(
5184
+ "certify-local",
5185
+ help="create candidate receipt from existing full ledger evidence without rerunning tests",
5186
+ )
5187
+ p_cert.add_argument("--change-dir", required=True)
5188
+ p_cert.add_argument("--project", default=None)
5189
+ p_cert.add_argument("--json", action="store_true", default=True)
5190
+ p_cert.set_defaults(func=cmd_certify_local_cli)
5191
+
4058
5192
  p_fin = sub.add_parser("finalize", help="single-process archive finalize")
4059
5193
  p_fin.add_argument("--change-dir", required=True)
4060
5194
  p_fin.add_argument("--archive-root", required=True)
@@ -4064,6 +5198,12 @@ def build_parser() -> argparse.ArgumentParser:
4064
5198
  action="store_true",
4065
5199
  help="record override when full-tier review evidence is missing",
4066
5200
  )
5201
+ p_fin.add_argument(
5202
+ "--intent",
5203
+ choices=("release-candidate", "record-only"),
5204
+ default="release-candidate",
5205
+ help="release-candidate enforces candidate evidence; record-only archives facts without release eligibility",
5206
+ )
4067
5207
  p_fin.add_argument("--json", action="store_true", default=True)
4068
5208
  p_fin.set_defaults(func=cmd_finalize_cli)
4069
5209