@hunter-harness/workflow-harness 0.2.21 → 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 (158) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/checklist.md +14 -0
  4. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/checklist.md +6 -0
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/checklist.md +6 -0
  16. package/harness/bundles/general/claude-code/scripts/harness_archive.py +606 -28
  17. package/harness/bundles/general/claude-code/scripts/harness_environment.py +459 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_events.py +93 -14
  19. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  20. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +14 -0
  22. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  23. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +6 -0
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-test/checklist.md +6 -0
  34. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +606 -28
  35. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +459 -0
  36. package/harness/bundles/general/codebuddy/scripts/harness_events.py +93 -14
  37. package/harness/bundles/general/codex/.harness-build.json +1 -1
  38. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-archive/checklist.md +14 -0
  40. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -3
  41. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-submit/checklist.md +6 -0
  49. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-test/checklist.md +6 -0
  52. package/harness/bundles/general/codex/scripts/harness_archive.py +606 -28
  53. package/harness/bundles/general/codex/scripts/harness_environment.py +459 -0
  54. package/harness/bundles/general/codex/scripts/harness_events.py +93 -14
  55. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  56. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-archive/checklist.md +14 -0
  58. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -3
  59. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-submit/checklist.md +6 -0
  67. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  68. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-test/checklist.md +6 -0
  70. package/harness/bundles/general/cursor/scripts/harness_archive.py +606 -28
  71. package/harness/bundles/general/cursor/scripts/harness_environment.py +459 -0
  72. package/harness/bundles/general/cursor/scripts/harness_events.py +93 -14
  73. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  74. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  75. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  76. package/harness/bundles/java/claude-code/harness-archive/checklist.md +14 -0
  77. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  78. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  79. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  80. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  81. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  82. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  84. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  85. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  86. package/harness/bundles/java/claude-code/harness-submit/checklist.md +6 -0
  87. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  88. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  89. package/harness/bundles/java/claude-code/scripts/harness_archive.py +606 -28
  90. package/harness/bundles/java/claude-code/scripts/harness_environment.py +459 -0
  91. package/harness/bundles/java/claude-code/scripts/harness_events.py +93 -14
  92. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  93. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  94. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  95. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +14 -0
  96. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  97. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  98. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  99. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  100. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  101. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  103. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  104. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  105. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +6 -0
  106. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  107. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  108. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +606 -28
  109. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +459 -0
  110. package/harness/bundles/java/codebuddy/scripts/harness_events.py +93 -14
  111. package/harness/bundles/java/codex/.harness-build.json +1 -1
  112. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  113. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  114. package/harness/bundles/java/codex/harness-archive/checklist.md +14 -0
  115. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -3
  116. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  117. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  118. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  119. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  120. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  122. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  123. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  124. package/harness/bundles/java/codex/harness-submit/checklist.md +6 -0
  125. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  126. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  127. package/harness/bundles/java/codex/scripts/harness_archive.py +606 -28
  128. package/harness/bundles/java/codex/scripts/harness_environment.py +459 -0
  129. package/harness/bundles/java/codex/scripts/harness_events.py +93 -14
  130. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  131. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  132. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  133. package/harness/bundles/java/cursor/harness-archive/checklist.md +14 -0
  134. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -3
  135. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  136. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  137. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  138. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  139. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  140. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  141. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  142. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  143. package/harness/bundles/java/cursor/harness-submit/checklist.md +6 -0
  144. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  146. package/harness/bundles/java/cursor/scripts/harness_archive.py +606 -28
  147. package/harness/bundles/java/cursor/scripts/harness_environment.py +459 -0
  148. package/harness/bundles/java/cursor/scripts/harness_events.py +93 -14
  149. package/harness/manifests/general/claude-code.json +21 -17
  150. package/harness/manifests/general/codebuddy.json +21 -17
  151. package/harness/manifests/general/codex.json +21 -17
  152. package/harness/manifests/general/cursor.json +21 -17
  153. package/harness/manifests/java/claude-code.json +22 -18
  154. package/harness/manifests/java/codebuddy.json +22 -18
  155. package/harness/manifests/java/codex.json +22 -18
  156. package/harness/manifests/java/cursor.json +22 -18
  157. package/hunter-workflow-family.json +1 -1
  158. 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())
@@ -968,28 +968,89 @@ def late_event_stats(phase_events: list[dict[str, Any]]) -> dict[str, int]:
968
968
  return {"lateEventCount": len(late_stamps), "lateEventSpanMs": span}
969
969
 
970
970
 
