@hunter-harness/workflow-harness 0.2.22 → 0.2.24

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/README.md +2 -2
  3. package/harness/bundles/general/claude-code/agents/harness-evaluator.md +4 -4
  4. package/harness/bundles/general/claude-code/agents/harness-explorer.md +2 -2
  5. package/harness/bundles/general/claude-code/agents/harness-reviewer.md +2 -2
  6. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +12 -3
  7. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +11 -0
  8. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +8 -4
  12. package/harness/bundles/general/claude-code/harness-plan/checklist.md +13 -17
  13. package/harness/bundles/general/claude-code/harness-plan/reference.md +24 -34
  14. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  15. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-run/protocols.md +3 -3
  17. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +3 -1
  18. package/harness/bundles/general/claude-code/harness-submit/checklist.md +9 -3
  19. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  20. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  21. package/harness/bundles/general/claude-code/scripts/harness_archive.py +574 -12
  22. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +22 -3
  23. package/harness/bundles/general/claude-code/scripts/harness_integration.py +126 -14
  24. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +30 -6
  25. package/harness/bundles/general/claude-code/scripts/harness_preflight.py +50 -29
  26. package/harness/bundles/general/claude-code/scripts/harness_profile.py +33 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +30 -8
  28. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  29. package/harness/bundles/general/codebuddy/README.md +2 -2
  30. package/harness/bundles/general/codebuddy/agents/harness-evaluator.md +5 -6
  31. package/harness/bundles/general/codebuddy/agents/harness-explorer.md +3 -3
  32. package/harness/bundles/general/codebuddy/agents/harness-reviewer.md +3 -5
  33. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +12 -3
  34. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +11 -0
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +8 -4
  39. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +13 -17
  40. package/harness/bundles/general/codebuddy/harness-plan/reference.md +24 -34
  41. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  42. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-run/protocols.md +3 -3
  44. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +3 -1
  45. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +9 -3
  46. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  47. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  48. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +574 -12
  49. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +22 -3
  50. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +126 -14
  51. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +30 -6
  52. package/harness/bundles/general/codebuddy/scripts/harness_preflight.py +50 -29
  53. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +33 -0
  54. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +30 -8
  55. package/harness/bundles/general/codex/.harness-build.json +1 -1
  56. package/harness/bundles/general/codex/README.md +2 -2
  57. package/harness/bundles/general/codex/harness-archive/SKILL.md +12 -3
  58. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +11 -0
  59. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-plan/SKILL.md +8 -5
  63. package/harness/bundles/general/codex/harness-plan/checklist.md +13 -17
  64. package/harness/bundles/general/codex/harness-plan/reference.md +24 -34
  65. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  66. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-run/protocols.md +3 -3
  68. package/harness/bundles/general/codex/harness-submit/SKILL.md +3 -1
  69. package/harness/bundles/general/codex/harness-submit/checklist.md +9 -3
  70. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  71. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  72. package/harness/bundles/general/codex/scripts/harness_archive.py +574 -12
  73. package/harness/bundles/general/codex/scripts/harness_deploy.py +22 -3
  74. package/harness/bundles/general/codex/scripts/harness_integration.py +126 -14
  75. package/harness/bundles/general/codex/scripts/harness_ledger.py +30 -6
  76. package/harness/bundles/general/codex/scripts/harness_preflight.py +50 -29
  77. package/harness/bundles/general/codex/scripts/harness_profile.py +33 -0
  78. package/harness/bundles/general/codex/scripts/harness_test_guard.py +30 -8
  79. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  80. package/harness/bundles/general/cursor/README.md +2 -2
  81. package/harness/bundles/general/cursor/harness-archive/SKILL.md +12 -3
  82. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +11 -0
  83. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  85. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  86. package/harness/bundles/general/cursor/harness-plan/SKILL.md +8 -5
  87. package/harness/bundles/general/cursor/harness-plan/checklist.md +13 -17
  88. package/harness/bundles/general/cursor/harness-plan/reference.md +24 -34
  89. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  90. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  91. package/harness/bundles/general/cursor/harness-run/protocols.md +3 -3
  92. package/harness/bundles/general/cursor/harness-submit/SKILL.md +3 -1
  93. package/harness/bundles/general/cursor/harness-submit/checklist.md +9 -3
  94. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/scripts/harness_archive.py +574 -12
  97. package/harness/bundles/general/cursor/scripts/harness_deploy.py +22 -3
  98. package/harness/bundles/general/cursor/scripts/harness_integration.py +126 -14
  99. package/harness/bundles/general/cursor/scripts/harness_ledger.py +30 -6
  100. package/harness/bundles/general/cursor/scripts/harness_preflight.py +50 -29
  101. package/harness/bundles/general/cursor/scripts/harness_profile.py +33 -0
  102. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +30 -8
  103. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  104. package/harness/bundles/java/claude-code/README.md +2 -2
  105. package/harness/bundles/java/claude-code/agents/harness-evaluator.md +4 -4
  106. package/harness/bundles/java/claude-code/agents/harness-explorer.md +2 -2
  107. package/harness/bundles/java/claude-code/agents/harness-reviewer.md +2 -2
  108. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +12 -3
  110. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +11 -0
  111. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  113. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +8 -4
  116. package/harness/bundles/java/claude-code/harness-plan/checklist.md +13 -17
  117. package/harness/bundles/java/claude-code/harness-plan/reference.md +24 -34
  118. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  119. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  120. package/harness/bundles/java/claude-code/harness-run/protocols.md +3 -3
  121. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +3 -1
  122. package/harness/bundles/java/claude-code/harness-submit/checklist.md +9 -3
  123. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/scripts/harness_archive.py +574 -12
  126. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +22 -3
  127. package/harness/bundles/java/claude-code/scripts/harness_integration.py +126 -14
  128. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +30 -6
  129. package/harness/bundles/java/claude-code/scripts/harness_preflight.py +50 -29
  130. package/harness/bundles/java/claude-code/scripts/harness_profile.py +33 -0
  131. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +30 -8
  132. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  133. package/harness/bundles/java/codebuddy/README.md +2 -2
  134. package/harness/bundles/java/codebuddy/agents/harness-evaluator.md +5 -6
  135. package/harness/bundles/java/codebuddy/agents/harness-explorer.md +3 -3
  136. package/harness/bundles/java/codebuddy/agents/harness-reviewer.md +3 -5
  137. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  138. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +12 -3
  139. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +11 -0
  140. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  141. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  142. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  143. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +8 -4
  145. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +13 -17
  146. package/harness/bundles/java/codebuddy/harness-plan/reference.md +24 -34
  147. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  148. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-run/protocols.md +3 -3
  150. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +3 -1
  151. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +9 -3
  152. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  153. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  154. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +574 -12
  155. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +22 -3
  156. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +126 -14
  157. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +30 -6
  158. package/harness/bundles/java/codebuddy/scripts/harness_preflight.py +50 -29
  159. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +33 -0
  160. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +30 -8
  161. package/harness/bundles/java/codex/.harness-build.json +1 -1
  162. package/harness/bundles/java/codex/README.md +2 -2
  163. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  164. package/harness/bundles/java/codex/harness-archive/SKILL.md +12 -3
  165. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +11 -0
  166. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  168. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  169. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  170. package/harness/bundles/java/codex/harness-plan/SKILL.md +8 -5
  171. package/harness/bundles/java/codex/harness-plan/checklist.md +13 -17
  172. package/harness/bundles/java/codex/harness-plan/reference.md +24 -34
  173. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  174. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  175. package/harness/bundles/java/codex/harness-run/protocols.md +3 -3
  176. package/harness/bundles/java/codex/harness-submit/SKILL.md +3 -1
  177. package/harness/bundles/java/codex/harness-submit/checklist.md +9 -3
  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/scripts/harness_archive.py +574 -12
  181. package/harness/bundles/java/codex/scripts/harness_deploy.py +22 -3
  182. package/harness/bundles/java/codex/scripts/harness_integration.py +126 -14
  183. package/harness/bundles/java/codex/scripts/harness_ledger.py +30 -6
  184. package/harness/bundles/java/codex/scripts/harness_preflight.py +50 -29
  185. package/harness/bundles/java/codex/scripts/harness_profile.py +33 -0
  186. package/harness/bundles/java/codex/scripts/harness_test_guard.py +30 -8
  187. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  188. package/harness/bundles/java/cursor/README.md +2 -2
  189. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  190. package/harness/bundles/java/cursor/harness-archive/SKILL.md +12 -3
  191. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +11 -0
  192. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  193. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  194. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  195. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  196. package/harness/bundles/java/cursor/harness-plan/SKILL.md +8 -5
  197. package/harness/bundles/java/cursor/harness-plan/checklist.md +13 -17
  198. package/harness/bundles/java/cursor/harness-plan/reference.md +24 -34
  199. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  200. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  201. package/harness/bundles/java/cursor/harness-run/protocols.md +3 -3
  202. package/harness/bundles/java/cursor/harness-submit/SKILL.md +3 -1
  203. package/harness/bundles/java/cursor/harness-submit/checklist.md +9 -3
  204. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  205. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  206. package/harness/bundles/java/cursor/scripts/harness_archive.py +574 -12
  207. package/harness/bundles/java/cursor/scripts/harness_deploy.py +22 -3
  208. package/harness/bundles/java/cursor/scripts/harness_integration.py +126 -14
  209. package/harness/bundles/java/cursor/scripts/harness_ledger.py +30 -6
  210. package/harness/bundles/java/cursor/scripts/harness_preflight.py +50 -29
  211. package/harness/bundles/java/cursor/scripts/harness_profile.py +33 -0
  212. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +30 -8
  213. package/harness/manifests/general/claude-code.json +28 -28
  214. package/harness/manifests/general/codebuddy.json +28 -28
  215. package/harness/manifests/general/codex.json +25 -25
  216. package/harness/manifests/general/cursor.json +25 -25
  217. package/harness/manifests/java/claude-code.json +30 -30
  218. package/harness/manifests/java/codebuddy.json +30 -30
  219. package/harness/manifests/java/codex.json +27 -27
  220. package/harness/manifests/java/cursor.json +27 -27
  221. package/hunter-workflow-family.json +2 -2
  222. package/package.json +1 -1
