@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.
Files changed (158) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/checklist.md +14 -0
  4. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/checklist.md +6 -0
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/checklist.md +6 -0
  16. package/harness/bundles/general/claude-code/scripts/harness_archive.py +606 -28
  17. package/harness/bundles/general/claude-code/scripts/harness_environment.py +459 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_events.py +93 -14
  19. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  20. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +14 -0
  22. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  23. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +6 -0
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-test/checklist.md +6 -0
  34. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +606 -28
  35. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +459 -0
  36. package/harness/bundles/general/codebuddy/scripts/harness_events.py +93 -14
  37. package/harness/bundles/general/codex/.harness-build.json +1 -1
  38. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-archive/checklist.md +14 -0
  40. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -3
  41. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-submit/checklist.md +6 -0
  49. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-test/checklist.md +6 -0
  52. package/harness/bundles/general/codex/scripts/harness_archive.py +606 -28
  53. package/harness/bundles/general/codex/scripts/harness_environment.py +459 -0
  54. package/harness/bundles/general/codex/scripts/harness_events.py +93 -14
  55. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  56. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-archive/checklist.md +14 -0
  58. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -3
  59. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-submit/checklist.md +6 -0
  67. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  68. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-test/checklist.md +6 -0
  70. package/harness/bundles/general/cursor/scripts/harness_archive.py +606 -28
  71. package/harness/bundles/general/cursor/scripts/harness_environment.py +459 -0
  72. package/harness/bundles/general/cursor/scripts/harness_events.py +93 -14
  73. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  74. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  75. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  76. package/harness/bundles/java/claude-code/harness-archive/checklist.md +14 -0
  77. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  78. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  79. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  80. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  81. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  82. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  84. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  85. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  86. package/harness/bundles/java/claude-code/harness-submit/checklist.md +6 -0
  87. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  88. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  89. package/harness/bundles/java/claude-code/scripts/harness_archive.py +606 -28
  90. package/harness/bundles/java/claude-code/scripts/harness_environment.py +459 -0
  91. package/harness/bundles/java/claude-code/scripts/harness_events.py +93 -14
  92. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  93. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  94. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  95. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +14 -0
  96. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  97. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  98. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  99. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  100. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  101. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  103. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  104. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  105. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +6 -0
  106. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  107. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  108. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +606 -28
  109. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +459 -0
  110. package/harness/bundles/java/codebuddy/scripts/harness_events.py +93 -14
  111. package/harness/bundles/java/codex/.harness-build.json +1 -1
  112. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  113. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  114. package/harness/bundles/java/codex/harness-archive/checklist.md +14 -0
  115. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -3
  116. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  117. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  118. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  119. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  120. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  122. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  123. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  124. package/harness/bundles/java/codex/harness-submit/checklist.md +6 -0
  125. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  126. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  127. package/harness/bundles/java/codex/scripts/harness_archive.py +606 -28
  128. package/harness/bundles/java/codex/scripts/harness_environment.py +459 -0
  129. package/harness/bundles/java/codex/scripts/harness_events.py +93 -14
  130. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  131. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  132. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  133. package/harness/bundles/java/cursor/harness-archive/checklist.md +14 -0
  134. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -3
  135. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  136. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  137. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  138. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  139. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  140. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  141. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  142. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  143. package/harness/bundles/java/cursor/harness-submit/checklist.md +6 -0
  144. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  146. package/harness/bundles/java/cursor/scripts/harness_archive.py +606 -28
  147. package/harness/bundles/java/cursor/scripts/harness_environment.py +459 -0
  148. package/harness/bundles/java/cursor/scripts/harness_events.py +93 -14
  149. package/harness/manifests/general/claude-code.json +21 -17
  150. package/harness/manifests/general/codebuddy.json +21 -17
  151. package/harness/manifests/general/codex.json +21 -17
  152. package/harness/manifests/general/cursor.json +21 -17
  153. package/harness/manifests/java/claude-code.json +22 -18
  154. package/harness/manifests/java/codebuddy.json +22 -18
  155. package/harness/manifests/java/codex.json +22 -18
  156. package/harness/manifests/java/cursor.json +22 -18
  157. package/hunter-workflow-family.json +1 -1
  158. 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(phase_events)
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. after-manifest + compare ---
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": True,
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
- can_close = validate_ok and manifest_ok
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
  )