971
- def attempt_invocations(phase_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
972
- """Per-attempt invocation view: attempt, status, durationMs (RET-22)."""
971
+ def _seal_timestamp_for_attempt(
972
+ events_in: list[dict[str, Any]],
973
+ *,
974
+ cutoff_ts: str | None,
975
+ next_attempt_start: Any = None,
976
+ ) -> tuple[Any, str | None]:
977
+ """Pick INCOMPLETE seal time: next start, recovery, or report cutoff."""
978
+ recovery_ts = None
979
+ for event in events_in:
980
+ if str(event.get("type") or "").lower() in {
981
+ "recovery",
982
+ "phase.recovery",
983
+ "attempt.recovery",
984
+ }:
985
+ recovery_ts = event.get("timestamp") or recovery_ts
986
+ if next_attempt_start:
987
+ return next_attempt_start, "INCOMPLETE"
988
+ if recovery_ts:
989
+ return recovery_ts, "INCOMPLETE"
990
+ if cutoff_ts:
991
+ return cutoff_ts, "INCOMPLETE"
992
+ # Fall back to last event timestamp so wall clock is not silently dropped.
993
+ for event in reversed(events_in):
994
+ if event.get("timestamp"):
995
+ return event.get("timestamp"), "INCOMPLETE"
996
+ return None, "INCOMPLETE"
997
+
998
+
999
+ def attempt_invocations(
1000
+ phase_events: list[dict[str, Any]],
1001
+ *,
1002
+ cutoff_ts: str | None = None,
1003
+ ) -> list[dict[str, Any]]:
1004
+ """Per-attempt invocation view: attempt, status, durationMs (RET-22 / IA-2).
1005
+
1006
+ Closed attempts keep phase.end status. Unclosed attempts are sealed as
1007
+ INCOMPLETE at the next attempt start, an explicit recovery event, or cutoff.
1008
+ """
1009
+ attempts = split_phase_attempts(phase_events)
973
1010
  invocations: list[dict[str, Any]] = []
974
- for attempt in split_phase_attempts(phase_events):
1011
+ for index, attempt in enumerate(attempts):
975
1012
  events_in = attempt.get("events") or []
976
1013
  start_ts = None
977
1014
  end_ts = None
978
1015
  status = None
1016
+ closed_by_end = False
979
1017
  for event in events_in:
980
1018
  if event.get("type") == "phase.start" and start_ts is None:
981
1019
  start_ts = event.get("timestamp")
982
1020
  elif event.get("type") == "phase.end":
983
1021
  end_ts = event.get("timestamp")
984
- status = event.get("status", status)
1022
+ if "status" in event:
1023
+ status = event.get("status")
1024
+ closed_by_end = True
1025
+ sealed_incomplete = False
1026
+ if not closed_by_end:
1027
+ next_start = None
1028
+ if index + 1 < len(attempts):
1029
+ for event in attempts[index + 1].get("events") or []:
1030
+ if event.get("type") == "phase.start" and event.get("timestamp"):
1031
+ next_start = event.get("timestamp")
1032
+ break
1033
+ seal_ts, seal_status = _seal_timestamp_for_attempt(
1034
+ events_in,
1035
+ cutoff_ts=cutoff_ts,
1036
+ next_attempt_start=next_start,
1037
+ )
1038
+ if seal_ts is not None:
1039
+ end_ts = seal_ts
1040
+ status = seal_status
1041
+ sealed_incomplete = True
985
1042
  duration = None
986
1043
  if start_ts and end_ts:
987
1044
  duration = duration_ms_between(start_ts, end_ts)
1045
+ # Active execution counts phase.end-closed attempts only (INCOMPLETE seals excluded).
1046
+ active_eligible = closed_by_end and not sealed_incomplete
988
1047
  invocations.append(
989
1048
  {
990
1049
  "attempt": attempt.get("attempt"),
991
1050
  "status": status,
992
1051
  "durationMs": duration,
1052
+ "activeEligible": active_eligible,
1053
+ "sealedIncomplete": sealed_incomplete,
993
1054
  }
994
1055
  )
995
1056
  return invocations
@@ -1005,30 +1066,48 @@ def phase_final_state(phase_events: list[dict[str, Any]]) -> dict[str, Any]:
1005
1066
  "attempt": latest.get("attempt"),
1006
1067
  "status": latest.get("status"),
1007
1068
  "durationMs": latest.get("durationMs"),
1008
- "closed": latest.get("status") is not None,
1069
+ "closed": latest.get("status") is not None and latest.get("status") != "INCOMPLETE",
1009
1070
  }
1010
1071
 
1011
1072
 
1012
- def canonical_phase_timing(phase_events: list[dict[str, Any]]) -> dict[str, Any]:
1013
- """Single reducer for every duration view (RET-20).
1073
+ def canonical_phase_timing(
1074
+ phase_events: list[dict[str, Any]],
1075
+ *,
1076
+ cutoff_ts: str | None = None,
1077
+ ) -> dict[str, Any]:
1078
+ """Single reducer for every duration view (RET-20 / IA-2).
1014
1079
 
