@hunter-harness/workflow-harness 0.2.85 → 0.3.0

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 (282) 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 -9
  9. package/harness/bundles/general/claude-code/harness-plan/checklist.md +19 -91
  10. package/harness/bundles/general/claude-code/harness-plan/protocols.md +3 -1
  11. package/harness/bundles/general/claude-code/harness-plan/reference.md +74 -83
  12. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  15. package/harness/bundles/general/claude-code/harness-review/protocols.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-run/SKILL.md +13 -5
  17. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  18. package/harness/bundles/general/claude-code/harness-submit/checklist.md +1 -1
  19. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  20. package/harness/bundles/general/claude-code/harness-test/SKILL.md +11 -2
  21. package/harness/bundles/general/claude-code/scripts/harness_adoption_metrics.py +391 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_archive.py +23 -14
  23. package/harness/bundles/general/claude-code/scripts/harness_change.py +25 -7
  24. package/harness/bundles/general/claude-code/scripts/harness_context.py +154 -60
  25. package/harness/bundles/general/claude-code/scripts/harness_events.py +1 -1
  26. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +1 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +14 -10
  28. package/harness/bundles/general/claude-code/scripts/harness_gate.py +293 -90
  29. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +47 -2
  30. package/harness/bundles/general/claude-code/scripts/harness_paths.py +175 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_phase.py +65 -23
  32. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +140 -874
  33. package/harness/bundles/general/claude-code/scripts/harness_review.py +25 -4
  34. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  35. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +46 -17
  36. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +2 -2
  37. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +2 -2
  38. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +170 -0
  39. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
  40. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +2 -2
  41. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +10 -9
  42. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +19 -91
  43. package/harness/bundles/general/codebuddy/harness-plan/protocols.md +3 -1
  44. package/harness/bundles/general/codebuddy/harness-plan/reference.md +74 -83
  45. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  47. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  48. package/harness/bundles/general/codebuddy/harness-review/protocols.md +1 -1
  49. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +13 -5
  50. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  51. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +1 -1
  52. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  53. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +11 -2
  54. package/harness/bundles/general/codebuddy/scripts/harness_adoption_metrics.py +391 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +23 -14
  56. package/harness/bundles/general/codebuddy/scripts/harness_change.py +25 -7
  57. package/harness/bundles/general/codebuddy/scripts/harness_context.py +154 -60
  58. package/harness/bundles/general/codebuddy/scripts/harness_events.py +1 -1
  59. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +1 -0
  60. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +14 -10
  61. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +293 -90
  62. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +47 -2
  63. package/harness/bundles/general/codebuddy/scripts/harness_paths.py +175 -0
  64. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +65 -23
  65. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +140 -874
  66. package/harness/bundles/general/codebuddy/scripts/harness_review.py +25 -4
  67. package/harness/bundles/general/codex/.harness-build.json +1 -1
  68. package/harness/bundles/general/codex/contracts/workflow-policy.json +46 -17
  69. package/harness/bundles/general/codex/harness-archive/SKILL.md +2 -2
  70. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +2 -2
  71. package/harness/bundles/general/codex/harness-execute/SKILL.md +170 -0
  72. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +2 -2
  73. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +2 -2
  74. package/harness/bundles/general/codex/harness-plan/SKILL.md +10 -9
  75. package/harness/bundles/general/codex/harness-plan/checklist.md +19 -91
  76. package/harness/bundles/general/codex/harness-plan/protocols.md +3 -1
  77. package/harness/bundles/general/codex/harness-plan/reference.md +74 -83
  78. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  79. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  80. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
  81. package/harness/bundles/general/codex/harness-review/protocols.md +1 -1
  82. package/harness/bundles/general/codex/harness-run/SKILL.md +13 -5
  83. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  84. package/harness/bundles/general/codex/harness-submit/checklist.md +1 -1
  85. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  86. package/harness/bundles/general/codex/harness-test/SKILL.md +11 -2
  87. package/harness/bundles/general/codex/scripts/harness_adoption_metrics.py +391 -0
  88. package/harness/bundles/general/codex/scripts/harness_archive.py +23 -14
  89. package/harness/bundles/general/codex/scripts/harness_change.py +25 -7
  90. package/harness/bundles/general/codex/scripts/harness_context.py +154 -60
  91. package/harness/bundles/general/codex/scripts/harness_events.py +1 -1
  92. package/harness/bundles/general/codex/scripts/harness_events_sync.py +1 -0
  93. package/harness/bundles/general/codex/scripts/harness_fixback.py +14 -10
  94. package/harness/bundles/general/codex/scripts/harness_gate.py +293 -90
  95. package/harness/bundles/general/codex/scripts/harness_ledger.py +47 -2
  96. package/harness/bundles/general/codex/scripts/harness_paths.py +175 -0
  97. package/harness/bundles/general/codex/scripts/harness_phase.py +65 -23
  98. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +140 -874
  99. package/harness/bundles/general/codex/scripts/harness_review.py +25 -4
  100. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  101. package/harness/bundles/general/cursor/contracts/workflow-policy.json +46 -17
  102. package/harness/bundles/general/cursor/harness-archive/SKILL.md +2 -2
  103. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +2 -2
  104. package/harness/bundles/general/cursor/harness-execute/SKILL.md +170 -0
  105. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +2 -2
  106. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +2 -2
  107. package/harness/bundles/general/cursor/harness-plan/SKILL.md +10 -9
  108. package/harness/bundles/general/cursor/harness-plan/checklist.md +19 -91
  109. package/harness/bundles/general/cursor/harness-plan/protocols.md +3 -1
  110. package/harness/bundles/general/cursor/harness-plan/reference.md +74 -83
  111. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  112. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  113. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
  114. package/harness/bundles/general/cursor/harness-review/protocols.md +1 -1
  115. package/harness/bundles/general/cursor/harness-run/SKILL.md +13 -5
  116. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  117. package/harness/bundles/general/cursor/harness-submit/checklist.md +1 -1
  118. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  119. package/harness/bundles/general/cursor/harness-test/SKILL.md +11 -2
  120. package/harness/bundles/general/cursor/scripts/harness_adoption_metrics.py +391 -0
  121. package/harness/bundles/general/cursor/scripts/harness_archive.py +23 -14
  122. package/harness/bundles/general/cursor/scripts/harness_change.py +25 -7
  123. package/harness/bundles/general/cursor/scripts/harness_context.py +154 -60
  124. package/harness/bundles/general/cursor/scripts/harness_events.py +1 -1
  125. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +1 -0
  126. package/harness/bundles/general/cursor/scripts/harness_fixback.py +14 -10
  127. package/harness/bundles/general/cursor/scripts/harness_gate.py +293 -90
  128. package/harness/bundles/general/cursor/scripts/harness_ledger.py +47 -2
  129. package/harness/bundles/general/cursor/scripts/harness_paths.py +175 -0
  130. package/harness/bundles/general/cursor/scripts/harness_phase.py +65 -23
  131. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +140 -874
  132. package/harness/bundles/general/cursor/scripts/harness_review.py +25 -4
  133. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  134. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +46 -17
  135. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
  136. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +2 -2
  137. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +2 -2
  138. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +189 -0
  139. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +2 -2
  140. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +2 -2
  141. package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -2
  142. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +10 -9
  143. package/harness/bundles/java/claude-code/harness-plan/checklist.md +19 -91
  144. package/harness/bundles/java/claude-code/harness-plan/protocols.md +3 -1
  145. package/harness/bundles/java/claude-code/harness-plan/reference.md +74 -83
  146. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  147. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  148. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  149. package/harness/bundles/java/claude-code/harness-review/protocols.md +1 -1
  150. package/harness/bundles/java/claude-code/harness-run/SKILL.md +13 -5
  151. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  152. package/harness/bundles/java/claude-code/harness-submit/checklist.md +1 -1
  153. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  154. package/harness/bundles/java/claude-code/harness-test/SKILL.md +11 -2
  155. package/harness/bundles/java/claude-code/scripts/harness_adoption_metrics.py +391 -0
  156. package/harness/bundles/java/claude-code/scripts/harness_archive.py +23 -14
  157. package/harness/bundles/java/claude-code/scripts/harness_change.py +25 -7
  158. package/harness/bundles/java/claude-code/scripts/harness_context.py +154 -60
  159. package/harness/bundles/java/claude-code/scripts/harness_events.py +1 -1
  160. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +1 -0
  161. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +14 -10
  162. package/harness/bundles/java/claude-code/scripts/harness_gate.py +293 -90
  163. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +47 -2
  164. package/harness/bundles/java/claude-code/scripts/harness_paths.py +175 -0
  165. package/harness/bundles/java/claude-code/scripts/harness_phase.py +65 -23
  166. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +140 -874
  167. package/harness/bundles/java/claude-code/scripts/harness_review.py +25 -4
  168. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  169. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +46 -17
  170. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
  171. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +2 -2
  172. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +2 -2
  173. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +170 -0
  174. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
  175. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +2 -2
  176. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
  177. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +10 -9
  178. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +19 -91
  179. package/harness/bundles/java/codebuddy/harness-plan/protocols.md +3 -1
  180. package/harness/bundles/java/codebuddy/harness-plan/reference.md +74 -83
  181. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  182. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  183. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  184. package/harness/bundles/java/codebuddy/harness-review/protocols.md +1 -1
  185. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +13 -5
  186. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  187. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +1 -1
  188. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  189. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +11 -2
  190. package/harness/bundles/java/codebuddy/scripts/harness_adoption_metrics.py +391 -0
  191. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +23 -14
  192. package/harness/bundles/java/codebuddy/scripts/harness_change.py +25 -7
  193. package/harness/bundles/java/codebuddy/scripts/harness_context.py +154 -60
  194. package/harness/bundles/java/codebuddy/scripts/harness_events.py +1 -1
  195. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +1 -0
  196. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +14 -10
  197. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +293 -90
  198. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +47 -2
  199. package/harness/bundles/java/codebuddy/scripts/harness_paths.py +175 -0
  200. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +65 -23
  201. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +140 -874
  202. package/harness/bundles/java/codebuddy/scripts/harness_review.py +25 -4
  203. package/harness/bundles/java/codex/.harness-build.json +1 -1
  204. package/harness/bundles/java/codex/contracts/workflow-policy.json +46 -17
  205. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
  206. package/harness/bundles/java/codex/harness-archive/SKILL.md +2 -2
  207. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +2 -2
  208. package/harness/bundles/java/codex/harness-execute/SKILL.md +170 -0
  209. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +2 -2
  210. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +2 -2
  211. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
  212. package/harness/bundles/java/codex/harness-plan/SKILL.md +10 -9
  213. package/harness/bundles/java/codex/harness-plan/checklist.md +19 -91
  214. package/harness/bundles/java/codex/harness-plan/protocols.md +3 -1
  215. package/harness/bundles/java/codex/harness-plan/reference.md +74 -83
  216. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  217. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  218. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
  219. package/harness/bundles/java/codex/harness-review/protocols.md +1 -1
  220. package/harness/bundles/java/codex/harness-run/SKILL.md +13 -5
  221. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  222. package/harness/bundles/java/codex/harness-submit/checklist.md +1 -1
  223. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-test/SKILL.md +11 -2
  225. package/harness/bundles/java/codex/scripts/harness_adoption_metrics.py +391 -0
  226. package/harness/bundles/java/codex/scripts/harness_archive.py +23 -14
  227. package/harness/bundles/java/codex/scripts/harness_change.py +25 -7
  228. package/harness/bundles/java/codex/scripts/harness_context.py +154 -60
  229. package/harness/bundles/java/codex/scripts/harness_events.py +1 -1
  230. package/harness/bundles/java/codex/scripts/harness_events_sync.py +1 -0
  231. package/harness/bundles/java/codex/scripts/harness_fixback.py +14 -10
  232. package/harness/bundles/java/codex/scripts/harness_gate.py +293 -90
  233. package/harness/bundles/java/codex/scripts/harness_ledger.py +47 -2
  234. package/harness/bundles/java/codex/scripts/harness_paths.py +175 -0
  235. package/harness/bundles/java/codex/scripts/harness_phase.py +65 -23
  236. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +140 -874
  237. package/harness/bundles/java/codex/scripts/harness_review.py +25 -4
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/contracts/workflow-policy.json +46 -17
  240. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
  241. package/harness/bundles/java/cursor/harness-archive/SKILL.md +2 -2
  242. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +2 -2
  243. package/harness/bundles/java/cursor/harness-execute/SKILL.md +170 -0
  244. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +2 -2
  245. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +2 -2
  246. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
  247. package/harness/bundles/java/cursor/harness-plan/SKILL.md +10 -9
  248. package/harness/bundles/java/cursor/harness-plan/checklist.md +19 -91
  249. package/harness/bundles/java/cursor/harness-plan/protocols.md +3 -1
  250. package/harness/bundles/java/cursor/harness-plan/reference.md +74 -83
  251. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
  254. package/harness/bundles/java/cursor/harness-review/protocols.md +1 -1
  255. package/harness/bundles/java/cursor/harness-run/SKILL.md +13 -5
  256. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  257. package/harness/bundles/java/cursor/harness-submit/checklist.md +1 -1
  258. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  259. package/harness/bundles/java/cursor/harness-test/SKILL.md +11 -2
  260. package/harness/bundles/java/cursor/scripts/harness_adoption_metrics.py +391 -0
  261. package/harness/bundles/java/cursor/scripts/harness_archive.py +23 -14
  262. package/harness/bundles/java/cursor/scripts/harness_change.py +25 -7
  263. package/harness/bundles/java/cursor/scripts/harness_context.py +154 -60
  264. package/harness/bundles/java/cursor/scripts/harness_events.py +1 -1
  265. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +1 -0
  266. package/harness/bundles/java/cursor/scripts/harness_fixback.py +14 -10
  267. package/harness/bundles/java/cursor/scripts/harness_gate.py +293 -90
  268. package/harness/bundles/java/cursor/scripts/harness_ledger.py +47 -2
  269. package/harness/bundles/java/cursor/scripts/harness_paths.py +175 -0
  270. package/harness/bundles/java/cursor/scripts/harness_phase.py +65 -23
  271. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +140 -874
  272. package/harness/bundles/java/cursor/scripts/harness_review.py +25 -4
  273. package/harness/manifests/general/claude-code.json +40 -32
  274. package/harness/manifests/general/codebuddy.json +40 -32
  275. package/harness/manifests/general/codex.json +40 -32
  276. package/harness/manifests/general/cursor.json +40 -32
  277. package/harness/manifests/java/claude-code.json +42 -34
  278. package/harness/manifests/java/codebuddy.json +42 -34
  279. package/harness/manifests/java/codex.json +42 -34
  280. package/harness/manifests/java/cursor.json +42 -34
  281. package/hunter-workflow-family.json +4 -4
  282. 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,
