@hunter-harness/workflow-harness 0.2.64 → 0.2.66

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 (154) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +6 -5
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +11 -5
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-run/SKILL.md +2 -2
  10. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  11. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/scripts/harness_archive.py +320 -35
  14. package/harness/bundles/general/claude-code/scripts/harness_context.py +43 -25
  15. package/harness/bundles/general/claude-code/scripts/harness_gate.py +22 -27
  16. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +155 -16
  17. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +31 -5
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +1 -1
  20. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +6 -5
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +11 -5
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +2 -2
  27. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  28. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +320 -35
  31. package/harness/bundles/general/codebuddy/scripts/harness_context.py +43 -25
  32. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +22 -27
  33. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +155 -16
  34. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +31 -5
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/contracts/workflow-policy.json +1 -1
  37. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +6 -5
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +11 -5
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-run/SKILL.md +2 -2
  44. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  45. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/scripts/harness_archive.py +320 -35
  48. package/harness/bundles/general/codex/scripts/harness_context.py +43 -25
  49. package/harness/bundles/general/codex/scripts/harness_gate.py +22 -27
  50. package/harness/bundles/general/codex/scripts/harness_ledger.py +155 -16
  51. package/harness/bundles/general/codex/scripts/harness_runtime.py +31 -5
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/contracts/workflow-policy.json +1 -1
  54. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +6 -5
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +11 -5
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-run/SKILL.md +2 -2
  61. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  62. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/scripts/harness_archive.py +320 -35
  65. package/harness/bundles/general/cursor/scripts/harness_context.py +43 -25
  66. package/harness/bundles/general/cursor/scripts/harness_gate.py +22 -27
  67. package/harness/bundles/general/cursor/scripts/harness_ledger.py +155 -16
  68. package/harness/bundles/general/cursor/scripts/harness_runtime.py +31 -5
  69. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  70. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +1 -1
  71. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  72. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  73. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +6 -5
  74. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +11 -5
  75. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  76. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  77. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  78. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  79. package/harness/bundles/java/claude-code/harness-run/SKILL.md +2 -2
  80. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  81. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  82. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/scripts/harness_archive.py +320 -35
  84. package/harness/bundles/java/claude-code/scripts/harness_context.py +43 -25
  85. package/harness/bundles/java/claude-code/scripts/harness_gate.py +22 -27
  86. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +155 -16
  87. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +31 -5
  88. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  89. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +1 -1
  90. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +6 -5
  93. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +11 -5
  94. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  96. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  97. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  98. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +2 -2
  99. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  100. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  101. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +320 -35
  103. package/harness/bundles/java/codebuddy/scripts/harness_context.py +43 -25
  104. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +22 -27
  105. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +155 -16
  106. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +31 -5
  107. package/harness/bundles/java/codex/.harness-build.json +1 -1
  108. package/harness/bundles/java/codex/contracts/workflow-policy.json +1 -1
  109. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  110. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  111. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +6 -5
  112. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +11 -5
  113. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  114. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  115. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  116. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  117. package/harness/bundles/java/codex/harness-run/SKILL.md +2 -2
  118. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  119. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  120. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/scripts/harness_archive.py +320 -35
  122. package/harness/bundles/java/codex/scripts/harness_context.py +43 -25
  123. package/harness/bundles/java/codex/scripts/harness_gate.py +22 -27
  124. package/harness/bundles/java/codex/scripts/harness_ledger.py +155 -16
  125. package/harness/bundles/java/codex/scripts/harness_runtime.py +31 -5
  126. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  127. package/harness/bundles/java/cursor/contracts/workflow-policy.json +1 -1
  128. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  129. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  130. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +6 -5
  131. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +11 -5
  132. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  133. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  134. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  135. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  136. package/harness/bundles/java/cursor/harness-run/SKILL.md +2 -2
  137. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  138. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  139. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  140. package/harness/bundles/java/cursor/scripts/harness_archive.py +320 -35
  141. package/harness/bundles/java/cursor/scripts/harness_context.py +43 -25
  142. package/harness/bundles/java/cursor/scripts/harness_gate.py +22 -27
  143. package/harness/bundles/java/cursor/scripts/harness_ledger.py +155 -16
  144. package/harness/bundles/java/cursor/scripts/harness_runtime.py +31 -5
  145. package/harness/manifests/general/claude-code.json +19 -19
  146. package/harness/manifests/general/codebuddy.json +19 -19
  147. package/harness/manifests/general/codex.json +19 -19
  148. package/harness/manifests/general/cursor.json +19 -19
  149. package/harness/manifests/java/claude-code.json +21 -21
  150. package/harness/manifests/java/codebuddy.json +21 -21
  151. package/harness/manifests/java/codex.json +21 -21
  152. package/harness/manifests/java/cursor.json +21 -21
  153. package/hunter-workflow-family.json +5 -5
  154. package/package.json +1 -1
