@hunter-harness/workflow-harness 0.2.11 → 0.2.12

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 (242) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/ci-metrics.schema.json +36 -0
  3. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +30 -0
  4. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  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 +4 -4
  9. package/harness/bundles/general/claude-code/harness-plan/checklist.md +6 -0
  10. package/harness/bundles/general/claude-code/harness-plan/reference.md +7 -2
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/checklist.md +3 -2
  14. package/harness/bundles/general/claude-code/harness-run/reference.md +6 -4
  15. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/scripts/harness_archive.py +282 -120
  19. package/harness/bundles/general/claude-code/scripts/harness_events.py +400 -24
  20. package/harness/bundles/general/claude-code/scripts/harness_gate.py +585 -21
  21. package/harness/bundles/general/claude-code/scripts/harness_integration.py +403 -18
  22. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +225 -29
  23. package/harness/bundles/general/claude-code/scripts/harness_phase.py +926 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +361 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_profile.py +50 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +248 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_state.py +15 -2
  28. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +188 -0
  29. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +19 -0
  30. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  31. package/harness/bundles/general/codebuddy/contracts/ci-metrics.schema.json +36 -0
  32. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +30 -0
  33. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
  38. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +6 -0
  39. package/harness/bundles/general/codebuddy/harness-plan/reference.md +7 -2
  40. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-run/checklist.md +3 -2
  43. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -4
  44. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  45. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  47. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +282 -120
  48. package/harness/bundles/general/codebuddy/scripts/harness_events.py +400 -24
  49. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +585 -21
  50. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +403 -18
  51. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +225 -29
  52. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +926 -0
  53. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +361 -0
  54. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +50 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +248 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_state.py +15 -2
  57. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +188 -0
  58. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +19 -0
  59. package/harness/bundles/general/codex/.harness-build.json +1 -1
  60. package/harness/bundles/general/codex/contracts/ci-metrics.schema.json +36 -0
  61. package/harness/bundles/general/codex/contracts/workflow-policy.json +30 -0
  62. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
  67. package/harness/bundles/general/codex/harness-plan/checklist.md +6 -0
  68. package/harness/bundles/general/codex/harness-plan/reference.md +7 -2
  69. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  70. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  71. package/harness/bundles/general/codex/harness-run/checklist.md +3 -2
  72. package/harness/bundles/general/codex/harness-run/reference.md +6 -4
  73. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  75. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  76. package/harness/bundles/general/codex/scripts/harness_archive.py +282 -120
  77. package/harness/bundles/general/codex/scripts/harness_events.py +400 -24
  78. package/harness/bundles/general/codex/scripts/harness_gate.py +585 -21
  79. package/harness/bundles/general/codex/scripts/harness_integration.py +403 -18
  80. package/harness/bundles/general/codex/scripts/harness_ledger.py +225 -29
  81. package/harness/bundles/general/codex/scripts/harness_phase.py +926 -0
  82. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +361 -0
  83. package/harness/bundles/general/codex/scripts/harness_profile.py +50 -0
  84. package/harness/bundles/general/codex/scripts/harness_runtime.py +248 -0
  85. package/harness/bundles/general/codex/scripts/harness_state.py +15 -2
  86. package/harness/bundles/general/codex/scripts/harness_test_guard.py +188 -0
  87. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +19 -0
  88. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  89. package/harness/bundles/general/cursor/contracts/ci-metrics.schema.json +36 -0
  90. package/harness/bundles/general/cursor/contracts/workflow-policy.json +30 -0
  91. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
  96. package/harness/bundles/general/cursor/harness-plan/checklist.md +6 -0
  97. package/harness/bundles/general/cursor/harness-plan/reference.md +7 -2
  98. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  99. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  100. package/harness/bundles/general/cursor/harness-run/checklist.md +3 -2
  101. package/harness/bundles/general/cursor/harness-run/reference.md +6 -4
  102. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  103. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  104. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  105. package/harness/bundles/general/cursor/scripts/harness_archive.py +282 -120
  106. package/harness/bundles/general/cursor/scripts/harness_events.py +400 -24
  107. package/harness/bundles/general/cursor/scripts/harness_gate.py +585 -21
  108. package/harness/bundles/general/cursor/scripts/harness_integration.py +403 -18
  109. package/harness/bundles/general/cursor/scripts/harness_ledger.py +225 -29
  110. package/harness/bundles/general/cursor/scripts/harness_phase.py +926 -0
  111. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +361 -0
  112. package/harness/bundles/general/cursor/scripts/harness_profile.py +50 -0
  113. package/harness/bundles/general/cursor/scripts/harness_runtime.py +248 -0
  114. package/harness/bundles/general/cursor/scripts/harness_state.py +15 -2
  115. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +188 -0
  116. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +19 -0
  117. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  118. package/harness/bundles/java/claude-code/contracts/ci-metrics.schema.json +36 -0
  119. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +30 -0
  120. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  126. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
  127. package/harness/bundles/java/claude-code/harness-plan/checklist.md +6 -0
  128. package/harness/bundles/java/claude-code/harness-plan/reference.md +7 -2
  129. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  131. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  132. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  133. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  134. package/harness/bundles/java/claude-code/scripts/harness_archive.py +282 -120
  135. package/harness/bundles/java/claude-code/scripts/harness_events.py +400 -24
  136. package/harness/bundles/java/claude-code/scripts/harness_gate.py +585 -21
  137. package/harness/bundles/java/claude-code/scripts/harness_integration.py +403 -18
  138. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +225 -29
  139. package/harness/bundles/java/claude-code/scripts/harness_phase.py +926 -0
  140. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +361 -0
  141. package/harness/bundles/java/claude-code/scripts/harness_profile.py +50 -0
  142. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +248 -0
  143. package/harness/bundles/java/claude-code/scripts/harness_state.py +15 -2
  144. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +188 -0
  145. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +19 -0
  146. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  147. package/harness/bundles/java/codebuddy/contracts/ci-metrics.schema.json +36 -0
  148. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +30 -0
  149. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  150. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  153. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  154. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  155. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
  156. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +6 -0
  157. package/harness/bundles/java/codebuddy/harness-plan/reference.md +7 -2
  158. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  159. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  160. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  161. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  162. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  163. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +282 -120
  164. package/harness/bundles/java/codebuddy/scripts/harness_events.py +400 -24
  165. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +585 -21
  166. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +403 -18
  167. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +225 -29
  168. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +926 -0
  169. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +361 -0
  170. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +50 -0
  171. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +248 -0
  172. package/harness/bundles/java/codebuddy/scripts/harness_state.py +15 -2
  173. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +188 -0
  174. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +19 -0
  175. package/harness/bundles/java/codex/.harness-build.json +1 -1
  176. package/harness/bundles/java/codex/contracts/ci-metrics.schema.json +36 -0
  177. package/harness/bundles/java/codex/contracts/workflow-policy.json +30 -0
  178. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  180. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  181. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  182. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  183. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  184. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
  185. package/harness/bundles/java/codex/harness-plan/checklist.md +6 -0
  186. package/harness/bundles/java/codex/harness-plan/reference.md +7 -2
  187. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  188. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  189. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  190. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  191. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  192. package/harness/bundles/java/codex/scripts/harness_archive.py +282 -120
  193. package/harness/bundles/java/codex/scripts/harness_events.py +400 -24
  194. package/harness/bundles/java/codex/scripts/harness_gate.py +585 -21
  195. package/harness/bundles/java/codex/scripts/harness_integration.py +403 -18
  196. package/harness/bundles/java/codex/scripts/harness_ledger.py +225 -29
  197. package/harness/bundles/java/codex/scripts/harness_phase.py +926 -0
  198. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +361 -0
  199. package/harness/bundles/java/codex/scripts/harness_profile.py +50 -0
  200. package/harness/bundles/java/codex/scripts/harness_runtime.py +248 -0
  201. package/harness/bundles/java/codex/scripts/harness_state.py +15 -2
  202. package/harness/bundles/java/codex/scripts/harness_test_guard.py +188 -0
  203. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +19 -0
  204. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  205. package/harness/bundles/java/cursor/contracts/ci-metrics.schema.json +36 -0
  206. package/harness/bundles/java/cursor/contracts/workflow-policy.json +30 -0
  207. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  209. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  210. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  211. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  212. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  213. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
  214. package/harness/bundles/java/cursor/harness-plan/checklist.md +6 -0
  215. package/harness/bundles/java/cursor/harness-plan/reference.md +7 -2
  216. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  217. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  218. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  219. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  220. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  221. package/harness/bundles/java/cursor/scripts/harness_archive.py +282 -120
  222. package/harness/bundles/java/cursor/scripts/harness_events.py +400 -24
  223. package/harness/bundles/java/cursor/scripts/harness_gate.py +585 -21
  224. package/harness/bundles/java/cursor/scripts/harness_integration.py +403 -18
  225. package/harness/bundles/java/cursor/scripts/harness_ledger.py +225 -29
  226. package/harness/bundles/java/cursor/scripts/harness_phase.py +926 -0
  227. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +361 -0
  228. package/harness/bundles/java/cursor/scripts/harness_profile.py +50 -0
  229. package/harness/bundles/java/cursor/scripts/harness_runtime.py +248 -0
  230. package/harness/bundles/java/cursor/scripts/harness_state.py +15 -2
  231. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +188 -0
  232. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +19 -0
  233. package/harness/manifests/general/claude-code.json +42 -26
  234. package/harness/manifests/general/codebuddy.json +42 -26
  235. package/harness/manifests/general/codex.json +42 -26
  236. package/harness/manifests/general/cursor.json +42 -26
  237. package/harness/manifests/java/claude-code.json +42 -26
  238. package/harness/manifests/java/codebuddy.json +42 -26
  239. package/harness/manifests/java/codex.json +42 -26
  240. package/harness/manifests/java/cursor.json +42 -26
  241. package/hunter-workflow-family.json +2 -2
  242. package/package.json +1 -1