@@ -331,12 +331,41 @@ 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 load_product_candidate_ci(change_dir: Path) -> dict[str, Any] | None:
335
- """Load structured product-candidate CI evidence (IA-1). Fail closed if absent."""
334
+ def _legacy_candidate_path(change_dir: Path) -> Path | None:
336
335
  for relative in (
337
336
  "evidence/product-candidate-ci.json",
338
337
  "meta/product-candidate-ci.json",
339
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",
340
369
  ):
341
370
  path = change_dir / relative
342
371
  if not path.is_file():
@@ -345,6 +374,17 @@ def load_product_candidate_ci(change_dir: Path) -> dict[str, Any] | None:
345
374
  data = read_json(path)
346
375
  except (OSError, json.JSONDecodeError):
347
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
348
388
  if isinstance(data, dict) and data.get("schemaVersion") == 1:
349
389
  return data
350
390
  ledger = load_ledger(change_dir) or {}
@@ -361,7 +401,7 @@ def load_product_candidate_ci(change_dir: Path) -> dict[str, Any] | None:
361
401
 
362
402
 
363
403
  def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
364
- """Hard gate: product candidate CI must be success with run URL + commit."""
404
+ """Evaluate product-candidate evidence independently of CI availability."""
365
405
  evidence = load_product_candidate_ci(change_dir)
