@hunter-harness/workflow-harness 0.2.32 → 0.2.34

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 (267) hide show
  1. package/README.md +25 -25
  2. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  3. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +1 -0
  4. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +215 -106
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +8 -0
  7. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  10. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -4
  12. package/harness/bundles/general/claude-code/harness-plan/checklist.md +2 -0
  13. package/harness/bundles/general/claude-code/harness-plan/reference.md +6 -0
  14. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  15. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +4 -4
  19. package/harness/bundles/general/claude-code/harness-test/checklist.md +1 -0
  20. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -1
  21. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +2 -2
  22. package/harness/bundles/general/claude-code/scripts/harness_archive.py +404 -12
  23. package/harness/bundles/general/claude-code/scripts/harness_context.py +717 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_gate.py +1 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +77 -9
  26. package/harness/bundles/general/claude-code/scripts/harness_phase.py +1 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_plan_aggregate.py +249 -0
  28. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +43 -11
  29. package/harness/bundles/general/claude-code/scripts/harness_profile.py +272 -15
  30. package/harness/bundles/general/claude-code/scripts/harness_report_model.py +420 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_review.py +16 -1
  32. package/harness/bundles/general/claude-code/scripts/harness_verification.py +114 -0
  33. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  34. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +1 -0
  35. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +215 -106
  37. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +8 -0
  38. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  41. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
  43. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +2 -0
  44. package/harness/bundles/general/codebuddy/harness-plan/reference.md +6 -0
  45. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  46. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  47. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  48. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  49. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +4 -4
  50. package/harness/bundles/general/codebuddy/harness-test/checklist.md +1 -0
  51. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -1
  52. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +2 -2
  53. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +404 -12
  54. package/harness/bundles/general/codebuddy/scripts/harness_context.py +717 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +77 -9
  57. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +1 -0
  58. package/harness/bundles/general/codebuddy/scripts/harness_plan_aggregate.py +249 -0
  59. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +43 -11
  60. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +272 -15
  61. package/harness/bundles/general/codebuddy/scripts/harness_report_model.py +420 -0
  62. package/harness/bundles/general/codebuddy/scripts/harness_review.py +16 -1
  63. package/harness/bundles/general/codebuddy/scripts/harness_verification.py +114 -0
  64. package/harness/bundles/general/codex/.harness-build.json +1 -1
  65. package/harness/bundles/general/codex/contracts/workflow-policy.json +1 -0
  66. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +215 -106
  68. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +8 -0
  69. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  70. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  71. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  72. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
  74. package/harness/bundles/general/codex/harness-plan/checklist.md +2 -0
  75. package/harness/bundles/general/codex/harness-plan/reference.md +6 -0
  76. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  77. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  78. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  79. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  80. package/harness/bundles/general/codex/harness-test/SKILL.md +4 -4
  81. package/harness/bundles/general/codex/harness-test/checklist.md +1 -0
  82. package/harness/bundles/general/codex/harness-test/reference.md +2 -1
  83. package/harness/bundles/general/codex/protocols/ledger-protocol.md +2 -2
  84. package/harness/bundles/general/codex/scripts/harness_archive.py +404 -12
  85. package/harness/bundles/general/codex/scripts/harness_context.py +717 -0
  86. package/harness/bundles/general/codex/scripts/harness_gate.py +1 -0
  87. package/harness/bundles/general/codex/scripts/harness_ledger.py +77 -9
  88. package/harness/bundles/general/codex/scripts/harness_phase.py +1 -0
  89. package/harness/bundles/general/codex/scripts/harness_plan_aggregate.py +249 -0
  90. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +43 -11
  91. package/harness/bundles/general/codex/scripts/harness_profile.py +272 -15
  92. package/harness/bundles/general/codex/scripts/harness_report_model.py +420 -0
  93. package/harness/bundles/general/codex/scripts/harness_review.py +16 -1
  94. package/harness/bundles/general/codex/scripts/harness_verification.py +114 -0
  95. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  96. package/harness/bundles/general/cursor/contracts/workflow-policy.json +1 -0
  97. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  98. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +215 -106
  99. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +8 -0
  100. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  101. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  102. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  103. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  104. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
  105. package/harness/bundles/general/cursor/harness-plan/checklist.md +2 -0
  106. package/harness/bundles/general/cursor/harness-plan/reference.md +6 -0
  107. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  108. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  109. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  110. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  111. package/harness/bundles/general/cursor/harness-test/SKILL.md +4 -4
  112. package/harness/bundles/general/cursor/harness-test/checklist.md +1 -0
  113. package/harness/bundles/general/cursor/harness-test/reference.md +2 -1
  114. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +2 -2
  115. package/harness/bundles/general/cursor/scripts/harness_archive.py +404 -12
  116. package/harness/bundles/general/cursor/scripts/harness_context.py +717 -0
  117. package/harness/bundles/general/cursor/scripts/harness_gate.py +1 -0
  118. package/harness/bundles/general/cursor/scripts/harness_ledger.py +77 -9
  119. package/harness/bundles/general/cursor/scripts/harness_phase.py +1 -0
  120. package/harness/bundles/general/cursor/scripts/harness_plan_aggregate.py +249 -0
  121. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +43 -11
  122. package/harness/bundles/general/cursor/scripts/harness_profile.py +272 -15
  123. package/harness/bundles/general/cursor/scripts/harness_report_model.py +420 -0
  124. package/harness/bundles/general/cursor/scripts/harness_review.py +16 -1
  125. package/harness/bundles/general/cursor/scripts/harness_verification.py +114 -0
  126. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  127. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +1 -0
  128. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  129. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +215 -106
  131. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +8 -0
  132. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  133. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  134. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  135. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  136. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  137. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
  138. package/harness/bundles/java/claude-code/harness-plan/checklist.md +2 -0
  139. package/harness/bundles/java/claude-code/harness-plan/reference.md +6 -0
  140. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  141. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  142. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  143. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  144. package/harness/bundles/java/claude-code/harness-test/SKILL.md +4 -4
  145. package/harness/bundles/java/claude-code/harness-test/checklist.md +1 -0
  146. package/harness/bundles/java/claude-code/harness-test/reference.md +2 -0
  147. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +2 -2
  148. package/harness/bundles/java/claude-code/scripts/harness_archive.py +404 -12
  149. package/harness/bundles/java/claude-code/scripts/harness_context.py +717 -0
  150. package/harness/bundles/java/claude-code/scripts/harness_gate.py +1 -0
  151. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +77 -9
  152. package/harness/bundles/java/claude-code/scripts/harness_phase.py +1 -0
  153. package/harness/bundles/java/claude-code/scripts/harness_plan_aggregate.py +249 -0
  154. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +43 -11
  155. package/harness/bundles/java/claude-code/scripts/harness_profile.py +272 -15
  156. package/harness/bundles/java/claude-code/scripts/harness_report_model.py +420 -0
  157. package/harness/bundles/java/claude-code/scripts/harness_review.py +16 -1
  158. package/harness/bundles/java/claude-code/scripts/harness_verification.py +114 -0
  159. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  160. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +1 -0
  161. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  162. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  163. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +215 -106
  164. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +8 -0
  165. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  166. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  167. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  168. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  169. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  170. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
  171. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +2 -0
  172. package/harness/bundles/java/codebuddy/harness-plan/reference.md +6 -0
  173. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  174. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  175. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  176. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  177. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +4 -4
  178. package/harness/bundles/java/codebuddy/harness-test/checklist.md +1 -0
  179. package/harness/bundles/java/codebuddy/harness-test/reference.md +2 -0
  180. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +2 -2
  181. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +404 -12
  182. package/harness/bundles/java/codebuddy/scripts/harness_context.py +717 -0
  183. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1 -0
  184. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +77 -9
  185. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +1 -0
  186. package/harness/bundles/java/codebuddy/scripts/harness_plan_aggregate.py +249 -0
  187. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +43 -11
  188. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +272 -15
  189. package/harness/bundles/java/codebuddy/scripts/harness_report_model.py +420 -0
  190. package/harness/bundles/java/codebuddy/scripts/harness_review.py +16 -1
  191. package/harness/bundles/java/codebuddy/scripts/harness_verification.py +114 -0
  192. package/harness/bundles/java/codex/.harness-build.json +1 -1
  193. package/harness/bundles/java/codex/contracts/workflow-policy.json +1 -0
  194. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  195. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  196. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +215 -106
  197. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +8 -0
  198. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  199. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  200. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  201. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  202. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  203. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
  204. package/harness/bundles/java/codex/harness-plan/checklist.md +2 -0
  205. package/harness/bundles/java/codex/harness-plan/reference.md +6 -0
  206. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  207. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  208. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  209. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  210. package/harness/bundles/java/codex/harness-test/SKILL.md +4 -4
  211. package/harness/bundles/java/codex/harness-test/checklist.md +1 -0
  212. package/harness/bundles/java/codex/harness-test/reference.md +2 -0
  213. package/harness/bundles/java/codex/protocols/ledger-protocol.md +2 -2
  214. package/harness/bundles/java/codex/scripts/harness_archive.py +404 -12
  215. package/harness/bundles/java/codex/scripts/harness_context.py +717 -0
  216. package/harness/bundles/java/codex/scripts/harness_gate.py +1 -0
  217. package/harness/bundles/java/codex/scripts/harness_ledger.py +77 -9
  218. package/harness/bundles/java/codex/scripts/harness_phase.py +1 -0
  219. package/harness/bundles/java/codex/scripts/harness_plan_aggregate.py +249 -0
  220. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +43 -11
  221. package/harness/bundles/java/codex/scripts/harness_profile.py +272 -15
  222. package/harness/bundles/java/codex/scripts/harness_report_model.py +420 -0
  223. package/harness/bundles/java/codex/scripts/harness_review.py +16 -1
  224. package/harness/bundles/java/codex/scripts/harness_verification.py +114 -0
  225. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  226. package/harness/bundles/java/cursor/contracts/workflow-policy.json +1 -0
  227. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  228. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  229. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +215 -106
  230. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +8 -0
  231. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  232. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  233. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  234. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  235. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  236. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
  237. package/harness/bundles/java/cursor/harness-plan/checklist.md +2 -0
  238. package/harness/bundles/java/cursor/harness-plan/reference.md +6 -0
  239. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  240. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  241. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  242. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-test/SKILL.md +4 -4
  244. package/harness/bundles/java/cursor/harness-test/checklist.md +1 -0
  245. package/harness/bundles/java/cursor/harness-test/reference.md +2 -0
  246. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +2 -2
  247. package/harness/bundles/java/cursor/scripts/harness_archive.py +404 -12
  248. package/harness/bundles/java/cursor/scripts/harness_context.py +717 -0
  249. package/harness/bundles/java/cursor/scripts/harness_gate.py +1 -0
  250. package/harness/bundles/java/cursor/scripts/harness_ledger.py +77 -9
  251. package/harness/bundles/java/cursor/scripts/harness_phase.py +1 -0
  252. package/harness/bundles/java/cursor/scripts/harness_plan_aggregate.py +249 -0
  253. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +43 -11
  254. package/harness/bundles/java/cursor/scripts/harness_profile.py +272 -15
  255. package/harness/bundles/java/cursor/scripts/harness_report_model.py +420 -0
  256. package/harness/bundles/java/cursor/scripts/harness_review.py +16 -1
  257. package/harness/bundles/java/cursor/scripts/harness_verification.py +114 -0
  258. package/harness/manifests/general/claude-code.json +45 -29
  259. package/harness/manifests/general/codebuddy.json +45 -29
  260. package/harness/manifests/general/codex.json +45 -29
  261. package/harness/manifests/general/cursor.json +45 -29
  262. package/harness/manifests/java/claude-code.json +47 -31
  263. package/harness/manifests/java/codebuddy.json +47 -31
  264. package/harness/manifests/java/codex.json +47 -31
  265. package/harness/manifests/java/cursor.json +47 -31
  266. package/hunter-workflow-family.json +4 -4
  267. package/package.json +1 -1
