@hunter-harness/workflow-harness 0.2.39 → 0.2.41

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 (230) 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 +3 -0
  4. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +22 -0
  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-plan/reference.md +16 -16
  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/reference.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -1
  16. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +10 -0
  17. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +12 -0
  18. package/harness/bundles/general/claude-code/protocols/powershell-protocol.md +21 -0
  19. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +11 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_archive.py +413 -2
  21. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +10 -51
  22. package/harness/bundles/general/claude-code/scripts/harness_environment.py +556 -39
  23. package/harness/bundles/general/claude-code/scripts/harness_gate.py +145 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +581 -55
  25. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +85 -6
  26. package/harness/bundles/general/claude-code/scripts/harness_service.py +291 -22
  27. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +405 -25
  28. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  29. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +3 -0
  31. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +22 -0
  32. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-plan/reference.md +16 -16
  37. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-run/reference.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -1
  43. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +10 -0
  44. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +12 -0
  45. package/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +21 -0
  46. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +11 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +413 -2
  48. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +10 -51
  49. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +556 -39
  50. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +145 -0
  51. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +581 -55
  52. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +85 -6
  53. package/harness/bundles/general/codebuddy/scripts/harness_service.py +291 -22
  54. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +405 -25
  55. package/harness/bundles/general/codex/.harness-build.json +1 -1
  56. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-archive/checklist.md +3 -0
  58. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +22 -0
  59. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-plan/reference.md +16 -16
  64. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-run/reference.md +1 -1
  67. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  69. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -1
  70. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +10 -0
  71. package/harness/bundles/general/codex/protocols/ledger-protocol.md +12 -0
  72. package/harness/bundles/general/codex/protocols/powershell-protocol.md +21 -0
  73. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +11 -0
  74. package/harness/bundles/general/codex/scripts/harness_archive.py +413 -2
  75. package/harness/bundles/general/codex/scripts/harness_deploy.py +10 -51
  76. package/harness/bundles/general/codex/scripts/harness_environment.py +556 -39
  77. package/harness/bundles/general/codex/scripts/harness_gate.py +145 -0
  78. package/harness/bundles/general/codex/scripts/harness_ledger.py +581 -55
  79. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +85 -6
  80. package/harness/bundles/general/codex/scripts/harness_service.py +291 -22
  81. package/harness/bundles/general/codex/scripts/harness_test_runner.py +405 -25
  82. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  83. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-archive/checklist.md +3 -0
  85. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +22 -0
  86. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  87. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  90. package/harness/bundles/general/cursor/harness-plan/reference.md +16 -16
  91. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-run/reference.md +1 -1
  94. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -1
  97. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +10 -0
  98. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +12 -0
  99. package/harness/bundles/general/cursor/protocols/powershell-protocol.md +21 -0
  100. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +11 -0
  101. package/harness/bundles/general/cursor/scripts/harness_archive.py +413 -2
  102. package/harness/bundles/general/cursor/scripts/harness_deploy.py +10 -51
  103. package/harness/bundles/general/cursor/scripts/harness_environment.py +556 -39
  104. package/harness/bundles/general/cursor/scripts/harness_gate.py +145 -0
  105. package/harness/bundles/general/cursor/scripts/harness_ledger.py +581 -55
  106. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +85 -6
  107. package/harness/bundles/general/cursor/scripts/harness_service.py +291 -22
  108. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +405 -25
  109. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  110. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-archive/checklist.md +3 -0
  113. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +22 -0
  114. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  118. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  119. package/harness/bundles/java/claude-code/harness-plan/reference.md +16 -16
  120. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
  125. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +10 -0
  126. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +12 -0
  127. package/harness/bundles/java/claude-code/protocols/powershell-protocol.md +21 -0
  128. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +11 -0
  129. package/harness/bundles/java/claude-code/scripts/harness_archive.py +413 -2
  130. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +10 -51
  131. package/harness/bundles/java/claude-code/scripts/harness_environment.py +556 -39
  132. package/harness/bundles/java/claude-code/scripts/harness_gate.py +145 -0
  133. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +581 -55
  134. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +85 -6
  135. package/harness/bundles/java/claude-code/scripts/harness_service.py +291 -22
  136. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +405 -25
  137. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  138. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  140. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +3 -0
  141. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +22 -0
  142. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  145. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  147. package/harness/bundles/java/codebuddy/harness-plan/reference.md +16 -16
  148. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  150. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
  153. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +10 -0
  154. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +12 -0
  155. package/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +21 -0
  156. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +11 -0
  157. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +413 -2
  158. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +10 -51
  159. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +556 -39
  160. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +145 -0
  161. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +581 -55
  162. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +85 -6
  163. package/harness/bundles/java/codebuddy/scripts/harness_service.py +291 -22
  164. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +405 -25
  165. package/harness/bundles/java/codex/.harness-build.json +1 -1
  166. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  168. package/harness/bundles/java/codex/harness-archive/checklist.md +3 -0
  169. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +22 -0
  170. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  171. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  173. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  174. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  175. package/harness/bundles/java/codex/harness-plan/reference.md +16 -16
  176. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  180. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
  181. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +10 -0
  182. package/harness/bundles/java/codex/protocols/ledger-protocol.md +12 -0
  183. package/harness/bundles/java/codex/protocols/powershell-protocol.md +21 -0
  184. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +11 -0
  185. package/harness/bundles/java/codex/scripts/harness_archive.py +413 -2
  186. package/harness/bundles/java/codex/scripts/harness_deploy.py +10 -51
  187. package/harness/bundles/java/codex/scripts/harness_environment.py +556 -39
  188. package/harness/bundles/java/codex/scripts/harness_gate.py +145 -0
  189. package/harness/bundles/java/codex/scripts/harness_ledger.py +581 -55
  190. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +85 -6
  191. package/harness/bundles/java/codex/scripts/harness_service.py +291 -22
  192. package/harness/bundles/java/codex/scripts/harness_test_runner.py +405 -25
  193. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  194. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  195. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  196. package/harness/bundles/java/cursor/harness-archive/checklist.md +3 -0
  197. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +22 -0
  198. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  199. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  200. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  201. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  202. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  203. package/harness/bundles/java/cursor/harness-plan/reference.md +16 -16
  204. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  205. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  206. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  207. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
  209. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +10 -0
  210. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +12 -0
  211. package/harness/bundles/java/cursor/protocols/powershell-protocol.md +21 -0
  212. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +11 -0
  213. package/harness/bundles/java/cursor/scripts/harness_archive.py +413 -2
  214. package/harness/bundles/java/cursor/scripts/harness_deploy.py +10 -51
  215. package/harness/bundles/java/cursor/scripts/harness_environment.py +556 -39
  216. package/harness/bundles/java/cursor/scripts/harness_gate.py +145 -0
  217. package/harness/bundles/java/cursor/scripts/harness_ledger.py +581 -55
  218. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +85 -6
  219. package/harness/bundles/java/cursor/scripts/harness_service.py +291 -22
  220. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +405 -25
  221. package/harness/manifests/general/claude-code.json +29 -29
  222. package/harness/manifests/general/codebuddy.json +29 -29
  223. package/harness/manifests/general/codex.json +29 -29
  224. package/harness/manifests/general/cursor.json +29 -29
  225. package/harness/manifests/java/claude-code.json +30 -30
  226. package/harness/manifests/java/codebuddy.json +30 -30
  227. package/harness/manifests/java/codex.json +30 -30
  228. package/harness/manifests/java/cursor.json +30 -30
  229. package/hunter-workflow-family.json +4 -4
  230. package/package.json +1 -1
