@hunter-harness/workflow-harness 0.2.86 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +46 -17
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +2 -2
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +2 -2
  5. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +189 -0
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +2 -2
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +2 -2
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +10 -10
  9. package/harness/bundles/general/claude-code/harness-plan/checklist.md +2 -13
  10. package/harness/bundles/general/claude-code/harness-plan/reference.md +43 -81
  11. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  14. package/harness/bundles/general/claude-code/harness-review/protocols.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-run/SKILL.md +8 -2
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  17. package/harness/bundles/general/claude-code/harness-submit/checklist.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  19. package/harness/bundles/general/claude-code/harness-test/SKILL.md +11 -2
  20. package/harness/bundles/general/claude-code/scripts/harness_adoption_metrics.py +391 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_archive.py +46 -15
  22. package/harness/bundles/general/claude-code/scripts/harness_context.py +94 -43
  23. package/harness/bundles/general/claude-code/scripts/harness_events.py +1 -1
  24. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +1 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +10 -10
  26. package/harness/bundles/general/claude-code/scripts/harness_gate.py +92 -25
  27. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +4 -2
  28. package/harness/bundles/general/claude-code/scripts/harness_paths.py +120 -5
  29. package/harness/bundles/general/claude-code/scripts/harness_phase.py +41 -17
  30. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +16 -873
  31. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  32. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +46 -17
  33. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +2 -2
  34. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +2 -2
  35. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +170 -0
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
  37. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +2 -2
  38. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +10 -10
  39. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +2 -13
  40. package/harness/bundles/general/codebuddy/harness-plan/reference.md +43 -81
  41. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  44. package/harness/bundles/general/codebuddy/harness-review/protocols.md +1 -1
  45. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +8 -2
  46. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  47. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +1 -1
  48. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  49. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +11 -2
  50. package/harness/bundles/general/codebuddy/scripts/harness_adoption_metrics.py +391 -0
  51. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +46 -15
  52. package/harness/bundles/general/codebuddy/scripts/harness_context.py +94 -43
  53. package/harness/bundles/general/codebuddy/scripts/harness_events.py +1 -1
  54. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +1 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +10 -10
  56. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +92 -25
  57. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +4 -2
  58. package/harness/bundles/general/codebuddy/scripts/harness_paths.py +120 -5
  59. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +41 -17
  60. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +16 -873
  61. package/harness/bundles/general/codex/.harness-build.json +1 -1
  62. package/harness/bundles/general/codex/contracts/workflow-policy.json +46 -17
  63. package/harness/bundles/general/codex/harness-archive/SKILL.md +2 -2
  64. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +2 -2
  65. package/harness/bundles/general/codex/harness-execute/SKILL.md +170 -0
  66. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +2 -2
  67. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +2 -2
  68. package/harness/bundles/general/codex/harness-plan/SKILL.md +10 -10
  69. package/harness/bundles/general/codex/harness-plan/checklist.md +2 -13
  70. package/harness/bundles/general/codex/harness-plan/reference.md +43 -81
  71. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  72. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
  74. package/harness/bundles/general/codex/harness-review/protocols.md +1 -1
  75. package/harness/bundles/general/codex/harness-run/SKILL.md +8 -2
  76. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  77. package/harness/bundles/general/codex/harness-submit/checklist.md +1 -1
  78. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  79. package/harness/bundles/general/codex/harness-test/SKILL.md +11 -2
  80. package/harness/bundles/general/codex/scripts/harness_adoption_metrics.py +391 -0
  81. package/harness/bundles/general/codex/scripts/harness_archive.py +46 -15
  82. package/harness/bundles/general/codex/scripts/harness_context.py +94 -43
  83. package/harness/bundles/general/codex/scripts/harness_events.py +1 -1
  84. package/harness/bundles/general/codex/scripts/harness_events_sync.py +1 -0
  85. package/harness/bundles/general/codex/scripts/harness_fixback.py +10 -10
  86. package/harness/bundles/general/codex/scripts/harness_gate.py +92 -25
  87. package/harness/bundles/general/codex/scripts/harness_ledger.py +4 -2
  88. package/harness/bundles/general/codex/scripts/harness_paths.py +120 -5
  89. package/harness/bundles/general/codex/scripts/harness_phase.py +41 -17
  90. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +16 -873
  91. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  92. package/harness/bundles/general/cursor/contracts/workflow-policy.json +46 -17
  93. package/harness/bundles/general/cursor/harness-archive/SKILL.md +2 -2
  94. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +2 -2
  95. package/harness/bundles/general/cursor/harness-execute/SKILL.md +170 -0
  96. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +2 -2
  97. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +2 -2
  98. package/harness/bundles/general/cursor/harness-plan/SKILL.md +10 -10
  99. package/harness/bundles/general/cursor/harness-plan/checklist.md +2 -13
  100. package/harness/bundles/general/cursor/harness-plan/reference.md +43 -81
  101. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  102. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  103. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
  104. package/harness/bundles/general/cursor/harness-review/protocols.md +1 -1
  105. package/harness/bundles/general/cursor/harness-run/SKILL.md +8 -2
  106. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  107. package/harness/bundles/general/cursor/harness-submit/checklist.md +1 -1
  108. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  109. package/harness/bundles/general/cursor/harness-test/SKILL.md +11 -2
  110. package/harness/bundles/general/cursor/scripts/harness_adoption_metrics.py +391 -0
  111. package/harness/bundles/general/cursor/scripts/harness_archive.py +46 -15
  112. package/harness/bundles/general/cursor/scripts/harness_context.py +94 -43
  113. package/harness/bundles/general/cursor/scripts/harness_events.py +1 -1
  114. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +1 -0
  115. package/harness/bundles/general/cursor/scripts/harness_fixback.py +10 -10
  116. package/harness/bundles/general/cursor/scripts/harness_gate.py +92 -25
  117. package/harness/bundles/general/cursor/scripts/harness_ledger.py +4 -2
  118. package/harness/bundles/general/cursor/scripts/harness_paths.py +120 -5
  119. package/harness/bundles/general/cursor/scripts/harness_phase.py +41 -17
  120. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +16 -873
  121. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  122. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +46 -17
  123. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
  124. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +2 -2
  125. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +2 -2
  126. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +189 -0
  127. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +2 -2
  128. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +2 -2
  129. package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -2
  130. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +10 -10
  131. package/harness/bundles/java/claude-code/harness-plan/checklist.md +2 -13
  132. package/harness/bundles/java/claude-code/harness-plan/reference.md +43 -81
  133. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  134. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  135. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  136. package/harness/bundles/java/claude-code/harness-review/protocols.md +1 -1
  137. package/harness/bundles/java/claude-code/harness-run/SKILL.md +8 -2
  138. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  139. package/harness/bundles/java/claude-code/harness-submit/checklist.md +1 -1
  140. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  141. package/harness/bundles/java/claude-code/harness-test/SKILL.md +11 -2
  142. package/harness/bundles/java/claude-code/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/java/claude-code/scripts/harness_archive.py +46 -15
  144. package/harness/bundles/java/claude-code/scripts/harness_context.py +94 -43
  145. package/harness/bundles/java/claude-code/scripts/harness_events.py +1 -1
  146. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +1 -0
  147. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +10 -10
  148. package/harness/bundles/java/claude-code/scripts/harness_gate.py +92 -25
  149. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +4 -2
  150. package/harness/bundles/java/claude-code/scripts/harness_paths.py +120 -5
  151. package/harness/bundles/java/claude-code/scripts/harness_phase.py +41 -17
  152. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +16 -873
  153. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  154. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +46 -17
  155. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
  156. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +2 -2
  157. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +2 -2
  158. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +170 -0
  159. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
  160. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +2 -2
  161. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
  162. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +10 -10
  163. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +2 -13
  164. package/harness/bundles/java/codebuddy/harness-plan/reference.md +43 -81
  165. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  166. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  167. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  168. package/harness/bundles/java/codebuddy/harness-review/protocols.md +1 -1
  169. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +8 -2
  170. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  171. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +1 -1
  172. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  173. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +11 -2
  174. package/harness/bundles/java/codebuddy/scripts/harness_adoption_metrics.py +391 -0
  175. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +46 -15
  176. package/harness/bundles/java/codebuddy/scripts/harness_context.py +94 -43
  177. package/harness/bundles/java/codebuddy/scripts/harness_events.py +1 -1
  178. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +1 -0
  179. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +10 -10
  180. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +92 -25
  181. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +4 -2
  182. package/harness/bundles/java/codebuddy/scripts/harness_paths.py +120 -5
  183. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +41 -17
  184. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +16 -873
  185. package/harness/bundles/java/codex/.harness-build.json +1 -1
  186. package/harness/bundles/java/codex/contracts/workflow-policy.json +46 -17
  187. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
  188. package/harness/bundles/java/codex/harness-archive/SKILL.md +2 -2
  189. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +2 -2
  190. package/harness/bundles/java/codex/harness-execute/SKILL.md +170 -0
  191. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +2 -2
  192. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +2 -2
  193. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
  194. package/harness/bundles/java/codex/harness-plan/SKILL.md +10 -10
  195. package/harness/bundles/java/codex/harness-plan/checklist.md +2 -13
  196. package/harness/bundles/java/codex/harness-plan/reference.md +43 -81
  197. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  198. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  199. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
  200. package/harness/bundles/java/codex/harness-review/protocols.md +1 -1
  201. package/harness/bundles/java/codex/harness-run/SKILL.md +8 -2
  202. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  203. package/harness/bundles/java/codex/harness-submit/checklist.md +1 -1
  204. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  205. package/harness/bundles/java/codex/harness-test/SKILL.md +11 -2
  206. package/harness/bundles/java/codex/scripts/harness_adoption_metrics.py +391 -0
  207. package/harness/bundles/java/codex/scripts/harness_archive.py +46 -15
  208. package/harness/bundles/java/codex/scripts/harness_context.py +94 -43
  209. package/harness/bundles/java/codex/scripts/harness_events.py +1 -1
  210. package/harness/bundles/java/codex/scripts/harness_events_sync.py +1 -0
  211. package/harness/bundles/java/codex/scripts/harness_fixback.py +10 -10
  212. package/harness/bundles/java/codex/scripts/harness_gate.py +92 -25
  213. package/harness/bundles/java/codex/scripts/harness_ledger.py +4 -2
  214. package/harness/bundles/java/codex/scripts/harness_paths.py +120 -5
  215. package/harness/bundles/java/codex/scripts/harness_phase.py +41 -17
  216. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +16 -873
  217. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  218. package/harness/bundles/java/cursor/contracts/workflow-policy.json +46 -17
  219. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
  220. package/harness/bundles/java/cursor/harness-archive/SKILL.md +2 -2
  221. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +2 -2
  222. package/harness/bundles/java/cursor/harness-execute/SKILL.md +170 -0
  223. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +2 -2
  224. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +2 -2
  225. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
  226. package/harness/bundles/java/cursor/harness-plan/SKILL.md +10 -10
  227. package/harness/bundles/java/cursor/harness-plan/checklist.md +2 -13
  228. package/harness/bundles/java/cursor/harness-plan/reference.md +43 -81
  229. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  230. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  231. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
  232. package/harness/bundles/java/cursor/harness-review/protocols.md +1 -1
  233. package/harness/bundles/java/cursor/harness-run/SKILL.md +8 -2
  234. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  235. package/harness/bundles/java/cursor/harness-submit/checklist.md +1 -1
  236. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  237. package/harness/bundles/java/cursor/harness-test/SKILL.md +11 -2
  238. package/harness/bundles/java/cursor/scripts/harness_adoption_metrics.py +391 -0
  239. package/harness/bundles/java/cursor/scripts/harness_archive.py +46 -15
  240. package/harness/bundles/java/cursor/scripts/harness_context.py +94 -43
  241. package/harness/bundles/java/cursor/scripts/harness_events.py +1 -1
  242. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +1 -0
  243. package/harness/bundles/java/cursor/scripts/harness_fixback.py +10 -10
  244. package/harness/bundles/java/cursor/scripts/harness_gate.py +92 -25
  245. package/harness/bundles/java/cursor/scripts/harness_ledger.py +4 -2
  246. package/harness/bundles/java/cursor/scripts/harness_paths.py +120 -5
  247. package/harness/bundles/java/cursor/scripts/harness_phase.py +41 -17
  248. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +16 -873
  249. package/harness/manifests/general/claude-code.json +36 -28
  250. package/harness/manifests/general/codebuddy.json +36 -28
  251. package/harness/manifests/general/codex.json +36 -28
  252. package/harness/manifests/general/cursor.json +36 -28
  253. package/harness/manifests/java/claude-code.json +38 -30
  254. package/harness/manifests/java/codebuddy.json +38 -30
  255. package/harness/manifests/java/codex.json +38 -30
  256. package/harness/manifests/java/cursor.json +38 -30
  257. package/hunter-workflow-family.json +2 -2
  258. package/package.json +1 -1
