@hunter-harness/workflow-harness 0.2.6 → 0.2.7

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