@hunter-harness/workflow-harness 0.2.6 → 0.2.8

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 (202) 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 +169 -0
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -1
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +3 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +9 -6
  12. package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
  13. package/harness/bundles/general/claude-code/harness-run/reference.md +5 -2
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +7 -5
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
  17. package/harness/bundles/general/claude-code/scripts/harness_apidoc.py +53 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_archive.py +41 -16
  19. package/harness/bundles/general/claude-code/scripts/harness_change.py +872 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_events.py +26 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_gate.py +1038 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_service.py +34 -1
  23. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +210 -14
  24. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +160 -0
  25. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  26. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +169 -0
  27. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -1
  28. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  31. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  32. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +3 -1
  33. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -1
  34. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -1
  35. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +9 -6
  36. package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
  37. package/harness/bundles/general/codebuddy/harness-run/reference.md +5 -2
  38. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +7 -5
  39. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
  41. package/harness/bundles/general/codebuddy/scripts/harness_apidoc.py +53 -0
  42. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +41 -16
  43. package/harness/bundles/general/codebuddy/scripts/harness_change.py +872 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_events.py +26 -0
  45. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1038 -0
  46. package/harness/bundles/general/codebuddy/scripts/harness_service.py +34 -1
  47. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +210 -14
  48. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +160 -0
  49. package/harness/bundles/general/codex/.harness-build.json +1 -1
  50. package/harness/bundles/general/codex/contracts/workflow-policy.json +169 -0
  51. package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -1
  52. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  55. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  56. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +3 -1
  57. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -1
  58. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -1
  59. package/harness/bundles/general/codex/harness-run/SKILL.md +9 -6
  60. package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
  61. package/harness/bundles/general/codex/harness-run/reference.md +5 -2
  62. package/harness/bundles/general/codex/harness-submit/SKILL.md +7 -5
  63. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
  65. package/harness/bundles/general/codex/scripts/harness_apidoc.py +53 -0
  66. package/harness/bundles/general/codex/scripts/harness_archive.py +41 -16
  67. package/harness/bundles/general/codex/scripts/harness_change.py +872 -0
  68. package/harness/bundles/general/codex/scripts/harness_events.py +26 -0
  69. package/harness/bundles/general/codex/scripts/harness_gate.py +1038 -0
  70. package/harness/bundles/general/codex/scripts/harness_service.py +34 -1
  71. package/harness/bundles/general/codex/scripts/harness_test_guard.py +210 -14
  72. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +160 -0
  73. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  74. package/harness/bundles/general/cursor/contracts/workflow-policy.json +169 -0
  75. package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -1
  76. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  79. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  80. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +3 -1
  81. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -1
  82. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -1
  83. package/harness/bundles/general/cursor/harness-run/SKILL.md +9 -6
  84. package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
  85. package/harness/bundles/general/cursor/harness-run/reference.md +5 -2
  86. package/harness/bundles/general/cursor/harness-submit/SKILL.md +7 -5
  87. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
  89. package/harness/bundles/general/cursor/scripts/harness_apidoc.py +53 -0
  90. package/harness/bundles/general/cursor/scripts/harness_archive.py +41 -16
  91. package/harness/bundles/general/cursor/scripts/harness_change.py +872 -0
  92. package/harness/bundles/general/cursor/scripts/harness_events.py +26 -0
  93. package/harness/bundles/general/cursor/scripts/harness_gate.py +1038 -0
  94. package/harness/bundles/general/cursor/scripts/harness_service.py +34 -1
  95. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +210 -14
  96. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +160 -0
  97. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  98. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +169 -0
  99. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +7 -1
  100. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -1
  101. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  104. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  105. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +3 -1
  106. package/harness/bundles/java/claude-code/harness-package/SKILL.md +6 -3
  107. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -1
  108. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -1
  109. package/harness/bundles/java/claude-code/harness-run/SKILL.md +9 -6
  110. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +7 -5
  111. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
  113. package/harness/bundles/java/claude-code/scripts/harness_apidoc.py +53 -0
  114. package/harness/bundles/java/claude-code/scripts/harness_archive.py +41 -16
  115. package/harness/bundles/java/claude-code/scripts/harness_change.py +872 -0
  116. package/harness/bundles/java/claude-code/scripts/harness_events.py +26 -0
  117. package/harness/bundles/java/claude-code/scripts/harness_gate.py +1038 -0
  118. package/harness/bundles/java/claude-code/scripts/harness_service.py +34 -1
  119. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +210 -14
  120. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +160 -0
  121. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  122. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +169 -0
  123. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +7 -1
  124. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -1
  125. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  128. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  129. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +3 -1
  130. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +6 -3
  131. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -1
  132. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -1
  133. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +9 -6
  134. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +7 -5
  135. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  136. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
  137. package/harness/bundles/java/codebuddy/scripts/harness_apidoc.py +53 -0
  138. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +41 -16
  139. package/harness/bundles/java/codebuddy/scripts/harness_change.py +872 -0
  140. package/harness/bundles/java/codebuddy/scripts/harness_events.py +26 -0
  141. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1038 -0
  142. package/harness/bundles/java/codebuddy/scripts/harness_service.py +34 -1
  143. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +210 -14
  144. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +160 -0
  145. package/harness/bundles/java/codex/.harness-build.json +1 -1
  146. package/harness/bundles/java/codex/contracts/workflow-policy.json +169 -0
  147. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +7 -1
  148. package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -1
  149. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  152. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  153. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +3 -1
  154. package/harness/bundles/java/codex/harness-package/SKILL.md +6 -3
  155. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -1
  156. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -1
  157. package/harness/bundles/java/codex/harness-run/SKILL.md +9 -6
  158. package/harness/bundles/java/codex/harness-submit/SKILL.md +7 -5
  159. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  160. package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
  161. package/harness/bundles/java/codex/scripts/harness_apidoc.py +53 -0
  162. package/harness/bundles/java/codex/scripts/harness_archive.py +41 -16
  163. package/harness/bundles/java/codex/scripts/harness_change.py +872 -0
  164. package/harness/bundles/java/codex/scripts/harness_events.py +26 -0
  165. package/harness/bundles/java/codex/scripts/harness_gate.py +1038 -0
  166. package/harness/bundles/java/codex/scripts/harness_service.py +34 -1
  167. package/harness/bundles/java/codex/scripts/harness_test_guard.py +210 -14
  168. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +160 -0
  169. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  170. package/harness/bundles/java/cursor/contracts/workflow-policy.json +169 -0
  171. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +7 -1
  172. package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -1
  173. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  175. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  176. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  177. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +3 -1
  178. package/harness/bundles/java/cursor/harness-package/SKILL.md +6 -3
  179. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -1
  180. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -1
  181. package/harness/bundles/java/cursor/harness-run/SKILL.md +9 -6
  182. package/harness/bundles/java/cursor/harness-submit/SKILL.md +7 -5
  183. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
  185. package/harness/bundles/java/cursor/scripts/harness_apidoc.py +53 -0
  186. package/harness/bundles/java/cursor/scripts/harness_archive.py +41 -16
  187. package/harness/bundles/java/cursor/scripts/harness_change.py +872 -0
  188. package/harness/bundles/java/cursor/scripts/harness_events.py +26 -0
  189. package/harness/bundles/java/cursor/scripts/harness_gate.py +1038 -0
  190. package/harness/bundles/java/cursor/scripts/harness_service.py +34 -1
  191. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +210 -14
  192. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +160 -0
  193. package/harness/manifests/general/claude-code.json +40 -20
  194. package/harness/manifests/general/codebuddy.json +40 -20
  195. package/harness/manifests/general/codex.json +40 -20
  196. package/harness/manifests/general/cursor.json +40 -20
  197. package/harness/manifests/java/claude-code.json +40 -20
  198. package/harness/manifests/java/codebuddy.json +40 -20
  199. package/harness/manifests/java/codex.json +40 -20
  200. package/harness/manifests/java/cursor.json +40 -20
  201. package/hunter-workflow-family.json +2 -2
  202. package/package.json +1 -1