@@ -1,19 +1,27 @@
1
1
  #!/usr/bin/env python3
2
- """Validate and publish a complete Harness plan artifact set transactionally."""
2
+ """已发布计划的只读验收与 v2 清单解包(read-side only)。
3
+
4
+ legacy staging/finalize/republish 写侧已于 2026-08 移除(roadmap 14;采纳度
5
+ gating 由用户决策解除)。新 change 的发布走 `hunter-harness plan evidence-pack`
6
+ + `plan finalize`(v2);计划修订走 evidence-pack 重跑(expected_baseline
7
+ = present + 新 attempt)。本模块保留:
8
+ - `verify_plan`:已发布计划的只读验收(legacy receipt 与 v2 事务/journal 两种
9
+ 证据形状),供 `harness_gate.validate_plan_handoff` 与 `verify` 子命令使用;
10
+ - v2 manifest / checkpoints 解包与 plan/scenario 表解析:供 gate 与 ledger
11
+ 在只读侧消费(哈希校验后)。
12
+
13
+ 历史 change 的 legacy 产物必须保持可读;删除的是写入路径,不是读路径。
14
+ """
3
15
 
4
16
  from __future__ import annotations
5
17
 
6
18
  import argparse
7
- import contextlib
8
19
  import hashlib
9
- import io
10
20
  import json
