@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,872 @@
1
+ #!/usr/bin/env python3
2
+ """Harness change resolution, leases, ports, and integration lock.
3
+
4
+ Subcommands:
5
+ list — list active changes
6
+ resolve — resolve explicit or sole active change-id
7
+ migrate — backfill change metadata without touching business files
8
+ claim / release — per-change phase lease
9
+ lease-port — assign an unused port from a configured range
10
+ integration-lock — acquire|release global main-branch integration lock
11
+
12
+ When run from a git worktree, state is resolved via git common dir to the main
13
+ project root (.harness/changes lives there). Python 3.10+, stdlib only.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import contextlib
20
+ import datetime as dt
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import uuid
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ SCRIPTS_DIR = Path(__file__).resolve().parent
31
+ if str(SCRIPTS_DIR) not in sys.path:
32
+ sys.path.insert(0, str(SCRIPTS_DIR))
33
+
34
+ if hasattr(sys.stdout, "reconfigure"):
35
+ sys.stdout.reconfigure(encoding="utf-8")
36
+ if hasattr(sys.stderr, "reconfigure"):
37
+ sys.stderr.reconfigure(encoding="utf-8")
38
+
39
+
40
+ RUNTIME_REL = Path(".harness") / "runtime"
41
+ LEASES_REL = RUNTIME_REL / "leases"
42
+ PORTS_REL = RUNTIME_REL / "ports"
43
+ INTEGRATION_LOCK_REL = RUNTIME_REL / "integration-lock.json"
44
+ CHANGE_CONTEXT_REL = Path("meta") / "change-context.json"
45
+ WORKTREE_META_REL = Path("meta") / "worktree.json"
46
+ CHECKPOINTS_REL = Path("meta") / "implementation-checkpoints.json"
47
+
48
+
49
+ def now_iso() -> str:
50
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
51
+
52
+
53
+ def emit(payload: dict[str, Any], *, as_json: bool) -> None:
54
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
55
+ sys.stdout.write(text if as_json else _human_line(payload))
56
+
57
+
58
+ def _human_line(payload: dict[str, Any]) -> str:
59
+ if payload.get("ok"):
60
+ code = payload.get("code", "OK")
61
+ change = payload.get("changeId") or payload.get("change")
62
+ if change:
63
+ return f"ok code={code} change={change}\n"
64
+ return f"ok code={code}\n"
65
+ return f"error code={payload.get('code', 'ERROR')} message={payload.get('message')}\n"
66
+
67
+
68
+ def emit_error(
69
+ code: str,
70
+ message: str,
71
+ *,
72
+ as_json: bool,
73
+ extra: dict[str, Any] | None = None,
74
+ exit_code: int = 1,
75
+ ) -> int:
76
+ payload: dict[str, Any] = {"ok": False, "code": code, "message": message}
77
+ if extra:
78
+ payload.update(extra)
79
+ if as_json:
80
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
81
+ else:
82
+ sys.stderr.write(f"error: {message} ({code})\n")
83
+ return exit_code
84
+
85
+
86
+ def _git_text(cwd: Path, *args: str) -> str | None:
87
+ proc = subprocess.run(
88
+ ["git", *args],
89
+ cwd=str(cwd),
90
+ capture_output=True,
91
+ text=True,
92
+ encoding="utf-8",
93
+ errors="replace",
94
+ check=False,
95
+ )
96
+ if proc.returncode != 0:
97
+ return None
98
+ return proc.stdout.strip()
99
+
100
+
101
+ def resolve_main_project_root(cwd: Path | None = None) -> Path:
102
+ """Locate main project root from a worktree or main checkout."""
103
+ start = (cwd or Path.cwd()).resolve()
104
+ common_raw = _git_text(start, "rev-parse", "--git-common-dir")
105
+ if not common_raw:
106
+ return start
107
+ common = Path(common_raw)
108
+ if not common.is_absolute():
109
+ common = (start / common).resolve()
110
+ if common.name == ".git":
111
+ return common.parent
112
+ return start
113
+
114
+
115
+ def changes_dir(project_root: Path) -> Path:
116
+ return project_root / ".harness" / "changes"
117
+
118
+
119
+ def runtime_dir(project_root: Path) -> Path:
120
+ return project_root / RUNTIME_REL
121
+
122
+
123
+ def _read_json(path: Path) -> Any:
124
+ return json.loads(path.read_text(encoding="utf-8-sig"))
125
+
126
+
127
+ def _write_json(path: Path, data: Any) -> None:
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
130
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
131
+ try:
132
+ tmp.write_text(text, encoding="utf-8", newline="\n")
133
+ os.replace(tmp, path)
134
+ except BaseException:
135
+ tmp.unlink(missing_ok=True)
136
+ raise
137
+
138
+
139
+ @contextlib.contextmanager
140
+ def _exclusive_file_lock(path: Path, wait_seconds: float = 5.0):
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ deadline = time.monotonic() + wait_seconds
143
+ descriptor: int | None = None
144
+ while descriptor is None:
145
+ try:
146
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
147
+ except (FileExistsError, PermissionError):
148
+ if time.monotonic() >= deadline:
149
+ raise TimeoutError(f"lock unavailable: {path}")
150
+ time.sleep(0.01)
151
+ try:
152
+ os.write(descriptor, f"{os.getpid()}\n".encode("ascii"))
153
+ yield
154
+ finally:
155
+ os.close(descriptor)
156
+ path.unlink(missing_ok=True)
157
+
158
+
159
+ def list_active_changes(project_root: Path) -> list[dict[str, Any]]:
160
+ root = changes_dir(project_root)
161
+ if not root.is_dir():
162
+ return []
163
+ active: list[dict[str, Any]] = []
164
+ for entry in sorted(root.iterdir()):
165
+ if not entry.is_dir():
166
+ continue
167
+ meta_archived = entry / "meta" / "archived.json"
168
+ if meta_archived.is_file():
169
+ try:
170
+ archived = _read_json(meta_archived)
171
+ except (OSError, json.JSONDecodeError):
172
+ archived = {}
173
+ if isinstance(archived, dict) and archived.get("status") == "archived":
174
+ continue
175
+ has_plan = any((entry / "plans").glob("*-plan.md"))
176
+ has_context = (entry / CHANGE_CONTEXT_REL).is_file()
177
+ has_checkpoints = (entry / CHECKPOINTS_REL).is_file()
178
+ worktree_path = entry / WORKTREE_META_REL
179
+ has_active_worktree = False
180
+ if worktree_path.is_file():
181
+ try:
182
+ worktree = _read_json(worktree_path)
183
+ has_active_worktree = isinstance(worktree, dict) and (
184
+ worktree.get("created") is True or
185
+ worktree.get("requested") is True
186
+ )
187
+ except (OSError, json.JSONDecodeError):
188
+ has_active_worktree = False
189
+ # Runtime notes, event logs and requested=false worktree metadata can
190
+ # survive archive/submit cleanup. They are residues, not active changes.
191
+ if not (has_plan or has_context or has_checkpoints or has_active_worktree):
192
+ continue
193
+ active.append(
194
+ {
195
+ "changeId": entry.name,
196
+ "path": str(entry.resolve()),
197
+ "hasPlan": has_plan,
198
+ "hasWorktreeMeta": worktree_path.is_file(),
199
+ }
200
+ )
201
+ return active
202
+
203
+
204
+ def change_dir_for_id(project_root: Path, change_id: str) -> Path | None:
205
+ candidate = changes_dir(project_root) / change_id
206
+ if candidate.is_dir():
207
+ return candidate.resolve()
208
+ return None
209
+
210
+
211
+ def resolve_change(
212
+ project_root: Path,
213
+ change_id: str | None,
214
+ ) -> dict[str, Any]:
215
+ active = list_active_changes(project_root)
216
+ if change_id:
217
+ resolved = change_dir_for_id(project_root, change_id)
218
+ if resolved is None:
219
+ return {
220
+ "ok": False,
221
+ "code": "CHANGE_NOT_FOUND",
222
+ "message": f"change not found: {change_id}",
223
+ "changeId": change_id,
224
+ }
225
+ return {
226
+ "ok": True,
227
+ "code": "RESOLVED",
228
+ "changeId": change_id,
229
+ "changeDir": str(resolved),
230
+ "projectRoot": str(project_root.resolve()),
231
+ "activeCount": len(active),
232
+ }
233
+ if not active:
234
+ return {
235
+ "ok": False,
236
+ "code": "NO_ACTIVE_CHANGE",
237
+ "message": "no active change under .harness/changes",
238
+ "activeChanges": [],
239
+ }
240
+ if len(active) == 1:
241
+ only = active[0]
242
+ return {
243
+ "ok": True,
244
+ "code": "RESOLVED",
245
+ "changeId": only["changeId"],
246
+ "changeDir": only["path"],
247
+ "projectRoot": str(project_root.resolve()),
248
+ "activeCount": 1,
249
+ "autoSelected": True,
250
+ }
251
+ return {
252
+ "ok": False,
253
+ "code": "CHANGE_SELECTION_REQUIRED",
254
+ "message": "multiple active changes; pass --change <id>",
255
+ "activeChanges": active,
256
+ "activeCount": len(active),
257
+ }
258
+
259
+
260
+ def migrate_change(project_root: Path, change_id: str) -> dict[str, Any]:
261
+ resolved = resolve_change(project_root, change_id)
262
+ if not resolved.get("ok"):
263
+ return resolved
264
+ change_dir = Path(resolved["changeDir"])
265
+ created: list[str] = []
266
+
267
+ context_path = change_dir / CHANGE_CONTEXT_REL
268
+ if not context_path.is_file():
269
+ worktree_meta = change_dir / WORKTREE_META_REL
270
+ worktree_root = project_root.resolve()
271
+ branch = _git_text(project_root, "rev-parse", "--abbrev-ref", "HEAD") or ""
272
+ if worktree_meta.is_file():
273
+ try:
274
+ wt = _read_json(worktree_meta)
275
+ if isinstance(wt, dict):
276
+ if isinstance(wt.get("path"), str) and wt["path"].strip():
277
+ worktree_root = Path(wt["path"]).expanduser().resolve()
278
+ elif isinstance(wt.get("worktreeRoot"), str) and wt["worktreeRoot"].strip():
279
+ worktree_root = Path(wt["worktreeRoot"]).expanduser().resolve()
280
+ if isinstance(wt.get("branch"), str) and wt["branch"].strip():
281
+ branch = wt["branch"].strip()
282
+ except (OSError, json.JSONDecodeError):
283
+ pass
284
+ context = {
285
+ "schemaVersion": 1,
286
+ "changeId": change_id,
287
+ "mainProjectRoot": str(project_root.resolve()),
288
+ "worktreeRoot": str(worktree_root),
289
+ "stateDir": str(change_dir.resolve()),
290
+ "branch": branch,
291
+ "migratedAt": now_iso(),
292
+ }
293
+ _write_json(context_path, context)
294
+ created.append(str(context_path.relative_to(change_dir)))
295
+
296
+ checkpoints_path = change_dir / CHECKPOINTS_REL
297
+ if not checkpoints_path.is_file():
298
+ checkpoints = {
299
+ "schemaVersion": 1,
300
+ "changeName": change_id,
301
+ "checkpoints": [
302
+ {
303
+ "id": "foundation-gate",
304
+ "afterTasks": [1, 2, 3, 4],
305
+ "beforeTasks": [6, 7, 8, 9, 10],
306
+ "reviewerTool": "codex",
307
+ "status": "pending",
308
+ "blocking": True,
309
+ "requiredReport": "reports/review/foundation-gate-review.md",
310
+ "purpose": "Block tasks 6+ until foundation interfaces are reviewed.",
311
+ }
312
+ ],
313
+ }
314
+ _write_json(checkpoints_path, checkpoints)
315
+ created.append(str(checkpoints_path.relative_to(change_dir)))
316
+
317
+ return {
318
+ "ok": True,
319
+ "code": "MIGRATED",
320
+ "changeId": change_id,
321
+ "changeDir": str(change_dir),
322
+ "created": created,
323
+ }
324
+
325
+
326
+ def _lease_path(project_root: Path, change_id: str) -> Path:
327
+ return project_root / LEASES_REL / f"{change_id}.json"
328
+
329
+
330
+ def _lease_expired(lease: dict[str, Any]) -> bool:
331
+ expires = lease.get("expiresAt")
332
+ if not isinstance(expires, str) or not expires.strip():
333
+ return True
334
+ try:
335
+ exp_dt = dt.datetime.fromisoformat(expires.replace("Z", "+00:00"))
336
+ except ValueError:
337
+ return True
338
+ return dt.datetime.now().astimezone() >= exp_dt
339
+
340
+
341
+ def inspect_lease(project_root: Path, change_id: str) -> dict[str, Any] | None:
342
+ """Return the current non-expired lease without mutating it."""
343
+ path = _lease_path(project_root, change_id)
344
+ if not path.is_file():
345
+ return None
346
+ try:
347
+ lease = _read_json(path)
348
+ except (OSError, json.JSONDecodeError):
349
+ return None
350
+ if not isinstance(lease, dict) or _lease_expired(lease):
351
+ return None
352
+ return lease
353
+
354
+
355
+ def _claim_lease_locked(
356
+ project_root: Path,
357
+ *,
358
+ change_id: str,
359
+ phase: str,
360
+ run_id: str,
361
+ ttl_seconds: int,
362
+ steal: bool = False,
363
+ ) -> dict[str, Any]:
364
+ path = _lease_path(project_root, change_id)
365
+ path.parent.mkdir(parents=True, exist_ok=True)
366
+ now = dt.datetime.now().astimezone()
367
+ expires = now + dt.timedelta(seconds=max(1, ttl_seconds))
368
+ new_lease = {
369
+ "changeId": change_id,
370
+ "phase": phase,
371
+ "runId": run_id,
372
+ "pid": os.getpid(),
373
+ "acquiredAt": now.isoformat(timespec="milliseconds"),
374
+ "expiresAt": expires.isoformat(timespec="milliseconds"),
375
+ "ttlSeconds": ttl_seconds,
376
+ }
377
+ if path.is_file():
378
+ try:
379
+ existing = _read_json(path)
380
+ except (OSError, json.JSONDecodeError):
381
+ existing = None
382
+ if isinstance(existing, dict) and not _lease_expired(existing):
383
+ same_owner = str(existing.get("runId")) == run_id
384
+ if same_owner:
385
+ existing.update(
386
+ {
387
+ "phase": phase,
388
+ "pid": os.getpid(),
389
+ "expiresAt": expires.isoformat(timespec="milliseconds"),
390
+ "refreshedAt": now.isoformat(timespec="milliseconds"),
391
+ }
392
+ )
393
+ _write_json(path, existing)
394
+ return {"ok": True, "code": "LEASE_REFRESHED", "lease": existing}
395
+ if not steal:
396
+ return {
397
+ "ok": False,
398
+ "code": "LEASE_CONFLICT",
399
+ "message": "change lease held by another run",
400
+ "holder": existing,
401
+ }
402
+ _write_json(path, new_lease)
403
+ return {"ok": True, "code": "LEASE_CLAIMED", "lease": new_lease}
404
+
405
+
406
+ def claim_lease(
407
+ project_root: Path,
408
+ *,
409
+ change_id: str,
410
+ phase: str,
411
+ run_id: str,
412
+ ttl_seconds: int,
413
+ steal: bool = False,
414
+ ) -> dict[str, Any]:
415
+ path = _lease_path(project_root, change_id)
416
+ with _exclusive_file_lock(path.with_suffix(".lock")):
417
+ return _claim_lease_locked(
418
+ project_root,
419
+ change_id=change_id,
420
+ phase=phase,
421
+ run_id=run_id,
422
+ ttl_seconds=ttl_seconds,
423
+ steal=steal,
424
+ )
425
+
426
+
427
+ def _release_lease_locked(
428
+ project_root: Path,
429
+ *,
430
+ change_id: str,
431
+ phase: str,
432
+ run_id: str,
433
+ ) -> dict[str, Any]:
434
+ path = _lease_path(project_root, change_id)
435
+ if not path.is_file():
436
+ return {
437
+ "ok": True,
438
+ "code": "LEASE_ABSENT",
439
+ "message": "no lease file present",
440
+ }
441
+ try:
442
+ existing = _read_json(path)
443
+ except (OSError, json.JSONDecodeError) as exc:
444
+ return {
445
+ "ok": False,
446
+ "code": "LEASE_INVALID",
447
+ "message": str(exc),
448
+ }
449
+ if not isinstance(existing, dict):
450
+ return {"ok": False, "code": "LEASE_INVALID", "message": "lease is not an object"}
451
+ if str(existing.get("runId")) != run_id:
452
+ return {
453
+ "ok": False,
454
+ "code": "LEASE_OWNER_MISMATCH",
455
+ "message": "run id does not match lease owner",
456
+ "holder": existing,
457
+ }
458
+ if str(existing.get("phase")) != phase:
459
+ return {
460
+ "ok": False,
461
+ "code": "LEASE_PHASE_MISMATCH",
462
+ "message": f"lease phase is {existing.get('phase')}, not {phase}",
463
+ "holder": existing,
464
+ }
465
+ path.unlink(missing_ok=True)
466
+ return {"ok": True, "code": "LEASE_RELEASED", "changeId": change_id, "phase": phase}
467
+
468
+
469
+ def release_lease(
470
+ project_root: Path,
471
+ *,
472
+ change_id: str,
473
+ phase: str,
474
+ run_id: str,
475
+ ) -> dict[str, Any]:
476
+ path = _lease_path(project_root, change_id)
477
+ with _exclusive_file_lock(path.with_suffix(".lock")):
478
+ return _release_lease_locked(
479
+ project_root, change_id=change_id, phase=phase, run_id=run_id
480
+ )
481
+
482
+
483
+ def lease_port(
484
+ project_root: Path,
485
+ *,
486
+ change_id: str,
487
+ run_id: str,
488
+ port_range: tuple[int, int],
489
+ ) -> dict[str, Any]:
490
+ start, end = port_range
491
+ if start > end:
492
+ return {
493
+ "ok": False,
494
+ "code": "INVALID_PORT_RANGE",
495
+ "message": f"invalid range {start}-{end}",
496
+ }
497
+ ports_root = project_root / PORTS_REL
498
+ ports_root.mkdir(parents=True, exist_ok=True)
499
+ registry_path = ports_root / "registry.json"
500
+ with _exclusive_file_lock(registry_path.with_suffix(".lock")):
501
+ registry: dict[str, Any] = {"leases": []}
502
+ if registry_path.is_file():
503
+ try:
504
+ loaded = _read_json(registry_path)
505
+ if isinstance(loaded, dict) and isinstance(loaded.get("leases"), list):
506
+ registry = loaded
507
+ except (OSError, json.JSONDecodeError):
508
+ registry = {"leases": []}
509
+
510
+ leases = [
511
+ item for item in registry["leases"]
512
+ if isinstance(item, dict) and not _lease_expired(item)
513
+ ]
514
+ used = {
515
+ int(item["port"])
516
+ for item in leases
517
+ if isinstance(item.get("port"), int)
518
+ }
519
+ for port in range(start, end + 1):
520
+ if port not in used:
521
+ entry = {
522
+ "changeId": change_id,
523
+ "runId": run_id,
524
+ "pid": os.getpid(),
525
+ "port": port,
526
+ "acquiredAt": now_iso(),
527
+ "expiresAt": (
528
+ dt.datetime.now().astimezone() + dt.timedelta(hours=4)
529
+ ).isoformat(timespec="milliseconds"),
530
+ }
531
+ leases.append(entry)
532
+ registry["leases"] = leases
533
+ _write_json(registry_path, registry)
534
+ return {"ok": True, "code": "PORT_LEASED", "port": port, "lease": entry}
535
+ return {
536
+ "ok": False,
537
+ "code": "PORT_RANGE_EXHAUSTED",
538
+ "message": f"no free port in {start}-{end}",
539
+ }
540
+
541
+
542
+ def release_port(
543
+ project_root: Path,
544
+ *,
545
+ change_id: str,
546
+ run_id: str,
547
+ ) -> dict[str, Any]:
548
+ registry_path = project_root / PORTS_REL / "registry.json"
549
+ if not registry_path.is_file():
550
+ return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
551
+ with _exclusive_file_lock(registry_path.with_suffix(".lock")):
552
+ try:
553
+ registry = _read_json(registry_path)
554
+ except (OSError, json.JSONDecodeError) as exc:
555
+ return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": str(exc)}
556
+ leases = registry.get("leases") if isinstance(registry, dict) else None
557
+ if not isinstance(leases, list):
558
+ return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": "leases is not a list"}
559
+ owned = [
560
+ item for item in leases
561
+ if isinstance(item, dict) and str(item.get("changeId")) == change_id
562
+ ]
563
+ if not owned:
564
+ return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
565
+ if any(str(item.get("runId")) != run_id for item in owned):
566
+ return {
567
+ "ok": False,
568
+ "code": "PORT_LEASE_OWNER_MISMATCH",
569
+ "message": "run id does not match port lease owner",
570
+ "holder": owned[0],
571
+ }
572
+ released_ports = [item.get("port") for item in owned]
573
+ registry["leases"] = [item for item in leases if item not in owned]
574
+ _write_json(registry_path, registry)
575
+ return {
576
+ "ok": True,
577
+ "code": "PORT_LEASE_RELEASED",
578
+ "changeId": change_id,
579
+ "ports": released_ports,
580
+ }
581
+
582
+
583
+ def _integration_lock_acquire_locked(
584
+ project_root: Path,
585
+ *,
586
+ run_id: str,
587
+ ttl_seconds: int = 3600,
588
+ ) -> dict[str, Any]:
589
+ path = project_root / INTEGRATION_LOCK_REL
590
+ path.parent.mkdir(parents=True, exist_ok=True)
591
+ now = dt.datetime.now().astimezone()
592
+ payload = {
593
+ "runId": run_id,
594
+ "pid": os.getpid(),
595
+ "acquiredAt": now.isoformat(timespec="milliseconds"),
596
+ "expiresAt": (now + dt.timedelta(seconds=ttl_seconds)).isoformat(
597
+ timespec="milliseconds"
598
+ ),
599
+ }
600
+ if path.is_file():
601
+ try:
602
+ existing = _read_json(path)
603
+ except (OSError, json.JSONDecodeError):
604
+ existing = None
605
+ if isinstance(existing, dict) and not _lease_expired(existing):
606
+ if str(existing.get("runId")) == run_id:
607
+ _write_json(path, payload)
608
+ return {"ok": True, "code": "INTEGRATION_LOCK_REFRESHED", "lock": payload}
609
+ return {
610
+ "ok": False,
611
+ "code": "INTEGRATION_LOCK_HELD",
612
+ "message": "integration lock held by another run",
613
+ "holder": existing,
614
+ }
615
+ _write_json(path, payload)
616
+ return {"ok": True, "code": "INTEGRATION_LOCK_ACQUIRED", "lock": payload}
617
+
618
+
619
+ def integration_lock_acquire(
620
+ project_root: Path,
621
+ *,
622
+ run_id: str,
623
+ ttl_seconds: int = 3600,
624
+ ) -> dict[str, Any]:
625
+ path = project_root / INTEGRATION_LOCK_REL
626
+ with _exclusive_file_lock(path.with_suffix(".lock")):
627
+ return _integration_lock_acquire_locked(
628
+ project_root, run_id=run_id, ttl_seconds=ttl_seconds
629
+ )
630
+
631
+
632
+ def _integration_lock_release_locked(project_root: Path, *, run_id: str) -> dict[str, Any]:
633
+ path = project_root / INTEGRATION_LOCK_REL
634
+ if not path.is_file():
635
+ return {"ok": True, "code": "INTEGRATION_LOCK_ABSENT"}
636
+ try:
637
+ existing = _read_json(path)
638
+ except (OSError, json.JSONDecodeError) as exc:
639
+ return {"ok": False, "code": "INTEGRATION_LOCK_INVALID", "message": str(exc)}
640
+ if not isinstance(existing, dict):
641
+ return {"ok": False, "code": "INTEGRATION_LOCK_INVALID", "message": "not an object"}
642
+ if str(existing.get("runId")) != run_id:
643
+ return {
644
+ "ok": False,
645
+ "code": "INTEGRATION_LOCK_OWNER_MISMATCH",
646
+ "message": "run id does not match lock owner",
647
+ "holder": existing,
648
+ }
649
+ path.unlink(missing_ok=True)
650
+ return {"ok": True, "code": "INTEGRATION_LOCK_RELEASED"}
651
+
652
+
653
+ def integration_lock_release(project_root: Path, *, run_id: str) -> dict[str, Any]:
654
+ path = project_root / INTEGRATION_LOCK_REL
655
+ with _exclusive_file_lock(path.with_suffix(".lock")):
656
+ return _integration_lock_release_locked(project_root, run_id=run_id)
657
+
658
+
659
+ def parse_port_range(raw: str) -> tuple[int, int] | None:
660
+ if "-" not in raw:
661
+ return None
662
+ left, right = raw.split("-", 1)
663
+ try:
664
+ return int(left.strip()), int(right.strip())
665
+ except ValueError:
666
+ return None
667
+
668
+
669
+ def cmd_list(args: argparse.Namespace) -> int:
670
+ project = resolve_main_project_root()
671
+ payload = {
672
+ "ok": True,
673
+ "code": "LISTED",
674
+ "projectRoot": str(project),
675
+ "activeChanges": list_active_changes(project),
676
+ }
677
+ emit(payload, as_json=bool(args.json))
678
+ return 0
679
+
680
+
681
+ def cmd_resolve(args: argparse.Namespace) -> int:
682
+ project = resolve_main_project_root()
683
+ payload = resolve_change(project, args.change)
684
+ if payload.get("ok"):
685
+ emit(payload, as_json=bool(args.json))
686
+ return 0
687
+ return emit_error(
688
+ str(payload.get("code", "RESOLVE_FAILED")),
689
+ str(payload.get("message", "resolve failed")),
690
+ as_json=bool(args.json),
691
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
692
+ )
693
+
694
+
695
+ def cmd_migrate(args: argparse.Namespace) -> int:
696
+ project = resolve_main_project_root()
697
+ if not args.change:
698
+ return emit_error("CHANGE_REQUIRED", "--change is required", as_json=bool(args.json))
699
+ payload = migrate_change(project, args.change)
700
+ if payload.get("ok"):
701
+ emit(payload, as_json=bool(args.json))
702
+ return 0
703
+ return emit_error(
704
+ str(payload.get("code", "MIGRATE_FAILED")),
705
+ str(payload.get("message", "migrate failed")),
706
+ as_json=bool(args.json),
707
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
708
+ )
709
+
710
+
711
+ def cmd_claim(args: argparse.Namespace) -> int:
712
+ project = resolve_main_project_root()
713
+ payload = claim_lease(
714
+ project,
715
+ change_id=args.change,
716
+ phase=args.phase,
717
+ run_id=args.run_id,
718
+ ttl_seconds=int(args.ttl_seconds),
719
+ steal=bool(args.steal),
720
+ )
721
+ if payload.get("ok"):
722
+ emit(payload, as_json=bool(args.json))
723
+ return 0
724
+ return emit_error(
725
+ str(payload.get("code", "CLAIM_FAILED")),
726
+ str(payload.get("message", "claim failed")),
727
+ as_json=bool(args.json),
728
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
729
+ )
730
+
731
+
732
+ def cmd_release(args: argparse.Namespace) -> int:
733
+ project = resolve_main_project_root()
734
+ payload = release_lease(
735
+ project,
736
+ change_id=args.change,
737
+ phase=args.phase,
738
+ run_id=args.run_id,
739
+ )
740
+ if payload.get("ok"):
741
+ emit(payload, as_json=bool(args.json))
742
+ return 0
743
+ return emit_error(
744
+ str(payload.get("code", "RELEASE_FAILED")),
745
+ str(payload.get("message", "release failed")),
746
+ as_json=bool(args.json),
747
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
748
+ )
749
+
750
+
751
+ def cmd_lease_port(args: argparse.Namespace) -> int:
752
+ project = resolve_main_project_root()
753
+ parsed = parse_port_range(args.range)
754
+ if parsed is None:
755
+ return emit_error(
756
+ "INVALID_PORT_RANGE",
757
+ f"expected --range <start-end>, got {args.range!r}",
758
+ as_json=bool(args.json),
759
+ )
760
+ payload = lease_port(
761
+ project,
762
+ change_id=args.change,
763
+ run_id=args.run_id,
764
+ port_range=parsed,
765
+ )
766
+ if payload.get("ok"):
767
+ emit(payload, as_json=bool(args.json))
768
+ return 0
769
+ return emit_error(
770
+ str(payload.get("code", "PORT_LEASE_FAILED")),
771
+ str(payload.get("message", "port lease failed")),
772
+ as_json=bool(args.json),
773
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
774
+ )
775
+
776
+
777
+ def cmd_release_port(args: argparse.Namespace) -> int:
778
+ project = resolve_main_project_root()
779
+ payload = release_port(project, change_id=args.change, run_id=args.run_id)
780
+ if payload.get("ok"):
781
+ emit(payload, as_json=bool(args.json))
782
+ return 0
783
+ return emit_error(
784
+ str(payload.get("code", "PORT_RELEASE_FAILED")),
785
+ str(payload.get("message", "port release failed")),
786
+ as_json=bool(args.json),
787
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
788
+ )
789
+
790
+
791
+ def cmd_integration_lock(args: argparse.Namespace) -> int:
792
+ project = resolve_main_project_root()
793
+ if args.integration_action == "acquire":
794
+ payload = integration_lock_acquire(
795
+ project,
796
+ run_id=args.run_id,
797
+ ttl_seconds=int(args.ttl_seconds),
798
+ )
799
+ else:
800
+ payload = integration_lock_release(project, run_id=args.run_id)
801
+ if payload.get("ok"):
802
+ emit(payload, as_json=bool(args.json))
803
+ return 0
804
+ return emit_error(
805
+ str(payload.get("code", "INTEGRATION_LOCK_FAILED")),
806
+ str(payload.get("message", "integration lock failed")),
807
+ as_json=bool(args.json),
808
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
809
+ )
810
+
811
+
812
+ def build_parser() -> argparse.ArgumentParser:
813
+ parser = argparse.ArgumentParser(prog="harness_change.py")
814
+ parser.add_argument("--json", action="store_true")
815
+ sub = parser.add_subparsers(dest="command_name", required=True)
816
+
817
+ shared = argparse.ArgumentParser(add_help=False)
818
+ shared.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
819
+
820
+ p_list = sub.add_parser("list", parents=[shared])
821
+ p_list.set_defaults(func=cmd_list)
822
+
823
+ p_resolve = sub.add_parser("resolve", parents=[shared])
824
+ p_resolve.add_argument("--change", default=None)
825
+ p_resolve.set_defaults(func=cmd_resolve)
826
+
827
+ p_migrate = sub.add_parser("migrate", parents=[shared])
828
+ p_migrate.add_argument("--change", required=True)
829
+ p_migrate.set_defaults(func=cmd_migrate)
830
+
831
+ p_claim = sub.add_parser("claim", parents=[shared])
832
+ p_claim.add_argument("--change", required=True)
833
+ p_claim.add_argument("--phase", required=True)
834
+ p_claim.add_argument("--run-id", required=True)
835
+ p_claim.add_argument("--ttl-seconds", type=int, default=3600)
836
+ p_claim.add_argument("--steal", action="store_true")
837
+ p_claim.set_defaults(func=cmd_claim)
838
+
839
+ p_release = sub.add_parser("release", parents=[shared])
840
+ p_release.add_argument("--change", required=True)
841
+ p_release.add_argument("--phase", required=True)
842
+ p_release.add_argument("--run-id", required=True)
843
+ p_release.set_defaults(func=cmd_release)
844
+
845
+ p_port = sub.add_parser("lease-port", parents=[shared])
846
+ p_port.add_argument("--change", required=True)
847
+ p_port.add_argument("--run-id", required=True)
848
+ p_port.add_argument("--range", required=True)
849
+ p_port.set_defaults(func=cmd_lease_port)
850
+
851
+ p_port_release = sub.add_parser("release-port", parents=[shared])
852
+ p_port_release.add_argument("--change", required=True)
853
+ p_port_release.add_argument("--run-id", required=True)
854
+ p_port_release.set_defaults(func=cmd_release_port)
855
+
856
+ p_lock = sub.add_parser("integration-lock", parents=[shared])
857
+ p_lock.add_argument("integration_action", choices=["acquire", "release"])
858
+ p_lock.add_argument("--run-id", required=True)
859
+ p_lock.add_argument("--ttl-seconds", type=int, default=3600)
860
+ p_lock.set_defaults(func=cmd_integration_lock)
861
+
862
+ return parser
863
+
864
+
865
+ def main(argv: list[str] | None = None) -> int:
866
+ parser = build_parser()
867
+ args = parser.parse_args(argv)
868
+ return int(args.func(args))
869
+
870
+
871
+ if __name__ == "__main__":
872
+ raise SystemExit(main())