@hunter-harness/workflow-harness 0.2.21 → 0.2.22
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 +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +6 -0
- 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 +606 -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/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +6 -0
- 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 +606 -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/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/general/codex/harness-submit/checklist.md +6 -0
- 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 +606 -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/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/general/cursor/harness-submit/checklist.md +6 -0
- 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 +606 -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/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 +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +6 -0
- 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 +606 -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/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 +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +6 -0
- 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 +606 -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/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 +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/java/codex/harness-submit/checklist.md +6 -0
- 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 +606 -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/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 +1 -1
- 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-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 +1 -1
- 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 +1 -1
- package/harness/bundles/java/cursor/harness-submit/checklist.md +6 -0
- 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 +606 -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/manifests/general/claude-code.json +21 -17
- package/harness/manifests/general/codebuddy.json +21 -17
- package/harness/manifests/general/codex.json +21 -17
- package/harness/manifests/general/cursor.json +21 -17
- package/harness/manifests/java/claude-code.json +22 -18
- package/harness/manifests/java/codebuddy.json +22 -18
- package/harness/manifests/java/codex.json +22 -18
- package/harness/manifests/java/cursor.json +22 -18
- package/hunter-workflow-family.json +1 -1
- package/package.json +1 -1
|
@@ -331,6 +331,399 @@ 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."""
|
|
336
|
+
for relative in (
|
|
337
|
+
"evidence/product-candidate-ci.json",
|
|
338
|
+
"meta/product-candidate-ci.json",
|
|
339
|
+
"runtime/product-candidate-ci.json",
|
|
340
|
+
):
|
|
341
|
+
path = change_dir / relative
|
|
342
|
+
if not path.is_file():
|
|
343
|
+
continue
|
|
344
|
+
try:
|
|
345
|
+
data = read_json(path)
|
|
346
|
+
except (OSError, json.JSONDecodeError):
|
|
347
|
+
continue
|
|
348
|
+
if isinstance(data, dict) and data.get("schemaVersion") == 1:
|
|
349
|
+
return data
|
|
350
|
+
ledger = load_ledger(change_dir) or {}
|
|
351
|
+
for key in ("productCandidateCi", "product_candidate_ci"):
|
|
352
|
+
value = ledger.get(key)
|
|
353
|
+
if isinstance(value, dict):
|
|
354
|
+
return {"schemaVersion": 1, **value}
|
|
355
|
+
validations = ledger.get("validations") if isinstance(ledger.get("validations"), dict) else {}
|
|
356
|
+
for key in ("productCandidateCi", "product_candidate_ci", "candidateCi"):
|
|
357
|
+
value = validations.get(key) if isinstance(validations, dict) else None
|
|
358
|
+
if isinstance(value, dict):
|
|
359
|
+
return {"schemaVersion": 1, **value}
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
364
|
+
"""Hard gate: product candidate CI must be success with run URL + commit."""
|
|
365
|
+
evidence = load_product_candidate_ci(change_dir)
|
|
366
|
+
if evidence is None:
|
|
367
|
+
return {
|
|
368
|
+
"ok": False,
|
|
369
|
+
"code": "PRODUCT_CI_NOT_GREEN",
|
|
370
|
+
"message": (
|
|
371
|
+
"missing product-candidate CI evidence "
|
|
372
|
+
"(need evidence/product-candidate-ci.json with conclusion=success, "
|
|
373
|
+
"runUrl, commit)"
|
|
374
|
+
),
|
|
375
|
+
"evidence": None,
|
|
376
|
+
}
|
|
377
|
+
conclusion = str(
|
|
378
|
+
evidence.get("conclusion") or evidence.get("status") or ""
|
|
379
|
+
).strip().lower()
|
|
380
|
+
run_url = str(evidence.get("runUrl") or evidence.get("url") or "").strip()
|
|
381
|
+
commit = str(evidence.get("commit") or evidence.get("headSha") or "").strip()
|
|
382
|
+
if conclusion in {"success", "successful", "passed", "pass", "green", "ok"}:
|
|
383
|
+
# Review Y1: success alone is insufficient — runUrl + commit are required.
|
|
384
|
+
if not run_url or not commit:
|
|
385
|
+
return {
|
|
386
|
+
"ok": False,
|
|
387
|
+
"code": "PRODUCT_CI_NOT_GREEN",
|
|
388
|
+
"message": (
|
|
389
|
+
"product candidate CI conclusion is success but missing "
|
|
390
|
+
f"runUrl={run_url or 'empty'} commit={commit or 'empty'}"
|
|
391
|
+
),
|
|
392
|
+
"evidence": evidence,
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
"ok": True,
|
|
396
|
+
"code": "PRODUCT_CI_GREEN",
|
|
397
|
+
"message": f"product candidate CI green commit={commit} url={run_url}",
|
|
398
|
+
"evidence": evidence,
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
"ok": False,
|
|
402
|
+
"code": "PRODUCT_CI_NOT_GREEN",
|
|
403
|
+
"message": (
|
|
404
|
+
f"product candidate CI conclusion={conclusion or 'unknown'} "
|
|
405
|
+
f"commit={commit or 'unknown'} runUrl={run_url or 'unknown'}"
|
|
406
|
+
),
|
|
407
|
+
"evidence": evidence,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
PRODUCT_TREE_HASH_FILE_LIMIT = 20_000
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def compute_product_tree_hash_detail(
|
|
415
|
+
project: Path,
|
|
416
|
+
*,
|
|
417
|
+
file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
|
|
418
|
+
) -> dict[str, Any]:
|
|
419
|
+
"""Hash product tree excluding .harness/**; report truncation metadata (Y3)."""
|
|
420
|
+
project = project.resolve()
|
|
421
|
+
skip_dirs = {
|
|
422
|
+
".harness",
|
|
423
|
+
".git",
|
|
424
|
+
".worktrees",
|
|
425
|
+
"node_modules",
|
|
426
|
+
".next",
|
|
427
|
+
"dist",
|
|
428
|
+
"coverage",
|
|
429
|
+
"__pycache__",
|
|
430
|
+
".venv",
|
|
431
|
+
"venv",
|
|
432
|
+
}
|
|
433
|
+
limit = max(1, int(file_limit))
|
|
434
|
+
lines: list[str] = []
|
|
435
|
+
truncated = False
|
|
436
|
+
for root, dirs, files in os.walk(project, onerror=lambda _exc: None):
|
|
437
|
+
dirs[:] = [name for name in dirs if name not in skip_dirs]
|
|
438
|
+
for name in sorted(files):
|
|
439
|
+
path = Path(root) / name
|
|
440
|
+
try:
|
|
441
|
+
rel = path.relative_to(project).as_posix()
|
|
442
|
+
except ValueError:
|
|
443
|
+
continue
|
|
444
|
+
try:
|
|
445
|
+
digest = sha256_file(path)
|
|
446
|
+
except OSError:
|
|
447
|
+
continue
|
|
448
|
+
lines.append(f"{rel}:{digest}")
|
|
449
|
+
if len(lines) >= limit:
|
|
450
|
+
truncated = True
|
|
451
|
+
break
|
|
452
|
+
if truncated:
|
|
453
|
+
break
|
|
454
|
+
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
455
|
+
return {
|
|
456
|
+
"hash": digest,
|
|
457
|
+
"truncated": truncated,
|
|
458
|
+
"fileCount": len(lines),
|
|
459
|
+
"limit": limit,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def compute_product_tree_hash(
|
|
464
|
+
project: Path,
|
|
465
|
+
*,
|
|
466
|
+
file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
|
|
467
|
+
) -> str:
|
|
468
|
+
"""Hash product tree excluding .harness/** governance paths."""
|
|
469
|
+
return str(compute_product_tree_hash_detail(project, file_limit=file_limit)["hash"])
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def resolve_product_archive_identity(
|
|
473
|
+
change_dir: Path,
|
|
474
|
+
*,
|
|
475
|
+
project: Path | None = None,
|
|
476
|
+
product_commit: str | None = None,
|
|
477
|
+
archive_commit: str | None = None,
|
|
478
|
+
) -> dict[str, Any]:
|
|
479
|
+
"""Resolve the three-way archive identity fields for summary/meta."""
|
|
480
|
+
change_dir = change_dir.resolve()
|
|
481
|
+
project_root = (project or find_project_root(change_dir)).resolve()
|
|
482
|
+
ledger = load_ledger(change_dir) or {}
|
|
483
|
+
ci = load_product_candidate_ci(change_dir) or {}
|
|
484
|
+
product = (
|
|
485
|
+
product_commit
|
|
486
|
+
or ledger.get("productCommit")
|
|
487
|
+
or ci.get("commit")
|
|
488
|
+
or ledger.get("finalCommit")
|
|
489
|
+
or ""
|
|
490
|
+
)
|
|
491
|
+
archive = (
|
|
492
|
+
archive_commit
|
|
493
|
+
or ledger.get("archiveCommit")
|
|
494
|
+
or ledger.get("finalCommit")
|
|
495
|
+
or product
|
|
496
|
+
)
|
|
497
|
+
tree = str(ledger.get("productTreeHash") or "")
|
|
498
|
+
under_harness = False
|
|
499
|
+
try:
|
|
500
|
+
change_dir.relative_to(project_root / ".harness")
|
|
501
|
+
under_harness = True
|
|
502
|
+
except ValueError:
|
|
503
|
+
under_harness = False
|
|
504
|
+
tree_meta: dict[str, Any] = {
|
|
505
|
+
"truncated": False,
|
|
506
|
+
"fileCount": 0,
|
|
507
|
+
"limit": PRODUCT_TREE_HASH_FILE_LIMIT,
|
|
508
|
+
}
|
|
509
|
+
if not tree:
|
|
510
|
+
if under_harness:
|
|
511
|
+
try:
|
|
512
|
+
tree_meta = compute_product_tree_hash_detail(project_root)
|
|
513
|
+
tree = str(tree_meta["hash"])
|
|
514
|
+
except OSError:
|
|
515
|
+
tree = hashlib.sha256(b"").hexdigest()
|
|
516
|
+
else:
|
|
517
|
+
# Bare temp fixtures are not real project roots — avoid scanning up-tree.
|
|
518
|
+
tree = hashlib.sha256(b"fixture-no-product-tree").hexdigest()
|
|
519
|
+
identity = {
|
|
520
|
+
"productCommit": str(product),
|
|
521
|
+
"productTreeHash": str(tree),
|
|
522
|
+
"archiveCommit": str(archive),
|
|
523
|
+
"productTreeHashTruncated": bool(tree_meta.get("truncated")),
|
|
524
|
+
"productTreeHashFileCount": int(tree_meta.get("fileCount") or 0),
|
|
525
|
+
}
|
|
526
|
+
expected_tree = tree
|
|
527
|
+
if under_harness:
|
|
528
|
+
try:
|
|
529
|
+
expected_meta = compute_product_tree_hash_detail(project_root)
|
|
530
|
+
expected_tree = str(expected_meta["hash"])
|
|
531
|
+
identity["productTreeHashTruncated"] = bool(expected_meta.get("truncated"))
|
|
532
|
+
identity["productTreeHashFileCount"] = int(expected_meta.get("fileCount") or 0)
|
|
533
|
+
except OSError:
|
|
534
|
+
expected_tree = tree
|
|
535
|
+
identity["validation"] = validate_product_identity(
|
|
536
|
+
product_commit=identity["productCommit"],
|
|
537
|
+
product_tree_hash=identity["productTreeHash"],
|
|
538
|
+
archive_commit=identity["archiveCommit"] or "unknown",
|
|
539
|
+
project=project_root if under_harness else None,
|
|
540
|
+
expected_tree_hash=expected_tree if under_harness else tree,
|
|
541
|
+
)
|
|
542
|
+
return identity
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def validate_product_identity(
|
|
546
|
+
*,
|
|
547
|
+
product_commit: str,
|
|
548
|
+
product_tree_hash: str,
|
|
549
|
+
archive_commit: str,
|
|
550
|
+
project: Path | None = None,
|
|
551
|
+
expected_tree_hash: str | None = None,
|
|
552
|
+
) -> dict[str, Any]:
|
|
553
|
+
"""Validate productCommit/productTreeHash/archiveCommit relationship."""
|
|
554
|
+
if not product_commit or not product_tree_hash or not archive_commit:
|
|
555
|
+
return {
|
|
556
|
+
"ok": False,
|
|
557
|
+
"code": "PRODUCT_IDENTITY_INCOMPLETE",
|
|
558
|
+
"message": "productCommit, productTreeHash, and archiveCommit are required",
|
|
559
|
+
}
|
|
560
|
+
expected = expected_tree_hash
|
|
561
|
+
if expected is None and project is not None:
|
|
562
|
+
expected = compute_product_tree_hash(project)
|
|
563
|
+
if expected is not None and str(product_tree_hash) != str(expected):
|
|
564
|
+
return {
|
|
565
|
+
"ok": False,
|
|
566
|
+
"code": "PRODUCT_TREE_HASH_MISMATCH",
|
|
567
|
+
"message": (
|
|
568
|
+
f"productTreeHash={product_tree_hash} does not match "
|
|
569
|
+
f"product tree={expected} for productCommit={product_commit}"
|
|
570
|
+
),
|
|
571
|
+
"expected": expected,
|
|
572
|
+
"actual": product_tree_hash,
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
"ok": True,
|
|
576
|
+
"code": "PRODUCT_IDENTITY_OK",
|
|
577
|
+
"productCommit": product_commit,
|
|
578
|
+
"productTreeHash": product_tree_hash,
|
|
579
|
+
"archiveCommit": archive_commit,
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def evaluate_release_evidence(
|
|
584
|
+
archived_identity: dict[str, Any],
|
|
585
|
+
*,
|
|
586
|
+
current_product_tree_hash: str,
|
|
587
|
+
) -> dict[str, Any]:
|
|
588
|
+
"""Old archive cannot remain release evidence after product inputs change."""
|
|
589
|
+
archived_tree = str(archived_identity.get("productTreeHash") or "")
|
|
590
|
+
if not archived_tree:
|
|
591
|
+
return {
|
|
592
|
+
"ok": False,
|
|
593
|
+
"code": "ARCHIVE_EVIDENCE_REOPEN_REQUIRED",
|
|
594
|
+
"message": "archived productTreeHash missing; reopen required",
|
|
595
|
+
}
|
|
596
|
+
if archived_tree != str(current_product_tree_hash):
|
|
597
|
+
return {
|
|
598
|
+
"ok": False,
|
|
599
|
+
"code": "ARCHIVE_EVIDENCE_REOPEN_REQUIRED",
|
|
600
|
+
"message": (
|
|
601
|
+
"product inputs changed since archive; "
|
|
602
|
+
f"archivedTree={archived_tree} currentTree={current_product_tree_hash}"
|
|
603
|
+
),
|
|
604
|
+
"archivedProductTreeHash": archived_tree,
|
|
605
|
+
"currentProductTreeHash": current_product_tree_hash,
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
"ok": True,
|
|
609
|
+
"code": "ARCHIVE_EVIDENCE_CURRENT",
|
|
610
|
+
"productTreeHash": archived_tree,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def build_workflow_timing(
|
|
615
|
+
events: list[dict[str, Any]],
|
|
616
|
+
*,
|
|
617
|
+
report_cutoff_at: str | None = None,
|
|
618
|
+
) -> dict[str, Any]:
|
|
619
|
+
"""Formal multi-semantic timing object (IA-2 / IA-6)."""
|
|
620
|
+
stamps = [e.get("timestamp") for e in events if e.get("timestamp")]
|
|
621
|
+
started = str(stamps[0]) if stamps else None
|
|
622
|
+
cutoff = report_cutoff_at or (str(stamps[-1]) if stamps else None)
|
|
623
|
+
stage_active = 0
|
|
624
|
+
stage_wall = 0
|
|
625
|
+
unclosed = 0
|
|
626
|
+
for _phase, phase_events in he.group_events_by_phase(events):
|
|
627
|
+
timing = he.canonical_phase_timing(phase_events, cutoff_ts=cutoff)
|
|
628
|
+
stage_active += int(timing.get("activeExecutionMs") or 0)
|
|
629
|
+
stage_wall += int(timing.get("wallClockSpanMs") or 0)
|
|
630
|
+
unclosed += int(timing.get("unclosedAttemptCount") or 0)
|
|
631
|
+
workflow_wall = he.duration_ms_between(started, cutoff) if started and cutoff else 0
|
|
632
|
+
external = 0
|
|
633
|
+
for event in events:
|
|
634
|
+
etype = str(event.get("type") or "").lower()
|
|
635
|
+
if etype in {"external.wait", "ci.wait", "environment.wait", "env.wait"}:
|
|
636
|
+
dur = event.get("duration_ms") or event.get("durationMs")
|
|
637
|
+
if isinstance(dur, int):
|
|
638
|
+
external += max(0, dur)
|
|
639
|
+
elif event.get("timestamp") and event.get("endedAt"):
|
|
640
|
+
gap = he.duration_ms_between(event.get("timestamp"), event.get("endedAt"))
|
|
641
|
+
if gap:
|
|
642
|
+
external += gap
|
|
643
|
+
unattributed = max(0, int(workflow_wall or 0) - stage_active - external)
|
|
644
|
+
post_archive_excluded = 0
|
|
645
|
+
if cutoff:
|
|
646
|
+
for event in events:
|
|
647
|
+
if str(event.get("phase") or "").lower() != "archive":
|
|
648
|
+
continue
|
|
649
|
+
ts = event.get("timestamp")
|
|
650
|
+
if ts and he.duration_ms_between(cutoff, ts) and he.duration_ms_between(cutoff, ts) > 0:
|
|
651
|
+
# timestamp after cutoff
|
|
652
|
+
start_dt = he.parse_timestamp(cutoff)
|
|
653
|
+
end_dt = he.parse_timestamp(ts)
|
|
654
|
+
if start_dt and end_dt and end_dt > start_dt:
|
|
655
|
+
post_archive_excluded += 1
|
|
656
|
+
return {
|
|
657
|
+
"workflowStartedAt": started or NOT_AVAILABLE,
|
|
658
|
+
"reportCutoffAt": cutoff or NOT_AVAILABLE,
|
|
659
|
+
"workflowWallClockMs": int(workflow_wall or 0),
|
|
660
|
+
"stageActiveExecutionMs": int(stage_active),
|
|
661
|
+
"stageWallClockSpanMs": int(stage_wall),
|
|
662
|
+
"externalWaitMs": int(external),
|
|
663
|
+
"agentOrToolUnattributedMs": int(unattributed),
|
|
664
|
+
"unclosedAttemptCount": int(unclosed),
|
|
665
|
+
"postArchiveEventsExcluded": int(post_archive_excluded),
|
|
666
|
+
"totalMinutesSemantics": "active-only",
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def verify_manifest_byte_coverage(
|
|
671
|
+
root: Path,
|
|
672
|
+
manifest: dict[str, Any],
|
|
673
|
+
*,
|
|
674
|
+
exclude_paths: list[str] | None = None,
|
|
675
|
+
exclusion_reasons: dict[str, str] | None = None,
|
|
676
|
+
) -> dict[str, Any]:
|
|
677
|
+
"""Verify every covered manifest entry still matches on-disk bytes (IA-7)."""
|
|
678
|
+
root = root.resolve()
|
|
679
|
+
excluded = {
|
|
680
|
+
p.replace("\\", "/") for p in (exclude_paths or [])
|
|
681
|
+
}
|
|
682
|
+
reasons = {
|
|
683
|
+
k.replace("\\", "/"): v for k, v in (exclusion_reasons or {}).items()
|
|
684
|
+
}
|
|
685
|
+
mismatches: list[dict[str, str]] = []
|
|
686
|
+
checked = 0
|
|
687
|
+
for item in manifest.get("files") or []:
|
|
688
|
+
if not isinstance(item, dict):
|
|
689
|
+
continue
|
|
690
|
+
rel = str(item.get("path") or "").replace("\\", "/")
|
|
691
|
+
if not rel or _manifest_path_excluded(rel):
|
|
692
|
+
continue
|
|
693
|
+
if rel in excluded:
|
|
694
|
+
reasons.setdefault(rel, "excluded from checksum coverage")
|
|
695
|
+
continue
|
|
696
|
+
path = root / rel
|
|
697
|
+
if not path.is_file():
|
|
698
|
+
mismatches.append({"path": rel, "reason": "missing"})
|
|
699
|
+
continue
|
|
700
|
+
actual = sha256_file(path)
|
|
701
|
+
expected = str(item.get("sha256") or "")
|
|
702
|
+
checked += 1
|
|
703
|
+
if actual != expected:
|
|
704
|
+
mismatches.append(
|
|
705
|
+
{
|
|
706
|
+
"path": rel,
|
|
707
|
+
"reason": "hash-mismatch",
|
|
708
|
+
"expected": expected,
|
|
709
|
+
"actual": actual,
|
|
710
|
+
}
|
|
711
|
+
)
|
|
712
|
+
ok = not mismatches
|
|
713
|
+
if ok and reasons:
|
|
714
|
+
checksum_status = "OK_WITH_EXCLUSIONS"
|
|
715
|
+
elif ok:
|
|
716
|
+
checksum_status = "OK"
|
|
717
|
+
else:
|
|
718
|
+
checksum_status = "FAIL"
|
|
719
|
+
return {
|
|
720
|
+
"ok": ok,
|
|
721
|
+
"checksumStatus": checksum_status,
|
|
722
|
+
"checked": checked,
|
|
723
|
+
"mismatched": mismatches,
|
|
724
|
+
"exclusionReasons": reasons,
|
|
725
|
+
}
|
|
726
|
+
|
|
334
727
|
def load_execution_log(change_dir: Path) -> str:
|
|
335
728
|
for rel in ("logs/execution-log.md", "execution-log.md"):
|
|
336
729
|
path = change_dir / rel
|
|
@@ -731,6 +1124,21 @@ def check_status(
|
|
|
731
1124
|
else:
|
|
732
1125
|
blockers.append(tier_issue)
|
|
733
1126
|
|
|
1127
|
+
# --- IA-1 product candidate CI hard gate ---
|
|
1128
|
+
ci_gate = evaluate_product_ci_gate(change_dir)
|
|
1129
|
+
checks["product_candidate_ci"] = {
|
|
1130
|
+
"ok": bool(ci_gate.get("ok")),
|
|
1131
|
+
"code": ci_gate.get("code"),
|
|
1132
|
+
"evidence": ci_gate.get("evidence"),
|
|
1133
|
+
}
|
|
1134
|
+
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
|
+
)
|
|
1141
|
+
|
|
734
1142
|
# --- artifact preflight (retro §5.31) ---
|
|
735
1143
|
# Classify artifact events before destructive finalize: informational (OK),
|
|
736
1144
|
# canonicalizable (same-change repo-relative path, warning), or blocking
|
|
@@ -1315,10 +1723,17 @@ def _durations_from_event_phases(
|
|
|
1315
1723
|
events: list[dict[str, Any]] | None = None,
|
|
1316
1724
|
) -> dict[str, Any]:
|
|
1317
1725
|
# Canonical per-phase timing (UT-008/RET-20): one reducer feeds every view.
|
|
1726
|
+
# totalMinutes remains active-only (IA-2); use top-level timing for wall clock.
|
|
1727
|
+
cutoff = None
|
|
1728
|
+
if events:
|
|
1729
|
+
stamps = [e.get("timestamp") for e in events if e.get("timestamp")]
|
|
1730
|
+
cutoff = str(stamps[-1]) if stamps else None
|
|
1318
1731
|
canonical: dict[str, dict[str, Any]] = {}
|
|
1319
1732
|
if events:
|
|
1320
1733
|
for phase_name, phase_events in he.group_events_by_phase(events):
|
|
1321
|
-
canonical[phase_name] = he.canonical_phase_timing(
|
|
1734
|
+
canonical[phase_name] = he.canonical_phase_timing(
|
|
1735
|
+
phase_events, cutoff_ts=cutoff
|
|
1736
|
+
)
|
|
1322
1737
|
stages: list[dict[str, Any]] = []
|
|
1323
1738
|
total_ms = 0
|
|
1324
1739
|
for name, info in (event_summary.get("phases") or {}).items():
|
|
@@ -1338,6 +1753,7 @@ def _durations_from_event_phases(
|
|
|
1338
1753
|
"startedAt": info.get("started_at") or NOT_AVAILABLE,
|
|
1339
1754
|
"endedAt": info.get("ended_at") or NOT_AVAILABLE,
|
|
1340
1755
|
"minutes": minutes,
|
|
1756
|
+
"minutesSemantics": "active-only",
|
|
1341
1757
|
"result": result,
|
|
1342
1758
|
"attempts": attempts,
|
|
1343
1759
|
}
|
|
@@ -1346,11 +1762,13 @@ def _durations_from_event_phases(
|
|
|
1346
1762
|
stage["activeExecutionMs"] = timing.get("activeExecutionMs")
|
|
1347
1763
|
stage["wallClockSpanMs"] = timing.get("wallClockSpanMs")
|
|
1348
1764
|
stage["lateEventCount"] = timing.get("lateEventCount")
|
|
1765
|
+
stage["unclosedAttemptCount"] = timing.get("unclosedAttemptCount")
|
|
1349
1766
|
stages.append(stage)
|
|
1350
1767
|
total_min = round(total_ms / 60000, 2)
|
|
1351
1768
|
return {
|
|
1352
|
-
"totalLabel": f"约 {int(round(total_min))}
|
|
1769
|
+
"totalLabel": f"约 {int(round(total_min))} 分(活动执行)",
|
|
1353
1770
|
"totalMinutes": total_min,
|
|
1771
|
+
"totalMinutesSemantics": "active-only",
|
|
1354
1772
|
"stages": stages,
|
|
1355
1773
|
}
|
|
1356
1774
|
|
|
@@ -2054,16 +2472,60 @@ def collect_summary_data(
|
|
|
2054
2472
|
if events:
|
|
2055
2473
|
data["durations"] = _durations_from_event_phases(event_summary, events)
|
|
2056
2474
|
data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
|
|
2475
|
+
stamps = [e.get("timestamp") for e in projected_events if e.get("timestamp")]
|
|
2476
|
+
cutoff = str(stamps[-1]) if stamps else None
|
|
2477
|
+
data["timing"] = build_workflow_timing(projected_events, report_cutoff_at=cutoff)
|
|
2057
2478
|
elif log_text and (not for_replay or not data.get("durations")):
|
|
2058
2479
|
data["durations"] = _parse_durations_from_log(log_text)
|
|
2059
2480
|
data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
|
|
2481
|
+
data.setdefault(
|
|
2482
|
+
"timing",
|
|
2483
|
+
{
|
|
2484
|
+
"workflowStartedAt": NOT_AVAILABLE,
|
|
2485
|
+
"reportCutoffAt": NOT_AVAILABLE,
|
|
2486
|
+
"workflowWallClockMs": 0,
|
|
2487
|
+
"stageActiveExecutionMs": int(
|
|
2488
|
+
round(float(data["durations"].get("totalMinutes") or 0) * 60000)
|
|
2489
|
+
),
|
|
2490
|
+
"stageWallClockSpanMs": 0,
|
|
2491
|
+
"externalWaitMs": 0,
|
|
2492
|
+
"agentOrToolUnattributedMs": 0,
|
|
2493
|
+
"unclosedAttemptCount": 0,
|
|
2494
|
+
"postArchiveEventsExcluded": 0,
|
|
2495
|
+
"totalMinutesSemantics": "active-only",
|
|
2496
|
+
},
|
|
2497
|
+
)
|
|
2060
2498
|
elif not data.get("durations"):
|
|
2061
2499
|
data["durations"] = {
|
|
2062
2500
|
"totalLabel": NOT_AVAILABLE,
|
|
2063
2501
|
"totalMinutes": 0,
|
|
2502
|
+
"totalMinutesSemantics": "active-only",
|
|
2064
2503
|
"stages": [],
|
|
2065
2504
|
}
|
|
2066
2505
|
data["skillCalls"] = []
|
|
2506
|
+
data.setdefault(
|
|
2507
|
+
"timing",
|
|
2508
|
+
{
|
|
2509
|
+
"workflowStartedAt": NOT_AVAILABLE,
|
|
2510
|
+
"reportCutoffAt": NOT_AVAILABLE,
|
|
2511
|
+
"workflowWallClockMs": 0,
|
|
2512
|
+
"stageActiveExecutionMs": 0,
|
|
2513
|
+
"stageWallClockSpanMs": 0,
|
|
2514
|
+
"externalWaitMs": 0,
|
|
2515
|
+
"agentOrToolUnattributedMs": 0,
|
|
2516
|
+
"unclosedAttemptCount": 0,
|
|
2517
|
+
"postArchiveEventsExcluded": 0,
|
|
2518
|
+
"totalMinutesSemantics": "active-only",
|
|
2519
|
+
},
|
|
2520
|
+
)
|
|
2521
|
+
|
|
2522
|
+
# IA-1/4 identity (product vs archive)
|
|
2523
|
+
if not for_replay:
|
|
2524
|
+
identity = resolve_product_archive_identity(change_dir, project=project)
|
|
2525
|
+
data["productCommit"] = identity.get("productCommit") or data.get("finalCommit")
|
|
2526
|
+
data["productTreeHash"] = identity.get("productTreeHash")
|
|
2527
|
+
data["archiveCommit"] = identity.get("archiveCommit") or data.get("finalCommit")
|
|
2528
|
+
data["changeIdentity"] = identity
|
|
2067
2529
|
|
|
2068
2530
|
# diffStat / changedFiles
|
|
2069
2531
|
if not for_replay or not data.get("changedFiles"):
|
|
@@ -2305,6 +2767,33 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
2305
2767
|
if summary.get("riskTier"):
|
|
2306
2768
|
parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
|
|
2307
2769
|
|
|
2770
|
+
timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
|
|
2771
|
+
if timing:
|
|
2772
|
+
parts.append("<h3>Timing</h3>")
|
|
2773
|
+
parts.append(
|
|
2774
|
+
"<p id=\"timingColumns\">"
|
|
2775
|
+
f"stageActiveExecution={esc(timing.get('stageActiveExecutionMs'))} · "
|
|
2776
|
+
f"stageWallClockSpan={esc(timing.get('stageWallClockSpanMs'))} · "
|
|
2777
|
+
f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
|
|
2778
|
+
"</p>"
|
|
2779
|
+
)
|
|
2780
|
+
parts.append(
|
|
2781
|
+
f"<p><strong>reportCutoffAt</strong>: "
|
|
2782
|
+
f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
|
|
2783
|
+
)
|
|
2784
|
+
parts.append(
|
|
2785
|
+
"<p><small>durations.totalMinutes is active-only; "
|
|
2786
|
+
"do not treat it as workflow wall clock.</small></p>"
|
|
2787
|
+
)
|
|
2788
|
+
|
|
2789
|
+
if summary.get("productCommit") or summary.get("productTreeHash"):
|
|
2790
|
+
parts.append("<h3>Identity</h3>")
|
|
2791
|
+
parts.append(
|
|
2792
|
+
f"<p>productCommit={esc(summary.get('productCommit'))} "
|
|
2793
|
+
f"productTreeHash={esc(summary.get('productTreeHash'))} "
|
|
2794
|
+
f"archiveCommit={esc(summary.get('archiveCommit'))}</p>"
|
|
2795
|
+
)
|
|
2796
|
+
|
|
2308
2797
|
pipeline = summary.get("reportPipeline") or {}
|
|
2309
2798
|
cmds = pipeline.get("commands") or []
|
|
2310
2799
|
parts.append("<h3>Commands</h3><ul>")
|
|
@@ -3458,6 +3947,27 @@ def cmd_finalize(
|
|
|
3458
3947
|
payload["ok"] = False
|
|
3459
3948
|
return 1, payload
|
|
3460
3949
|
|
|
3950
|
+
# --- 2d. product candidate CI hard gate (IA-1) ---
|
|
3951
|
+
ci_gate = evaluate_product_ci_gate(work_dir)
|
|
3952
|
+
payload["steps"]["product_candidate_ci"] = {
|
|
3953
|
+
"ok": bool(ci_gate.get("ok")),
|
|
3954
|
+
"code": ci_gate.get("code"),
|
|
3955
|
+
"message": ci_gate.get("message"),
|
|
3956
|
+
}
|
|
3957
|
+
if not ci_gate.get("ok"):
|
|
3958
|
+
payload["error"] = str(ci_gate.get("message") or "PRODUCT_CI_NOT_GREEN")
|
|
3959
|
+
payload["issues"] = [
|
|
3960
|
+
{
|
|
3961
|
+
"code": "PRODUCT_CI_NOT_GREEN",
|
|
3962
|
+
"severity": "error",
|
|
3963
|
+
"message": str(ci_gate.get("message") or "product candidate CI not green"),
|
|
3964
|
+
}
|
|
3965
|
+
]
|
|
3966
|
+
_restore_finalize_failure()
|
|
3967
|
+
payload["warnings"] = warnings
|
|
3968
|
+
payload["ok"] = False
|
|
3969
|
+
return 1, payload
|
|
3970
|
+
|
|
3461
3971
|
# --- 3. candidate phase.end + freeze cutoff (RET-19 freeze-first) ---
|
|
3462
3972
|
# This terminal exists only inside the isolated operation staging tree. It
|
|
3463
3973
|
# becomes authoritative atomically at publish; validation failure discards
|
|
@@ -3589,7 +4099,31 @@ def cmd_finalize(
|
|
|
3589
4099
|
warnings.append(f"archive-meta write failed: {exc}")
|
|
3590
4100
|
payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
|
|
3591
4101
|
|
|
3592
|
-
# --- 9.
|
|
4102
|
+
# --- 9/10. final summary stats + render, then LAST manifest (IA-7) ---
|
|
4103
|
+
# Post-manifest rewrites of covered bytes are forbidden. We update summary /
|
|
4104
|
+
# HTML first, regenerate after-manifest last, then verify on-disk hashes.
|
|
4105
|
+
# If summary/html must still change after that, they are excluded with reasons.
|
|
4106
|
+
summary = read_json(summary_path)
|
|
4107
|
+
summary["archiveManifest"] = {
|
|
4108
|
+
"movedFiles": 0,
|
|
4109
|
+
"generatedFiles": 0,
|
|
4110
|
+
"totalArchiveFiles": 0,
|
|
4111
|
+
"checksumStatus": "PENDING",
|
|
4112
|
+
}
|
|
4113
|
+
write_json(summary_path, summary)
|
|
4114
|
+
render_result = render_final_summary(work_dir, summary_path)
|
|
4115
|
+
if not render_result.get("ok"):
|
|
4116
|
+
payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
|
|
4117
|
+
_restore_finalize_failure()
|
|
4118
|
+
return 1, payload
|
|
4119
|
+
summary = read_json(summary_path)
|
|
4120
|
+
validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
|
|
4121
|
+
payload["steps"]["validate"] = validate_result
|
|
4122
|
+
summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
|
|
4123
|
+
"issues"
|
|
4124
|
+
) or []
|
|
4125
|
+
write_json(summary_path, summary)
|
|
4126
|
+
|
|
3593
4127
|
after_path = work_dir / "evidence" / "archive-manifest-after.json"
|
|
3594
4128
|
try:
|
|
3595
4129
|
after_manifest = generate_manifest(work_dir, after_path)
|
|
@@ -3597,10 +4131,71 @@ def cmd_finalize(
|
|
|
3597
4131
|
if before_in_archive.is_file():
|
|
3598
4132
|
before_manifest = read_json(before_in_archive)
|
|
3599
4133
|
compare_result = compare_manifests(before_manifest, after_manifest)
|
|
4134
|
+
coverage = verify_manifest_byte_coverage(work_dir, after_manifest)
|
|
4135
|
+
# Embed compare stats into summary AFTER manifest → must exclude those paths.
|
|
4136
|
+
summary = read_json(summary_path)
|
|
4137
|
+
summary["archiveManifest"] = {
|
|
4138
|
+
"movedFiles": compare_result.get("movedFiles", 0),
|
|
4139
|
+
"generatedFiles": compare_result.get("generatedFiles", 0),
|
|
4140
|
+
"totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
|
|
4141
|
+
"checksumStatus": coverage.get("checksumStatus", "FAIL"),
|
|
4142
|
+
"exclusionReasons": {
|
|
4143
|
+
"reports/final/summary-data.json": (
|
|
4144
|
+
"archiveManifest stats written after coverage snapshot"
|
|
4145
|
+
),
|
|
4146
|
+
"reports/final/final-summary.html": (
|
|
4147
|
+
"re-rendered after coverage snapshot to display manifest stats"
|
|
4148
|
+
),
|
|
4149
|
+
},
|
|
4150
|
+
**_manifest_self_stats(work_dir, after_manifest, after_path),
|
|
4151
|
+
}
|
|
4152
|
+
write_json(summary_path, summary)
|
|
4153
|
+
render_result = render_final_summary(work_dir, summary_path)
|
|
4154
|
+
if not render_result.get("ok"):
|
|
4155
|
+
payload["error"] = (
|
|
4156
|
+
f"post-manifest summary render failed: {render_result.get('error')}"
|
|
4157
|
+
)
|
|
4158
|
+
_restore_finalize_failure()
|
|
4159
|
+
return 1, payload
|
|
4160
|
+
coverage = verify_manifest_byte_coverage(
|
|
4161
|
+
work_dir,
|
|
4162
|
+
after_manifest,
|
|
4163
|
+
exclude_paths=[
|
|
4164
|
+
"reports/final/summary-data.json",
|
|
4165
|
+
"reports/final/final-summary.html",
|
|
4166
|
+
],
|
|
4167
|
+
exclusion_reasons=summary["archiveManifest"]["exclusionReasons"],
|
|
4168
|
+
)
|
|
4169
|
+
summary["archiveManifest"]["checksumStatus"] = coverage.get(
|
|
4170
|
+
"checksumStatus", "FAIL"
|
|
4171
|
+
)
|
|
4172
|
+
summary["archiveManifest"]["exclusionReasons"] = coverage.get(
|
|
4173
|
+
"exclusionReasons"
|
|
4174
|
+
) or summary["archiveManifest"]["exclusionReasons"]
|
|
4175
|
+
summary["archiveManifest"]["coverageChecked"] = coverage.get("checked")
|
|
4176
|
+
write_json(summary_path, summary)
|
|
4177
|
+
# Re-render once more for checksumStatus display; remain excluded.
|
|
4178
|
+
render_final_summary(work_dir, summary_path)
|
|
4179
|
+
summary = read_json(summary_path)
|
|
4180
|
+
validate_result = validate_summary(
|
|
4181
|
+
summary, html_path if html_path.is_file() else None
|
|
4182
|
+
)
|
|
4183
|
+
payload["steps"]["validate"] = validate_result
|
|
4184
|
+
summary.setdefault("reportPipeline", {})["validationIssues"] = (
|
|
4185
|
+
validate_result.get("issues") or []
|
|
4186
|
+
)
|
|
4187
|
+
write_json(summary_path, summary)
|
|
3600
4188
|
payload["steps"]["after_manifest"] = {
|
|
3601
|
-
"ok":
|
|
4189
|
+
"ok": bool(coverage.get("ok")) and bool(compare_result.get("ok")),
|
|
3602
4190
|
"path": str(after_path),
|
|
3603
4191
|
"compare": compare_result,
|
|
4192
|
+
"coverage": coverage,
|
|
4193
|
+
}
|
|
4194
|
+
compare_result = {
|
|
4195
|
+
**compare_result,
|
|
4196
|
+
"ok": bool(compare_result.get("ok")) and bool(coverage.get("ok")),
|
|
4197
|
+
"checksumStatus": coverage.get("checksumStatus"),
|
|
4198
|
+
"exclusionReasons": coverage.get("exclusionReasons"),
|
|
3604
4199
|
}
|
|
3605
4200
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
3606
4201
|
payload["error"] = f"after-manifest failed: {exc}"
|
|
@@ -3608,42 +4203,25 @@ def cmd_finalize(
|
|
|
3608
4203
|
_restore_finalize_failure()
|
|
3609
4204
|
return 1, payload
|
|
3610
4205
|
|
|
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
4206
|
# --- 11. closure: only when both validators and manifest pass ---
|
|
3633
4207
|
validate_ok = bool(validate_result.get("ok"))
|
|
3634
4208
|
manifest_ok = bool(compare_result.get("ok"))
|
|
3635
|
-
|
|
4209
|
+
# Honest checksum: FAIL blocks; OK and OK_WITH_EXCLUSIONS (with reasons) pass.
|
|
4210
|
+
checksum = str(compare_result.get("checksumStatus") or "")
|
|
4211
|
+
checksum_ok = checksum in {"OK", "OK_WITH_EXCLUSIONS"}
|
|
4212
|
+
can_close = validate_ok and manifest_ok and checksum_ok
|
|
3636
4213
|
|
|
3637
4214
|
if not can_close:
|
|
3638
4215
|
issues_out = list(validate_result.get("issues") or [])
|
|
3639
|
-
if not manifest_ok:
|
|
4216
|
+
if not manifest_ok or not checksum_ok:
|
|
3640
4217
|
issues_out.append(
|
|
3641
4218
|
{
|
|
3642
4219
|
"code": "manifest-mismatch",
|
|
3643
4220
|
"severity": "error",
|
|
3644
4221
|
"message": (
|
|
3645
4222
|
f"missing={compare_result.get('missing')} "
|
|
3646
|
-
f"mismatched={compare_result.get('mismatched')}"
|
|
4223
|
+
f"mismatched={compare_result.get('mismatched')} "
|
|
4224
|
+
f"checksumStatus={checksum}"
|
|
3647
4225
|
),
|
|
3648
4226
|
}
|
|
3649
4227
|
)
|