@@ -12,10 +12,13 @@ import argparse
12
12
  import datetime as dt
13
13
  import hashlib
14
14
  import json
15
+ import os
15
16
  import sys
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
20
+ import harness_service as hservice
21
+
19
22
 
20
23
  if hasattr(sys.stdout, "reconfigure"):
21
24
  sys.stdout.reconfigure(encoding="utf-8")
@@ -45,6 +48,17 @@ FINGERPRINT_CANDIDATES = (
45
48
  "compose.yaml",
46
49
  )
47
50
 
51
+ REQUIRED_CONTENT_EVIDENCE_FIELDS = (
52
+ "instanceId",
53
+ "instanceStartedAt",
54
+ "migrationHead",
55
+ "seedVersion",
56
+ "apiBuildIdentity",
57
+ "redisPing",
58
+ "databaseIndex",
59
+ "isolationPrefixHash",
60
+ )
61
+
48
62
 
49
63
  def now_iso() -> str:
50
64
  return dt.datetime.now().astimezone().isoformat(timespec="seconds")
@@ -128,6 +142,141 @@ def compute_environment_hash(
128
142
  return "sha256:" + _sha256_bytes(payload)
129
143
 
130
144
 
145
+ def _canonical_content_evidence(raw: dict[str, Any]) -> dict[str, Any]:
146
+ content = {
147
+ key: str(raw.get(key) or "").strip()
148
+ for key in (
149
+ "instanceId",
150
+ "instanceStartedAt",
151
+ "migrationHead",
152
+ "seedVersion",
153
+ "apiBuildIdentity",
154
+ "redisPing",
155
+ "databaseIndex",
156
+ "isolationPrefixHash",
157
+ )
158
+ if str(raw.get(key) or "").strip()
159
+ }
160
+ canaries_raw = raw.get("canaries")
161
+ canaries: list[dict[str, str]] = []
162
+ if isinstance(canaries_raw, list):
163
+ for index, item in enumerate(canaries_raw):
164
+ if not isinstance(item, dict):
165
+ raise ValueError(f"content canaries[{index}] must be an object")
166
+ name = str(item.get("name") or "").strip()
167
+ status = str(item.get("status") or "").strip().upper()
168
+ identity = str(item.get("identity") or "").strip()
169
+ if not name or status not in {"PASS", "FAIL", "UNKNOWN"}:
170
+ raise ValueError(
171
+ f"content canaries[{index}] requires name and PASS/FAIL/UNKNOWN status"
172
+ )
173
+ canonical = {"name": name, "status": status}
174
+ if identity:
175
+ canonical["identity"] = identity
176
+ canaries.append(canonical)
177
+ content["canaries"] = sorted(
178
+ canaries,
179
+ key=lambda item: (item["name"], item.get("identity", "")),
180
+ )
181
+ return content
182
+
183
+
184
+ def create_environment_receipt(
185
+ project: Path,
186
+ *,
187
+ stack_id: str,
188
+ content_evidence: dict[str, Any],
189
+ environment_values: dict[str, str] | None = None,
190
+ powershell: dict[str, str] | None = None,
191
+ ) -> dict[str, Any]:
192
+ """Create a typed, secret-free receipt for reusable environment contents."""
193
+ content = _canonical_content_evidence(content_evidence)
194
+ canonical_content = json.dumps(
195
+ content,
196
+ ensure_ascii=False,
197
+ sort_keys=True,
198
+ separators=(",", ":"),
199
+ ).encode("utf-8")
200
+ fields = {
201
+ str(name): "sha256:" + _sha256_bytes(str(value).encode("utf-8"))
202
+ for name, value in sorted((environment_values or {}).items())
203
+ if str(name).strip() and value is not None
204
+ }
205
+ shell = {
206
+ key: str((powershell or {}).get(key) or "").strip()
207
+ for key in ("edition", "version")
208
+ if str((powershell or {}).get(key) or "").strip()
209
+ }
210
+ receipt = {
211
+ "schemaVersion": 1,
212
+ "stackId": stack_id,
213
+ "preparedAt": now_iso(),
214
+ "environmentHash": compute_environment_hash(project),
215
+ "content": content,
216
+ "contentFingerprint": "sha256:" + _sha256_bytes(canonical_content),
217
+ "environmentFields": fields,
218
+ "powershell": shell,
219
+ }
220
+ identity_payload = json.dumps(
221
+ {
222
+ key: value
223
+ for key, value in receipt.items()
224
+ if key not in {"preparedAt", "receiptId"}
225
+ },
226
+ ensure_ascii=False,
227
+ sort_keys=True,
228
+ separators=(",", ":"),
229
+ ).encode("utf-8")
230
+ receipt["receiptId"] = "sha256:" + _sha256_bytes(identity_payload)
231
+ return receipt
232
+
233
+
234
+ def resolve_verification_environment(
235
+ receipt: dict[str, Any],
236
+ *,
237
+ required_fields: list[str],
238
+ source_environment: dict[str, str] | None = None,
239
+ ) -> dict[str, Any]:
240
+ """Close a verification's dynamic environment over a typed receipt."""
241
+ source = source_environment if source_environment is not None else dict(os.environ)
242
+ identities = receipt.get("environmentFields")
243
+ if receipt.get("schemaVersion") != 1 or not isinstance(identities, dict):
244
+ return {
245
+ "ok": False,
246
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
247
+ "message": "environment receipt is missing typed environmentFields",
248
+ "missing": sorted(set(required_fields)),
249
+ "changed": [],
250
+ }
251
+ required = sorted({str(item).strip() for item in required_fields if str(item).strip()})
252
+ missing = [
253
+ field
254
+ for field in required
255
+ if field not in identities or field not in source or source[field] == ""
256
+ ]
257
+ changed = [
258
+ field
259
+ for field in required
260
+ if field not in missing
261
+ and identities.get(field)
262
+ != "sha256:" + _sha256_bytes(str(source[field]).encode("utf-8"))
263
+ ]
264
+ if missing or changed:
265
+ return {
266
+ "ok": False,
267
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
268
+ "message": "required dynamic environment fields are missing or changed",
269
+ "missing": missing,
270
+ "changed": changed,
271
+ }
272
+ return {
273
+ "ok": True,
274
+ "code": "VERIFICATION_ENVIRONMENT_READY",
275
+ "environment": {field: str(source[field]) for field in required},
276
+ "receiptId": receipt.get("receiptId"),
277
+ }
278
+
279
+
131
280
  def _lease_path(lease_root: Path, stack_id: str) -> Path:
132
281
  safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in stack_id)