@@ -55,6 +55,7 @@ MANIFEST_COMPARE_EXCLUDE = frozenset(
55
55
  "logs/execution-log.md",
56
56
  "execution-log.md",
57
57
  "events.ndjson",
58
+ "evidence/archive-manifest-after.json",
58
59
  }
59
60
  )
60
61
 
@@ -66,6 +67,21 @@ _RE_UNIT_COUNTS = re.compile(
66
67
  r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)"
67
68
  )
68
69
  _RE_API_PASSED = re.compile(r"(\d+)/(\d+)\s*passed", re.I)
70
+ _RE_RESULT_FRACTION = re.compile(
71
+ r"(\d{1,7})\s*/\s*(\d{1,7})(?=\s*(?:passed|通过|[((]|$))",
72
+ re.I,
73
+ )
74
+
75
+
76
+ def _result_fraction(value: str) -> tuple[int, int] | None:
77
+ match = _RE_RESULT_FRACTION.search(value)
78
+ if match is None:
79
+ return None
80
+ passed = int(match.group(1))
81
+ total = int(match.group(2))
82
+ if total <= 0 or passed > total:
83
+ return None
84
+ return passed, total
69
85
 
70
86
  # Ensure sibling harness_events is importable.
71
87
  if str(SCRIPTS_DIR) not in sys.path:
@@ -1831,12 +1847,14 @@ def verify_manifest_byte_coverage(
1831
1847
  }
1832
1848
  mismatches: list[dict[str, str]] = []
1833
1849
  checked = 0
1850
+ manifest_paths: set[str] = set()
1834
1851
  for item in manifest.get("files") or []:
1835
1852
  if not isinstance(item, dict):
1836
1853
  continue
1837
1854
  rel = str(item.get("path") or "").replace("\\", "/")
1838
1855
  if not rel or _manifest_path_excluded(rel):
1839
1856
  continue
1857
+ manifest_paths.add(rel)
1840
1858
  if rel in excluded:
1841
1859
  reasons.setdefault(rel, "excluded from checksum coverage")
1842
1860
  continue
@@ -1856,6 +1874,14 @@ def verify_manifest_byte_coverage(
1856
1874
  "actual": actual,
1857
1875
  }
1858
1876
  )
1877
+ for path in sorted(root.rglob("*")):
1878
+ if not path.is_file() or path.name.endswith(".lock"):
1879
+ continue
1880
+ rel = path.relative_to(root).as_posix()
1881
+ if _manifest_path_excluded(rel) or rel in excluded:
1882
+ continue
1883
+ if rel not in manifest_paths:
1884
+ mismatches.append({"path": rel, "reason": "unexpected"})
1859
1885
  ok = not mismatches
1860
1886
  if ok and reasons:
1861
1887
  checksum_status = "OK_WITH_EXCLUSIONS"
