@hunter-harness/workflow-harness 0.2.5 → 0.2.7

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 (270) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +169 -0
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +7 -2
  4. package/harness/bundles/general/claude-code/harness-archive/reference.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +78 -155
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +4 -2
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +5 -3
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  10. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +6 -2
  11. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +8 -3
  12. package/harness/bundles/general/claude-code/harness-review/SKILL.md +8 -3
  13. package/harness/bundles/general/claude-code/harness-run/SKILL.md +13 -7
  14. package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
  15. package/harness/bundles/general/claude-code/harness-run/reference.md +6 -3
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +11 -6
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +5 -3
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +12 -3
  19. package/harness/bundles/general/claude-code/harness-test/reference.md +1 -1
  20. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +1 -1
  21. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +11 -4
  22. package/harness/bundles/general/claude-code/scripts/harness_apidoc.py +53 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_archive.py +231 -48
  24. package/harness/bundles/general/claude-code/scripts/harness_change.py +854 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_events.py +134 -40
  26. package/harness/bundles/general/claude-code/scripts/harness_gate.py +1038 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_preflight.py +46 -3
  28. package/harness/bundles/general/claude-code/scripts/harness_profile.py +2 -2
  29. package/harness/bundles/general/claude-code/scripts/harness_service.py +34 -1
  30. package/harness/bundles/general/claude-code/scripts/harness_state.py +148 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +210 -14
  32. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +160 -0
  33. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  34. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +169 -0
  35. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +7 -2
  36. package/harness/bundles/general/codebuddy/harness-archive/reference.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +78 -155
  38. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +4 -2
  39. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +5 -3
  40. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  41. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  42. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +6 -2
  43. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +8 -3
  44. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +8 -3
  45. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +13 -7
  46. package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
  47. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -3
  48. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +11 -6
  49. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +5 -3
  50. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +12 -3
  51. package/harness/bundles/general/codebuddy/harness-test/reference.md +1 -1
  52. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +1 -1
  53. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +11 -4
  54. package/harness/bundles/general/codebuddy/scripts/harness_apidoc.py +53 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +231 -48
  56. package/harness/bundles/general/codebuddy/scripts/harness_change.py +854 -0
  57. package/harness/bundles/general/codebuddy/scripts/harness_events.py +134 -40
  58. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1038 -0
  59. package/harness/bundles/general/codebuddy/scripts/harness_preflight.py +46 -3
  60. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +2 -2
  61. package/harness/bundles/general/codebuddy/scripts/harness_service.py +34 -1
  62. package/harness/bundles/general/codebuddy/scripts/harness_state.py +148 -0
  63. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +210 -14
  64. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +160 -0
  65. package/harness/bundles/general/codex/.harness-build.json +1 -1
  66. package/harness/bundles/general/codex/contracts/workflow-policy.json +169 -0
  67. package/harness/bundles/general/codex/harness-archive/SKILL.md +7 -2
  68. package/harness/bundles/general/codex/harness-archive/reference.md +1 -1
  69. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +78 -155
  70. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +4 -2
  71. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +5 -3
  72. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  73. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  74. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +6 -2
  75. package/harness/bundles/general/codex/harness-plan/SKILL.md +8 -3
  76. package/harness/bundles/general/codex/harness-review/SKILL.md +7 -2
  77. package/harness/bundles/general/codex/harness-run/SKILL.md +13 -7
  78. package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
  79. package/harness/bundles/general/codex/harness-run/reference.md +6 -3
  80. package/harness/bundles/general/codex/harness-submit/SKILL.md +11 -6
  81. package/harness/bundles/general/codex/harness-sync/SKILL.md +5 -3
  82. package/harness/bundles/general/codex/harness-test/SKILL.md +12 -3
  83. package/harness/bundles/general/codex/harness-test/reference.md +1 -1
  84. package/harness/bundles/general/codex/protocols/ledger-protocol.md +1 -1
  85. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +11 -4
  86. package/harness/bundles/general/codex/scripts/harness_apidoc.py +53 -0
  87. package/harness/bundles/general/codex/scripts/harness_archive.py +231 -48
  88. package/harness/bundles/general/codex/scripts/harness_change.py +854 -0
  89. package/harness/bundles/general/codex/scripts/harness_events.py +134 -40
  90. package/harness/bundles/general/codex/scripts/harness_gate.py +1038 -0
  91. package/harness/bundles/general/codex/scripts/harness_preflight.py +46 -3
  92. package/harness/bundles/general/codex/scripts/harness_profile.py +2 -2
  93. package/harness/bundles/general/codex/scripts/harness_service.py +34 -1
  94. package/harness/bundles/general/codex/scripts/harness_state.py +148 -0
  95. package/harness/bundles/general/codex/scripts/harness_test_guard.py +210 -14
  96. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +160 -0
  97. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  98. package/harness/bundles/general/cursor/contracts/workflow-policy.json +169 -0
  99. package/harness/bundles/general/cursor/harness-archive/SKILL.md +7 -2
  100. package/harness/bundles/general/cursor/harness-archive/reference.md +1 -1
  101. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +78 -155
  102. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +4 -2
  103. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +5 -3
  104. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  105. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  106. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +6 -2
  107. package/harness/bundles/general/cursor/harness-plan/SKILL.md +8 -3
  108. package/harness/bundles/general/cursor/harness-review/SKILL.md +7 -2
  109. package/harness/bundles/general/cursor/harness-run/SKILL.md +13 -7
  110. package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
  111. package/harness/bundles/general/cursor/harness-run/reference.md +6 -3
  112. package/harness/bundles/general/cursor/harness-submit/SKILL.md +11 -6
  113. package/harness/bundles/general/cursor/harness-sync/SKILL.md +5 -3
  114. package/harness/bundles/general/cursor/harness-test/SKILL.md +12 -3
  115. package/harness/bundles/general/cursor/harness-test/reference.md +1 -1
  116. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +1 -1
  117. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +11 -4
  118. package/harness/bundles/general/cursor/scripts/harness_apidoc.py +53 -0
  119. package/harness/bundles/general/cursor/scripts/harness_archive.py +231 -48
  120. package/harness/bundles/general/cursor/scripts/harness_change.py +854 -0
  121. package/harness/bundles/general/cursor/scripts/harness_events.py +134 -40
  122. package/harness/bundles/general/cursor/scripts/harness_gate.py +1038 -0
  123. package/harness/bundles/general/cursor/scripts/harness_preflight.py +46 -3
  124. package/harness/bundles/general/cursor/scripts/harness_profile.py +2 -2
  125. package/harness/bundles/general/cursor/scripts/harness_service.py +34 -1
  126. package/harness/bundles/general/cursor/scripts/harness_state.py +148 -0
  127. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +210 -14
  128. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +160 -0
  129. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  130. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +169 -0
  131. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +11 -2
  132. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +7 -2
  133. package/harness/bundles/java/claude-code/harness-archive/reference.md +1 -1
  134. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +78 -155
  135. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +4 -2
  136. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +5 -3
  137. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  138. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  139. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +6 -2
  140. package/harness/bundles/java/claude-code/harness-package/SKILL.md +10 -4
  141. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +8 -3
  142. package/harness/bundles/java/claude-code/harness-review/SKILL.md +8 -3
  143. package/harness/bundles/java/claude-code/harness-run/SKILL.md +13 -7
  144. package/harness/bundles/java/claude-code/harness-run/reference.md +1 -1
  145. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +11 -6
  146. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +5 -3
  147. package/harness/bundles/java/claude-code/harness-test/SKILL.md +12 -3
  148. package/harness/bundles/java/claude-code/harness-test/reference.md +1 -1
  149. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +1 -1
  150. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +11 -4
  151. package/harness/bundles/java/claude-code/scripts/harness_apidoc.py +53 -0
  152. package/harness/bundles/java/claude-code/scripts/harness_archive.py +231 -48
  153. package/harness/bundles/java/claude-code/scripts/harness_change.py +854 -0
  154. package/harness/bundles/java/claude-code/scripts/harness_events.py +134 -40
  155. package/harness/bundles/java/claude-code/scripts/harness_gate.py +1038 -0
  156. package/harness/bundles/java/claude-code/scripts/harness_preflight.py +46 -3
  157. package/harness/bundles/java/claude-code/scripts/harness_profile.py +2 -2
  158. package/harness/bundles/java/claude-code/scripts/harness_service.py +34 -1
  159. package/harness/bundles/java/claude-code/scripts/harness_state.py +148 -0
  160. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +210 -14
  161. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +160 -0
  162. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  163. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +169 -0
  164. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +11 -2
  165. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +7 -2
  166. package/harness/bundles/java/codebuddy/harness-archive/reference.md +1 -1
  167. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +78 -155
  168. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +4 -2
  169. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +5 -3
  170. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  171. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  172. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +6 -2
  173. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +10 -4
  174. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +8 -3
  175. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +8 -3
  176. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +13 -7
  177. package/harness/bundles/java/codebuddy/harness-run/reference.md +1 -1
  178. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +11 -6
  179. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +5 -3
  180. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +12 -3
  181. package/harness/bundles/java/codebuddy/harness-test/reference.md +1 -1
  182. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +1 -1
  183. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +11 -4
  184. package/harness/bundles/java/codebuddy/scripts/harness_apidoc.py +53 -0
  185. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +231 -48
  186. package/harness/bundles/java/codebuddy/scripts/harness_change.py +854 -0
  187. package/harness/bundles/java/codebuddy/scripts/harness_events.py +134 -40
  188. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1038 -0
  189. package/harness/bundles/java/codebuddy/scripts/harness_preflight.py +46 -3
  190. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +2 -2
  191. package/harness/bundles/java/codebuddy/scripts/harness_service.py +34 -1
  192. package/harness/bundles/java/codebuddy/scripts/harness_state.py +148 -0
  193. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +210 -14
  194. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +160 -0
  195. package/harness/bundles/java/codex/.harness-build.json +1 -1
  196. package/harness/bundles/java/codex/contracts/workflow-policy.json +169 -0
  197. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +11 -2
  198. package/harness/bundles/java/codex/harness-archive/SKILL.md +7 -2
  199. package/harness/bundles/java/codex/harness-archive/reference.md +1 -1
  200. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +78 -155
  201. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +4 -2
  202. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +5 -3
  203. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  204. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  205. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +6 -2
  206. package/harness/bundles/java/codex/harness-package/SKILL.md +10 -4
  207. package/harness/bundles/java/codex/harness-plan/SKILL.md +8 -3
  208. package/harness/bundles/java/codex/harness-review/SKILL.md +7 -2
  209. package/harness/bundles/java/codex/harness-run/SKILL.md +13 -7
  210. package/harness/bundles/java/codex/harness-run/reference.md +1 -1
  211. package/harness/bundles/java/codex/harness-submit/SKILL.md +11 -6
  212. package/harness/bundles/java/codex/harness-sync/SKILL.md +5 -3
  213. package/harness/bundles/java/codex/harness-test/SKILL.md +12 -3
  214. package/harness/bundles/java/codex/harness-test/reference.md +1 -1
  215. package/harness/bundles/java/codex/protocols/ledger-protocol.md +1 -1
  216. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +11 -4
  217. package/harness/bundles/java/codex/scripts/harness_apidoc.py +53 -0
  218. package/harness/bundles/java/codex/scripts/harness_archive.py +231 -48
  219. package/harness/bundles/java/codex/scripts/harness_change.py +854 -0
  220. package/harness/bundles/java/codex/scripts/harness_events.py +134 -40
  221. package/harness/bundles/java/codex/scripts/harness_gate.py +1038 -0
  222. package/harness/bundles/java/codex/scripts/harness_preflight.py +46 -3
  223. package/harness/bundles/java/codex/scripts/harness_profile.py +2 -2
  224. package/harness/bundles/java/codex/scripts/harness_service.py +34 -1
  225. package/harness/bundles/java/codex/scripts/harness_state.py +148 -0
  226. package/harness/bundles/java/codex/scripts/harness_test_guard.py +210 -14
  227. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +160 -0
  228. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  229. package/harness/bundles/java/cursor/contracts/workflow-policy.json +169 -0
  230. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +11 -2
  231. package/harness/bundles/java/cursor/harness-archive/SKILL.md +7 -2
  232. package/harness/bundles/java/cursor/harness-archive/reference.md +1 -1
  233. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +78 -155
  234. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +4 -2
  235. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +5 -3
  236. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +109 -7
  237. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  238. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +6 -2
  239. package/harness/bundles/java/cursor/harness-package/SKILL.md +10 -4
  240. package/harness/bundles/java/cursor/harness-plan/SKILL.md +8 -3
  241. package/harness/bundles/java/cursor/harness-review/SKILL.md +7 -2
  242. package/harness/bundles/java/cursor/harness-run/SKILL.md +13 -7
  243. package/harness/bundles/java/cursor/harness-run/reference.md +1 -1
  244. package/harness/bundles/java/cursor/harness-submit/SKILL.md +11 -6
  245. package/harness/bundles/java/cursor/harness-sync/SKILL.md +5 -3
  246. package/harness/bundles/java/cursor/harness-test/SKILL.md +12 -3
  247. package/harness/bundles/java/cursor/harness-test/reference.md +1 -1
  248. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +1 -1
  249. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +11 -4
  250. package/harness/bundles/java/cursor/scripts/harness_apidoc.py +53 -0
  251. package/harness/bundles/java/cursor/scripts/harness_archive.py +231 -48
  252. package/harness/bundles/java/cursor/scripts/harness_change.py +854 -0
  253. package/harness/bundles/java/cursor/scripts/harness_events.py +134 -40
  254. package/harness/bundles/java/cursor/scripts/harness_gate.py +1038 -0
  255. package/harness/bundles/java/cursor/scripts/harness_preflight.py +46 -3
  256. package/harness/bundles/java/cursor/scripts/harness_profile.py +2 -2
  257. package/harness/bundles/java/cursor/scripts/harness_service.py +34 -1
  258. package/harness/bundles/java/cursor/scripts/harness_state.py +148 -0
  259. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +210 -14
  260. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +160 -0
  261. package/harness/manifests/general/claude-code.json +48 -28
  262. package/harness/manifests/general/codebuddy.json +48 -28
  263. package/harness/manifests/general/codex.json +48 -28
  264. package/harness/manifests/general/cursor.json +48 -28
  265. package/harness/manifests/java/claude-code.json +49 -29
  266. package/harness/manifests/java/codebuddy.json +49 -29
  267. package/harness/manifests/java/codex.json +49 -29
  268. package/harness/manifests/java/cursor.json +49 -29
  269. package/hunter-workflow-family.json +2 -2
  270. package/package.json +1 -1