@@ -0,0 +1,717 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-agent workflow context and append-only transition receipts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ PHASE_GRAPH = {
19
+ "plan": ("run",),
20
+ "run": ("test",),
21
+ "test": ("review", "run"),
22
+ "review": ("submit", "run"),
23
+ "submit": (),
24
+ }
25
+
26
+
27
+ def _now() -> dt.datetime:
28
+ return dt.datetime.now(dt.timezone.utc)
29
+
30
+
31
+ def _read_json(path: Path) -> dict[str, Any]:
32
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
33
+ if not isinstance(data, dict):
34
+ raise ValueError(f"JSON object required: {path}")
35
+ return data
36
+
37
+
38
+ def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ fd, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
41
+ temp = Path(raw)
42
+ try:
43
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
44
+ handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
45
+ handle.flush()
46
+ os.fsync(handle.fileno())
47
+ os.replace(temp, path)
48
+ finally:
49
+ temp.unlink(missing_ok=True)
50
+
51
+
52
+ def _append_ndjson(path: Path, payload: dict[str, Any]) -> None:
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ lock = path.with_suffix(path.suffix + ".lock")
55
+ deadline = time.monotonic() + 2
56
+ descriptor: int | None = None
57
+ while descriptor is None:
58
+ try:
59
+ descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
60
+ except FileExistsError:
61
+ if time.monotonic() >= deadline:
62
+ raise TimeoutError(f"context transition log locked: {path}")
63
+ time.sleep(0.02)
64
+ try:
65
+ with path.open("a", encoding="utf-8", newline="\n") as handle:
66
+ handle.write(
67
+ json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n"
68
+ )
69
+ handle.flush()
70
+ os.fsync(handle.fileno())
71
+ finally:
72
+ os.close(descriptor)
73
+ lock.unlink(missing_ok=True)
74
+
75
+
76
+ def _read_ndjson(path: Path) -> list[dict[str, Any]]:
77
+ if not path.is_file():
78
+ return []
79
+ entries: list[dict[str, Any]] = []
80
+ for line in path.read_text(encoding="utf-8-sig").splitlines():
81
+ if not line.strip():
82
+ continue
83
+ try:
84
+ item = json.loads(line)
85
+ except json.JSONDecodeError:
86
+ continue
87
+ if isinstance(item, dict):
88
+ entries.append(item)
89
+ return entries
90
+
91
+
92
+ def _sha256(path: Path) -> str:
93
+ return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
94
+
95
+
96
+ def _payload_hash(payload: dict[str, Any]) -> str:
97
+ return "sha256:" + hashlib.sha256(
98
+ json.dumps(
99
+ payload,
100
+ ensure_ascii=False,
101
+ sort_keys=True,
102
+ separators=(",", ":"),
103
+ ).encode("utf-8")
104
+ ).hexdigest()
105
+
106
+
107
+ def _head(project: Path) -> str | None:
108
+ process = subprocess.run(
109
+ ["git", "-C", str(project), "rev-parse", "--verify", "HEAD"],
110
+ text=True,
111
+ stdout=subprocess.PIPE,
112
+ stderr=subprocess.PIPE,
113
+ check=False,
114
+ )
115
+ value = process.stdout.strip()
116
+ return value if process.returncode == 0 and value else None
117
+
118
+
119
+ def _git_common_dir(project: Path) -> Path | None:
120
+ process = subprocess.run(
121
+ ["git", "-C", str(project), "rev-parse", "--git-common-dir"],
122
+ text=True,
123
+ stdout=subprocess.PIPE,
124
+ stderr=subprocess.PIPE,
125
+ check=False,
126
+ )
127
+ value = process.stdout.strip()
128
+ if process.returncode != 0 or not value:
129
+ return None
130
+ common = Path(value)
131
+ return common.resolve() if common.is_absolute() else (project / common).resolve()
132
+
133
+
134
+ def _same_repository(project: Path, candidate: Path) -> bool:
135
+ project_common = _git_common_dir(project)
136
+ candidate_common = _git_common_dir(candidate)
137
+ if project_common is not None or candidate_common is not None:
138
+ return (
139
+ project_common is not None
140
+ and candidate_common is not None
141
+ and project_common == candidate_common
142
+ )
143
+ return candidate.is_relative_to(project)
144
+
145
+
146
+ def _contract(project: Path, change: str) -> tuple[Path, dict[str, Any], Path]:
147
+ root = project.resolve()
148
+ contract_root = (root / ".harness" / "changes" / change).resolve()
149
+ changes_root = (root / ".harness" / "changes").resolve()
150
+ if not contract_root.is_relative_to(changes_root) or not contract_root.is_dir():
151
+ raise ValueError(f"CHANGE_NOT_FOUND: {change}")
152
+ context_path = contract_root / "meta" / "change-context.json"
153
+ contract = _read_json(context_path) if context_path.is_file() else {}
154
+ ownership = contract.get("stateOwnership")
155
+ runtime_rel = (
156
+ ownership.get("runtimeRoot")
157
+ if isinstance(ownership, dict)
158
+ else None
159
+ )
160
+ if runtime_rel:
161
+ candidate = (root / str(runtime_rel)).resolve()
162
+ state_parent = (root / ".harness" / "state" / "changes").resolve()
163
+ expected = (state_parent / change).resolve()
164
+ if candidate != expected or not candidate.is_relative_to(state_parent):
165
+ raise ValueError("STATE_ROOT_INVALID")
166
+ state_root = candidate
167
+ else:
168
+ state_root = contract_root
169
+ return contract_root, contract, state_root
170
+
171
+
172
+ def _active_changes(project: Path) -> list[str]:
173
+ root = project.resolve() / ".harness" / "changes"
174
+ if not root.is_dir():
175
+ return []
176
+ active: list[str] = []
177
+ for child in sorted(root.iterdir()):
178
+ path = child / "meta" / "change-context.json"
179
+ if not child.is_dir() or not path.is_file():
180
+ continue
181
+ try:
182
+ contract = _read_json(path)
183
+ except (OSError, ValueError, json.JSONDecodeError):
184
+ continue
185
+ lifecycle = contract.get("lifecycle")
186
+ if isinstance(lifecycle, dict) and lifecycle.get("status") == "active":
187
+ active.append(child.name)
188
+ return active
189
+
190
+
191
+ def _paths(state_root: Path) -> dict[str, Path]:
192
+ runtime = state_root / "runtime"
193
+ return {
194
+ "runtime": runtime,
195
+ "transitions": runtime / "transitions.ndjson",
196
+ "begins": runtime / "transition-begins.ndjson",
197
+ "current": runtime / "current-context.json",
198
+ "lease": runtime / "context-lease.json",
199
+ }
200
+
201
+
202
+ def _parse_time(value: Any) -> dt.datetime | None:
203
+ if not isinstance(value, str) or not value.strip():
204
+ return None
205
+ try:
206
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
207
+ except ValueError:
208
+ return None
209
+ if parsed.tzinfo is None:
210
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
211
+ return parsed.astimezone(dt.timezone.utc)
212
+
213
+
214
+ def _execution_root(project: Path, contract_root: Path, state_root: Path) -> Path:
215
+ for candidate in (
216
+ state_root / "meta" / "worktree.json",
217
+ contract_root / "meta" / "worktree.json",
218
+ ):
219
+ if not candidate.is_file():
220
+ continue
221
+ try:
222
+ worktree = _read_json(candidate)
223
+ except (OSError, ValueError, json.JSONDecodeError):
224
+ continue
225
+ path = worktree.get("path")
226
+ if not path and worktree.get("worktreeRoot"):
227
+ path = Path(str(worktree["worktreeRoot"])) / contract_root.name
228
+ if path:
229
+ unresolved = Path(str(path))
230
+ resolved = (
231
+ unresolved.resolve()
232
+ if unresolved.is_absolute()
233
+ else (project / unresolved).resolve()
234
+ )
235
+ if resolved.is_dir() and _same_repository(project, resolved):
236
+ return resolved
237
+ return project.resolve()
238
+
239
+
240
+ def prepare_context(
241
+ project: Path,
242
+ *,
243
+ phase: str,
244
+ executor: str,
245
+ change: str | None = None,
246
+ ttl_seconds: int = 3600,
247
+ ) -> dict[str, Any]:
248
+ project = Path(project).resolve()
249
+ candidates = _active_changes(project)
250
+ if change is None:
251
+ if not candidates:
252
+ return {
253
+ "ok": False,
254
+ "code": "ACTIVE_CHANGE_MISSING",
255
+ "candidates": [],
256
+ }
257
+ if len(candidates) > 1:
258
+ return {
259
+ "ok": False,
260
+ "code": "ACTIVE_CHANGE_AMBIGUOUS",
261
+ "candidates": candidates,
262
+ }
263
+ change = candidates[0]
264
+ try:
265
+ contract_root, contract, state_root = _contract(project, change)
266
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
267
+ return {"ok": False, "code": str(exc).split(":", 1)[0], "error": str(exc)}
268
+ lifecycle = contract.get("lifecycle")
269
+ if (
270
+ isinstance(lifecycle, dict)
271
+ and lifecycle.get("status") not in {None, "active"}
272
+ ):
273
+ return {"ok": False, "code": "CHANGE_NOT_ACTIVE", "changeName": change}
274
+ paths = _paths(state_root)
275
+ paths["runtime"].mkdir(parents=True, exist_ok=True)
276
+ recovery: dict[str, Any] | None = None
277
+ if paths["lease"].is_file():
278
+ try:
279
+ lease = _read_json(paths["lease"])
280
+ except (OSError, ValueError, json.JSONDecodeError):
281
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
282
+ expiry = _parse_time(lease.get("expiresAt"))
283
+ expired = expiry is None or _now() >= expiry
284
+ if not expired and lease.get("owner") != executor:
285
+ return {
286
+ "ok": False,
287
+ "code": "CONTEXT_LEASE_HELD",
288
+ "holder": lease.get("owner"),
289
+ "expiresAt": lease.get("expiresAt"),
290
+ }
291
+ if expired:
292
+ recovery = {
293
+ "code": "LEASE_EXPIRED_RECOVERED",
294
+ "previousOwner": lease.get("owner"),
295
+ "expiredAt": lease.get("expiresAt"),
296
+ }
297
+
298
+ transitions = _read_ndjson(paths["transitions"])
299
+ latest = transitions[-1] if transitions else None
300
+ current = (
301
+ _read_json(paths["current"])
302
+ if paths["current"].is_file()
303
+ else None
304
+ )
305
+ if recovery is None and isinstance(current, dict) and (
306
+ current.get("executor") != executor or current.get("phase") != phase
307
+ ):
308
+ valid_receipt = (
309
+ isinstance(latest, dict)
310
+ and latest.get("toPhase") == phase
311
+ and latest.get("fromPhase") == current.get("phase")
312
+ )
313
+ if not valid_receipt:
314
+ return {
315
+ "ok": False,
316
+ "code": "HANDOFF_REQUIRED",
317
+ "fromExecutor": current.get("executor"),
318
+ "fromPhase": current.get("phase"),
319
+ "toExecutor": executor,
320
+ "toPhase": phase,
321
+ }
322
+
323
+ execution_root = _execution_root(project, contract_root, state_root)
324
+ now = _now()
325
+ lease = {
326
+ "schemaVersion": 1,
327
+ "changeName": change,
328
+ "phase": phase,
329
+ "owner": executor,
330
+ "acquiredAt": now.isoformat(),
331
+ "expiresAt": (
332
+ now + dt.timedelta(seconds=max(1, int(ttl_seconds)))
333
+ ).isoformat(),
334
+ }
335
+ _write_json_atomic(paths["lease"], lease)
336
+ _write_json_atomic(
337
+ paths["current"],
338
+ {
339
+ "schemaVersion": 1,
340
+ "changeName": change,
341
+ "phase": phase,
342
+ "executor": executor,
343
+ "executionRoot": str(execution_root),
344
+ "preparedAt": now.isoformat(),
345
+ "receiptHash": latest.get("receiptHash") if latest else None,
346
+ },
347
+ )
348
+ next_phases = (
349
+ list(PHASE_GRAPH.get(str(latest.get("toPhase")), ()))
350
+ if latest
351
+ else ["plan", "run"]
352
+ )
353
+ return {
354
+ "ok": True,
355
+ "code": "CONTEXT_PREPARED",
356
+ "changeName": change,
357
+ "phase": phase,
358
+ "executionRoot": str(execution_root),
359
+ "contractRoot": str(contract_root),
360
+ "stateRoot": str(state_root),
361
+ "nextPhases": next_phases,
362
+ "legacyBootstrap": not transitions,
363
+ "latestTransition": latest,
364
+ "lease": lease,
365
+ "recovery": recovery,
366
+ }
367
+
368
+
369
+ def _invalidate_for_fixback(
370
+ state_root: Path,
371
+ *,
372
+ transition_hash: str,
373
+ from_phase: str,
374
+ to_phase: str,
375
+ ) -> dict[str, Any]:
376
+ ledger_path = state_root / "evidence" / "verification-ledger.json"
377
+ invalidated: list[str] = []
378
+ if ledger_path.is_file():
379
+ try:
380
+ ledger = _read_json(ledger_path)
381
+ except (OSError, ValueError, json.JSONDecodeError):
382
+ ledger = {}
383
+ targets = ledger.get("verificationTargets")
384
+ if isinstance(targets, dict):
385
+ for target_id, target in targets.items():
386
+ if not isinstance(target, dict):
387
+ continue
388
+ target["reusable"] = False
389
+ target["invalidation"] = {
390
+ "code": "FIXBACK_INVALIDATED",
391
+ "transitionHash": transition_hash,
392
+ "fromPhase": from_phase,
393
+ "toPhase": to_phase,
394
+ "invalidatedAt": _now().isoformat(),
395
+ }
396
+ invalidated.append(str(target_id))
397
+ validations = ledger.get("validations")
398
+ if isinstance(validations, dict):
399
+ for entry in validations.values():
400
+ if isinstance(entry, dict):
401
+ entry["reusable"] = False
402
+ entry["invalidation"] = {
403
+ "code": "FIXBACK_INVALIDATED",
404
+ "transitionHash": transition_hash,
405
+ }
406
+ _write_json_atomic(ledger_path, ledger)
407
+ record = {
408
+ "schemaVersion": 1,
409
+ "code": "FIXBACK_INVALIDATED",
410
+ "transitionHash": transition_hash,
411
+ "targetIds": invalidated,
412
+ "createdAt": _now().isoformat(),
413
+ }
414
+ _append_ndjson(
415
+ state_root / "evidence" / "verification-invalidations.ndjson",
416
+ record,
417
+ )
418
+ return record
419
+
420
+
421
+ def close_transition(
422
+ project: Path,
423
+ change: str,
424
+ *,
425
+ from_phase: str,
426
+ to_phase: str,
427
+ executor: str,
428
+ artifacts: list[str] | None = None,
429
+ status: str = "OK",
430
+ ) -> dict[str, Any]:
431
+ project = Path(project).resolve()
432
+ if to_phase not in PHASE_GRAPH.get(from_phase, ()):
433
+ return {
434
+ "ok": False,
435
+ "code": "TRANSITION_ILLEGAL",
436
+ "fromPhase": from_phase,
437
+ "toPhase": to_phase,
438
+ }
439
+ try:
440
+ contract_root, _contract_data, state_root = _contract(project, change)
441
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
442
+ return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
443
+ paths = _paths(state_root)
444
+ if not paths["lease"].is_file():
445
+ return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
446
+ try:
447
+ lease = _read_json(paths["lease"])
448
+ except (OSError, ValueError, json.JSONDecodeError):
449
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
450
+ if lease.get("owner") != executor or lease.get("phase") != from_phase:
451
+ return {
452
+ "ok": False,
453
+ "code": "CONTEXT_LEASE_MISMATCH",
454
+ "holder": lease.get("owner"),
455
+ "leasePhase": lease.get("phase"),
456
+ }
457
+ expiry = _parse_time(lease.get("expiresAt"))
458
+ if expiry is None or _now() >= expiry:
459
+ return {"ok": False, "code": "CONTEXT_LEASE_EXPIRED"}
460
+
461
+ artifact_entries: list[dict[str, Any]] = []
462
+ for raw in artifacts or []:
463
+ path = Path(raw)
464
+ if not path.is_absolute():
465
+ path = contract_root / path
466
+ resolved = path.resolve()
467
+ if not resolved.is_relative_to(project) or not resolved.is_file():
468
+ return {
469
+ "ok": False,
470
+ "code": "TRANSITION_ARTIFACT_INVALID",
471
+ "path": str(path),
472
+ }
473
+ artifact_entries.append(
474
+ {
475
+ "path": resolved.relative_to(project).as_posix(),
476
+ "sha256": _sha256(resolved),
477
+ }
478
+ )
479
+ transitions = _read_ndjson(paths["transitions"])
480
+ previous_hash = transitions[-1].get("receiptHash") if transitions else None
481
+ attempt = (
482
+ 1
483
+ + sum(
484
+ 1
485
+ for item in transitions
486
+ if item.get("fromPhase") == from_phase
487
+ and item.get("toPhase") == to_phase
488
+ )
489
+ )
490
+ receipt: dict[str, Any] = {
491
+ "schemaVersion": 1,
492
+ "changeName": change,
493
+ "fromPhase": from_phase,
494
+ "toPhase": to_phase,
495
+ "status": status,
496
+ "executor": executor,
497
+ "productCommit": _head(project),
498
+ "artifacts": artifact_entries,
499
+ "attempt": attempt,
500
+ "closedAt": _now().isoformat(),
501
+ "previousReceiptHash": previous_hash,
502
+ }
503
+ receipt["receiptHash"] = _payload_hash(receipt)
504
+ _append_ndjson(paths["transitions"], receipt)
505
+ paths["lease"].unlink(missing_ok=True)
506
+ invalidation = None
507
+ if to_phase == "run" and from_phase in {"test", "review"}:
508
+ invalidation = _invalidate_for_fixback(
509
+ state_root,
510
+ transition_hash=receipt["receiptHash"],
511
+ from_phase=from_phase,
512
+ to_phase=to_phase,
513
+ )
514
+ return {
515
+ "ok": True,
516
+ "code": "TRANSITION_CLOSED",
517
+ "receipt": receipt,
518
+ "path": str(paths["transitions"]),
519
+ "invalidation": invalidation,
520
+ }
521
+
522
+
523
+ def begin_transition(
524
+ project: Path,
525
+ change: str,
526
+ *,
527
+ phase: str,
528
+ executor: str,
529
+ ) -> dict[str, Any]:
530
+ project = Path(project).resolve()
531
+ try:
532
+ _contract_root, _contract_data, state_root = _contract(project, change)
533
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
534
+ return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
535
+ paths = _paths(state_root)
536
+ transitions = _read_ndjson(paths["transitions"])
537
+ if not transitions:
538
+ return {
539
+ "ok": False,
540
+ "code": "LEGACY_BOOTSTRAP_REQUIRED",
541
+ "legacyBootstrap": True,
542
+ }
543
+ receipt = transitions[-1]
544
+ if receipt.get("toPhase") != phase:
545
+ return {
546
+ "ok": False,
547
+ "code": "HANDOFF_REQUIRED",
548
+ "expectedPhase": receipt.get("toPhase"),
549
+ "requestedPhase": phase,
550
+ }
551
+ unhashed = {key: value for key, value in receipt.items() if key != "receiptHash"}
552
+ issues: list[str] = []
553
+ if _payload_hash(unhashed) != receipt.get("receiptHash"):
554
+ issues.append("receipt hash mismatch")
555
+ if len(transitions) > 1 and receipt.get("previousReceiptHash") != transitions[-2].get(
556
+ "receiptHash"
557
+ ):
558
+ issues.append("receipt chain mismatch")
559
+ if receipt.get("productCommit") != _head(project):
560
+ issues.append("HEAD drift")
561
+ for artifact in receipt.get("artifacts", []):
562
+ if not isinstance(artifact, dict):
563
+ issues.append("artifact entry invalid")
564
+ continue
565
+ path = (project / str(artifact.get("path") or "")).resolve()
566
+ if (
567
+ not path.is_relative_to(project)
568
+ or not path.is_file()
569
+ or _sha256(path) != artifact.get("sha256")
570
+ ):
571
+ issues.append(f"artifact drift: {artifact.get('path')}")
572
+ if issues:
573
+ return {
574
+ "ok": False,
575
+ "code": "HANDOFF_IDENTITY_MISMATCH",
576
+ "issues": issues,
577
+ "receipt": receipt,
578
+ }
579
+ acknowledgment = {
580
+ "schemaVersion": 1,
581
+ "changeName": change,
582
+ "phase": phase,
583
+ "executor": executor,
584
+ "receiptHash": receipt["receiptHash"],
585
+ "begunAt": _now().isoformat(),
586
+ }
587
+ _append_ndjson(paths["begins"], acknowledgment)
588
+ _write_json_atomic(
589
+ paths["current"],
590
+ {
591
+ "schemaVersion": 1,
592
+ "changeName": change,
593
+ "phase": phase,
594
+ "executor": executor,
595
+ "executionRoot": str(_execution_root(project, project / ".harness/changes" / change, state_root)),
596
+ "receiptHash": receipt["receiptHash"],
597
+ "begunAt": acknowledgment["begunAt"],
598
+ },
599
+ )
600
+ return {
601
+ "ok": True,
602
+ "code": "TRANSITION_BEGUN",
603
+ "receipt": receipt,
604
+ "acknowledgment": acknowledgment,
605
+ }
606
+
607
+
608
+ def context_view(project: Path, change: str) -> dict[str, Any]:
609
+ project = Path(project).resolve()
610
+ try:
611
+ contract_root, contract, state_root = _contract(project, change)
612
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
613
+ return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
614
+ paths = _paths(state_root)
615
+ transitions = _read_ndjson(paths["transitions"])
616
+ begins = _read_ndjson(paths["begins"])
617
+ current = (
618
+ _read_json(paths["current"]) if paths["current"].is_file() else None
619
+ )
620
+ ledger_path = state_root / "evidence" / "verification-ledger.json"
621
+ findings_path = state_root / "evidence" / "review-findings.json"
622
+ events_paths = [state_root / "events.ndjson"]
623
+ if state_root != contract_root:
624
+ events_paths.append(contract_root / "events.ndjson")
625
+ return {
626
+ "ok": True,
627
+ "code": "CONTEXT_VIEW",
628
+ "changeName": change,
629
+ "lifecycle": contract.get("lifecycle"),
630
+ "currentPhase": (
631
+ transitions[-1].get("toPhase")
632
+ if transitions
633
+ else current.get("phase")
634
+ if isinstance(current, dict)
635
+ else None
636
+ ),
637
+ "transitions": transitions,
638
+ "attemptHistory": {"closes": transitions, "begins": begins},
639
+ "current": current,
640
+ "ledger": _read_json(ledger_path) if ledger_path.is_file() else None,
641
+ "findings": _read_json(findings_path) if findings_path.is_file() else None,
642
+ "events": [
643
+ item
644
+ for path in events_paths
645
+ for item in _read_ndjson(path)
646
+ ],
647
+ }
648
+
649
+
650
+ def build_parser() -> argparse.ArgumentParser:
651
+ parser = argparse.ArgumentParser(prog="harness_context.py")
652
+ parser.add_argument("--json", action="store_true")
653
+ sub = parser.add_subparsers(dest="command", required=True)
654
+ prepare = sub.add_parser("prepare")
655
+ prepare.add_argument("--json", action="store_true")
656
+ prepare.add_argument("--project", required=True, type=Path)
657
+ prepare.add_argument("--change")
658
+ prepare.add_argument("--phase", required=True)
659
+ prepare.add_argument("--executor", required=True)
660
+ prepare.add_argument("--ttl-seconds", type=int, default=3600)
661
+ close = sub.add_parser("close")
662
+ close.add_argument("--json", action="store_true")
663
+ close.add_argument("--project", required=True, type=Path)
664
+ close.add_argument("--change", required=True)
665
+ close.add_argument("--from-phase", required=True)
666
+ close.add_argument("--to-phase", required=True)
667
+ close.add_argument("--executor", required=True)
668
+ close.add_argument("--artifact", action="append", default=[])
669
+ close.add_argument("--status", default="OK")
670
+ begin = sub.add_parser("begin")
671
+ begin.add_argument("--json", action="store_true")
672
+ begin.add_argument("--project", required=True, type=Path)
673
+ begin.add_argument("--change", required=True)
674
+ begin.add_argument("--phase", required=True)
675
+ begin.add_argument("--executor", required=True)
676
+ view = sub.add_parser("view")
677
+ view.add_argument("--json", action="store_true")
678
+ view.add_argument("--project", required=True, type=Path)
679
+ view.add_argument("--change", required=True)
680
+ return parser
681
+
682
+
683
+ def main(argv: list[str] | None = None) -> int:
684
+ args = build_parser().parse_args(argv)
685
+ if args.command == "prepare":
686
+ result = prepare_context(
687
+ args.project,
688
+ phase=args.phase,
689
+ executor=args.executor,
690
+ change=args.change,
691
+ ttl_seconds=args.ttl_seconds,
692
+ )
693
+ elif args.command == "close":
694
+ result = close_transition(
695
+ args.project,
696
+ args.change,
697
+ from_phase=args.from_phase,
698
+ to_phase=args.to_phase,
699
+ executor=args.executor,
700
+ artifacts=args.artifact,
701
+ status=args.status,
702
+ )
703
+ elif args.command == "begin":
704
+ result = begin_transition(
705
+ args.project,
706
+ args.change,
707
+ phase=args.phase,
708
+ executor=args.executor,
709
+ )
710
+ else:
711
+ result = context_view(args.project, args.change)
712
+ print(json.dumps(result, ensure_ascii=False, indent=2))
713
+ return 0 if result.get("ok") else 1
714
+
715
+
716
+ if __name__ == "__main__":
717
+ raise SystemExit(main())