@@ -2907,6 +2933,19 @@ def execute_archive(
2907
2933
  except OSError:
2908
2934
  pass
2909
2935
  if not gate.get("ok"):
2936
+ if state_root.is_dir():
2937
+ try:
2938
+ append_event(
2939
+ state_root,
2940
+ phase="archive",
2941
+ type_="phase.end",
2942
+ status="BLOCKED",
2943
+ code=str(gate.get("reasonCode") or "ARCHIVE_PREPARE_BLOCKED"),
2944
+ message=str(gate.get("nextAction") or "归档条件未满足"),
2945
+ note="归档未开始,流程已在预检处结束",
2946
+ )
2947
+ except OSError:
2948
+ pass
2910
2949
  status = gate.get("status") if isinstance(gate.get("status"), dict) else {}
2911
2950
  blocked_payload = {
2912
2951
  "ok": False,
@@ -2923,6 +2962,13 @@ def execute_archive(
2923
2962
  }
2924
2963
  if candidate_certification is not None:
2925
2964
  blocked_payload["candidateCertification"] = candidate_certification
2965
+ _best_effort_archive_terminal_sync(
2966
+ find_project_root(change_dir),
2967
+ state_root,
2968
+ change_key=change_dir.name,
2969
+ run_id=hes.run_id_for(change_dir),
2970
+ payload=blocked_payload,
2971
+ )
2926
2972
  return 1, blocked_payload
2927
2973
  code, payload = cmd_finalize(
2928
2974
  change_dir,
@@ -3057,6 +3103,15 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
3057
3103
  skipped = int(m.group(4))
3058
3104
  source = "evidence-text"
3059
3105
  counted = True
3106
+ else:
3107
+ fraction = _result_fraction(evidence_text)
3108
+ if fraction is not None:
3109
+ passed_count, run = fraction
3110
+ failures = max(run - passed_count, 0)
3111
+ errors = 0
3112
+ skipped = 0
3113
+ source = "evidence-text"
3114
+ counted = True
3060
3115
 
3061
3116
  if not counted:
3062
3117
  run = int(unit.get("run", unit.get("testsRun", 0)) or 0)
@@ -3146,6 +3201,11 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
3146
3201
  )
3147
3202
  or 0
3148
3203
  )
3204
+ if attempt_total == 0 and isinstance(attempt.get("evidence"), str):
3205
+ fraction = _result_fraction(str(attempt["evidence"]))
3206
+ if fraction is not None:
3207
+ attempt_passed, attempt_total = fraction
3208
+ attempt_failed = max(attempt_total - attempt_passed, 0)
3149
3209
  raw_identities = (
3150
3210
  attempt.get("testIdentities")
3151
3211
  or attempt_metrics.get("testIdentities")
@@ -3258,6 +3318,14 @@ def _ledger_api_tests(
3258
3318
  blocked = 0
3259
3319
  source = "evidence-text"
3260
3320
  counted = True
3321
+ else:
3322
+ fraction = _result_fraction(evidence_text)
3323
+ if fraction is not None:
3324
+ passed, total = fraction
3325
+ failed = max(total - passed, 0)
3326
+ blocked = 0
3327
+ source = "evidence-text"
3328
+ counted = True
3261
3329
 
3262
3330
  if not counted and change_dir is not None:
3263
3331
  results_path = change_dir / "runtime" / "api-test-results.json"
@@ -3845,6 +3913,11 @@ def _compute_final_status(
3845
3913
  browser_status = str(browser.get("status") or "")
3846
3914
  reasons: list[str] = []
3847
3915
  for phase, v in stage_status.items():
3916
+ # Archive is closeout/transport state, not a product-quality gate. Keep
3917
+ # its failed attempt visible in stageStatus and the command payload,
3918
+ # but do not let it poison a later finalize retry.
3919
+ if phase == "archive":
3920
+ continue
3848
3921
  if v == "FAIL":
3849
3922
  return "FAIL", [f"stage {phase}=FAIL"]
3850
3923
  unit = verification.get("unitTests") or {}
@@ -3871,6 +3944,8 @@ def _compute_final_status(
3871
3944
  if reasons:
3872
3945
  return "CONDITIONAL_OK", reasons
3873
3946
  for phase, v in stage_status.items():
3947
+ if phase == "archive":
3948
+ continue
3874
3949
  if v == "WARN":
3875
3950
  return "WARN", [f"stage {phase}=WARN"]
3876
3951
  if v in conditional:
@@ -4615,6 +4690,11 @@ def _final_commit_from_sources(
4615
4690
 
4616
4691
 
4617
4692
  def _business_goal_from_sources(change_dir: Path, events: list[dict[str, Any]]) -> str:
4693
+ generic_plan_heading = re.compile(
4694
+ r"(?:(?:任务|实施|执行|变更)?计划|(?:implementation|execution|task)?\s*plan)"
4695
+ r"(?:\s*[—–-]\s*\S+)?",
4696
+ re.IGNORECASE,
4697
+ )
4618
4698
  plans_root = change_dir / "plans"
4619
4699
  primary = sorted(plans_root.glob("*-plan.md"))
4620
4700
  secondary = [
@@ -4653,11 +4733,23 @@ def _business_goal_from_sources(change_dir: Path, events: list[dict[str, Any]])
4653
4733
  break
4654
4734
  if lines:
4655
4735
  return lines[0]
4736
+ heading = re.search(r"(?m)^#\s+(.+?)\s*$", body)
4737
+ if heading:
4738
+ title = heading.group(1).strip()
4739
+ title = re.sub(
4740
+ r"\s*[—–-]\s*(?:任务拆分|任务表|实施计划|执行计划|计划)\s*$",
4741
+ "",
4742
+ title,
4743
+ ).strip()
4744
+ if title and not generic_plan_heading.fullmatch(title):
4745
+ return title
4656
4746
  first_task = re.search(r"(?m)^\s*\|\s*1\s*\|\s*([^|]+?)\s*\|", body)
4657
4747
  if first_task:
4658
4748
  return first_task.group(1).strip()
4659
4749
  for line in body.splitlines():
4660
4750
  clean = line.strip().lstrip("#").strip()
4751
+ if generic_plan_heading.fullmatch(clean):
4752
+ continue
4661
4753
  if clean and not clean.startswith(("---", ">", "|")) and len(clean) > 8:
4662
4754
  return clean
4663
4755
  for event in events:
@@ -5328,10 +5420,47 @@ def _append_finalize_failure_terminal(
5328
5420
  message=f"归档条件未满足:{message}",
5329
5421
  note=f"归档操作 {operation_id} 未进入正式执行",
5330
5422
  )
5423
+ append_event(
5424
+ authoritative_change_dir,
5425
+ phase="archive",
5426
+ type_="phase.end",
5427
+ status="BLOCKED",
5428
+ code="ARCHIVE_PREPARE_BLOCKED",
5429
+ message=f"归档条件未满足:{message}",
5430
+ note="归档未开始,流程已在预检处结束",
5431
+ )
5331
5432
  except OSError:
5332
5433
  pass
5333
5434
 
5334
5435
 
5436
+ def _best_effort_archive_terminal_sync(
5437
+ project_root: Path,
5438
+ event_dir: Path,
5439
+ *,
5440
+ change_key: str,
5441
+ run_id: str | None,
5442
+ payload: dict[str, Any],
5443
+ ) -> None:
5444
+ """Publish terminal archive state before any slow post-archive networking."""
5445
+ try:
5446
+ monitor = hes.auto_events_sync(
5447
+ project_root,
5448
+ event_dir,
5449
+ run_id=run_id,
5450
+ change_key=change_key,
5451
+ )
5452
+ payload.setdefault("steps", {})["platform_events_sync"] = monitor
5453
+ if monitor.get("warning"):
5454
+ payload.setdefault("warnings", []).append(str(monitor["warning"]))
5455
+ except Exception as exc: # noqa: BLE001 — monitoring cannot roll back archive
5456
+ warning = f"events-sync terminal hook failed: {exc}"
5457
+ payload.setdefault("warnings", []).append(warning)
5458
+ payload.setdefault("steps", {})["platform_events_sync"] = {
5459
+ "ok": False,
5460
+ "warning": warning,
5461
+ }
5462
+
5463
+
5335
5464
  def _freeze_evidence_cutoff(work_dir: Path) -> dict[str, Any]:
5336
5465
  """Freeze the events cutoff: fsync events, write evidence-cutoff.json.
5337
5466
 
@@ -5599,7 +5728,11 @@ def validate_summary_data(summary: dict[str, Any]) -> dict[str, Any]:
5599
5728
  )
5600
5729
  stage = summary.get("stageStatus") or {}
5601
5730
  stage = stage if isinstance(stage, dict) else {}
5602
- has_failed_stage = any(str(value).upper() == "FAIL" for value in stage.values())
5731
+ has_failed_stage = any(
5732
+ str(value).upper() == "FAIL"
5733
+ for phase, value in stage.items()
5734
+ if str(phase).lower() != "archive"
5735
+ )
5603
5736
  if (has_skip or has_failed_verification or has_failed_stage) and final_status == "OK":
5604
5737
  issues.append(
5605
5738
  {
@@ -7374,6 +7507,7 @@ def cmd_finalize(
7374
7507
  if split_state_dir is not None and split_state_dir.is_dir()
7375
7508
  else original_change_dir
7376
7509
  )
7510
+ monitoring_run_id = hes.run_id_for(original_change_dir)
7377
7511
  operation_id = f"a-{uuid.uuid4().hex[:12]}"
7378
7512
  operation_root = project_root / ".harness" / "archive-operations"
7379
7513
  operation_temp_dir = operation_root / "staging" / operation_id / change_dir.name
@@ -7399,6 +7533,13 @@ def cmd_finalize(
7399
7533
  )
7400
7534
  except OSError:
7401
7535
  pass
7536
+ _best_effort_archive_terminal_sync(
7537
+ project_root,
7538
+ authoritative_event_dir,
7539
+ change_key=change_name,
7540
+ run_id=monitoring_run_id,
7541
+ payload=payload,
7542
+ )
7402
7543
  try:
7403
7544
  write_json(
7404
7545
  operation_record,
@@ -7479,12 +7620,17 @@ def cmd_finalize(
7479
7620
  payload["original_preserved"] = original_change_dir.is_dir()
7480
7621
  payload["finalStatus"] = "BLOCKED"
7481
7622
  _append_finalize_failure_terminal(
7482
- split_state_dir
7483
- if split_state_dir is not None and split_state_dir.is_dir()
7484
- else original_change_dir,
7623
+ authoritative_event_dir,
7485
7624
  payload["error"],
7486
7625
  operation_id=operation_id,
7487
7626
  )
7627
+ _best_effort_archive_terminal_sync(
7628
+ project_root,
7629
+ authoritative_event_dir,
7630
+ change_key=change_name,
7631
+ run_id=monitoring_run_id,
7632
+ payload=payload,
7633
+ )
7488
7634
  return 1, payload
7489
7635
 
7490
7636
  # The formal archive attempt starts after the one-shot preflight succeeds,
@@ -8253,7 +8399,6 @@ def cmd_finalize(
8253
8399
  # as mutable staging, so a failed attempt cannot poison the next retry.
8254
8400
  # Capture monitoring run_id from the live change path before it is deleted
8255
8401
  # so archive finalize can report terminal status on the same platform run.
8256
- monitoring_run_id = hes.run_id_for(original_change_dir)
8257
8402
  try:
8258
8403
  archive_root.mkdir(parents=True, exist_ok=True)
8259
8404
  shutil.move(str(operation_temp_dir), str(archive_dir))
@@ -8300,11 +8445,56 @@ def cmd_finalize(
8300
8445
  }
8301
8446
  payload["knowledgeMaintenance"] = "REMOTE_PENDING"
8302
8447
 
8448
+ # Freeze the platform run immediately after the local archive is published.
8449
+ # Service shutdown, ZIP upload, and managed snapshot synchronization are
8450
+ # independent post-phase work and may wait on process/network deadlines.
8451
+ _best_effort_archive_terminal_sync(
8452
+ project_root,
8453
+ archive_dir,
8454
+ change_key=change_name,
8455
+ run_id=monitoring_run_id,
8456
+ payload=payload,
8457
+ )
8458
+
8303
8459
  service_result = run_service_stop(work_dir)
8304
8460
  payload["steps"]["service_stop"] = service_result
8305
8461
  if service_result.get("warning"):
8306
8462
  warnings.append(str(service_result["warning"]))
8307
8463
 
8464
+ # When remote credentials exist, upload one deterministic core-only ZIP.
8465
+ push_result = auto_push_archive_core(
8466
+ project_root,
8467
+ archive_dir,
8468
+ change_key=change_name,
8469
+ )
8470
+ payload["steps"]["archive_push"] = push_result
8471
+ payload["knowledgeMaintenance"] = _knowledge_maintenance_from_archive_push(
8472
+ push_result
8473
+ )
8474
+ if push_result.get("archiveStatus") == "durable":
8475
+ payload["archiveDurability"] = "ARCHIVED_REMOTE_DURABLE"
8476
+ if push_result.get("warning"):
8477
+ warnings.append(str(push_result["warning"]))
8478
+
8479
+ managed_snapshot = auto_push_managed_snapshot(project_root)
8480
+ payload["steps"]["managed_snapshot_push"] = managed_snapshot
8481
+ if managed_snapshot.get("warning"):
8482
+ warnings.append(str(managed_snapshot["warning"]))
8483
+
8484
+ archive_remote = {
8485
+ key: push_result.get(key)
8486
+ for key in (
8487
+ "archiveId",
8488
+ "archiveStatus",
8489
+ "knowledgeStatus",
8490
+ "uploadStatus",
8491
+ "packageSha256",
8492
+ "manifestSha256",
8493
+ "reasonCode",
8494
+ "remoteReceipt",
8495
+ )
8496
+ if push_result.get(key) is not None
8497
+ }
8308
8498
  try:
8309
8499
  write_json(
8310
8500
  operation_record,
@@ -8321,41 +8511,14 @@ def cmd_finalize(
8321
8511
  archive_dir / "evidence" / "archive-manifest-after.json"
8322
8512
  ),
8323
8513
  "archiveDurability": payload.get("archiveDurability"),
8514
+ "knowledgeMaintenance": payload["knowledgeMaintenance"],
8515
+ "archiveRemote": archive_remote,
8516
+ "managedSnapshot": managed_snapshot,
8324
8517
  },
8325
8518
  )
8326
8519
  except OSError as exc:
8327
8520
  warnings.append(f"could not persist completed archive operation: {exc}")
8328
8521
 
8329
- # When remote credentials exist, upload one deterministic core-only ZIP.
8330
- push_result = auto_push_archive_core(
8331
- project_root,
8332
- archive_dir,
8333
- change_key=change_name,
8334
- )
8335
- payload["steps"]["archive_push"] = push_result
8336
- payload["knowledgeMaintenance"] = _knowledge_maintenance_from_archive_push(
8337
- push_result
8338
- )
8339
- if push_result.get("warning"):
8340
- warnings.append(str(push_result["warning"]))
8341
-
8342
- # C3: best-effort terminal events-sync so platform run ends (succeeded/failed).
8343
- # Use the pre-delete change-path run_id + original change_key for continuity.
8344
- try:
8345
- monitor = hes.auto_events_sync(
8346
- project_root,
8347
- archive_dir,
8348
- run_id=monitoring_run_id,
8349
- change_key=change_name,
8350
- )
8351
- payload["steps"]["platform_events_sync"] = monitor
8352
- if monitor.get("warning"):
8353
- warnings.append(str(monitor["warning"]))
8354
- except Exception as exc: # noqa: BLE001 — never roll back archive
8355
- warn = f"events-sync terminal hook failed: {exc}"
8356
- warnings.append(warn)
8357
- payload["steps"]["platform_events_sync"] = {"ok": False, "warning": warn}
8358
-
8359
8522
  payload["ok"] = True
8360
8523
  payload["finalStatus"] = "OK"
8361
8524
  payload["warnings"] = warnings
@@ -8877,6 +9040,82 @@ def build_archive_package(
8877
9040
  }
8878
9041
 
8879
9042
 
9043
+ def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9044
+ """Best-effort upload of rules and Codebase Map through the normal push protocol."""
9045
+ project_root = project_root.resolve()
9046
+ credentials = _resolve_archive_remote_credentials(project_root, os.environ)
9047
+ if not credentials["configured"]:
9048
+ return {
9049
+ "ok": False,
9050
+ "skipped": True,
9051
+ "reasonCode": "MANAGED_SNAPSHOT_CREDENTIALS_MISSING",
9052
+ "missingCredentials": credentials["missing"],
9053
+ }
9054
+ try:
9055
+ launcher = resolve_npx_launcher()
9056
+ except OSError as exc:
9057
+ return {
9058
+ "ok": False,
9059
+ "skipped": False,
9060
+ "reasonCode": "MANAGED_SNAPSHOT_UPLOAD_DEFERRED",
9061
+ "warning": f"项目规则与架构地图上传已延后:{exc}",
9062
+ }
9063
+ command = [
9064
+ *launcher,
9065
+ "--yes",
9066
+ "hunter-harness",
9067
+ "push",
9068
+ "--yes",
9069
+ "--non-interactive",
9070
+ "--json",
9071
+ "--server-url",
9072
+ str(credentials["serverUrl"]),
9073
+ ]
9074
+ if credentials["tokenEnv"] is not None:
9075
+ command.extend(["--token-env", str(credentials["tokenEnv"])])
9076
+ try:
9077
+ completed = subprocess.run(
9078
+ command,
9079
+ cwd=str(project_root),
9080
+ text=True,
9081
+ stdout=subprocess.PIPE,
9082
+ stderr=subprocess.PIPE,
9083
+ timeout=180,
9084
+ shell=False,
9085
+ )
9086
+ except (OSError, subprocess.TimeoutExpired) as exc:
9087
+ return {
9088
+ "ok": False,
9089
+ "skipped": False,
9090
+ "reasonCode": "MANAGED_SNAPSHOT_UPLOAD_DEFERRED",
9091
+ "warning": f"项目规则与架构地图上传已延后:{exc}",
9092
+ }
9093
+ try:
9094
+ output = json.loads(completed.stdout)
9095
+ except (json.JSONDecodeError, TypeError):
9096
+ output = None
9097
+ if completed.returncode != 0 or not isinstance(output, dict):
9098
+ return {
9099
+ "ok": False,
9100
+ "skipped": False,
9101
+ "exitCode": completed.returncode,
9102
+ "reasonCode": "MANAGED_SNAPSHOT_UPLOAD_FAILED",
9103
+ "warning": (
9104
+ "项目规则与架构地图未能同步到平台;归档不受影响,"
9105
+ "稍后可运行 npx hunter-harness push --yes --non-interactive 重试。"
9106
+ ),
9107
+ }
9108
+ summary = output.get("summary")
9109
+ submitted = summary.get("submitted", 0) if isinstance(summary, dict) else 0
9110
+ return {
9111
+ "ok": bool(output.get("ok", True)),
9112
+ "skipped": False,
9113
+ "projectId": output.get("project_id"),
9114
+ "submitted": submitted if isinstance(submitted, int) else 0,
9115
+ "unchanged": "MANAGED_SNAPSHOT_UNCHANGED" in output.get("warnings", []),
9116
+ }
9117
+
9118
+
8880
9119
  def auto_push_archive_core(
8881
9120
  project_root: Path,
8882
9121
  archive_dir: Path,
@@ -8913,6 +9152,12 @@ def auto_push_archive_core(
8913
9152
  label="archive upload receipt",
8914
9153
  must_exist=False,
8915
9154
  )
9155
+ remote_receipt_path = _require_archive_project_path(
9156
+ project_root,
9157
+ package_path.with_suffix(".remote.json"),
9158
+ label="durable archive receipt",
9159
+ must_exist=False,
9160
+ )
8916
9161
  except (OSError, ValueError) as exc:
8917
9162
  return {
8918
9163
  "skipped": False,
@@ -9153,6 +9398,45 @@ def auto_push_archive_core(
9153
9398
  else:
9154
9399
  upload_status = "pending"
9155
9400
  reason_code = "ARCHIVE_KNOWLEDGE_INDEXING"
9401
+ durable_receipt = {
9402
+ "schemaVersion": 1,
9403
+ "recordedAt": now_iso(),
9404
+ "changeKey": effective_change_key,
9405
+ "archiveId": cli_payload.get("archive_id"),
9406
+ "archiveStatus": archive_status,
9407
+ "knowledgeStatus": knowledge_status,
9408
+ "uploadStatus": upload_status,
9409
+ "packageSha256": package["packageSha256"],
9410
+ "manifestSha256": package["manifestSha256"],
9411
+ "fileCount": package["fileCount"],
9412
+ }
9413
+ try:
9414
+ remote_receipt_path.parent.mkdir(parents=True, exist_ok=True)
9415
+ write_json(remote_receipt_path, durable_receipt)
9416
+ except OSError as exc:
9417
+ try:
9418
+ persist_receipt(
9419
+ upload_status=upload_status,
9420
+ reason_code="ARCHIVE_DURABLE_RECEIPT_WRITE_FAILED",
9421
+ archive_status=archive_status,
9422
+ knowledge_status=knowledge_status,
9423
+ exit_code=completed.returncode,
9424
+ )
9425
+ except OSError:
9426
+ pass
9427
+ return {
9428
+ **base_result,
9429
+ "ok": True,
9430
+ "uploadStatus": upload_status,
9431
+ "archiveId": cli_payload.get("archive_id"),
9432
+ "archiveStatus": archive_status,
9433
+ "knowledgeStatus": knowledge_status,
9434
+ "reasonCode": "ARCHIVE_DURABLE_RECEIPT_WRITE_FAILED",
9435
+ "warning": (
9436
+ "服务端归档已持久化,但无法写入本地归档状态回执;"
9437
+ f"已保留重试文件:{exc}"
9438
+ ),
9439
+ }
9156
9440
  try:
9157
9441
  persist_receipt(
9158
9442
  upload_status=upload_status,
@@ -9180,6 +9464,7 @@ def auto_push_archive_core(
9180
9464
  "archiveStatus": archive_status,
9181
9465
  "knowledgeStatus": knowledge_status,
9182
9466
  "reasonCode": reason_code,
9467
+ "remoteReceipt": str(remote_receipt_path),
9183
9468
  }
9184
9469
  if fully_ready:
9185
9470
  cleanup_warnings: list[str] = []
@@ -9,12 +9,19 @@ import hashlib
9
9
  import json
10
10
  import os
11
11
  import subprocess
12
+ import sys
12
13
  import tempfile
13
14
  import time
14
15
  from contextlib import contextmanager
15
16
  from pathlib import Path
16
17
  from typing import Any
17
18
 
19
+ SCRIPTS_DIR = Path(__file__).resolve().parent
20
+ if str(SCRIPTS_DIR) not in sys.path:
21
+ sys.path.insert(0, str(SCRIPTS_DIR))
22
+
23
+ import harness_ledger as hl # noqa: E402
24
+
18
25
 
19
26
  PHASE_GRAPH = {
20
27
  "plan": ("run",),
@@ -309,6 +316,14 @@ def configure_phase_plan(
309
316
  "ok": False,
310
317
  "code": "PHASE_PLAN_JUSTIFICATION_REQUIRED",
311
318
  }
319
+ worktree_path = contract_root / "meta" / "worktree.json"
320
+ if worktree_path.is_file():
321
+ try:
322
+ worktree = _read_json(worktree_path)
323
+ except (OSError, ValueError, json.JSONDecodeError):
324
+ worktree = {}
325
+ if bool(worktree.get("requested") or worktree.get("created")) and "merge" not in normalized:
326
+ normalized.insert(normalized.index("archive"), "merge")
312
327
  policy_path = contract_root / "meta" / "gate-policy.json"
313
328
  policy = _read_json(policy_path) if policy_path.is_file() else {"schemaVersion": 1}
314
329
  skipped = [
@@ -382,28 +397,16 @@ def _parse_time(value: Any) -> dt.datetime | None:
382
397
 
383
398
 
384
399
  def _execution_root(project: Path, contract_root: Path, state_root: Path) -> Path:
385
- for candidate in (
386
- state_root / "meta" / "worktree.json",
387
- contract_root / "meta" / "worktree.json",
388
- ):
389
- if not candidate.is_file():
390
- continue
391
- try:
392
- worktree = _read_json(candidate)
393
- except (OSError, ValueError, json.JSONDecodeError):
394
- continue
395
- path = worktree.get("path")
396
- if not path and worktree.get("worktreeRoot"):
397
- path = Path(str(worktree["worktreeRoot"])) / contract_root.name
398
- if path:
399
- unresolved = Path(str(path))
400
- resolved = (
401
- unresolved.resolve()
402
- if unresolved.is_absolute()
403
- else (project / unresolved).resolve()
404
- )
405
- if resolved.is_dir() and _same_repository(project, resolved):
406
- return resolved
400
+ declared = False
401
+ # Context、ledger gate 必须以同一份可信 worktree 契约为准。split
402
+ # state 可能携带运行元数据,因此两个根都交给统一解析器检查。
403
+ for change_root in (state_root, contract_root):
404
+ inferred = hl.infer_execution_project_root(change_root)
405
+ if inferred is not None:
406
+ return inferred
407
+ declared = declared or hl.declares_execution_worktree(change_root)
408
+ if declared:
409
+ raise ValueError("EXECUTION_WORKTREE_INVALID")
407
410
  return project.resolve()
408
411
 
409
412
 
@@ -595,7 +598,14 @@ def _claim_prepared_context(
595
598
  "toPhase": phase,
596
599
  }
597
600
 
598
- execution_root = _execution_root(project, contract_root, state_root)
601
+ try:
602
+ execution_root = _execution_root(project, contract_root, state_root)
603
+ except ValueError as exc:
604
+ return {
605
+ "ok": False,
606
+ "code": "EXECUTION_WORKTREE_INVALID",
607
+ "error": str(exc),
608
+ }
599
609
  now = _now()
600
610
  lease: dict[str, Any] = {
601
611
  "schemaVersion": 1,
@@ -950,10 +960,18 @@ def _begin_transition_unlocked(
950
960
  ) -> dict[str, Any]:
951
961
  project = Path(project).resolve()
952
962
  try:
953
- _contract_root, _contract_data, state_root = _contract(project, change)
963
+ contract_root, _contract_data, state_root = _contract(project, change)
954
964
  except (OSError, ValueError, json.JSONDecodeError) as exc:
955
965
  return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
956
966
  paths = _paths(state_root)
967
+ try:
968
+ execution_root = _execution_root(project, contract_root, state_root)
969
+ except ValueError as exc:
970
+ return {
971
+ "ok": False,
972
+ "code": "EXECUTION_WORKTREE_INVALID",
973
+ "error": str(exc),
974
+ }
957
975
  if preparation_id is not None:
958
976
  current = _read_json(paths["current"]) if paths["current"].is_file() else None
959
977
  lease = _read_json(paths["lease"]) if paths["lease"].is_file() else None
@@ -1027,7 +1045,7 @@ def _begin_transition_unlocked(
1027
1045
  "changeName": change,
1028
1046
  "phase": phase,
1029
1047
  "executor": executor,
1030
- "executionRoot": str(_execution_root(project, project / ".harness/changes" / change, state_root)),
1048
+ "executionRoot": str(execution_root),
1031
1049
  "receiptHash": receipt["receiptHash"],
1032
1050
  "begunAt": acknowledgment["begunAt"],
1033
1051
  }