11
21
  import os
12
22
  import re
13
- import shutil
14
23
  import stat
15
24
  import sys
16
- import tempfile
17
25
  from pathlib import Path, PurePosixPath
18
26
  from typing import Any
19
27
 
@@ -44,7 +52,7 @@ _BUILD_FILE_NAMES = {
44
52
  }
45
53
 
46
54
  # C8: valid ownerPhase values (lifecycle phases that can own tasks).
47
- VALID_OWNER_PHASES = {"plan", "run", "test", "review", "submit"}
55
+ VALID_OWNER_PHASES = {"plan", "execute", "review", "submit"}
48
56
  SLICE_CANDIDATE_TYPES = {
49
57
  "independent-release-candidate",
50
58
  "child-of-aggregate",
@@ -210,7 +218,7 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, Any]]:
210
218
  and complete_row
211
219
  and owner_phase_index < len(cells)
212
220
  and cells[owner_phase_index].strip()
213
- else "test"
221
+ else "execute"
214
222
  ),
215
223
  "requiredEvidenceKind": PRIORITY_EVIDENCE_KIND.get(
216
224
  priority,
@@ -455,21 +463,6 @@ def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
455
463
  return tasks
456
464
 
457
465
 
458
- def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
459
- path.parent.mkdir(parents=True, exist_ok=True)
460
- fd, raw_tmp = tempfile.mkstemp(
461
- prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
462
- )
463
- tmp = Path(raw_tmp)
464
- try:
465
- with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
466
- json.dump(payload, handle, ensure_ascii=False, indent=2)
467
- handle.write("\n")
468
- handle.flush()
469
- os.fsync(handle.fileno())
470
- os.replace(tmp, path)
471
- finally:
472
- tmp.unlink(missing_ok=True)
473
466
 
474
467
 
475
468
  def _frontmatter(text: str) -> dict[str, str] | None:
@@ -509,362 +502,17 @@ def _is_link_or_reparse(path: Path) -> bool:
509
502
  return bool(attributes & _FILE_ATTRIBUTE_REPARSE_POINT)
510
503
 
511
504
 
512
- def lint_slice_plan(plan_path: Path) -> dict[str, Any]:
513
- """Validate the identity, reuse, and storage contract for a slice plan."""
514
- text = Path(plan_path).read_text(encoding="utf-8-sig")
515
- frontmatter = _frontmatter(text)
516
- if frontmatter is None:
517
- return _result_error(
518
- "SLICE_PLAN_CONTRACT_INVALID",
519
- f"{plan_path.name}: frontmatter is required",
520
- )
521
- enabled = (
522
- str(frontmatter.get("slice-plan") or "").strip().lower()
523
- in {"true", "yes", "1"}
524
- or "candidate-type" in frontmatter
525
- )
526
- if not enabled:
527
- return {
528
- "ok": True,
529
- "configured": False,
530
- "code": "SLICE_PLAN_NOT_CONFIGURED",
531
- }
532
505
 
533
- issues: list[str] = []
534
- candidate_type = str(frontmatter.get("candidate-type") or "").strip()
535
- aggregate_parent = str(frontmatter.get("aggregate-parent") or "").strip()
536
- evidence_reuse = str(frontmatter.get("evidence-reuse") or "").strip()
537
- raw_budget = str(frontmatter.get("artifact-budget-bytes") or "").strip()
538
- if candidate_type not in SLICE_CANDIDATE_TYPES:
539
- issues.append(
540
- "candidate-type must be one of "
541
- + ", ".join(sorted(SLICE_CANDIDATE_TYPES))
542
- )
543
- if not aggregate_parent:
544
- issues.append(
545
- "aggregate-parent is required (use 'none' for an independent candidate)"
546
- )
547
- if not evidence_reuse:
548
- issues.append(
549
- "evidence-reuse is required (use 'none' when no evidence is reused)"
550
- )
551
- try:
552
- artifact_budget = int(raw_budget)
553
- if artifact_budget <= 0:
554
- raise ValueError
555
- except ValueError:
556
- artifact_budget = 0
557
- issues.append("artifact-budget-bytes must be a positive integer")
558
- if issues:
559
- return {
560
- "ok": False,
561
- "code": "SLICE_PLAN_CONTRACT_INVALID",
562
- "error": f"{plan_path.name}: " + "; ".join(issues),
563
- "issues": issues,
564
- }
565
- return {
566
- "ok": True,
567
- "configured": True,
568
- "code": "SLICE_PLAN_CONTRACT_OK",
569
- "candidateType": candidate_type,
570
- "aggregateParent": aggregate_parent,
571
- "evidenceReuse": evidence_reuse,
572
- "artifactBudgetBytes": artifact_budget,
573
- }
574
506
 
575
507
 
576
- def _artifact_files(staging: Path) -> list[Path]:
577
- files: list[Path] = []
578
- for path in staging.rglob("*"):
579
- if _is_link_or_reparse(path):
580
- raise ValueError(f"PLAN_ARTIFACT_SYMLINK: {path}")
581
- if path.is_file():
582
- files.append(path)
583
- return sorted(files, key=lambda item: item.relative_to(staging).as_posix())
584
508
 
585
509
 
586
- def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
587
- staging = staging.resolve()
588
- required = {Path(name) for name in _required_artifact_names(change_name)}
589
- try:
590
- files = _artifact_files(staging)
591
- except ValueError as exc:
592
- code = str(exc).split(":", 1)[0]
593
- return _result_error(code, str(exc))
594
- rel_files = {path.relative_to(staging) for path in files}
595
- missing = sorted(path.as_posix() for path in required - rel_files)
596
- if missing:
597
- return _result_error(
598
- "PLAN_ARTIFACT_MISSING", "missing required artifacts: " + ", ".join(missing)
599
- )
600
510
 
601
- for path in files:
602
- rel = path.relative_to(staging)
603
- if rel.parts[0] not in {"spec", "plans", "meta"}:
604
- return _result_error(
605
- "PLAN_ARTIFACT_PATH_INVALID", f"unexpected artifact root: {rel.as_posix()}"
606
- )
607
- if path.suffix.lower() == ".json":
608
- try:
609
- payload = json.loads(path.read_text(encoding="utf-8-sig"))
610
- except (OSError, json.JSONDecodeError) as exc:
611
- return _result_error(
612
- "PLAN_ARTIFACT_INVALID_JSON", f"{rel.as_posix()}: {exc}"
613
- )
614
- if not isinstance(payload, dict):
615
- return _result_error(
616
- "PLAN_ARTIFACT_INVALID_JSON",
617
- f"{rel.as_posix()}: top-level JSON must be an object",
618
- )
619
- elif path.suffix.lower() == ".md":
620
- try:
621
- text = path.read_text(encoding="utf-8-sig")
622
- except OSError as exc:
623
- return _result_error("PLAN_ARTIFACT_UNREADABLE", str(exc))
624
- frontmatter = _frontmatter(text)
625
- if frontmatter is None or frontmatter.get("change-name") != change_name:
626
- return _result_error(
627
- "PLAN_ARTIFACT_FRONTMATTER_INVALID",
628
- f"{rel.as_posix()}: change-name frontmatter mismatch",
629
- )
630
- for raw_link in _LINK.findall(text):
631
- link = raw_link.strip().split("#", 1)[0]
632
- if not link or "://" in link or link.startswith("mailto:"):
633
- continue
634
- target = (path.parent / link).resolve()
635
- try:
636
- target.relative_to(staging)
637
- except ValueError:
638
- return _result_error(
639
- "PLAN_ARTIFACT_REFERENCE_INVALID",
640
- f"{rel.as_posix()}: reference escapes staging: {raw_link}",
641
- )
642
- if not target.is_file():
643
- return _result_error(
644
- "PLAN_ARTIFACT_REFERENCE_MISSING",
645
- f"{rel.as_posix()}: missing reference {raw_link}",
646
- )
647
511
 
648
- # C8: validate ownerPhase values in plan.md task table.
649
- plan_path = staging / "plans" / f"{change_name}-plan.md"
650
- try:
651
- tasks = parse_plan_tasks(plan_path)
652
- except PlanParseError as exc:
653
- return _result_error(exc.code, str(exc))
654
- if not tasks:
655
- return _result_error(
656
- "PLAN_TASKS_EMPTY",
657
- f"{plan_path.relative_to(staging).as_posix()}: no task rows were parsed",
658
- )
659
- task_ids = [str(task.get("num") or "").strip() for task in tasks]
660
- duplicate_task_ids = sorted(
661
- {task_id for task_id in task_ids if task_ids.count(task_id) > 1}
662
- )
663
- if duplicate_task_ids:
664
- return _result_error(
665
- "PLAN_TASK_ID_DUPLICATE",
666
- "duplicate task IDs: " + ", ".join(duplicate_task_ids),
667
- )
668
- for task in tasks:
669
- owner = task.get("ownerPhase")
670
- if owner is not None and owner != "" and owner not in VALID_OWNER_PHASES:
671
- return _result_error(
672
- "PLAN_OWNER_PHASE_INVALID",
673
- f"task {task.get('num', '?')}: ownerPhase '{owner}' not in {sorted(VALID_OWNER_PHASES)}",
674
- )
675
- slice_plan = lint_slice_plan(plan_path)
676
- if not slice_plan.get("ok"):
677
- return slice_plan
678
512
 
679
- # C9: parse test-scenarios.md for scenario manifest.
680
- scenarios_path = staging / "plans" / f"{change_name}-test-scenarios.md"
681
- try:
682
- scenarios = parse_test_scenarios(scenarios_path)
683
- except PlanParseError as exc:
684
- return _result_error(exc.code, str(exc))
685
- if not scenarios:
686
- return _result_error(
687
- "PLAN_SCENARIOS_EMPTY",
688
- f"{scenarios_path.relative_to(staging).as_posix()}: no scenario rows were parsed",
689
- )
690
- scenario_ids = [str(item.get("id") or "").strip() for item in scenarios]
691
- duplicate_scenario_ids = sorted(
692
- {
693
- scenario_id
694
- for scenario_id in scenario_ids
695
- if scenario_ids.count(scenario_id) > 1
696
- }
697
- )
698
- if duplicate_scenario_ids:
699
- return _result_error(
700
- "PLAN_SCENARIO_ID_DUPLICATE",
701
- "duplicate scenario IDs: " + ", ".join(duplicate_scenario_ids),
702
- )
703
- for scenario in scenarios:
704
- priority = str(scenario.get("priority") or "").upper()
705
- if priority not in PRIORITY_EVIDENCE_KIND:
706
- return _result_error(
707
- "PLAN_SCENARIO_PRIORITY_INVALID",
708
- f"scenario {scenario.get('id', '?')}: unsupported priority '{priority}'",
709
- )
710
- owner = str(scenario.get("ownerPhase") or "")
711
- if owner not in VALID_OWNER_PHASES:
712
- return _result_error(
713
- "PLAN_SCENARIO_OWNER_PHASE_INVALID",
714
- f"scenario {scenario.get('id', '?')}: unsupported ownerPhase '{owner}'",
715
- )
716
- if (
717
- scenario.get("requiredEvidenceKind") == "ledger"
718
- and scenario.get("executableMappingDeclared")
719
- ):
720
- missing_mapping = [
721
- field
722
- for field in ("executableTestId", "testFile", "testTitle")
723
- if not str(scenario.get(field) or "").strip()
724
- ]
725
- if missing_mapping:
726
- return _result_error(
727
- "PLAN_SCENARIO_EXECUTABLE_MAPPING_MISSING",
728
- f"scenario {scenario.get('id', '?')}: missing executable mapping fields: "
729
- + ", ".join(missing_mapping),
730
- )
731
513
 
732
- digest = hashlib.sha256()
733
- artifact_names: list[str] = []
734
- for path in files:
735
- rel = path.relative_to(staging).as_posix()
736
- artifact_names.append(rel)
737
- digest.update(rel.encode("utf-8"))
738
- digest.update(b"\0")
739
- digest.update(path.read_bytes())
740
- digest.update(b"\0")
741
- return {
742
- "ok": True,
743
- "files": artifact_names,
744
- "artifactsHash": "sha256:" + digest.hexdigest(),
745
- "tasks": tasks,
746
- "scenarios": scenarios,
747
- "slicePlan": slice_plan,
748
- }
749
514
 
750
515
 
751
- def validate_product_ownership(change_dir: Path, staging: Path) -> dict[str, Any]:
752
- """Ensure Plan declares stable prefix ownership for explicit code/test/build paths."""
753
- context_path = change_dir / "meta" / "change-context.json"
754
- if not context_path.is_file():
755
- return {"ok": True, "code": "PLAN_PRODUCT_PATHS_LEGACY_UNDECLARED"}
756
- try:
757
- context = json.loads(context_path.read_text(encoding="utf-8-sig"))
758
- except (OSError, json.JSONDecodeError) as exc:
759
- return _result_error("PLAN_CHANGE_CONTEXT_INVALID", str(exc))
760
- ownership = context.get("ownership") if isinstance(context, dict) else None
761
- raw_paths = ownership.get("productPaths") if isinstance(ownership, dict) else None
762
- product_paths = [
763
- str(item).strip().replace("\\", "/").removeprefix("./")
764
- for item in raw_paths or []
765
- if isinstance(item, str) and item.strip()
766
- ]
767
- unsupported = sorted(path for path in product_paths if any(char in path for char in "*?[]"))
768
- if unsupported:
769
- result = _result_error(
770
- "PLAN_PRODUCT_PATHS_GLOB_UNSUPPORTED",
771
- "productPaths 只接受精确文件或目录前缀,不支持通配:" + ", ".join(unsupported),
772
- )
773
- result["unsupportedPaths"] = unsupported
774
- return result
775
-
776
- planned_paths: set[str] = set()
777
- for document in sorted(staging.rglob("*.md")):
778
- text = document.read_text(encoding="utf-8-sig")
779
- for raw in _BACKTICK.findall(text):
780
- candidate = raw.strip().strip("'\"").replace("\\", "/")
781
- if not candidate or " " in candidate or "://" in candidate or "<" in candidate:
782
- continue
783
- candidate = re.sub(r":\d+(?::\d+)?$", "", candidate)
784
- candidate = candidate.removeprefix("./").strip("/.,;,;")
785
- if not candidate or candidate.startswith("../") or candidate.startswith(".harness/"):
786
- continue
787
- pure = PurePosixPath(candidate)
788
- if pure.name.lower() in _BUILD_FILE_NAMES or pure.suffix.lower() in _CODE_PATH_SUFFIXES:
789
- planned_paths.add(candidate)
790
-
791
- def covered(path: str) -> bool:
792
- return any(
793
- path == owned.rstrip("/")
794
- or path.startswith(owned.rstrip("/") + "/")
795
- for owned in product_paths
796
- if owned.rstrip("/")
797
- )
798
-
799
- missing = sorted(path for path in planned_paths if not covered(path))
800
- if missing:
801
- result = _result_error(
802
- "PLAN_PRODUCT_PATHS_INCOMPLETE",
803
- "productPaths 未覆盖计划明确引用的产品文件:" + ", ".join(missing),
804
- )
805
- result["missingPaths"] = missing
806
- result["recoveryAction"] = "在 Plan 阶段补充精确文件或目录前缀后重新 finalize。"
807
- return result
808
- return {
809
- "ok": True,
810
- "code": "PLAN_PRODUCT_PATHS_COMPLETE",
811
- "plannedPaths": sorted(planned_paths),
812
- "productPaths": product_paths,
813
- }
814
-
815
-
816
- def _terminal_exists(change_dir: Path, run_id: str, attempt: int) -> bool:
817
- try:
818
- events = harness_events.load_events(harness_events.events_path(change_dir))
819
- except (OSError, ValueError):
820
- return False
821
- return any(
822
- event.get("phase") == "plan"
823
- and event.get("type") == "phase.end"
824
- and event.get("run_id") == run_id
825
- and event.get("attempt") == attempt
826
- and str(event.get("status") or "").upper() == "OK"
827
- for event in events
828
- )
829
-
830
-
831
- def _validate_plan_start(
832
- change_dir: Path,
833
- run_id: str,
834
- attempt: int,
835
- ) -> dict[str, Any]:
836
- events_path = harness_events.events_path(change_dir)
837
- try:
838
- events = harness_events.load_events(events_path)
839
- except (OSError, ValueError) as exc:
840
- return _result_error("EVENTS_PARSE_ERROR", str(exc))
841
- matching = [
842
- event
843
- for event in events
844
- if event.get("phase") == "plan"
845
- and event.get("type") == "phase.start"
846
- and event.get("run_id") == run_id
847
- and event.get("attempt") == attempt
848
- ]
849
- if not matching:
850
- return _result_error(
851
- "PHASE_START_MISSING",
852
- "no matching plan phase.start event found for finalizer runId/attempt; "
853
- "append one with the same run id and attempt before finalizing, or use "
854
- "the `republish` subcommand which allocates and appends both for you",
855
- )
856
- if len(matching) > 1:
857
- return _result_error(
858
- "PHASE_START_DUPLICATE",
859
- f"found {len(matching)} matching plan phase.start events for run id "
860
- f"{run_id}. `harness_gate.py begin` already appends phase.start; a "
861
- "second manual append of the same run id creates this state. Recover "
862
- "with a fresh run id and the next attempt number (attempts are global "
863
- "per phase, not per run id): append phase.start with both, then "
864
- "finalize with the same pair.",
865
- )
866
- return {"ok": True, "phaseStartCount": 1}
867
-
868
516
 
869
517
  def _receipt_artifact_targets(
870
518
  change_dir: Path,
@@ -1326,515 +974,27 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
1326
974
  }
1327
975
 
1328
976
 
1329
- def _read_design_capabilities(staging: Path, change_name: str) -> list[str]:
1330
- """Read capabilities from design frontmatter (retro §5.4)."""
1331
- design_path = staging / "spec" / f"{change_name}-design.md"
1332
- if not design_path.is_file():
1333
- return []
1334
- try:
1335
- text = design_path.read_text(encoding="utf-8-sig")
1336
- except OSError:
1337
- return []
1338
- frontmatter = _frontmatter(text)
1339
- if not frontmatter:
1340
- return []
1341
- raw = frontmatter.get("capabilities") or ""
1342
- if not raw:
1343
- return []
1344
- # capabilities may be comma-separated or YAML list
1345
- if raw.startswith("["):
1346
- try:
1347
- parsed = json.loads(raw)
1348
- if isinstance(parsed, list):
1349
- return [str(c) for c in parsed if isinstance(c, str)]
1350
- except json.JSONDecodeError:
1351
- pass
1352
- return [c.strip() for c in raw.split(",") if c.strip()]
1353
-
1354
-
1355
- def _reclassify_gate_policy(
1356
- staging: Path,
1357
- change_name: str,
1358
- ) -> dict[str, Any]:
1359
- """Reclassify gate policy based on approved design capabilities (retro §5.4).
1360
-
1361
- Reads design frontmatter `capabilities`, invokes harness_gate.py classify
1362
- to recompute the gate DAG, and updates staging/meta/gate-policy.json.
1363
- Returns {"ok": bool, "capabilities": [...], "drift": bool}.
1364
- """
1365
- capabilities = _read_design_capabilities(staging, change_name)
1366
- if not capabilities:
1367
- return {"ok": True, "capabilities": [], "drift": False, "updated": False}
1368
977
 
1369
- gate_policy_path = staging / "meta" / "gate-policy.json"
1370
- try:
1371
- existing = json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
1372
- except (OSError, json.JSONDecodeError):
1373
- existing = {}
1374
978
 
1375
- existing_caps = set(existing.get("capabilities") or [])
1376
- design_caps = set(capabilities)
1377
- drift = existing_caps != design_caps
1378
979
 
1379
- if drift:
1380
- # Update gate-policy.json with design capabilities
1381
- existing["capabilities"] = sorted(design_caps)
1382
- try:
1383
- gate_policy_path.write_text(
1384
- json.dumps(existing, ensure_ascii=False, indent=2) + "\n",
1385
- encoding="utf-8",
1386
- newline="\n",
1387
- )
1388
- except OSError as exc:
1389
- return {
1390
- "ok": False,
1391
- "error": f"failed to update gate-policy.json: {exc}",
1392
- "capabilities": capabilities,
1393
- "drift": True,
1394
- "updated": False,
1395
- }
1396
980
 
1397
- return {
1398
- "ok": True,
1399
- "capabilities": sorted(design_caps),
1400
- "drift": drift,
1401
- "updated": drift,
1402
- }
1403
981
 
1404
982
 
1405
- def _append_terminal(change_dir: Path, run_id: str, attempt: int) -> tuple[int, str]:
1406
- stdout = io.StringIO()
1407
- stderr = io.StringIO()
1408
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1409
- code = harness_events.main(
1410
- [
1411
- "append",
1412
- "--change-dir",
1413
- str(change_dir),
1414
- "--phase",
1415
- "plan",
1416
- "--type",
1417
- "phase.end",
1418
- "--status",
1419
- "OK",
1420
- "--reason",
1421
- "plan artifacts validated and published",
1422
- "--run-id",
1423
- run_id,
1424
- "--attempt",
1425
- str(attempt),
1426
- "--json",
1427
- ]
1428
- )
1429
- return code, stderr.getvalue().strip()
1430
-
1431
983
 
1432
- def _rollback_publish(
1433
- created: list[Path], replaced: list[tuple[Path, bytes]]
1434
- ) -> None:
1435
- """Undo a failed publish: delete new files, restore overwritten ones."""
1436
- for target in reversed(created):
1437
- target.unlink(missing_ok=True)
1438
- for target, original in reversed(replaced):
1439
- try:
1440
- target.write_bytes(original)
1441
- except OSError:
1442
- # Best effort: the caller already reports the publish failure.
1443
- pass
1444
-
1445
-
1446
- def _plan_attempts(change_dir: Path) -> list[int]:
1447
- """Every attempt number already used by a plan lifecycle event."""
1448
- try:
1449
- events = harness_events.load_events(harness_events.events_path(change_dir))
1450
- except (OSError, ValueError):
1451
- return []
1452
- attempts: list[int] = []
1453
- for event in events:
1454
- if event.get("phase") != "plan":
1455
- continue
1456
- value = event.get("attempt")
1457
- if isinstance(value, int) and not isinstance(value, bool):
1458
- attempts.append(value)
1459
- return attempts
1460
-
1461
-
1462
- def _run_id_in_use(change_dir: Path, run_id: str) -> bool:
1463
- try:
1464
- events = harness_events.load_events(harness_events.events_path(change_dir))
1465
- except (OSError, ValueError):
1466
- return False
1467
- return any(event.get("run_id") == run_id for event in events)
1468
-
1469
-
1470
- def _append_plan_start(
1471
- change_dir: Path, run_id: str, attempt: int, reason: str
1472
- ) -> tuple[int, str]:
1473
- stdout = io.StringIO()
1474
- stderr = io.StringIO()
1475
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1476
- code = harness_events.main(
1477
- [
1478
- "append",
1479
- "--change-dir",
1480
- str(change_dir),
1481
- "--phase",
1482
- "plan",
1483
- "--type",
1484
- "phase.start",
1485
- # phase.start accepts only `note` beyond provenance fields.
1486
- "--note",
1487
- reason,
1488
- "--run-id",
1489
- run_id,
1490
- "--attempt",
1491
- str(attempt),
1492
- "--json",
1493
- ]
1494
- )
1495
- return code, stderr.getvalue().strip()
1496
-
1497
-
1498
- def finalize_plan(
1499
- change_dir: Path,
1500
- staging: Path,
1501
- *,
1502
- change_name: str,
1503
- run_id: str,
1504
- attempt: int,
1505
- allow_amend: bool = False,
1506
- amend_reason: str | None = None,
1507
- ) -> dict[str, Any]:
1508
- change_dir = change_dir.resolve()
1509
- staging = staging.resolve()
1510
-
1511
- # C2 (retro §5.4): reclassify gate policy based on approved design capabilities.
1512
- # This updates staging/meta/gate-policy.json before validation, so the hash
1513
- # reflects the final gate policy. Drift between design capabilities and
1514
- # gate-policy capabilities is resolved in favor of the approved design.
1515
- reclassify = _reclassify_gate_policy(staging, change_name)
1516
- if not reclassify.get("ok"):
1517
- return _result_error("CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed"))
1518
-
1519
- validation = validate_staging(staging, change_name)
1520
- if not validation["ok"]:
1521
- return validation
1522
- ownership_validation = validate_product_ownership(change_dir, staging)
1523
- if not ownership_validation["ok"]:
1524
- return ownership_validation
1525
- start_validation = _validate_plan_start(change_dir, run_id, attempt)
1526
- if not start_validation["ok"]:
1527
- return start_validation
1528
-
1529
- receipt_path = change_dir / "meta" / "plan-finalization.json"
1530
- lock_path = change_dir / "meta" / "plan-finalize.lock"
1531
- receipt: dict[str, Any] | None = None
1532
- supersedes: dict[str, Any] | None = None
1533
- if receipt_path.is_file():
1534
- try:
1535
- loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1536
- receipt = loaded if isinstance(loaded, dict) else None
1537
- except (OSError, json.JSONDecodeError) as exc:
1538
- return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1539
- if receipt and receipt.get("artifactsHash") != validation["artifactsHash"]:
1540
- if not allow_amend:
1541
- return _result_error(
1542
- "PLAN_FINALIZATION_HASH_CONFLICT",
1543
- "finalizer was already invoked with a different artifact set; "
1544
- "to publish an approved amendment run: harness_plan_finalize.py "
1545
- "republish --change-dir <dir> --staging-dir <dir> --change "
1546
- f"{change_name} --run-id <fresh-run-id> --reason \"<why>\"",
1547
- )
1548
- # Authorized amendment: keep the superseded identity in the new
1549
- # receipt so the published history stays auditable.
1550
- supersedes = {
1551
- "artifactsHash": receipt.get("artifactsHash"),
1552
- "runId": receipt.get("runId"),
1553
- "attempt": receipt.get("attempt"),
1554
- }
1555
- elif (
1556
- receipt
1557
- and receipt.get("status") == "finalized"
1558
- and _terminal_exists(change_dir, run_id, attempt)
1559
- ):
1560
- return {
1561
- "ok": True,
1562
- "action": "finalize",
1563
- "idempotent": True,
1564
- "artifactsHash": validation["artifactsHash"],
1565
- "files": validation["files"],
1566
- "receiptPath": str(receipt_path),
1567
- "artifactRef": "meta/plan-finalization.json",
1568
- }
1569
-
1570
- lock_path.parent.mkdir(parents=True, exist_ok=True)
1571
- try:
1572
- lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1573
- os.close(lock_fd)
1574
- except FileExistsError:
1575
- return _result_error("PLAN_FINALIZATION_LOCKED", f"lock exists: {lock_path}")
1576
-
1577
- created: list[Path] = []
1578
- # Amendment overwrites replace already-published bytes. Keep the originals
1579
- # so a failed publish restores them instead of deleting the published plan.
1580
- replaced: list[tuple[Path, bytes]] = []
1581
- terminal_committed = False
1582
- try:
1583
- for rel_text in validation["files"]:
1584
- source = staging / rel_text
1585
- target = change_dir / rel_text
1586
- if (
1587
- not allow_amend
1588
- and target.exists()
1589
- and target.read_bytes() != source.read_bytes()
1590
- ):
1591
- return _result_error(
1592
- "PLAN_TARGET_CONFLICT",
1593
- f"refusing to overwrite {rel_text}; publish an approved "
1594
- "amendment with the `republish` subcommand instead",
1595
- )
1596
984
 
