@hunter-harness/workflow-harness 0.2.20 → 0.2.22

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/CONTEXT.md +20 -0
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-archive/checklist.md +14 -0
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/checklist.md +6 -0
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-submit/checklist.md +12 -0
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-test/checklist.md +6 -0
  18. package/harness/bundles/general/claude-code/protocols/model-routing-protocol.md +22 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +606 -28
  20. package/harness/bundles/general/claude-code/scripts/harness_environment.py +459 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_events.py +252 -15
  22. package/harness/bundles/general/claude-code/scripts/harness_integration.py +34 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +127 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_migration_head.py +220 -0
  25. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  26. package/harness/bundles/general/codebuddy/CONTEXT.md +20 -0
  27. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +14 -0
  29. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  30. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-run/checklist.md +6 -0
  37. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +12 -0
  39. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-test/checklist.md +6 -0
  42. package/harness/bundles/general/codebuddy/protocols/model-routing-protocol.md +22 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +606 -28
  44. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +459 -0
  45. package/harness/bundles/general/codebuddy/scripts/harness_events.py +252 -15
  46. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +34 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +127 -0
  48. package/harness/bundles/general/codebuddy/scripts/harness_migration_head.py +220 -0
  49. package/harness/bundles/general/codex/.harness-build.json +1 -1
  50. package/harness/bundles/general/codex/CONTEXT.md +20 -0
  51. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-archive/checklist.md +14 -0
  53. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -3
  54. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-run/checklist.md +6 -0
  61. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-submit/checklist.md +12 -0
  63. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-test/checklist.md +6 -0
  66. package/harness/bundles/general/codex/protocols/model-routing-protocol.md +22 -0
  67. package/harness/bundles/general/codex/scripts/harness_archive.py +606 -28
  68. package/harness/bundles/general/codex/scripts/harness_environment.py +459 -0
  69. package/harness/bundles/general/codex/scripts/harness_events.py +252 -15
  70. package/harness/bundles/general/codex/scripts/harness_integration.py +34 -0
  71. package/harness/bundles/general/codex/scripts/harness_ledger.py +127 -0
  72. package/harness/bundles/general/codex/scripts/harness_migration_head.py +220 -0
  73. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  74. package/harness/bundles/general/cursor/CONTEXT.md +20 -0
  75. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-archive/checklist.md +14 -0
  77. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -3
  78. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  83. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-run/checklist.md +6 -0
  85. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  86. package/harness/bundles/general/cursor/harness-submit/checklist.md +12 -0
  87. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-test/checklist.md +6 -0
  90. package/harness/bundles/general/cursor/protocols/model-routing-protocol.md +22 -0
  91. package/harness/bundles/general/cursor/scripts/harness_archive.py +606 -28
  92. package/harness/bundles/general/cursor/scripts/harness_environment.py +459 -0
  93. package/harness/bundles/general/cursor/scripts/harness_events.py +252 -15
  94. package/harness/bundles/general/cursor/scripts/harness_integration.py +34 -0
  95. package/harness/bundles/general/cursor/scripts/harness_ledger.py +127 -0
  96. package/harness/bundles/general/cursor/scripts/harness_migration_head.py +220 -0
  97. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  98. package/harness/bundles/java/claude-code/CONTEXT.md +20 -0
  99. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-archive/checklist.md +14 -0
  102. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  103. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  106. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  110. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-submit/checklist.md +12 -0
  112. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  113. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/protocols/model-routing-protocol.md +22 -0
  115. package/harness/bundles/java/claude-code/scripts/harness_archive.py +606 -28
  116. package/harness/bundles/java/claude-code/scripts/harness_environment.py +459 -0
  117. package/harness/bundles/java/claude-code/scripts/harness_events.py +252 -15
  118. package/harness/bundles/java/claude-code/scripts/harness_integration.py +34 -0
  119. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +127 -0
  120. package/harness/bundles/java/claude-code/scripts/harness_migration_head.py +220 -0
  121. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  122. package/harness/bundles/java/codebuddy/CONTEXT.md +20 -0
  123. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +14 -0
  126. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  127. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  130. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  131. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +12 -0
  136. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  138. package/harness/bundles/java/codebuddy/protocols/model-routing-protocol.md +22 -0
  139. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +606 -28
  140. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +459 -0
  141. package/harness/bundles/java/codebuddy/scripts/harness_events.py +252 -15
  142. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +34 -0
  143. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +127 -0
  144. package/harness/bundles/java/codebuddy/scripts/harness_migration_head.py +220 -0
  145. package/harness/bundles/java/codex/.harness-build.json +1 -1
  146. package/harness/bundles/java/codex/CONTEXT.md +20 -0
  147. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  149. package/harness/bundles/java/codex/harness-archive/checklist.md +14 -0
  150. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -3
  151. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  153. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  154. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  156. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  157. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  158. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  159. package/harness/bundles/java/codex/harness-submit/checklist.md +12 -0
  160. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  161. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/protocols/model-routing-protocol.md +22 -0
  163. package/harness/bundles/java/codex/scripts/harness_archive.py +606 -28
  164. package/harness/bundles/java/codex/scripts/harness_environment.py +459 -0
  165. package/harness/bundles/java/codex/scripts/harness_events.py +252 -15
  166. package/harness/bundles/java/codex/scripts/harness_integration.py +34 -0
  167. package/harness/bundles/java/codex/scripts/harness_ledger.py +127 -0
  168. package/harness/bundles/java/codex/scripts/harness_migration_head.py +220 -0
  169. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  170. package/harness/bundles/java/cursor/CONTEXT.md +20 -0
  171. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  172. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  173. package/harness/bundles/java/cursor/harness-archive/checklist.md +14 -0
  174. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -3
  175. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  177. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  178. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  179. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  180. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  181. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  182. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  183. package/harness/bundles/java/cursor/harness-submit/checklist.md +12 -0
  184. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  185. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  186. package/harness/bundles/java/cursor/protocols/model-routing-protocol.md +22 -0
  187. package/harness/bundles/java/cursor/scripts/harness_archive.py +606 -28
  188. package/harness/bundles/java/cursor/scripts/harness_environment.py +459 -0
  189. package/harness/bundles/java/cursor/scripts/harness_events.py +252 -15
  190. package/harness/bundles/java/cursor/scripts/harness_integration.py +34 -0
  191. package/harness/bundles/java/cursor/scripts/harness_ledger.py +127 -0
  192. package/harness/bundles/java/cursor/scripts/harness_migration_head.py +220 -0
  193. package/harness/manifests/general/claude-code.json +33 -21
  194. package/harness/manifests/general/codebuddy.json +33 -21
  195. package/harness/manifests/general/codex.json +33 -21
  196. package/harness/manifests/general/cursor.json +33 -21
  197. package/harness/manifests/java/claude-code.json +33 -21
  198. package/harness/manifests/java/codebuddy.json +33 -21
  199. package/harness/manifests/java/codex.json +33 -21
  200. package/harness/manifests/java/cursor.json +33 -21
  201. package/hunter-workflow-family.json +1 -1
  202. package/package.json +1 -1
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal environment fingerprint + lease manager (IA-3 Wave-A).
3
+
4
+ Contract: prepare → fingerprint → acquire lease → reset/clone → run → record → release.
5
+ This module implements fingerprint/lease gates with a file-backed stub suitable for
6
+ unit tests. Full Docker orchestration is out of scope for Wave-A.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import hashlib
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ if hasattr(sys.stdout, "reconfigure"):
21
+ sys.stdout.reconfigure(encoding="utf-8")
22
+ if hasattr(sys.stderr, "reconfigure"):
23
+ sys.stderr.reconfigure(encoding="utf-8")
24
+
25
+
26
+ FINGERPRINT_CANDIDATES = (
27
+ "package-lock.json",
28
+ "npm-shrinkwrap.json",
29
+ "pnpm-lock.yaml",
30
+ "yarn.lock",
31
+ "poetry.lock",
32
+ "Pipfile.lock",
33
+ "requirements.txt",
34
+ "requirements.lock",
35
+ "Cargo.lock",
36
+ "go.sum",
37
+ "composer.lock",
38
+ ".nvmrc",
39
+ ".node-version",
40
+ ".python-version",
41
+ "Dockerfile",
42
+ "docker-compose.yml",
43
+ "docker-compose.yaml",
44
+ "compose.yml",
45
+ "compose.yaml",
46
+ )
47
+
48
+
49
+ def now_iso() -> str:
50
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
51
+
52
+
53
+ def emit_json(payload: dict[str, Any], *, ok: bool = True) -> int:
54
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
55
+ return 0 if ok else 1
56
+
57
+
58
+ def _sha256_bytes(data: bytes) -> str:
59
+ return hashlib.sha256(data).hexdigest()
60
+
61
+
62
+ def _sha256_file(path: Path) -> str:
63
+ h = hashlib.sha256()
64
+ with path.open("rb") as fh:
65
+ for chunk in iter(lambda: fh.read(65536), b""):
66
+ h.update(chunk)
67
+ return h.hexdigest()
68
+
69
+
70
+ def _read_json(path: Path) -> Any:
71
+ return json.loads(path.read_text(encoding="utf-8-sig"))
72
+
73
+
74
+ def _write_json(path: Path, data: Any) -> None:
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ path.write_text(
77
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n",
78
+ encoding="utf-8",
79
+ newline="\n",
80
+ )
81
+
82
+
83
+ def default_lease_root(project: Path) -> Path:
84
+ return project / ".harness" / "runtime" / "env-leases"
85
+
86
+
87
+ def _migration_head_token(project: Path) -> str:
88
+ cfg = project / ".harness" / "config" / "migration-head.json"
89
+ if cfg.is_file():
90
+ try:
91
+ data = _read_json(cfg)
92
+ if isinstance(data, dict):
93
+ head = data.get("head") or data.get("revision") or data.get("version")
94
+ if head:
95
+ return f"migration-head:{head}"
96
+ except (OSError, json.JSONDecodeError, TypeError):
97
+ pass
98
+ versions = project / "alembic" / "versions"
99
+ if versions.is_dir():
100
+ names = sorted(p.name for p in versions.glob("*.py"))
101
+ if names:
102
+ return "alembic:" + ",".join(names[-3:])
103
+ return "migration-head:none"
104
+
105
+
106
+ def compute_environment_hash(
107
+ project: Path,
108
+ *,
109
+ extra_paths: list[str] | None = None,
110
+ ) -> str:
111
+ """Stable hash over lockfiles/toolchain/compose/migration head inputs."""
112
+ project = project.resolve()
113
+ lines: list[str] = []
114
+ seen: set[str] = set()
115
+ for rel in list(FINGERPRINT_CANDIDATES) + list(extra_paths or []):
116
+ if rel in seen:
117
+ continue
118
+ seen.add(rel)
119
+ path = project / rel
120
+ if path.is_file():
121
+ lines.append(f"file:{rel}:{_sha256_file(path)}")
122
+ lines.append(_migration_head_token(project))
123
+ # Desensitized runner hint (presence only).
124
+ for runner in ("playwright.config.ts", "playwright.config.js", "vitest.config.ts"):
125
+ if (project / runner).is_file():
126
+ lines.append(f"runner:{runner}:present")
127
+ payload = "\n".join(sorted(lines)).encode("utf-8")
128
+ return "sha256:" + _sha256_bytes(payload)
129
+
130
+
131
+ def _lease_path(lease_root: Path, stack_id: str) -> Path:
132
+ safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in stack_id)
133
+ return lease_root / f"{safe}.json"
134
+
135
+
136
+ def list_leases(lease_root: Path) -> list[dict[str, Any]]:
137
+ if not lease_root.is_dir():
138
+ return []
139
+ out: list[dict[str, Any]] = []
140
+ for path in sorted(lease_root.glob("*.json")):
141
+ try:
142
+ data = _read_json(path)
143
+ except (OSError, json.JSONDecodeError):
144
+ continue
145
+ if isinstance(data, dict):
146
+ out.append(data)
147
+ return out
148
+
149
+
150
+ def require_writable_lease(
151
+ project: Path,
152
+ *,
153
+ change_id: str,
154
+ stack_id: str,
155
+ lease_root: Path | None = None,
156
+ ) -> dict[str, Any]:
157
+ """Gate: a writable stack may be used only with an active matching lease."""
158
+ root = lease_root or default_lease_root(project)
159
+ path = _lease_path(root, stack_id)
160
+ if not path.is_file():
161
+ return {
162
+ "ok": False,
163
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
164
+ "message": f"no lease for stack={stack_id}; acquire before writable use",
165
+ "changeId": change_id,
166
+ "stackId": stack_id,
167
+ }
168
+ try:
169
+ lease = _read_json(path)
170
+ except (OSError, json.JSONDecodeError) as exc:
171
+ return {
172
+ "ok": False,
173
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
174
+ "message": f"lease unreadable: {exc}",
175
+ "changeId": change_id,
176
+ "stackId": stack_id,
177
+ }
178
+ if str(lease.get("changeId") or "") != change_id:
179
+ return {
180
+ "ok": False,
181
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
182
+ "message": (
183
+ f"stack={stack_id} leased by change={lease.get('changeId')}; "
184
+ f"requested change={change_id}"
185
+ ),
186
+ "changeId": change_id,
187
+ "stackId": stack_id,
188
+ "holder": lease.get("changeId"),
189
+ }
190
+ # Review Y2: expired leases must not remain writable.
191
+ expires_raw = lease.get("expiresAt")
192
+ if isinstance(expires_raw, str) and expires_raw.strip():
193
+ try:
194
+ raw = expires_raw.strip().replace("Z", "+00:00")
195
+ expires_at = dt.datetime.fromisoformat(raw)
196
+ # Normalize both sides to UTC so naive/aware/offset mixes compare safely.
197
+ if expires_at.tzinfo is None:
198
+ expires_at = expires_at.replace(tzinfo=dt.timezone.utc)
199
+ else:
200
+ expires_at = expires_at.astimezone(dt.timezone.utc)
201
+ now_utc = dt.datetime.now(dt.timezone.utc)
202
+ if now_utc >= expires_at:
203
+ return {
204
+ "ok": False,
205
+ "code": "ENVIRONMENT_LEASE_EXPIRED",
206
+ "message": (
207
+ f"lease for stack={stack_id} expired at {expires_raw}; "
208
+ "re-acquire before writable use"
209
+ ),
210
+ "changeId": change_id,
211
+ "stackId": stack_id,
212
+ "expiresAt": expires_raw,
213
+ }
214
+ except ValueError:
215
+ return {
216
+ "ok": False,
217
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
218
+ "message": f"lease expiresAt unreadable: {expires_raw}",
219
+ "changeId": change_id,
220
+ "stackId": stack_id,
221
+ }
222
+ return {"ok": True, "code": "LEASE_HELD", "lease": lease}
223
+
224
+
225
+ def acquire_lease(
226
+ project: Path,
227
+ *,
228
+ change_id: str,
229
+ stack_id: str,
230
+ environment_hash: str,
231
+ lease_root: Path | None = None,
232
+ writable_volumes: list[str] | None = None,
233
+ ttl_seconds: int = 3600,
234
+ ) -> dict[str, Any]:
235
+ """Acquire a lease; reject cross-change sharing of the same writable volume."""
236
+ root = lease_root or default_lease_root(project)
237
+ root.mkdir(parents=True, exist_ok=True)
238
+ volumes = [str(v).replace("\\", "/") for v in (writable_volumes or [])]
239
+ for existing in list_leases(root):
240
+ holder = str(existing.get("changeId") or "")
241
+ if holder == change_id:
242
+ continue
243
+ other_vols = {
244
+ str(v).replace("\\", "/")
245
+ for v in (existing.get("writableVolumes") or [])
246
+ }
247
+ overlap = sorted(set(volumes) & other_vols)
248
+ if overlap:
249
+ return {
250
+ "ok": False,
251
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
252
+ "message": (
253
+ f"writable volume(s) {overlap} already held by change={holder}"
254
+ ),
255
+ "changeId": change_id,
256
+ "holder": holder,
257
+ "overlap": overlap,
258
+ }
259
+ if existing.get("stackId") == stack_id and holder and holder != change_id:
260
+ return {
261
+ "ok": False,
262
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
263
+ "message": f"stack={stack_id} already leased by change={holder}",
264
+ "changeId": change_id,
265
+ "holder": holder,
266
+ }
267
+
268
+ path = _lease_path(root, stack_id)
269
+ if path.is_file():
270
+ try:
271
+ current = _read_json(path)
272
+ except (OSError, json.JSONDecodeError):
273
+ current = {}
274
+ if str(current.get("changeId") or "") not in {"", change_id}:
275
+ return {
276
+ "ok": False,
277
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
278
+ "message": (
279
+ f"stack={stack_id} already leased by change={current.get('changeId')}"
280
+ ),
281
+ "changeId": change_id,
282
+ "holder": current.get("changeId"),
283
+ }
284
+
285
+ started = dt.datetime.now().astimezone()
286
+ expires = started + dt.timedelta(seconds=max(60, int(ttl_seconds)))
287
+ lease = {
288
+ "schemaVersion": 1,
289
+ "changeId": change_id,
290
+ "stackId": stack_id,
291
+ "environmentHash": environment_hash,
292
+ "writableVolumes": volumes,
293
+ "acquiredAt": started.isoformat(timespec="seconds"),
294
+ "expiresAt": expires.isoformat(timespec="seconds"),
295
+ "projectRoot": str(project.resolve()),
296
+ }
297
+ _write_json(path, lease)
298
+ return {"ok": True, "code": "LEASE_ACQUIRED", "lease": lease, "path": str(path)}
299
+
300
+
301
+ def release_lease(
302
+ project: Path,
303
+ *,
304
+ change_id: str,
305
+ stack_id: str,
306
+ lease_root: Path | None = None,
307
+ ) -> dict[str, Any]:
308
+ root = lease_root or default_lease_root(project)
309
+ path = _lease_path(root, stack_id)
310
+ if not path.is_file():
311
+ return {
312
+ "ok": True,
313
+ "code": "LEASE_ABSENT",
314
+ "message": "nothing to release",
315
+ "changeId": change_id,
316
+ "stackId": stack_id,
317
+ }
318
+ try:
319
+ lease = _read_json(path)
320
+ except (OSError, json.JSONDecodeError) as exc:
321
+ return {"ok": False, "code": "LEASE_UNREADABLE", "message": str(exc)}
322
+ if str(lease.get("changeId") or "") != change_id:
323
+ return {
324
+ "ok": False,
325
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
326
+ "message": "cannot release lease owned by another change",
327
+ "holder": lease.get("changeId"),
328
+ "changeId": change_id,
329
+ }
330
+ path.unlink(missing_ok=True)
331
+ return {"ok": True, "code": "LEASE_RELEASED", "changeId": change_id, "stackId": stack_id}
332
+
333
+
334
+ def cmd_fingerprint(args: argparse.Namespace) -> int:
335
+ project = Path(args.project).resolve()
336
+ digest = compute_environment_hash(project)
337
+ return emit_json(
338
+ {
339
+ "ok": True,
340
+ "action": "fingerprint",
341
+ "projectRoot": str(project),
342
+ "environmentHash": digest,
343
+ }
344
+ )
345
+
346
+
347
+ def cmd_acquire(args: argparse.Namespace) -> int:
348
+ project = Path(args.project).resolve()
349
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
350
+ env_hash = args.environment_hash or compute_environment_hash(project)
351
+ volumes = [v for v in (args.writable_volume or []) if v]
352
+ result = acquire_lease(
353
+ project,
354
+ change_id=args.change,
355
+ stack_id=args.stack_id,
356
+ environment_hash=env_hash,
357
+ lease_root=root,
358
+ writable_volumes=volumes,
359
+ ttl_seconds=int(args.ttl_seconds or 3600),
360
+ )
361
+ return emit_json(result, ok=bool(result.get("ok")))
362
+
363
+
364
+ def cmd_release(args: argparse.Namespace) -> int:
365
+ project = Path(args.project).resolve()
366
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
367
+ result = release_lease(
368
+ project,
369
+ change_id=args.change,
370
+ stack_id=args.stack_id,
371
+ lease_root=root,
372
+ )
373
+ return emit_json(result, ok=bool(result.get("ok")))
374
+
375
+
376
+ def cmd_require(args: argparse.Namespace) -> int:
377
+ project = Path(args.project).resolve()
378
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
379
+ result = require_writable_lease(
380
+ project,
381
+ change_id=args.change,
382
+ stack_id=args.stack_id,
383
+ lease_root=root,
384
+ )
385
+ return emit_json(result, ok=bool(result.get("ok")))
386
+
387
+
388
+ def cmd_status(args: argparse.Namespace) -> int:
389
+ project = Path(args.project).resolve()
390
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
391
+ return emit_json(
392
+ {
393
+ "ok": True,
394
+ "action": "status",
395
+ "projectRoot": str(project),
396
+ "environmentHash": compute_environment_hash(project),
397
+ "leases": list_leases(root),
398
+ "leaseRoot": str(root),
399
+ "contract": [
400
+ "prepare",
401
+ "fingerprint",
402
+ "acquire",
403
+ "reset/clone",
404
+ "run",
405
+ "record",
406
+ "release",
407
+ ],
408
+ }
409
+ )
410
+
411
+
412
+ def build_parser() -> argparse.ArgumentParser:
413
+ parser = argparse.ArgumentParser(prog="harness_environment.py")
414
+ sub = parser.add_subparsers(dest="command", required=True)
415
+
416
+ fp = sub.add_parser("fingerprint")
417
+ fp.add_argument("--project", required=True)
418
+ fp.set_defaults(func=cmd_fingerprint)
419
+
420
+ ac = sub.add_parser("acquire")
421
+ ac.add_argument("--project", required=True)
422
+ ac.add_argument("--change", required=True)
423
+ ac.add_argument("--stack-id", required=True)
424
+ ac.add_argument("--environment-hash")
425
+ ac.add_argument("--lease-root")
426
+ ac.add_argument("--writable-volume", action="append", default=[])
427
+ ac.add_argument("--ttl-seconds", type=int, default=3600)
428
+ ac.set_defaults(func=cmd_acquire)
429
+
430
+ rel = sub.add_parser("release")
431
+ rel.add_argument("--project", required=True)
432
+ rel.add_argument("--change", required=True)
433
+ rel.add_argument("--stack-id", required=True)
434
+ rel.add_argument("--lease-root")
435
+ rel.set_defaults(func=cmd_release)
436
+
437
+ req = sub.add_parser("require")
438
+ req.add_argument("--project", required=True)
439
+ req.add_argument("--change", required=True)
440
+ req.add_argument("--stack-id", required=True)
441
+ req.add_argument("--lease-root")
442
+ req.set_defaults(func=cmd_require)
443
+
444
+ st = sub.add_parser("status")
445
+ st.add_argument("--project", required=True)
446
+ st.add_argument("--lease-root")
447
+ st.set_defaults(func=cmd_status)
448
+
449
+ return parser
450
+
451
+
452
+ def main(argv: list[str] | None = None) -> int:
453
+ parser = build_parser()
454
+ args = parser.parse_args(argv)
455
+ return int(args.func(args))
456
+
457
+
458
+ if __name__ == "__main__":
459
+ raise SystemExit(main())