@hunter-harness/workflow-harness 0.4.0 → 0.4.2

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 (386) 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-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
  33. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  34. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
  50. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  51. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
  67. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  68. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
  69. package/harness/bundles/general/pi/.harness-build.json +6 -0
  70. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  71. package/harness/bundles/general/pi/README.md +478 -0
  72. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  73. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  74. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  75. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  76. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  77. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  78. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  79. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  80. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  81. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  82. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  83. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  84. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  85. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  86. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  87. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  88. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  89. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  90. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  91. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  92. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  93. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  94. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  99. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  100. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  101. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  102. package/harness/bundles/general/pi/harness-plan/SKILL.md +157 -0
  103. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  104. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  105. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  106. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  107. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  108. package/harness/bundles/general/pi/harness-review/SKILL.md +166 -0
  109. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  110. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  111. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  112. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  113. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  114. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  115. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  116. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  117. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  118. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  119. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  120. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  121. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  122. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  123. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  124. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  125. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  126. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  127. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  128. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  129. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  130. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  132. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  133. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  134. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  135. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  136. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  137. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  138. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  139. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  140. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  141. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  142. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  144. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  145. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  146. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  147. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  148. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  149. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  150. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  151. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  152. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  153. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  155. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  156. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  157. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  158. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  159. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  160. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  161. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  162. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  163. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  164. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  165. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  166. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  167. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  168. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  169. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  170. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  171. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  172. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  173. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  174. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  175. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  176. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  177. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  178. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  179. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  180. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  181. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  182. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  184. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  185. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  190. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  199. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  200. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  201. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  202. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  203. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  204. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
  217. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  218. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  219. package/harness/bundles/java/codex/.harness-build.json +1 -1
  220. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  221. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  222. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  223. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  225. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  226. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  227. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  228. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  229. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  230. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  231. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  232. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  235. package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
  236. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  237. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
  255. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  256. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
  257. package/harness/bundles/java/pi/.harness-build.json +6 -0
  258. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  259. package/harness/bundles/java/pi/README.md +478 -0
  260. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  261. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  262. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  263. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  264. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  265. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  266. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  267. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  268. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  269. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  270. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  271. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  272. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  273. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  274. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  275. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  276. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  277. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  278. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  279. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  280. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  281. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  282. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  283. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  284. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  285. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  286. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  287. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  288. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  289. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  290. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  291. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  292. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  293. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  294. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  295. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  296. package/harness/bundles/java/pi/harness-plan/SKILL.md +157 -0
  297. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  298. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  299. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  300. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  301. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  302. package/harness/bundles/java/pi/harness-review/SKILL.md +166 -0
  303. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  304. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  305. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  306. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  307. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  308. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  309. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  310. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  311. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  312. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  313. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  314. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  315. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  316. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  317. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  318. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  319. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  320. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  321. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  322. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  323. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  324. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  325. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  326. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  327. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  328. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  329. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  330. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  331. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  332. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  333. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  334. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  335. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  336. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  337. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  338. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  339. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  340. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  341. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  342. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  343. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  344. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  345. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  346. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  347. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  348. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  349. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  350. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  351. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  352. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  353. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  354. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  355. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  356. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  357. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  358. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  359. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  360. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  361. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  362. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  363. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  364. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  365. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  366. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  367. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  368. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  369. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  370. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  371. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  372. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  373. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  374. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  375. package/harness/manifests/general/claude-code.json +17 -17
  376. package/harness/manifests/general/codebuddy.json +17 -17
  377. package/harness/manifests/general/codex.json +17 -17
  378. package/harness/manifests/general/cursor.json +17 -17
  379. package/harness/manifests/general/pi.json +475 -0
  380. package/harness/manifests/java/claude-code.json +19 -19
  381. package/harness/manifests/java/codebuddy.json +19 -19
  382. package/harness/manifests/java/codex.json +19 -19
  383. package/harness/manifests/java/cursor.json +19 -19
  384. package/harness/manifests/java/pi.json +499 -0
  385. package/hunter-workflow-family.json +2 -2
  386. package/package.json +1 -1