@@ -0,0 +1,926 @@
1
+ #!/usr/bin/env python3
2
+ """Phase reconciliation, trace projection, and normalized CI metrics.
3
+
4
+ The reconciler is deliberately an evidence orchestrator: it decides REUSE,
5
+ RUN, or BLOCK for the required gate DAG. It never executes user test commands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import datetime as dt
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ import uuid
19
+ import xml.etree.ElementTree as ET
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ SCRIPTS_DIR = Path(__file__).resolve().parent
26
+ if str(SCRIPTS_DIR) not in sys.path:
27
+ sys.path.insert(0, str(SCRIPTS_DIR))
28
+
29
+ import harness_events as he # noqa: E402
30
+ import harness_ledger as hl # noqa: E402
31
+ import harness_paths as hpaths # noqa: E402
32
+
33
+
34
+ SCHEMA_VERSION = 1
35
+ CI_METRICS_SCHEMA_VERSION = 1
36
+ PHASE_ORDER = ("plan", "run", "test", "review", "package", "apidoc", "submit", "archive")
37
+ VALIDATION_PHASES = {
38
+ "compile": "run",
39
+ "unitTest": "run",
40
+ "unitTestFull": "test",
41
+ "apiTest": "test",
42
+ "dbCompatibility": "test",
43
+ "package": "package",
44
+ }
45
+
46
+
47
+ def _read_json(path: Path) -> dict[str, Any]:
48
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
49
+ if not isinstance(value, dict):
50
+ raise ValueError(f"JSON object required: {path}")
51
+ return value
52
+
53
+
54
+ def _write_json(path: Path, value: Any) -> None:
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ temp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
57
+ try:
58
+ temp.write_text(
59
+ json.dumps(value, ensure_ascii=False, indent=2) + "\n",
60
+ encoding="utf-8",
61
+ newline="\n",
62
+ )
63
+ temp.replace(path)
64
+ except BaseException:
65
+ temp.unlink(missing_ok=True)
66
+ raise
67
+
68
+
69
+ def _stable_hex(*parts: Any, length: int) -> str:
70
+ raw = "\x1f".join(str(part) for part in parts).encode("utf-8")
71
+ return hashlib.sha256(raw).hexdigest()[:length]
72
+
73
+
74
+ def target_required_dag(policy: dict[str, Any], target_phase: str) -> dict[str, Any]:
75
+ """Return only the required predecessors needed to close ``target_phase``.
76
+
77
+ Validations owned by the target phase are prerequisites to closing it;
78
+ stage nodes for the target itself are deliberately excluded because their
79
+ ``phase.end`` is the result of close, not evidence required before close.
80
+ """
81
+ if target_phase not in PHASE_ORDER:
82
+ raise ValueError(f"unsupported reconcile target phase: {target_phase}")
83
+ dag = policy.get("requiredGateDag")
84
+ if not isinstance(dag, dict) or not isinstance(dag.get("nodes"), list):
85
+ raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
86
+ target_rank = PHASE_ORDER.index(target_phase)
87
+ selected: list[dict[str, Any]] = []
88
+ for raw in dag["nodes"]:
89
+ if not isinstance(raw, dict):
90
+ raise ValueError("requiredGateDag node must be an object")
91
+ node = dict(raw)
92
+ node_id = str(node.get("id") or "")
93
+ node_kind, _, node_name = node_id.partition(":")
94
+ inferred_phase = (
95
+ VALIDATION_PHASES.get(node_name)
96
+ if node_kind == "validation"
97
+ else node_name
98
+ )
99
+ node_phase = str(node.get("phase") or inferred_phase or "")
100
+ if node_phase not in PHASE_ORDER:
101
+ raise ValueError(f"requiredGateDag node has unsupported phase: {node_id}")
102
+ node_rank = PHASE_ORDER.index(node_phase)
103
+ is_validation = node.get("kind") == "validation" or node_id.startswith(
104
+ "validation:"
105
+ )
106
+ if node_rank < target_rank or (is_validation and node_rank == target_rank):
107
+ selected.append(node)
108
+ selected_ids = {str(node.get("id")) for node in selected}
109
+ for node in selected:
110
+ excluded_dependencies = [
111
+ dependency
112
+ for dependency in (node.get("dependsOn") or [])
113
+ if dependency not in selected_ids
114
+ ]
115
+ if excluded_dependencies:
116
+ raise ValueError(
117
+ f"target DAG for {target_phase} excludes dependencies of "
118
+ f"{node.get('id')}: {', '.join(excluded_dependencies)}"
119
+ )
120
+ return {
121
+ "schemaVersion": dag.get("schemaVersion", 1),
122
+ "nodes": selected,
123
+ "edges": [
124
+ edge
125
+ for edge in (dag.get("edges") or [])
126
+ if isinstance(edge, dict)
127
+ and edge.get("from") in selected_ids
128
+ and edge.get("to") in selected_ids
129
+ ],
130
+ "targetPhase": target_phase,
131
+ }
132
+
133
+
134
+ def select_phase_capsule(
135
+ change_dir: Path,
136
+ project_root: Path,
137
+ *,
138
+ phase: str,
139
+ run_id: str | None,
140
+ ) -> dict[str, Any]:
141
+ """Select a phase capsule by its exact phase/run identity, never by mtime."""
142
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", phase):
143
+ return {
144
+ "ok": False,
145
+ "code": "PHASE_INVALID",
146
+ "message": f"invalid phase selector: {phase}",
147
+ "remediation": "pass the phase name emitted by harness_gate.py begin",
148
+ }
149
+ state_dir = hpaths.resolve_state_dir_for_contract(change_dir, project_root)
150
+ capsule_dir = state_dir / "runtime" / "phase-context"
151
+ candidates = (
152
+ [
153
+ path
154
+ for path in capsule_dir.iterdir()
155
+ if path.is_file()
156
+ and path.name.startswith(f"{phase}-")
157
+ and path.suffix == ".json"
158
+ ]
159
+ if capsule_dir.is_dir()
160
+ else []
161
+ )
162
+ if not run_id:
163
+ if not candidates:
164
+ return {
165
+ "ok": True,
166
+ "code": "PHASE_CAPSULE_ABSENT",
167
+ "capsule": None,
168
+ "path": None,
169
+ }
170
+ return {
171
+ "ok": False,
172
+ "code": "PHASE_RUN_ID_REQUIRED",
173
+ "message": f"{len(candidates)} capsule(s) exist for phase {phase}",
174
+ "remediation": f"rerun reconcile with --phase {phase} --run-id <run-id>",
175
+ }
176
+ key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
177
+ path = capsule_dir / f"{phase}-{key}.json"
178
+ if not path.is_file():
179
+ return {
180
+ "ok": False,
181
+ "code": "PHASE_CAPSULE_NOT_FOUND",
182
+ "message": f"no capsule for phase {phase} and run-id {run_id}",
183
+ "expectedPath": str(path),
184
+ "remediation": f"begin/resume the phase or verify --phase {phase} --run-id {run_id}",
185
+ }
186
+ try:
187
+ capsule = _read_json(path)
188
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
189
+ return {
190
+ "ok": False,
191
+ "code": "PHASE_CAPSULE_INVALID",
192
+ "message": str(exc),
193
+ "path": str(path),
194
+ "remediation": f"repair or resume the capsule for --phase {phase} --run-id {run_id}",
195
+ }
196
+ if capsule.get("phase") != phase or capsule.get("runId") != run_id:
197
+ return {
198
+ "ok": False,
199
+ "code": "PHASE_CAPSULE_IDENTITY_MISMATCH",
200
+ "message": "capsule content does not match the requested phase/run-id",
201
+ "path": str(path),
202
+ "remediation": f"resume the exact phase with --phase {phase} --run-id {run_id}",
203
+ }
204
+ return {
205
+ "ok": True,
206
+ "code": "PHASE_CAPSULE_SELECTED",
207
+ "capsule": capsule,
208
+ "path": str(path),
209
+ }
210
+
211
+
212
+ def reconcile_dag(
213
+ dag: dict[str, Any],
214
+ evidence: dict[str, dict[str, Any]],
215
+ *,
216
+ identity: dict[str, Any],
217
+ ) -> dict[str, Any]:
218
+ """Reduce a required DAG to deterministic REUSE/RUN/BLOCK decisions."""
219
+ raw_nodes = dag.get("nodes")
220
+ if not isinstance(raw_nodes, list):
221
+ raise ValueError("requiredGateDag.nodes must be an array")
222
+ if not raw_nodes:
223
+ raise ValueError("requiredGateDag.nodes must not be empty")
224
+ nodes_by_id: dict[str, dict[str, Any]] = {}
225
+ for raw in raw_nodes:
226
+ if not isinstance(raw, dict) or not str(raw.get("id") or "").strip():
227
+ raise ValueError("requiredGateDag node requires a non-empty id")
228
+ node_id = str(raw["id"])
229
+ if node_id in nodes_by_id:
230
+ raise ValueError(f"duplicate DAG node: {node_id}")
231
+ depends = raw.get("dependsOn") or []
232
+ if not isinstance(depends, list) or any(not isinstance(item, str) for item in depends):
233
+ raise ValueError(f"invalid dependsOn for {node_id}")
234
+ nodes_by_id[node_id] = dict(raw)
235
+ for node_id, node in nodes_by_id.items():
236
+ missing = [dep for dep in node.get("dependsOn") or [] if dep not in nodes_by_id]
237
+ if missing:
238
+ raise ValueError(f"unknown dependency for {node_id}: {', '.join(missing)}")
239
+
240
+ if not identity.get("ok"):
241
+ blocker = {
242
+ "code": str(identity.get("code") or "IDENTITY_MISMATCH"),
243
+ "message": str(identity.get("message") or "identity validation failed"),
244
+ }
245
+ return {
246
+ "nodes": [
247
+ {
248
+ **node,
249
+ "decision": "BLOCK",
250
+ "reason": blocker["code"],
251
+ "evidence": evidence.get(node_id) or {},
252
+ }
253
+ for node_id, node in nodes_by_id.items()
254
+ ],
255
+ "blockers": [blocker],
256
+ "canClose": False,
257
+ }
258
+
259
+ pending = set(nodes_by_id)
260
+ decided: dict[str, dict[str, Any]] = {}
261
+ while pending:
262
+ progressed = False
263
+ for node_id in list(pending):
264
+ node = nodes_by_id[node_id]
265
+ dependencies = list(node.get("dependsOn") or [])
266
+ if any(dep not in decided for dep in dependencies):
267
+ continue
268
+ facts = evidence.get(node_id) or {}
269
+ upstream = [decided[dep] for dep in dependencies]
270
+ if any(item["decision"] == "BLOCK" for item in upstream):
271
+ decision, reason = "BLOCK", "upstream-blocked"
272
+ elif any(item["decision"] != "REUSE" for item in upstream):
273
+ decision, reason = "RUN", "upstream-invalidated"
274
+ elif bool(facts.get("reusable")):
275
+ decision, reason = "REUSE", str(facts.get("reason") or "evidence-current")
276
+ elif str(facts.get("decision") or "").upper() == "BLOCK":
277
+ decision, reason = "BLOCK", str(facts.get("reason") or "evidence-blocked")
278
+ else:
279
+ decision, reason = "RUN", str(facts.get("reason") or "evidence-missing")
280
+ decided[node_id] = {
281
+ **node,
282
+ "decision": decision,
283
+ "reason": reason,
284
+ "evidence": facts,
285
+ }
286
+ pending.remove(node_id)
287
+ progressed = True
288
+ if not progressed:
289
+ raise ValueError("requiredGateDag contains a cycle")
290
+
291
+ ordered = [decided[str(raw["id"])] for raw in raw_nodes]
292
+ blockers = [
293
+ {"code": "NODE_BLOCKED", "nodeId": node["id"], "message": node["reason"]}
294
+ for node in ordered
295
+ if node["decision"] == "BLOCK"
296
+ ]
297
+ return {
298
+ "nodes": ordered,
299
+ "blockers": blockers,
300
+ "canClose": bool(ordered) and all(node["decision"] == "REUSE" for node in ordered),
301
+ }
302
+
303
+
304
+ def _event_groups(events: list[dict[str, Any]]) -> list[tuple[str, int, list[dict[str, Any]]]]:
305
+ order: list[tuple[str, int]] = []
306
+ groups: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list)
307
+ for event in events:
308
+ key = (str(event.get("phase") or "unknown"), int(event.get("attempt") or 1))
309
+ if key not in groups:
310
+ order.append(key)
311
+ groups[key].append(event)
312
+ return [(phase, attempt, groups[(phase, attempt)]) for phase, attempt in order]
313
+
314
+
315
+ def timing_dimensions(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
316
+ """Keep runner, orchestration-active, wall-clock, and user wait distinct."""
317
+ output: list[dict[str, Any]] = []
318
+ for phase, attempt, grouped in _event_groups(events):
319
+ timing = he.canonical_phase_timing(grouped)
320
+ runner_values = [
321
+ event.get("duration_ms", event.get("durationMs"))
322
+ for event in grouped
323
+ if event.get("type") == "command"
324
+ and isinstance(event.get("duration_ms", event.get("durationMs")), int)
325
+ ]
326
+ wait_values = [
327
+ event.get("user_wait_ms", event.get("userWaitMs"))
328
+ for event in grouped
329
+ if isinstance(event.get("user_wait_ms", event.get("userWaitMs")), int)
330
+ ]
331
+ output.append(
332
+ {
333
+ "phase": phase,
334
+ "attempt": attempt,
335
+ "runnerMs": sum(runner_values) if runner_values else None,
336
+ "orchestrationActiveMs": timing.get("activeExecutionMs"),
337
+ "wallClockMs": timing.get("wallClockSpanMs"),
338
+ "userWaitMs": sum(wait_values) if wait_values else None,
339
+ "lateEventCount": timing.get("lateEventCount"),
340
+ }
341
+ )
342
+ return output
343
+
344
+
345
+ def build_trace(events: list[dict[str, Any]], *, change_id: str) -> dict[str, Any]:
346
+ """Project legacy or trace-aware events into stable phase/attempt/tool spans."""
347
+ event_ids = [str(event.get("id") or index) for index, event in enumerate(events)]
348
+ explicit_trace = next(
349
+ (
350
+ str(event.get("traceId") or event.get("trace_id"))
351
+ for event in events
352
+ if event.get("traceId") or event.get("trace_id")
353
+ ),
354
+ "",
355
+ )
356
+ trace_id = explicit_trace if len(explicit_trace) == 32 else _stable_hex(change_id, *event_ids, length=32)
357
+ root_span = _stable_hex(trace_id, "root", length=16)
358
+ spans: list[dict[str, Any]] = [
359
+ {
360
+ "traceId": trace_id,
361
+ "spanId": root_span,
362
+ "parentSpanId": None,
363
+ "kind": "change",
364
+ "changeId": change_id,
365
+ }
366
+ ]
367
+ for phase, attempt, grouped in _event_groups(events):
368
+ phase_span = _stable_hex(trace_id, phase, attempt, length=16)
369
+ spans.append(
370
+ {
371
+ "traceId": trace_id,
372
+ "spanId": phase_span,
373
+ "parentSpanId": root_span,
374
+ "kind": "phase-attempt",
375
+ "phase": phase,
376
+ "attempt": attempt,
377
+ }
378
+ )
379
+ tools: list[str] = []
380
+ for event in grouped:
381
+ tool = str(event.get("executor_tool") or event.get("executorTool") or "").strip()
382
+ if tool and tool not in tools:
383
+ tools.append(tool)
384
+ for tool in tools:
385
+ spans.append(
386
+ {
387
+ "traceId": trace_id,
388
+ "spanId": _stable_hex(trace_id, phase, attempt, tool, length=16),
389
+ "parentSpanId": phase_span,
390
+ "kind": "tool",
391
+ "phase": phase,
392
+ "attempt": attempt,
393
+ "tool": tool,
394
+ }
395
+ )
396
+ return {"schemaVersion": 1, "traceId": trace_id, "spans": spans}
397
+
398
+
399
+ def _number(value: Any, *, integer: bool = True) -> int | float | None:
400
+ if value is None or isinstance(value, bool):
401
+ return None
402
+ try:
403
+ number = float(value)
404
+ except (TypeError, ValueError):
405
+ return None
406
+ return int(number) if integer else number
407
+
408
+
409
+ def _junit_metrics(text: str) -> dict[str, Any]:
410
+ root = ET.fromstring(text)
411
+ suites = [root] if root.tag.endswith("testsuite") else list(root.findall(".//testsuite"))
412
+ aggregate = root if root.tag.endswith(("testsuite", "testsuites")) else None
413
+ attrs = aggregate.attrib if aggregate is not None else {}
414
+ def attribute_total(name: str, fallback: str | None = None) -> int | None:
415
+ direct = _number(attrs.get(name, attrs.get(fallback) if fallback else None))
416
+ if direct is not None:
417
+ return int(direct)
418
+ values = [
419
+ _number(item.attrib.get(name, item.attrib.get(fallback) if fallback else None))
420
+ for item in suites
421
+ ]
422
+ return (
423
+ sum(int(value) for value in values if value is not None)
424
+ if any(value is not None for value in values)
425
+ else None
426
+ )
427
+
428
+ total = attribute_total("tests")
429
+ failures = attribute_total("failures")
430
+ errors = attribute_total("errors")
431
+ skipped = attribute_total("skipped", "disabled")
432
+ duration_seconds = _number(attrs.get("time"), integer=False)
433
+ if duration_seconds is None:
434
+ durations = [_number(item.attrib.get("time"), integer=False) for item in suites]
435
+ duration_seconds = (
436
+ sum(value for value in durations if value is not None)
437
+ if any(value is not None for value in durations)
438
+ else None
439
+ )
440
+ passed = None
441
+ if all(value is not None for value in (total, failures, errors, skipped)):
442
+ passed = max(int(total) - int(failures) - int(errors) - int(skipped), 0)
443
+ return {
444
+ "suites": len(suites) if suites else None,
445
+ "passed": passed,
446
+ "failed": failures,
447
+ "skipped": skipped,
448
+ "setupErrors": errors,
449
+ "durationMs": int(duration_seconds * 1000) if duration_seconds is not None else None,
450
+ }
451
+
452
+
453
+ def normalize_ci_metrics(
454
+ raw: str | dict[str, Any],
455
+ *,
456
+ runner: str,
457
+ head_sha: str | None,
458
+ source: str | None = None,
459
+ ) -> dict[str, Any]:
460
+ """Normalize JUnit, Vitest, or Playwright machine output without inventing zeros."""
461
+ normalized_runner = runner.lower()
462
+ if normalized_runner == "junit":
463
+ if not isinstance(raw, str):
464
+ raise ValueError("JUnit input must be XML text")
465
+ metrics = _junit_metrics(raw)
466
+ else:
467
+ if isinstance(raw, str):
468
+ value = json.loads(raw)
469
+ else:
470
+ value = raw
471
+ if not isinstance(value, dict):
472
+ raise ValueError("runner metrics must be a JSON object")
473
+ if normalized_runner == "vitest":
474
+ runtimes = [
475
+ _number((item.get("perfStats") or {}).get("runtime"))
476
+ for item in (value.get("testResults") or [])
477
+ if isinstance(item, dict)
478
+ ]
479
+ metrics = {
480
+ "suites": _number(value.get("numTotalTestSuites")),
481
+ "passed": _number(value.get("numPassedTests")),
482
+ "failed": _number(value.get("numFailedTests")),
483
+ "skipped": _number(value.get("numPendingTests")),
484
+ "setupErrors": _number(value.get("numRuntimeErrorTestSuites")),
485
+ "durationMs": sum(item for item in runtimes if item is not None)
486
+ if any(item is not None for item in runtimes)
487
+ else None,
488
+ }
489
+ elif normalized_runner == "playwright":
490
+ stats = value.get("stats") if isinstance(value.get("stats"), dict) else {}
491
+ suites = value.get("suites")
492
+ error_counts = []
493
+ for raw_errors in (value.get("errors"), stats.get("errors")):
494
+ count = len(raw_errors) if isinstance(raw_errors, list) else _number(raw_errors)
495
+ if count is not None:
496
+ error_counts.append(count)
497
+ setup_errors = max(error_counts) if error_counts else None
498
+ metrics = {
499
+ "suites": len(suites) if isinstance(suites, list) else None,
500
+ "passed": _number(stats.get("expected")),
501
+ "failed": _number(stats.get("unexpected")),
502
+ "skipped": _number(stats.get("skipped")),
503
+ "setupErrors": _number(setup_errors),
504
+ "durationMs": _number(stats.get("duration")),
505
+ }
506
+ else:
507
+ raise ValueError(f"unsupported runner: {runner}")
508
+ result = {
509
+ "schemaVersion": CI_METRICS_SCHEMA_VERSION,
510
+ "runner": normalized_runner,
511
+ "source": source,
512
+ **metrics,
513
+ "headSha": head_sha,
514
+ }
515
+ for field in ("suites", "passed", "failed", "skipped", "setupErrors", "durationMs"):
516
+ value = result.get(field)
517
+ if value is not None and (not isinstance(value, int) or value < 0):
518
+ raise ValueError(f"invalid CI metric {field}: expected nonnegative integer or null")
519
+ if head_sha is not None and not re.fullmatch(r"[0-9a-f]{40}(?:[0-9a-f]{24})?", head_sha):
520
+ raise ValueError("headSha must be a lowercase 40- or 64-character hex commit id")
521
+ return result
522
+
523
+
524
+ def _git_head(project_root: Path) -> str | None:
525
+ try:
526
+ proc = subprocess.run(
527
+ ["git", "rev-parse", "--verify", "HEAD"],
528
+ cwd=str(project_root),
529
+ capture_output=True,
530
+ text=True,
531
+ encoding="utf-8",
532
+ errors="replace",
533
+ check=False,
534
+ )
535
+ except OSError:
536
+ return None
537
+ return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
538
+
539
+
540
+ def assess_identity(
541
+ change_dir: Path,
542
+ project_root: Path,
543
+ ledger: dict[str, Any] | None,
544
+ *,
545
+ target_phase: str | None = None,
546
+ run_id: str | None = None,
547
+ ) -> dict[str, Any]:
548
+ current_head = _git_head(project_root)
549
+ capsule: dict[str, Any] | None = None
550
+ capsule_selection: dict[str, Any] | None = None
551
+ if target_phase is not None:
552
+ capsule_selection = select_phase_capsule(
553
+ change_dir,
554
+ project_root,
555
+ phase=target_phase,
556
+ run_id=run_id,
557
+ )
558
+ if not capsule_selection.get("ok"):
559
+ return {
560
+ "ok": False,
561
+ "code": capsule_selection.get("code"),
562
+ "message": capsule_selection.get("message"),
563
+ "remediation": capsule_selection.get("remediation"),
564
+ "capsuleSelection": {
565
+ key: value
566
+ for key, value in capsule_selection.items()
567
+ if key != "capsule"
568
+ },
569
+ }
570
+ selected = capsule_selection.get("capsule")
571
+ capsule = selected if isinstance(selected, dict) else None
572
+ if capsule is not None:
573
+ stored_head = capsule.get("currentHead")
574
+ if stored_head and current_head and stored_head != current_head:
575
+ return {
576
+ "ok": False,
577
+ "code": "HEAD_MISMATCH",
578
+ "message": f"phase capsule head {stored_head} != current {current_head}",
579
+ "storedHead": stored_head,
580
+ "currentHead": current_head,
581
+ "capsuleSelection": capsule_selection,
582
+ }
583
+ if not ledger or ledger.get("schemaVersion") != 3:
584
+ return {
585
+ "ok": True,
586
+ "code": "IDENTITY_NOT_REUSABLE",
587
+ "message": "no ledger v3 identity; validations must RUN unless their own evidence is current",
588
+ "currentHead": current_head,
589
+ "capsuleSelection": capsule_selection,
590
+ }
591
+ missing = hl.validate_ledger_identity(ledger)
592
+ if missing:
593
+ return {
594
+ "ok": False,
595
+ "code": "LEDGER_IDENTITY_INVALID",
596
+ "message": "missing: " + ", ".join(missing),
597
+ }
598
+ if capsule is not None and capsule.get("baseCommit") not in {
599
+ None,
600
+ ledger.get("baseCommit"),
601
+ }:
602
+ return {
603
+ "ok": False,
604
+ "code": "BASE_MISMATCH",
605
+ "message": "phase capsule and ledger base commits differ",
606
+ "capsuleBase": capsule.get("baseCommit"),
607
+ "ledgerBase": ledger.get("baseCommit"),
608
+ }
609
+ try:
610
+ contract = hpaths.load_change_contract(change_dir)
611
+ current_repository = hpaths.repository_identity(project_root)
612
+ current_ownership = hl.ownership_hash(contract)
613
+ current_diff = hl.compute_ownership_diff(
614
+ project_root,
615
+ base=str(ledger["baseCommit"]),
616
+ change_dir=change_dir,
617
+ )["diffHash"]
618
+ except (OSError, ValueError, RuntimeError) as exc:
619
+ return {"ok": False, "code": "IDENTITY_RESOLUTION_FAILED", "message": str(exc)}
620
+ identity_dimensions = {
621
+ "repositoryId": (ledger.get("repositoryId"), current_repository),
622
+ "ownershipHash": (ledger.get("ownershipHash"), current_ownership),
623
+ "currentHead": (ledger.get("currentHead"), current_head),
624
+ }
625
+ changed = {
626
+ key: {"stored": stored, "current": current}
627
+ for key, (stored, current) in identity_dimensions.items()
628
+ if stored != current
629
+ }
630
+ if changed:
631
+ return {
632
+ "ok": False,
633
+ "code": "LEDGER_IDENTITY_MISMATCH",
634
+ "message": "ledger identity does not match current change",
635
+ "mismatches": changed,
636
+ }
637
+ if ledger.get("diffHash") != current_diff:
638
+ return {
639
+ "ok": True,
640
+ "code": "DIFF_CHANGED",
641
+ "message": "ownership diff changed; reuse is decided per node inputs",
642
+ "storedDiffHash": ledger.get("diffHash"),
643
+ "currentDiffHash": current_diff,
644
+ "currentHead": current_head,
645
+ }
646
+ return {
647
+ "ok": True,
648
+ "code": "IDENTITY_OK",
649
+ "currentHead": current_head,
650
+ "capsuleSelection": capsule_selection,
651
+ }
652
+
653
+
654
+ def _latest_phase_status(events: list[dict[str, Any]], phase: str) -> str | None:
655
+ status = None
656
+ for event in he.apply_event_corrections(events):
657
+ if event.get("type") == "phase.end" and event.get("phase") == phase:
658
+ status = str(event.get("status") or "").upper() or None
659
+ return status
660
+
661
+
662
+ def collect_node_evidence(
663
+ dag: dict[str, Any],
664
+ change_dir: Path,
665
+ events: list[dict[str, Any]],
666
+ ledger: dict[str, Any] | None,
667
+ ) -> dict[str, dict[str, Any]]:
668
+ result: dict[str, dict[str, Any]] = {}
669
+ validations = (ledger or {}).get("validations") or {}
670
+ for node in dag.get("nodes") or []:
671
+ node_id = str(node.get("id") or "")
672
+ if node.get("kind") == "validation" or node_id.startswith("validation:"):
673
+ verification = node_id.split(":", 1)[1]
674
+ entry = validations.get(verification) if isinstance(validations, dict) else None
675
+ if not isinstance(entry, dict):
676
+ result[node_id] = {"reusable": False, "reason": "validation-missing"}
677
+ continue
678
+ files = entry.get("inputsFiles") if isinstance(entry.get("inputsFiles"), list) else []
679
+ if not files:
680
+ result[node_id] = {"reusable": False, "reason": "inputs-missing"}
681
+ continue
682
+ reuse = hl.decide_can_reuse(
683
+ change_dir=change_dir,
684
+ verification=verification,
685
+ files=[str(item) for item in files],
686
+ requested_scope=entry.get("scope") if isinstance(entry.get("scope"), str) else None,
687
+ requested_command=str(entry.get("command") or "") or None,
688
+ )
689
+ result[node_id] = {
690
+ "reusable": bool(reuse.get("reuse")),
691
+ "reason": str(reuse.get("code") or reuse.get("reason") or "not-reusable"),
692
+ "reuse": reuse,
693
+ }
694
+ else:
695
+ phase = str(node.get("phase") or node_id.split(":", 1)[-1])
696
+ status = _latest_phase_status(events, phase)
697
+ result[node_id] = {
698
+ "reusable": status in {"OK", "WARN"},
699
+ "reason": f"phase-status:{status or 'missing'}",
700
+ "status": status,
701
+ }
702
+ return result
703
+
704
+
705
+ def reconcile(
706
+ change_dir: Path,
707
+ project_root: Path,
708
+ *,
709
+ target_phase: str | None = None,
710
+ run_id: str | None = None,
711
+ ) -> dict[str, Any]:
712
+ change_dir = change_dir.resolve()
713
+ project_root = project_root.resolve()
714
+ policy = _read_json(change_dir / "meta" / "gate-policy.json")
715
+ dag = (
716
+ target_required_dag(policy, target_phase)
717
+ if target_phase is not None
718
+ else policy.get("requiredGateDag")
719
+ )
720
+ if not isinstance(dag, dict):
721
+ raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
722
+ ledger, ledger_path = hl.load_ledger(change_dir)
723
+ events_file = he.events_path(change_dir)
724
+ events = he.load_events(events_file) if events_file.is_file() else []
725
+ identity = assess_identity(
726
+ change_dir,
727
+ project_root,
728
+ ledger,
729
+ target_phase=target_phase,
730
+ run_id=run_id,
731
+ )
732
+ evidence = collect_node_evidence(dag, change_dir, events, ledger)
733
+ if identity.get("code") not in {"IDENTITY_OK", "DIFF_CHANGED"}:
734
+ for facts in evidence.values():
735
+ facts["reusable"] = False
736
+ facts["reason"] = "identity-not-reusable"
737
+ if dag.get("nodes"):
738
+ decision = reconcile_dag(dag, evidence, identity=identity)
739
+ elif identity.get("ok"):
740
+ decision = {"nodes": [], "blockers": [], "canClose": True}
741
+ else:
742
+ decision = {
743
+ "nodes": [],
744
+ "blockers": [
745
+ {
746
+ "code": str(identity.get("code") or "IDENTITY_MISMATCH"),
747
+ "message": str(identity.get("message") or "identity validation failed"),
748
+ }
749
+ ],
750
+ "canClose": False,
751
+ }
752
+ guard_path = change_dir / "evidence" / "test-guard-snapshot.json"
753
+ guard = _read_json(guard_path) if guard_path.is_file() else None
754
+ return {
755
+ "ok": not decision["blockers"],
756
+ "action": "reconcile",
757
+ "schemaVersion": SCHEMA_VERSION,
758
+ "generatedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
759
+ "changeId": change_dir.name,
760
+ "projectRoot": str(project_root),
761
+ "changeDir": str(change_dir),
762
+ "targetPhase": target_phase,
763
+ "runId": run_id,
764
+ "identity": identity,
765
+ **decision,
766
+ "guard": guard,
767
+ "ledgerPath": str(ledger_path) if ledger_path else None,
768
+ "trace": build_trace(events, change_id=change_dir.name),
769
+ "timing": timing_dimensions(events),
770
+ }
771
+
772
+
773
+ def format_compact(result: dict[str, Any]) -> str:
774
+ lines: list[str] = []
775
+ for node in result.get("nodes") or []:
776
+ decision = str(node.get("decision") or "")
777
+ if decision == "REUSE":
778
+ continue
779
+ lines.append(f"{decision} {node.get('id')}: {node.get('reason')}")
780
+ for blocker in result.get("blockers") or []:
781
+ lines.append(f"BLOCK {blocker.get('code')}: {blocker.get('message')}")
782
+ if not lines:
783
+ nodes = result.get("nodes") or []
784
+ if not result.get("ok") or not nodes:
785
+ lines.append(
786
+ f"BLOCK {result.get('code') or 'EMPTY_RECONCILIATION'}: "
787
+ f"{result.get('error') or 'no required nodes were reconciled'}"
788
+ )
789
+ else:
790
+ lines.append(f"REUSED {len(nodes)} required nodes; safe to close")
791
+ return "\n".join(lines)
792
+
793
+
794
+ def _close_gate(args: argparse.Namespace) -> dict[str, Any]:
795
+ command = [
796
+ sys.executable,
797
+ str(SCRIPTS_DIR / "harness_gate.py"),
798
+ "close",
799
+ "--json",
800
+ "--phase",
801
+ args.phase,
802
+ "--change",
803
+ Path(args.change_dir).name,
804
+ "--project",
805
+ str(Path(args.project).resolve()),
806
+ "--status",
807
+ "OK",
808
+ ]
809
+ if args.run_id:
810
+ command.extend(["--run-id", args.run_id])
811
+ proc = subprocess.run(
812
+ command,
813
+ capture_output=True,
814
+ text=True,
815
+ encoding="utf-8",
816
+ errors="replace",
817
+ check=False,
818
+ )
819
+ try:
820
+ payload = json.loads(proc.stdout)
821
+ except json.JSONDecodeError:
822
+ payload = {"ok": False, "code": "GATE_CLOSE_INVALID_OUTPUT", "stderr": proc.stderr}
823
+ payload["exitCode"] = proc.returncode
824
+ return payload
825
+
826
+
827
+ def _cmd_reconcile(args: argparse.Namespace) -> int:
828
+ try:
829
+ result = reconcile(
830
+ Path(args.change_dir),
831
+ Path(args.project),
832
+ target_phase=args.phase,
833
+ run_id=args.run_id,
834
+ )
835
+ if args.close:
836
+ if not result.get("canClose"):
837
+ result["close"] = {
838
+ "ok": False,
839
+ "code": "RECONCILE_NOT_CLOSABLE",
840
+ "message": "required nodes still need RUN or are BLOCKED",
841
+ }
842
+ result["ok"] = False
843
+ result.setdefault("blockers", []).append(
844
+ {
845
+ "code": "RECONCILE_NOT_CLOSABLE",
846
+ "message": "explicit close refused because required nodes are not reusable",
847
+ }
848
+ )
849
+ else:
850
+ result["close"] = _close_gate(args)
851
+ if not result["close"].get("ok"):
852
+ result["ok"] = False
853
+ if args.output:
854
+ _write_json(Path(args.output).resolve(), result)
855
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
856
+ result = {"ok": False, "action": "reconcile", "code": "RECONCILE_FAILED", "error": str(exc)}
857
+ if args.json:
858
+ print(json.dumps(result, ensure_ascii=False, indent=2))
859
+ else:
860
+ print(format_compact(result))
861
+ return 0 if result.get("ok") else 2
862
+
863
+
864
+ def _cmd_metrics(args: argparse.Namespace) -> int:
865
+ input_path = Path(args.input).resolve()
866
+ try:
867
+ text = input_path.read_text(encoding="utf-8-sig")
868
+ runner = args.runner
869
+ if runner == "auto":
870
+ if input_path.suffix.lower() == ".xml":
871
+ runner = "junit"
872
+ raw: str | dict[str, Any] = text
873
+ else:
874
+ raw = json.loads(text)
875
+ runner = (
876
+ "playwright"
877
+ if isinstance(raw, dict) and "stats" in raw and "suites" in raw
878
+ else "vitest"
879
+ )
880
+ else:
881
+ raw = text if runner == "junit" else json.loads(text)
882
+ result = normalize_ci_metrics(
883
+ raw,
884
+ runner=runner,
885
+ head_sha=args.head_sha,
886
+ source=str(input_path),
887
+ )
888
+ if args.output:
889
+ _write_json(Path(args.output).resolve(), result)
890
+ except (OSError, ValueError, ET.ParseError, json.JSONDecodeError) as exc:
891
+ result = {"ok": False, "code": "CI_METRICS_FAILED", "error": str(exc)}
892
+ print(json.dumps(result, ensure_ascii=False, indent=2))
893
+ return 2
894
+ print(json.dumps(result, ensure_ascii=False, indent=2))
895
+ return 0
896
+
897
+
898
+ def build_parser() -> argparse.ArgumentParser:
899
+ parser = argparse.ArgumentParser(prog="harness_phase.py")
900
+ sub = parser.add_subparsers(dest="command", required=True)
901
+ reconcile_parser = sub.add_parser("reconcile")
902
+ reconcile_parser.add_argument("--change-dir", required=True)
903
+ reconcile_parser.add_argument("--project", required=True)
904
+ reconcile_parser.add_argument("--json", action="store_true")
905
+ reconcile_parser.add_argument("--output")
906
+ reconcile_parser.add_argument("--close", action="store_true")
907
+ reconcile_parser.add_argument("--phase", default="run")
908
+ reconcile_parser.add_argument("--run-id")
909
+ reconcile_parser.set_defaults(func=_cmd_reconcile)
910
+
911
+ metrics_parser = sub.add_parser("metrics")
912
+ metrics_parser.add_argument("--input", required=True)
913
+ metrics_parser.add_argument("--runner", choices=("auto", "junit", "vitest", "playwright"), default="auto")
914
+ metrics_parser.add_argument("--head-sha")
915
+ metrics_parser.add_argument("--output")
916
+ metrics_parser.set_defaults(func=_cmd_metrics)
917
+ return parser
918
+
919
+
920
+ def main(argv: list[str] | None = None) -> int:
921
+ args = build_parser().parse_args(argv)
922
+ return int(args.func(args))
923
+
924
+
925
+ if __name__ == "__main__":
926
+ raise SystemExit(main())