133
282
  return lease_root / f"{safe}.json"
@@ -147,12 +296,137 @@ def list_leases(lease_root: Path) -> list[dict[str, Any]]:
147
296
  return out
148
297
 
149
298
 
299
+ def _parse_expiry(value: Any) -> dt.datetime | None:
300
+ if not isinstance(value, str) or not value.strip():
301
+ return None
302
+ try:
303
+ parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
304
+ except ValueError:
305
+ return None
306
+ if parsed.tzinfo is None:
307
+ return parsed.replace(tzinfo=dt.timezone.utc)
308
+ return parsed.astimezone(dt.timezone.utc)
309
+
310
+
311
+ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
312
+ """Classify leases without mutating or terminating any owner process."""
313
+ results: list[dict[str, Any]] = []
314
+ if not lease_root.is_dir():
315
+ return results
316
+ now = dt.datetime.now(dt.timezone.utc)
317
+ for path in sorted(lease_root.glob("*.json")):
318
+ try:
319
+ lease = _read_json(path)
320
+ except (OSError, json.JSONDecodeError) as exc:
321
+ results.append(
322
+ {
323
+ "stackId": path.stem,
324
+ "classification": "LEASE_UNREADABLE",
325
+ "message": str(exc),
326
+ "sourcePath": str(path),
327
+ }
328
+ )
329
+ continue
330
+ if not isinstance(lease, dict):
331
+ continue
332
+ item = {
333
+ "stackId": str(lease.get("stackId") or path.stem),
334
+ "changeId": lease.get("changeId"),
335
+ "expiresAt": lease.get("expiresAt"),
336
+ "sourcePath": str(path),
337
+ }
338
+ expires_at = _parse_expiry(lease.get("expiresAt"))
339
+ if expires_at is None:
340
+ item["classification"] = "LEASE_EXPIRY_INVALID"
341
+ results.append(item)
342
+ continue
343
+ if now < expires_at:
344
+ item["classification"] = (
345
+ "STALE_CONTENT"
346
+ if lease.get("status") == "STALE_CONTENT"
347
+ else "ACTIVE"
348
+ )
349
+ results.append(item)
350
+ continue
351
+ owner = lease.get("owner")
352
+ if not isinstance(owner, dict) or any(
353
+ not owner.get(field) for field in ("pid", "executable", "startedAt")
354
+ ):
355
+ item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
356
+ results.append(item)
357
+ continue
358
+ try:
359
+ owner_pid = int(owner["pid"])
360
+ except (TypeError, ValueError):
361
+ item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
362
+ results.append(item)
363
+ continue
364
+ if not hservice.is_pid_alive(owner_pid):
365
+ item["classification"] = "RECLAIMABLE"
366
+ results.append(item)
367
+ continue
368
+ identity = hservice.verify_process_identity(
369
+ {
370
+ "pid": owner_pid,
371
+ "startedAt": owner["startedAt"],
372
+ "processIdentity": {"executable": owner["executable"]},
373
+ }
374
+ )
375
+ item["classification"] = (
376
+ "OWNER_ACTIVE"
377
+ if identity is True
378
+ else (
379
+ "OWNER_IDENTITY_UNCONFIRMED"
380
+ if identity is None
381
+ else "OWNER_IDENTITY_MISMATCH"
382
+ )
383
+ )
384
+ results.append(item)
385
+ return results
386
+
387
+
388
+ def reap_expired_leases(lease_root: Path) -> dict[str, Any]:
389
+ """Remove only expired leases whose recorded owner is definitely gone."""
390
+ leases = classify_leases(lease_root)
391
+ reaped: list[str] = []
392
+ for item in leases:
393
+ if item.get("classification") != "RECLAIMABLE":
394
+ continue
395
+ path = Path(str(item["sourcePath"]))
396
+ try:
397
+ path.unlink()
398
+ except FileNotFoundError:
399
+ pass
400
+ reaped.append(str(item["stackId"]))
401
+ receipt = {
402
+ "schemaVersion": 1,
403
+ "action": "environment-lease-reap",
404
+ "completedAt": now_iso(),
405
+ "reaped": reaped,
406
+ "leases": leases,
407
+ }
408
+ receipt_path = (
409
+ lease_root
410
+ / "reap-receipts"
411
+ / f"{dt.datetime.now().strftime('%Y%m%dT%H%M%S')}-{os.getpid()}.json"
412
+ )
413
+ _write_json(receipt_path, receipt)
414
+ return {
415
+ "ok": True,
416
+ "code": "LEASE_REAP_COMPLETE",
417
+ "reaped": reaped,
418
+ "leases": leases,
419
+ "receiptPath": str(receipt_path),
420
+ }
421
+
422
+
150
423
  def require_writable_lease(
151
424
  project: Path,
152
425
  *,
153
426
  change_id: str,
154
427
  stack_id: str,
155
428
  lease_root: Path | None = None,
429
+ environment_receipt: dict[str, Any] | None = None,
156
430
  ) -> dict[str, Any]:
157
431
  """Gate: a writable stack may be used only with an active matching lease."""
158
432
  root = lease_root or default_lease_root(project)
@@ -190,28 +464,8 @@ def require_writable_lease(
190
464
  # Review Y2: expired leases must not remain writable.
191
465
  expires_raw = lease.get("expiresAt")
192
466
  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:
467
+ expires_at = _parse_expiry(expires_raw)
468
+ if expires_at is None:
215
469
  return {
216
470
  "ok": False,
217
471
  "code": "ENVIRONMENT_LEASE_REQUIRED",
@@ -219,6 +473,62 @@ def require_writable_lease(
219
473
  "changeId": change_id,
220
474
  "stackId": stack_id,
221
475
  }
476
+ if dt.datetime.now(dt.timezone.utc) >= expires_at:
477
+ return {
478
+ "ok": False,
479
+ "code": "ENVIRONMENT_LEASE_EXPIRED",
480
+ "message": (
481
+ f"lease for stack={stack_id} expired at {expires_raw}; "
482
+ "re-acquire before writable use"
483
+ ),
484
+ "changeId": change_id,
485
+ "stackId": stack_id,
486
+ "expiresAt": expires_raw,
487
+ }
488
+ if lease.get("status") == "STALE_CONTENT":
489
+ return {
490
+ "ok": False,
491
+ "code": "STALE_CONTENT",
492
+ "message": "lease content was previously marked stale; prepare again",
493
+ "lease": lease,
494
+ }
495
+ expected_content = str(lease.get("contentFingerprint") or "").strip()
496
+ if expected_content:
497
+ if not isinstance(environment_receipt, dict):
498
+ return {
499
+ "ok": False,
500
+ "code": "ENVIRONMENT_CONTENT_RECEIPT_REQUIRED",
501
+ "message": "current typed environment receipt is required before reuse",
502
+ "changeId": change_id,
503
+ "stackId": stack_id,
504
+ }
505
+ observed_content = str(
506
+ environment_receipt.get("contentFingerprint") or ""
507
+ ).strip()
508
+ canaries = (
509
+ environment_receipt.get("content", {}).get("canaries")
510
+ if isinstance(environment_receipt.get("content"), dict)
511
+ else None
512
+ )
513
+ canaries_ok = isinstance(canaries, list) and all(
514
+ isinstance(item, dict) and item.get("status") == "PASS"
515
+ for item in canaries
516
+ )
517
+ if observed_content != expected_content or not canaries_ok:
518
+ lease["status"] = "STALE_CONTENT"
519
+ lease["staleAt"] = now_iso()
520
+ lease["expectedContentFingerprint"] = expected_content
521
+ lease["observedContentFingerprint"] = observed_content
522
+ _write_json(path, lease)
523
+ return {
524
+ "ok": False,
525
+ "code": "STALE_CONTENT",
526
+ "message": "environment content fingerprint or canary changed",
527
+ "expectedContentFingerprint": expected_content,
528
+ "observedContentFingerprint": observed_content,
529
+ }
530
+ lease["heartbeatAt"] = now_iso()
531
+ _write_json(path, lease)
222
532
  return {"ok": True, "code": "LEASE_HELD", "lease": lease}
223
533
 
224
534
 
@@ -231,6 +541,7 @@ def acquire_lease(
231
541
  lease_root: Path | None = None,
232
542
  writable_volumes: list[str] | None = None,
233
543
  ttl_seconds: int = 3600,
544
+ environment_receipt: dict[str, Any] | None = None,
234
545
  ) -> dict[str, Any]:
235
546
  """Acquire a lease; reject cross-change sharing of the same writable volume."""
236
547
  root = lease_root or default_lease_root(project)
@@ -284,16 +595,55 @@ def acquire_lease(
284
595
 
285
596
  started = dt.datetime.now().astimezone()
286
597
  expires = started + dt.timedelta(seconds=max(60, int(ttl_seconds)))
598
+ if environment_receipt is not None:
599
+ if (
600
+ environment_receipt.get("schemaVersion") != 1
601
+ or environment_receipt.get("stackId") != stack_id
602
+ or not str(environment_receipt.get("contentFingerprint") or "").strip()
603
+ ):
604
+ return {
605
+ "ok": False,
606
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
607
+ "message": "environment receipt identity does not match requested stack",
608
+ }
609
+ canaries = (
610
+ environment_receipt.get("content", {}).get("canaries")
611
+ if isinstance(environment_receipt.get("content"), dict)
612
+ else None
613
+ )
614
+ if not isinstance(canaries, list) or not canaries or any(
615
+ not isinstance(item, dict) or item.get("status") != "PASS"
616
+ for item in canaries
617
+ ):
618
+ return {
619
+ "ok": False,
620
+ "code": "ENVIRONMENT_CONTENT_NOT_READY",
621
+ "message": "all environment content canaries must pass before acquire",
622
+ }
287
623
  lease = {
288
- "schemaVersion": 1,
624
+ "schemaVersion": 2 if environment_receipt is not None else 1,
289
625
  "changeId": change_id,
290
626
  "stackId": stack_id,
291
627
  "environmentHash": environment_hash,
292
628
  "writableVolumes": volumes,
293
629
  "acquiredAt": started.isoformat(timespec="seconds"),
630
+ "heartbeatAt": started.isoformat(timespec="seconds"),
294
631
  "expiresAt": expires.isoformat(timespec="seconds"),
295
632
  "projectRoot": str(project.resolve()),
633
+ "status": "ACTIVE",
634
+ "owner": {
635
+ "pid": os.getpid(),
636
+ "executable": hservice.get_process_executable(os.getpid())
637
+ or str(Path(sys.executable).resolve()),
638
+ "startedAt": (
639
+ hservice.get_process_create_time(os.getpid())
640
+ or started
641
+ ).isoformat(timespec="seconds"),
642
+ },
296
643
  }
644
+ if environment_receipt is not None:
645
+ lease["contentFingerprint"] = environment_receipt["contentFingerprint"]
646
+ lease["environmentReceiptId"] = environment_receipt.get("receiptId")
297
647
  _write_json(path, lease)
298
648
  return {"ok": True, "code": "LEASE_ACQUIRED", "lease": lease, "path": str(path)}
299
649
 
@@ -344,20 +694,155 @@ def cmd_fingerprint(args: argparse.Namespace) -> int:
344
694
  )
345
695
 
346
696
 
697
+ def _load_receipt_arg(value: str | None) -> dict[str, Any] | None:
698
+ if not value:
699
+ return None
700
+ path = Path(value).resolve()
701
+ data = _read_json(path)
702
+ if not isinstance(data, dict):
703
+ raise ValueError(f"environment receipt must be a JSON object: {path}")
704
+ return data
705
+
706
+
707
+ def cmd_prepare(args: argparse.Namespace) -> int:
708
+ project = Path(args.project).resolve()
709
+ evidence_path = Path(args.content_evidence_file).resolve()
710
+ try:
711
+ evidence = _read_json(evidence_path)
712
+ except (OSError, json.JSONDecodeError) as exc:
713
+ return emit_json(
714
+ {
715
+ "ok": False,
716
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
717
+ "message": str(exc),
718
+ },
719
+ ok=False,
720
+ )
721
+ if not isinstance(evidence, dict):
722
+ return emit_json(
723
+ {
724
+ "ok": False,
725
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
726
+ "message": "content evidence must be a JSON object",
727
+ },
728
+ ok=False,
729
+ )
730
+ try:
731
+ canonical_evidence = _canonical_content_evidence(evidence)
732
+ except ValueError as exc:
733
+ return emit_json(
734
+ {
735
+ "ok": False,
736
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
737
+ "message": str(exc),
738
+ },
739
+ ok=False,
740
+ )
741
+ missing_content = [
742
+ field
743
+ for field in REQUIRED_CONTENT_EVIDENCE_FIELDS
744
+ if not canonical_evidence.get(field)
745
+ ]
746
+ if not canonical_evidence.get("canaries"):
747
+ missing_content.append("canaries")
748
+ if missing_content:
749
+ return emit_json(
750
+ {
751
+ "ok": False,
752
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INCOMPLETE",
753
+ "message": "formal environment receipts require complete content evidence",
754
+ "missing": missing_content,
755
+ },
756
+ ok=False,
757
+ )
758
+ field_names = sorted(
759
+ {str(item).strip() for item in (args.environment_field or []) if str(item).strip()}
760
+ )
761
+ missing = [field for field in field_names if not os.environ.get(field)]
762
+ if missing:
763
+ return emit_json(
764
+ {
765
+ "ok": False,
766
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
767
+ "message": "declared environment fields are missing",
768
+ "missing": missing,
769
+ },
770
+ ok=False,
771
+ )
772
+ powershell = {
773
+ "edition": str(
774
+ args.powershell_edition
775
+ or os.environ.get("HARNESS_POWERSHELL_EDITION")
776
+ or ""
777
+ ),
778
+ "version": str(
779
+ args.powershell_version
780
+ or os.environ.get("HARNESS_POWERSHELL_VERSION")
781
+ or ""
782
+ ),
783
+ }
784
+ if bool(powershell["edition"]) != bool(powershell["version"]):
785
+ return emit_json(
786
+ {
787
+ "ok": False,
788
+ "code": "POWERSHELL_RUNTIME_INCOMPLETE",
789
+ "message": "PowerShell edition and version must be recorded together",
790
+ },
791
+ ok=False,
792
+ )
793
+ try:
794
+ receipt = create_environment_receipt(
795
+ project,
796
+ stack_id=args.stack_id,
797
+ content_evidence=evidence,
798
+ environment_values={field: os.environ[field] for field in field_names},
799
+ powershell=powershell,
800
+ )
801
+ except ValueError as exc:
802
+ return emit_json(
803
+ {
804
+ "ok": False,
805
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
806
+ "message": str(exc),
807
+ },
808
+ ok=False,
809
+ )
810
+ output = Path(args.output).resolve()
811
+ _write_json(output, receipt)
812
+ return emit_json(
813
+ {
814
+ "ok": True,
815
+ "code": "ENVIRONMENT_RECEIPT_PREPARED",
816
+ "receiptId": receipt["receiptId"],
817
+ "contentFingerprint": receipt["contentFingerprint"],
818
+ "output": str(output),
819
+ }
820
+ )
821
+
822
+
347
823
  def cmd_acquire(args: argparse.Namespace) -> int:
348
824
  project = Path(args.project).resolve()
349
825
  root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
350
826
  env_hash = args.environment_hash or compute_environment_hash(project)
351
827
  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
- )
828
+ try:
829
+ receipt = _load_receipt_arg(args.environment_receipt)
830
+ result = acquire_lease(
831
+ project,
832
+ change_id=args.change,
833
+ stack_id=args.stack_id,
834
+ environment_hash=env_hash,
835
+ lease_root=root,
836
+ writable_volumes=volumes,
837
+ ttl_seconds=int(args.ttl_seconds or 3600),
838
+ environment_receipt=receipt,
839
+ )
840
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
841
+ result = {
842
+ "ok": False,
843
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
844
+ "message": str(exc),
845
+ }
361
846
  return emit_json(result, ok=bool(result.get("ok")))
362
847
 
363
848
 
@@ -376,12 +861,21 @@ def cmd_release(args: argparse.Namespace) -> int:
376
861
  def cmd_require(args: argparse.Namespace) -> int:
377
862
  project = Path(args.project).resolve()
378
863
  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
- )
864
+ try:
865
+ receipt = _load_receipt_arg(args.environment_receipt)
866
+ result = require_writable_lease(
867
+ project,
868
+ change_id=args.change,
869
+ stack_id=args.stack_id,
870
+ lease_root=root,
871
+ environment_receipt=receipt,
872
+ )
873
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
874
+ result = {
875
+ "ok": False,
876
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
877
+ "message": str(exc),
878
+ }
385
879
  return emit_json(result, ok=bool(result.get("ok")))