@@ -0,0 +1,1626 @@
1
+ #!/usr/bin/env python3
2
+ """Harness change resolution, leases, ports, and integration lock.
3
+
4
+ Subcommands:
5
+ list — list active changes
6
+ resolve — resolve explicit or sole active change-id
7
+ migrate — backfill change metadata without touching business files
8
+ claim / release — per-change phase lease
9
+ lease-port — assign an unused port from a configured range
10
+ integration-lock — acquire|release global main-branch integration lock
11
+
12
+ When run from a git worktree, state is resolved via git common dir to the main
13
+ project root (.harness/changes lives there). Python 3.10+, stdlib only.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import contextlib
20
+ import datetime as dt
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import uuid
29
+ from pathlib import Path
30
+ from typing import Any, Callable, Mapping
31
+
32
+ SCRIPTS_DIR = Path(__file__).resolve().parent
33
+ if str(SCRIPTS_DIR) not in sys.path:
34
+ sys.path.insert(0, str(SCRIPTS_DIR))
35
+
36
+ import harness_paths # noqa: E402
37
+
38
+ if hasattr(sys.stdout, "reconfigure"):
39
+ sys.stdout.reconfigure(encoding="utf-8")
40
+ if hasattr(sys.stderr, "reconfigure"):
41
+ sys.stderr.reconfigure(encoding="utf-8")
42
+
43
+
44
+ RUNTIME_REL = Path(".harness") / "runtime"
45
+ LEASES_REL = RUNTIME_REL / "leases"
46
+ PORTS_REL = RUNTIME_REL / "ports"
47
+ INTEGRATION_LOCK_REL = RUNTIME_REL / "integration-lock.json"
48
+ CHANGE_CONTEXT_REL = Path("meta") / "change-context.json"
49
+ WORKTREE_META_REL = Path("meta") / "worktree.json"
50
+ CHECKPOINTS_REL = Path("meta") / "implementation-checkpoints.json"
51
+
52
+
53
+ def now_iso() -> str:
54
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
55
+
56
+
57
+ def sha256_file(path: Path) -> str:
58
+ digest = hashlib.sha256()
59
+ with path.open("rb") as handle:
60
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
61
+ digest.update(chunk)
62
+ return digest.hexdigest()
63
+
64
+
65
+ def emit(payload: dict[str, Any], *, as_json: bool) -> None:
66
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
67
+ sys.stdout.write(text if as_json else _human_line(payload))
68
+
69
+
70
+ def _human_line(payload: dict[str, Any]) -> str:
71
+ if payload.get("ok"):
72
+ code = payload.get("code", "OK")
73
+ change = payload.get("changeId") or payload.get("change")
74
+ if change:
75
+ return f"ok code={code} change={change}\n"
76
+ return f"ok code={code}\n"
77
+ return f"error code={payload.get('code', 'ERROR')} message={payload.get('message')}\n"
78
+
79
+
80
+ def emit_error(
81
+ code: str,
82
+ message: str,
83
+ *,
84
+ as_json: bool,
85
+ extra: dict[str, Any] | None = None,
86
+ exit_code: int = 1,
87
+ ) -> int:
88
+ payload: dict[str, Any] = {"ok": False, "code": code, "message": message}
89
+ if extra:
90
+ payload.update(extra)
91
+ if as_json:
92
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
93
+ else:
94
+ sys.stderr.write(f"error: {message} ({code})\n")
95
+ return exit_code
96
+
97
+
98
+ def _git_text(cwd: Path, *args: str) -> str | None:
99
+ proc = subprocess.run(
100
+ ["git", *args],
101
+ cwd=str(cwd),
102
+ capture_output=True,
103
+ text=True,
104
+ encoding="utf-8",
105
+ errors="replace",
106
+ check=False,
107
+ )
108
+ if proc.returncode != 0:
109
+ return None
110
+ return proc.stdout.strip()
111
+
112
+
113
+ def resolve_main_project_root(cwd: Path | None = None) -> Path:
114
+ """Locate main project root from a worktree or main checkout."""
115
+ start = (cwd or Path.cwd()).resolve()
116
+ common_raw = _git_text(start, "rev-parse", "--git-common-dir")
117
+ if not common_raw:
118
+ return start
119
+ common = Path(common_raw)
120
+ if not common.is_absolute():
121
+ common = (start / common).resolve()
122
+ if common.name == ".git":
123
+ return common.parent
124
+ return start
125
+
126
+
127
+ def changes_dir(project_root: Path) -> Path:
128
+ return project_root / ".harness" / "changes"
129
+
130
+
131
+ def runtime_dir(project_root: Path) -> Path:
132
+ return project_root / RUNTIME_REL
133
+
134
+
135
+ def _read_json(path: Path) -> Any:
136
+ return json.loads(path.read_text(encoding="utf-8-sig"))
137
+
138
+
139
+ def _write_json(path: Path, data: Any) -> None:
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
142
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
143
+ try:
144
+ tmp.write_text(text, encoding="utf-8", newline="\n")
145
+ os.replace(tmp, path)
146
+ except BaseException:
147
+ tmp.unlink(missing_ok=True)
148
+ raise
149
+
150
+
151
+ @contextlib.contextmanager
152
+ def _exclusive_file_lock(path: Path, wait_seconds: float = 5.0):
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ deadline = time.monotonic() + wait_seconds
155
+ descriptor: int | None = None
156
+ while descriptor is None:
157
+ try:
158
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
159
+ except (FileExistsError, PermissionError):
160
+ if time.monotonic() >= deadline:
161
+ raise TimeoutError(f"lock unavailable: {path}")
162
+ time.sleep(0.01)
163
+ try:
164
+ os.write(descriptor, f"{os.getpid()}\n".encode("ascii"))
165
+ yield
166
+ finally:
167
+ os.close(descriptor)
168
+ path.unlink(missing_ok=True)
169
+
170
+
171
+ def list_active_changes(project_root: Path) -> list[dict[str, Any]]:
172
+ root = changes_dir(project_root)
173
+ if not root.is_dir():
174
+ return []
175
+ active: list[dict[str, Any]] = []
176
+ for entry in sorted(root.iterdir()):
177
+ if not entry.is_dir():
178
+ continue
179
+ meta_archived = entry / "meta" / "archived.json"
180
+ if meta_archived.is_file():
181
+ try:
182
+ archived = _read_json(meta_archived)
183
+ except (OSError, json.JSONDecodeError):
184
+ archived = {}
185
+ if isinstance(archived, dict) and archived.get("status") == "archived":
186
+ continue
187
+ has_plan = any((entry / "plans").glob("*-plan.md"))
188
+ has_context = (entry / CHANGE_CONTEXT_REL).is_file()
189
+ has_checkpoints = (entry / CHECKPOINTS_REL).is_file()
190
+ worktree_path = entry / WORKTREE_META_REL
191
+ has_active_worktree = False
192
+ if worktree_path.is_file():
193
+ try:
194
+ worktree = _read_json(worktree_path)
195
+ has_active_worktree = isinstance(worktree, dict) and (
196
+ worktree.get("created") is True or
197
+ worktree.get("requested") is True
198
+ )
199
+ except (OSError, json.JSONDecodeError):
200
+ has_active_worktree = False
201
+ # Runtime notes, event logs and requested=false worktree metadata can
202
+ # survive archive/submit cleanup. They are residues, not active changes.
203
+ if not (has_plan or has_context or has_checkpoints or has_active_worktree):
204
+ continue
205
+ active.append(
206
+ {
207
+ "changeId": entry.name,
208
+ "path": str(entry.resolve()),
209
+ "hasPlan": has_plan,
210
+ "hasWorktreeMeta": worktree_path.is_file(),
211
+ }
212
+ )
213
+ return active
214
+
215
+
216
+ def _verified_archive_receipts(project_root: Path) -> dict[str, dict[str, Any]]:
217
+ results: dict[str, dict[str, Any]] = {}
218
+ archive_root = project_root / ".harness" / "archive"
219
+ if not archive_root.is_dir():
220
+ return results
221
+ for archive_dir in sorted(archive_root.iterdir()):
222
+ if not archive_dir.is_dir():
223
+ continue
224
+ receipt_path = archive_dir / "meta" / "archive-receipt.json"
225
+ summary_path = archive_dir / "reports" / "final" / "summary-data.json"
226
+ if not receipt_path.is_file() or not summary_path.is_file():
227
+ continue
228
+ try:
229
+ receipt = _read_json(receipt_path)
230
+ summary = _read_json(summary_path)
231
+ except (OSError, json.JSONDecodeError):
232
+ continue
233
+ if not isinstance(receipt, dict) or not isinstance(summary, dict):
234
+ continue
235
+ change_name = str(
236
+ receipt.get("changeName") or summary.get("changeName") or ""
237
+ ).strip()
238
+ if not change_name or receipt.get("status") != "archived":
239
+ continue
240
+ expected = str(receipt.get("summarySha256") or "").removeprefix("sha256:")
241
+ actual = sha256_file(summary_path)
242
+ results[change_name] = {
243
+ "archivePath": str(archive_dir.resolve()),
244
+ "receiptPath": str(receipt_path.resolve()),
245
+ "summaryPath": str(summary_path.resolve()),
246
+ "expectedSha256": expected,
247
+ "actualSha256": actual,
248
+ "verified": bool(expected) and expected == actual,
249
+ }
250
+ return results
251
+
252
+
253
+ def classify_changes(project_root: Path) -> list[dict[str, Any]]:
254
+ """Classify every change directory without deleting or rewriting anything."""
255
+ root = changes_dir(project_root)
256
+ if not root.is_dir():
257
+ return []
258
+ active_ids = {
259
+ item["changeId"] for item in list_active_changes(project_root)
260
+ }
261
+ archive_receipts = _verified_archive_receipts(project_root)
262
+ results: list[dict[str, Any]] = []
263
+ for entry in sorted(root.iterdir()):
264
+ if not entry.is_dir():
265
+ continue
266
+ reasons: list[str] = []
267
+ evidence: list[str] = []
268
+ archive = archive_receipts.get(entry.name)
269
+ if archive is not None:
270
+ evidence.extend([archive["receiptPath"], archive["summaryPath"]])
271
+ if archive["verified"]:
272
+ status = "ARCHIVED_LEFTOVER"
273
+ reasons.append("ARCHIVE_RECEIPT_VERIFIED")
274
+ else:
275
+ status = "INVALID"
276
+ reasons.append("ARCHIVE_HASH_MISMATCH")
277
+ elif entry.name in active_ids:
278
+ status = "ACTIVE"
279
+ reasons.append("ACTIVE_CHANGE_EVIDENCE")
280
+ else:
281
+ archived_meta = entry / "meta" / "archived.json"
282
+ if archived_meta.is_file():
283
+ try:
284
+ archived = _read_json(archived_meta)
285
+ except (OSError, json.JSONDecodeError):
286
+ archived = None
287
+ if not isinstance(archived, dict):
288
+ status = "INVALID"
289
+ reasons.append("ARCHIVED_METADATA_INVALID")
290
+ elif archived.get("status") == "archived":
291
+ status = "ARCHIVED_LEFTOVER"
292
+ reasons.append("LOCAL_ARCHIVED_MARKER")
293
+ evidence.append(str(archived_meta.resolve()))
294
+ else:
295
+ status = "INVALID"
296
+ reasons.append("ARCHIVED_METADATA_CONTRADICTORY")
297
+ elif (
298
+ (entry / "events.ndjson").is_file()
299
+ or (entry / "runtime").is_dir()
300
+ or (entry / "evidence").is_dir()
301
+ ):
302
+ status = "RECOVERABLE"
303
+ reasons.append("INTERRUPTED_CHANGE_EVIDENCE")
304
+ else:
305
+ status = "ORPHAN"
306
+ reasons.append("NO_ACTIVITY_OR_ARCHIVE_EVIDENCE")
307
+ results.append({
308
+ "changeId": entry.name,
309
+ "path": str(entry.resolve()),
310
+ "status": status,
311
+ "reasonCodes": reasons,
312
+ "evidence": evidence,
313
+ "safeToCleanup": status == "ARCHIVED_LEFTOVER",
314
+ })
315
+ return results
316
+
317
+
318
+ def cleanup_changes(project_root: Path, *, apply: bool = False) -> dict[str, Any]:
319
+ """Safely quarantine only hash-verified archived leftovers."""
320
+ statuses = classify_changes(project_root)
321
+ eligible = sorted(
322
+ item["changeId"]
323
+ for item in statuses
324
+ if item["status"] == "ARCHIVED_LEFTOVER"
325
+ and "ARCHIVE_RECEIPT_VERIFIED" in item["reasonCodes"]
326
+ )
327
+ moved: list[dict[str, str]] = []
328
+ if apply and eligible:
329
+ stamp = now_iso().replace(":", "-").replace(".", "-")
330
+ quarantine = (
331
+ project_root / ".harness" / "runtime"
332
+ / "change-cleanup" / stamp
333
+ )
334
+ quarantine.mkdir(parents=True, exist_ok=True)
335
+ for change_id in eligible:
336
+ source = changes_dir(project_root) / change_id
337
+ target = quarantine / change_id
338
+ if not source.is_dir() or target.exists():
339
+ continue
340
+ shutil.move(str(source), str(target))
341
+ moved.append({
342
+ "changeId": change_id,
343
+ "from": str(source),
344
+ "quarantine": str(target),
345
+ })
346
+ return {
347
+ "ok": True,
348
+ "dryRun": not apply,
349
+ "eligible": eligible,
350
+ "moved": moved,
351
+ "recoverable": True,
352
+ }
353
+
354
+
355
+ def change_dir_for_id(project_root: Path, change_id: str) -> Path | None:
356
+ candidate = changes_dir(project_root) / change_id
357
+ if candidate.is_dir():
358
+ return candidate.resolve()
359
+ return None
360
+
361
+
362
+ def _layout_fields(project_root: Path, change_id: str) -> dict[str, Any]:
363
+ """Layout enrichment for resolve payloads; empty on any resolution issue."""
364
+ try:
365
+ layout = harness_paths.resolve_change_layout(project_root, change_id)
366
+ except (FileNotFoundError, ValueError, OSError):
367
+ return {}
368
+ return {
369
+ "contractRoot": layout["contractRoot"],
370
+ "stateRoot": layout["stateRoot"],
371
+ "layout": layout["layout"],
372
+ "repositoryId": layout["repositoryId"],
373
+ }
374
+
375
+
376
+ def read_concurrency_mode(project_root: Path) -> str:
377
+ """Return the configured concurrency mode (retro §5.2).
378
+
379
+ Defaults to ``single-active`` when no config declares a mode. Supported
380
+ values: ``single-active`` (default, blocks a second active Change),
381
+ ``isolated-multi-active`` (allows multiple active Changes but all
382
+ Change-scoped commands require ``--change``).
383
+ """
384
+ configs = [
385
+ project_root / ".harness" / "config" / "harness.json",
386
+ project_root / ".harness" / "config.json",
387
+ ]
388
+ for cfg in configs:
389
+ if not cfg.is_file():
390
+ continue
391
+ try:
392
+ data = json.loads(cfg.read_text(encoding="utf-8"))
393
+ if isinstance(data, dict):
394
+ mode = data.get("concurrencyMode")
395
+ if isinstance(mode, str) and mode in {
396
+ "single-active",
397
+ "isolated-multi-active",
398
+ }:
399
+ return mode
400
+ except (OSError, json.JSONDecodeError):
401
+ continue
402
+ return "single-active"
403
+
404
+
405
+ def check_concurrency_block(
406
+ project_root: Path, change_id: str
407
+ ) -> dict[str, Any] | None:
408
+ """Return a blocking payload when a second active Change is forbidden.
409
+
410
+ Returns ``None`` when the begin may proceed. In ``single-active`` mode a
411
+ second active Change (any other than ``change_id``) blocks begin; in
412
+ ``isolated-multi-active`` mode multiple active Changes are allowed.
413
+ """
414
+ mode = read_concurrency_mode(project_root)
415
+ if mode == "isolated-multi-active":
416
+ return None
417
+ active = list_active_changes(project_root)
418
+ others = [entry for entry in active if entry.get("changeId") != change_id]
419
+ if not others:
420
+ return None
421
+ return {
422
+ "ok": False,
423
+ "code": "SINGLE_ACTIVE_BLOCKED",
424
+ "message": (
425
+ "single-active concurrency mode: another active Change exists; "
426
+ "use portfolio/decompose to sequence multiple Changes, or switch "
427
+ "to isolated-multi-active after ensuring full Change-scoped isolation"
428
+ ),
429
+ "concurrencyMode": mode,
430
+ "activeChanges": active,
431
+ "blockingChanges": others,
432
+ }
433
+
434
+
435
+ def resolve_change(
436
+ project_root: Path,
437
+ change_id: str | None,
438
+ ) -> dict[str, Any]:
439
+ active = list_active_changes(project_root)
440
+ if change_id:
441
+ resolved = change_dir_for_id(project_root, change_id)
442
+ if resolved is None:
443
+ return {
444
+ "ok": False,
445
+ "code": "CHANGE_NOT_FOUND",
446
+ "message": f"change not found: {change_id}",
447
+ "changeId": change_id,
448
+ }
449
+ return {
450
+ "ok": True,
451
+ "code": "RESOLVED",
452
+ "changeId": change_id,
453
+ "changeDir": str(resolved),
454
+ "projectRoot": str(project_root.resolve()),
455
+ "activeCount": len(active),
456
+ **_layout_fields(project_root, change_id),
457
+ }
458
+ if not active:
459
+ return {
460
+ "ok": False,
461
+ "code": "NO_ACTIVE_CHANGE",
462
+ "message": "no active change under .harness/changes",
463
+ "activeChanges": [],
464
+ }
465
+ if len(active) == 1:
466
+ only = active[0]
467
+ return {
468
+ "ok": True,
469
+ "code": "RESOLVED",
470
+ "changeId": only["changeId"],
471
+ "changeDir": only["path"],
472
+ "projectRoot": str(project_root.resolve()),
473
+ "activeCount": 1,
474
+ "autoSelected": True,
475
+ **_layout_fields(project_root, only["changeId"]),
476
+ }
477
+ return {
478
+ "ok": False,
479
+ "code": "CHANGE_SELECTION_REQUIRED",
480
+ "message": "multiple active changes; pass --change <id>",
481
+ "activeChanges": active,
482
+ "activeCount": len(active),
483
+ }
484
+
485
+
486
+ def migrate_change(project_root: Path, change_id: str) -> dict[str, Any]:
487
+ resolved = resolve_change(project_root, change_id)
488
+ if not resolved.get("ok"):
489
+ return resolved
490
+ change_dir = Path(resolved["changeDir"])
491
+ created: list[str] = []
492
+
493
+ context_path = change_dir / CHANGE_CONTEXT_REL
494
+ if not context_path.is_file():
495
+ worktree_meta = change_dir / WORKTREE_META_REL
496
+ worktree_root = project_root.resolve()
497
+ branch = _git_text(project_root, "rev-parse", "--abbrev-ref", "HEAD") or ""
498
+ if worktree_meta.is_file():
499
+ try:
500
+ wt = _read_json(worktree_meta)
501
+ if isinstance(wt, dict):
502
+ if isinstance(wt.get("path"), str) and wt["path"].strip():
503
+ worktree_root = Path(wt["path"]).expanduser().resolve()
504
+ elif isinstance(wt.get("worktreeRoot"), str) and wt["worktreeRoot"].strip():
505
+ worktree_root = Path(wt["worktreeRoot"]).expanduser().resolve()
506
+ if isinstance(wt.get("branch"), str) and wt["branch"].strip():
507
+ branch = wt["branch"].strip()
508
+ except (OSError, json.JSONDecodeError):
509
+ pass
510
+ context = {
511
+ "schemaVersion": 1,
512
+ "changeId": change_id,
513
+ "mainProjectRoot": str(project_root.resolve()),
514
+ "worktreeRoot": str(worktree_root),
515
+ "stateDir": str(change_dir.resolve()),
516
+ "branch": branch,
517
+ "migratedAt": now_iso(),
518
+ }
519
+ _write_json(context_path, context)
520
+ created.append(str(context_path.relative_to(change_dir)))
521
+
522
+ checkpoints_path = change_dir / CHECKPOINTS_REL
523
+ if not checkpoints_path.is_file():
524
+ checkpoints = {
525
+ "schemaVersion": 1,
526
+ "changeName": change_id,
527
+ "checkpoints": [
528
+ {
529
+ "id": "foundation-gate",
530
+ "afterTasks": [1, 2, 3, 4],
531
+ "beforeTasks": [6, 7, 8, 9, 10],
532
+ "reviewerTool": "codex",
533
+ "status": "pending",
534
+ "blocking": True,
535
+ "requiredReport": "reports/review/foundation-gate-review.md",
536
+ "purpose": "Block tasks 6+ until foundation interfaces are reviewed.",
537
+ }
538
+ ],
539
+ }
540
+ _write_json(checkpoints_path, checkpoints)
541
+ created.append(str(checkpoints_path.relative_to(change_dir)))
542
+
543
+ return {
544
+ "ok": True,
545
+ "code": "MIGRATED",
546
+ "changeId": change_id,
547
+ "changeDir": str(change_dir),
548
+ "created": created,
549
+ }
550
+
551
+
552
+ def _lease_path(project_root: Path, change_id: str) -> Path:
553
+ return project_root / LEASES_REL / f"{change_id}.json"
554
+
555
+
556
+ def _lease_expired(lease: dict[str, Any]) -> bool:
557
+ expires = lease.get("expiresAt")
558
+ if not isinstance(expires, str) or not expires.strip():
559
+ return True
560
+ try:
561
+ exp_dt = dt.datetime.fromisoformat(expires.replace("Z", "+00:00"))
562
+ except ValueError:
563
+ return True
564
+ return dt.datetime.now().astimezone() >= exp_dt
565
+
566
+
567
+ def inspect_lease_state(project_root: Path, change_id: str) -> dict[str, Any]:
568
+ """Read the lease without mutating it, keeping the three failure modes apart.
569
+
570
+ ``inspect_lease`` collapses absent / expired / corrupt into ``None``, so a
571
+ caller cannot tell "nobody ever held this" from "the phase outlived its
572
+ TTL". Those need different answers: an expired lease still carries the
573
+ runId that proves who owns the phase, a corrupt one proves nothing, and an
574
+ absent one may just mean the phase already closed.
575
+
576
+ state is one of ``active`` | ``expired`` | ``absent`` | ``corrupt``. The
577
+ lease dict is returned for ``active`` and ``expired``; ``None`` otherwise.
578
+ """
579
+ path = _lease_path(project_root, change_id)
580
+ if not path.is_file():
581
+ return {"state": "absent", "lease": None}
582
+ try:
583
+ lease = _read_json(path)
584
+ except (OSError, json.JSONDecodeError):
585
+ return {"state": "corrupt", "lease": None}
586
+ if not isinstance(lease, dict):
587
+ return {"state": "corrupt", "lease": None}
588
+ if _lease_expired(lease):
589
+ return {"state": "expired", "lease": lease}
590
+ return {"state": "active", "lease": lease}
591
+
592
+
593
+ def inspect_lease(project_root: Path, change_id: str) -> dict[str, Any] | None:
594
+ """Return the current non-expired lease without mutating it."""
595
+ state = inspect_lease_state(project_root, change_id)
596
+ return state["lease"] if state["state"] == "active" else None
597
+
598
+
599
+ def _claim_lease_locked(
600
+ project_root: Path,
601
+ *,
602
+ change_id: str,
603
+ phase: str,
604
+ run_id: str,
605
+ ttl_seconds: int,
606
+ steal: bool = False,
607
+ expected_generation: int | None = None,
608
+ ) -> dict[str, Any]:
609
+ path = _lease_path(project_root, change_id)
610
+ path.parent.mkdir(parents=True, exist_ok=True)
611
+ now = dt.datetime.now().astimezone()
612
+ expires = now + dt.timedelta(seconds=max(1, ttl_seconds))
613
+ new_lease = {
614
+ "leaseId": str(uuid.uuid4()),
615
+ "changeId": change_id,
616
+ "phase": phase,
617
+ "runId": run_id,
618
+ "pid": os.getpid(),
619
+ "acquiredAt": now.isoformat(timespec="milliseconds"),
620
+ "expiresAt": expires.isoformat(timespec="milliseconds"),
621
+ "ttlSeconds": ttl_seconds,
622
+ "generation": 1,
623
+ }
624
+ if path.is_file():
625
+ try:
626
+ existing = _read_json(path)
627
+ except (OSError, json.JSONDecodeError):
628
+ existing = None
629
+ if isinstance(existing, dict) and not _lease_expired(existing):
630
+ if expected_generation is not None and existing.get("generation") != expected_generation:
631
+ return {
632
+ "ok": False,
633
+ "code": "LEASE_GENERATION_CONFLICT",
634
+ "holder": existing,
635
+ }
636
+ same_owner = str(existing.get("runId")) == run_id
637
+ if same_owner:
638
+ existing.update(
639
+ {
640
+ "phase": phase,
641
+ "pid": os.getpid(),
642
+ "expiresAt": expires.isoformat(timespec="milliseconds"),
643
+ "refreshedAt": now.isoformat(timespec="milliseconds"),
644
+ "generation": int(existing.get("generation") or 1) + 1,
645
+ }
646
+ )
647
+ _write_json(path, existing)
648
+ return {"ok": True, "code": "LEASE_REFRESHED", "lease": existing}
649
+ if not steal:
650
+ return {
651
+ "ok": False,
652
+ "code": "LEASE_CONFLICT",
653
+ "message": "change lease held by another run",
654
+ "holder": existing,
655
+ }
656
+ _write_json(path, new_lease)
657
+ return {"ok": True, "code": "LEASE_CLAIMED", "lease": new_lease}
658
+
659
+
660
+ def claim_lease(
661
+ project_root: Path,
662
+ *,
663
+ change_id: str,
664
+ phase: str,
665
+ run_id: str,
666
+ ttl_seconds: int,
667
+ steal: bool = False,
668
+ expected_generation: int | None = None,
669
+ ) -> dict[str, Any]:
670
+ path = _lease_path(project_root, change_id)
671
+ with _exclusive_file_lock(path.with_suffix(".lock")):
672
+ return _claim_lease_locked(
673
+ project_root,
674
+ change_id=change_id,
675
+ phase=phase,
676
+ run_id=run_id,
677
+ ttl_seconds=ttl_seconds,
678
+ steal=steal,
679
+ expected_generation=expected_generation,
680
+ )
681
+
682
+
683
+ def _release_lease_locked(
684
+ project_root: Path,
685
+ *,
686
+ change_id: str,
687
+ phase: str,
688
+ run_id: str,
689
+ lease_id: str | None = None,
690
+ generation: int | None = None,
691
+ ) -> dict[str, Any]:
692
+ path = _lease_path(project_root, change_id)
693
+ if not path.is_file():
694
+ return {
695
+ "ok": True,
696
+ "code": "LEASE_ABSENT",
697
+ "message": "no lease file present",
698
+ }
699
+ try:
700
+ existing = _read_json(path)
701
+ except (OSError, json.JSONDecodeError) as exc:
702
+ return {
703
+ "ok": False,
704
+ "code": "LEASE_INVALID",
705
+ "message": str(exc),
706
+ }
707
+ if not isinstance(existing, dict):
708
+ return {"ok": False, "code": "LEASE_INVALID", "message": "lease is not an object"}
709
+ if str(existing.get("runId")) != run_id:
710
+ return {
711
+ "ok": False,
712
+ "code": "LEASE_OWNER_MISMATCH",
713
+ "message": "run id does not match lease owner",
714
+ "holder": existing,
715
+ }
716
+ if str(existing.get("phase")) != phase:
717
+ return {
718
+ "ok": False,
719
+ "code": "LEASE_PHASE_MISMATCH",
720
+ "message": f"lease phase is {existing.get('phase')}, not {phase}",
721
+ "holder": existing,
722
+ }
723
+ if lease_id is not None and str(existing.get("leaseId")) != lease_id:
724
+ return {
725
+ "ok": False,
726
+ "code": "LEASE_ID_MISMATCH",
727
+ "holder": existing,
728
+ }
729
+ if generation is not None and existing.get("generation") != generation:
730
+ return {
731
+ "ok": False,
732
+ "code": "LEASE_GENERATION_CONFLICT",
733
+ "holder": existing,
734
+ }
735
+ path.unlink(missing_ok=True)
736
+ return {"ok": True, "code": "LEASE_RELEASED", "changeId": change_id, "phase": phase}
737
+
738
+
739
+ def release_lease(
740
+ project_root: Path,
741
+ *,
742
+ change_id: str,
743
+ phase: str,
744
+ run_id: str,
745
+ lease_id: str | None = None,
746
+ generation: int | None = None,
747
+ ) -> dict[str, Any]:
748
+ path = _lease_path(project_root, change_id)
749
+ with _exclusive_file_lock(path.with_suffix(".lock")):
750
+ return _release_lease_locked(
751
+ project_root,
752
+ change_id=change_id,
753
+ phase=phase,
754
+ run_id=run_id,
755
+ lease_id=lease_id,
756
+ generation=generation,
757
+ )
758
+
759
+
760
+ def lease_port(
761
+ project_root: Path,
762
+ *,
763
+ change_id: str,
764
+ run_id: str,
765
+ port_range: tuple[int, int],
766
+ generation: int = 1,
767
+ listener_identity: Mapping[str, Any] | None = None,
768
+ ) -> dict[str, Any]:
769
+ start, end = port_range
770
+ if start > end:
771
+ return {
772
+ "ok": False,
773
+ "code": "INVALID_PORT_RANGE",
774
+ "message": f"invalid range {start}-{end}",
775
+ }
776
+ ports_root = project_root / PORTS_REL
777
+ ports_root.mkdir(parents=True, exist_ok=True)
778
+ registry_path = ports_root / "registry.json"
779
+ with _exclusive_file_lock(registry_path.with_suffix(".lock")):
780
+ registry: dict[str, Any] = {"leases": []}
781
+ if registry_path.is_file():
782
+ try:
783
+ loaded = _read_json(registry_path)
784
+ if isinstance(loaded, dict) and isinstance(loaded.get("leases"), list):
785
+ registry = loaded
786
+ except (OSError, json.JSONDecodeError):
787
+ registry = {"leases": []}
788
+
789
+ leases = [
790
+ item for item in registry["leases"]
791
+ if isinstance(item, dict) and not _lease_expired(item)
792
+ ]
793
+ used = {
794
+ int(item["port"])
795
+ for item in leases
796
+ if isinstance(item.get("port"), int)
797
+ }
798
+ for port in range(start, end + 1):
799
+ if port not in used:
800
+ lease_id = str(uuid.uuid4())
801
+ entry = {
802
+ "leaseId": lease_id,
803
+ "changeId": change_id,
804
+ "runId": run_id,
805
+ "generation": generation,
806
+ "pid": os.getpid(),
807
+ "port": port,
808
+ "acquiredAt": now_iso(),
809
+ "expiresAt": (
810
+ dt.datetime.now().astimezone() + dt.timedelta(hours=4)
811
+ ).isoformat(timespec="milliseconds"),
812
+ }
813
+ if isinstance(listener_identity, Mapping):
814
+ entry["listenerIdentity"] = dict(listener_identity)
815
+ entry["listenerProofRequired"] = True
816
+ leases.append(entry)
817
+ registry["leases"] = leases
818
+ _write_json(registry_path, registry)
819
+ return {"ok": True, "code": "PORT_LEASED", "port": port, "leaseId": lease_id, "lease": entry}
820
+ return {
821
+ "ok": False,
822
+ "code": "PORT_RANGE_EXHAUSTED",
823
+ "message": f"no free port in {start}-{end}",
824
+ }
825
+
826
+
827
+ def release_port(
828
+ project_root: Path,
829
+ *,
830
+ change_id: str,
831
+ run_id: str,
832
+ port: int | None = None,
833
+ lease_id: str | None = None,
834
+ generation: int | None = None,
835
+ listener_identity: Mapping[str, Any] | None = None,
836
+ listener_observer: Callable[[int], Mapping[str, Any] | None] | None = None,
837
+ ) -> dict[str, Any]:
838
+ """Release port leases by subset (retro §5.16).
839
+
840
+ - `--lease-id`: release only the matching leaseId.
841
+ - `--port`: release only the matching port (must match changeId+runId).
842
+ - Neither: release all leases matching (changeId, runId) — does NOT require
843
+ all leases under changeId to have the same runId.
844
+ """
845
+ registry_path = project_root / PORTS_REL / "registry.json"
846
+ if not registry_path.is_file():
847
+ return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
848
+ with _exclusive_file_lock(registry_path.with_suffix(".lock")):
849
+ try:
850
+ registry = _read_json(registry_path)
851
+ except (OSError, json.JSONDecodeError) as exc:
852
+ return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": str(exc)}
853
+ leases = registry.get("leases") if isinstance(registry, dict) else None
854
+ if not isinstance(leases, list):
855
+ return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": "leases is not a list"}
856
+
857
+ # Select the subset to release based on lease_id, port, or (changeId, runId)
858
+ if lease_id is not None:
859
+ to_release = [
860
+ item for item in leases
861
+ if isinstance(item, dict) and str(item.get("leaseId")) == lease_id
862
+ ]
863
+ # Validate that the lease belongs to this change
864
+ for item in to_release:
865
+ if generation is not None and item.get("generation") != generation:
866
+ return {
867
+ "ok": False,
868
+ "code": "PORT_LEASE_GENERATION_CONFLICT",
869
+ "holder": item,
870
+ }
871
+ if str(item.get("changeId")) != change_id:
872
+ return {
873
+ "ok": False,
874
+ "code": "PORT_LEASE_OWNER_MISMATCH",
875
+ "message": "leaseId does not belong to this change",
876
+ "holder": item,
877
+ }
878
+ elif port is not None:
879
+ to_release = [
880
+ item for item in leases
881
+ if isinstance(item, dict)
882
+ and item.get("port") == port
883
+ and str(item.get("changeId")) == change_id
884
+ ]
885
+ # Validate runId matches
886
+ for item in to_release:
887
+ if generation is not None and item.get("generation") != generation:
888
+ return {
889
+ "ok": False,
890
+ "code": "PORT_LEASE_GENERATION_CONFLICT",
891
+ "holder": item,
892
+ }
893
+ if str(item.get("runId")) != run_id:
894
+ return {
895
+ "ok": False,
896
+ "code": "PORT_LEASE_OWNER_MISMATCH",
897
+ "message": f"port {port} owned by different runId",
898
+ "holder": item,
899
+ "conflictingOwners": [
900
+ {"runId": str(i.get("runId")), "port": i.get("port")}
901
+ for i in leases
902
+ if isinstance(i, dict)
903
+ and i.get("port") == port
904
+ and str(i.get("changeId")) == change_id
905
+ ],
906
+ }
907
+ else:
908
+ # Release all matching (changeId, runId) — subset release
909
+ to_release = [
910
+ item for item in leases
911
+ if isinstance(item, dict)
912
+ and str(item.get("changeId")) == change_id
913
+ and str(item.get("runId")) == run_id
914
+ ]
915
+ if generation is not None:
916
+ mismatched = [
917
+ item for item in to_release if item.get("generation") != generation
918
+ ]
919
+ if mismatched:
920
+ return {
921
+ "ok": False,
922
+ "code": "PORT_LEASE_GENERATION_CONFLICT",
923
+ "holder": mismatched[0],
924
+ }
925
+
926
+ for item in to_release:
927
+ expected_listener = item.get("listenerIdentity")
928
+ if not isinstance(expected_listener, Mapping):
929
+ continue
930
+ observed_listener = (
931
+ listener_observer(int(item["port"]))
932
+ if listener_observer is not None and isinstance(item.get("port"), int)
933
+ else listener_identity
934
+ )
935
+ if not isinstance(observed_listener, Mapping):
936
+ return {
937
+ "ok": False,
938
+ "code": "LISTENER_IDENTITY_UNVERIFIABLE",
939
+ "message": "listener identity could not be independently observed",
940
+ "holder": item,
941
+ }
942
+ try:
943
+ from harness_process import verify_process_identity
944
+
945
+ decision = verify_process_identity(expected_listener, observed_listener)
946
+ except (ImportError, TypeError, ValueError):
947
+ decision = {"ok": False, "reasonCode": "IDENTITY_UNVERIFIABLE"}
948
+ if decision.get("ok") is not True:
949
+ return {
950
+ "ok": False,
951
+ "code": "LISTENER_IDENTITY_UNVERIFIABLE",
952
+ "message": "listener identity did not match the leased service",
953
+ "details": decision,
954
+ "holder": item,
955
+ }
956
+
957
+ if not to_release:
958
+ # Check if there are other leases under this changeId (different runId)
959
+ other_owned = [
960
+ item for item in leases
961
+ if isinstance(item, dict)
962
+ and str(item.get("changeId")) == change_id
963
+ and str(item.get("runId")) != run_id
964
+ ]
965
+ if other_owned:
966
+ return {
967
+ "ok": False,
968
+ "code": "PORT_LEASE_OWNER_MISMATCH",
969
+ "message": "no matching leases for this runId; other runIds exist under this changeId",
970
+ "conflictingOwners": [
971
+ {"runId": str(i.get("runId")), "port": i.get("port"), "leaseId": i.get("leaseId")}
972
+ for i in other_owned
973
+ ],
974
+ }
975
+ return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
976
+
977
+ released_ports = [item.get("port") for item in to_release]
978
+ released_ids = [item.get("leaseId") for item in to_release]
979
+ registry["leases"] = [item for item in leases if item not in to_release]
980
+ _write_json(registry_path, registry)
981
+ return {
982
+ "ok": True,
983
+ "code": "PORT_LEASE_RELEASED",
984
+ "changeId": change_id,
985
+ "ports": released_ports,
986
+ "leaseIds": released_ids,
987
+ }
988
+
989
+
990
+ def _integration_lock_acquire_locked(
991
+ project_root: Path,
992
+ *,
993
+ run_id: str,
994
+ ttl_seconds: int = 3600,
995
+ ) -> dict[str, Any]:
996
+ path = project_root / INTEGRATION_LOCK_REL
997
+ path.parent.mkdir(parents=True, exist_ok=True)
998
+ now = dt.datetime.now().astimezone()
999
+ payload = {
1000
+ "runId": run_id,
1001
+ "pid": os.getpid(),
1002
+ "acquiredAt": now.isoformat(timespec="milliseconds"),
1003
+ "expiresAt": (now + dt.timedelta(seconds=ttl_seconds)).isoformat(
1004
+ timespec="milliseconds"
1005
+ ),
1006
+ }
1007
+ if path.is_file():
1008
+ try:
1009
+ existing = _read_json(path)
1010
+ except (OSError, json.JSONDecodeError):
1011
+ existing = None
1012
+ if isinstance(existing, dict) and not _lease_expired(existing):
1013
+ if str(existing.get("runId")) == run_id:
1014
+ _write_json(path, payload)
1015
+ return {"ok": True, "code": "INTEGRATION_LOCK_REFRESHED", "lock": payload}
1016
+ return {
1017
+ "ok": False,
1018
+ "code": "INTEGRATION_LOCK_HELD",
1019
+ "message": "integration lock held by another run",
1020
+ "holder": existing,
1021
+ }
1022
+ _write_json(path, payload)
1023
+ return {"ok": True, "code": "INTEGRATION_LOCK_ACQUIRED", "lock": payload}
1024
+
1025
+
1026
+ def integration_lock_acquire(
1027
+ project_root: Path,
1028
+ *,
1029
+ run_id: str,
1030
+ ttl_seconds: int = 3600,
1031
+ ) -> dict[str, Any]:
1032
+ path = project_root / INTEGRATION_LOCK_REL
1033
+ with _exclusive_file_lock(path.with_suffix(".lock")):
1034
+ return _integration_lock_acquire_locked(
1035
+ project_root, run_id=run_id, ttl_seconds=ttl_seconds
1036
+ )
1037
+
1038
+
1039
+ def _integration_lock_release_locked(project_root: Path, *, run_id: str) -> dict[str, Any]:
1040
+ path = project_root / INTEGRATION_LOCK_REL
1041
+ if not path.is_file():
1042
+ return {"ok": True, "code": "INTEGRATION_LOCK_ABSENT"}
1043
+ try:
1044
+ existing = _read_json(path)
1045
+ except (OSError, json.JSONDecodeError) as exc:
1046
+ return {"ok": False, "code": "INTEGRATION_LOCK_INVALID", "message": str(exc)}
1047
+ if not isinstance(existing, dict):
1048
+ return {"ok": False, "code": "INTEGRATION_LOCK_INVALID", "message": "not an object"}
1049
+ if str(existing.get("runId")) != run_id:
1050
+ return {
1051
+ "ok": False,
1052
+ "code": "INTEGRATION_LOCK_OWNER_MISMATCH",
1053
+ "message": "run id does not match lock owner",
1054
+ "holder": existing,
1055
+ }
1056
+ path.unlink(missing_ok=True)
1057
+ return {"ok": True, "code": "INTEGRATION_LOCK_RELEASED"}
1058
+
1059
+
1060
+ def integration_lock_release(project_root: Path, *, run_id: str) -> dict[str, Any]:
1061
+ path = project_root / INTEGRATION_LOCK_REL
1062
+ with _exclusive_file_lock(path.with_suffix(".lock")):
1063
+ return _integration_lock_release_locked(project_root, run_id=run_id)
1064
+
1065
+
1066
+ def parse_port_range(raw: str) -> tuple[int, int] | None:
1067
+ if "-" not in raw:
1068
+ return None
1069
+ left, right = raw.split("-", 1)
1070
+ try:
1071
+ return int(left.strip()), int(right.strip())
1072
+ except ValueError:
1073
+ return None
1074
+
1075
+
1076
+ def cmd_list(args: argparse.Namespace) -> int:
1077
+ project = resolve_main_project_root()
1078
+ payload = {
1079
+ "ok": True,
1080
+ "code": "LISTED",
1081
+ "projectRoot": str(project),
1082
+ "activeChanges": list_active_changes(project),
1083
+ }
1084
+ emit(payload, as_json=bool(args.json))
1085
+ return 0
1086
+
1087
+
1088
+ def cmd_resolve(args: argparse.Namespace) -> int:
1089
+ project = resolve_main_project_root()
1090
+ payload = resolve_change(project, args.change)
1091
+ if payload.get("ok"):
1092
+ emit(payload, as_json=bool(args.json))
1093
+ return 0
1094
+ return emit_error(
1095
+ str(payload.get("code", "RESOLVE_FAILED")),
1096
+ str(payload.get("message", "resolve failed")),
1097
+ as_json=bool(args.json),
1098
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1099
+ )
1100
+
1101
+
1102
+ def declare_product_ownership(
1103
+ project_root: Path, change_id: str, *, product_paths: list[str]
1104
+ ) -> dict[str, Any]:
1105
+ """把 `ownership.productPaths` 写进 change 契约。
1106
+
1107
+ 这个字段此前没有任何写入方:plan 的 validate_product_ownership 只校验、缺失时
1108
+ 软放行,而归档的 compute_ownership_diff 会把全部改动判成 foreignPaths,
1109
+ filesChanged=0 直接触发 DIFF_ZERO_WITH_NONEMPTY_COMMIT。两端口径不一致,中间
1110
+ 没工具能补——只能手改契约。这里给出正规入口。
1111
+
1112
+ 规则与 plan 校验一致:只收精确文件或目录前缀,不接受通配。
1113
+ """
1114
+ resolved = resolve_change(project_root, change_id)
1115
+ if not resolved.get("ok"):
1116
+ return resolved
1117
+ change_dir = Path(resolved["changeDir"])
1118
+ normalized = sorted({
1119
+ str(item).strip().replace("\\", "/").removeprefix("./")
1120
+ for item in product_paths
1121
+ if isinstance(item, str) and item.strip()
1122
+ })
1123
+ if not normalized:
1124
+ return {
1125
+ "ok": False,
1126
+ "code": "PLAN_PRODUCT_PATHS_REQUIRED",
1127
+ "message": "至少声明一条 productPaths(精确文件或目录前缀)",
1128
+ }
1129
+ unsupported = sorted(p for p in normalized if any(ch in p for ch in "*?[]"))
1130
+ if unsupported:
1131
+ return {
1132
+ "ok": False,
1133
+ "code": "PLAN_PRODUCT_PATHS_GLOB_UNSUPPORTED",
1134
+ "message": "productPaths 只接受精确文件或目录前缀,不支持通配:" + ", ".join(unsupported),
1135
+ "unsupportedPaths": unsupported,
1136
+ }
1137
+
1138
+ context_path = change_dir / CHANGE_CONTEXT_REL
1139
+ try:
1140
+ context = _read_json(context_path)
1141
+ except (OSError, json.JSONDecodeError) as exc:
1142
+ return {"ok": False, "code": "CHANGE_CONTEXT_INVALID", "message": str(exc)}
1143
+ if not isinstance(context, dict):
1144
+ return {"ok": False, "code": "CHANGE_CONTEXT_INVALID", "message": "change-context.json 不是对象"}
1145
+
1146
+ ownership = context.get("ownership")
1147
+ ownership = dict(ownership) if isinstance(ownership, dict) else {}
1148
+ if ownership.get("productPaths") == normalized:
1149
+ return {
1150
+ "ok": True,
1151
+ "code": "PRODUCT_OWNERSHIP_DECLARED",
1152
+ "idempotent": True,
1153
+ "changeId": change_id,
1154
+ "productPaths": normalized,
1155
+ "path": str(context_path),
1156
+ }
1157
+ ownership["productPaths"] = normalized
1158
+ context["ownership"] = ownership
1159
+ _write_json(context_path, context)
1160
+ return {
1161
+ "ok": True,
1162
+ "code": "PRODUCT_OWNERSHIP_DECLARED",
1163
+ "idempotent": False,
1164
+ "changeId": change_id,
1165
+ "productPaths": normalized,
1166
+ "path": str(context_path),
1167
+ }
1168
+
1169
+
1170
+ def cmd_declare_ownership(args: argparse.Namespace) -> int:
1171
+ project = resolve_main_project_root()
1172
+ payload = declare_product_ownership(
1173
+ project, args.change, product_paths=list(args.product_path)
1174
+ )
1175
+ if payload.get("ok"):
1176
+ emit(payload, as_json=bool(args.json))
1177
+ return 0
1178
+ return emit_error(
1179
+ str(payload.get("code", "PRODUCT_OWNERSHIP_FAILED")),
1180
+ str(payload.get("message", "declare ownership failed")),
1181
+ as_json=bool(args.json),
1182
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1183
+ )
1184
+
1185
+
1186
+ def cmd_migrate(args: argparse.Namespace) -> int:
1187
+ project = resolve_main_project_root()
1188
+ if not args.change:
1189
+ return emit_error("CHANGE_REQUIRED", "--change is required", as_json=bool(args.json))
1190
+ payload = migrate_change(project, args.change)
1191
+ if payload.get("ok"):
1192
+ emit(payload, as_json=bool(args.json))
1193
+ return 0
1194
+ return emit_error(
1195
+ str(payload.get("code", "MIGRATE_FAILED")),
1196
+ str(payload.get("message", "migrate failed")),
1197
+ as_json=bool(args.json),
1198
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1199
+ )
1200
+
1201
+
1202
+ def cmd_claim(args: argparse.Namespace) -> int:
1203
+ project = resolve_main_project_root()
1204
+ payload = claim_lease(
1205
+ project,
1206
+ change_id=args.change,
1207
+ phase=args.phase,
1208
+ run_id=args.run_id,
1209
+ ttl_seconds=int(args.ttl_seconds),
1210
+ steal=bool(args.steal),
1211
+ expected_generation=getattr(args, "expected_generation", None),
1212
+ )
1213
+ if payload.get("ok"):
1214
+ emit(payload, as_json=bool(args.json))
1215
+ return 0
1216
+ return emit_error(
1217
+ str(payload.get("code", "CLAIM_FAILED")),
1218
+ str(payload.get("message", "claim failed")),
1219
+ as_json=bool(args.json),
1220
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1221
+ )
1222
+
1223
+
1224
+ def cmd_release(args: argparse.Namespace) -> int:
1225
+ project = resolve_main_project_root()
1226
+ payload = release_lease(
1227
+ project,
1228
+ change_id=args.change,
1229
+ phase=args.phase,
1230
+ run_id=args.run_id,
1231
+ lease_id=getattr(args, "lease_id", None),
1232
+ generation=getattr(args, "generation", None),
1233
+ )
1234
+ if payload.get("ok"):
1235
+ emit(payload, as_json=bool(args.json))
1236
+ return 0
1237
+ return emit_error(
1238
+ str(payload.get("code", "RELEASE_FAILED")),
1239
+ str(payload.get("message", "release failed")),
1240
+ as_json=bool(args.json),
1241
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1242
+ )
1243
+
1244
+
1245
+ def cmd_lease_port(args: argparse.Namespace) -> int:
1246
+ project = resolve_main_project_root()
1247
+ parsed = parse_port_range(args.range)
1248
+ if parsed is None:
1249
+ return emit_error(
1250
+ "INVALID_PORT_RANGE",
1251
+ f"expected --range <start-end>, got {args.range!r}",
1252
+ as_json=bool(args.json),
1253
+ )
1254
+ listener_identity = None
1255
+ if getattr(args, "listener_identity_json", None):
1256
+ try:
1257
+ parsed_listener = json.loads(args.listener_identity_json)
1258
+ if not isinstance(parsed_listener, dict):
1259
+ raise ValueError("listener identity must be an object")
1260
+ listener_identity = parsed_listener
1261
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
1262
+ return emit_error(
1263
+ "LISTENER_IDENTITY_UNVERIFIABLE",
1264
+ str(exc),
1265
+ as_json=bool(args.json),
1266
+ )
1267
+ payload = lease_port(
1268
+ project,
1269
+ change_id=args.change,
1270
+ run_id=args.run_id,
1271
+ port_range=parsed,
1272
+ generation=int(getattr(args, "generation", 1)),
1273
+ listener_identity=listener_identity,
1274
+ )
1275
+ if payload.get("ok"):
1276
+ emit(payload, as_json=bool(args.json))
1277
+ return 0
1278
+ return emit_error(
1279
+ str(payload.get("code", "PORT_LEASE_FAILED")),
1280
+ str(payload.get("message", "port lease failed")),
1281
+ as_json=bool(args.json),
1282
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1283
+ )
1284
+
1285
+
1286
+ def cmd_release_port(args: argparse.Namespace) -> int:
1287
+ project = resolve_main_project_root()
1288
+ listener_identity = None
1289
+ if getattr(args, "listener_identity_json", None):
1290
+ try:
1291
+ parsed_listener = json.loads(args.listener_identity_json)
1292
+ if not isinstance(parsed_listener, dict):
1293
+ raise ValueError("listener identity must be an object")
1294
+ listener_identity = parsed_listener
1295
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
1296
+ return emit_error(
1297
+ "LISTENER_IDENTITY_UNVERIFIABLE",
1298
+ str(exc),
1299
+ as_json=bool(args.json),
1300
+ )
1301
+ payload = release_port(
1302
+ project,
1303
+ change_id=args.change,
1304
+ run_id=args.run_id,
1305
+ port=getattr(args, "port", None),
1306
+ lease_id=getattr(args, "lease_id", None),
1307
+ generation=getattr(args, "generation", None),
1308
+ listener_identity=listener_identity,
1309
+ )
1310
+ if payload.get("ok"):
1311
+ emit(payload, as_json=bool(args.json))
1312
+ return 0
1313
+ return emit_error(
1314
+ str(payload.get("code", "PORT_RELEASE_FAILED")),
1315
+ str(payload.get("message", "port release failed")),
1316
+ as_json=bool(args.json),
1317
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1318
+ )
1319
+
1320
+
1321
+ def cmd_integration_lock(args: argparse.Namespace) -> int:
1322
+ project = resolve_main_project_root()
1323
+ if args.integration_action == "acquire":
1324
+ payload = integration_lock_acquire(
1325
+ project,
1326
+ run_id=args.run_id,
1327
+ ttl_seconds=int(args.ttl_seconds),
1328
+ )
1329
+ else:
1330
+ payload = integration_lock_release(project, run_id=args.run_id)
1331
+ if payload.get("ok"):
1332
+ emit(payload, as_json=bool(args.json))
1333
+ return 0
1334
+ return emit_error(
1335
+ str(payload.get("code", "INTEGRATION_LOCK_FAILED")),
1336
+ str(payload.get("message", "integration lock failed")),
1337
+ as_json=bool(args.json),
1338
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1339
+ )
1340
+
1341
+
1342
+ def _ensure_change_identity(change_dir: Path) -> dict[str, str]:
1343
+ """Ensure meta/change-identity.json exists with a stable UUID (retro §5.5).
1344
+
1345
+ Returns the identity dict with changeUuid and changeName.
1346
+ """
1347
+ identity_path = change_dir / "meta" / "change-identity.json"
1348
+ if identity_path.is_file():
1349
+ try:
1350
+ data = json.loads(identity_path.read_text(encoding="utf-8-sig"))
1351
+ if isinstance(data, dict) and data.get("changeUuid"):
1352
+ return data
1353
+ except (OSError, json.JSONDecodeError):
1354
+ pass
1355
+ # Generate new identity
1356
+ identity = {
1357
+ "schemaVersion": 1,
1358
+ "changeUuid": str(uuid.uuid4()),
1359
+ "changeName": change_dir.name,
1360
+ "createdAt": now_iso(),
1361
+ }
1362
+ identity_path.parent.mkdir(parents=True, exist_ok=True)
1363
+ identity_path.write_text(
1364
+ json.dumps(identity, ensure_ascii=False, indent=2) + "\n",
1365
+ encoding="utf-8",
1366
+ newline="\n",
1367
+ )
1368
+ return identity
1369
+
1370
+
1371
+ def cmd_rename(args: argparse.Namespace) -> int:
1372
+ """Rename a change atomically: directory, pointers, worktree, identity (retro §5.5).
1373
+
1374
+ Appends a change.rename event; does not rewrite history.
1375
+ """
1376
+ project = resolve_main_project_root()
1377
+ changes_root = project / ".harness" / "changes"
1378
+ old_dir = changes_root / args.change
1379
+ new_dir = changes_root / args.to
1380
+
1381
+ if not old_dir.is_dir():
1382
+ return emit_error(
1383
+ "CHANGE_NOT_FOUND",
1384
+ f"change directory not found: {old_dir}",
1385
+ as_json=bool(args.json),
1386
+ )
1387
+ if new_dir.exists():
1388
+ return emit_error(
1389
+ "CHANGE_ALREADY_EXISTS",
1390
+ f"target change directory already exists: {new_dir}",
1391
+ as_json=bool(args.json),
1392
+ )
1393
+
1394
+ # Ensure identity exists before rename
1395
+ identity = _ensure_change_identity(old_dir)
1396
+ old_uuid = identity.get("changeUuid", "")
1397
+
1398
+ # Rename directory
1399
+ import shutil
1400
+ shutil.move(str(old_dir), str(new_dir))
1401
+
1402
+ # Update knowledge-context.json.changeId if present
1403
+ kc_path = new_dir / "meta" / "knowledge-context.json"
1404
+ if kc_path.is_file():
1405
+ try:
1406
+ kc = json.loads(kc_path.read_text(encoding="utf-8-sig"))
1407
+ if isinstance(kc, dict):
1408
+ kc["changeId"] = args.to
1409
+ kc["changeUuid"] = old_uuid
1410
+ kc_path.write_text(
1411
+ json.dumps(kc, ensure_ascii=False, indent=2) + "\n",
1412
+ encoding="utf-8",
1413
+ newline="\n",
1414
+ )
1415
+ except (OSError, json.JSONDecodeError):
1416
+ pass
1417
+
1418
+ # Update worktree.json path/branch if present
1419
+ wt_path = new_dir / "meta" / "worktree.json"
1420
+ if wt_path.is_file():
1421
+ try:
1422
+ wt = json.loads(wt_path.read_text(encoding="utf-8-sig"))
1423
+ if isinstance(wt, dict):
1424
+ old_name = args.change
1425
+ new_name = args.to
1426
+ # Update path and branch if they contain old name
1427
+ if isinstance(wt.get("path"), str) and old_name in wt["path"]:
1428
+ wt["path"] = wt["path"].replace(old_name, new_name)
1429
+ if isinstance(wt.get("branch"), str) and old_name in wt["branch"]:
1430
+ wt["branch"] = wt["branch"].replace(old_name, new_name)
1431
+ wt_path.write_text(
1432
+ json.dumps(wt, ensure_ascii=False, indent=2) + "\n",
1433
+ encoding="utf-8",
1434
+ newline="\n",
1435
+ )
1436
+ except (OSError, json.JSONDecodeError):
1437
+ pass
1438
+
1439
+ # Update change-identity.json.changeName
1440
+ identity_path = new_dir / "meta" / "change-identity.json"
1441
+ if identity_path.is_file():
1442
+ try:
1443
+ ident = json.loads(identity_path.read_text(encoding="utf-8-sig"))
1444
+ if isinstance(ident, dict):
1445
+ ident["changeName"] = args.to
1446
+ ident["renamedFrom"] = args.change
1447
+ identity_path.write_text(
1448
+ json.dumps(ident, ensure_ascii=False, indent=2) + "\n",
1449
+ encoding="utf-8",
1450
+ newline="\n",
1451
+ )
1452
+ except (OSError, json.JSONDecodeError):
1453
+ pass
1454
+
1455
+ # Append change.rename event (does not rewrite history)
1456
+ try:
1457
+ import harness_events
1458
+ harness_events.append_event(
1459
+ new_dir,
1460
+ phase="plan",
1461
+ type_="change.rename",
1462
+ renamed_from=args.change,
1463
+ renamed_to=args.to,
1464
+ change_uuid=old_uuid,
1465
+ note=f"change renamed from {args.change} to {args.to}",
1466
+ )
1467
+ except Exception:
1468
+ pass # event append failure should not block rename
1469
+
1470
+ payload = {
1471
+ "ok": True,
1472
+ "code": "RENAMED",
1473
+ "changeUuid": old_uuid,
1474
+ "renamedFrom": args.change,
1475
+ "renamedTo": args.to,
1476
+ "changeDir": str(new_dir),
1477
+ }
1478
+ emit(payload, as_json=bool(args.json))
1479
+ return 0
1480
+
1481
+
1482
+ def cmd_ensure_identity(args: argparse.Namespace) -> int:
1483
+ """Ensure meta/change-identity.json exists with a stable UUID (retro §5.5)."""
1484
+ project = resolve_main_project_root()
1485
+ change_dir = project / ".harness" / "changes" / args.change
1486
+ if not change_dir.is_dir():
1487
+ return emit_error(
1488
+ "CHANGE_NOT_FOUND",
1489
+ f"change directory not found: {change_dir}",
1490
+ as_json=bool(args.json),
1491
+ )
1492
+ identity = _ensure_change_identity(change_dir)
1493
+ emit(identity, as_json=bool(args.json))
1494
+ return 0
1495
+
1496
+
1497
+ def cmd_status(args: argparse.Namespace) -> int:
1498
+ project = resolve_main_project_root()
1499
+ items = classify_changes(project)
1500
+ payload = {
1501
+ "ok": True,
1502
+ "code": "CHANGE_STATUS",
1503
+ "project": str(project),
1504
+ "items": items,
1505
+ "summary": {
1506
+ status: sum(1 for item in items if item["status"] == status)
1507
+ for status in (
1508
+ "ACTIVE",
1509
+ "ARCHIVED_LEFTOVER",
1510
+ "RECOVERABLE",
1511
+ "ORPHAN",
1512
+ "INVALID",
1513
+ )
1514
+ },
1515
+ }
1516
+ emit(payload, as_json=bool(args.json))
1517
+ return 0
1518
+
1519
+
1520
+ def cmd_cleanup_changes(args: argparse.Namespace) -> int:
1521
+ project = resolve_main_project_root()
1522
+ result = cleanup_changes(project, apply=bool(args.apply))
1523
+ emit(result, as_json=bool(args.json))
1524
+ return 0
1525
+
1526
+
1527
+ def build_parser() -> argparse.ArgumentParser:
1528
+ parser = argparse.ArgumentParser(prog="harness_change.py")
1529
+ parser.add_argument("--json", action="store_true")
1530
+ sub = parser.add_subparsers(dest="command_name", required=True)
1531
+
1532
+ shared = argparse.ArgumentParser(add_help=False)
1533
+ shared.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
1534
+
1535
+ p_list = sub.add_parser("list", parents=[shared])
1536
+ p_list.set_defaults(func=cmd_list)
1537
+
1538
+ p_status = sub.add_parser("status", parents=[shared])
1539
+ p_status.add_argument("--all", action="store_true", dest="show_all")
1540
+ p_status.set_defaults(func=cmd_status)
1541
+
1542
+ p_cleanup = sub.add_parser("cleanup", parents=[shared])
1543
+ cleanup_mode = p_cleanup.add_mutually_exclusive_group()
1544
+ cleanup_mode.add_argument("--dry-run", action="store_true")
1545
+ cleanup_mode.add_argument("--apply", action="store_true")
1546
+ p_cleanup.set_defaults(func=cmd_cleanup_changes)
1547
+
1548
+ p_resolve = sub.add_parser("resolve", parents=[shared])
1549
+ p_resolve.add_argument("--change", default=None)
1550
+ p_resolve.set_defaults(func=cmd_resolve)
1551
+
1552
+ p_migrate = sub.add_parser("migrate", parents=[shared])
1553
+ p_migrate.add_argument("--change", required=True)
1554
+ p_migrate.set_defaults(func=cmd_migrate)
1555
+
1556
+ p_ownership = sub.add_parser(
1557
+ "declare-ownership", parents=[shared],
1558
+ help="declare ownership.productPaths so archive can project the real diff",
1559
+ )
1560
+ p_ownership.add_argument("--change", required=True)
1561
+ p_ownership.add_argument(
1562
+ "--product-path", required=True, action="append",
1563
+ help="exact file or directory prefix (no globs); repeatable",
1564
+ )
1565
+ p_ownership.set_defaults(func=cmd_declare_ownership)
1566
+
1567
+ p_claim = sub.add_parser("claim", parents=[shared])
1568
+ p_claim.add_argument("--change", required=True)
1569
+ p_claim.add_argument("--phase", required=True)
1570
+ p_claim.add_argument("--run-id", required=True)
1571
+ p_claim.add_argument("--ttl-seconds", type=int, default=3600)
1572
+ p_claim.add_argument("--steal", action="store_true")
1573
+ p_claim.add_argument("--expected-generation", type=int, default=None)
1574
+ p_claim.set_defaults(func=cmd_claim)
1575
+
1576
+ p_release = sub.add_parser("release", parents=[shared])
1577
+ p_release.add_argument("--change", required=True)
1578
+ p_release.add_argument("--phase", required=True)
1579
+ p_release.add_argument("--run-id", required=True)
1580
+ p_release.add_argument("--lease-id", default=None)
1581
+ p_release.add_argument("--generation", type=int, default=None)
1582
+ p_release.set_defaults(func=cmd_release)
1583
+
1584
+ p_port = sub.add_parser("lease-port", parents=[shared])
1585
+ p_port.add_argument("--change", required=True)
1586
+ p_port.add_argument("--run-id", required=True)
1587
+ p_port.add_argument("--range", required=True)
1588
+ p_port.add_argument("--generation", type=int, default=1)
1589
+ p_port.add_argument("--listener-identity-json", default=None)
1590
+ p_port.set_defaults(func=cmd_lease_port)
1591
+
1592
+ p_port_release = sub.add_parser("release-port", parents=[shared])
1593
+ p_port_release.add_argument("--change", required=True)
1594
+ p_port_release.add_argument("--run-id", required=True)
1595
+ p_port_release.add_argument("--port", type=int, default=None)
1596
+ p_port_release.add_argument("--lease-id", default=None)
1597
+ p_port_release.add_argument("--generation", type=int, default=None)
1598
+ p_port_release.add_argument("--listener-identity-json", default=None)
1599
+ p_port_release.set_defaults(func=cmd_release_port)
1600
+
1601
+ p_lock = sub.add_parser("integration-lock", parents=[shared])
1602
+ p_lock.add_argument("integration_action", choices=["acquire", "release"])
1603
+ p_lock.add_argument("--run-id", required=True)
1604
+ p_lock.add_argument("--ttl-seconds", type=int, default=3600)
1605
+ p_lock.set_defaults(func=cmd_integration_lock)
1606
+
1607
+ p_rename = sub.add_parser("rename", parents=[shared])
1608
+ p_rename.add_argument("--change", required=True)
1609
+ p_rename.add_argument("--to", required=True)
1610
+ p_rename.set_defaults(func=cmd_rename)
1611
+
1612
+ p_identity = sub.add_parser("ensure-identity", parents=[shared])
1613
+ p_identity.add_argument("--change", required=True)
1614
+ p_identity.set_defaults(func=cmd_ensure_identity)
1615
+
1616
+ return parser
1617
+
1618
+
1619
+ def main(argv: list[str] | None = None) -> int:
1620
+ parser = build_parser()
1621
+ args = parser.parse_args(argv)
1622
+ return int(args.func(args))
1623
+
1624
+
1625
+ if __name__ == "__main__":
1626
+ raise SystemExit(main())