1597
- for rel_text in validation["files"]:
1598
- source = staging / rel_text
1599
- target = change_dir / rel_text
1600
- existed = target.exists()
1601
- if existed:
1602
- original = target.read_bytes()
1603
- if not allow_amend or original == source.read_bytes():
1604
- continue
1605
- target.parent.mkdir(parents=True, exist_ok=True)
1606
- fd, raw_tmp = tempfile.mkstemp(
1607
- prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
1608
- )
1609
- os.close(fd)
1610
- tmp = Path(raw_tmp)
1611
- try:
1612
- shutil.copyfile(source, tmp)
1613
- os.replace(tmp, target)
1614
- if existed:
1615
- replaced.append((target, original))
1616
- else:
1617
- created.append(target)
1618
- finally:
1619
- tmp.unlink(missing_ok=True)
1620
-
1621
- # C8: write implementation-checkpoints.json with parsed task ownerPhase.
1622
- tasks = validation.get("tasks") or []
1623
- if tasks:
1624
- checkpoints_path = change_dir / "meta" / "implementation-checkpoints.json"
1625
- checkpoints_payload = {
1626
- "schemaVersion": 1,
1627
- "changeName": change_name,
1628
- "tasks": tasks,
1629
- "foundationGate": "approved",
1630
- }
1631
- if checkpoints_path.is_file():
1632
- replaced.append((checkpoints_path, checkpoints_path.read_bytes()))
1633
- else:
1634
- created.append(checkpoints_path)
1635
- _atomic_write_json(checkpoints_path, checkpoints_payload)
1636
-
1637
- # C9: write scenario-manifest.json with parsed scenarios.
1638
- scenarios = validation.get("scenarios") or []
1639
- manifest_path = change_dir / "meta" / "scenario-manifest.json"
1640
- manifest_payload = {
1641
- "schemaVersion": scenario_manifest_schema_version(scenarios),
1642
- "changeName": change_name,
1643
- "scenarios": [
1644
- {
1645
- key: value
1646
- for key, value in scenario.items()
1647
- if key != "executableMappingDeclared"
1648
- }
1649
- for scenario in scenarios
1650
- ],
1651
- }
1652
- if manifest_path.is_file():
1653
- replaced.append((manifest_path, manifest_path.read_bytes()))
1654
- else:
1655
- created.append(manifest_path)
1656
- _atomic_write_json(manifest_path, manifest_payload)
1657
-
1658
- pending_receipt = {
1659
- "schemaVersion": SCHEMA_VERSION,
1660
- "changeName": change_name,
1661
- "status": "publishing",
1662
- "artifactsHash": validation["artifactsHash"],
1663
- "files": validation["files"],
1664
- "runId": run_id,
1665
- "attempt": attempt,
1666
- }
1667
- if supersedes is not None:
1668
- pending_receipt["supersedes"] = supersedes
1669
- pending_receipt["amendReason"] = amend_reason or ""
1670
- _atomic_write_json(receipt_path, pending_receipt)
1671
- terminal_code, terminal_error = _append_terminal(change_dir, run_id, attempt)
1672
- terminal_committed = _terminal_exists(change_dir, run_id, attempt)
1673
- if terminal_code != 0 and not terminal_committed:
1674
- _rollback_publish(created, replaced)
1675
- receipt_path.unlink(missing_ok=True)
1676
- return _result_error(
1677
- "PLAN_TERMINAL_APPEND_FAILED",
1678
- terminal_error or "phase.end append failed",
1679
- )
1680
- pending_receipt["status"] = "finalized"
1681
- _atomic_write_json(receipt_path, pending_receipt)
1682
- result: dict[str, Any] = {
1683
- "ok": True,
1684
- "action": "republish" if supersedes is not None else "finalize",
1685
- "idempotent": False,
1686
- "artifactsHash": validation["artifactsHash"],
1687
- "files": validation["files"],
1688
- "receiptPath": str(receipt_path),
1689
- "artifactRef": "meta/plan-finalization.json",
1690
- "executionLogPath": str(harness_events.execution_log_path(change_dir)),
1691
- }
1692
- if supersedes is not None:
1693
- result["supersedes"] = supersedes
1694
- result["scenarioCount"] = len(scenarios)
1695
- result["taskCount"] = len(tasks)
1696
- return result
1697
- except OSError as exc:
1698
- if terminal_committed or _terminal_exists(change_dir, run_id, attempt):
1699
- return _result_error(
1700
- "PLAN_FINALIZATION_RECOVERY_REQUIRED",
1701
- f"terminal committed; retry finalization to complete receipt: {exc}",
1702
- )
1703
- _rollback_publish(created, replaced)
1704
- receipt_path.unlink(missing_ok=True)
1705
- return _result_error("PLAN_FINALIZATION_IO_ERROR", str(exc))
1706
- finally:
1707
- lock_path.unlink(missing_ok=True)
1708
985
 