@@ -261,6 +269,127 @@ def scenario_manifest_schema_version(scenarios: list[dict[str, Any]]) -> int:
261
269
  return 1
262
270
 
263
271
 
272
+ # v2 场景键 → 门禁与 ledger 消费的 legacy 键。可执行三元是可选的:缺了只是把
273
+ # manifest 降到 schemaVersion 1(绑不上结构化执行收据),不算缺口。
274
+ _V2_SCENARIO_FIELD_MAP = (
275
+ ("scenario_id", "id"),
276
+ ("priority", "priority"),
277
+ ("required_evidence_kind", "requiredEvidenceKind"),
278
+ ("owner_phase", "ownerPhase"),
279
+ )
280
+ _V2_SCENARIO_OPTIONAL_FIELD_MAP = (
281
+ ("executable_test_id", "executableTestId"),
282
+ ("test_file", "testFile"),
283
+ ("test_title", "testTitle"),
284
+ )
285
+ V2_MANIFEST_UNSUPPORTED = "SCENARIO_MANIFEST_V2_UNSUPPORTED"
286
+
287
+
288
+ def is_v2_scenario_manifest(manifest: Any) -> bool:
289
+ """v2 plan finalize 派生的 scenario_manifest artifact 包装体。
290
+
291
+ 它没有顶层 schemaVersion,场景字段全在 content.scenarios 下且改了名。
292
+ """
293
+ return (
294
+ isinstance(manifest, dict)
295
+ and manifest.get("artifact_type") == "scenario_manifest"
296
+ )
297
+
298
+
299
+ def unpack_v2_scenario_manifest(manifest: Any) -> dict[str, Any] | None:
300
+ """v2 artifact 包装体 → 消费端的 legacy 形状;字段不齐时 fail-closed。
301
+
302
+ 返回 None 表示"这不是 v2 包装体",调用方按 legacy 原样处理。否则返回
303
+ ``{"ok": True, "manifest": {...}}``(已是 legacy 形状,后续判定逻辑对
304
+ v2 与 legacy 完全一致)或 ``{"ok": False, "code": V2_MANIFEST_UNSUPPORTED}``。
305
+
306
+ 这个函数住在 finalizer 里,是因为 legacy manifest 的 schema 本来就由本模块
307
+ 定义(``scenario_manifest_schema_version`` / ``PRIORITY_EVIDENCE_KIND``)。
308
+ 门禁与 ledger 都 import 它,两边不会各推一套解包规则。
309
+
310
+ 为什么必须逐场景校验、不能取键的并集:并集只要有**一条**场景带了
311
+ priority 就算"present",其余缺 priority 的场景会静默落进非必需集,
312
+ ``required_ids`` 随之缩水——那正是把证据门禁悄悄关掉的老路子。
313
+ """
314
+ if not is_v2_scenario_manifest(manifest):
315
+ return None
316
+ content = manifest.get("content")
317
+ raw = content.get("scenarios") if isinstance(content, dict) else None
318
+ if not isinstance(raw, list):
319
+ return {
320
+ "ok": False,
321
+ "code": V2_MANIFEST_UNSUPPORTED,
322
+ "message": "v2 scenario-manifest 的 content.scenarios 必须是数组",
323
+ "missingFields": [legacy for _, legacy in _V2_SCENARIO_FIELD_MAP],
324
+ "artifactType": "scenario_manifest",
325
+ }
326
+ scenarios: list[dict[str, Any]] = []
327
+ missing: set[str] = set()
328
+ for item in raw:
329
+ if not isinstance(item, dict):
330
+ missing.update(legacy for _, legacy in _V2_SCENARIO_FIELD_MAP)
331
+ continue
332
+ mapped: dict[str, Any] = {}
333
+ for source, legacy in _V2_SCENARIO_FIELD_MAP:
334
+ value = item.get(source)
335
+ if not str(value or "").strip():
336
+ missing.add(legacy)
337
+ continue
338
+ mapped[legacy] = value
339
+ for source, legacy in _V2_SCENARIO_OPTIONAL_FIELD_MAP:
340
+ value = item.get(source)
341
+ if str(value or "").strip():
342
+ mapped[legacy] = value
343
+ scenarios.append(mapped)
344
+ if missing:
345
+ return {
346
+ "ok": False,
347
+ "code": V2_MANIFEST_UNSUPPORTED,
348
+ "message": (
349
+ "meta/scenario-manifest.json 是 v2 plan artifact 包装体,"
350
+ "部分场景缺少消费端需要的字段;请在规划阶段补齐后重新发布,"
351
+ "不要手改派生产物"
352
+ ),
353
+ "missingFields": sorted(missing),
354
+ "artifactType": "scenario_manifest",
355
+ }
356
+ return {
357
+ "ok": True,
358
+ "manifest": {
359
+ # 与 legacy 同一条规则判版本,不另立一套。
360
+ "schemaVersion": scenario_manifest_schema_version(scenarios),
361
+ "scenarios": scenarios,
362
+ },
363
+ }
364
+
365
+
366
+ def unpack_v2_implementation_checkpoints(document: Any) -> dict[str, Any] | None:
367
+ """v2 的 implementation_checkpoints artifact 包装体 → 门禁消费的形状。
368
+
369
+ 返回 None 表示"这不是 v2 包装体",调用方按原样处理。
370
+
371
+ v2 的 content 是 ``{tasks, foundation_gate}``,而 ``checkpoint_status`` 找的是
372
+ ``checkpoints: [{id, status}]``——找不到就返回 "missing",``foundation_gate_blocks``
373
+ 随即放行。也就是说 foundation-gate 对**所有 v2 计划**是静默关闭的。信息本身是够
374
+ 的(``foundation_gate`` 就是那个状态),只是形状不同,所以这里解包而不是改文件名
375
+ ——与 scenario-manifest 同一模式。
376
+ """
377
+ if (
378
+ not isinstance(document, dict)
379
+ or document.get("artifact_type") != "implementation_checkpoints"
380
+ ):
381
+ return None
382
+ content = document.get("content")
383
+ content = content if isinstance(content, dict) else {}
384
+ status = str(content.get("foundation_gate") or "").strip() or "pending"
385
+ tasks = content.get("tasks")
386
+ return {
387
+ "schemaVersion": 1,
388
+ "checkpoints": [{"id": "foundation-gate", "status": status, "blocking": True}],
389
+ "tasks": tasks if isinstance(tasks, list) else [],
390
+ }
391
+
392
+
264
393
  def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
