@hunter-harness/workflow-harness 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
  33. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  34. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
  50. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  51. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
  67. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  68. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
  69. package/harness/bundles/general/pi/.harness-build.json +6 -0
  70. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  71. package/harness/bundles/general/pi/README.md +478 -0
  72. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  73. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  74. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  75. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  76. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  77. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  78. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  79. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  80. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  81. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  82. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  83. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  84. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  85. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  86. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  87. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  88. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  89. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  90. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  91. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  92. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  93. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  94. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  99. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  100. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  101. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  102. package/harness/bundles/general/pi/harness-plan/SKILL.md +157 -0
  103. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  104. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  105. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  106. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  107. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  108. package/harness/bundles/general/pi/harness-review/SKILL.md +166 -0
  109. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  110. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  111. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  112. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  113. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  114. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  115. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  116. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  117. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  118. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  119. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  120. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  121. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  122. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  123. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  124. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  125. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  126. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  127. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  128. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  129. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  130. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  132. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  133. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  134. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  135. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  136. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  137. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  138. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  139. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  140. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  141. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  142. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  144. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  145. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  146. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  147. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  148. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  149. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  150. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  151. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  152. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  153. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  155. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  156. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  157. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  158. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  159. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  160. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  161. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  162. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  163. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  164. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  165. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  166. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  167. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  168. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  169. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  170. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  171. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  172. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  173. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  174. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  175. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  176. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  177. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  178. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  179. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  180. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  181. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  182. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  184. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  185. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  190. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  199. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  200. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  201. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  202. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  203. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  204. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
  217. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  218. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  219. package/harness/bundles/java/codex/.harness-build.json +1 -1
  220. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  221. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  222. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  223. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  225. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  226. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  227. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  228. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  229. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  230. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  231. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  232. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  235. package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
  236. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  237. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
  255. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  256. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
  257. package/harness/bundles/java/pi/.harness-build.json +6 -0
  258. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  259. package/harness/bundles/java/pi/README.md +478 -0
  260. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  261. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  262. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  263. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  264. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  265. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  266. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  267. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  268. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  269. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  270. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  271. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  272. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  273. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  274. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  275. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  276. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  277. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  278. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  279. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  280. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  281. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  282. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  283. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  284. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  285. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  286. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  287. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  288. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  289. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  290. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  291. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  292. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  293. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  294. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  295. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  296. package/harness/bundles/java/pi/harness-plan/SKILL.md +157 -0
  297. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  298. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  299. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  300. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  301. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  302. package/harness/bundles/java/pi/harness-review/SKILL.md +166 -0
  303. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  304. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  305. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  306. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  307. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  308. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  309. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  310. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  311. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  312. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  313. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  314. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  315. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  316. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  317. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  318. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  319. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  320. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  321. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  322. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  323. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  324. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  325. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  326. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  327. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  328. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  329. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  330. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  331. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  332. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  333. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  334. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  335. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  336. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  337. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  338. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  339. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  340. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  341. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  342. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  343. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  344. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  345. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  346. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  347. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  348. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  349. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  350. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  351. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  352. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  353. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  354. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  355. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  356. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  357. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  358. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  359. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  360. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  361. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  362. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  363. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  364. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  365. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  366. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  367. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  368. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  369. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  370. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  371. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  372. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  373. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  374. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  375. package/harness/manifests/general/claude-code.json +17 -17
  376. package/harness/manifests/general/codebuddy.json +17 -17
  377. package/harness/manifests/general/codex.json +17 -17
  378. package/harness/manifests/general/cursor.json +17 -17
  379. package/harness/manifests/general/pi.json +475 -0
  380. package/harness/manifests/java/claude-code.json +19 -19
  381. package/harness/manifests/java/codebuddy.json +19 -19
  382. package/harness/manifests/java/codex.json +19 -19
  383. package/harness/manifests/java/cursor.json +19 -19
  384. package/harness/manifests/java/pi.json +499 -0
  385. package/hunter-workflow-family.json +2 -2
  386. package/package.json +1 -1