@@ -0,0 +1,1038 @@
1
+ #!/usr/bin/env python3
2
+ """Harness deterministic phase gate: begin/close, checkpoints, classify, lint.
3
+
4
+ Subcommands:
5
+ begin — claim lease, optional identity capture, append phase.start
6
+ close — validate ledger/test-guard/policy, append phase.end, release lease
7
+ classify — plan/post-run risk tier stub
8
+ checkpoint — status|approve foundation-gate (and future checkpoints)
9
+ lint-skills — forbid hand-written ledger patterns in skill trees
10
+
11
+ foundation-gate must block task>=6 until approved (API-012).
12
+ Python 3.10+, stdlib only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ import uuid
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ SCRIPTS_DIR = Path(__file__).resolve().parent
29
+ if str(SCRIPTS_DIR) not in sys.path:
30
+ sys.path.insert(0, str(SCRIPTS_DIR))
31
+
32
+ import harness_change as hc # noqa: E402
33
+ import harness_events as he # noqa: E402
34
+ import harness_ledger as hl # noqa: E402
35
+ import harness_workflow_policy as hwp # noqa: E402
36
+ import harness_test_guard as htg # noqa: E402
37
+
38
+ if hasattr(sys.stdout, "reconfigure"):
39
+ sys.stdout.reconfigure(encoding="utf-8")
40
+ if hasattr(sys.stderr, "reconfigure"):
41
+ sys.stderr.reconfigure(encoding="utf-8")
42
+
43
+
44
+ CHECKPOINTS_REL = Path("meta") / "implementation-checkpoints.json"
45
+ FORBIDDEN_SKILL_PATTERNS = (
46
+ re.compile(r"Write\s+verification-ledger\.json", re.IGNORECASE),
47
+ re.compile(r"Edit\s+verification-ledger", re.IGNORECASE),
48
+ re.compile(r"hand-?write.*verification-ledger", re.IGNORECASE),
49
+ )
50
+
51
+ LEDGER_V2_REQUIRED_ENTRY_FIELDS = (
52
+ "algorithmVersion",
53
+ "coverage",
54
+ "inputsHash",
55
+ "inputsFiles",
56
+ "status",
57
+ "command",
58
+ "evidence",
59
+ )
60
+
61
+ CAPABILITY_MARKERS: dict[str, tuple[str, ...]] = {
62
+ "risk-classify-plan": ("harness_gate.py classify",),
63
+ "gate-begin": ("harness_gate.py begin",),
64
+ "gate-close": ("harness_gate.py close",),
65
+ "ledger-record": ("harness_ledger.py record",),
66
+ "ledger-can-reuse": ("harness_ledger.py can-reuse",),
67
+ "test-guard": ("harness_test_guard.py begin", "harness_test_guard.py close"),
68
+ "test-guard-stage": ("harness_test_guard.py stage",),
69
+ "integration-lock": ("harness_change.py integration-lock",),
70
+ }
71
+
72
+
73
+ def _load_workflow_policy(
74
+ *, project: Path | None = None, skills_root: Path | None = None
75
+ ) -> dict[str, Any]:
76
+ candidates = []
77
+ if project is not None:
78
+ candidates.append(project / "harness" / "contracts" / "workflow-policy.json")
79
+ if skills_root is not None:
80
+ candidates.append(skills_root / "contracts" / "workflow-policy.json")
81
+ candidates.append(SCRIPTS_DIR.parent / "contracts" / "workflow-policy.json")
82
+ for path in candidates:
83
+ if path.is_file():
84
+ raw = json.loads(path.read_text(encoding="utf-8"))
85
+ return hwp.validate_policy(raw)
86
+ raise FileNotFoundError("workflow-policy.json not found beside project or skills")
87
+
88
+
89
+ def emit(payload: dict[str, Any], *, as_json: bool) -> None:
90
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
91
+
92
+
93
+ def emit_error(
94
+ code: str,
95
+ message: str,
96
+ *,
97
+ as_json: bool,
98
+ extra: dict[str, Any] | None = None,
99
+ ) -> int:
100
+ payload: dict[str, Any] = {"ok": False, "code": code, "message": message}
101
+ if extra:
102
+ payload.update(extra)
103
+ if as_json:
104
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
105
+ else:
106
+ sys.stderr.write(f"error: {message} ({code})\n")
107
+ return 1
108
+
109
+
110
+ def _read_json(path: Path) -> Any:
111
+ return json.loads(path.read_text(encoding="utf-8-sig"))
112
+
113
+
114
+ def _write_json(path: Path, data: Any) -> None:
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
117
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
118
+ try:
119
+ tmp.write_text(text, encoding="utf-8", newline="\n")
120
+ os.replace(tmp, path)
121
+ except BaseException:
122
+ tmp.unlink(missing_ok=True)
123
+ raise
124
+
125
+
126
+ def load_checkpoints(change_dir: Path) -> dict[str, Any] | None:
127
+ path = change_dir / CHECKPOINTS_REL
128
+ if not path.is_file():
129
+ return None
130
+ try:
131
+ data = _read_json(path)
132
+ except (OSError, json.JSONDecodeError):
133
+ return None
134
+ return data if isinstance(data, dict) else None
135
+
136
+
137
+ def checkpoint_status(checkpoints: dict[str, Any] | None, checkpoint_id: str) -> str:
138
+ if not checkpoints:
139
+ return "missing"
140
+ items = checkpoints.get("checkpoints")
141
+ if not isinstance(items, list):
142
+ return "missing"
143
+ for item in items:
144
+ if isinstance(item, dict) and item.get("id") == checkpoint_id:
145
+ return str(item.get("status") or "pending")
146
+ return "missing"
147
+
148
+
149
+ def foundation_gate_blocks(task_number: int | None, change_dir: Path) -> dict[str, Any] | None:
150
+ checkpoints = load_checkpoints(change_dir)
151
+ status = checkpoint_status(checkpoints, "foundation-gate")
152
+ if status == "approved":
153
+ return None
154
+ if task_number is None:
155
+ return {
156
+ "ok": False,
157
+ "code": "TASK_NUMBER_REQUIRED",
158
+ "message": "--task is required while foundation-gate is pending",
159
+ "checkpointId": "foundation-gate",
160
+ "checkpointStatus": status,
161
+ }
162
+ if task_number < 6:
163
+ return None
164
+ return {
165
+ "ok": False,
166
+ "code": "FOUNDATION_GATE_PENDING",
167
+ "message": (
168
+ "foundation-gate is not approved; tasks 6+ are blocked until "
169
+ "reports/review/foundation-gate-review.md is reviewed and checkpoint approved"
170
+ ),
171
+ "checkpointId": "foundation-gate",
172
+ "checkpointStatus": status,
173
+ "task": task_number,
174
+ }
175
+
176
+
177
+ def validate_ledger_entry_v2(entry: dict[str, Any], verification: str) -> list[str]:
178
+ missing: list[str] = []
179
+ for field in LEDGER_V2_REQUIRED_ENTRY_FIELDS:
180
+ value = entry.get(field)
181
+ if field == "inputsFiles":
182
+ if not isinstance(value, list):
183
+ missing.append(field)
184
+ elif verification == "unitTestFull" and not value:
185
+ missing.append("inputsFiles(non-empty)")
186
+ elif field == "status":
187
+ if value != "OK":
188
+ missing.append("status=OK")
189
+ elif not (isinstance(value, str) and value.strip()):
190
+ missing.append(field)
191
+ elif field == "coverage" and str(value).strip() not in hl.COVERAGE_RANK:
192
+ missing.append("coverage(valid)")
193
+ elif field == "algorithmVersion" and str(value).strip() != hl.LEDGER_VERSION:
194
+ missing.append("algorithmVersion(harness-ledger-2)")
195
+ return missing
196
+
197
+
198
+ def validate_ledger_for_phase_close(
199
+ change_dir: Path,
200
+ phase: str,
201
+ policy: dict[str, Any],
202
+ ) -> dict[str, Any]:
203
+ """Validate ledger v2 fields required for phase close (UT-026)."""
204
+ required = policy.get("requiredValidations", {}).get(phase, [])
205
+ if not required:
206
+ return {"ok": True, "code": "LEDGER_NOT_REQUIRED", "phase": phase}
207
+
208
+ ledger, ledger_path = hl.load_ledger(change_dir)
209
+ if ledger is None:
210
+ return {
211
+ "ok": False,
212
+ "code": "LEDGER_MISSING",
213
+ "message": "verification ledger missing",
214
+ "phase": phase,
215
+ "required": required,
216
+ }
217
+ validations = ledger.get("validations")
218
+ if not isinstance(validations, dict):
219
+ return {
220
+ "ok": False,
221
+ "code": "VALIDATIONS_MISSING",
222
+ "message": "ledger validations missing",
223
+ "phase": phase,
224
+ "ledgerPath": str(ledger_path) if ledger_path else None,
225
+ }
226
+
227
+ problems: list[dict[str, Any]] = []
228
+ for verification in required:
229
+ entry = validations.get(verification)
230
+ if not isinstance(entry, dict):
231
+ problems.append(
232
+ {
233
+ "verification": verification,
234
+ "missing": ["entry"],
235
+ "code": "VALIDATION_MISSING",
236
+ }
237
+ )
238
+ continue
239
+ missing = validate_ledger_entry_v2(entry, verification)
240
+ if missing:
241
+ code = (
242
+ "MISSING_V2_FIELDS"
243
+ if any(
244
+ field in missing
245
+ for field in (
246
+ "algorithmVersion",
247
+ "algorithmVersion(harness-ledger-2)",
248
+ "coverage",
249
+ "coverage(valid)",
250
+ )
251
+ )
252
+ else "MISSING_FIELDS"
253
+ )
254
+ problems.append(
255
+ {
256
+ "verification": verification,
257
+ "missing": missing,
258
+ "code": code,
259
+ }
260
+ )
261
+
262
+ if problems:
263
+ return {
264
+ "ok": False,
265
+ "code": problems[0]["code"],
266
+ "message": "ledger validation failed for phase close",
267
+ "phase": phase,
268
+ "problems": problems,
269
+ "ledgerPath": str(ledger_path) if ledger_path else None,
270
+ "detail": "natural-language override is not permitted",
271
+ }
272
+ return {
273
+ "ok": True,
274
+ "code": "LEDGER_OK",
275
+ "phase": phase,
276
+ "validated": required,
277
+ "ledgerPath": str(ledger_path) if ledger_path else None,
278
+ }
279
+
280
+
281
+ def _sha256_file(path: Path) -> str:
282
+ digest = hashlib.sha256()
283
+ with path.open("rb") as handle:
284
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
285
+ digest.update(chunk)
286
+ return digest.hexdigest()
287
+
288
+
289
+ def validate_identity(
290
+ project: Path,
291
+ skills_root: Path,
292
+ executor_tool: str | None,
293
+ ) -> dict[str, Any]:
294
+ skills_root = skills_root.resolve()
295
+ build_path = skills_root / ".harness-build.json"
296
+ if not build_path.is_file():
297
+ raise ValueError("BUNDLE_IDENTITY_MISSING: refresh the selected Harness adapter")
298
+ build = _read_json(build_path)
299
+ if not isinstance(build, dict) or build.get("schemaVersion") != 1:
300
+ raise ValueError("BUNDLE_IDENTITY_INVALID: .harness-build.json schema")
301
+ agent = str(build.get("agent") or "").strip()
302
+ core_hash = str(build.get("coreHash") or "").strip()
303
+ if not agent or not core_hash:
304
+ raise ValueError("BUNDLE_IDENTITY_INVALID: agent/coreHash is required")
305
+ if executor_tool and executor_tool != agent:
306
+ raise ValueError(f"BUNDLE_IDENTITY_MISMATCH: executor {executor_tool} uses {agent} bundle")
307
+
308
+ context = _read_json(project / ".harness" / "context-index.json")
309
+ installed = _read_json(
310
+ project / ".harness" / "state" / "local" / "installed-harness-bundle.json"
311
+ )
312
+ if not isinstance(context, dict) or not isinstance(installed, dict):
313
+ raise ValueError("BUNDLE_IDENTITY_INVALID: context or installed state")
314
+ adapters = context.get("project", {}).get("adapters", {})
315
+ adapter = adapters.get(agent) if isinstance(adapters, dict) else None
316
+ if not isinstance(adapter, dict):
317
+ raise ValueError(f"BUNDLE_IDENTITY_MISMATCH: adapter {agent} is not configured")
318
+ configured_root = (project / str(adapter.get("skills_root") or "")).resolve()
319
+ if configured_root != skills_root:
320
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: skills root differs from context-index")
321
+ bundle = context.get("skill_bundles", {}).get(agent)
322
+ if not isinstance(bundle, dict):
323
+ raise ValueError("BUNDLE_IDENTITY_INVALID: context bundle metadata missing")
324
+ registry_version = str(bundle.get("registry_version") or "")
325
+ bundle_hash = str(bundle.get("bundle_hash") or "")
326
+ profile = str((installed.get("profiles") or {}).get(agent) or "")
327
+ manifests = installed.get("manifests")
328
+ manifest = next((item for item in manifests if isinstance(item, dict)
329
+ and item.get("adapter") == agent and item.get("profile") == profile), None) \
330
+ if isinstance(manifests, list) else None
331
+ if not isinstance(manifest, dict) or \
332
+ str(manifest.get("bundle_version") or "") != registry_version or \
333
+ str(manifest.get("bundle_manifest_hash") or "") != bundle_hash:
334
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: installed manifest differs from context-index")
335
+ try:
336
+ marker_target = build_path.relative_to(project).as_posix()
337
+ except ValueError as exc:
338
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: skills root is outside project") from exc
339
+ files = installed.get("files")
340
+ marker = next((item for item in files if isinstance(item, dict)
341
+ and item.get("owner") == agent
342
+ and str(item.get("target_path") or "").replace("\\", "/") == marker_target), None) \
343
+ if isinstance(files, list) else None
344
+ actual_hash = _sha256_file(build_path)
345
+ if not isinstance(marker, dict) or str(marker.get("sha256") or "") != actual_hash:
346
+ raise ValueError("BUNDLE_IDENTITY_MISMATCH: installed build marker hash drifted; refresh required")
347
+ return {
348
+ "skillsRoot": str(skills_root),
349
+ "registryVersion": registry_version,
350
+ "bundleHash": bundle_hash,
351
+ "coreHash": core_hash,
352
+ "overlay": str(build.get("overlay") or "none"),
353
+ "profile": profile,
354
+ "adapter": agent,
355
+ "buildMarkerHash": actual_hash,
356
+ "contextIndexPresent": True,
357
+ }
358
+
359
+
360
+ def read_identity(skills_root: Path) -> dict[str, Any]:
361
+ """Backward-compatible marker reader used by callers that only need display data."""
362
+ identity: dict[str, Any] = {"skillsRoot": str(skills_root.resolve())}
363
+ build_path = skills_root / ".harness-build.json"
364
+ if build_path.is_file():
365
+ try:
366
+ build = _read_json(build_path)
367
+ if isinstance(build, dict):
368
+ for key in (
369
+ "registryVersion",
370
+ "bundleHash",
371
+ "coreHash",
372
+ "overlayHash",
373
+ "profile",
374
+ "adapter",
375
+ ):
376
+ if key in build:
377
+ identity[key] = build[key]
378
+ except (OSError, json.JSONDecodeError):
379
+ identity["buildReadError"] = True
380
+ context_path = skills_root / ".harness" / "context-index.json"
381
+ if not context_path.is_file():
382
+ alt = skills_root.parent / ".harness" / "context-index.json"
383
+ if alt.is_file():
384
+ context_path = alt
385
+ if context_path.is_file():
386
+ try:
387
+ context = _read_json(context_path)
388
+ if isinstance(context, dict):
389
+ identity["contextIndexPresent"] = True
390
+ except (OSError, json.JSONDecodeError):
391
+ identity["contextIndexPresent"] = False
392
+ return identity
393
+
394
+
395
+ def change_code_root(change_dir: Path) -> Path:
396
+ project = change_dir.parents[2]
397
+ metadata = (
398
+ (change_dir / "meta" / "change-context.json", ("worktreeRoot",)),
399
+ (change_dir / "meta" / "worktree.json", ("worktreePath", "path")),
400
+ )
401
+ for context_path, fields in metadata:
402
+ if not context_path.is_file():
403
+ continue
404
+ try:
405
+ context = json.loads(context_path.read_text(encoding="utf-8"))
406
+ raw_root = next(
407
+ (str(context.get(field) or "").strip() for field in fields if context.get(field)),
408
+ "",
409
+ )
410
+ if raw_root:
411
+ raw_path = Path(raw_root).expanduser()
412
+ candidate = (raw_path if raw_path.is_absolute() else project / raw_path).resolve()
413
+ if candidate.is_dir():
414
+ return candidate
415
+ except (OSError, ValueError, json.JSONDecodeError):
416
+ pass
417
+ return project
418
+
419
+
420
+ def classify_risk(change_dir: Path, stage: str) -> dict[str, Any]:
421
+ tier = "full"
422
+ source = "default-full"
423
+ plan_path = change_dir / "plans"
424
+ for candidate in sorted(plan_path.glob("*.md")) if plan_path.is_dir() else []:
425
+ text = candidate.read_text(encoding="utf-8", errors="replace")
426
+ match = re.search(r"风险等级[::]\s*(fast|standard|full)", text, re.IGNORECASE)
427
+ if match:
428
+ tier = match.group(1).lower()
429
+ source = f"plan:{candidate.name}"
430
+ break
431
+ match = re.search(r"risk[^:]*:\s*(fast|standard|full)", text, re.IGNORECASE)
432
+ if match:
433
+ tier = match.group(1).lower()
434
+ source = f"plan:{candidate.name}"
435
+ break
436
+ signals: list[str] = []
437
+ if stage == "post-run":
438
+ project = change_code_root(change_dir)
439
+ proc = subprocess.run(
440
+ ["git", "status", "--porcelain", "--untracked-files=all"],
441
+ cwd=project,
442
+ capture_output=True,
443
+ text=True,
444
+ encoding="utf-8",
445
+ errors="replace",
446
+ check=False,
447
+ )
448
+ changed: list[str] = []
449
+ for line in proc.stdout.splitlines():
450
+ raw = line[3:].strip().strip('"').replace("\\", "/")
451
+ if not raw or raw.startswith(".harness/"):
452
+ continue
453
+ changed.append(raw)
454
+ lowered = "\n".join(changed).lower()
455
+ full_markers = {
456
+ "auth": ("auth", "token", "credential", "permission"),
457
+ "security": ("security", "secret", "crypto"),
458
+ "migration": ("migration", "migrate", "/sql/", ".sql"),
459
+ "concurrency": ("concurr", "lock", "lease", "transaction"),
460
+ "artifact-protocol": ("artifact", "protocol", "manifest", "baseline"),
461
+ "shared-state": ("shared", "state/", "workflow-policy"),
462
+ "delete": ("delete", "purge", "archive"),
463
+ }
464
+ for signal, markers in full_markers.items():
465
+ if any(marker in lowered for marker in markers):
466
+ signals.append(signal)
467
+ if signals:
468
+ observed = "full"
469
+ elif changed and all(
470
+ path.lower().endswith((".md", ".txt", ".rst"))
471
+ or path.lower().startswith("docs/")
472
+ for path in changed
473
+ ):
474
+ observed = "fast"
475
+ signals.append("docs-only")
476
+ elif changed:
477
+ observed = "standard"
478
+ signals.append("production-code")
479
+ else:
480
+ observed = tier
481
+ signals.append("no-code-diff")
482
+ rank = {"fast": 0, "standard": 1, "full": 2}
483
+ if rank[observed] > rank[tier]:
484
+ tier = observed
485
+ source = f"{source}+post-run"
486
+ main_project = change_dir.parents[2]
487
+ workflow = _load_workflow_policy(project=main_project)
488
+ tier_policy = workflow["riskTiers"][tier]
489
+ unique_signals = sorted(set(signals))
490
+ stage_decisions: dict[str, dict[str, Any]] = {}
491
+ for stage_name, stage_policy in workflow["conditionalStages"].items():
492
+ default_required = stage_name in tier_policy["defaultPhases"]
493
+ matched = sorted(set(unique_signals) & set(stage_policy["signals"]))
494
+ signal_required = tier in stage_policy["tiers"] and bool(matched)
495
+ stage_decisions[stage_name] = {
496
+ "required": default_required or signal_required,
497
+ "reason": (
498
+ "tier-default" if default_required
499
+ else "signal:" + ",".join(matched) if signal_required
500
+ else "not-triggered"
501
+ ),
502
+ "matchedSignals": matched,
503
+ }
504
+ payload = {
505
+ "ok": True,
506
+ "code": "CLASSIFIED",
507
+ "stage": stage,
508
+ "tier": tier,
509
+ "source": source,
510
+ "changeId": change_dir.name,
511
+ "signals": unique_signals,
512
+ "defaultPhases": list(tier_policy["defaultPhases"]),
513
+ "requiredValidations": list(tier_policy["requiredValidations"]),
514
+ "conditionalStages": list(tier_policy["conditionalStages"]),
515
+ "stageDecisions": stage_decisions,
516
+ }
517
+ if stage == "post-run":
518
+ _write_json(change_dir / "meta" / "risk-classification.json", payload)
519
+ return payload
520
+
521
+
522
+ def lint_skill_tree(skills_root: Path) -> dict[str, Any]:
523
+ violations: list[dict[str, Any]] = []
524
+ if not skills_root.is_dir():
525
+ return {
526
+ "ok": False,
527
+ "code": "SKILLS_ROOT_MISSING",
528
+ "message": f"skills root not found: {skills_root}",
529
+ }
530
+ for path in sorted(skills_root.rglob("*.md")):
531
+ try:
532
+ text = path.read_text(encoding="utf-8", errors="replace")
533
+ except OSError:
534
+ continue
535
+ for line_no, line in enumerate(text.splitlines(), start=1):
536
+ for pattern in FORBIDDEN_SKILL_PATTERNS:
537
+ if pattern.search(line):
538
+ violations.append(
539
+ {
540
+ "file": str(path.relative_to(skills_root)),
541
+ "line": line_no,
542
+ "pattern": pattern.pattern,
543
+ "text": line.strip(),
544
+ }
545
+ )
546
+ try:
547
+ workflow = _load_workflow_policy(skills_root=skills_root)
548
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
549
+ violations.append(
550
+ {
551
+ "file": "contracts/workflow-policy.json",
552
+ "line": 0,
553
+ "pattern": "valid workflow policy",
554
+ "text": str(exc),
555
+ }
556
+ )
557
+ workflow = {"skills": {}}
558
+ active_profile: str | None = None
559
+ build_marker = skills_root / ".harness-build.json"
560
+ if build_marker.is_file():
561
+ try:
562
+ marker = json.loads(build_marker.read_text(encoding="utf-8"))
563
+ active_profile = "java" if marker.get("overlay") == "java" else "general"
564
+ except (OSError, json.JSONDecodeError):
565
+ active_profile = None
566
+ for skill_name, contract in sorted(workflow["skills"].items()):
567
+ if active_profile is not None and active_profile not in contract.get(
568
+ "profiles", ["general", "java"]
569
+ ):
570
+ continue
571
+ skill_files = sorted(skills_root.rglob(f"{skill_name}/SKILL.md"))
572
+ if not skill_files:
573
+ violations.append(
574
+ {
575
+ "file": skill_name,
576
+ "line": 0,
577
+ "pattern": "policy skill exists",
578
+ "text": f"missing {skill_name}/SKILL.md",
579
+ }
580
+ )
581
+ continue
582
+ required = tuple(
583
+ marker
584
+ for capability in contract.get("capabilities", [])
585
+ for marker in CAPABILITY_MARKERS.get(capability, ())
586
+ )
587
+ for skill_file in skill_files:
588
+ text = skill_file.read_text(encoding="utf-8", errors="replace")
589
+ for marker in required:
590
+ if marker not in text:
591
+ violations.append(
592
+ {
593
+ "file": str(skill_file.relative_to(skills_root)),
594
+ "line": 0,
595
+ "pattern": marker,
596
+ "text": f"missing capability command: {marker}",
597
+ }
598
+ )
599
+ return {
600
+ "ok": len(violations) == 0,
601
+ "code": "LINT_OK" if not violations else "SKILL_CONTRACT_VIOLATION",
602
+ "violations": violations,
603
+ "skillsRoot": str(skills_root.resolve()),
604
+ }
605
+
606
+
607
+ def append_phase_event(
608
+ change_dir: Path,
609
+ *,
610
+ phase: str,
611
+ type_: str,
612
+ status: str | None = None,
613
+ note: str = "",
614
+ identity: dict[str, Any] | None = None,
615
+ run_id: str | None = None,
616
+ executor_tool: str | None = None,
617
+ executor_agent: str | None = None,
618
+ executor_model: str | None = None,
619
+ ) -> dict[str, Any]:
620
+ events_file = he.events_path(change_dir)
621
+ existing = he.load_events(events_file)
622
+ args = argparse.Namespace(
623
+ phase=phase,
624
+ type=type_,
625
+ status=status,
626
+ note=note,
627
+ command=None,
628
+ exit_code=None,
629
+ duration_ms=None,
630
+ name=None,
631
+ path=None,
632
+ kind=None,
633
+ code=None,
634
+ severity=None,
635
+ message=None,
636
+ decision=None,
637
+ reason=None,
638
+ run_id=run_id,
639
+ attempt=None,
640
+ executor_tool=executor_tool,
641
+ executor_agent=executor_agent,
642
+ executor_model=executor_model,
643
+ handoff_from_tool=None,
644
+ handoff_reason=None,
645
+ )
646
+ event = he.build_event(args, existing)
647
+ if identity:
648
+ for key, value in identity.items():
649
+ if key not in event and value is not None:
650
+ event[key] = value
651
+ line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
652
+ lock_path = change_dir / "events.ndjson.lock"
653
+ with he.event_file_lock(lock_path):
654
+ he.atomic_append_line(events_file, line)
655
+ rendered = False
656
+ log_path = None
657
+ if type_ == "phase.end":
658
+ events = he.load_events(events_file)
659
+ content = he.render_execution_log(events)
660
+ log_path = he.write_execution_log(change_dir, content)
661
+ rendered = True
662
+ return {
663
+ "ok": True,
664
+ "eventId": event.get("id"),
665
+ "path": str(events_file),
666
+ "rendered": rendered,
667
+ "executionLogPath": str(log_path) if log_path else None,
668
+ }
669
+
670
+
671
+ def _phase_event_exists(change_dir: Path, phase: str, type_: str, run_id: str) -> bool:
672
+ return any(
673
+ event.get("phase") == phase
674
+ and event.get("type") == type_
675
+ and event.get("run_id") == run_id
676
+ for event in he.load_events(he.events_path(change_dir))
677
+ )
678
+
679
+
680
+ def cmd_begin(args: argparse.Namespace) -> int:
681
+ as_json = bool(args.json)
682
+ project = hc.resolve_main_project_root()
683
+ resolved = hc.resolve_change(project, args.change)
684
+ if not resolved.get("ok"):
685
+ return emit_error(
686
+ str(resolved.get("code", "RESOLVE_FAILED")),
687
+ str(resolved.get("message", "change resolve failed")),
688
+ as_json=as_json,
689
+ extra={k: v for k, v in resolved.items() if k not in {"ok", "message"}},
690
+ )
691
+ change_dir = Path(resolved["changeDir"])
692
+ blocked = foundation_gate_blocks(getattr(args, "task", None), change_dir)
693
+ if blocked:
694
+ return emit_error(
695
+ blocked["code"],
696
+ blocked["message"],
697
+ as_json=as_json,
698
+ extra={k: v for k, v in blocked.items() if k not in {"ok", "message", "code"}},
699
+ )
700
+
701
+ try:
702
+ policy = _load_workflow_policy(project=project)
703
+ except (OSError, ValueError, hwp.PolicyValidationError) as exc:
704
+ return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
705
+
706
+ executor_tool = args.executor_tool or os.environ.get("HUNTER_HARNESS_TOOL")
707
+ if not args.skills_root:
708
+ return emit_error(
709
+ "BUNDLE_IDENTITY_REQUIRED",
710
+ "--skills-root is required; refresh the selected Harness adapter if identity is missing",
711
+ as_json=as_json,
712
+ )
713
+ try:
714
+ identity = validate_identity(
715
+ project, Path(args.skills_root).expanduser().resolve(), executor_tool
716
+ )
717
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
718
+ return emit_error("BUNDLE_IDENTITY_INVALID", str(exc), as_json=as_json)
719
+ executor_tool = executor_tool or str(identity.get("adapter") or "") or None
720
+ explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
721
+ current_lease = hc.inspect_lease(project, resolved["changeId"])
722
+ run_id = explicit_run_id or (
723
+ str(current_lease.get("runId"))
724
+ if isinstance(current_lease, dict) and current_lease.get("phase") == args.phase
725
+ else "run-" + uuid.uuid4().hex
726
+ )
727
+ claim = hc.claim_lease(
728
+ project,
729
+ change_id=resolved["changeId"],
730
+ phase=args.phase,
731
+ run_id=run_id,
732
+ ttl_seconds=int(args.ttl_seconds),
733
+ )
734
+ if not claim.get("ok"):
735
+ return emit_error(
736
+ str(claim.get("code", "LEASE_CONFLICT")),
737
+ str(claim.get("message", "lease claim failed")),
738
+ as_json=as_json,
739
+ extra={k: v for k, v in claim.items() if k not in {"ok", "message", "code"}},
740
+ )
741
+
742
+ try:
743
+ event_result = {"ok": True, "skipped": True, "reason": "already-recorded"} \
744
+ if _phase_event_exists(change_dir, args.phase, "phase.start", run_id) \
745
+ else append_phase_event(
746
+ change_dir,
747
+ phase=args.phase,
748
+ type_="phase.start",
749
+ note=args.note or "",
750
+ identity=identity,
751
+ run_id=run_id,
752
+ executor_tool=executor_tool,
753
+ executor_agent=args.executor_agent or os.environ.get("HUNTER_HARNESS_AGENT"),
754
+ executor_model=args.executor_model or os.environ.get("HUNTER_HARNESS_MODEL"),
755
+ )
756
+ except BaseException:
757
+ hc.release_lease(
758
+ project, change_id=resolved["changeId"], phase=args.phase, run_id=run_id
759
+ )
760
+ raise
761
+
762
+ payload = {
763
+ "ok": True,
764
+ "code": "PHASE_BEGUN",
765
+ "phase": args.phase,
766
+ "changeId": resolved["changeId"],
767
+ "changeDir": str(change_dir),
768
+ "projectRoot": str(project),
769
+ "lease": claim.get("lease"),
770
+ "identity": identity,
771
+ "event": event_result,
772
+ "policySchemaVersion": policy.get("schemaVersion"),
773
+ }
774
+ emit(payload, as_json=as_json)
775
+ return 0
776
+
777
+
778
+ def cmd_close(args: argparse.Namespace) -> int:
779
+ as_json = bool(args.json)
780
+ project = hc.resolve_main_project_root()
781
+ resolved = hc.resolve_change(project, args.change)
782
+ if not resolved.get("ok"):
783
+ return emit_error(
784
+ str(resolved.get("code", "RESOLVE_FAILED")),
785
+ str(resolved.get("message", "change resolve failed")),
786
+ as_json=as_json,
787
+ extra={k: v for k, v in resolved.items() if k not in {"ok", "message"}},
788
+ )
789
+ change_dir = Path(resolved["changeDir"])
790
+ blocked = foundation_gate_blocks(getattr(args, "task", None), change_dir)
791
+ if blocked:
792
+ return emit_error(
793
+ blocked["code"],
794
+ blocked["message"],
795
+ as_json=as_json,
796
+ extra={k: v for k, v in blocked.items() if k not in {"ok", "message", "code"}},
797
+ )
798
+
799
+ try:
800
+ policy = _load_workflow_policy(project=project)
801
+ except (OSError, ValueError, hwp.PolicyValidationError) as exc:
802
+ return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
803
+
804
+ explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
805
+ current_lease = hc.inspect_lease(project, resolved["changeId"])
806
+ if current_lease is None:
807
+ return emit_error("LEASE_ABSENT", "no active lease for phase close", as_json=as_json)
808
+ run_id = explicit_run_id or str(current_lease.get("runId") or "")
809
+ if str(current_lease.get("runId")) != run_id or str(current_lease.get("phase")) != args.phase:
810
+ return emit_error(
811
+ "LEASE_OWNER_MISMATCH",
812
+ "active lease does not match close phase/run-id",
813
+ as_json=as_json,
814
+ extra={"holder": current_lease},
815
+ )
816
+
817
+ ledger_result = validate_ledger_for_phase_close(change_dir, args.phase, policy)
818
+ if not ledger_result.get("ok") and args.phase in {"run", "test", "package"}:
819
+ return emit_error(
820
+ str(ledger_result.get("code", "LEDGER_INVALID")),
821
+ str(ledger_result.get("message", "ledger validation failed")),
822
+ as_json=as_json,
823
+ extra={k: v for k, v in ledger_result.items() if k not in {"ok", "message", "code"}},
824
+ )
825
+
826
+ guard_result = None
827
+ if args.phase in {"run", "test"}:
828
+ project_root = args.project or str(project)
829
+ guard_result = htg.close(project_root, change_dir)
830
+ if not guard_result.get("ok"):
831
+ return emit_error(
832
+ str(guard_result.get("code", "TEST_GUARD_CLOSE_FAILED")),
833
+ "test guard close failed",
834
+ as_json=as_json,
835
+ extra=guard_result,
836
+ )
837
+
838
+ event_result = append_phase_event(
839
+ change_dir,
840
+ phase=args.phase,
841
+ type_="phase.end",
842
+ status=args.status,
843
+ note=args.note or "",
844
+ run_id=run_id,
845
+ ) if not _phase_event_exists(change_dir, args.phase, "phase.end", run_id) else {
846
+ "ok": True, "skipped": True, "reason": "already-recorded"
847
+ }
848
+
849
+ release = hc.release_lease(
850
+ project,
851
+ change_id=resolved["changeId"],
852
+ phase=args.phase,
853
+ run_id=run_id,
854
+ )
855
+ if not release.get("ok"):
856
+ return emit_error(
857
+ str(release.get("code", "LEASE_RELEASE_FAILED")),
858
+ str(release.get("message", "lease release failed")),
859
+ as_json=as_json,
860
+ extra={k: v for k, v in release.items() if k not in {"ok", "message", "code"}},
861
+ )
862
+
863
+ payload = {
864
+ "ok": True,
865
+ "code": "PHASE_CLOSED",
866
+ "phase": args.phase,
867
+ "status": args.status,
868
+ "changeId": resolved["changeId"],
869
+ "ledger": ledger_result,
870
+ "testGuard": guard_result,
871
+ "event": event_result,
872
+ "lease": release,
873
+ }
874
+ emit(payload, as_json=as_json)
875
+ return 0
876
+
877
+
878
+ def cmd_classify(args: argparse.Namespace) -> int:
879
+ as_json = bool(args.json)
880
+ project = hc.resolve_main_project_root()
881
+ resolved = hc.resolve_change(project, args.change)
882
+ if not resolved.get("ok"):
883
+ return emit_error(
884
+ str(resolved.get("code", "RESOLVE_FAILED")),
885
+ str(resolved.get("message", "change resolve failed")),
886
+ as_json=as_json,
887
+ extra={k: v for k, v in resolved.items() if k not in {"ok", "message"}},
888
+ )
889
+ payload = classify_risk(Path(resolved["changeDir"]), args.stage)
890
+ emit(payload, as_json=as_json)
891
+ return 0
892
+
893
+
894
+ def cmd_checkpoint(args: argparse.Namespace) -> int:
895
+ as_json = bool(args.json)
896
+ project = hc.resolve_main_project_root()
897
+ resolved = hc.resolve_change(project, args.change)
898
+ if not resolved.get("ok"):
899
+ return emit_error(
900
+ str(resolved.get("code", "RESOLVE_FAILED")),
901
+ str(resolved.get("message", "change resolve failed")),
902
+ as_json=as_json,
903
+ extra={k: v for k, v in resolved.items() if k not in {"ok", "message"}},
904
+ )
905
+ change_dir = Path(resolved["changeDir"])
906
+ path = change_dir / CHECKPOINTS_REL
907
+ if args.checkpoint_action == "status":
908
+ checkpoints = load_checkpoints(change_dir)
909
+ status = checkpoint_status(checkpoints, args.id)
910
+ payload = {
911
+ "ok": True,
912
+ "code": "CHECKPOINT_STATUS",
913
+ "checkpointId": args.id,
914
+ "status": status,
915
+ "path": str(path) if path.is_file() else None,
916
+ }
917
+ emit(payload, as_json=as_json)
918
+ return 0
919
+
920
+ if args.checkpoint_action != "approve":
921
+ return emit_error("INVALID_CHECKPOINT_ACTION", args.checkpoint_action, as_json=as_json)
922
+
923
+ checkpoints = load_checkpoints(change_dir)
924
+ items = checkpoints.get("checkpoints") if isinstance(checkpoints, dict) else None
925
+ item = next((candidate for candidate in items if isinstance(candidate, dict)
926
+ and candidate.get("id") == args.id), None) if isinstance(items, list) else None
927
+ if item is None:
928
+ return emit_error("CHECKPOINT_NOT_FOUND", f"checkpoint not found: {args.id}", as_json=as_json)
929
+ expected_reviewer = str(item.get("reviewerTool") or "")
930
+ if not args.reviewer or (expected_reviewer and args.reviewer != expected_reviewer):
931
+ return emit_error(
932
+ "CHECKPOINT_REVIEWER_MISMATCH",
933
+ f"checkpoint requires reviewer {expected_reviewer or 'explicit reviewer'}",
934
+ as_json=as_json,
935
+ )
936
+ required_report = str(item.get("requiredReport") or "")
937
+ report_path = change_dir / required_report
938
+ if not required_report or not report_path.is_file() or report_path.stat().st_size == 0:
939
+ return emit_error(
940
+ "CHECKPOINT_REPORT_MISSING",
941
+ f"required report is missing: {required_report or '<unset>'}",
942
+ as_json=as_json,
943
+ )
944
+ report_text = report_path.read_text(encoding="utf-8", errors="replace")
945
+ if not re.search(r"(?im)^foundation-gate:\s*approved\s*$", report_text):
946
+ return emit_error(
947
+ "CHECKPOINT_REPORT_NOT_APPROVED",
948
+ "required report does not contain 'foundation-gate: approved'",
949
+ as_json=as_json,
950
+ )
951
+ item["status"] = "approved"
952
+ item["approvedAt"] = hc.now_iso()
953
+ item["approvedBy"] = args.reviewer
954
+ _write_json(path, checkpoints)
955
+ payload = {
956
+ "ok": True,
957
+ "code": "CHECKPOINT_APPROVED",
958
+ "checkpointId": args.id,
959
+ "status": "approved",
960
+ "path": str(path),
961
+ }
962
+ emit(payload, as_json=as_json)
963
+ return 0
964
+
965
+
966
+ def cmd_lint_skills(args: argparse.Namespace) -> int:
967
+ as_json = bool(args.json)
968
+ root = Path(args.skills_root).expanduser().resolve()
969
+ payload = lint_skill_tree(root)
970
+ if payload.get("ok"):
971
+ emit(payload, as_json=as_json)
972
+ return 0
973
+ return emit_error(
974
+ str(payload.get("code", "LINT_FAILED")),
975
+ str(payload.get("message", "skill lint failed")),
976
+ as_json=as_json,
977
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message", "code"}},
978
+ )
979
+
980
+
981
+ def build_parser() -> argparse.ArgumentParser:
982
+ parser = argparse.ArgumentParser(prog="harness_gate.py")
983
+ parser.add_argument("--json", action="store_true")
984
+ sub = parser.add_subparsers(dest="command_name", required=True)
985
+ shared = argparse.ArgumentParser(add_help=False)
986
+ shared.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
987
+
988
+ p_begin = sub.add_parser("begin", parents=[shared])
989
+ p_begin.add_argument("--phase", required=True)
990
+ p_begin.add_argument("--change", default=None)
991
+ p_begin.add_argument("--project", default=None)
992
+ p_begin.add_argument("--skills-root", default=None)
993
+ p_begin.add_argument("--run-id", default=None)
994
+ p_begin.add_argument("--ttl-seconds", type=int, default=3600)
995
+ p_begin.add_argument("--task", type=int, default=None)
996
+ p_begin.add_argument("--note", default="")
997
+ p_begin.add_argument("--executor-tool", default=None)
998
+ p_begin.add_argument("--executor-agent", default=None)
999
+ p_begin.add_argument("--executor-model", default=None)
1000
+ p_begin.set_defaults(func=cmd_begin)
1001
+
1002
+ p_close = sub.add_parser("close", parents=[shared])
1003
+ p_close.add_argument("--phase", required=True)
1004
+ p_close.add_argument("--change", default=None)
1005
+ p_close.add_argument("--project", default=None)
1006
+ p_close.add_argument("--status", required=True)
1007
+ p_close.add_argument("--run-id", default=None)
1008
+ p_close.add_argument("--task", type=int, default=None)
1009
+ p_close.add_argument("--note", default="")
1010
+ p_close.set_defaults(func=cmd_close)
1011
+
1012
+ p_classify = sub.add_parser("classify", parents=[shared])
1013
+ p_classify.add_argument("--change", default=None)
1014
+ p_classify.add_argument("--stage", required=True, choices=["plan", "post-run"])
1015
+ p_classify.set_defaults(func=cmd_classify)
1016
+
1017
+ p_checkpoint = sub.add_parser("checkpoint", parents=[shared])
1018
+ p_checkpoint.add_argument("checkpoint_action", choices=["status", "approve"])
1019
+ p_checkpoint.add_argument("--id", required=True)
1020
+ p_checkpoint.add_argument("--change", default=None)
1021
+ p_checkpoint.add_argument("--reviewer", default=None)
1022
+ p_checkpoint.set_defaults(func=cmd_checkpoint)
1023
+
1024
+ p_lint = sub.add_parser("lint-skills", parents=[shared])
1025
+ p_lint.add_argument("--skills-root", required=True)
1026
+ p_lint.set_defaults(func=cmd_lint_skills)
1027
+
1028
+ return parser
1029
+
1030
+
1031
+ def main(argv: list[str] | None = None) -> int:
1032
+ parser = build_parser()
1033
+ args = parser.parse_args(argv)
1034
+ return int(args.func(args))
1035
+
1036
+
1037
+ if __name__ == "__main__":
1038
+ raise SystemExit(main())