265
394
  """C8: parse plan.md task table rows, extracting optional ownerPhase/implementationDoneWhen/verificationPhase columns.
266
395
 
@@ -334,21 +463,6 @@ def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
334
463
  return tasks
335
464
 
336
465
 
337
- def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
338
- path.parent.mkdir(parents=True, exist_ok=True)
339
- fd, raw_tmp = tempfile.mkstemp(
340
- prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
341
- )
342
- tmp = Path(raw_tmp)
343
- try:
344
- with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
345
- json.dump(payload, handle, ensure_ascii=False, indent=2)
346
- handle.write("\n")
347
- handle.flush()
348
- os.fsync(handle.fileno())
349
- os.replace(tmp, path)
350
- finally:
351
- tmp.unlink(missing_ok=True)
352
466
 
353
467
 
354
468
  def _frontmatter(text: str) -> dict[str, str] | None:
@@ -388,362 +502,17 @@ def _is_link_or_reparse(path: Path) -> bool:
388
502
  return bool(attributes & _FILE_ATTRIBUTE_REPARSE_POINT)
389
503
 
390
504
 
391
- def lint_slice_plan(plan_path: Path) -> dict[str, Any]:
392
- """Validate the identity, reuse, and storage contract for a slice plan."""
393
- text = Path(plan_path).read_text(encoding="utf-8-sig")
394
- frontmatter = _frontmatter(text)
395
- if frontmatter is None:
396
- return _result_error(
397
- "SLICE_PLAN_CONTRACT_INVALID",
398
- f"{plan_path.name}: frontmatter is required",
399
- )
400
- enabled = (
401
- str(frontmatter.get("slice-plan") or "").strip().lower()
402
- in {"true", "yes", "1"}
403
- or "candidate-type" in frontmatter
404
- )
405
- if not enabled:
406
- return {
407
- "ok": True,
408
- "configured": False,
409
- "code": "SLICE_PLAN_NOT_CONFIGURED",
410
- }
411
505
 
412
- issues: list[str] = []
413
- candidate_type = str(frontmatter.get("candidate-type") or "").strip()
414
- aggregate_parent = str(frontmatter.get("aggregate-parent") or "").strip()
415
- evidence_reuse = str(frontmatter.get("evidence-reuse") or "").strip()
416
- raw_budget = str(frontmatter.get("artifact-budget-bytes") or "").strip()
417
- if candidate_type not in SLICE_CANDIDATE_TYPES:
418
- issues.append(
419
- "candidate-type must be one of "
420
- + ", ".join(sorted(SLICE_CANDIDATE_TYPES))
421
- )
422
- if not aggregate_parent:
423
- issues.append(
424
- "aggregate-parent is required (use 'none' for an independent candidate)"
425
- )
426
- if not evidence_reuse:
427
- issues.append(
428
- "evidence-reuse is required (use 'none' when no evidence is reused)"
429
- )
430
- try:
431
- artifact_budget = int(raw_budget)
432
- if artifact_budget <= 0:
433
- raise ValueError
434
- except ValueError:
435
- artifact_budget = 0
436
- issues.append("artifact-budget-bytes must be a positive integer")
437
- if issues:
438
- return {
439
- "ok": False,
440
- "code": "SLICE_PLAN_CONTRACT_INVALID",
441
- "error": f"{plan_path.name}: " + "; ".join(issues),
442
- "issues": issues,
443
- }
444
- return {
445
- "ok": True,
446
- "configured": True,
447
- "code": "SLICE_PLAN_CONTRACT_OK",
448
- "candidateType": candidate_type,
449
- "aggregateParent": aggregate_parent,
450
- "evidenceReuse": evidence_reuse,
451
- "artifactBudgetBytes": artifact_budget,
452
- }
453
506
 
454
507
 
455
- def _artifact_files(staging: Path) -> list[Path]:
456
- files: list[Path] = []
457
- for path in staging.rglob("*"):
458
- if _is_link_or_reparse(path):
459
- raise ValueError(f"PLAN_ARTIFACT_SYMLINK: {path}")
460
- if path.is_file():
461
- files.append(path)
462
- return sorted(files, key=lambda item: item.relative_to(staging).as_posix())
463
508
 
464
509
 
465
- def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
466
- staging = staging.resolve()
467
- required = {Path(name) for name in _required_artifact_names(change_name)}
468
- try:
469
- files = _artifact_files(staging)
470
- except ValueError as exc:
471
- code = str(exc).split(":", 1)[0]
472
- return _result_error(code, str(exc))
473
- rel_files = {path.relative_to(staging) for path in files}
474
- missing = sorted(path.as_posix() for path in required - rel_files)
475
- if missing:
476
- return _result_error(
477
- "PLAN_ARTIFACT_MISSING", "missing required artifacts: " + ", ".join(missing)
478
- )
479
510
 
480
- for path in files:
481
- rel = path.relative_to(staging)
482
- if rel.parts[0] not in {"spec", "plans", "meta"}:
483
- return _result_error(
484
- "PLAN_ARTIFACT_PATH_INVALID", f"unexpected artifact root: {rel.as_posix()}"
485
- )
486
- if path.suffix.lower() == ".json":
487
- try:
488
- payload = json.loads(path.read_text(encoding="utf-8-sig"))
489
- except (OSError, json.JSONDecodeError) as exc:
490
- return _result_error(
491
- "PLAN_ARTIFACT_INVALID_JSON", f"{rel.as_posix()}: {exc}"
492
- )
493
- if not isinstance(payload, dict):
494
- return _result_error(
495
- "PLAN_ARTIFACT_INVALID_JSON",
496
- f"{rel.as_posix()}: top-level JSON must be an object",
497
- )
498
- elif path.suffix.lower() == ".md":
499
- try:
500
- text = path.read_text(encoding="utf-8-sig")
501
- except OSError as exc:
502
- return _result_error("PLAN_ARTIFACT_UNREADABLE", str(exc))
503
- frontmatter = _frontmatter(text)
504
- if frontmatter is None or frontmatter.get("change-name") != change_name:
505
- return _result_error(
506
- "PLAN_ARTIFACT_FRONTMATTER_INVALID",
507
- f"{rel.as_posix()}: change-name frontmatter mismatch",
508
- )
509
- for raw_link in _LINK.findall(text):
510
- link = raw_link.strip().split("#", 1)[0]
511
- if not link or "://" in link or link.startswith("mailto:"):
512
- continue
513
- target = (path.parent / link).resolve()
514
- try:
515
- target.relative_to(staging)
516
- except ValueError:
517
- return _result_error(
518
- "PLAN_ARTIFACT_REFERENCE_INVALID",
519
- f"{rel.as_posix()}: reference escapes staging: {raw_link}",
520
- )
521
- if not target.is_file():
522
- return _result_error(
523
- "PLAN_ARTIFACT_REFERENCE_MISSING",
524
- f"{rel.as_posix()}: missing reference {raw_link}",
525
- )
526
511
 
527
- # C8: validate ownerPhase values in plan.md task table.
528
- plan_path = staging / "plans" / f"{change_name}-plan.md"
529
- try:
530
- tasks = parse_plan_tasks(plan_path)
531
- except PlanParseError as exc:
532
- return _result_error(exc.code, str(exc))
533
- if not tasks:
534
- return _result_error(
535
- "PLAN_TASKS_EMPTY",
536
- f"{plan_path.relative_to(staging).as_posix()}: no task rows were parsed",
537
- )
538
- task_ids = [str(task.get("num") or "").strip() for task in tasks]
539
- duplicate_task_ids = sorted(
540
- {task_id for task_id in task_ids if task_ids.count(task_id) > 1}
541
- )
542
- if duplicate_task_ids:
543
- return _result_error(
544
- "PLAN_TASK_ID_DUPLICATE",
545
- "duplicate task IDs: " + ", ".join(duplicate_task_ids),
546
- )
547
- for task in tasks:
548
- owner = task.get("ownerPhase")
549
- if owner is not None and owner != "" and owner not in VALID_OWNER_PHASES:
550
- return _result_error(
551
- "PLAN_OWNER_PHASE_INVALID",
552
- f"task {task.get('num', '?')}: ownerPhase '{owner}' not in {sorted(VALID_OWNER_PHASES)}",
553
- )
554
- slice_plan = lint_slice_plan(plan_path)
555
- if not slice_plan.get("ok"):
556
- return slice_plan
557
512
 
558
- # C9: parse test-scenarios.md for scenario manifest.
559
- scenarios_path = staging / "plans" / f"{change_name}-test-scenarios.md"
560
- try:
561
- scenarios = parse_test_scenarios(scenarios_path)
562
- except PlanParseError as exc:
563
- return _result_error(exc.code, str(exc))
564
- if not scenarios:
565
- return _result_error(
566
- "PLAN_SCENARIOS_EMPTY",
567
- f"{scenarios_path.relative_to(staging).as_posix()}: no scenario rows were parsed",
568
- )
569
- scenario_ids = [str(item.get("id") or "").strip() for item in scenarios]
570
- duplicate_scenario_ids = sorted(
571
- {
572
- scenario_id
573
- for scenario_id in scenario_ids
574
- if scenario_ids.count(scenario_id) > 1
575
- }
576
- )
577
- if duplicate_scenario_ids:
578
- return _result_error(
579
- "PLAN_SCENARIO_ID_DUPLICATE",
580
- "duplicate scenario IDs: " + ", ".join(duplicate_scenario_ids),
581
- )
582
- for scenario in scenarios:
583
- priority = str(scenario.get("priority") or "").upper()
584
- if priority not in PRIORITY_EVIDENCE_KIND:
585
- return _result_error(
586
- "PLAN_SCENARIO_PRIORITY_INVALID",
587
- f"scenario {scenario.get('id', '?')}: unsupported priority '{priority}'",
588
- )
589
- owner = str(scenario.get("ownerPhase") or "")
590
- if owner not in VALID_OWNER_PHASES:
591
- return _result_error(
592
- "PLAN_SCENARIO_OWNER_PHASE_INVALID",
593
- f"scenario {scenario.get('id', '?')}: unsupported ownerPhase '{owner}'",
594
- )
595
- if (
596
- scenario.get("requiredEvidenceKind") == "ledger"
597
- and scenario.get("executableMappingDeclared")
598
- ):
599
- missing_mapping = [
600
- field
601
- for field in ("executableTestId", "testFile", "testTitle")
602
- if not str(scenario.get(field) or "").strip()
603
- ]
604
- if missing_mapping:
605
- return _result_error(
606
- "PLAN_SCENARIO_EXECUTABLE_MAPPING_MISSING",
607
- f"scenario {scenario.get('id', '?')}: missing executable mapping fields: "
608
- + ", ".join(missing_mapping),
609
- )
610
513
 
611
- digest = hashlib.sha256()
612
- artifact_names: list[str] = []
613
- for path in files:
614
- rel = path.relative_to(staging).as_posix()
615
- artifact_names.append(rel)
616
- digest.update(rel.encode("utf-8"))
617
- digest.update(b"\0")
618
- digest.update(path.read_bytes())
619
- digest.update(b"\0")
620
- return {
621
- "ok": True,
622
- "files": artifact_names,
623
- "artifactsHash": "sha256:" + digest.hexdigest(),
624
- "tasks": tasks,
625
- "scenarios": scenarios,
626
- "slicePlan": slice_plan,
627
- }
628
514
 
629
515
 
630
- def validate_product_ownership(change_dir: Path, staging: Path) -> dict[str, Any]:
631
- """Ensure Plan declares stable prefix ownership for explicit code/test/build paths."""
632
- context_path = change_dir / "meta" / "change-context.json"
633
- if not context_path.is_file():
634
- return {"ok": True, "code": "PLAN_PRODUCT_PATHS_LEGACY_UNDECLARED"}
635
- try:
636
- context = json.loads(context_path.read_text(encoding="utf-8-sig"))
637
- except (OSError, json.JSONDecodeError) as exc:
638
- return _result_error("PLAN_CHANGE_CONTEXT_INVALID", str(exc))
639
- ownership = context.get("ownership") if isinstance(context, dict) else None
640
- raw_paths = ownership.get("productPaths") if isinstance(ownership, dict) else None
641
- product_paths = [
642
- str(item).strip().replace("\\", "/").removeprefix("./")
643
- for item in raw_paths or []
644
- if isinstance(item, str) and item.strip()
645
- ]
646
- unsupported = sorted(path for path in product_paths if any(char in path for char in "*?[]"))
647
- if unsupported:
648
- result = _result_error(
649
- "PLAN_PRODUCT_PATHS_GLOB_UNSUPPORTED",
650
- "productPaths 只接受精确文件或目录前缀,不支持通配:" + ", ".join(unsupported),
651
- )
652
- result["unsupportedPaths"] = unsupported
653
- return result
654
-
655
- planned_paths: set[str] = set()
656
- for document in sorted(staging.rglob("*.md")):
657
- text = document.read_text(encoding="utf-8-sig")
658
- for raw in _BACKTICK.findall(text):
659
- candidate = raw.strip().strip("'\"").replace("\\", "/")
660
- if not candidate or " " in candidate or "://" in candidate or "<" in candidate:
661
- continue
662
- candidate = re.sub(r":\d+(?::\d+)?$", "", candidate)
663
- candidate = candidate.removeprefix("./").strip("/.,;,;")
664
- if not candidate or candidate.startswith("../") or candidate.startswith(".harness/"):
665
- continue
666
- pure = PurePosixPath(candidate)
667
- if pure.name.lower() in _BUILD_FILE_NAMES or pure.suffix.lower() in _CODE_PATH_SUFFIXES:
668
- planned_paths.add(candidate)
669
-
670
- def covered(path: str) -> bool:
671
- return any(
672
- path == owned.rstrip("/")
673
- or path.startswith(owned.rstrip("/") + "/")
674
- for owned in product_paths
675
- if owned.rstrip("/")
676
- )
677
-
678
- missing = sorted(path for path in planned_paths if not covered(path))
679
- if missing:
680
- result = _result_error(
681
- "PLAN_PRODUCT_PATHS_INCOMPLETE",
682
- "productPaths 未覆盖计划明确引用的产品文件:" + ", ".join(missing),
683
- )
684
- result["missingPaths"] = missing
685
- result["recoveryAction"] = "在 Plan 阶段补充精确文件或目录前缀后重新 finalize。"
686
- return result
687
- return {
688
- "ok": True,
689
- "code": "PLAN_PRODUCT_PATHS_COMPLETE",
690
- "plannedPaths": sorted(planned_paths),
691
- "productPaths": product_paths,
692
- }
693
-
694
-
695
- def _terminal_exists(change_dir: Path, run_id: str, attempt: int) -> bool:
696
- try:
697
- events = harness_events.load_events(harness_events.events_path(change_dir))
698
- except (OSError, ValueError):
699
- return False
700
- return any(
701
- event.get("phase") == "plan"
702
- and event.get("type") == "phase.end"
703
- and event.get("run_id") == run_id
704
- and event.get("attempt") == attempt
705
- and str(event.get("status") or "").upper() == "OK"
706
- for event in events
707
- )
708
-
709
-
710
- def _validate_plan_start(
711
- change_dir: Path,
712
- run_id: str,
713
- attempt: int,
714
- ) -> dict[str, Any]:
715
- events_path = harness_events.events_path(change_dir)
716
- try:
717
- events = harness_events.load_events(events_path)
718
- except (OSError, ValueError) as exc:
719
- return _result_error("EVENTS_PARSE_ERROR", str(exc))
720
- matching = [
721
- event
722
- for event in events
723
- if event.get("phase") == "plan"
724
- and event.get("type") == "phase.start"
725
- and event.get("run_id") == run_id
726
- and event.get("attempt") == attempt
727
- ]
728
- if not matching:
729
- return _result_error(
730
- "PHASE_START_MISSING",
731
- "no matching plan phase.start event found for finalizer runId/attempt; "
732
- "append one with the same run id and attempt before finalizing, or use "
733
- "the `republish` subcommand which allocates and appends both for you",
734
- )
735
- if len(matching) > 1:
736
- return _result_error(
737
- "PHASE_START_DUPLICATE",
738
- f"found {len(matching)} matching plan phase.start events for run id "
739
- f"{run_id}. `harness_gate.py begin` already appends phase.start; a "
740
- "second manual append of the same run id creates this state. Recover "
741
- "with a fresh run id and the next attempt number (attempts are global "
742
- "per phase, not per run id): append phase.start with both, then "
743
- "finalize with the same pair.",
744
- )
745
- return {"ok": True, "phaseStartCount": 1}
746
-
747
516
 
748
517
  def _receipt_artifact_targets(
749
518
  change_dir: Path,
@@ -877,7 +646,9 @@ def _verify_plan_v2(change_dir: Path) -> dict[str, Any] | None:
877
646
  f"plans/{change_dir.name}-plan.md",
878
647
  f"plans/{change_dir.name}-implementation-detail.md",
879
648
  f"plans/{change_dir.name}-test-scenarios.md",
880
- "meta/gate-policy.json",
649
+ # v2 发布的是派生视图 meta/plan-profile.json,不是 Python classify 写的
650
+ # meta/gate-policy.json——后者是 run/test 门禁的权威输入,不能被发布覆盖。
651
+ "meta/plan-profile.json",
881
652
  "meta/worktree.json",
882
653
  }
883
654
  missing_required = sorted(required - set(ownership_paths))
@@ -1203,515 +974,27 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
1203
974
  }
1204
975
 
1205
976
 
1206
- def _read_design_capabilities(staging: Path, change_name: str) -> list[str]:
1207
- """Read capabilities from design frontmatter (retro §5.4)."""
1208
- design_path = staging / "spec" / f"{change_name}-design.md"
1209
- if not design_path.is_file():
1210
- return []
1211
- try:
1212
- text = design_path.read_text(encoding="utf-8-sig")
1213
- except OSError:
1214
- return []
1215
- frontmatter = _frontmatter(text)
1216
- if not frontmatter:
1217
- return []
1218
- raw = frontmatter.get("capabilities") or ""
1219
- if not raw:
1220
- return []
1221
- # capabilities may be comma-separated or YAML list
1222
- if raw.startswith("["):
1223
- try:
1224
- parsed = json.loads(raw)
1225
- if isinstance(parsed, list):
1226
- return [str(c) for c in parsed if isinstance(c, str)]
1227
- except json.JSONDecodeError:
1228
- pass
1229
- return [c.strip() for c in raw.split(",") if c.strip()]
1230
-
1231
-
1232
- def _reclassify_gate_policy(
1233
- staging: Path,
1234
- change_name: str,
1235
- ) -> dict[str, Any]:
1236
- """Reclassify gate policy based on approved design capabilities (retro §5.4).
1237
-
1238
- Reads design frontmatter `capabilities`, invokes harness_gate.py classify
1239
- to recompute the gate DAG, and updates staging/meta/gate-policy.json.
1240
- Returns {"ok": bool, "capabilities": [...], "drift": bool}.
1241
- """
1242
- capabilities = _read_design_capabilities(staging, change_name)
1243
- if not capabilities:
1244
- return {"ok": True, "capabilities": [], "drift": False, "updated": False}
1245
-
1246
- gate_policy_path = staging / "meta" / "gate-policy.json"
1247
- try:
1248
- existing = json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
1249
- except (OSError, json.JSONDecodeError):
1250
- existing = {}
1251
977
 
1252
- existing_caps = set(existing.get("capabilities") or [])
1253
- design_caps = set(capabilities)
1254
- drift = existing_caps != design_caps
1255
978
 
1256
- if drift:
1257
- # Update gate-policy.json with design capabilities
1258
- existing["capabilities"] = sorted(design_caps)
1259
- try:
1260
- gate_policy_path.write_text(
1261
- json.dumps(existing, ensure_ascii=False, indent=2) + "\n",
1262
- encoding="utf-8",
1263
- newline="\n",
1264
- )
1265
- except OSError as exc:
1266
- return {
1267
- "ok": False,
1268
- "error": f"failed to update gate-policy.json: {exc}",
1269
- "capabilities": capabilities,
1270
- "drift": True,
1271
- "updated": False,
1272
- }
1273
979
 
1274
- return {
1275
- "ok": True,
1276
- "capabilities": sorted(design_caps),
1277
- "drift": drift,
1278
- "updated": drift,
1279
- }
1280
-
1281
-
1282
- def _append_terminal(change_dir: Path, run_id: str, attempt: int) -> tuple[int, str]:
1283
- stdout = io.StringIO()
1284
- stderr = io.StringIO()
1285
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1286
- code = harness_events.main(
1287
- [
1288
- "append",
1289
- "--change-dir",
1290
- str(change_dir),
1291
- "--phase",
1292
- "plan",
1293
- "--type",
1294
- "phase.end",
1295
- "--status",
1296
- "OK",
1297
- "--reason",
1298
- "plan artifacts validated and published",
1299
- "--run-id",
1300
- run_id,
1301
- "--attempt",
1302
- str(attempt),
1303
- "--json",
1304
- ]
1305
- )
1306
- return code, stderr.getvalue().strip()
1307
-
1308
-
1309
- def _rollback_publish(
1310
- created: list[Path], replaced: list[tuple[Path, bytes]]
1311
- ) -> None:
1312
- """Undo a failed publish: delete new files, restore overwritten ones."""
1313
- for target in reversed(created):
1314
- target.unlink(missing_ok=True)
1315
- for target, original in reversed(replaced):
1316
- try:
1317
- target.write_bytes(original)
1318
- except OSError:
1319
- # Best effort: the caller already reports the publish failure.
1320
- pass
1321
-
1322
-
1323
- def _plan_attempts(change_dir: Path) -> list[int]:
1324
- """Every attempt number already used by a plan lifecycle event."""
1325
- try:
1326
- events = harness_events.load_events(harness_events.events_path(change_dir))
1327
- except (OSError, ValueError):
1328
- return []
1329
- attempts: list[int] = []
1330
- for event in events:
1331
- if event.get("phase") != "plan":
1332
- continue
1333
- value = event.get("attempt")
1334
- if isinstance(value, int) and not isinstance(value, bool):
1335
- attempts.append(value)
1336
- return attempts
1337
-
1338
-
1339
- def _run_id_in_use(change_dir: Path, run_id: str) -> bool:
1340
- try:
1341
- events = harness_events.load_events(harness_events.events_path(change_dir))
1342
- except (OSError, ValueError):
1343
- return False
1344
- return any(event.get("run_id") == run_id for event in events)
1345
-
1346
-
1347
- def _append_plan_start(
1348
- change_dir: Path, run_id: str, attempt: int, reason: str
1349
- ) -> tuple[int, str]:
1350
- stdout = io.StringIO()
1351
- stderr = io.StringIO()
1352
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1353
- code = harness_events.main(
1354
- [
1355
- "append",
1356
- "--change-dir",
1357
- str(change_dir),
1358
- "--phase",
1359
- "plan",
1360
- "--type",
1361
- "phase.start",
1362
- # phase.start accepts only `note` beyond provenance fields.
1363
- "--note",
1364
- reason,
1365
- "--run-id",
1366
- run_id,
1367
- "--attempt",
1368
- str(attempt),
1369
- "--json",
1370
- ]
1371
- )
1372
- return code, stderr.getvalue().strip()
1373
980
 
1374
981
 
1375
- def finalize_plan(
1376
- change_dir: Path,
1377
- staging: Path,
1378
- *,
1379
- change_name: str,
1380
- run_id: str,
1381
- attempt: int,
1382
- allow_amend: bool = False,
1383
- amend_reason: str | None = None,
1384
- ) -> dict[str, Any]:
1385
- change_dir = change_dir.resolve()
1386
- staging = staging.resolve()
1387
-
1388
- # C2 (retro §5.4): reclassify gate policy based on approved design capabilities.
1389
- # This updates staging/meta/gate-policy.json before validation, so the hash
1390
- # reflects the final gate policy. Drift between design capabilities and
1391
- # gate-policy capabilities is resolved in favor of the approved design.
1392
- reclassify = _reclassify_gate_policy(staging, change_name)
1393
- if not reclassify.get("ok"):
1394
- return _result_error("CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed"))
1395
-
1396
- validation = validate_staging(staging, change_name)
1397
- if not validation["ok"]:
1398
- return validation
1399
- ownership_validation = validate_product_ownership(change_dir, staging)
1400
- if not ownership_validation["ok"]:
1401
- return ownership_validation
1402
- start_validation = _validate_plan_start(change_dir, run_id, attempt)
1403
- if not start_validation["ok"]:
1404
- return start_validation
1405
982
 
1406
- receipt_path = change_dir / "meta" / "plan-finalization.json"
1407
- lock_path = change_dir / "meta" / "plan-finalize.lock"
1408
- receipt: dict[str, Any] | None = None
1409
- supersedes: dict[str, Any] | None = None
1410
- if receipt_path.is_file():
1411
- try:
1412
- loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1413
- receipt = loaded if isinstance(loaded, dict) else None
1414
- except (OSError, json.JSONDecodeError) as exc:
1415
- return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1416
- if receipt and receipt.get("artifactsHash") != validation["artifactsHash"]:
1417
- if not allow_amend:
1418
- return _result_error(
1419
- "PLAN_FINALIZATION_HASH_CONFLICT",
1420
- "finalizer was already invoked with a different artifact set; "
1421
- "to publish an approved amendment run: harness_plan_finalize.py "
1422
- "republish --change-dir <dir> --staging-dir <dir> --change "
1423
- f"{change_name} --run-id <fresh-run-id> --reason \"<why>\"",
1424
- )
1425
- # Authorized amendment: keep the superseded identity in the new
1426
- # receipt so the published history stays auditable.
1427
- supersedes = {
1428
- "artifactsHash": receipt.get("artifactsHash"),
1429
- "runId": receipt.get("runId"),
1430
- "attempt": receipt.get("attempt"),
1431
- }
1432
- elif (
1433
- receipt
1434
- and receipt.get("status") == "finalized"
1435
- and _terminal_exists(change_dir, run_id, attempt)
1436
- ):
1437
- return {
1438
- "ok": True,
1439
- "action": "finalize",
1440
- "idempotent": True,
1441
- "artifactsHash": validation["artifactsHash"],
1442
- "files": validation["files"],
1443
- "receiptPath": str(receipt_path),
1444
- "artifactRef": "meta/plan-finalization.json",
1445
- }
1446
983
 
1447
- lock_path.parent.mkdir(parents=True, exist_ok=True)
1448
- try:
1449
- lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1450
- os.close(lock_fd)
1451
- except FileExistsError:
1452
- return _result_error("PLAN_FINALIZATION_LOCKED", f"lock exists: {lock_path}")
1453
-
1454
- created: list[Path] = []
1455
- # Amendment overwrites replace already-published bytes. Keep the originals
1456
- # so a failed publish restores them instead of deleting the published plan.
1457
- replaced: list[tuple[Path, bytes]] = []
1458
- terminal_committed = False
1459
- try:
1460
- for rel_text in validation["files"]:
1461
- source = staging / rel_text
1462
- target = change_dir / rel_text
1463
- if (
1464
- not allow_amend
1465
- and target.exists()
1466
- and target.read_bytes() != source.read_bytes()
1467
- ):
1468
- return _result_error(
1469
- "PLAN_TARGET_CONFLICT",
1470
- f"refusing to overwrite {rel_text}; publish an approved "
1471
- "amendment with the `republish` subcommand instead",
1472
- )
1473
984
 
1474
- for rel_text in validation["files"]:
1475
- source = staging / rel_text
1476
- target = change_dir / rel_text
1477
- existed = target.exists()
1478
- if existed:
1479
- original = target.read_bytes()
1480
- if not allow_amend or original == source.read_bytes():
1481
- continue
1482
- target.parent.mkdir(parents=True, exist_ok=True)
1483
- fd, raw_tmp = tempfile.mkstemp(
1484
- prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
1485
- )
1486
- os.close(fd)
1487
- tmp = Path(raw_tmp)
1488
- try:
1489
- shutil.copyfile(source, tmp)
1490
- os.replace(tmp, target)
1491
- if existed:
1492
- replaced.append((target, original))
1493
- else:
1494
- created.append(target)
1495
- finally:
1496
- tmp.unlink(missing_ok=True)
1497
-
1498
- # C8: write implementation-checkpoints.json with parsed task ownerPhase.
1499
- tasks = validation.get("tasks") or []
1500
- if tasks:
1501
- checkpoints_path = change_dir / "meta" / "implementation-checkpoints.json"
1502
- checkpoints_payload = {
1503
- "schemaVersion": 1,
1504
- "changeName": change_name,
1505
- "tasks": tasks,
1506
- "foundationGate": "approved",
1507
- }
1508
- if checkpoints_path.is_file():
1509
- replaced.append((checkpoints_path, checkpoints_path.read_bytes()))
1510
- else:
1511
- created.append(checkpoints_path)
1512
- _atomic_write_json(checkpoints_path, checkpoints_payload)
1513
-
1514
- # C9: write scenario-manifest.json with parsed scenarios.
1515
- scenarios = validation.get("scenarios") or []
1516
- manifest_path = change_dir / "meta" / "scenario-manifest.json"
1517
- manifest_payload = {
1518
- "schemaVersion": scenario_manifest_schema_version(scenarios),
1519
- "changeName": change_name,
1520
- "scenarios": [
1521
- {
1522
- key: value
1523
- for key, value in scenario.items()
1524
- if key != "executableMappingDeclared"
1525
- }
1526
- for scenario in scenarios
1527
- ],
1528
- }
1529
- if manifest_path.is_file():
1530
- replaced.append((manifest_path, manifest_path.read_bytes()))
1531
- else:
1532
- created.append(manifest_path)
1533
- _atomic_write_json(manifest_path, manifest_payload)
1534
-
1535
- pending_receipt = {
1536
- "schemaVersion": SCHEMA_VERSION,
1537
- "changeName": change_name,
1538
- "status": "publishing",
1539
- "artifactsHash": validation["artifactsHash"],
1540
- "files": validation["files"],
1541
- "runId": run_id,
1542
- "attempt": attempt,
1543
- }
1544
- if supersedes is not None:
1545
- pending_receipt["supersedes"] = supersedes
1546
- pending_receipt["amendReason"] = amend_reason or ""
1547
- _atomic_write_json(receipt_path, pending_receipt)
1548
- terminal_code, terminal_error = _append_terminal(change_dir, run_id, attempt)
1549
- terminal_committed = _terminal_exists(change_dir, run_id, attempt)
1550
- if terminal_code != 0 and not terminal_committed:
1551
- _rollback_publish(created, replaced)
1552
- receipt_path.unlink(missing_ok=True)
1553
- return _result_error(
1554
- "PLAN_TERMINAL_APPEND_FAILED",
1555
- terminal_error or "phase.end append failed",
1556
- )
1557
- pending_receipt["status"] = "finalized"
1558
- _atomic_write_json(receipt_path, pending_receipt)
1559
- result: dict[str, Any] = {
1560
- "ok": True,
1561
- "action": "republish" if supersedes is not None else "finalize",
1562
- "idempotent": False,
1563
- "artifactsHash": validation["artifactsHash"],
1564
- "files": validation["files"],
1565
- "receiptPath": str(receipt_path),
1566
- "artifactRef": "meta/plan-finalization.json",
1567
- "executionLogPath": str(harness_events.execution_log_path(change_dir)),
1568
- }
1569
- if supersedes is not None:
1570
- result["supersedes"] = supersedes
1571
- result["scenarioCount"] = len(scenarios)
1572
- result["taskCount"] = len(tasks)
1573
- return result
1574
- except OSError as exc:
1575
- if terminal_committed or _terminal_exists(change_dir, run_id, attempt):
1576
- return _result_error(
1577
- "PLAN_FINALIZATION_RECOVERY_REQUIRED",
1578
- f"terminal committed; retry finalization to complete receipt: {exc}",
1579
- )
1580
- _rollback_publish(created, replaced)
1581
- receipt_path.unlink(missing_ok=True)
1582
- return _result_error("PLAN_FINALIZATION_IO_ERROR", str(exc))
1583
- finally:
1584
- lock_path.unlink(missing_ok=True)
1585
985
 
1586
986
 
1587
- def republish_plan(
1588
- change_dir: Path,
1589
- staging: Path,
1590
- *,
1591
- change_name: str,
1592
- run_id: str,
1593
- reason: str,
1594
- ) -> dict[str, Any]:
1595
- """Publish an approved amendment to an already-finalized plan.
1596
-
1597
- Amending a published plan (adding a scenario, correcting a task) used to
1598
- require reverse-engineering a five-step lifecycle dance across three
1599
- modules — new run id, incremented attempt, stale receipt removal, dangling
1600
- attempt sealing — and every wrong turn produced a different error code
1601
- (PLAN_FINALIZATION_HASH_CONFLICT / PHASE_START_MISSING /
1602
- EVENT_ATTEMPT_CONFLICT / PHASE_ALREADY_CLOSED). Agents that gave up
1603
- hand-edited meta/scenario-manifest.json, creating exactly the drift the
1604
- hash guard exists to prevent.
1605
-
1606
- This performs the whole sequence atomically and keeps the superseded
1607
- identity in the receipt, so amendment stays auditable rather than silent.
1608
- """
1609
- change_dir = change_dir.resolve()
1610
- staging = staging.resolve()
1611
987
 
1612
- if not str(reason).strip():
1613
- return _result_error(
1614
- "PLAN_AMEND_REASON_REQUIRED",
1615
- "--reason is required: an amendment to a published plan must record why",
1616
- )
1617
988
 
1618
- receipt_path = change_dir / "meta" / "plan-finalization.json"
1619
- if not receipt_path.is_file():
1620
- return _result_error(
1621
- "PLAN_NOT_FINALIZED",
1622
- "no plan-finalization.json to amend; use `finalize` for the first "
1623
- "publication",
1624
- )
1625
989
 
1626
- if _run_id_in_use(change_dir, run_id):
1627
- return _result_error(
1628
- "PLAN_AMEND_RUN_ID_IN_USE",
1629
- f"run id {run_id} already appears in this change's events; "
1630
- "an amendment needs a fresh run id",
1631
- )
1632
990
 
1633
- # Nothing to amend is a no-op, not a new attempt: probe the staged hash
1634
- # before writing any lifecycle event.
1635
- reclassify = _reclassify_gate_policy(staging, change_name)
1636
- if not reclassify.get("ok"):
1637
- return _result_error(
1638
- "CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed")
1639
- )
1640
- probe = validate_staging(staging, change_name)
1641
- if not probe["ok"]:
1642
- return probe
1643
- try:
1644
- published = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1645
- except (OSError, json.JSONDecodeError) as exc:
1646
- return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1647
- if (
1648
- isinstance(published, dict)
1649
- and published.get("status") == "finalized"
1650
- and published.get("artifactsHash") == probe["artifactsHash"]
1651
- ):
1652
- return {
1653
- "ok": True,
1654
- "action": "republish",
1655
- "idempotent": True,
1656
- "artifactsHash": probe["artifactsHash"],
1657
- "files": probe["files"],
1658
- "receiptPath": str(receipt_path),
1659
- "artifactRef": "meta/plan-finalization.json",
1660
- "note": "published plan already matches staging; nothing to amend",
1661
- }
1662
991
 
1663
- attempt = max(_plan_attempts(change_dir), default=0) + 1
1664
- start_code, start_error = _append_plan_start(
1665
- change_dir, run_id, attempt, f"plan amendment: {reason}"
1666
- )
1667
- if start_code != 0:
1668
- return _result_error(
1669
- "PLAN_AMEND_START_FAILED",
1670
- start_error or "failed to append plan phase.start for the amendment",
1671
- )
1672
992
 
1673
- result = finalize_plan(
1674
- change_dir,
1675
- staging,
1676
- change_name=change_name,
1677
- run_id=run_id,
1678
- attempt=attempt,
1679
- allow_amend=True,
1680
- amend_reason=reason,
1681
- )
1682
- if isinstance(result, dict):
1683
- result.setdefault("runId", run_id)
1684
- result.setdefault("attempt", attempt)
1685
- return result
1686
993
 
1687
994
 
1688
995
  def build_parser() -> argparse.ArgumentParser:
1689
996
  parser = argparse.ArgumentParser(prog="harness_plan_finalize.py")
1690
997
  sub = parser.add_subparsers(dest="command", required=True)
1691
- republish = sub.add_parser(
1692
- "republish",
1693
- help=(
1694
- "publish an approved amendment to an already-finalized plan "
1695
- "(allocates a fresh attempt and records what it supersedes)"
1696
- ),
1697
- )
1698
- republish.add_argument("--change-dir", required=True)
1699
- republish.add_argument("--staging-dir", required=True)
1700
- republish.add_argument("--change", required=True)
1701
- republish.add_argument(
1702
- "--run-id", required=True, help="a fresh run id, unused by this change"
1703
- )
1704
- republish.add_argument(
1705
- "--reason", required=True, help="why the published plan is being amended"
1706
- )
1707
- republish.add_argument("--json", action="store_true")
1708
- finalize = sub.add_parser("finalize")
1709
- finalize.add_argument("--change-dir", required=True)
1710
- finalize.add_argument("--staging-dir", required=True)
1711
- finalize.add_argument("--change", required=True)
1712
- finalize.add_argument("--run-id", required=True)
1713
- finalize.add_argument("--attempt", required=True, type=int)
1714
- finalize.add_argument("--json", action="store_true")
1715
998
  verify = sub.add_parser("verify")
1716
999
  verify.add_argument("--change-dir", required=True)
1717
1000
  verify.add_argument("--json", action="store_true")
@@ -1720,24 +1003,7 @@ def build_parser() -> argparse.ArgumentParser:
1720
1003
 
1721
1004
  def main(argv: list[str] | None = None) -> int:
1722
1005
  args = build_parser().parse_args(argv)
1723
- if args.command == "verify":
1724
- result = verify_plan(Path(args.change_dir))
1725
- elif args.command == "republish":
1726
- result = republish_plan(
1727
- Path(args.change_dir),
1728
- Path(args.staging_dir),
1729
- change_name=args.change,
1730
- run_id=args.run_id,
1731
- reason=args.reason,
1732
- )
1733
- else:
1734
- result = finalize_plan(
1735
- Path(args.change_dir),
1736
- Path(args.staging_dir),
1737
- change_name=args.change,
1738
- run_id=args.run_id,
1739
- attempt=args.attempt,
1740
- )
1006
+ result = verify_plan(Path(args.change_dir))
1741
1007
  stream = sys.stdout if result["ok"] else sys.stderr
1742
1008
  if args.json:
1743
1009
  stream.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")