366
406
  if evidence is None:
367
407
  return {
@@ -374,6 +414,133 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
374
414
  ),
375
415
  "evidence": None,
376
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
+
377
544
  conclusion = str(
378
545
  evidence.get("conclusion") or evidence.get("status") or ""
379
546
  ).strip().lower()
@@ -408,6 +575,247 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
408
575
  }
409
576
 
410
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
+
411
819
  PRODUCT_TREE_HASH_FILE_LIMIT = 20_000
412
820
 
413
821
 
@@ -481,9 +889,13 @@ def resolve_product_archive_identity(
481
889
  project_root = (project or find_project_root(change_dir)).resolve()
482
890
  ledger = load_ledger(change_dir) or {}
483
891
  ci = load_product_candidate_ci(change_dir) or {}
892
+ candidate_subject = (
893
+ ci.get("subject") if isinstance(ci.get("subject"), dict) else {}
894
+ )
484
895
  product = (
485
896
  product_commit
486
897
  or ledger.get("productCommit")
898
+ or candidate_subject.get("productCommit")
487
899
  or ci.get("commit")
488
900
  or ledger.get("finalCommit")
489
901
  or ""
@@ -884,8 +1296,13 @@ def check_status(
884
1296
  change_dir: Path,
885
1297
  *,
886
1298
  allow_missing_review: bool = False,
1299
+ archive_intent: str = "release-candidate",
887
1300
  ) -> dict[str, Any]:
888
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
+ )
889
1306
  blockers: list[dict[str, str]] = []
890
1307
  warnings: list[dict[str, str]] = []
891
1308
  checks: dict[str, Any] = {}
@@ -894,6 +1311,7 @@ def check_status(
894
1311
  return {
895
1312
  "ok": False,
896
1313
  "archivable": False,
1314
+ "releaseEligible": False,
897
1315
  "change_dir": str(change_dir),
898
1316
  "blockers": [{"code": "missing-change-dir", "message": f"not found: {change_dir}"}],
899
1317
  "warnings": [],
@@ -1124,7 +1542,9 @@ def check_status(
1124
1542
  else:
1125
1543
  blockers.append(tier_issue)
1126
1544
 
1127
- # --- IA-1 product candidate CI hard gate ---
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.
1128
1548
  ci_gate = evaluate_product_ci_gate(change_dir)
1129
1549
  checks["product_candidate_ci"] = {
1130
1550
  "ok": bool(ci_gate.get("ok")),
@@ -1132,12 +1552,20 @@ def check_status(
1132
1552
  "evidence": ci_gate.get("evidence"),
1133
1553
  }
1134
1554
  if not ci_gate.get("ok"):
1135
- blockers.append(
1136
- {
1137
- "code": "PRODUCT_CI_NOT_GREEN",
1138
- "message": str(ci_gate.get("message") or "product candidate CI not green"),
1139
- }
1140
- )
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)
1141
1569
 
1142
1570
  # --- artifact preflight (retro §5.31) ---
1143
1571
  # Classify artifact events before destructive finalize: informational (OK),
@@ -1168,9 +1596,20 @@ def check_status(
1168
1596
  })
1169
1597
 
1170
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
+ )
1171
1606
  return {
1172
1607
  "ok": True,
1173
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,
1174
1613
  "change_dir": str(change_dir),
1175
1614
  "change_name": infer_change_name(change_dir),
1176
1615
  "blockers": blockers,
@@ -2368,6 +2807,10 @@ def collect_summary_data(
2368
2807
  "maintenanceNotes",
2369
2808
  "knownRisks",
2370
2809
  "manualActions",
2810
+ "archiveIntent",
2811
+ "archiveIntegrity",
2812
+ "candidateVerification",
2813
+ "releaseEligible",
2371
2814
  ):
2372
2815
  if key in existing:
2373
2816
  data[key] = _deepcopy_json(existing[key])
@@ -2467,6 +2910,22 @@ def collect_summary_data(
2467
2910
 
2468
2911
  gate_policy = load_gate_policy(change_dir)
2469
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"))
2470
2929
 
2471
2930
  # durations / skillCalls
2472
2931
  if events:
@@ -3729,8 +4188,15 @@ def cmd_finalize(
3729
4188
  *,
3730
4189
  skip_ingest: bool = False,
3731
4190
  allow_missing_review: bool = False,
4191
+ archive_intent: str = "release-candidate",
3732
4192
  ) -> tuple[int, dict[str, Any]]:
3733
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
+ }
3734
4200
  warnings: list[str] = []
3735
4201
  original_change_dir = change_dir.resolve()
3736
4202
  change_name = original_change_dir.name
@@ -3796,6 +4262,8 @@ def cmd_finalize(
3796
4262
  "operationId": operation_id,
3797
4263
  "operationTempDir": str(operation_temp_dir),
3798
4264
  "operationRecord": str(operation_record),
4265
+ "archiveIntent": archive_intent,
4266
+ "releaseEligible": False,
3799
4267
  "warnings": warnings,
3800
4268
  "steps": {},
3801
4269
  }
@@ -3947,14 +4415,37 @@ def cmd_finalize(
3947
4415
  payload["ok"] = False
3948
4416
  return 1, payload
3949
4417
 
3950
- # --- 2d. product candidate CI hard gate (IA-1) ---
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}")
3951
4440
  ci_gate = evaluate_product_ci_gate(work_dir)
3952
4441
  payload["steps"]["product_candidate_ci"] = {
3953
4442
  "ok": bool(ci_gate.get("ok")),
3954
4443
  "code": ci_gate.get("code"),
3955
4444
  "message": ci_gate.get("message"),
3956
4445
  }
3957
- if not ci_gate.get("ok"):
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":
3958
4449
  payload["error"] = str(ci_gate.get("message") or "PRODUCT_CI_NOT_GREEN")
3959
4450
  payload["issues"] = [
3960
4451
  {
@@ -3967,6 +4458,21 @@ def cmd_finalize(
3967
4458
  payload["warnings"] = warnings
3968
4459
  payload["ok"] = False
3969
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
+ )
3970
4476
 
3971
4477
  # --- 3. candidate phase.end + freeze cutoff (RET-19 freeze-first) ---
3972
4478
  # This terminal exists only inside the isolated operation staging tree. It
@@ -4428,12 +4934,46 @@ def cmd_status_cli(args: argparse.Namespace) -> int:
4428
4934
  result = check_status(
4429
4935
  change_dir,
4430
4936
  allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
4937
+ archive_intent=str(getattr(args, "intent", "release-candidate")),
4431
4938
  )
4432
4939
  emit_json(result)
4433
4940
  # Checks completed → exit 0; archivable flag conveys the gate result.
4434
4941
  return 0 if result.get("ok") else 1
4435
4942
 
4436
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
+
4437
4977
  def cmd_finalize_cli(args: argparse.Namespace) -> int:
4438
4978
  change_dir = resolve_path(args.change_dir)
4439
4979
  archive_root = resolve_path(args.archive_root)
@@ -4442,6 +4982,7 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
4442
4982
  archive_root,
4443
4983
  skip_ingest=bool(args.skip_ingest),
4444
4984
  allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
4985
+ archive_intent=str(getattr(args, "intent", "release-candidate")),
4445
4986
  )
4446
4987
  emit_json(payload)
4447
4988
  return code
@@ -4631,8 +5172,23 @@ def build_parser() -> argparse.ArgumentParser:
4631
5172
  action="store_true",
4632
5173
  help="downgrade full-tier missing review from blocker to warning",
4633
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
+ )
4634
5181
  p_status.set_defaults(func=cmd_status_cli)
4635
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
+
4636
5192
  p_fin = sub.add_parser("finalize", help="single-process archive finalize")
4637
5193
  p_fin.add_argument("--change-dir", required=True)
4638
5194
  p_fin.add_argument("--archive-root", required=True)
@@ -4642,6 +5198,12 @@ def build_parser() -> argparse.ArgumentParser:
4642
5198
  action="store_true",
4643
5199
  help="record override when full-tier review evidence is missing",
4644
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
+ )
4645
5207
  p_fin.add_argument("--json", action="store_true", default=True)
4646
5208
  p_fin.set_defaults(func=cmd_finalize_cli)
4647
5209