386
880
 
387
881
 
@@ -394,7 +888,7 @@ def cmd_status(args: argparse.Namespace) -> int:
394
888
  "action": "status",
395
889
  "projectRoot": str(project),
396
890
  "environmentHash": compute_environment_hash(project),
397
- "leases": list_leases(root),
891
+ "leases": classify_leases(root),
398
892
  "leaseRoot": str(root),
399
893
  "contract": [
400
894
  "prepare",
@@ -409,10 +903,26 @@ def cmd_status(args: argparse.Namespace) -> int:
409
903
  )
410
904
 
411
905
 
906
+ def cmd_reap_expired(args: argparse.Namespace) -> int:
907
+ project = Path(args.project).resolve()
908
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
909
+ return emit_json(reap_expired_leases(root))
910
+
911
+
412
912
  def build_parser() -> argparse.ArgumentParser:
413
913
  parser = argparse.ArgumentParser(prog="harness_environment.py")
414
914
  sub = parser.add_subparsers(dest="command", required=True)
415
915
 
916
+ prep = sub.add_parser("prepare")
917
+ prep.add_argument("--project", required=True)
918
+ prep.add_argument("--stack-id", required=True)
919
+ prep.add_argument("--content-evidence-file", required=True)
920
+ prep.add_argument("--environment-field", action="append", default=[])
921
+ prep.add_argument("--powershell-edition")
922
+ prep.add_argument("--powershell-version")
923
+ prep.add_argument("--output", required=True)
924
+ prep.set_defaults(func=cmd_prepare)
925
+
416
926
  fp = sub.add_parser("fingerprint")