1015
- activeExecutionMs: phase.start matching phase.end (closed contract).
1016
- wallClockSpanMs: first → last timestamp in the bucket, late events included.
1080
+ activeExecutionMs: sum of closed attempt start→end only (INCOMPLETE excluded).
1081
+ wallClockSpanMs: first → last/cutoff timestamp; unclosed attempts retain span.
1017
1082
  """
1083
+ invocations = attempt_invocations(phase_events, cutoff_ts=cutoff_ts)
1018
1084
  stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
1019
- invocation_durations = [
1085
+ # Cutoff seals INCOMPLETE attempts but must not inflate stage wall by itself.
1086
+ # Include cutoff in the wall span only when it sealed an otherwise-open attempt.
1087
+ if cutoff_ts and any(inv.get("sealedIncomplete") for inv in invocations):
1088
+ stamps = list(stamps) + [cutoff_ts]
1089
+ closed_durations = [
1020
1090
  invocation["durationMs"]
1021
- for invocation in attempt_invocations(phase_events)
1022
- if invocation.get("durationMs") is not None
1091
+ for invocation in invocations
1092
+ if invocation.get("activeEligible") and invocation.get("durationMs") is not None
1023
1093
  ]
1024
- active = sum(invocation_durations) if invocation_durations else None
1094
+ active = sum(closed_durations) if closed_durations else (0 if invocations else None)
1025
1095
  late = late_event_stats(phase_events)
1026
- wall = duration_ms_between(stamps[0], stamps[-1]) if len(stamps) >= 2 else active
1096
+ if len(stamps) >= 2:
1097
+ wall = duration_ms_between(stamps[0], stamps[-1])
1098
+ else:
1099
+ wall = active
1100
+ unclosed = sum(
1101
+ 1
1102
+ for invocation in invocations
1103
+ if invocation.get("sealedIncomplete") or invocation.get("status") == "INCOMPLETE"
1104
+ )
1027
1105
  return {
1028
1106
  "activeExecutionMs": active,
1029
1107
  "wallClockSpanMs": wall,
1030
1108
  "lateEventCount": late["lateEventCount"],
1031
1109
  "lateEventSpanMs": late["lateEventSpanMs"],
1110
+ "unclosedAttemptCount": unclosed,
1032
1111
  }
1033
1112
 
1034
1113
 
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "none",
4
4
  "agent": "cursor",
5
- "coreHash": "cd03d22b60760428"
5
+ "coreHash": "36597f7648520b90"
6
6
  }
@@ -3,7 +3,7 @@ name: harness-archive
3
3
  description: 归档所有变更产出(计划/测试报告/审查/SQL/API文档)到
4
4
  .harness/archive/,含归档元数据和可视化最终总结。使用场景:归档、完成归档、收尾、打包产出物
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=cd03d22b60760428; overlay=none; agent=cursor; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=36597f7648520b90; overlay=none; agent=cursor; do not edit -->
7
7
  # harness-archive — 变更归档
8
8
 
9
9
  ## Purpose
@@ -14,6 +14,20 @@ description: harness-archive 的归档前检查项和归档后验证项。仅在
14
14
  - before/after checksum 不一致时,不得删除原目录。
15
15
  - 默认渲染器 `templates/render-summary.mjs`(finalize 内嵌调用)。
16
16
 
17
+ ### Wave-A 状态机与身份(IA-1 / IA-4)
18
+
19
+ 硬顺序:feature frozen → local gates → product candidate CI → **CI green** → merge → authoritative CI → archive product candidate → archive-only governance → release。
20
+
21
+ - [ ] `evidence/product-candidate-ci.json`(或等价 ledger 字段)`conclusion=success`,含 `runUrl` + `commit`;否则 `PRODUCT_CI_NOT_GREEN` 阻断
22
+ - [ ] summary/identity 含 `productCommit` / `productTreeHash` / `archiveCommit`;`productTreeHash` 排除 `.harness/**`
23
+ - [ ] 产品输入在 archive 后变化 → `ARCHIVE_EVIDENCE_REOPEN_REQUIRED`,旧 archive 不可作发布证据
24
+ - [ ] Manifest:coverage 字节在最终 snapshot 后不得静默漂移;后写报告必须 `exclusionReasons`,禁止假绿 `checksumStatus=OK`
25
+
26
+ ### Environment 合同(IA-3)
27
+
28
+ - [ ] 使用可写环境栈前:`harness_environment.py fingerprint` → `acquire` lease → run → `release`
29
+ - [ ] 租约落在主仓 `.harness/runtime/env-leases/`;跨 change 默认不得共享可写 volume
30
+
17
31
  ## 归档前检查(Phase 1)
18
32
 
19
33
  > ⚠️ **单一所有权**:finalize 内部负责且仅负责一次 `phase.start` / `phase.end`。归档前检查不得自行追加 archive 阶段边界。