@hunter-harness/workflow-harness 0.3.1 → 0.4.1

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 (402) 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/reference.md +30 -1
  4. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +1 -0
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-execute/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-pull/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  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/scripts/harness_archive.py +146 -8
  18. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  19. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +538 -4
  20. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  21. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-archive/reference.md +30 -1
  23. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +1 -0
  24. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +146 -8
  37. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  38. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +538 -4
  39. package/harness/bundles/general/codex/.harness-build.json +1 -1
  40. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-archive/reference.md +30 -1
  42. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +1 -0
  43. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/scripts/harness_archive.py +146 -8
  56. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  57. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +538 -4
  58. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  59. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-archive/reference.md +30 -1
  61. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +1 -0
  62. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  67. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  68. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  73. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  74. package/harness/bundles/general/cursor/scripts/harness_archive.py +146 -8
  75. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  76. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +538 -4
  77. package/harness/bundles/general/pi/.harness-build.json +6 -0
  78. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  79. package/harness/bundles/general/pi/README.md +478 -0
  80. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  81. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  82. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  83. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  84. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  85. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  86. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  87. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  88. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  89. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  90. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  91. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  92. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  93. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  94. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  99. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  100. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  101. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  102. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  103. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  104. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  105. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  106. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  107. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  108. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  109. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  110. package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
  111. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  112. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  113. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  114. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  115. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  116. package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
  117. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  118. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  119. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  120. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  121. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  122. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  123. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  124. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  125. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  126. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  127. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  128. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  129. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  130. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  131. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  132. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  133. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  134. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  135. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  136. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  137. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  138. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  139. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  140. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  141. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  142. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  143. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  144. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  145. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  146. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  147. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  148. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  149. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  150. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  151. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  152. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  153. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  154. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  155. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  156. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  157. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  158. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  159. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  160. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  161. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  162. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  163. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  164. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  165. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  166. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  167. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  168. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  169. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  170. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  171. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  172. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  173. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  174. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  175. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  176. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  177. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  178. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  179. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  180. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  181. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  182. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  183. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  184. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  185. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  186. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  187. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  188. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  189. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  190. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-archive/reference.md +30 -1
  193. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +1 -0
  194. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  198. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  199. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  200. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  201. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  202. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  203. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  204. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  205. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  206. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  207. package/harness/bundles/java/claude-code/scripts/harness_archive.py +146 -8
  208. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  209. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +538 -4
  210. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  211. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-archive/reference.md +30 -1
  214. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +1 -0
  215. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  217. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  218. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  219. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  220. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  221. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  222. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  223. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  224. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  225. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  226. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  227. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  228. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +146 -8
  229. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  230. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +538 -4
  231. package/harness/bundles/java/codex/.harness-build.json +1 -1
  232. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-archive/reference.md +30 -1
  235. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +1 -0
  236. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  237. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  238. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  239. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  240. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  241. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  242. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  243. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  244. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  245. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  246. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  247. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  248. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  249. package/harness/bundles/java/codex/scripts/harness_archive.py +146 -8
  250. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  251. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +538 -4
  252. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  253. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  255. package/harness/bundles/java/cursor/harness-archive/reference.md +30 -1
  256. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +1 -0
  257. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  258. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  259. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  260. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  261. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  262. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  263. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  264. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  265. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  266. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  267. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  268. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  269. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  270. package/harness/bundles/java/cursor/scripts/harness_archive.py +146 -8
  271. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  272. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +538 -4
  273. package/harness/bundles/java/pi/.harness-build.json +6 -0
  274. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  275. package/harness/bundles/java/pi/README.md +478 -0
  276. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  277. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  278. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  279. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  280. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  281. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  282. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  283. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  284. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  285. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  286. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  287. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  288. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  289. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  290. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  291. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  292. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  293. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  294. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  295. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  296. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  297. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  298. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  299. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  300. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  301. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  302. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  303. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  304. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  305. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  306. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  307. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  308. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  309. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  310. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  311. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  312. package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
  313. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  314. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  315. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  316. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  317. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  318. package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
  319. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  320. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  321. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  322. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  323. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  324. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  325. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  326. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  327. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  328. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  329. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  330. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  331. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  332. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  333. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  334. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  335. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  336. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  337. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  338. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  339. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  340. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  341. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  342. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  343. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  344. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  345. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  346. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  347. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  348. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  349. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  350. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  351. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  352. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  353. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  354. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  355. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  356. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  357. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  358. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  359. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  360. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  361. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  362. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  363. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  364. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  365. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  366. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  367. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  368. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  369. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  370. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  371. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  372. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  373. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  374. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  375. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  376. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  377. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  378. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  379. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  380. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  381. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  382. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  383. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  384. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  385. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  386. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  387. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  388. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  389. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  390. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  391. package/harness/manifests/general/claude-code.json +19 -19
  392. package/harness/manifests/general/codebuddy.json +19 -19
  393. package/harness/manifests/general/codex.json +19 -19
  394. package/harness/manifests/general/cursor.json +19 -19
  395. package/harness/manifests/general/pi.json +475 -0
  396. package/harness/manifests/java/claude-code.json +21 -21
  397. package/harness/manifests/java/codebuddy.json +21 -21
  398. package/harness/manifests/java/codex.json +21 -21
  399. package/harness/manifests/java/cursor.json +21 -21
  400. package/harness/manifests/java/pi.json +499 -0
  401. package/hunter-workflow-family.json +2 -2
  402. package/package.json +1 -1