1709
986
 
1710
- def republish_plan(
1711
- change_dir: Path,
1712
- staging: Path,
1713
- *,
1714
- change_name: str,
1715
- run_id: str,
1716
- reason: str,
1717
- ) -> dict[str, Any]:
1718
- """Publish an approved amendment to an already-finalized plan.
1719
-
1720
- Amending a published plan (adding a scenario, correcting a task) used to
1721
- require reverse-engineering a five-step lifecycle dance across three
1722
- modules — new run id, incremented attempt, stale receipt removal, dangling
1723
- attempt sealing — and every wrong turn produced a different error code
1724
- (PLAN_FINALIZATION_HASH_CONFLICT / PHASE_START_MISSING /
1725
- EVENT_ATTEMPT_CONFLICT / PHASE_ALREADY_CLOSED). Agents that gave up
1726
- hand-edited meta/scenario-manifest.json, creating exactly the drift the
1727
- hash guard exists to prevent.
1728
-
1729
- This performs the whole sequence atomically and keeps the superseded
1730
- identity in the receipt, so amendment stays auditable rather than silent.
1731
- """
1732
- change_dir = change_dir.resolve()
1733
- staging = staging.resolve()
1734
987
 
1735
- if not str(reason).strip():
1736
- return _result_error(
1737
- "PLAN_AMEND_REASON_REQUIRED",
1738
- "--reason is required: an amendment to a published plan must record why",
1739
- )
1740
988
 
