@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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +12 -3
- package/harness/bundles/general/claude-code/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +2 -1
- package/harness/bundles/general/claude-code/harness-plan/checklist.md +1 -0
- package/harness/bundles/general/claude-code/harness-plan/reference.md +17 -15
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +7 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/checklist.md +6 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +1168 -28
- package/harness/bundles/general/claude-code/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/claude-code/scripts/harness_events.py +93 -14
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +126 -14
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +30 -6
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +33 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +30 -8
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +12 -3
- package/harness/bundles/general/codebuddy/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +2 -1
- package/harness/bundles/general/codebuddy/harness-plan/checklist.md +1 -0
- package/harness/bundles/general/codebuddy/harness-plan/reference.md +17 -15
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +7 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/checklist.md +6 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +1168 -28
- package/harness/bundles/general/codebuddy/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +93 -14
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +126 -14
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +30 -6
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +33 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +30 -8
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +12 -3
- package/harness/bundles/general/codex/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +2 -1
- package/harness/bundles/general/codex/harness-plan/checklist.md +1 -0
- package/harness/bundles/general/codex/harness-plan/reference.md +17 -15
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-submit/checklist.md +7 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/checklist.md +6 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +1168 -28
- package/harness/bundles/general/codex/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/codex/scripts/harness_events.py +93 -14
- package/harness/bundles/general/codex/scripts/harness_integration.py +126 -14
- package/harness/bundles/general/codex/scripts/harness_ledger.py +30 -6
- package/harness/bundles/general/codex/scripts/harness_profile.py +33 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +30 -8
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +12 -3
- package/harness/bundles/general/cursor/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +2 -1
- package/harness/bundles/general/cursor/harness-plan/checklist.md +1 -0
- package/harness/bundles/general/cursor/harness-plan/reference.md +17 -15
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-submit/checklist.md +7 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/checklist.md +6 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +1168 -28
- package/harness/bundles/general/cursor/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/cursor/scripts/harness_events.py +93 -14
- package/harness/bundles/general/cursor/scripts/harness_integration.py +126 -14
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +30 -6
- package/harness/bundles/general/cursor/scripts/harness_profile.py +33 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +30 -8
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +12 -3
- package/harness/bundles/java/claude-code/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +2 -1
- package/harness/bundles/java/claude-code/harness-plan/checklist.md +1 -0
- package/harness/bundles/java/claude-code/harness-plan/reference.md +17 -15
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +7 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +1168 -28
- package/harness/bundles/java/claude-code/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/claude-code/scripts/harness_events.py +93 -14
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +126 -14
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +30 -6
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +33 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +30 -8
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +12 -3
- package/harness/bundles/java/codebuddy/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +2 -1
- package/harness/bundles/java/codebuddy/harness-plan/checklist.md +1 -0
- package/harness/bundles/java/codebuddy/harness-plan/reference.md +17 -15
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +7 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +1168 -28
- package/harness/bundles/java/codebuddy/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +93 -14
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +126 -14
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +30 -6
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +33 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +30 -8
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +12 -3
- package/harness/bundles/java/codex/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +2 -1
- package/harness/bundles/java/codex/harness-plan/checklist.md +1 -0
- package/harness/bundles/java/codex/harness-plan/reference.md +17 -15
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-submit/checklist.md +7 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +1168 -28
- package/harness/bundles/java/codex/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/codex/scripts/harness_events.py +93 -14
- package/harness/bundles/java/codex/scripts/harness_integration.py +126 -14
- package/harness/bundles/java/codex/scripts/harness_ledger.py +30 -6
- package/harness/bundles/java/codex/scripts/harness_profile.py +33 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +30 -8
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +12 -3
- package/harness/bundles/java/cursor/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -3
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +11 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +2 -1
- package/harness/bundles/java/cursor/harness-plan/checklist.md +1 -0
- package/harness/bundles/java/cursor/harness-plan/reference.md +17 -15
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-submit/checklist.md +7 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +1168 -28
- package/harness/bundles/java/cursor/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/cursor/scripts/harness_events.py +93 -14
- package/harness/bundles/java/cursor/scripts/harness_integration.py +126 -14
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +30 -6
- package/harness/bundles/java/cursor/scripts/harness_profile.py +33 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +30 -8
- package/harness/manifests/general/claude-code.json +29 -25
- package/harness/manifests/general/codebuddy.json +29 -25
- package/harness/manifests/general/codex.json +29 -25
- package/harness/manifests/general/cursor.json +29 -25
- package/harness/manifests/java/claude-code.json +30 -26
- package/harness/manifests/java/codebuddy.json +30 -26
- package/harness/manifests/java/codex.json +30 -26
- package/harness/manifests/java/cursor.json +30 -26
- package/hunter-workflow-family.json +2 -2
- 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(
|
|
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.
|
|
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":
|
|
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
|
-
|
|
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
|
|