@@ -0,0 +1,854 @@
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 json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import uuid
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ SCRIPTS_DIR = Path(__file__).resolve().parent
31
+ if str(SCRIPTS_DIR) not in sys.path:
32
+ sys.path.insert(0, str(SCRIPTS_DIR))
33
+
34
+ if hasattr(sys.stdout, "reconfigure"):
35
+ sys.stdout.reconfigure(encoding="utf-8")
36
+ if hasattr(sys.stderr, "reconfigure"):
37
+ sys.stderr.reconfigure(encoding="utf-8")
38
+
39
+
40
+ RUNTIME_REL = Path(".harness") / "runtime"
41
+ LEASES_REL = RUNTIME_REL / "leases"
42
+ PORTS_REL = RUNTIME_REL / "ports"
43
+ INTEGRATION_LOCK_REL = RUNTIME_REL / "integration-lock.json"
44
+ CHANGE_CONTEXT_REL = Path("meta") / "change-context.json"
45
+ WORKTREE_META_REL = Path("meta") / "worktree.json"
46
+ CHECKPOINTS_REL = Path("meta") / "implementation-checkpoints.json"
47
+
48
+
49
+ def now_iso() -> str:
50
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
51
+
52
+
53
+ def emit(payload: dict[str, Any], *, as_json: bool) -> None:
54
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
55
+ sys.stdout.write(text if as_json else _human_line(payload))
56
+
57
+
58
+ def _human_line(payload: dict[str, Any]) -> str:
59
+ if payload.get("ok"):
60
+ code = payload.get("code", "OK")
61
+ change = payload.get("changeId") or payload.get("change")
62
+ if change:
63
+ return f"ok code={code} change={change}\n"
64
+ return f"ok code={code}\n"
65
+ return f"error code={payload.get('code', 'ERROR')} message={payload.get('message')}\n"
66
+
67
+
68
+ def emit_error(
69
+ code: str,
70
+ message: str,
71
+ *,
72
+ as_json: bool,
73
+ extra: dict[str, Any] | None = None,
74
+ exit_code: int = 1,
75
+ ) -> int:
76
+ payload: dict[str, Any] = {"ok": False, "code": code, "message": message}
77
+ if extra:
78
+ payload.update(extra)
79
+ if as_json:
80
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
81
+ else:
82
+ sys.stderr.write(f"error: {message} ({code})\n")
83
+ return exit_code
84
+
85
+
86
+ def _git_text(cwd: Path, *args: str) -> str | None:
87
+ proc = subprocess.run(
88
+ ["git", *args],
89
+ cwd=str(cwd),
90
+ capture_output=True,
91
+ text=True,
92
+ encoding="utf-8",
93
+ errors="replace",
94
+ check=False,
95
+ )
96
+ if proc.returncode != 0:
97
+ return None
98
+ return proc.stdout.strip()
99
+
100
+
101
+ def resolve_main_project_root(cwd: Path | None = None) -> Path:
102
+ """Locate main project root from a worktree or main checkout."""
103
+ start = (cwd or Path.cwd()).resolve()
104
+ common_raw = _git_text(start, "rev-parse", "--git-common-dir")
105
+ if not common_raw:
106
+ return start
107
+ common = Path(common_raw)
108
+ if not common.is_absolute():
109
+ common = (start / common).resolve()
110
+ if common.name == ".git":
111
+ return common.parent
112
+ return start
113
+
114
+
115
+ def changes_dir(project_root: Path) -> Path:
116
+ return project_root / ".harness" / "changes"
117
+
118
+
119
+ def runtime_dir(project_root: Path) -> Path:
120
+ return project_root / RUNTIME_REL
121
+
122
+
123
+ def _read_json(path: Path) -> Any:
124
+ return json.loads(path.read_text(encoding="utf-8-sig"))
125
+
126
+
127
+ def _write_json(path: Path, data: Any) -> None:
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
130
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
131
+ try:
132
+ tmp.write_text(text, encoding="utf-8", newline="\n")
133
+ os.replace(tmp, path)
134
+ except BaseException:
135
+ tmp.unlink(missing_ok=True)
136
+ raise
137
+
138
+
139
+ @contextlib.contextmanager
140
+ def _exclusive_file_lock(path: Path, wait_seconds: float = 5.0):
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ deadline = time.monotonic() + wait_seconds
143
+ descriptor: int | None = None
144
+ while descriptor is None:
145
+ try:
146
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
147
+ except (FileExistsError, PermissionError):
148
+ if time.monotonic() >= deadline:
149
+ raise TimeoutError(f"lock unavailable: {path}")
150
+ time.sleep(0.01)
151
+ try:
152
+ os.write(descriptor, f"{os.getpid()}\n".encode("ascii"))
153
+ yield
154
+ finally:
155
+ os.close(descriptor)
156
+ path.unlink(missing_ok=True)
157
+
158
+
159
+ def list_active_changes(project_root: Path) -> list[dict[str, Any]]:
160
+ root = changes_dir(project_root)
161
+ if not root.is_dir():
162
+ return []
163
+ active: list[dict[str, Any]] = []
164
+ for entry in sorted(root.iterdir()):
165
+ if not entry.is_dir():
166
+ continue
167
+ meta_archived = entry / "meta" / "archived.json"
168
+ if meta_archived.is_file():
169
+ try:
170
+ archived = _read_json(meta_archived)
171
+ except (OSError, json.JSONDecodeError):
172
+ archived = {}
173
+ if isinstance(archived, dict) and archived.get("status") == "archived":
174
+ continue
175
+ active.append(
176
+ {
177
+ "changeId": entry.name,
178
+ "path": str(entry.resolve()),
179
+ "hasPlan": (entry / "plans").is_dir(),
180
+ "hasWorktreeMeta": (entry / WORKTREE_META_REL).is_file(),
181
+ }
182
+ )
183
+ return active
184
+
185
+
186
+ def change_dir_for_id(project_root: Path, change_id: str) -> Path | None:
187
+ candidate = changes_dir(project_root) / change_id
188
+ if candidate.is_dir():
189
+ return candidate.resolve()
190
+ return None
191
+
192
+
193
+ def resolve_change(
194
+ project_root: Path,
195
+ change_id: str | None,
196
+ ) -> dict[str, Any]:
197
+ active = list_active_changes(project_root)
198
+ if change_id:
199
+ resolved = change_dir_for_id(project_root, change_id)
200
+ if resolved is None:
201
+ return {
202
+ "ok": False,
203
+ "code": "CHANGE_NOT_FOUND",
204
+ "message": f"change not found: {change_id}",
205
+ "changeId": change_id,
206
+ }
207
+ return {
208
+ "ok": True,
209
+ "code": "RESOLVED",
210
+ "changeId": change_id,
211
+ "changeDir": str(resolved),
212
+ "projectRoot": str(project_root.resolve()),
213
+ "activeCount": len(active),
214
+ }
215
+ if not active:
216
+ return {
217
+ "ok": False,
218
+ "code": "NO_ACTIVE_CHANGE",
219
+ "message": "no active change under .harness/changes",
220
+ "activeChanges": [],
221
+ }
222
+ if len(active) == 1:
223
+ only = active[0]
224
+ return {
225
+ "ok": True,
226
+ "code": "RESOLVED",
227
+ "changeId": only["changeId"],
228
+ "changeDir": only["path"],
229
+ "projectRoot": str(project_root.resolve()),
230
+ "activeCount": 1,
231
+ "autoSelected": True,
232
+ }
233
+ return {
234
+ "ok": False,
235
+ "code": "CHANGE_SELECTION_REQUIRED",
236
+ "message": "multiple active changes; pass --change <id>",
237
+ "activeChanges": active,
238
+ "activeCount": len(active),
239
+ }
240
+
241
+
242
+ def migrate_change(project_root: Path, change_id: str) -> dict[str, Any]:
243
+ resolved = resolve_change(project_root, change_id)
244
+ if not resolved.get("ok"):
245
+ return resolved
246
+ change_dir = Path(resolved["changeDir"])
247
+ created: list[str] = []
248
+
249
+ context_path = change_dir / CHANGE_CONTEXT_REL
250
+ if not context_path.is_file():
251
+ worktree_meta = change_dir / WORKTREE_META_REL
252
+ worktree_root = project_root.resolve()
253
+ branch = _git_text(project_root, "rev-parse", "--abbrev-ref", "HEAD") or ""
254
+ if worktree_meta.is_file():
255
+ try:
256
+ wt = _read_json(worktree_meta)
257
+ if isinstance(wt, dict):
258
+ if isinstance(wt.get("path"), str) and wt["path"].strip():
259
+ worktree_root = Path(wt["path"]).expanduser().resolve()
260
+ elif isinstance(wt.get("worktreeRoot"), str) and wt["worktreeRoot"].strip():
261
+ worktree_root = Path(wt["worktreeRoot"]).expanduser().resolve()
262
+ if isinstance(wt.get("branch"), str) and wt["branch"].strip():
263
+ branch = wt["branch"].strip()
264
+ except (OSError, json.JSONDecodeError):
265
+ pass
266
+ context = {
267
+ "schemaVersion": 1,
268
+ "changeId": change_id,
269
+ "mainProjectRoot": str(project_root.resolve()),
270
+ "worktreeRoot": str(worktree_root),
271
+ "stateDir": str(change_dir.resolve()),
272
+ "branch": branch,
273
+ "migratedAt": now_iso(),
274
+ }
275
+ _write_json(context_path, context)
276
+ created.append(str(context_path.relative_to(change_dir)))
277
+
278
+ checkpoints_path = change_dir / CHECKPOINTS_REL
279
+ if not checkpoints_path.is_file():
280
+ checkpoints = {
281
+ "schemaVersion": 1,
282
+ "changeName": change_id,
283
+ "checkpoints": [
284
+ {
285
+ "id": "foundation-gate",
286
+ "afterTasks": [1, 2, 3, 4],
287
+ "beforeTasks": [6, 7, 8, 9, 10],
288
+ "reviewerTool": "codex",
289
+ "status": "pending",
290
+ "blocking": True,
291
+ "requiredReport": "reports/review/foundation-gate-review.md",
292
+ "purpose": "Block tasks 6+ until foundation interfaces are reviewed.",
293
+ }
294
+ ],
295
+ }
296
+ _write_json(checkpoints_path, checkpoints)
297
+ created.append(str(checkpoints_path.relative_to(change_dir)))
298
+
299
+ return {
300
+ "ok": True,
301
+ "code": "MIGRATED",
302
+ "changeId": change_id,
303
+ "changeDir": str(change_dir),
304
+ "created": created,
305
+ }
306
+
307
+
308
+ def _lease_path(project_root: Path, change_id: str) -> Path:
309
+ return project_root / LEASES_REL / f"{change_id}.json"
310
+
311
+
312
+ def _lease_expired(lease: dict[str, Any]) -> bool:
313
+ expires = lease.get("expiresAt")
314
+ if not isinstance(expires, str) or not expires.strip():
315
+ return True
316
+ try:
317
+ exp_dt = dt.datetime.fromisoformat(expires.replace("Z", "+00:00"))
318
+ except ValueError:
319
+ return True
320
+ return dt.datetime.now().astimezone() >= exp_dt
321
+
322
+
323
+ def inspect_lease(project_root: Path, change_id: str) -> dict[str, Any] | None:
324
+ """Return the current non-expired lease without mutating it."""
325
+ path = _lease_path(project_root, change_id)
326
+ if not path.is_file():
327
+ return None
328
+ try:
329
+ lease = _read_json(path)
330
+ except (OSError, json.JSONDecodeError):
331
+ return None
332
+ if not isinstance(lease, dict) or _lease_expired(lease):
333
+ return None
334
+ return lease
335
+
336
+
337
+ def _claim_lease_locked(
338
+ project_root: Path,
339
+ *,
340
+ change_id: str,
341
+ phase: str,
342
+ run_id: str,
343
+ ttl_seconds: int,
344
+ steal: bool = False,
345
+ ) -> dict[str, Any]:
346
+ path = _lease_path(project_root, change_id)
347
+ path.parent.mkdir(parents=True, exist_ok=True)
348
+ now = dt.datetime.now().astimezone()
349
+ expires = now + dt.timedelta(seconds=max(1, ttl_seconds))
350
+ new_lease = {
351
+ "changeId": change_id,
352
+ "phase": phase,
353
+ "runId": run_id,
354
+ "pid": os.getpid(),
355
+ "acquiredAt": now.isoformat(timespec="milliseconds"),
356
+ "expiresAt": expires.isoformat(timespec="milliseconds"),
357
+ "ttlSeconds": ttl_seconds,
358
+ }
359
+ if path.is_file():
360
+ try:
361
+ existing = _read_json(path)
362
+ except (OSError, json.JSONDecodeError):
363
+ existing = None
364
+ if isinstance(existing, dict) and not _lease_expired(existing):
365
+ same_owner = str(existing.get("runId")) == run_id
366
+ if same_owner:
367
+ existing.update(
368
+ {
369
+ "phase": phase,
370
+ "pid": os.getpid(),
371
+ "expiresAt": expires.isoformat(timespec="milliseconds"),
372
+ "refreshedAt": now.isoformat(timespec="milliseconds"),
373
+ }
374
+ )
375
+ _write_json(path, existing)
376
+ return {"ok": True, "code": "LEASE_REFRESHED", "lease": existing}
377
+ if not steal:
378
+ return {
379
+ "ok": False,
380
+ "code": "LEASE_CONFLICT",
381
+ "message": "change lease held by another run",
382
+ "holder": existing,
383
+ }
384
+ _write_json(path, new_lease)
385
+ return {"ok": True, "code": "LEASE_CLAIMED", "lease": new_lease}
386
+
387
+
388
+ def claim_lease(
389
+ project_root: Path,
390
+ *,
391
+ change_id: str,
392
+ phase: str,
393
+ run_id: str,
394
+ ttl_seconds: int,
395
+ steal: bool = False,
396
+ ) -> dict[str, Any]:
397
+ path = _lease_path(project_root, change_id)
398
+ with _exclusive_file_lock(path.with_suffix(".lock")):
399
+ return _claim_lease_locked(
400
+ project_root,
401
+ change_id=change_id,
402
+ phase=phase,
403
+ run_id=run_id,
404
+ ttl_seconds=ttl_seconds,
405
+ steal=steal,
406
+ )
407
+
408
+
409
+ def _release_lease_locked(
410
+ project_root: Path,
411
+ *,
412
+ change_id: str,
413
+ phase: str,
414
+ run_id: str,
415
+ ) -> dict[str, Any]:
416
+ path = _lease_path(project_root, change_id)
417
+ if not path.is_file():
418
+ return {
419
+ "ok": True,
420
+ "code": "LEASE_ABSENT",
421
+ "message": "no lease file present",
422
+ }
423
+ try:
424
+ existing = _read_json(path)
425
+ except (OSError, json.JSONDecodeError) as exc:
426
+ return {
427
+ "ok": False,
428
+ "code": "LEASE_INVALID",
429
+ "message": str(exc),
430
+ }
431
+ if not isinstance(existing, dict):
432
+ return {"ok": False, "code": "LEASE_INVALID", "message": "lease is not an object"}
433
+ if str(existing.get("runId")) != run_id:
434
+ return {
435
+ "ok": False,
436
+ "code": "LEASE_OWNER_MISMATCH",
437
+ "message": "run id does not match lease owner",
438
+ "holder": existing,
439
+ }
440
+ if str(existing.get("phase")) != phase:
441
+ return {
442
+ "ok": False,
443
+ "code": "LEASE_PHASE_MISMATCH",
444
+ "message": f"lease phase is {existing.get('phase')}, not {phase}",
445
+ "holder": existing,
446
+ }
447
+ path.unlink(missing_ok=True)
448
+ return {"ok": True, "code": "LEASE_RELEASED", "changeId": change_id, "phase": phase}
449
+
450
+
451
+ def release_lease(
452
+ project_root: Path,
453
+ *,
454
+ change_id: str,
455
+ phase: str,
456
+ run_id: str,
457
+ ) -> dict[str, Any]:
458
+ path = _lease_path(project_root, change_id)
459
+ with _exclusive_file_lock(path.with_suffix(".lock")):
460
+ return _release_lease_locked(
461
+ project_root, change_id=change_id, phase=phase, run_id=run_id
462
+ )
463
+
464
+
465
+ def lease_port(
466
+ project_root: Path,
467
+ *,
468
+ change_id: str,
469
+ run_id: str,
470
+ port_range: tuple[int, int],
471
+ ) -> dict[str, Any]:
472
+ start, end = port_range
473
+ if start > end:
474
+ return {
475
+ "ok": False,
476
+ "code": "INVALID_PORT_RANGE",
477
+ "message": f"invalid range {start}-{end}",
478
+ }
479
+ ports_root = project_root / PORTS_REL
480
+ ports_root.mkdir(parents=True, exist_ok=True)
481
+ registry_path = ports_root / "registry.json"
482
+ with _exclusive_file_lock(registry_path.with_suffix(".lock")):
483
+ registry: dict[str, Any] = {"leases": []}
484
+ if registry_path.is_file():
485
+ try:
486
+ loaded = _read_json(registry_path)
487
+ if isinstance(loaded, dict) and isinstance(loaded.get("leases"), list):
488
+ registry = loaded
489
+ except (OSError, json.JSONDecodeError):
490
+ registry = {"leases": []}
491
+
492
+ leases = [
493
+ item for item in registry["leases"]
494
+ if isinstance(item, dict) and not _lease_expired(item)
495
+ ]
496
+ used = {
497
+ int(item["port"])
498
+ for item in leases
499
+ if isinstance(item.get("port"), int)
500
+ }
501
+ for port in range(start, end + 1):
502
+ if port not in used:
503
+ entry = {
504
+ "changeId": change_id,
505
+ "runId": run_id,
506
+ "pid": os.getpid(),
507
+ "port": port,
508
+ "acquiredAt": now_iso(),
509
+ "expiresAt": (
510
+ dt.datetime.now().astimezone() + dt.timedelta(hours=4)
511
+ ).isoformat(timespec="milliseconds"),
512
+ }
513
+ leases.append(entry)
514
+ registry["leases"] = leases
515
+ _write_json(registry_path, registry)
516
+ return {"ok": True, "code": "PORT_LEASED", "port": port, "lease": entry}
517
+ return {
518
+ "ok": False,
519
+ "code": "PORT_RANGE_EXHAUSTED",
520
+ "message": f"no free port in {start}-{end}",
521
+ }
522
+
523
+
524
+ def release_port(
525
+ project_root: Path,
526
+ *,
527
+ change_id: str,
528
+ run_id: str,
529
+ ) -> dict[str, Any]:
530
+ registry_path = project_root / PORTS_REL / "registry.json"
531
+ if not registry_path.is_file():
532
+ return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
533
+ with _exclusive_file_lock(registry_path.with_suffix(".lock")):
534
+ try:
535
+ registry = _read_json(registry_path)
536
+ except (OSError, json.JSONDecodeError) as exc:
537
+ return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": str(exc)}
538
+ leases = registry.get("leases") if isinstance(registry, dict) else None
539
+ if not isinstance(leases, list):
540
+ return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": "leases is not a list"}
541
+ owned = [
542
+ item for item in leases
543
+ if isinstance(item, dict) and str(item.get("changeId")) == change_id
544
+ ]
545
+ if not owned:
546
+ return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
547
+ if any(str(item.get("runId")) != run_id for item in owned):
548
+ return {
549
+ "ok": False,
550
+ "code": "PORT_LEASE_OWNER_MISMATCH",
551
+ "message": "run id does not match port lease owner",
552
+ "holder": owned[0],
553
+ }
554
+ released_ports = [item.get("port") for item in owned]
555
+ registry["leases"] = [item for item in leases if item not in owned]
556
+ _write_json(registry_path, registry)
557
+ return {
558
+ "ok": True,
559
+ "code": "PORT_LEASE_RELEASED",
560
+ "changeId": change_id,
561
+ "ports": released_ports,
562
+ }
563
+
564
+
565
+ def _integration_lock_acquire_locked(
566
+ project_root: Path,
567
+ *,
568
+ run_id: str,
569
+ ttl_seconds: int = 3600,
570
+ ) -> dict[str, Any]:
571
+ path = project_root / INTEGRATION_LOCK_REL
572
+ path.parent.mkdir(parents=True, exist_ok=True)
573
+ now = dt.datetime.now().astimezone()
574
+ payload = {
575
+ "runId": run_id,
576
+ "pid": os.getpid(),
577
+ "acquiredAt": now.isoformat(timespec="milliseconds"),
578
+ "expiresAt": (now + dt.timedelta(seconds=ttl_seconds)).isoformat(
579
+ timespec="milliseconds"
580
+ ),
581
+ }
582
+ if path.is_file():
583
+ try:
584
+ existing = _read_json(path)
585
+ except (OSError, json.JSONDecodeError):
586
+ existing = None
587
+ if isinstance(existing, dict) and not _lease_expired(existing):
588
+ if str(existing.get("runId")) == run_id:
589
+ _write_json(path, payload)
590
+ return {"ok": True, "code": "INTEGRATION_LOCK_REFRESHED", "lock": payload}
591
+ return {
592
+ "ok": False,
593
+ "code": "INTEGRATION_LOCK_HELD",
594
+ "message": "integration lock held by another run",
595
+ "holder": existing,
596
+ }
597
+ _write_json(path, payload)
598
+ return {"ok": True, "code": "INTEGRATION_LOCK_ACQUIRED", "lock": payload}
599
+
600
+
601
+ def integration_lock_acquire(
602
+ project_root: Path,
603
+ *,
604
+ run_id: str,
605
+ ttl_seconds: int = 3600,
606
+ ) -> dict[str, Any]:
607
+ path = project_root / INTEGRATION_LOCK_REL
608
+ with _exclusive_file_lock(path.with_suffix(".lock")):
609
+ return _integration_lock_acquire_locked(
610
+ project_root, run_id=run_id, ttl_seconds=ttl_seconds
611
+ )
612
+
613
+
614
+ def _integration_lock_release_locked(project_root: Path, *, run_id: str) -> dict[str, Any]:
615
+ path = project_root / INTEGRATION_LOCK_REL
616
+ if not path.is_file():
617
+ return {"ok": True, "code": "INTEGRATION_LOCK_ABSENT"}
618
+ try:
619
+ existing = _read_json(path)
620
+ except (OSError, json.JSONDecodeError) as exc:
621
+ return {"ok": False, "code": "INTEGRATION_LOCK_INVALID", "message": str(exc)}
622
+ if not isinstance(existing, dict):
623
+ return {"ok": False, "code": "INTEGRATION_LOCK_INVALID", "message": "not an object"}
624
+ if str(existing.get("runId")) != run_id:
625
+ return {
626
+ "ok": False,
627
+ "code": "INTEGRATION_LOCK_OWNER_MISMATCH",
628
+ "message": "run id does not match lock owner",
629
+ "holder": existing,
630
+ }
631
+ path.unlink(missing_ok=True)
632
+ return {"ok": True, "code": "INTEGRATION_LOCK_RELEASED"}
633
+
634
+
635
+ def integration_lock_release(project_root: Path, *, run_id: str) -> dict[str, Any]:
636
+ path = project_root / INTEGRATION_LOCK_REL
637
+ with _exclusive_file_lock(path.with_suffix(".lock")):
638
+ return _integration_lock_release_locked(project_root, run_id=run_id)
639
+
640
+
641
+ def parse_port_range(raw: str) -> tuple[int, int] | None:
642
+ if "-" not in raw:
643
+ return None
644
+ left, right = raw.split("-", 1)
645
+ try:
646
+ return int(left.strip()), int(right.strip())
647
+ except ValueError:
648
+ return None
649
+
650
+
651
+ def cmd_list(args: argparse.Namespace) -> int:
652
+ project = resolve_main_project_root()
653
+ payload = {
654
+ "ok": True,
655
+ "code": "LISTED",
656
+ "projectRoot": str(project),
657
+ "activeChanges": list_active_changes(project),
658
+ }
659
+ emit(payload, as_json=bool(args.json))
660
+ return 0
661
+
662
+
663
+ def cmd_resolve(args: argparse.Namespace) -> int:
664
+ project = resolve_main_project_root()
665
+ payload = resolve_change(project, args.change)
666
+ if payload.get("ok"):
667
+ emit(payload, as_json=bool(args.json))
668
+ return 0
669
+ return emit_error(
670
+ str(payload.get("code", "RESOLVE_FAILED")),
671
+ str(payload.get("message", "resolve failed")),
672
+ as_json=bool(args.json),
673
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
674
+ )
675
+
676
+
677
+ def cmd_migrate(args: argparse.Namespace) -> int:
678
+ project = resolve_main_project_root()
679
+ if not args.change:
680
+ return emit_error("CHANGE_REQUIRED", "--change is required", as_json=bool(args.json))
681
+ payload = migrate_change(project, args.change)
682
+ if payload.get("ok"):
683
+ emit(payload, as_json=bool(args.json))
684
+ return 0
685
+ return emit_error(
686
+ str(payload.get("code", "MIGRATE_FAILED")),
687
+ str(payload.get("message", "migrate failed")),
688
+ as_json=bool(args.json),
689
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
690
+ )
691
+
692
+
693
+ def cmd_claim(args: argparse.Namespace) -> int:
694
+ project = resolve_main_project_root()
695
+ payload = claim_lease(
696
+ project,
697
+ change_id=args.change,
698
+ phase=args.phase,
699
+ run_id=args.run_id,
700
+ ttl_seconds=int(args.ttl_seconds),
701
+ steal=bool(args.steal),
702
+ )
703
+ if payload.get("ok"):
704
+ emit(payload, as_json=bool(args.json))
705
+ return 0
706
+ return emit_error(
707
+ str(payload.get("code", "CLAIM_FAILED")),
708
+ str(payload.get("message", "claim failed")),
709
+ as_json=bool(args.json),
710
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
711
+ )
712
+
713
+
714
+ def cmd_release(args: argparse.Namespace) -> int:
715
+ project = resolve_main_project_root()
716
+ payload = release_lease(
717
+ project,
718
+ change_id=args.change,
719
+ phase=args.phase,
720
+ run_id=args.run_id,
721
+ )
722
+ if payload.get("ok"):
723
+ emit(payload, as_json=bool(args.json))
724
+ return 0
725
+ return emit_error(
726
+ str(payload.get("code", "RELEASE_FAILED")),
727
+ str(payload.get("message", "release failed")),
728
+ as_json=bool(args.json),
729
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
730
+ )
731
+
732
+
733
+ def cmd_lease_port(args: argparse.Namespace) -> int:
734
+ project = resolve_main_project_root()
735
+ parsed = parse_port_range(args.range)
736
+ if parsed is None:
737
+ return emit_error(
738
+ "INVALID_PORT_RANGE",
739
+ f"expected --range <start-end>, got {args.range!r}",
740
+ as_json=bool(args.json),
741
+ )
742
+ payload = lease_port(
743
+ project,
744
+ change_id=args.change,
745
+ run_id=args.run_id,
746
+ port_range=parsed,
747
+ )
748
+ if payload.get("ok"):
749
+ emit(payload, as_json=bool(args.json))
750
+ return 0
751
+ return emit_error(
752
+ str(payload.get("code", "PORT_LEASE_FAILED")),
753
+ str(payload.get("message", "port lease failed")),
754
+ as_json=bool(args.json),
755
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
756
+ )
757
+
758
+
759
+ def cmd_release_port(args: argparse.Namespace) -> int:
760
+ project = resolve_main_project_root()
761
+ payload = release_port(project, change_id=args.change, run_id=args.run_id)
762
+ if payload.get("ok"):
763
+ emit(payload, as_json=bool(args.json))
764
+ return 0
765
+ return emit_error(
766
+ str(payload.get("code", "PORT_RELEASE_FAILED")),
767
+ str(payload.get("message", "port release failed")),
768
+ as_json=bool(args.json),
769
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
770
+ )
771
+
772
+
773
+ def cmd_integration_lock(args: argparse.Namespace) -> int:
774
+ project = resolve_main_project_root()
775
+ if args.integration_action == "acquire":
776
+ payload = integration_lock_acquire(
777
+ project,
778
+ run_id=args.run_id,
779
+ ttl_seconds=int(args.ttl_seconds),
780
+ )
781
+ else:
782
+ payload = integration_lock_release(project, run_id=args.run_id)
783
+ if payload.get("ok"):
784
+ emit(payload, as_json=bool(args.json))
785
+ return 0
786
+ return emit_error(
787
+ str(payload.get("code", "INTEGRATION_LOCK_FAILED")),
788
+ str(payload.get("message", "integration lock failed")),
789
+ as_json=bool(args.json),
790
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
791
+ )
792
+
793
+
794
+ def build_parser() -> argparse.ArgumentParser:
795
+ parser = argparse.ArgumentParser(prog="harness_change.py")
796
+ parser.add_argument("--json", action="store_true")
797
+ sub = parser.add_subparsers(dest="command_name", required=True)
798
+
799
+ shared = argparse.ArgumentParser(add_help=False)
800
+ shared.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
801
+
802
+ p_list = sub.add_parser("list", parents=[shared])
803
+ p_list.set_defaults(func=cmd_list)
804
+
805
+ p_resolve = sub.add_parser("resolve", parents=[shared])
806
+ p_resolve.add_argument("--change", default=None)
807
+ p_resolve.set_defaults(func=cmd_resolve)
808
+
809
+ p_migrate = sub.add_parser("migrate", parents=[shared])
810
+ p_migrate.add_argument("--change", required=True)
811
+ p_migrate.set_defaults(func=cmd_migrate)
812
+
813
+ p_claim = sub.add_parser("claim", parents=[shared])
814
+ p_claim.add_argument("--change", required=True)
815
+ p_claim.add_argument("--phase", required=True)
816
+ p_claim.add_argument("--run-id", required=True)
817
+ p_claim.add_argument("--ttl-seconds", type=int, default=3600)
818
+ p_claim.add_argument("--steal", action="store_true")
819
+ p_claim.set_defaults(func=cmd_claim)
820
+
821
+ p_release = sub.add_parser("release", parents=[shared])
822
+ p_release.add_argument("--change", required=True)
823
+ p_release.add_argument("--phase", required=True)
824
+ p_release.add_argument("--run-id", required=True)
825
+ p_release.set_defaults(func=cmd_release)
826
+
827
+ p_port = sub.add_parser("lease-port", parents=[shared])
828
+ p_port.add_argument("--change", required=True)
829
+ p_port.add_argument("--run-id", required=True)
830
+ p_port.add_argument("--range", required=True)
831
+ p_port.set_defaults(func=cmd_lease_port)
832
+
833
+ p_port_release = sub.add_parser("release-port", parents=[shared])
834
+ p_port_release.add_argument("--change", required=True)
835
+ p_port_release.add_argument("--run-id", required=True)
836
+ p_port_release.set_defaults(func=cmd_release_port)
837
+
838
+ p_lock = sub.add_parser("integration-lock", parents=[shared])
839
+ p_lock.add_argument("integration_action", choices=["acquire", "release"])
840
+ p_lock.add_argument("--run-id", required=True)
841
+ p_lock.add_argument("--ttl-seconds", type=int, default=3600)
842
+ p_lock.set_defaults(func=cmd_integration_lock)
843
+
844
+ return parser
845
+
846
+
847
+ def main(argv: list[str] | None = None) -> int:
848
+ parser = build_parser()
849
+ args = parser.parse_args(argv)
850
+ return int(args.func(args))
851
+
852
+
853
+ if __name__ == "__main__":
854
+ raise SystemExit(main())