1741
- receipt_path = change_dir / "meta" / "plan-finalization.json"
1742
- if not receipt_path.is_file():
1743
- return _result_error(
1744
- "PLAN_NOT_FINALIZED",
1745
- "no plan-finalization.json to amend; use `finalize` for the first "
1746
- "publication",
1747
- )
1748
989
 
1749
- if _run_id_in_use(change_dir, run_id):
1750
- return _result_error(
1751
- "PLAN_AMEND_RUN_ID_IN_USE",
1752
- f"run id {run_id} already appears in this change's events; "
1753
- "an amendment needs a fresh run id",
1754
- )
1755
990
 
1756
- # Nothing to amend is a no-op, not a new attempt: probe the staged hash
1757
- # before writing any lifecycle event.
1758
- reclassify = _reclassify_gate_policy(staging, change_name)
1759
- if not reclassify.get("ok"):
1760
- return _result_error(
1761
- "CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed")
1762
- )
1763
- probe = validate_staging(staging, change_name)
1764
- if not probe["ok"]:
1765
- return probe
1766
- try:
1767
- published = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1768
- except (OSError, json.JSONDecodeError) as exc:
1769
- return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1770
- if (
1771
- isinstance(published, dict)
1772
- and published.get("status") == "finalized"
1773
- and published.get("artifactsHash") == probe["artifactsHash"]
1774
- ):
1775
- return {
1776
- "ok": True,
1777
- "action": "republish",
1778
- "idempotent": True,
1779
- "artifactsHash": probe["artifactsHash"],
1780
- "files": probe["files"],
1781
- "receiptPath": str(receipt_path),
1782
- "artifactRef": "meta/plan-finalization.json",
1783
- "note": "published plan already matches staging; nothing to amend",
1784
- }
1785
991
 