@@ -0,0 +1,1690 @@
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 os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ import uuid
22
+ from contextlib import contextmanager
23
+ from pathlib import Path
24
+ from typing import Any, Iterator
25
+
26
+ import harness_process as hprocess
27
+ from harness_paths import resolve_state_dir_for_contract
28
+
29
+
30
+ def _verify_recorded_owner(owner: dict[str, Any]) -> bool | None:
31
+ try:
32
+ pid = int(owner.get("pid") or 0)
33
+ except (TypeError, ValueError):
34
+ return None
35
+ observed = hprocess.observe_process_identity(pid)
36
+ decision = hprocess.verify_process_identity(
37
+ {
38
+ "pid": pid,
39
+ "createdAt": owner.get("startedAt"),
40
+ "executable": owner.get("executable"),
41
+ },
42
+ observed,
43
+ )
44
+ if decision.get("ok") is True:
45
+ return True
46
+ if decision.get("reasonCode") == "IDENTITY_UNVERIFIABLE":
47
+ return None
48
+ return False
49
+
50
+
51
+ if hasattr(sys.stdout, "reconfigure"):
52
+ sys.stdout.reconfigure(encoding="utf-8")
53
+ if hasattr(sys.stderr, "reconfigure"):
54
+ sys.stderr.reconfigure(encoding="utf-8")
55
+
56
+
57
+ FINGERPRINT_CANDIDATES = (
58
+ "package-lock.json",
59
+ "npm-shrinkwrap.json",
60
+ "pnpm-lock.yaml",
61
+ "yarn.lock",
62
+ "poetry.lock",
63
+ "Pipfile.lock",
64
+ "requirements.txt",
65
+ "requirements.lock",
66
+ "Cargo.lock",
67
+ "go.sum",
68
+ "composer.lock",
69
+ ".nvmrc",
70
+ ".node-version",
71
+ ".python-version",
72
+ "Dockerfile",
73
+ "docker-compose.yml",
74
+ "docker-compose.yaml",
75
+ "compose.yml",
76
+ "compose.yaml",
77
+ )
78
+
79
+ REQUIRED_CONTENT_EVIDENCE_FIELDS = (
80
+ "instanceId",
81
+ "instanceStartedAt",
82
+ "migrationHead",
83
+ "seedVersion",
84
+ "apiBuildIdentity",
85
+ "redisPing",
86
+ "databaseIndex",
87
+ "isolationPrefixHash",
88
+ )
89
+
90
+
91
+ def now_iso() -> str:
92
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
93
+
94
+
95
+ def emit_json(payload: dict[str, Any], *, ok: bool = True) -> int:
96
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
97
+ return 0 if ok else 1
98
+
99
+
100
+ def _sha256_bytes(data: bytes) -> str:
101
+ return hashlib.sha256(data).hexdigest()
102
+
103
+
104
+ def _sha256_file(path: Path) -> str:
105
+ h = hashlib.sha256()
106
+ with path.open("rb") as fh:
107
+ for chunk in iter(lambda: fh.read(65536), b""):
108
+ h.update(chunk)
109
+ return h.hexdigest()
110
+
111
+
112
+ def _read_json(path: Path) -> Any:
113
+ return json.loads(path.read_text(encoding="utf-8-sig"))
114
+
115
+
116
+ def _write_json(path: Path, data: Any) -> None:
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ fd, raw_tmp = tempfile.mkstemp(
119
+ prefix=f".{path.name}.",
120
+ suffix=".tmp",
121
+ dir=str(path.parent),
122
+ )
123
+ tmp = Path(raw_tmp)
124
+ try:
125
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
126
+ handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
127
+ handle.flush()
128
+ os.fsync(handle.fileno())
129
+ os.replace(tmp, path)
130
+ finally:
131
+ tmp.unlink(missing_ok=True)
132
+
133
+
134
+ def default_lease_root(project: Path) -> Path:
135
+ return project / ".harness" / "runtime" / "env-leases"
136
+
137
+
138
+ def _migration_head_token(project: Path) -> str:
139
+ cfg = project / ".harness" / "config" / "migration-head.json"
140
+ if cfg.is_file():
141
+ try:
142
+ data = _read_json(cfg)
143
+ if isinstance(data, dict):
144
+ head = data.get("head") or data.get("revision") or data.get("version")
145
+ if head:
146
+ return f"migration-head:{head}"
147
+ except (OSError, json.JSONDecodeError, TypeError):
148
+ pass
149
+ versions = project / "alembic" / "versions"
150
+ if versions.is_dir():
151
+ names = sorted(p.name for p in versions.glob("*.py"))
152
+ if names:
153
+ return "alembic:" + ",".join(names[-3:])
154
+ return "migration-head:none"
155
+
156
+
157
+ def compute_environment_hash(
158
+ project: Path,
159
+ *,
160
+ extra_paths: list[str] | None = None,
161
+ ) -> str:
162
+ """Stable hash over lockfiles/toolchain/compose/migration head inputs."""
163
+ project = project.resolve()
164
+ lines: list[str] = []
165
+ seen: set[str] = set()
166
+ for rel in list(FINGERPRINT_CANDIDATES) + list(extra_paths or []):
167
+ if rel in seen:
168
+ continue
169
+ seen.add(rel)
170
+ path = project / rel
171
+ if path.is_file():
172
+ lines.append(f"file:{rel}:{_sha256_file(path)}")
173
+ lines.append(_migration_head_token(project))
174
+ # Desensitized runner hint (presence only).
175
+ for runner in ("playwright.config.ts", "playwright.config.js", "vitest.config.ts"):
176
+ if (project / runner).is_file():
177
+ lines.append(f"runner:{runner}:present")
178
+ payload = "\n".join(sorted(lines)).encode("utf-8")
179
+ return "sha256:" + _sha256_bytes(payload)
180
+
181
+
182
+ def _canonical_content_evidence(raw: dict[str, Any]) -> dict[str, Any]:
183
+ content = {
184
+ key: str(raw.get(key) or "").strip()
185
+ for key in (
186
+ "instanceId",
187
+ "instanceStartedAt",
188
+ "migrationHead",
189
+ "seedVersion",
190
+ "apiBuildIdentity",
191
+ "redisPing",
192
+ "databaseIndex",
193
+ "isolationPrefixHash",
194
+ )
195
+ if str(raw.get(key) or "").strip()
196
+ }
197
+ canaries_raw = raw.get("canaries")
198
+ canaries: list[dict[str, str]] = []
199
+ if isinstance(canaries_raw, list):
200
+ for index, item in enumerate(canaries_raw):
201
+ if not isinstance(item, dict):
202
+ raise ValueError(f"content canaries[{index}] must be an object")
203
+ name = str(item.get("name") or "").strip()
204
+ status = str(item.get("status") or "").strip().upper()
205
+ identity = str(item.get("identity") or "").strip()
206
+ if not name or status not in {"PASS", "FAIL", "UNKNOWN"}:
207
+ raise ValueError(
208
+ f"content canaries[{index}] requires name and PASS/FAIL/UNKNOWN status"
209
+ )
210
+ canonical = {"name": name, "status": status}
211
+ if identity:
212
+ canonical["identity"] = identity
213
+ canaries.append(canonical)
214
+ content["canaries"] = sorted(
215
+ canaries,
216
+ key=lambda item: (item["name"], item.get("identity", "")),
217
+ )
218
+ return content
219
+
220
+
221
+ def create_environment_receipt(
222
+ project: Path,
223
+ *,
224
+ stack_id: str,
225
+ content_evidence: dict[str, Any],
226
+ environment_values: dict[str, str] | None = None,
227
+ powershell: dict[str, str] | None = None,
228
+ ) -> dict[str, Any]:
229
+ """Create a typed, secret-free receipt for reusable environment contents."""
230
+ content = _canonical_content_evidence(content_evidence)
231
+ canonical_content = json.dumps(
232
+ content,
233
+ ensure_ascii=False,
234
+ sort_keys=True,
235
+ separators=(",", ":"),
236
+ ).encode("utf-8")
237
+ fields = {
238
+ str(name): "sha256:" + _sha256_bytes(str(value).encode("utf-8"))
239
+ for name, value in sorted((environment_values or {}).items())
240
+ if str(name).strip() and value is not None
241
+ }
242
+ shell = {
243
+ key: str((powershell or {}).get(key) or "").strip()
244
+ for key in ("edition", "version")
245
+ if str((powershell or {}).get(key) or "").strip()
246
+ }
247
+ receipt = {
248
+ "schemaVersion": 1,
249
+ "stackId": stack_id,
250
+ "preparedAt": now_iso(),
251
+ "environmentHash": compute_environment_hash(project),
252
+ "content": content,
253
+ "contentFingerprint": "sha256:" + _sha256_bytes(canonical_content),
254
+ "environmentFields": fields,
255
+ "powershell": shell,
256
+ }
257
+ identity_payload = json.dumps(
258
+ {
259
+ key: value
260
+ for key, value in receipt.items()
261
+ if key not in {"preparedAt", "receiptId"}
262
+ },
263
+ ensure_ascii=False,
264
+ sort_keys=True,
265
+ separators=(",", ":"),
266
+ ).encode("utf-8")
267
+ receipt["receiptId"] = "sha256:" + _sha256_bytes(identity_payload)
268
+ return receipt
269
+
270
+
271
+ def _validate_environment_receipt(
272
+ receipt: Any,
273
+ *,
274
+ stack_id: str,
275
+ environment_hash: str,
276
+ ) -> bool:
277
+ if not isinstance(receipt, dict) or receipt.get("schemaVersion") != 1:
278
+ return False
279
+ if (
280
+ receipt.get("stackId") != stack_id
281
+ or receipt.get("environmentHash") != environment_hash
282
+ or not isinstance(receipt.get("content"), dict)
283
+ ):
284
+ return False
285
+ try:
286
+ canonical_content = json.dumps(
287
+ _canonical_content_evidence(receipt["content"]),
288
+ ensure_ascii=False,
289
+ sort_keys=True,
290
+ separators=(",", ":"),
291
+ ).encode("utf-8")
292
+ except ValueError:
293
+ return False
294
+ expected_fingerprint = "sha256:" + _sha256_bytes(canonical_content)
295
+ if receipt.get("contentFingerprint") != expected_fingerprint:
296
+ return False
297
+ identity_payload = json.dumps(
298
+ {
299
+ key: value
300
+ for key, value in receipt.items()
301
+ if key not in {"preparedAt", "receiptId"}
302
+ },
303
+ ensure_ascii=False,
304
+ sort_keys=True,
305
+ separators=(",", ":"),
306
+ ).encode("utf-8")
307
+ return receipt.get("receiptId") == "sha256:" + _sha256_bytes(identity_payload)
308
+
309
+
310
+ def resolve_verification_environment(
311
+ receipt: dict[str, Any],
312
+ *,
313
+ required_fields: list[str],
314
+ source_environment: dict[str, str] | None = None,
315
+ ) -> dict[str, Any]:
316
+ """Close a verification's dynamic environment over a typed receipt."""
317
+ source = source_environment if source_environment is not None else dict(os.environ)
318
+ identities = receipt.get("environmentFields")
319
+ if receipt.get("schemaVersion") != 1 or not isinstance(identities, dict):
320
+ return {
321
+ "ok": False,
322
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
323
+ "message": "environment receipt is missing typed environmentFields",
324
+ "missing": sorted(set(required_fields)),
325
+ "changed": [],
326
+ }
327
+ required = sorted({str(item).strip() for item in required_fields if str(item).strip()})
328
+ missing = [
329
+ field
330
+ for field in required
331
+ if field not in identities or field not in source or source[field] == ""
332
+ ]
333
+ changed = [
334
+ field
335
+ for field in required
336
+ if field not in missing
337
+ and identities.get(field)
338
+ != "sha256:" + _sha256_bytes(str(source[field]).encode("utf-8"))
339
+ ]
340
+ if missing or changed:
341
+ return {
342
+ "ok": False,
343
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
344
+ "message": "required dynamic environment fields are missing or changed",
345
+ "missing": missing,
346
+ "changed": changed,
347
+ }
348
+ return {
349
+ "ok": True,
350
+ "code": "VERIFICATION_ENVIRONMENT_READY",
351
+ "environment": {field: str(source[field]) for field in required},
352
+ "receiptId": receipt.get("receiptId"),
353
+ }
354
+
355
+
356
+ def _lease_path(lease_root: Path, stack_id: str) -> Path:
357
+ safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in stack_id)
358
+ if safe != stack_id:
359
+ safe = f"{safe}-{hashlib.sha256(stack_id.encode('utf-8')).hexdigest()[:12]}"
360
+ return lease_root / f"{safe}.json"
361
+
362
+
363
+ @contextmanager
364
+ def _lease_registry_guard(lease_root: Path) -> Iterator[None]:
365
+ lease_root.mkdir(parents=True, exist_ok=True)
366
+ lock_path = lease_root / ".acquire.lock"
367
+ deadline = time.monotonic() + 5.0
368
+ token = uuid.uuid4().hex
369
+ while True:
370
+ try:
371
+ fd = os.open(
372
+ lock_path,
373
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
374
+ 0o600,
375
+ )
376
+ except FileExistsError:
377
+ try:
378
+ holder = _read_json(lock_path)
379
+ except (OSError, json.JSONDecodeError):
380
+ holder = {}
381
+ pid = holder.get("pid") if isinstance(holder, dict) else None
382
+ if isinstance(pid, int) and pid > 0 and not hprocess.is_pid_alive(pid):
383
+ try:
384
+ lock_path.unlink()
385
+ continue
386
+ except OSError:
387
+ pass
388
+ if time.monotonic() >= deadline:
389
+ raise RuntimeError("ENVIRONMENT_LEASE_BUSY")
390
+ time.sleep(0.02)
391
+ continue
392
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
393
+ json.dump(
394
+ {
395
+ "schemaVersion": 1,
396
+ "pid": os.getpid(),
397
+ "token": token,
398
+ "acquiredAt": now_iso(),
399
+ },
400
+ handle,
401
+ ensure_ascii=False,
402
+ )
403
+ handle.flush()
404
+ os.fsync(handle.fileno())
405
+ break
406
+ try:
407
+ yield
408
+ finally:
409
+ try:
410
+ holder = _read_json(lock_path)
411
+ except (OSError, json.JSONDecodeError):
412
+ holder = {}
413
+ if isinstance(holder, dict) and holder.get("token") == token:
414
+ lock_path.unlink(missing_ok=True)
415
+
416
+
417
+ def _provider_operation_root(lease_root: Path) -> Path:
418
+ return lease_root / "provider-operations"
419
+
420
+
421
+ def _provider_receipt_digest(receipt: dict[str, Any]) -> str:
422
+ payload = {
423
+ key: value
424
+ for key, value in receipt.items()
425
+ if key != "receiptDigest"
426
+ }
427
+ return "sha256:" + _sha256_bytes(
428
+ json.dumps(
429
+ payload,
430
+ ensure_ascii=False,
431
+ sort_keys=True,
432
+ separators=(",", ":"),
433
+ ).encode("utf-8")
434
+ )
435
+
436
+
437
+ def execute_provider_operation(
438
+ project: Path,
439
+ *,
440
+ change_id: str,
441
+ stack_id: str,
442
+ operation: str,
443
+ argv: list[str],
444
+ lease_root: Path | None = None,
445
+ timeout_seconds: float = 900.0,
446
+ ) -> dict[str, Any]:
447
+ """Execute a reset/cleanup provider and emit a bound, durable receipt."""
448
+ if operation not in {"reset", "cleanup"}:
449
+ raise ValueError("ENVIRONMENT_PROVIDER_OPERATION_INVALID")
450
+ if (
451
+ not argv
452
+ or not all(isinstance(item, str) and item for item in argv)
453
+ or timeout_seconds <= 0
454
+ ):
455
+ raise ValueError("ENVIRONMENT_PROVIDER_ARGV_INVALID")
456
+ root = lease_root or default_lease_root(project)
457
+ lease_path = _lease_path(root, stack_id)
458
+ lease = _read_json(lease_path)
459
+ if (
460
+ not isinstance(lease, dict)
461
+ or lease.get("changeId") != change_id
462
+ or lease.get("stackId") != stack_id
463
+ or lease.get("status") != "ACTIVE"
464
+ ):
465
+ raise ValueError("ENVIRONMENT_PROVIDER_LEASE_INVALID")
466
+ executable = shutil.which(argv[0]) or str(Path(argv[0]).expanduser().resolve())
467
+ started_at = now_iso()
468
+ spawned = hprocess.spawn_structured_argv(
469
+ argv,
470
+ cwd=project.resolve(),
471
+ environment={},
472
+ owner_token=f"environment:{change_id}:{stack_id}:{uuid.uuid4().hex}",
473
+ stdout=subprocess.PIPE,
474
+ stderr=subprocess.PIPE,
475
+ )
476
+ process = spawned.process
477
+ created = hprocess.get_process_create_time(process.pid)
478
+ cleanup_complete = False
479
+ try:
480
+ stdout, stderr = process.communicate(timeout=timeout_seconds)
481
+ timed_out = False
482
+ except subprocess.TimeoutExpired:
483
+ timed_out = True
484
+ hprocess.capture_owned_members(spawned)
485
+ cleanup = hprocess.terminate_owned_tree(
486
+ spawned.attestation,
487
+ spawned.ownershipProof,
488
+ {"graceSeconds": 3.0},
489
+ )
490
+ cleanup_complete = bool(cleanup.get("cleanupComplete"))
491
+ stdout, stderr = process.communicate()
492
+ finally:
493
+ if process.poll() is None:
494
+ hprocess.capture_owned_members(spawned)
495
+ cleanup = hprocess.terminate_owned_tree(
496
+ spawned.attestation,
497
+ spawned.ownershipProof,
498
+ {"graceSeconds": 3.0},
499
+ )
500
+ cleanup_complete = bool(cleanup.get("cleanupComplete"))
501
+ spawned.close()
502
+ operation_id = uuid.uuid4().hex
503
+ receipt_path = _provider_operation_root(root) / f"{operation}-{operation_id}.json"
504
+ receipt: dict[str, Any] = {
505
+ "schemaVersion": 2,
506
+ "operation": operation,
507
+ "status": "OK" if process.returncode == 0 and not timed_out else "FAIL",
508
+ "operationId": operation_id,
509
+ "changeId": change_id,
510
+ "stackId": stack_id,
511
+ "environmentHash": str(lease.get("environmentHash") or ""),
512
+ "environmentSessionId": str(lease.get("sessionId") or ""),
513
+ "contentFingerprint": str(
514
+ lease.get("contentFingerprint") or "sha256:none"
515
+ ),
516
+ "performedAt": started_at,
517
+ "completedAt": now_iso(),
518
+ "provider": {
519
+ "pid": process.pid,
520
+ "executable": executable,
521
+ "startedAt": (
522
+ created.isoformat(timespec="seconds")
523
+ if created is not None
524
+ else started_at
525
+ ),
526
+ "argvHash": "sha256:"
527
+ + _sha256_bytes(
528
+ json.dumps(argv, ensure_ascii=False, separators=(",", ":")).encode(
529
+ "utf-8"
530
+ )
531
+ ),
532
+ "processAttestation": spawned.attestation,
533
+ "ownershipProof": spawned.ownershipProof,
534
+ "cleanupComplete": cleanup_complete or process.poll() is not None,
535
+ },
536
+ "exitCode": process.returncode,
537
+ "timedOut": timed_out,
538
+ "stdoutDigest": "sha256:" + _sha256_bytes(stdout),
539
+ "stderrDigest": "sha256:" + _sha256_bytes(stderr),
540
+ "receiptPath": str(receipt_path.resolve()),
541
+ }
542
+ receipt["receiptDigest"] = _provider_receipt_digest(receipt)
543
+ _write_json(receipt_path, receipt)
544
+ return receipt
545
+
546
+
547
+ def _operation_evidence(
548
+ evidence: dict[str, Any] | None,
549
+ *,
550
+ operation: str,
551
+ lease_root: Path,
552
+ lease: dict[str, Any],
553
+ ) -> dict[str, Any] | None:
554
+ if not isinstance(evidence, dict):
555
+ return None
556
+ raw_path = str(evidence.get("receiptPath") or "").strip()
557
+ if not raw_path:
558
+ return None
559
+ receipt_path = Path(raw_path).expanduser().resolve()
560
+ provider_root = _provider_operation_root(lease_root).resolve()
561
+ try:
562
+ receipt_path.relative_to(provider_root)
563
+ except ValueError:
564
+ return None
565
+ try:
566
+ stored = _read_json(receipt_path)
567
+ except (OSError, json.JSONDecodeError):
568
+ return None
569
+ if stored != evidence or not isinstance(stored, dict):
570
+ return None
571
+ performed_at = _parse_expiry(stored.get("performedAt"))
572
+ age_seconds = (
573
+ (dt.datetime.now(dt.timezone.utc) - performed_at).total_seconds()
574
+ if performed_at is not None
575
+ else 1_000_000
576
+ )
577
+ if (
578
+ stored.get("schemaVersion") != 2
579
+ or stored.get("operation") != operation
580
+ or stored.get("status") != "OK"
581
+ or stored.get("changeId") != lease.get("changeId")
582
+ or stored.get("stackId") != lease.get("stackId")
583
+ or stored.get("environmentHash") != lease.get("environmentHash")
584
+ or stored.get("environmentSessionId") != lease.get("sessionId")
585
+ or stored.get("contentFingerprint")
586
+ != str(lease.get("contentFingerprint") or "sha256:none")
587
+ or not str(stored.get("operationId") or "").strip()
588
+ or not isinstance(stored.get("provider"), dict)
589
+ or not str(stored["provider"].get("executable") or "").strip()
590
+ or not str(stored["provider"].get("argvHash") or "").startswith("sha256:")
591
+ or stored.get("exitCode") != 0
592
+ or stored.get("timedOut") is not False
593
+ or age_seconds < -5
594
+ or age_seconds > 900
595
+ or stored.get("receiptDigest") != _provider_receipt_digest(stored)
596
+ ):
597
+ return None
598
+ consumed = lease_root / ".consumed-provider-operations" / (
599
+ hashlib.sha256(str(stored["operationId"]).encode("utf-8")).hexdigest()
600
+ + ".json"
601
+ )
602
+ if consumed.exists():
603
+ return None
604
+ return stored
605
+
606
+
607
+ def _consume_operation_evidence(
608
+ lease_root: Path,
609
+ evidence: dict[str, Any],
610
+ ) -> bool:
611
+ consumed = lease_root / ".consumed-provider-operations" / (
612
+ hashlib.sha256(str(evidence["operationId"]).encode("utf-8")).hexdigest()
613
+ + ".json"
614
+ )
615
+ consumed.parent.mkdir(parents=True, exist_ok=True)
616
+ try:
617
+ descriptor = os.open(
618
+ consumed,
619
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
620
+ 0o600,
621
+ )
622
+ except FileExistsError:
623
+ return False
624
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
625
+ json.dump(
626
+ {
627
+ "schemaVersion": 1,
628
+ "operationId": evidence["operationId"],
629
+ "receiptDigest": evidence["receiptDigest"],
630
+ "consumedAt": now_iso(),
631
+ },
632
+ stream,
633
+ ensure_ascii=False,
634
+ )
635
+ stream.flush()
636
+ os.fsync(stream.fileno())
637
+ return True
638
+
639
+
640
+ def _write_environment_action_receipt(
641
+ lease_root: Path,
642
+ *,
643
+ action: str,
644
+ lease: dict[str, Any],
645
+ previous_session_id: str | None = None,
646
+ receipt_dir: Path | None = None,
647
+ operation_evidence: dict[str, Any] | None = None,
648
+ ) -> Path:
649
+ receipt = {
650
+ "schemaVersion": 1,
651
+ "action": action,
652
+ "recordedAt": now_iso(),
653
+ "changeId": lease.get("changeId"),
654
+ "stackId": lease.get("stackId"),
655
+ "sessionId": lease.get("sessionId"),
656
+ "mode": lease.get("mode"),
657
+ "environmentHash": lease.get("environmentHash"),
658
+ "contentFingerprint": lease.get("contentFingerprint"),
659
+ }
660
+ if previous_session_id:
661
+ receipt["previousSessionId"] = previous_session_id
662
+ if operation_evidence is not None:
663
+ receipt["operationEvidence"] = operation_evidence
664
+ receipt_dir = receipt_dir or lease_root.parent / "environment-receipts"
665
+ stack_digest = hashlib.sha256(
666
+ str(lease.get("stackId") or "").encode("utf-8")
667
+ ).hexdigest()[:12]
668
+ target = receipt_dir / f"{action}-{stack_digest}-{uuid.uuid4().hex}.json"
669
+ _write_json(target, receipt)
670
+ return target
671
+
672
+
673
+ def list_leases(lease_root: Path) -> list[dict[str, Any]]:
674
+ if not lease_root.is_dir():
675
+ return []
676
+ out: list[dict[str, Any]] = []
677
+ for path in sorted(lease_root.glob("*.json")):
678
+ try:
679
+ data = _read_json(path)
680
+ except (OSError, json.JSONDecodeError):
681
+ continue
682
+ if isinstance(data, dict):
683
+ out.append(data)
684
+ return out
685
+
686
+
687
+ def _parse_expiry(value: Any) -> dt.datetime | None:
688
+ if not isinstance(value, str) or not value.strip():
689
+ return None
690
+ try:
691
+ parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
692
+ except ValueError:
693
+ return None
694
+ if parsed.tzinfo is None:
695
+ return parsed.replace(tzinfo=dt.timezone.utc)
696
+ return parsed.astimezone(dt.timezone.utc)
697
+
698
+
699
+ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
700
+ """Classify leases without mutating or terminating any owner process."""
701
+ results: list[dict[str, Any]] = []
702
+ if not lease_root.is_dir():
703
+ return results
704
+ now = dt.datetime.now(dt.timezone.utc)
705
+ for path in sorted(lease_root.glob("*.json")):
706
+ try:
707
+ lease = _read_json(path)
708
+ except (OSError, json.JSONDecodeError) as exc:
709
+ results.append(
710
+ {
711
+ "stackId": path.stem,
712
+ "classification": "LEASE_UNREADABLE",
713
+ "message": str(exc),
714
+ "sourcePath": str(path),
715
+ }
716
+ )
717
+ continue
718
+ if not isinstance(lease, dict):
719
+ continue
720
+ item = {
721
+ "stackId": str(lease.get("stackId") or path.stem),
722
+ "changeId": lease.get("changeId"),
723
+ "expiresAt": lease.get("expiresAt"),
724
+ "sourcePath": str(path),
725
+ }
726
+ expires_at = _parse_expiry(lease.get("expiresAt"))
727
+ if expires_at is None:
728
+ item["classification"] = "LEASE_EXPIRY_INVALID"
729
+ results.append(item)
730
+ continue
731
+ unexpired = now < expires_at
732
+ owner = lease.get("owner")
733
+ if not isinstance(owner, dict) or any(
734
+ not owner.get(field) for field in ("pid", "executable", "startedAt")
735
+ ):
736
+ if unexpired:
737
+ item["classification"] = (
738
+ "STALE_CONTENT"
739
+ if lease.get("status") == "STALE_CONTENT"
740
+ else "ACTIVE"
741
+ )
742
+ results.append(item)
743
+ continue
744
+ item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
745
+ results.append(item)
746
+ continue
747
+ try:
748
+ owner_pid = int(owner["pid"])
749
+ except (TypeError, ValueError):
750
+ if unexpired:
751
+ item["classification"] = (
752
+ "STALE_CONTENT"
753
+ if lease.get("status") == "STALE_CONTENT"
754
+ else "ACTIVE"
755
+ )
756
+ results.append(item)
757
+ continue
758
+ item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
759
+ results.append(item)
760
+ continue
761
+ if not hprocess.is_pid_alive(owner_pid):
762
+ item["classification"] = "RECLAIMABLE"
763
+ results.append(item)
764
+ continue
765
+ if unexpired:
766
+ item["classification"] = (
767
+ "STALE_CONTENT"
768
+ if lease.get("status") == "STALE_CONTENT"
769
+ else "ACTIVE"
770
+ )
771
+ results.append(item)
772
+ continue
773
+ identity = _verify_recorded_owner(owner)
774
+ item["classification"] = (
775
+ "OWNER_ACTIVE"
776
+ if identity is True
777
+ else (
778
+ "OWNER_IDENTITY_UNCONFIRMED"
779
+ if identity is None
780
+ else "OWNER_IDENTITY_MISMATCH"
781
+ )
782
+ )
783
+ results.append(item)
784
+ return results
785
+
786
+
787
+ def reap_expired_leases(lease_root: Path) -> dict[str, Any]:
788
+ """Remove only leases whose recorded owner is definitely gone."""
789
+ leases = classify_leases(lease_root)
790
+ reaped: list[str] = []
791
+ for item in leases:
792
+ if item.get("classification") != "RECLAIMABLE":
793
+ continue
794
+ path = Path(str(item["sourcePath"]))
795
+ try:
796
+ path.unlink()
797
+ except FileNotFoundError:
798
+ pass
799
+ reaped.append(str(item["stackId"]))
800
+ receipt = {
801
+ "schemaVersion": 1,
802
+ "action": "environment-lease-reap",
803
+ "completedAt": now_iso(),
804
+ "reaped": reaped,
805
+ "leases": leases,
806
+ }
807
+ receipt_path = (
808
+ lease_root
809
+ / "reap-receipts"
810
+ / f"{dt.datetime.now().strftime('%Y%m%dT%H%M%S')}-{os.getpid()}.json"
811
+ )
812
+ _write_json(receipt_path, receipt)
813
+ return {
814
+ "ok": True,
815
+ "code": "LEASE_REAP_COMPLETE",
816
+ "reaped": reaped,
817
+ "leases": leases,
818
+ "receiptPath": str(receipt_path),
819
+ }
820
+
821
+
822
+ def require_writable_lease(
823
+ project: Path,
824
+ *,
825
+ change_id: str,
826
+ stack_id: str,
827
+ lease_root: Path | None = None,
828
+ environment_receipt: dict[str, Any] | None = None,
829
+ ) -> dict[str, Any]:
830
+ """Gate: a writable stack may be used only with an active matching lease."""
831
+ root = lease_root or default_lease_root(project)
832
+ path = _lease_path(root, stack_id)
833
+ if not path.is_file():
834
+ return {
835
+ "ok": False,
836
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
837
+ "message": f"no lease for stack={stack_id}; acquire before writable use",
838
+ "changeId": change_id,
839
+ "stackId": stack_id,
840
+ }
841
+ try:
842
+ lease = _read_json(path)
843
+ except (OSError, json.JSONDecodeError) as exc:
844
+ return {
845
+ "ok": False,
846
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
847
+ "message": f"lease unreadable: {exc}",
848
+ "changeId": change_id,
849
+ "stackId": stack_id,
850
+ }
851
+ if str(lease.get("changeId") or "") != change_id:
852
+ return {
853
+ "ok": False,
854
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
855
+ "message": (
856
+ f"stack={stack_id} leased by change={lease.get('changeId')}; "
857
+ f"requested change={change_id}"
858
+ ),
859
+ "changeId": change_id,
860
+ "stackId": stack_id,
861
+ "holder": lease.get("changeId"),
862
+ }
863
+ # Review Y2: expired leases must not remain writable.
864
+ expires_raw = lease.get("expiresAt")
865
+ if isinstance(expires_raw, str) and expires_raw.strip():
866
+ expires_at = _parse_expiry(expires_raw)
867
+ if expires_at is None:
868
+ return {
869
+ "ok": False,
870
+ "code": "ENVIRONMENT_LEASE_REQUIRED",
871
+ "message": f"lease expiresAt unreadable: {expires_raw}",
872
+ "changeId": change_id,
873
+ "stackId": stack_id,
874
+ }
875
+ if dt.datetime.now(dt.timezone.utc) >= expires_at:
876
+ return {
877
+ "ok": False,
878
+ "code": "ENVIRONMENT_LEASE_EXPIRED",
879
+ "message": (
880
+ f"lease for stack={stack_id} expired at {expires_raw}; "
881
+ "re-acquire before writable use"
882
+ ),
883
+ "changeId": change_id,
884
+ "stackId": stack_id,
885
+ "expiresAt": expires_raw,
886
+ }
887
+ if lease.get("status") == "STALE_CONTENT":
888
+ return {
889
+ "ok": False,
890
+ "code": "STALE_CONTENT",
891
+ "message": "lease content was previously marked stale; prepare again",
892
+ "lease": lease,
893
+ }
894
+ expected_content = str(lease.get("contentFingerprint") or "").strip()
895
+ if expected_content:
896
+ if not isinstance(environment_receipt, dict):
897
+ return {
898
+ "ok": False,
899
+ "code": "ENVIRONMENT_CONTENT_RECEIPT_REQUIRED",
900
+ "message": "current typed environment receipt is required before reuse",
901
+ "changeId": change_id,
902
+ "stackId": stack_id,
903
+ }
904
+ if not _validate_environment_receipt(
905
+ environment_receipt,
906
+ stack_id=stack_id,
907
+ environment_hash=str(lease.get("environmentHash") or ""),
908
+ ):
909
+ return {
910
+ "ok": False,
911
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
912
+ "message": "environment receipt digest or identity is invalid",
913
+ }
914
+ observed_content = str(
915
+ environment_receipt.get("contentFingerprint") or ""
916
+ ).strip()
917
+ canaries = (
918
+ environment_receipt.get("content", {}).get("canaries")
919
+ if isinstance(environment_receipt.get("content"), dict)
920
+ else None
921
+ )
922
+ canaries_ok = isinstance(canaries, list) and all(
923
+ isinstance(item, dict) and item.get("status") == "PASS"
924
+ for item in canaries
925
+ )
926
+ if observed_content != expected_content or not canaries_ok:
927
+ lease["status"] = "STALE_CONTENT"
928
+ lease["staleAt"] = now_iso()
929
+ lease["expectedContentFingerprint"] = expected_content
930
+ lease["observedContentFingerprint"] = observed_content
931
+ _write_json(path, lease)
932
+ return {
933
+ "ok": False,
934
+ "code": "STALE_CONTENT",
935
+ "message": "environment content fingerprint or canary changed",
936
+ "expectedContentFingerprint": expected_content,
937
+ "observedContentFingerprint": observed_content,
938
+ }
939
+ lease["heartbeatAt"] = now_iso()
940
+ _write_json(path, lease)
941
+ return {"ok": True, "code": "LEASE_HELD", "lease": lease}
942
+
943
+
944
+ def _acquire_lease_locked(
945
+ project: Path,
946
+ *,
947
+ change_id: str,
948
+ stack_id: str,
949
+ environment_hash: str,
950
+ lease_root: Path | None = None,
951
+ writable_volumes: list[str] | None = None,
952
+ ttl_seconds: int = 3600,
953
+ environment_receipt: dict[str, Any] | None = None,
954
+ mode: str = "change-session",
955
+ reset_evidence: dict[str, Any] | None = None,
956
+ receipt_dir: Path | None = None,
957
+ ) -> dict[str, Any]:
958
+ """Acquire, reuse, or reset an isolated environment session."""
959
+ if mode not in {"change-session", "ephemeral"}:
960
+ return {
961
+ "ok": False,
962
+ "code": "ENVIRONMENT_SESSION_MODE_INVALID",
963
+ "message": f"unsupported environment session mode: {mode}",
964
+ }
965
+ root = lease_root or default_lease_root(project)
966
+ root.mkdir(parents=True, exist_ok=True)
967
+ volumes = [str(v).replace("\\", "/") for v in (writable_volumes or [])]
968
+ for existing in list_leases(root):
969
+ holder = str(existing.get("changeId") or "")
970
+ if holder == change_id:
971
+ continue
972
+ other_vols = {
973
+ str(v).replace("\\", "/")
974
+ for v in (existing.get("writableVolumes") or [])
975
+ }
976
+ overlap = sorted(set(volumes) & other_vols)
977
+ if overlap:
978
+ return {
979
+ "ok": False,
980
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
981
+ "message": (
982
+ f"writable volume(s) {overlap} already held by change={holder}"
983
+ ),
984
+ "changeId": change_id,
985
+ "holder": holder,
986
+ "overlap": overlap,
987
+ }
988
+ if existing.get("stackId") == stack_id and holder and holder != change_id:
989
+ return {
990
+ "ok": False,
991
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
992
+ "message": f"stack={stack_id} already leased by change={holder}",
993
+ "changeId": change_id,
994
+ "holder": holder,
995
+ }
996
+
997
+ path = _lease_path(root, stack_id)
998
+ current: dict[str, Any] = {}
999
+ if path.is_file():
1000
+ try:
1001
+ current = _read_json(path)
1002
+ except (OSError, json.JSONDecodeError) as exc:
1003
+ return {
1004
+ "ok": False,
1005
+ "code": "ENVIRONMENT_LEASE_UNREADABLE",
1006
+ "message": str(exc),
1007
+ }
1008
+ if str(current.get("changeId") or "") not in {"", change_id}:
1009
+ return {
1010
+ "ok": False,
1011
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
1012
+ "message": (
1013
+ f"stack={stack_id} already leased by change={current.get('changeId')}"
1014
+ ),
1015
+ "changeId": change_id,
1016
+ "holder": current.get("changeId"),
1017
+ }
1018
+
1019
+ started = dt.datetime.now().astimezone()
1020
+ expires = started + dt.timedelta(seconds=max(60, int(ttl_seconds)))
1021
+ if environment_receipt is not None:
1022
+ if not _validate_environment_receipt(
1023
+ environment_receipt,
1024
+ stack_id=stack_id,
1025
+ environment_hash=environment_hash,
1026
+ ):
1027
+ return {
1028
+ "ok": False,
1029
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
1030
+ "message": "environment receipt digest or identity does not match",
1031
+ }
1032
+ canaries = (
1033
+ environment_receipt.get("content", {}).get("canaries")
1034
+ if isinstance(environment_receipt.get("content"), dict)
1035
+ else None
1036
+ )
1037
+ if not isinstance(canaries, list) or not canaries or any(
1038
+ not isinstance(item, dict) or item.get("status") != "PASS"
1039
+ for item in canaries
1040
+ ):
1041
+ return {
1042
+ "ok": False,
1043
+ "code": "ENVIRONMENT_CONTENT_NOT_READY",
1044
+ "message": "all environment content canaries must pass before acquire",
1045
+ }
1046
+ current_fingerprint = str(current.get("contentFingerprint") or "").strip()
1047
+ requested_fingerprint = str(
1048
+ (environment_receipt or {}).get("contentFingerprint") or ""
1049
+ ).strip()
1050
+ same_content = bool(current_fingerprint) and (
1051
+ current_fingerprint == requested_fingerprint
1052
+ )
1053
+ owner = current.get("owner")
1054
+ owner_verified = False
1055
+ if isinstance(owner, dict):
1056
+ try:
1057
+ owner_pid = int(owner.get("pid"))
1058
+ except (TypeError, ValueError):
1059
+ owner_pid = 0
1060
+ owner_verified = _verify_recorded_owner(owner) is True
1061
+ expires_at = _parse_expiry(current.get("expiresAt"))
1062
+ unexpired = (
1063
+ expires_at is not None
1064
+ and dt.datetime.now(dt.timezone.utc) < expires_at
1065
+ )
1066
+ if (
1067
+ current
1068
+ and str(current.get("changeId") or "") == change_id
1069
+ and mode == "change-session"
1070
+ and str(current.get("mode") or "change-session") == "change-session"
1071
+ and current.get("status") == "ACTIVE"
1072
+ and current.get("environmentHash") == environment_hash
1073
+ and current.get("writableVolumes") == volumes
1074
+ and owner_verified
1075
+ and unexpired
1076
+ and same_content
1077
+ ):
1078
+ current["heartbeatAt"] = started.isoformat(timespec="seconds")
1079
+ current["expiresAt"] = expires.isoformat(timespec="seconds")
1080
+ current["reuseCount"] = int(current.get("reuseCount") or 0) + 1
1081
+ _write_json(path, current)
1082
+ action_receipt = _write_environment_action_receipt(
1083
+ root,
1084
+ action="reuse",
1085
+ lease=current,
1086
+ receipt_dir=receipt_dir,
1087
+ )
1088
+ return {
1089
+ "ok": True,
1090
+ "code": "LEASE_REUSED",
1091
+ "lease": current,
1092
+ "path": str(path),
1093
+ "receiptPath": str(action_receipt),
1094
+ }
1095
+ previous_session_id = str(current.get("sessionId") or "").strip() or None
1096
+ reset_count = int(current.get("resetCount") or 0)
1097
+ is_reset = bool(current)
1098
+ reset_operation = _operation_evidence(
1099
+ reset_evidence,
1100
+ operation="reset",
1101
+ lease_root=root,
1102
+ lease=current,
1103
+ )
1104
+ if is_reset and reset_operation is None:
1105
+ return {
1106
+ "ok": False,
1107
+ "code": "ENVIRONMENT_RESET_REQUIRED",
1108
+ "message": (
1109
+ "existing environment cannot be replaced until a matching "
1110
+ "provider reset receipt is supplied"
1111
+ ),
1112
+ "changeId": change_id,
1113
+ "stackId": stack_id,
1114
+ "previousSessionId": previous_session_id,
1115
+ }
1116
+ if is_reset and not _consume_operation_evidence(root, reset_operation):
1117
+ return {
1118
+ "ok": False,
1119
+ "code": "ENVIRONMENT_RESET_EVIDENCE_REPLAYED",
1120
+ "message": "provider reset evidence was already consumed",
1121
+ }
1122
+ lease = {
1123
+ "schemaVersion": 3,
1124
+ "changeId": change_id,
1125
+ "stackId": stack_id,
1126
+ "sessionId": uuid.uuid4().hex,
1127
+ "mode": mode,
1128
+ "environmentHash": environment_hash,
1129
+ "writableVolumes": volumes,
1130
+ "acquiredAt": started.isoformat(timespec="seconds"),
1131
+ "heartbeatAt": started.isoformat(timespec="seconds"),
1132
+ "expiresAt": expires.isoformat(timespec="seconds"),
1133
+ "projectRoot": str(project.resolve()),
1134
+ "status": "ACTIVE",
1135
+ "reuseCount": 0,
1136
+ "resetCount": reset_count + (1 if is_reset else 0),
1137
+ "owner": {
1138
+ "pid": os.getpid(),
1139
+ "executable": hprocess.get_process_executable(os.getpid())
1140
+ or str(Path(sys.executable).resolve()),
1141
+ "startedAt": (
1142
+ hprocess.get_process_create_time(os.getpid())
1143
+ or started
1144
+ ).isoformat(timespec="seconds"),
1145
+ },
1146
+ }
1147
+ if environment_receipt is not None:
1148
+ lease["contentFingerprint"] = environment_receipt["contentFingerprint"]
1149
+ lease["environmentReceiptId"] = environment_receipt.get("receiptId")
1150
+ _write_json(path, lease)
1151
+ action = "reset" if is_reset else "prepare"
1152
+ action_receipt = _write_environment_action_receipt(
1153
+ root,
1154
+ action=action,
1155
+ lease=lease,
1156
+ previous_session_id=previous_session_id,
1157
+ receipt_dir=receipt_dir,
1158
+ operation_evidence=reset_operation,
1159
+ )
1160
+ return {
1161
+ "ok": True,
1162
+ "code": "LEASE_RESET" if is_reset else "LEASE_ACQUIRED",
1163
+ "lease": lease,
1164
+ "path": str(path),
1165
+ "receiptPath": str(action_receipt),
1166
+ }
1167
+
1168
+
1169
+ def acquire_lease(
1170
+ project: Path,
1171
+ *,
1172
+ change_id: str,
1173
+ stack_id: str,
1174
+ environment_hash: str,
1175
+ lease_root: Path | None = None,
1176
+ writable_volumes: list[str] | None = None,
1177
+ ttl_seconds: int = 3600,
1178
+ environment_receipt: dict[str, Any] | None = None,
1179
+ mode: str = "change-session",
1180
+ reset_evidence: dict[str, Any] | None = None,
1181
+ ) -> dict[str, Any]:
1182
+ root = lease_root or default_lease_root(project)
1183
+ receipt_dir: Path | None = None
1184
+ if lease_root is None:
1185
+ contract_dir = project / ".harness" / "changes" / change_id
1186
+ receipt_dir = (
1187
+ resolve_state_dir_for_contract(contract_dir, project)
1188
+ / "runtime"
1189
+ / "environment-receipts"
1190
+ )
1191
+ try:
1192
+ with _lease_registry_guard(root):
1193
+ return _acquire_lease_locked(
1194
+ project,
1195
+ change_id=change_id,
1196
+ stack_id=stack_id,
1197
+ environment_hash=environment_hash,
1198
+ lease_root=root,
1199
+ writable_volumes=writable_volumes,
1200
+ ttl_seconds=ttl_seconds,
1201
+ environment_receipt=environment_receipt,
1202
+ mode=mode,
1203
+ reset_evidence=reset_evidence,
1204
+ receipt_dir=receipt_dir,
1205
+ )
1206
+ except RuntimeError as exc:
1207
+ if str(exc) != "ENVIRONMENT_LEASE_BUSY":
1208
+ raise
1209
+ return {
1210
+ "ok": False,
1211
+ "code": "ENVIRONMENT_LEASE_BUSY",
1212
+ "message": "another lease acquisition is still in progress",
1213
+ }
1214
+
1215
+
1216
+ def _release_lease_locked(
1217
+ project: Path,
1218
+ *,
1219
+ change_id: str,
1220
+ stack_id: str,
1221
+ lease_root: Path | None = None,
1222
+ cleanup_evidence: dict[str, Any] | None = None,
1223
+ receipt_dir: Path | None = None,
1224
+ ) -> dict[str, Any]:
1225
+ root = lease_root or default_lease_root(project)
1226
+ path = _lease_path(root, stack_id)
1227
+ if not path.is_file():
1228
+ return {
1229
+ "ok": True,
1230
+ "code": "LEASE_ABSENT",
1231
+ "message": "nothing to release",
1232
+ "changeId": change_id,
1233
+ "stackId": stack_id,
1234
+ }
1235
+ try:
1236
+ lease = _read_json(path)
1237
+ except (OSError, json.JSONDecodeError) as exc:
1238
+ return {"ok": False, "code": "LEASE_UNREADABLE", "message": str(exc)}
1239
+ if str(lease.get("changeId") or "") != change_id:
1240
+ return {
1241
+ "ok": False,
1242
+ "code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
1243
+ "message": "cannot release lease owned by another change",
1244
+ "holder": lease.get("changeId"),
1245
+ "changeId": change_id,
1246
+ }
1247
+ cleanup_operation = _operation_evidence(
1248
+ cleanup_evidence,
1249
+ operation="cleanup",
1250
+ lease_root=root,
1251
+ lease=lease,
1252
+ )
1253
+ if cleanup_evidence is not None and cleanup_operation is None:
1254
+ return {
1255
+ "ok": False,
1256
+ "code": "ENVIRONMENT_CLEANUP_EVIDENCE_INVALID",
1257
+ "message": "provider cleanup evidence does not match the held lease",
1258
+ }
1259
+ if cleanup_operation is not None and not _consume_operation_evidence(
1260
+ root, cleanup_operation
1261
+ ):
1262
+ return {
1263
+ "ok": False,
1264
+ "code": "ENVIRONMENT_CLEANUP_EVIDENCE_REPLAYED",
1265
+ "message": "provider cleanup evidence was already consumed",
1266
+ }
1267
+ action_receipt = _write_environment_action_receipt(
1268
+ root,
1269
+ action="cleanup" if cleanup_operation is not None else "release",
1270
+ lease=lease,
1271
+ receipt_dir=receipt_dir,
1272
+ operation_evidence=cleanup_operation,
1273
+ )
1274
+ path.unlink(missing_ok=True)
1275
+ return {
1276
+ "ok": True,
1277
+ "code": "LEASE_RELEASED",
1278
+ "changeId": change_id,
1279
+ "stackId": stack_id,
1280
+ "receiptPath": str(action_receipt),
1281
+ }
1282
+
1283
+
1284
+ def release_lease(
1285
+ project: Path,
1286
+ *,
1287
+ change_id: str,
1288
+ stack_id: str,
1289
+ lease_root: Path | None = None,
1290
+ cleanup_evidence: dict[str, Any] | None = None,
1291
+ ) -> dict[str, Any]:
1292
+ root = lease_root or default_lease_root(project)
1293
+ receipt_dir: Path | None = None
1294
+ if lease_root is None:
1295
+ contract_dir = project / ".harness" / "changes" / change_id
1296
+ receipt_dir = (
1297
+ resolve_state_dir_for_contract(contract_dir, project)
1298
+ / "runtime"
1299
+ / "environment-receipts"
1300
+ )
1301
+ try:
1302
+ with _lease_registry_guard(root):
1303
+ return _release_lease_locked(
1304
+ project,
1305
+ change_id=change_id,
1306
+ stack_id=stack_id,
1307
+ lease_root=root,
1308
+ cleanup_evidence=cleanup_evidence,
1309
+ receipt_dir=receipt_dir,
1310
+ )
1311
+ except RuntimeError as exc:
1312
+ if str(exc) != "ENVIRONMENT_LEASE_BUSY":
1313
+ raise
1314
+ return {
1315
+ "ok": False,
1316
+ "code": "ENVIRONMENT_LEASE_BUSY",
1317
+ "message": "another lease operation is still in progress",
1318
+ }
1319
+
1320
+
1321
+ def cmd_fingerprint(args: argparse.Namespace) -> int:
1322
+ project = Path(args.project).resolve()
1323
+ digest = compute_environment_hash(project)
1324
+ return emit_json(
1325
+ {
1326
+ "ok": True,
1327
+ "action": "fingerprint",
1328
+ "projectRoot": str(project),
1329
+ "environmentHash": digest,
1330
+ }
1331
+ )
1332
+
1333
+
1334
+ def _load_receipt_arg(value: str | None) -> dict[str, Any] | None:
1335
+ if not value:
1336
+ return None
1337
+ path = Path(value).resolve()
1338
+ data = _read_json(path)
1339
+ if not isinstance(data, dict):
1340
+ raise ValueError(f"environment receipt must be a JSON object: {path}")
1341
+ return data
1342
+
1343
+
1344
+ def cmd_prepare(args: argparse.Namespace) -> int:
1345
+ project = Path(args.project).resolve()
1346
+ evidence_path = Path(args.content_evidence_file).resolve()
1347
+ try:
1348
+ evidence = _read_json(evidence_path)
1349
+ except (OSError, json.JSONDecodeError) as exc:
1350
+ return emit_json(
1351
+ {
1352
+ "ok": False,
1353
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
1354
+ "message": str(exc),
1355
+ },
1356
+ ok=False,
1357
+ )
1358
+ if not isinstance(evidence, dict):
1359
+ return emit_json(
1360
+ {
1361
+ "ok": False,
1362
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
1363
+ "message": "content evidence must be a JSON object",
1364
+ },
1365
+ ok=False,
1366
+ )
1367
+ try:
1368
+ canonical_evidence = _canonical_content_evidence(evidence)
1369
+ except ValueError as exc:
1370
+ return emit_json(
1371
+ {
1372
+ "ok": False,
1373
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
1374
+ "message": str(exc),
1375
+ },
1376
+ ok=False,
1377
+ )
1378
+ missing_content = [
1379
+ field
1380
+ for field in REQUIRED_CONTENT_EVIDENCE_FIELDS
1381
+ if not canonical_evidence.get(field)
1382
+ ]
1383
+ if not canonical_evidence.get("canaries"):
1384
+ missing_content.append("canaries")
1385
+ if missing_content:
1386
+ return emit_json(
1387
+ {
1388
+ "ok": False,
1389
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INCOMPLETE",
1390
+ "message": "formal environment receipts require complete content evidence",
1391
+ "missing": missing_content,
1392
+ },
1393
+ ok=False,
1394
+ )
1395
+ field_names = sorted(
1396
+ {str(item).strip() for item in (args.environment_field or []) if str(item).strip()}
1397
+ )
1398
+ missing = [field for field in field_names if not os.environ.get(field)]
1399
+ if missing:
1400
+ return emit_json(
1401
+ {
1402
+ "ok": False,
1403
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
1404
+ "message": "declared environment fields are missing",
1405
+ "missing": missing,
1406
+ },
1407
+ ok=False,
1408
+ )
1409
+ powershell = {
1410
+ "edition": str(
1411
+ args.powershell_edition
1412
+ or os.environ.get("HARNESS_POWERSHELL_EDITION")
1413
+ or ""
1414
+ ),
1415
+ "version": str(
1416
+ args.powershell_version
1417
+ or os.environ.get("HARNESS_POWERSHELL_VERSION")
1418
+ or ""
1419
+ ),
1420
+ }
1421
+ if bool(powershell["edition"]) != bool(powershell["version"]):
1422
+ return emit_json(
1423
+ {
1424
+ "ok": False,
1425
+ "code": "POWERSHELL_RUNTIME_INCOMPLETE",
1426
+ "message": "PowerShell edition and version must be recorded together",
1427
+ },
1428
+ ok=False,
1429
+ )
1430
+ try:
1431
+ receipt = create_environment_receipt(
1432
+ project,
1433
+ stack_id=args.stack_id,
1434
+ content_evidence=evidence,
1435
+ environment_values={field: os.environ[field] for field in field_names},
1436
+ powershell=powershell,
1437
+ )
1438
+ except ValueError as exc:
1439
+ return emit_json(
1440
+ {
1441
+ "ok": False,
1442
+ "code": "ENVIRONMENT_CONTENT_EVIDENCE_INVALID",
1443
+ "message": str(exc),
1444
+ },
1445
+ ok=False,
1446
+ )
1447
+ output = Path(args.output).resolve()
1448
+ _write_json(output, receipt)
1449
+ return emit_json(
1450
+ {
1451
+ "ok": True,
1452
+ "code": "ENVIRONMENT_RECEIPT_PREPARED",
1453
+ "receiptId": receipt["receiptId"],
1454
+ "contentFingerprint": receipt["contentFingerprint"],
1455
+ "output": str(output),
1456
+ }
1457
+ )
1458
+
1459
+
1460
+ def cmd_acquire(args: argparse.Namespace) -> int:
1461
+ project = Path(args.project).resolve()
1462
+ root = Path(args.lease_root).resolve() if args.lease_root else None
1463
+ env_hash = args.environment_hash or compute_environment_hash(project)
1464
+ volumes = [v for v in (args.writable_volume or []) if v]
1465
+ try:
1466
+ receipt = _load_receipt_arg(args.environment_receipt)
1467
+ reset_evidence = _load_receipt_arg(args.reset_evidence)
1468
+ result = acquire_lease(
1469
+ project,
1470
+ change_id=args.change,
1471
+ stack_id=args.stack_id,
1472
+ environment_hash=env_hash,
1473
+ lease_root=root,
1474
+ writable_volumes=volumes,
1475
+ ttl_seconds=int(args.ttl_seconds or 3600),
1476
+ environment_receipt=receipt,
1477
+ mode=args.mode,
1478
+ reset_evidence=reset_evidence,
1479
+ )
1480
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
1481
+ result = {
1482
+ "ok": False,
1483
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
1484
+ "message": str(exc),
1485
+ }
1486
+ return emit_json(result, ok=bool(result.get("ok")))
1487
+
1488
+
1489
+ def cmd_release(args: argparse.Namespace) -> int:
1490
+ project = Path(args.project).resolve()
1491
+ root = Path(args.lease_root).resolve() if args.lease_root else None
1492
+ try:
1493
+ cleanup_evidence = _load_receipt_arg(args.cleanup_evidence)
1494
+ result = release_lease(
1495
+ project,
1496
+ change_id=args.change,
1497
+ stack_id=args.stack_id,
1498
+ lease_root=root,
1499
+ cleanup_evidence=cleanup_evidence,
1500
+ )
1501
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
1502
+ result = {
1503
+ "ok": False,
1504
+ "code": "ENVIRONMENT_CLEANUP_EVIDENCE_INVALID",
1505
+ "message": str(exc),
1506
+ }
1507
+ return emit_json(result, ok=bool(result.get("ok")))
1508
+
1509
+
1510
+ def cmd_provider_operation(args: argparse.Namespace) -> int:
1511
+ project = Path(args.project).resolve()
1512
+ root = Path(args.lease_root).resolve() if args.lease_root else None
1513
+ try:
1514
+ argv = json.loads(args.argv_json)
1515
+ if not isinstance(argv, list):
1516
+ raise ValueError("provider argv must be a JSON array")
1517
+ result = execute_provider_operation(
1518
+ project,
1519
+ change_id=args.change,
1520
+ stack_id=args.stack_id,
1521
+ operation=args.operation,
1522
+ argv=argv,
1523
+ lease_root=root,
1524
+ timeout_seconds=float(args.timeout_seconds),
1525
+ )
1526
+ except (
1527
+ OSError,
1528
+ json.JSONDecodeError,
1529
+ ValueError,
1530
+ subprocess.SubprocessError,
1531
+ ) as exc:
1532
+ return emit_json(
1533
+ {
1534
+ "ok": False,
1535
+ "code": "ENVIRONMENT_PROVIDER_OPERATION_FAILED",
1536
+ "message": str(exc),
1537
+ },
1538
+ ok=False,
1539
+ )
1540
+ if args.output:
1541
+ _write_json(Path(args.output).resolve(), result)
1542
+ return emit_json(
1543
+ {
1544
+ "ok": result.get("status") == "OK",
1545
+ "code": (
1546
+ "ENVIRONMENT_PROVIDER_OPERATION_OK"
1547
+ if result.get("status") == "OK"
1548
+ else "ENVIRONMENT_PROVIDER_OPERATION_FAILED"
1549
+ ),
1550
+ "receiptPath": result.get("receiptPath"),
1551
+ "receiptDigest": result.get("receiptDigest"),
1552
+ },
1553
+ ok=result.get("status") == "OK",
1554
+ )
1555
+
1556
+
1557
+ def cmd_require(args: argparse.Namespace) -> int:
1558
+ project = Path(args.project).resolve()
1559
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
1560
+ try:
1561
+ receipt = _load_receipt_arg(args.environment_receipt)
1562
+ result = require_writable_lease(
1563
+ project,
1564
+ change_id=args.change,
1565
+ stack_id=args.stack_id,
1566
+ lease_root=root,
1567
+ environment_receipt=receipt,
1568
+ )
1569
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
1570
+ result = {
1571
+ "ok": False,
1572
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
1573
+ "message": str(exc),
1574
+ }
1575
+ return emit_json(result, ok=bool(result.get("ok")))
1576
+
1577
+
1578
+ def cmd_status(args: argparse.Namespace) -> int:
1579
+ project = Path(args.project).resolve()
1580
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
1581
+ return emit_json(
1582
+ {
1583
+ "ok": True,
1584
+ "action": "status",
1585
+ "projectRoot": str(project),
1586
+ "environmentHash": compute_environment_hash(project),
1587
+ "leases": classify_leases(root),
1588
+ "leaseRoot": str(root),
1589
+ "contract": [
1590
+ "prepare",
1591
+ "fingerprint",
1592
+ "acquire",
1593
+ "reset/clone",
1594
+ "run",
1595
+ "record",
1596
+ "release",
1597
+ ],
1598
+ }
1599
+ )
1600
+
1601
+
1602
+ def cmd_reap_expired(args: argparse.Namespace) -> int:
1603
+ project = Path(args.project).resolve()
1604
+ root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
1605
+ return emit_json(reap_expired_leases(root))
1606
+
1607
+
1608
+ def build_parser() -> argparse.ArgumentParser:
1609
+ parser = argparse.ArgumentParser(prog="harness_environment.py")
1610
+ sub = parser.add_subparsers(dest="command", required=True)
1611
+
1612
+ prep = sub.add_parser("prepare")
1613
+ prep.add_argument("--project", required=True)
1614
+ prep.add_argument("--stack-id", required=True)
1615
+ prep.add_argument("--content-evidence-file", required=True)
1616
+ prep.add_argument("--environment-field", action="append", default=[])
1617
+ prep.add_argument("--powershell-edition")
1618
+ prep.add_argument("--powershell-version")
1619
+ prep.add_argument("--output", required=True)
1620
+ prep.set_defaults(func=cmd_prepare)
1621
+
1622
+ fp = sub.add_parser("fingerprint")
1623
+ fp.add_argument("--project", required=True)
1624
+ fp.set_defaults(func=cmd_fingerprint)
1625
+
1626
+ ac = sub.add_parser("acquire")
1627
+ ac.add_argument("--project", required=True)
1628
+ ac.add_argument("--change", required=True)
1629
+ ac.add_argument("--stack-id", required=True)
1630
+ ac.add_argument("--environment-hash")
1631
+ ac.add_argument("--environment-receipt")
1632
+ ac.add_argument("--reset-evidence")
1633
+ ac.add_argument("--lease-root")
1634
+ ac.add_argument("--writable-volume", action="append", default=[])
1635
+ ac.add_argument("--ttl-seconds", type=int, default=3600)
1636
+ ac.add_argument(
1637
+ "--mode",
1638
+ choices=("change-session", "ephemeral"),
1639
+ default="change-session",
1640
+ )
1641
+ ac.set_defaults(func=cmd_acquire)
1642
+
1643
+ rel = sub.add_parser("release")
1644
+ rel.add_argument("--project", required=True)
1645
+ rel.add_argument("--change", required=True)
1646
+ rel.add_argument("--stack-id", required=True)
1647
+ rel.add_argument("--lease-root")
1648
+ rel.add_argument("--cleanup-evidence")
1649
+ rel.set_defaults(func=cmd_release)
1650
+
1651
+ provider = sub.add_parser("provider-operation")
1652
+ provider.add_argument("--project", required=True)
1653
+ provider.add_argument("--change", required=True)
1654
+ provider.add_argument("--stack-id", required=True)
1655
+ provider.add_argument("--operation", choices=("reset", "cleanup"), required=True)
1656
+ provider.add_argument("--argv-json", required=True)
1657
+ provider.add_argument("--lease-root")
1658
+ provider.add_argument("--timeout-seconds", type=float, default=900.0)
1659
+ provider.add_argument("--output")
1660
+ provider.set_defaults(func=cmd_provider_operation)
1661
+
1662
+ req = sub.add_parser("require")
1663
+ req.add_argument("--project", required=True)
1664
+ req.add_argument("--change", required=True)
1665
+ req.add_argument("--stack-id", required=True)
1666
+ req.add_argument("--lease-root")
1667
+ req.add_argument("--environment-receipt")
1668
+ req.set_defaults(func=cmd_require)
1669
+
1670
+ st = sub.add_parser("status")
1671
+ st.add_argument("--project", required=True)
1672
+ st.add_argument("--lease-root")
1673
+ st.set_defaults(func=cmd_status)
1674
+
1675
+ reap = sub.add_parser("reap-expired")
1676
+ reap.add_argument("--project", required=True)
1677
+ reap.add_argument("--lease-root")
1678
+ reap.set_defaults(func=cmd_reap_expired)
1679
+
1680
+ return parser
1681
+
1682
+
1683
+ def main(argv: list[str] | None = None) -> int:
1684
+ parser = build_parser()
1685
+ args = parser.parse_args(argv)
1686
+ return int(args.func(args))
1687
+
1688
+
1689
+ if __name__ == "__main__":
1690
+ raise SystemExit(main())