417
927
  fp.add_argument("--project", required=True)
418
928
  fp.set_defaults(func=cmd_fingerprint)
@@ -422,6 +932,7 @@ def build_parser() -> argparse.ArgumentParser:
422
932
  ac.add_argument("--change", required=True)
423
933
  ac.add_argument("--stack-id", required=True)
424
934
  ac.add_argument("--environment-hash")
935
+ ac.add_argument("--environment-receipt")
425
936
  ac.add_argument("--lease-root")
426
937
  ac.add_argument("--writable-volume", action="append", default=[])
427
938
  ac.add_argument("--ttl-seconds", type=int, default=3600)
@@ -439,6 +950,7 @@ def build_parser() -> argparse.ArgumentParser:
439
950
  req.add_argument("--change", required=True)
440
951
  req.add_argument("--stack-id", required=True)
441
952
  req.add_argument("--lease-root")
953
+ req.add_argument("--environment-receipt")
442
954
  req.set_defaults(func=cmd_require)
443
955
 
444
956
  st = sub.add_parser("status")
@@ -446,6 +958,11 @@ def build_parser() -> argparse.ArgumentParser:
446
958
  st.add_argument("--lease-root")
447
959
  st.set_defaults(func=cmd_status)
448
960
 
961
+ reap = sub.add_parser("reap-expired")
962
+ reap.add_argument("--project", required=True)
963
+ reap.add_argument("--lease-root")
964
+ reap.set_defaults(func=cmd_reap_expired)
965
+
449
966
  return parser
450
967
 
451
968