1786
- attempt = max(_plan_attempts(change_dir), default=0) + 1
1787
- start_code, start_error = _append_plan_start(
1788
- change_dir, run_id, attempt, f"plan amendment: {reason}"
1789
- )
1790
- if start_code != 0:
1791
- return _result_error(
1792
- "PLAN_AMEND_START_FAILED",
1793
- start_error or "failed to append plan phase.start for the amendment",
1794
- )
1795
992
 
1796
- result = finalize_plan(
1797
- change_dir,
1798
- staging,
1799
- change_name=change_name,
1800
- run_id=run_id,
1801
- attempt=attempt,
1802
- allow_amend=True,
1803
- amend_reason=reason,
1804
- )
1805
- if isinstance(result, dict):
1806
- result.setdefault("runId", run_id)
1807
- result.setdefault("attempt", attempt)
1808
- return result
1809
993
 
1810
994
 
1811
995
  def build_parser() -> argparse.ArgumentParser:
1812
996
  parser = argparse.ArgumentParser(prog="harness_plan_finalize.py")
1813
997
  sub = parser.add_subparsers(dest="command", required=True)
1814
- republish = sub.add_parser(
1815
- "republish",
1816
- help=(
1817
- "publish an approved amendment to an already-finalized plan "
1818
- "(allocates a fresh attempt and records what it supersedes)"
1819
- ),
1820
- )
1821
- republish.add_argument("--change-dir", required=True)
1822
- republish.add_argument("--staging-dir", required=True)
1823
- republish.add_argument("--change", required=True)
1824
- republish.add_argument(
1825
- "--run-id", required=True, help="a fresh run id, unused by this change"
1826
- )
1827
- republish.add_argument(
1828
- "--reason", required=True, help="why the published plan is being amended"
1829
- )
1830
- republish.add_argument("--json", action="store_true")
1831
- finalize = sub.add_parser("finalize")
1832
- finalize.add_argument("--change-dir", required=True)
1833
- finalize.add_argument("--staging-dir", required=True)
1834
- finalize.add_argument("--change", required=True)
1835
- finalize.add_argument("--run-id", required=True)
1836
- finalize.add_argument("--attempt", required=True, type=int)
1837
- finalize.add_argument("--json", action="store_true")
1838
998
  verify = sub.add_parser("verify")
1839
999
  verify.add_argument("--change-dir", required=True)
1840
1000
  verify.add_argument("--json", action="store_true")
@@ -1843,24 +1003,7 @@ def build_parser() -> argparse.ArgumentParser:
1843
1003
 
1844
1004
  def main(argv: list[str] | None = None) -> int:
1845
1005
  args = build_parser().parse_args(argv)
1846
- if args.command == "verify":
1847
- result = verify_plan(Path(args.change_dir))
1848
- elif args.command == "republish":
1849
- result = republish_plan(
1850
- Path(args.change_dir),
1851
- Path(args.staging_dir),
1852
- change_name=args.change,
1853
- run_id=args.run_id,
1854
- reason=args.reason,
1855
- )
1856
- else:
1857
- result = finalize_plan(
1858
- Path(args.change_dir),
1859
- Path(args.staging_dir),
1860
- change_name=args.change,
1861
- run_id=args.run_id,
1862
- attempt=args.attempt,
1863
- )
1006
+ result = verify_plan(Path(args.change_dir))
1864
1007
  stream = sys.stdout if result["ok"] else sys.stderr
1865
1008
  if args.json:
1866
1009
  stream.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")