@@ -0,0 +1,1774 @@
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 sys
13
+ import tempfile
14
+ import time
15
+ from contextlib import contextmanager
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ SCRIPTS_DIR = Path(__file__).resolve().parent
20
+ if str(SCRIPTS_DIR) not in sys.path:
21
+ sys.path.insert(0, str(SCRIPTS_DIR))
22
+
23
+ import harness_ledger as hl # noqa: E402
24
+ import harness_paths as hpaths # noqa: E402
25
+
26
+
27
+ PHASE_GRAPH = {
28
+ "plan": ("execute",),
29
+ "execute": ("review", "execute"),
30
+ "review": ("submit", "execute"),
31
+ "submit": (),
32
+ }
33
+
34
+ # 唯一权威清单在 harness_paths(叶子模块,harness_phase 引的是同一个对象)。
35
+ WORKFLOW_PHASES = hpaths.WORKFLOW_PHASES
36
+
37
+ DISPLAY_TITLE_MAX_LENGTH = 80
38
+
39
+
40
+ def _now() -> dt.datetime:
41
+ return dt.datetime.now(dt.timezone.utc)
42
+
43
+
44
+ def _read_json(path: Path) -> dict[str, Any]:
45
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
46
+ if not isinstance(data, dict):
47
+ raise ValueError(f"JSON object required: {path}")
48
+ return data
49
+
50
+
51
+ def _normalize_display_title(value: Any) -> str | None:
52
+ if value is None:
53
+ return None
54
+ if not isinstance(value, str):
55
+ raise ValueError("display title must be text")
56
+ title = value.strip()
57
+ if not title:
58
+ raise ValueError("display title must not be empty")
59
+ if len(title) > DISPLAY_TITLE_MAX_LENGTH:
60
+ raise ValueError(
61
+ f"display title exceeds {DISPLAY_TITLE_MAX_LENGTH} characters"
62
+ )
63
+ if any(ord(character) < 32 or ord(character) == 127 for character in title):
64
+ raise ValueError("display title contains control characters")
65
+ return title
66
+
67
+
68
+ def _ensure_display_title(contract_root: Path, value: Any) -> str | None:
69
+ path = contract_root / "meta" / "change-title.json"
70
+ if path.is_file():
71
+ payload = _read_json(path)
72
+ return _normalize_display_title(payload.get("displayTitle"))
73
+ title = _normalize_display_title(value)
74
+ if title is None:
75
+ return None
76
+ _write_json_atomic(
77
+ path,
78
+ {
79
+ "schemaVersion": 1,
80
+ "displayTitle": title,
81
+ },
82
+ )
83
+ return title
84
+
85
+
86
+ def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ fd, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
89
+ temp = Path(raw)
90
+ try:
91
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
92
+ handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
93
+ handle.flush()
94
+ os.fsync(handle.fileno())
95
+ os.replace(temp, path)
96
+ finally:
97
+ temp.unlink(missing_ok=True)
98
+
99
+
100
+ def _append_ndjson(path: Path, payload: dict[str, Any]) -> None:
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ lock = path.with_suffix(path.suffix + ".lock")
103
+ deadline = time.monotonic() + 2
104
+ descriptor: int | None = None
105
+ while descriptor is None:
106
+ try:
107
+ descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
108
+ except FileExistsError:
109
+ if time.monotonic() >= deadline:
110
+ raise TimeoutError(f"context transition log locked: {path}")
111
+ time.sleep(0.02)
112
+ try:
113
+ with path.open("a", encoding="utf-8", newline="\n") as handle:
114
+ handle.write(
115
+ json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n"
116
+ )
117
+ handle.flush()
118
+ os.fsync(handle.fileno())
119
+ finally:
120
+ os.close(descriptor)
121
+ lock.unlink(missing_ok=True)
122
+
123
+
124
+ @contextmanager
125
+ def _exclusive_state_lock(path: Path):
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ deadline = time.monotonic() + 2
128
+ descriptor: int | None = None
129
+ while descriptor is None:
130
+ try:
131
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
132
+ except FileExistsError:
133
+ if time.monotonic() >= deadline:
134
+ raise TimeoutError(f"context state locked: {path}")
135
+ time.sleep(0.02)
136
+ try:
137
+ yield
138
+ finally:
139
+ os.close(descriptor)
140
+ path.unlink(missing_ok=True)
141
+
142
+
143
+ def _read_ndjson(path: Path) -> list[dict[str, Any]]:
144
+ if not path.is_file():
145
+ return []
146
+ entries: list[dict[str, Any]] = []
147
+ for line in path.read_text(encoding="utf-8-sig").splitlines():
148
+ if not line.strip():
149
+ continue
150
+ try:
151
+ item = json.loads(line)
152
+ except json.JSONDecodeError:
153
+ continue
154
+ if isinstance(item, dict):
155
+ entries.append(item)
156
+ return entries
157
+
158
+
159
+ def _sha256(path: Path) -> str:
160
+ return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
161
+
162
+
163
+ def _payload_hash(payload: dict[str, Any]) -> str:
164
+ return "sha256:" + hashlib.sha256(
165
+ json.dumps(
166
+ payload,
167
+ ensure_ascii=False,
168
+ sort_keys=True,
169
+ separators=(",", ":"),
170
+ ).encode("utf-8")
171
+ ).hexdigest()
172
+
173
+
174
+ def _head(project: Path) -> str | None:
175
+ process = subprocess.run(
176
+ ["git", "-C", str(project), "rev-parse", "--verify", "HEAD"],
177
+ text=True,
178
+ encoding="utf-8",
179
+ errors="replace",
180
+ stdout=subprocess.PIPE,
181
+ stderr=subprocess.PIPE,
182
+ check=False,
183
+ )
184
+ value = process.stdout.strip()
185
+ return value if process.returncode == 0 and value else None
186
+
187
+
188
+ def _git_common_dir(project: Path) -> Path | None:
189
+ process = subprocess.run(
190
+ ["git", "-C", str(project), "rev-parse", "--git-common-dir"],
191
+ text=True,
192
+ encoding="utf-8",
193
+ errors="replace",
194
+ stdout=subprocess.PIPE,
195
+ stderr=subprocess.PIPE,
196
+ check=False,
197
+ )
198
+ value = process.stdout.strip()
199
+ if process.returncode != 0 or not value:
200
+ return None
201
+ common = Path(value)
202
+ return common.resolve() if common.is_absolute() else (project / common).resolve()
203
+
204
+
205
+ def _same_repository(project: Path, candidate: Path) -> bool:
206
+ project_common = _git_common_dir(project)
207
+ candidate_common = _git_common_dir(candidate)
208
+ if project_common is not None or candidate_common is not None:
209
+ return (
210
+ project_common is not None
211
+ and candidate_common is not None
212
+ and project_common == candidate_common
213
+ )
214
+ return candidate.is_relative_to(project)
215
+
216
+
217
+ def _contract_error_code(exc: BaseException) -> str:
218
+ """Keep PROJECT_ROOT_INVALID distinct from a genuinely missing change."""
219
+ if str(exc).startswith("PROJECT_ROOT_INVALID"):
220
+ return "PROJECT_ROOT_INVALID"
221
+ return "CHANGE_NOT_FOUND"
222
+
223
+
224
+ def _contract(project: Path, change: str) -> tuple[Path, dict[str, Any], Path]:
225
+ root = project.resolve()
226
+ # A bare project *name* (--project udp) resolves to <cwd>/udp and then
227
+ # reports CHANGE_NOT_FOUND, which sends the caller hunting for the change
228
+ # instead of fixing the argument. Separate the two failures.
229
+ if not root.is_dir():
230
+ raise ValueError(
231
+ f"PROJECT_ROOT_INVALID: {root} is not a directory — --project takes "
232
+ "a path to the project root (use '.'), not the project name"
233
+ )
234
+ changes_root = (root / ".harness" / "changes").resolve()
235
+ if not changes_root.is_dir():
236
+ raise ValueError(
237
+ f"PROJECT_ROOT_INVALID: {changes_root} does not exist — "
238
+ f"{root} is not a harness project root"
239
+ )
240
+ contract_root = (root / ".harness" / "changes" / change).resolve()
241
+ if not contract_root.is_relative_to(changes_root) or not contract_root.is_dir():
242
+ raise ValueError(f"CHANGE_NOT_FOUND: {change}")
243
+ context_path = contract_root / "meta" / "change-context.json"
244
+ contract = _read_json(context_path) if context_path.is_file() else {}
245
+ ownership = contract.get("stateOwnership")
246
+ runtime_rel = (
247
+ ownership.get("runtimeRoot")
248
+ if isinstance(ownership, dict)
249
+ else None
250
+ )
251
+ if runtime_rel:
252
+ candidate = (root / str(runtime_rel)).resolve()
253
+ state_parent = (root / ".harness" / "state" / "changes").resolve()
254
+ expected = (state_parent / change).resolve()
255
+ if candidate != expected or not candidate.is_relative_to(state_parent):
256
+ raise ValueError("STATE_ROOT_INVALID")
257
+ state_root = candidate
258
+ else:
259
+ state_root = contract_root
260
+ return contract_root, contract, state_root
261
+
262
+
263
+ def _phase_plan(contract_root: Path) -> tuple[list[str] | None, str]:
264
+ """读出阶段计划;无法采信时说明原因,而不是悄悄退成 legacy。
265
+
266
+ 以前这里遇到未知阶段名一律返回 (None, "legacy"):阶段计划整个失效,
267
+ `_allowed_next_phases` 退回硬编码的 PHASE_GRAPH,而调用方看不出发生过这件事。
268
+ 未知阶段名不是假想问题——阶段清单少一个 merge 时,worktree 变更的
269
+ plannedPhases 就会带着 merge 走到这里。
270
+
271
+ 未知名先过 LEGACY_PHASE_ALIASES 读时映射(阶段改名后历史 change 靠它继续可读),
272
+ 仍然解析不了才降级,并把原因与具体阶段名带在 source 里。
273
+
274
+ 权威切换(2026-08):v2 发布快照(plan-profile.json)优先,classify 工作
275
+ 副本回退——发布后的阶段计划以已审批快照为准。
276
+ """
277
+ loaded = hpaths.load_change_gate_policy(contract_root)
278
+ policy = loaded.get("policy")
279
+ if policy is None:
280
+ if loaded.get("working_error"):
281
+ return None, "legacy:policy-unreadable"
282
+ return None, "legacy"
283
+ unknown: list[str] = []
284
+ for field, source in (
285
+ ("plannedPhases", "change"),
286
+ ("defaultPhases", "policy-default"),
287
+ ):
288
+ raw = policy.get(field)
289
+ if not isinstance(raw, list):
290
+ continue
291
+ phases = [str(item).strip() for item in raw if str(item).strip()]
292
+ if not phases or len(phases) != len(set(phases)):
293
+ continue
294
+ resolved = [hpaths.resolve_phase_name(item) for item in phases]
295
+ missing = [item for item, hit in zip(phases, resolved) if hit is None]
296
+ if missing:
297
+ unknown.extend(missing)
298
+ continue
299
+ # 映射后可能出现重复(两个旧名合并成同一个新阶段),保序去重。
300
+ return list(dict.fromkeys(str(item) for item in resolved)), source
301
+ if unknown:
302
+ return None, "legacy:unknown-phases=" + ",".join(dict.fromkeys(unknown))
303
+ return None, "legacy"
304
+
305
+
306
+ def _allowed_next_phases(contract_root: Path, from_phase: str) -> list[str]:
307
+ planned, _source = _phase_plan(contract_root)
308
+ resolved_from = hpaths.resolve_phase_name(from_phase) or from_phase
309
+ if planned is None:
310
+ return list(PHASE_GRAPH.get(resolved_from, ()))
311
+ if resolved_from not in planned:
312
+ return []
313
+ index = planned.index(resolved_from)
314
+ allowed = planned[index + 1 : index + 2]
315
+ if resolved_from in {"execute", "review"} and "execute" in planned:
316
+ allowed = [*allowed, "execute"]
317
+ return list(dict.fromkeys(allowed))
318
+
319
+
320
+ def configure_phase_plan(
321
+ project: Path,
322
+ change: str,
323
+ *,
324
+ phases: list[str],
325
+ operator: str,
326
+ reason: str,
327
+ ) -> dict[str, Any]:
328
+ """Persist the single phase plan consumed by Context, Archive and Platform."""
329
+ project = Path(project).resolve()
330
+ try:
331
+ contract_root, _contract_data, _state_root = _contract(project, change)
332
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
333
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
334
+ normalized = [str(item).strip() for item in phases if str(item).strip()]
335
+ resolved = [hpaths.resolve_phase_name(item) for item in normalized]
336
+ if (
337
+ not normalized
338
+ or len(normalized) != len(set(normalized))
339
+ or any(item is None for item in resolved)
340
+ or len(set(resolved)) != len(resolved)
341
+ or resolved[0] != "plan"
342
+ or resolved[-1] != "archive"
343
+ ):
344
+ return {
345
+ "ok": False,
346
+ "code": "PHASE_PLAN_INVALID",
347
+ "message": "phase plan must be unique, start with plan, and end with archive",
348
+ "allowedPhases": list(WORKFLOW_PHASES),
349
+ }
350
+ if not operator.strip() or not reason.strip():
351
+ return {
352
+ "ok": False,
353
+ "code": "PHASE_PLAN_JUSTIFICATION_REQUIRED",
354
+ }
355
+ # 旧名入参(plan,run,test,review,archive)归一为现阶段名后落盘;
356
+ # 上面已校验 resolved 无 None 且保序唯一。
357
+ normalized = [str(item) for item in resolved]
358
+ worktree_path = contract_root / "meta" / "worktree.json"
359
+ if worktree_path.is_file():
360
+ try:
361
+ worktree = _read_json(worktree_path)
362
+ except (OSError, ValueError, json.JSONDecodeError):
363
+ worktree = {}
364
+ if bool(worktree.get("requested") or worktree.get("created")) and "merge" not in normalized:
365
+ normalized.insert(normalized.index("archive"), "merge")
366
+ policy_path = contract_root / "meta" / "gate-policy.json"
367
+ policy = _read_json(policy_path) if policy_path.is_file() else {"schemaVersion": 1}
368
+ skipped = [
369
+ {
370
+ "phase": phase,
371
+ "reason": reason.strip(),
372
+ "operator": operator.strip(),
373
+ "decidedAt": _now().isoformat(),
374
+ }
375
+ for phase in WORKFLOW_PHASES
376
+ if phase not in normalized and phase != "merge"
377
+ ]
378
+ policy["plannedPhases"] = normalized
379
+ policy["skippedPhases"] = skipped
380
+ policy["phasePlan"] = {
381
+ "source": "change-override",
382
+ "operator": operator.strip(),
383
+ "reason": reason.strip(),
384
+ "updatedAt": _now().isoformat(),
385
+ }
386
+ _write_json_atomic(policy_path, policy)
387
+ return {
388
+ "ok": True,
389
+ "code": "PHASE_PLAN_CONFIGURED",
390
+ "plannedPhases": normalized,
391
+ "skippedPhases": skipped,
392
+ "path": str(policy_path),
393
+ }
394
+
395
+
396
+ def _active_changes(project: Path) -> list[str]:
397
+ root = project.resolve() / ".harness" / "changes"
398
+ if not root.is_dir():
399
+ return []
400
+ active: list[str] = []
401
+ for child in sorted(root.iterdir()):
402
+ path = child / "meta" / "change-context.json"
403
+ if not child.is_dir() or not path.is_file():
404
+ continue
405
+ try:
406
+ contract = _read_json(path)
407
+ except (OSError, ValueError, json.JSONDecodeError):
408
+ continue
409
+ lifecycle = contract.get("lifecycle")
410
+ if isinstance(lifecycle, dict) and lifecycle.get("status") == "active":
411
+ active.append(child.name)
412
+ return active
413
+
414
+
415
+ def _paths(state_root: Path) -> dict[str, Path]:
416
+ runtime = state_root / "runtime"
417
+ return {
418
+ "runtime": runtime,
419
+ "transitions": runtime / "transitions.ndjson",
420
+ "begins": runtime / "transition-begins.ndjson",
421
+ "current": runtime / "current-context.json",
422
+ "lease": runtime / "context-lease.json",
423
+ "adoptions": runtime / "context-adoptions.ndjson",
424
+ }
425
+
426
+
427
+ def _parse_time(value: Any) -> dt.datetime | None:
428
+ if not isinstance(value, str) or not value.strip():
429
+ return None
430
+ try:
431
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
432
+ except ValueError:
433
+ return None
434
+ if parsed.tzinfo is None:
435
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
436
+ return parsed.astimezone(dt.timezone.utc)
437
+
438
+
439
+ def _execution_root(project: Path, contract_root: Path, state_root: Path) -> Path:
440
+ declared = False
441
+ # Context、ledger 与 gate 必须以同一份可信 worktree 契约为准。split
442
+ # state 可能携带运行元数据,因此两个根都交给统一解析器检查。
443
+ for change_root in (state_root, contract_root):
444
+ inferred = hl.infer_execution_project_root(change_root)
445
+ if inferred is not None:
446
+ return inferred
447
+ declared = declared or hl.declares_execution_worktree(change_root)
448
+ if declared:
449
+ raise ValueError("EXECUTION_WORKTREE_INVALID")
450
+ return project.resolve()
451
+
452
+
453
+ def _reselect_review_fixback(
454
+ project: Path,
455
+ change: str,
456
+ *,
457
+ contract_root: Path,
458
+ state_root: Path,
459
+ executor: str,
460
+ ) -> dict[str, Any]:
461
+ """Supersede an unbegun review→submit choice with review→execute."""
462
+
463
+ paths = _paths(state_root)
464
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
465
+ transitions = _read_ndjson(paths["transitions"])
466
+ latest = transitions[-1] if transitions else None
467
+ if (
468
+ isinstance(latest, dict)
469
+ and hpaths.resolve_phase_name(latest.get("fromPhase")) == "review"
470
+ and hpaths.resolve_phase_name(latest.get("toPhase")) == "execute"
471
+ and latest.get("trigger") == "review-fixback"
472
+ ):
473
+ return {
474
+ "ok": True,
475
+ "code": "FIXBACK_BRANCH_ALREADY_SELECTED",
476
+ "receipt": latest,
477
+ }
478
+ if not (
479
+ isinstance(latest, dict)
480
+ and latest.get("fromPhase") == "review"
481
+ and latest.get("toPhase") == "submit"
482
+ ):
483
+ return {
484
+ "ok": False,
485
+ "code": "FIXBACK_RESELECT_UNAVAILABLE",
486
+ "message": "当前没有可安全重选的评审后继分支。",
487
+ }
488
+ begins = _read_ndjson(paths["begins"])
489
+ submit_begun = any(
490
+ isinstance(item, dict)
491
+ and item.get("phase") == "submit"
492
+ and item.get("receiptHash") == latest.get("receiptHash")
493
+ for item in begins
494
+ )
495
+ event_paths = [state_root / "events.ndjson"]
496
+ if state_root != contract_root:
497
+ event_paths.append(contract_root / "events.ndjson")
498
+ submit_started = any(
499
+ item.get("phase") == "submit" and item.get("type") == "phase.start"
500
+ for path in event_paths
501
+ for item in _read_ndjson(path)
502
+ )
503
+ if submit_begun or submit_started:
504
+ return {
505
+ "ok": False,
506
+ "code": "FIXBACK_RESELECT_UNSAFE",
507
+ "message": "提交阶段已经开始,不能自动切回修复;请先完成或明确恢复提交现场。",
508
+ }
509
+ if paths["lease"].is_file():
510
+ try:
511
+ lease = _read_json(paths["lease"])
512
+ except (OSError, ValueError, json.JSONDecodeError):
513
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
514
+ if lease.get("phase") != "submit" or lease.get("owner") != executor:
515
+ return {
516
+ "ok": False,
517
+ "code": "FIXBACK_RESELECT_UNSAFE",
518
+ "message": "当前阶段租约不属于本次可撤销的提交准备。",
519
+ }
520
+ paths["lease"].unlink(missing_ok=True)
521
+ # Any submit context present here was only prepared, never begun. The
522
+ # new execute context is written by prepare_context after this atomic choice.
523
+ paths["current"].unlink(missing_ok=True)
524
+
525
+ receipt: dict[str, Any] = {
526
+ "schemaVersion": 1,
527
+ "changeName": change,
528
+ "fromPhase": "review",
529
+ "toPhase": "execute",
530
+ "status": "OK",
531
+ "executor": executor,
532
+ "productCommit": _head(project),
533
+ "artifacts": [],
534
+ "attempt": 1
535
+ + sum(
536
+ 1
537
+ for item in transitions
538
+ if hpaths.resolve_phase_name(item.get("fromPhase")) == "review"
539
+ and hpaths.resolve_phase_name(item.get("toPhase")) == "execute"
540
+ ),
541
+ "closedAt": _now().isoformat(),
542
+ "previousReceiptHash": latest.get("receiptHash"),
543
+ "supersedesReceiptHash": latest.get("receiptHash"),
544
+ "trigger": "review-fixback",
545
+ "selectionReason": "用户选择处理评审中的代码修复项",
546
+ }
547
+ receipt["receiptHash"] = _payload_hash(receipt)
548
+ _append_ndjson(paths["transitions"], receipt)
549
+ invalidation = _invalidate_for_fixback(
550
+ state_root,
551
+ transition_hash=receipt["receiptHash"],
552
+ from_phase="review",
553
+ to_phase="execute",
554
+ )
555
+ return {
556
+ "ok": True,
557
+ "code": "FIXBACK_BRANCH_RESELECTED",
558
+ "receipt": receipt,
559
+ "invalidation": invalidation,
560
+ }
561
+
562
+
563
+ def _claim_prepared_context(
564
+ project: Path,
565
+ change: str,
566
+ *,
567
+ contract_root: Path,
568
+ state_root: Path,
569
+ phase: str,
570
+ executor: str,
571
+ ttl_seconds: int,
572
+ preparation_id: str | None,
573
+ display_title: str | None,
574
+ ) -> dict[str, Any]:
575
+ """Atomically check and claim the lease/current preparation pair."""
576
+
577
+ paths = _paths(state_root)
578
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
579
+ recovery: dict[str, Any] | None = None
580
+ if paths["lease"].is_file():
581
+ try:
582
+ existing_lease = _read_json(paths["lease"])
583
+ except (OSError, ValueError, json.JSONDecodeError):
584
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
585
+ expiry = _parse_time(existing_lease.get("expiresAt"))
586
+ expired = expiry is None or _now() >= expiry
587
+ if not expired and existing_lease.get("owner") != executor:
588
+ return {
589
+ "ok": False,
590
+ "code": "CONTEXT_LEASE_HELD",
591
+ "holder": existing_lease.get("owner"),
592
+ "expiresAt": existing_lease.get("expiresAt"),
593
+ }
594
+ if (
595
+ not expired
596
+ and preparation_id is not None
597
+ and existing_lease.get("preparationId") != preparation_id
598
+ ):
599
+ return {
600
+ "ok": False,
601
+ "code": "CONTEXT_PREPARATION_ACTIVE",
602
+ "message": "已有一轮修复准备或执行正在进行,本次重复启动未修改现有上下文。",
603
+ "preparationId": existing_lease.get("preparationId"),
604
+ "expiresAt": existing_lease.get("expiresAt"),
605
+ }
606
+ if expired:
607
+ recovery = {
608
+ "code": "LEASE_EXPIRED_RECOVERED",
609
+ "previousOwner": existing_lease.get("owner"),
610
+ "expiredAt": existing_lease.get("expiresAt"),
611
+ }
612
+
613
+ transitions = _read_ndjson(paths["transitions"])
614
+ latest = transitions[-1] if transitions else None
615
+ try:
616
+ current = (
617
+ _read_json(paths["current"])
618
+ if paths["current"].is_file()
619
+ else None
620
+ )
621
+ except (OSError, ValueError, json.JSONDecodeError):
622
+ return {"ok": False, "code": "CONTEXT_CURRENT_INVALID"}
623
+ # 跨阶段推进必须有 receipt 证明它合法;同一个阶段换执行者不需要。
624
+ # 以前两者都要 receipt,于是上一阶段崩溃没能正常 close 时,接手的工具
625
+ # 连"继续把这个阶段干完"都做不到。并发保护不受影响:租约未过期而
626
+ # owner 不同,上面 CONTEXT_LEASE_HELD 已经拦下了,能走到这里只剩
627
+ # 「租约已过期」和「租约已被上一阶段 close 删除」两种情况。
628
+ adoption: dict[str, Any] | None = None
629
+ if recovery is None and isinstance(current, dict) and (
630
+ hpaths.resolve_phase_name(current.get("phase")) != phase
631
+ ):
632
+ valid_receipt = (
633
+ isinstance(latest, dict)
634
+ and hpaths.resolve_phase_name(latest.get("toPhase")) == phase
635
+ and hpaths.resolve_phase_name(latest.get("fromPhase"))
636
+ == hpaths.resolve_phase_name(current.get("phase"))
637
+ )
638
+ if not valid_receipt:
639
+ return {
640
+ "ok": False,
641
+ "code": "HANDOFF_REQUIRED",
642
+ "fromExecutor": current.get("executor"),
643
+ "fromPhase": current.get("phase"),
644
+ "toExecutor": executor,
645
+ "toPhase": phase,
646
+ }
647
+ executor_changed = (
648
+ isinstance(current, dict) and current.get("executor") != executor
649
+ )
650
+ if recovery is not None or executor_changed:
651
+ adoption = {
652
+ "previousExecutor": (
653
+ current.get("executor") if isinstance(current, dict) else None
654
+ ),
655
+ "newExecutor": executor,
656
+ "reason": (
657
+ "lease_expired" if recovery is not None else "same_phase_handoff"
658
+ ),
659
+ }
660
+
661
+ try:
662
+ execution_root = _execution_root(project, contract_root, state_root)
663
+ except ValueError as exc:
664
+ return {
665
+ "ok": False,
666
+ "code": "EXECUTION_WORKTREE_INVALID",
667
+ "error": str(exc),
668
+ }
669
+ now = _now()
670
+ lease: dict[str, Any] = {
671
+ "schemaVersion": 1,
672
+ "changeName": change,
673
+ "phase": phase,
674
+ "owner": executor,
675
+ "acquiredAt": now.isoformat(),
676
+ "expiresAt": (
677
+ now + dt.timedelta(seconds=max(1, int(ttl_seconds)))
678
+ ).isoformat(),
679
+ }
680
+ if preparation_id is not None:
681
+ lease["preparationId"] = preparation_id
682
+ current_context: dict[str, Any] = {
683
+ "schemaVersion": 1,
684
+ "changeName": change,
685
+ "phase": phase,
686
+ "executor": executor,
687
+ "executionRoot": str(execution_root),
688
+ "preparedAt": now.isoformat(),
689
+ "receiptHash": latest.get("receiptHash") if latest else None,
690
+ "displayTitle": display_title,
691
+ }
692
+ if preparation_id is not None:
693
+ current_context["preparationId"] = preparation_id
694
+ _write_json_atomic(paths["lease"], lease)
695
+ _write_json_atomic(paths["current"], current_context)
696
+ if adoption is not None:
697
+ # 接管必须留痕。过期租约本来就允许任何执行者顶替,但以前只在返回体
698
+ # 里挂一个 recovery,退出进程就没了——事后查"这个阶段是谁接手的"
699
+ # 无据可依。收据形状照 harness_archive.adopt_existing_range。
700
+ adoption = {
701
+ "schemaVersion": 1,
702
+ "changeName": change,
703
+ "phase": phase,
704
+ **adoption,
705
+ "previousOwner": recovery.get("previousOwner") if recovery else None,
706
+ "expiredAt": recovery.get("expiredAt") if recovery else None,
707
+ "adoptedAt": now.isoformat(),
708
+ }
709
+ _append_ndjson(paths["adoptions"], adoption)
710
+ return {
711
+ "ok": True,
712
+ "lease": lease,
713
+ "current": current_context,
714
+ "transitions": transitions,
715
+ "latestTransition": latest,
716
+ "recovery": recovery,
717
+ "adoption": adoption,
718
+ "executionRoot": str(execution_root),
719
+ }
720
+
721
+
722
+ def _unknown_phase_error(phase: Any) -> dict[str, Any]:
723
+ return {
724
+ "ok": False,
725
+ "code": "PHASE_UNKNOWN",
726
+ "phase": str(phase),
727
+ "allowedPhases": list(WORKFLOW_PHASES),
728
+ "legacyAliases": dict(hpaths.LEGACY_PHASE_ALIASES),
729
+ "message": (
730
+ f"未知阶段名 {phase!r};合法阶段 {list(WORKFLOW_PHASES)},"
731
+ f"旧名别名 {hpaths.LEGACY_PHASE_ALIASES}。"
732
+ ),
733
+ }
734
+
735
+
736
+ def prepare_context(
737
+ project: Path,
738
+ *,
739
+ phase: str,
740
+ executor: str,
741
+ change: str | None = None,
742
+ display_title: str | None = None,
743
+ ttl_seconds: int = 3600,
744
+ trigger: str | None = None,
745
+ preparation_id: str | None = None,
746
+ ) -> dict[str, Any]:
747
+ project = Path(project).resolve()
748
+ canonical_phase = hpaths.resolve_phase_name(phase)
749
+ if canonical_phase is None:
750
+ return _unknown_phase_error(phase)
751
+ phase = canonical_phase
752
+ candidates = _active_changes(project)
753
+ if change is None:
754
+ if not candidates:
755
+ return {
756
+ "ok": False,
757
+ "code": "ACTIVE_CHANGE_MISSING",
758
+ "candidates": [],
759
+ }
760
+ if len(candidates) > 1:
761
+ return {
762
+ "ok": False,
763
+ "code": "ACTIVE_CHANGE_AMBIGUOUS",
764
+ "candidates": candidates,
765
+ }
766
+ change = candidates[0]
767
+ try:
768
+ contract_root, contract, state_root = _contract(project, change)
769
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
770
+ return {"ok": False, "code": str(exc).split(":", 1)[0], "error": str(exc)}
771
+ try:
772
+ resolved_display_title = _ensure_display_title(
773
+ contract_root, display_title
774
+ )
775
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
776
+ return {
777
+ "ok": False,
778
+ "code": "DISPLAY_TITLE_INVALID",
779
+ "error": str(exc),
780
+ }
781
+ lifecycle = contract.get("lifecycle")
782
+ if (
783
+ isinstance(lifecycle, dict)
784
+ and lifecycle.get("status") not in {None, "active"}
785
+ ):
786
+ return {"ok": False, "code": "CHANGE_NOT_ACTIVE", "changeName": change}
787
+ planned_phases, phase_plan_source = _phase_plan(contract_root)
788
+ if planned_phases is not None and phase not in planned_phases:
789
+ return {
790
+ "ok": False,
791
+ "code": "PHASE_NOT_PLANNED",
792
+ "phase": phase,
793
+ "plannedPhases": planned_phases,
794
+ }
795
+ paths = _paths(state_root)
796
+ paths["runtime"].mkdir(parents=True, exist_ok=True)
797
+ reselection: dict[str, Any] | None = None
798
+ if trigger == "review-fixback" and phase == "execute":
799
+ reselection = _reselect_review_fixback(
800
+ project,
801
+ change,
802
+ contract_root=contract_root,
803
+ state_root=state_root,
804
+ executor=executor,
805
+ )
806
+ if not reselection.get("ok"):
807
+ return reselection
808
+ # v2 计划的交接凭证由此补录:finalize 不写 context 事务,缺它 run 阶段进不去
809
+ if phase != "plan":
810
+ _bootstrap_v2_plan_transition(
811
+ project,
812
+ change,
813
+ contract_root=contract_root,
814
+ state_root=state_root,
815
+ to_phase=phase,
816
+ executor=executor,
817
+ )
818
+ claim = _claim_prepared_context(
819
+ project,
820
+ change,
821
+ contract_root=contract_root,
822
+ state_root=state_root,
823
+ phase=phase,
824
+ executor=executor,
825
+ ttl_seconds=ttl_seconds,
826
+ preparation_id=preparation_id,
827
+ display_title=resolved_display_title,
828
+ )
829
+ if not claim.get("ok"):
830
+ return claim
831
+ transitions = claim["transitions"]
832
+ latest = claim["latestTransition"]
833
+ execution_root = Path(claim["executionRoot"])
834
+ lease = claim["lease"]
835
+ recovery = claim["recovery"]
836
+ next_phases = (
837
+ _allowed_next_phases(contract_root, phase)
838
+ if planned_phases is not None
839
+ else (
840
+ list(
841
+ PHASE_GRAPH.get(
842
+ hpaths.resolve_phase_name(str(latest.get("toPhase"))) or "",
843
+ (),
844
+ )
845
+ )
846
+ if latest
847
+ else ["plan", "execute"]
848
+ )
849
+ )
850
+ return {
851
+ "ok": True,
852
+ "code": "CONTEXT_PREPARED",
853
+ "changeName": change,
854
+ "phase": phase,
855
+ "executionRoot": str(execution_root),
856
+ "contractRoot": str(contract_root),
857
+ "stateRoot": str(state_root),
858
+ "nextPhases": next_phases,
859
+ "plannedPhases": planned_phases,
860
+ "phasePlanSource": phase_plan_source,
861
+ "legacyBootstrap": not transitions,
862
+ "latestTransition": latest,
863
+ "lease": lease,
864
+ "recovery": recovery,
865
+ "displayTitle": resolved_display_title,
866
+ "branchSelection": reselection,
867
+ }
868
+
869
+
870
+ def _plan_run_identity(change_dir: Path) -> tuple[str, int] | None:
871
+ """已存在的 plan `phase.start` 身份(run-id, attempt)。
872
+
873
+ finalize 按生命周期身份 fail-closed:重跑引导时换一个 run-id,整条链路就对不上。
874
+ 因此引导必须复用已有身份,而不是每次新生成。
875
+ """
876
+ for item in reversed(_read_ndjson(change_dir / "events.ndjson")):
877
+ if item.get("phase") != "plan" or item.get("type") != "phase.start":
878
+ continue
879
+ run_id = item.get("run_id")
880
+ if isinstance(run_id, str) and run_id:
881
+ attempt = item.get("attempt")
882
+ return run_id, attempt if isinstance(attempt, int) and attempt > 0 else 1
883
+ return None
884
+
885
+
886
+ def bootstrap_plan(
887
+ project: Path,
888
+ *,
889
+ change: str,
890
+ executor: str,
891
+ display_title: str | None = None,
892
+ stage: str = "plan",
893
+ ttl_seconds: int = 3600,
894
+ ) -> dict[str, Any]:
895
+ """阶段 0 一次性引导:doctor + prepare + capture + classify + phase.start。
896
+
897
+ 拆成五条子进程调用时,每条的完整 argv 都要调用方记住(少 `--project` 或
898
+ `--change-dir` 就白跑一轮),run-id 还要调用方自己保证小写字母开头。这些都是
899
+ 确定性工作,交给脚本做;调用方只需要一次调用和一份紧凑摘要。
900
+
901
+ 重跑安全:已有 plan `phase.start` 时复用同一 run-id / attempt,不追加第二条。
902
+ """
903
+ import uuid
904
+
905
+ import harness_change as hchg
906
+ import harness_events as he
907
+ import harness_gate as hg
908
+ import harness_runtime as hr
909
+ import harness_state as hs
910
+
911
+ project = Path(project).resolve()
912
+ changes_root = project / ".harness" / "changes"
913
+ if not changes_root.is_dir():
914
+ return {
915
+ "ok": False,
916
+ "code": "PROJECT_ROOT_INVALID",
917
+ "error": f"{changes_root} 不存在——先在该项目运行 hunter-harness init",
918
+ }
919
+
920
+ # 新 change 的目录此前没有任何脚本负责创建,靠的是后续 Write 的副作用;
921
+ # 引导阶段显式建立骨架,prepare 才有 contract 可读
922
+ change_dir = changes_root / change
923
+ created_change = not change_dir.is_dir()
924
+ if created_change:
925
+ (change_dir / "meta").mkdir(parents=True, exist_ok=True)
926
+ hchg.migrate_change(project, change)
927
+
928
+ prepared = prepare_context(
929
+ project,
930
+ phase="plan",
931
+ executor=executor,
932
+ change=change,
933
+ display_title=display_title,
934
+ ttl_seconds=ttl_seconds,
935
+ )
936
+ if not prepared.get("ok"):
937
+ return prepared
938
+
939
+ change_name = str(prepared["changeName"])
940
+ # executionRoot 是代码执行根(无 worktree 时等于项目根),change_dir 才是状态目录。
941
+ # 计划产物一律落 change_dir,混用会把 gate-policy/events 写到项目根去。
942
+ change_dir = (changes_root / change_name).resolve()
943
+ execution_root = Path(prepared["executionRoot"])
944
+
945
+ # doctor 失败不阻断引导:它是环境体检,结论随摘要返回给调用方判断
946
+ try:
947
+ health = hr.doctor(project, change_dir, agent=executor)
948
+ except Exception as exc: # noqa: BLE001 - 体检失败要报告而不是中断引导
949
+ health = {"ok": False, "code": "DOCTOR_FAILED", "error": str(exc)}
950
+
951
+ # 首次 capture 把当时的 HEAD 固定为不可变 changeBase(design §3.6)
952
+ snapshot, changed_segments = hs.capture_current_state(
953
+ project=project,
954
+ change_dir=change_dir,
955
+ change_name=change_name,
956
+ worktree_root=project,
957
+ )
958
+ git_state = snapshot.get("git") or {}
959
+
960
+ workflow = hg._load_workflow_policy(project=project)
961
+ classification = hg.classify_risk(change_dir, stage, workflow=workflow)
962
+ classification.setdefault("tierOverride", None)
963
+ classification["classifiedAt"] = _now().isoformat().replace("+00:00", "Z")
964
+ policy_path = change_dir / "meta" / "gate-policy.json"
965
+ hg._write_json(policy_path, hg.gate_policy_document(classification))
966
+
967
+ identity = _plan_run_identity(change_dir)
968
+ reused = identity is not None
969
+ if identity is None:
970
+ # v2 identity:必须小写字母开头,裸 UUID 有 10/16 概率数字开头被拒
971
+ run_id = f"plan_{uuid.uuid4()}"
972
+ attempt = 1
973
+ appended = he.append_event(
974
+ change_dir,
975
+ phase="plan",
976
+ type_="phase.start",
977
+ run_id=run_id,
978
+ attempt=attempt,
979
+ executor_tool=executor,
980
+ note=f"/harness-plan 引导:{change_name}",
981
+ )
982
+ if not appended.get("ok", True):
983
+ return appended
984
+ else:
985
+ run_id, attempt = identity
986
+
987
+ return {
988
+ "ok": True,
989
+ "code": "PLAN_BOOTSTRAPPED",
990
+ "changeName": change_name,
991
+ "displayTitle": prepared.get("displayTitle"),
992
+ "changeCreated": created_change,
993
+ "changeDir": str(change_dir),
994
+ "executionRoot": str(execution_root),
995
+ "runId": run_id,
996
+ "attempt": attempt,
997
+ "reused": reused,
998
+ "tier": classification.get("tier"),
999
+ "tierSource": classification.get("source"),
1000
+ "defaultPhases": list(classification.get("defaultPhases") or []),
1001
+ "conditionalPhases": list(classification.get("conditionalPhases") or []),
1002
+ "requiredValidations": list(classification.get("requiredValidations") or []),
1003
+ "gatePolicyPath": str(policy_path),
1004
+ "plannedPhases": prepared.get("plannedPhases"),
1005
+ "changeBase": git_state.get("base"),
1006
+ "head": git_state.get("head"),
1007
+ "changedSegments": sorted(changed_segments),
1008
+ "legacyBootstrap": prepared.get("legacyBootstrap"),
1009
+ "doctorOk": bool(health.get("ok", True)),
1010
+ "doctor": health,
1011
+ }
1012
+
1013
+
1014
+ def _invalidate_for_fixback(
1015
+ state_root: Path,
1016
+ *,
1017
+ transition_hash: str,
1018
+ from_phase: str,
1019
+ to_phase: str,
1020
+ ) -> dict[str, Any]:
1021
+ # A review→run handoff happens before product files are changed. Invalidating
1022
+ # every target here made unrelated API/build evidence unusable. The Fixback
1023
+ # resolver now invalidates only entries whose inputsFiles intersect the
1024
+ # issue's actual changedFiles.
1025
+ record = {
1026
+ "schemaVersion": 1,
1027
+ "code": "FIXBACK_INVALIDATION_DEFERRED",
1028
+ "transitionHash": transition_hash,
1029
+ "fromPhase": from_phase,
1030
+ "toPhase": to_phase,
1031
+ "targetIds": [],
1032
+ "deferred": True,
1033
+ "reason": "changed-files-not-known",
1034
+ "createdAt": _now().isoformat(),
1035
+ }
1036
+ _append_ndjson(
1037
+ state_root / "evidence" / "verification-invalidations.ndjson",
1038
+ record,
1039
+ )
1040
+ return record
1041
+
1042
+
1043
+ def _committed_publication_journal(contract_root: Path) -> Path | None:
1044
+ """已 committed 的 v2 发布 journal —— plan 确实完成的机器证据。"""
1045
+ journal_dir = contract_root / "meta" / "publication-journals"
1046
+ if not journal_dir.is_dir():
1047
+ return None
1048
+ for path in sorted(journal_dir.glob("*.json")):
1049
+ try:
1050
+ payload = _read_json(path)
1051
+ except (OSError, ValueError, json.JSONDecodeError):
1052
+ continue
1053
+ if isinstance(payload, dict) and payload.get("state") == "committed":
1054
+ return path
1055
+ return None
1056
+
1057
+
1058
+ def _published_plan_artifacts(project: Path, contract_root: Path, change: str) -> list[dict[str, Any]]:
1059
+ """交接携带的 v2 发布产物(存在者入账,缺失者跳过)。"""
1060
+ entries: list[dict[str, Any]] = []
1061
+ for rel in (
1062
+ f"plans/{change}-design.md",
1063
+ f"plans/{change}-plan.md",
1064
+ f"plans/{change}-implementation-detail.md",
1065
+ f"plans/{change}-test-scenarios.md",
1066
+ "meta/scenario-manifest.json",
1067
+ ):
1068
+ candidate = contract_root / rel
1069
+ if candidate.is_file():
1070
+ entries.append(
1071
+ {
1072
+ "path": candidate.resolve().relative_to(project).as_posix(),
1073
+ "sha256": _sha256(candidate),
1074
+ }
1075
+ )
1076
+ return entries
1077
+
1078
+
1079
+ def _bootstrap_v2_plan_transition(
1080
+ project: Path,
1081
+ change: str,
1082
+ *,
1083
+ contract_root: Path,
1084
+ state_root: Path,
1085
+ to_phase: str,
1086
+ executor: str,
1087
+ ) -> dict[str, Any] | None:
1088
+ """v2 发布已 committed 却没有交接凭证时,补录 plan → to_phase 凭证。
1089
+
1090
+ v2 plan finalize 只写 plan-events.ndjson 与发布 journal,不碰 context 事务
1091
+ 存储,于是 execute 阶段 prepare 必报 HANDOFF_REQUIRED、begin 必报
1092
+ LEGACY_BOOTSTRAP_REQUIRED——两个错误都不给恢复路径,调用方只能读脚本源码
1093
+ 自己拼出 classify + configure-plan + close,参数全靠现编,可审计性更差。
1094
+
1095
+ committed 的发布 journal 是比人工 close 更强的完成证据:据此补录,并在凭证上
1096
+ 留 bootstrapSource/bootstrapEvidence,事后能与人工 close 区分。没有该证据时
1097
+ 返回 None,交接继续 fail-closed。
1098
+ """
1099
+ paths = _paths(state_root)
1100
+ if _read_ndjson(paths["transitions"]):
1101
+ return None
1102
+ if to_phase not in _allowed_next_phases(contract_root, "plan"):
1103
+ return None
1104
+ journal = _committed_publication_journal(contract_root)
1105
+ if journal is None:
1106
+ return None
1107
+ receipt: dict[str, Any] = {
1108
+ "schemaVersion": 1,
1109
+ "changeName": change,
1110
+ "fromPhase": "plan",
1111
+ "toPhase": to_phase,
1112
+ "status": "OK",
1113
+ "executor": executor,
1114
+ "productCommit": _head(project),
1115
+ "artifacts": _published_plan_artifacts(project, contract_root, change),
1116
+ "attempt": 1,
1117
+ "closedAt": _now().isoformat(),
1118
+ "previousReceiptHash": None,
1119
+ "bootstrapSource": "plan_publication_journal",
1120
+ "bootstrapEvidence": journal.resolve().relative_to(project).as_posix(),
1121
+ }
1122
+ receipt["receiptHash"] = _payload_hash(receipt)
1123
+ _append_ndjson(paths["transitions"], receipt)
1124
+ return receipt
1125
+
1126
+
1127
+ def close_transition(
1128
+ project: Path,
1129
+ change: str,
1130
+ *,
1131
+ from_phase: str,
1132
+ to_phase: str,
1133
+ executor: str | None,
1134
+ artifacts: list[str] | None = None,
1135
+ status: str = "OK",
1136
+ ) -> dict[str, Any]:
1137
+ project = Path(project).resolve()
1138
+ canonical_from = hpaths.resolve_phase_name(from_phase)
1139
+ canonical_to = hpaths.resolve_phase_name(to_phase)
1140
+ if canonical_from is None:
1141
+ return _unknown_phase_error(from_phase)
1142
+ if canonical_to is None:
1143
+ return _unknown_phase_error(to_phase)
1144
+ from_phase = canonical_from
1145
+ to_phase = canonical_to
1146
+ try:
1147
+ contract_root, _contract_data, state_root = _contract(project, change)
1148
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1149
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
1150
+ allowed_next = _allowed_next_phases(contract_root, from_phase)
1151
+ if to_phase not in allowed_next:
1152
+ planned_phases, source = _phase_plan(contract_root)
1153
+ return {
1154
+ "ok": False,
1155
+ "code": "TRANSITION_ILLEGAL",
1156
+ "fromPhase": from_phase,
1157
+ "toPhase": to_phase,
1158
+ "allowedNextPhases": allowed_next,
1159
+ "plannedPhases": planned_phases,
1160
+ "phasePlanSource": source,
1161
+ }
1162
+ paths = _paths(state_root)
1163
+
1164
+ artifact_entries: list[dict[str, Any]] = []
1165
+ for raw in artifacts or []:
1166
+ requested = Path(raw)
1167
+ artifact_bases: list[Path] = []
1168
+ for base in (project, contract_root, state_root):
1169
+ resolved_base = base.resolve()
1170
+ if resolved_base not in artifact_bases:
1171
+ artifact_bases.append(resolved_base)
1172
+ candidates = (
1173
+ [requested]
1174
+ if requested.is_absolute()
1175
+ else [base / requested for base in artifact_bases]
1176
+ )
1177
+ valid: list[Path] = []
1178
+ for candidate in candidates:
1179
+ try:
1180
+ resolved_candidate = candidate.resolve()
1181
+ except OSError:
1182
+ continue
1183
+ if resolved_candidate in valid:
1184
+ continue
1185
+ if resolved_candidate.is_relative_to(project) and resolved_candidate.is_file():
1186
+ valid.append(resolved_candidate)
1187
+ if len(valid) > 1:
1188
+ return {
1189
+ "ok": False,
1190
+ "code": "TRANSITION_ARTIFACT_AMBIGUOUS",
1191
+ "path": raw,
1192
+ "candidates": [item.relative_to(project).as_posix() for item in valid],
1193
+ "message": "relative artifact exists under both project and change roots",
1194
+ }
1195
+ if not valid:
1196
+ return {
1197
+ "ok": False,
1198
+ "code": "TRANSITION_ARTIFACT_INVALID",
1199
+ "path": raw,
1200
+ "acceptedBases": [str(base) for base in artifact_bases],
1201
+ "examples": [
1202
+ str(contract_root / "meta" / "plan-finalization.json"),
1203
+ f".harness/changes/{change}/meta/plan-finalization.json",
1204
+ f".harness/state/changes/{change}/evidence/verification-ledger.json",
1205
+ "meta/plan-finalization.json",
1206
+ ],
1207
+ }
1208
+ resolved_artifact = valid[0]
1209
+ artifact_entries.append(
1210
+ {
1211
+ "path": resolved_artifact.relative_to(project).as_posix(),
1212
+ "sha256": _sha256(resolved_artifact),
1213
+ }
1214
+ )
1215
+
1216
+ transitions = _read_ndjson(paths["transitions"])
1217
+ if not paths["lease"].is_file():
1218
+ latest = transitions[-1] if transitions else None
1219
+ if (
1220
+ isinstance(latest, dict)
1221
+ and hpaths.resolve_phase_name(latest.get("fromPhase")) == from_phase
1222
+ and hpaths.resolve_phase_name(latest.get("toPhase")) == to_phase
1223
+ and latest.get("status") == status
1224
+ and latest.get("artifacts") == artifact_entries
1225
+ and (executor is None or latest.get("executor") == executor)
1226
+ ):
1227
+ return {
1228
+ "ok": True,
1229
+ "code": "TRANSITION_ALREADY_CLOSED",
1230
+ "idempotent": True,
1231
+ "receipt": latest,
1232
+ "path": str(paths["transitions"]),
1233
+ "invalidation": None,
1234
+ }
1235
+ return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
1236
+ try:
1237
+ lease = _read_json(paths["lease"])
1238
+ except (OSError, ValueError, json.JSONDecodeError):
1239
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
1240
+ effective_executor = executor or str(lease.get("owner") or "")
1241
+ if not effective_executor:
1242
+ return {"ok": False, "code": "CONTEXT_EXECUTOR_REQUIRED"}
1243
+ if lease.get("owner") != effective_executor or (
1244
+ hpaths.resolve_phase_name(lease.get("phase")) != from_phase
1245
+ ):
1246
+ return {
1247
+ "ok": False,
1248
+ "code": "CONTEXT_LEASE_MISMATCH",
1249
+ "holder": lease.get("owner"),
1250
+ "leasePhase": lease.get("phase"),
1251
+ }
1252
+ # Owner and phase already matched above, so nobody took this lease over: a
1253
+ # takeover rewrites the file and would have produced CONTEXT_LEASE_MISMATCH.
1254
+ # A lapsed deadline therefore proves only that the phase outlived the TTL.
1255
+ expiry = _parse_time(lease.get("expiresAt"))
1256
+ lease_lapsed = expiry is None or _now() >= expiry
1257
+
1258
+ previous_hash = transitions[-1].get("receiptHash") if transitions else None
1259
+ attempt = (
1260
+ 1
1261
+ + sum(
1262
+ 1
1263
+ for item in transitions
1264
+ if hpaths.resolve_phase_name(item.get("fromPhase")) == from_phase
1265
+ and hpaths.resolve_phase_name(item.get("toPhase")) == to_phase
1266
+ )
1267
+ )
1268
+ receipt: dict[str, Any] = {
1269
+ "schemaVersion": 1,
1270
+ "changeName": change,
1271
+ "fromPhase": from_phase,
1272
+ "toPhase": to_phase,
1273
+ "status": status,
1274
+ "executor": effective_executor,
1275
+ "productCommit": _head(project),
1276
+ "artifacts": artifact_entries,
1277
+ "attempt": attempt,
1278
+ "closedAt": _now().isoformat(),
1279
+ "previousReceiptHash": previous_hash,
1280
+ }
1281
+ if lease_lapsed:
1282
+ # Recorded, not hidden: the phase ran past its lease without a renewal.
1283
+ receipt["leaseLapsed"] = {
1284
+ "expiresAt": lease.get("expiresAt"),
1285
+ "acquiredAt": lease.get("acquiredAt"),
1286
+ }
1287
+ receipt["receiptHash"] = _payload_hash(receipt)
1288
+ _append_ndjson(paths["transitions"], receipt)
1289
+ paths["lease"].unlink(missing_ok=True)
1290
+ invalidation = None
1291
+ if to_phase == "execute" and from_phase in {"execute", "review"}:
1292
+ invalidation = _invalidate_for_fixback(
1293
+ state_root,
1294
+ transition_hash=receipt["receiptHash"],
1295
+ from_phase=from_phase,
1296
+ to_phase=to_phase,
1297
+ )
1298
+ return {
1299
+ "ok": True,
1300
+ "code": "TRANSITION_CLOSED",
1301
+ "receipt": receipt,
1302
+ "path": str(paths["transitions"]),
1303
+ "invalidation": invalidation,
1304
+ }
1305
+
1306
+
1307
+ def _begin_transition_unlocked(
1308
+ project: Path,
1309
+ change: str,
1310
+ *,
1311
+ phase: str,
1312
+ executor: str,
1313
+ preparation_id: str | None = None,
1314
+ ) -> dict[str, Any]:
1315
+ project = Path(project).resolve()
1316
+ try:
1317
+ contract_root, _contract_data, state_root = _contract(project, change)
1318
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1319
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
1320
+ paths = _paths(state_root)
1321
+ try:
1322
+ execution_root = _execution_root(project, contract_root, state_root)
1323
+ except ValueError as exc:
1324
+ return {
1325
+ "ok": False,
1326
+ "code": "EXECUTION_WORKTREE_INVALID",
1327
+ "error": str(exc),
1328
+ }
1329
+ if preparation_id is not None:
1330
+ current = _read_json(paths["current"]) if paths["current"].is_file() else None
1331
+ lease = _read_json(paths["lease"]) if paths["lease"].is_file() else None
1332
+ if not (
1333
+ isinstance(current, dict)
1334
+ and isinstance(lease, dict)
1335
+ and current.get("preparationId") == preparation_id
1336
+ and lease.get("preparationId") == preparation_id
1337
+ ):
1338
+ return {
1339
+ "ok": False,
1340
+ "code": "CONTEXT_PREPARATION_MISMATCH",
1341
+ "message": "本次修复准备凭证与当前上下文不一致,未启动新阶段。",
1342
+ }
1343
+ transitions = _read_ndjson(paths["transitions"])
1344
+ if not transitions and phase != "plan":
1345
+ # prepare 之外的入口(或跳过了 prepare)同样要能从发布证据恢复
1346
+ _bootstrap_v2_plan_transition(
1347
+ project,
1348
+ change,
1349
+ contract_root=contract_root,
1350
+ state_root=state_root,
1351
+ to_phase=phase,
1352
+ executor=executor,
1353
+ )
1354
+ transitions = _read_ndjson(paths["transitions"])
1355
+ if not transitions:
1356
+ return {
1357
+ "ok": False,
1358
+ "code": "LEGACY_BOOTSTRAP_REQUIRED",
1359
+ "legacyBootstrap": True,
1360
+ "message": (
1361
+ "没有交接凭证。v2 计划应有 committed 的 meta/publication-journals/*.json "
1362
+ "作为补录证据;legacy 计划需先完成 plan 阶段的 close。"
1363
+ "首次进入某阶段(还没有任何 transition 凭证)时不要用 context begin——"
1364
+ "改跑 `harness_gate.py begin --change-dir .harness/changes/<change> "
1365
+ "--phase <phase> --run-id <phase>_<uuid4hex> --note \"<触发指令>\"`,"
1366
+ "它会领取租约并写好 phase.start。"
1367
+ ),
1368
+ }
1369
+ receipt = transitions[-1]
1370
+ if hpaths.resolve_phase_name(receipt.get("toPhase")) != phase:
1371
+ return {
1372
+ "ok": False,
1373
+ "code": "HANDOFF_REQUIRED",
1374
+ "expectedPhase": receipt.get("toPhase"),
1375
+ "requestedPhase": phase,
1376
+ }
1377
+ unhashed = {key: value for key, value in receipt.items() if key != "receiptHash"}
1378
+ issues: list[str] = []
1379
+ if _payload_hash(unhashed) != receipt.get("receiptHash"):
1380
+ issues.append("receipt hash mismatch")
1381
+ if len(transitions) > 1 and receipt.get("previousReceiptHash") != transitions[-2].get(
1382
+ "receiptHash"
1383
+ ):
1384
+ issues.append("receipt chain mismatch")
1385
+ if receipt.get("productCommit") != _head(project):
1386
+ issues.append("HEAD drift")
1387
+ for artifact in receipt.get("artifacts", []):
1388
+ if not isinstance(artifact, dict):
1389
+ issues.append("artifact entry invalid")
1390
+ continue
1391
+ path = (project / str(artifact.get("path") or "")).resolve()
1392
+ if (
1393
+ not path.is_relative_to(project)
1394
+ or not path.is_file()
1395
+ or _sha256(path) != artifact.get("sha256")
1396
+ ):
1397
+ issues.append(f"artifact drift: {artifact.get('path')}")
1398
+ if issues:
1399
+ return {
1400
+ "ok": False,
1401
+ "code": "HANDOFF_IDENTITY_MISMATCH",
1402
+ "issues": issues,
1403
+ "receipt": receipt,
1404
+ }
1405
+ acknowledgment = {
1406
+ "schemaVersion": 1,
1407
+ "changeName": change,
1408
+ "phase": phase,
1409
+ "executor": executor,
1410
+ "receiptHash": receipt["receiptHash"],
1411
+ "begunAt": _now().isoformat(),
1412
+ }
1413
+ if preparation_id is not None:
1414
+ acknowledgment["preparationId"] = preparation_id
1415
+ _append_ndjson(paths["begins"], acknowledgment)
1416
+ current_context = {
1417
+ "schemaVersion": 1,
1418
+ "changeName": change,
1419
+ "phase": phase,
1420
+ "executor": executor,
1421
+ "executionRoot": str(execution_root),
1422
+ "receiptHash": receipt["receiptHash"],
1423
+ "begunAt": acknowledgment["begunAt"],
1424
+ }
1425
+ if preparation_id is not None:
1426
+ current_context["preparationId"] = preparation_id
1427
+ _write_json_atomic(paths["current"], current_context)
1428
+ return {
1429
+ "ok": True,
1430
+ "code": "TRANSITION_BEGUN",
1431
+ "receipt": receipt,
1432
+ "acknowledgment": acknowledgment,
1433
+ }
1434
+
1435
+
1436
+ def begin_transition(
1437
+ project: Path,
1438
+ change: str,
1439
+ *,
1440
+ phase: str,
1441
+ executor: str,
1442
+ preparation_id: str | None = None,
1443
+ ) -> dict[str, Any]:
1444
+ """Acknowledge a transition while serializing branch selection."""
1445
+
1446
+ project = Path(project).resolve()
1447
+ canonical_phase = hpaths.resolve_phase_name(phase)
1448
+ if canonical_phase is None:
1449
+ return _unknown_phase_error(phase)
1450
+ phase = canonical_phase
1451
+ try:
1452
+ _contract_root, _contract_data, state_root = _contract(project, change)
1453
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1454
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
1455
+ paths = _paths(state_root)
1456
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
1457
+ return _begin_transition_unlocked(
1458
+ project,
1459
+ change,
1460
+ phase=phase,
1461
+ executor=executor,
1462
+ preparation_id=preparation_id,
1463
+ )
1464
+
1465
+
1466
+ def cancel_prepared_context(
1467
+ project: Path,
1468
+ change: str,
1469
+ *,
1470
+ phase: str,
1471
+ executor: str,
1472
+ preparation_id: str | None = None,
1473
+ ) -> dict[str, Any]:
1474
+ """Remove a target-phase preparation that never obtained its gate."""
1475
+
1476
+ project = Path(project).resolve()
1477
+ canonical_phase = hpaths.resolve_phase_name(phase)
1478
+ if canonical_phase is None:
1479
+ return _unknown_phase_error(phase)
1480
+ phase = canonical_phase
1481
+ try:
1482
+ _contract_root, _contract_data, state_root = _contract(project, change)
1483
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1484
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
1485
+ paths = _paths(state_root)
1486
+ removed: list[str] = []
1487
+ receipt_hash: str | None = None
1488
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
1489
+ if paths["current"].is_file():
1490
+ try:
1491
+ current = _read_json(paths["current"])
1492
+ except (OSError, ValueError, json.JSONDecodeError):
1493
+ current = None
1494
+ if isinstance(current, dict) and (
1495
+ hpaths.resolve_phase_name(current.get("phase")) == phase
1496
+ and current.get("executor") == executor
1497
+ and (
1498
+ preparation_id is None
1499
+ or current.get("preparationId") == preparation_id
1500
+ )
1501
+ ):
1502
+ receipt_hash = str(current.get("receiptHash") or "") or None
1503
+ paths["current"].unlink(missing_ok=True)
1504
+ removed.append("current")
1505
+ if paths["lease"].is_file():
1506
+ try:
1507
+ lease = _read_json(paths["lease"])
1508
+ except (OSError, ValueError, json.JSONDecodeError):
1509
+ lease = None
1510
+ if isinstance(lease, dict) and (
1511
+ hpaths.resolve_phase_name(lease.get("phase")) == phase
1512
+ and lease.get("owner") == executor
1513
+ and (
1514
+ preparation_id is None
1515
+ or lease.get("preparationId") == preparation_id
1516
+ )
1517
+ ):
1518
+ paths["lease"].unlink(missing_ok=True)
1519
+ removed.append("lease")
1520
+ begins = _read_ndjson(paths["begins"])
1521
+ retained = [
1522
+ item
1523
+ for item in begins
1524
+ if not (
1525
+ receipt_hash is not None
1526
+ and hpaths.resolve_phase_name(item.get("phase")) == phase
1527
+ and item.get("executor") == executor
1528
+ and item.get("receiptHash") == receipt_hash
1529
+ and (
1530
+ preparation_id is None
1531
+ or item.get("preparationId") == preparation_id
1532
+ )
1533
+ )
1534
+ ]
1535
+ if len(retained) != len(begins):
1536
+ paths["begins"].parent.mkdir(parents=True, exist_ok=True)
1537
+ payload = "".join(
1538
+ json.dumps(item, ensure_ascii=False, sort_keys=True) + "\n"
1539
+ for item in retained
1540
+ )
1541
+ fd, raw = tempfile.mkstemp(
1542
+ prefix=f".{paths['begins'].name}.",
1543
+ suffix=".tmp",
1544
+ dir=str(paths["begins"].parent),
1545
+ )
1546
+ temporary = Path(raw)
1547
+ try:
1548
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
1549
+ handle.write(payload)
1550
+ handle.flush()
1551
+ os.fsync(handle.fileno())
1552
+ os.replace(temporary, paths["begins"])
1553
+ finally:
1554
+ temporary.unlink(missing_ok=True)
1555
+ removed.append("begin")
1556
+ return {
1557
+ "ok": True,
1558
+ "code": (
1559
+ "PREPARED_CONTEXT_CANCELLED"
1560
+ if removed
1561
+ else "PREPARED_CONTEXT_NOT_OWNED"
1562
+ ),
1563
+ "removed": removed,
1564
+ }
1565
+
1566
+
1567
+ def renew_lease(
1568
+ project: Path,
1569
+ change: str,
1570
+ *,
1571
+ executor: str,
1572
+ ttl_seconds: int = 3600,
1573
+ ) -> dict[str, Any]:
1574
+ """Extend the current phase lease in place.
1575
+
1576
+ Only the recorded owner may renew, and only while no other executor has
1577
+ taken the lease over. Renewal touches nothing but ``expiresAt``, so it is
1578
+ safe to call on a heartbeat during a long phase.
1579
+ """
1580
+ project = project.resolve()
1581
+ try:
1582
+ _, _, state_root = _contract(project, change)
1583
+ except ValueError as exc:
1584
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
1585
+ paths = _paths(state_root)
1586
+ if not paths["lease"].is_file():
1587
+ return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
1588
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
1589
+ try:
1590
+ lease = _read_json(paths["lease"])
1591
+ except (OSError, ValueError, json.JSONDecodeError):
1592
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
1593
+ if lease.get("owner") != executor:
1594
+ return {
1595
+ "ok": False,
1596
+ "code": "CONTEXT_LEASE_MISMATCH",
1597
+ "holder": lease.get("owner"),
1598
+ }
1599
+ expiry = _parse_time(lease.get("expiresAt"))
1600
+ lapsed = expiry is None or _now() >= expiry
1601
+ lease["expiresAt"] = (
1602
+ _now() + dt.timedelta(seconds=max(1, int(ttl_seconds)))
1603
+ ).isoformat()
1604
+ lease["renewedAt"] = _now().isoformat()
1605
+ _write_json_atomic(paths["lease"], lease)
1606
+ return {
1607
+ "ok": True,
1608
+ "code": "CONTEXT_LEASE_RENEWED",
1609
+ "lease": lease,
1610
+ "wasLapsed": lapsed,
1611
+ }
1612
+
1613
+
1614
+ def context_view(project: Path, change: str) -> dict[str, Any]:
1615
+ project = Path(project).resolve()
1616
+ try:
1617
+ contract_root, contract, state_root = _contract(project, change)
1618
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1619
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
1620
+ paths = _paths(state_root)
1621
+ transitions = _read_ndjson(paths["transitions"])
1622
+ begins = _read_ndjson(paths["begins"])
1623
+ current = (
1624
+ _read_json(paths["current"]) if paths["current"].is_file() else None
1625
+ )
1626
+ ledger_path = state_root / "evidence" / "verification-ledger.json"
1627
+ findings_path = state_root / "evidence" / "review-findings.json"
1628
+ events_paths = [state_root / "events.ndjson"]
1629
+ if state_root != contract_root:
1630
+ events_paths.append(contract_root / "events.ndjson")
1631
+ return {
1632
+ "ok": True,
1633
+ "code": "CONTEXT_VIEW",
1634
+ "changeName": change,
1635
+ "lifecycle": contract.get("lifecycle"),
1636
+ "currentPhase": (
1637
+ transitions[-1].get("toPhase")
1638
+ if transitions
1639
+ else current.get("phase")
1640
+ if isinstance(current, dict)
1641
+ else None
1642
+ ),
1643
+ "transitions": transitions,
1644
+ "attemptHistory": {"closes": transitions, "begins": begins},
1645
+ "current": current,
1646
+ "ledger": _read_json(ledger_path) if ledger_path.is_file() else None,
1647
+ "findings": _read_json(findings_path) if findings_path.is_file() else None,
1648
+ "events": [
1649
+ item
1650
+ for path in events_paths
1651
+ for item in _read_ndjson(path)
1652
+ ],
1653
+ }
1654
+
1655
+
1656
+ def build_parser() -> argparse.ArgumentParser:
1657
+ parser = argparse.ArgumentParser(prog="harness_context.py")
1658
+ parser.add_argument("--json", action="store_true")
1659
+ sub = parser.add_subparsers(dest="command", required=True)
1660
+ prepare = sub.add_parser("prepare")
1661
+ prepare.add_argument("--json", action="store_true")
1662
+ prepare.add_argument("--project", required=True, type=Path)
1663
+ prepare.add_argument("--change")
1664
+ prepare.add_argument("--phase", required=True)
1665
+ prepare.add_argument("--executor", required=True)
1666
+ prepare.add_argument("--title")
1667
+ prepare.add_argument("--ttl-seconds", type=int, default=3600)
1668
+ prepare.add_argument("--trigger", choices=["review-fixback"])
1669
+ bootstrap = sub.add_parser("bootstrap-plan")
1670
+ bootstrap.add_argument("--json", action="store_true")
1671
+ bootstrap.add_argument("--project", required=True, type=Path)
1672
+ bootstrap.add_argument("--change", required=True)
1673
+ bootstrap.add_argument("--executor", required=True)
1674
+ bootstrap.add_argument("--title")
1675
+ bootstrap.add_argument("--stage", default="plan", choices=["plan", "post-run"])
1676
+ bootstrap.add_argument("--ttl-seconds", type=int, default=3600)
1677
+ close = sub.add_parser("close")
1678
+ close.add_argument("--json", action="store_true")
1679
+ close.add_argument("--project", required=True, type=Path)
1680
+ close.add_argument("--change", required=True)
1681
+ close.add_argument("--from-phase", required=True)
1682
+ close.add_argument("--to-phase", required=True)
1683
+ close.add_argument("--executor", required=True)
1684
+ close.add_argument("--artifact", action="append", default=[])
1685
+ close.add_argument("--status", default="OK")
1686
+ begin = sub.add_parser("begin")
1687
+ begin.add_argument("--json", action="store_true")
1688
+ begin.add_argument("--project", required=True, type=Path)
1689
+ begin.add_argument("--change", required=True)
1690
+ begin.add_argument("--phase", required=True)
1691
+ begin.add_argument("--executor", required=True)
1692
+ renew = sub.add_parser(
1693
+ "renew", help="extend the current phase lease without touching context"
1694
+ )
1695
+ renew.add_argument("--json", action="store_true")
1696
+ renew.add_argument("--project", required=True, type=Path)
1697
+ renew.add_argument("--change", required=True)
1698
+ renew.add_argument("--executor", required=True)
1699
+ renew.add_argument("--ttl-seconds", type=int, default=3600)
1700
+ configure = sub.add_parser("configure-plan")
1701
+ configure.add_argument("--json", action="store_true")
1702
+ configure.add_argument("--project", required=True, type=Path)
1703
+ configure.add_argument("--change", required=True)
1704
+ configure.add_argument("--phases", required=True)
1705
+ configure.add_argument("--operator", required=True)
1706
+ configure.add_argument("--reason", required=True)
1707
+ view = sub.add_parser("view")
1708
+ view.add_argument("--json", action="store_true")
1709
+ view.add_argument("--project", required=True, type=Path)
1710
+ view.add_argument("--change", required=True)
1711
+ return parser
1712
+
1713
+
1714
+ def main(argv: list[str] | None = None) -> int:
1715
+ args = build_parser().parse_args(argv)
1716
+ if args.command == "prepare":
1717
+ result = prepare_context(
1718
+ args.project,
1719
+ phase=args.phase,
1720
+ executor=args.executor,
1721
+ change=args.change,
1722
+ display_title=args.title,
1723
+ ttl_seconds=args.ttl_seconds,
1724
+ trigger=args.trigger,
1725
+ )
1726
+ elif args.command == "bootstrap-plan":
1727
+ result = bootstrap_plan(
1728
+ args.project,
1729
+ change=args.change,
1730
+ executor=args.executor,
1731
+ display_title=args.title,
1732
+ stage=args.stage,
1733
+ ttl_seconds=args.ttl_seconds,
1734
+ )
1735
+ elif args.command == "close":
1736
+ result = close_transition(
1737
+ args.project,
1738
+ args.change,
1739
+ from_phase=args.from_phase,
1740
+ to_phase=args.to_phase,
1741
+ executor=args.executor,
1742
+ artifacts=args.artifact,
1743
+ status=args.status,
1744
+ )
1745
+ elif args.command == "begin":
1746
+ result = begin_transition(
1747
+ args.project,
1748
+ args.change,
1749
+ phase=args.phase,
1750
+ executor=args.executor,
1751
+ )
1752
+ elif args.command == "renew":
1753
+ result = renew_lease(
1754
+ args.project,
1755
+ args.change,
1756
+ executor=args.executor,
1757
+ ttl_seconds=args.ttl_seconds,
1758
+ )
1759
+ elif args.command == "configure-plan":
1760
+ result = configure_phase_plan(
1761
+ args.project,
1762
+ args.change,
1763
+ phases=[item.strip() for item in args.phases.split(",") if item.strip()],
1764
+ operator=args.operator,
1765
+ reason=args.reason,
1766
+ )
1767
+ else:
1768
+ result = context_view(args.project, args.change)
1769
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1770
+ return 0 if result.get("ok") else 1
1771
+
1772
+
1773
+ if __name__ == "__main__":
1774
+ raise SystemExit(main())