@hunter-harness/workflow-harness 0.2.59 → 0.2.61

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/README.md +14 -16
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +43 -46
  4. package/harness/bundles/general/claude-code/harness-archive/checklist.md +10 -10
  5. package/harness/bundles/general/claude-code/harness-archive/reference.md +29 -22
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +1 -7
  7. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -4
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +5 -3
  13. package/harness/bundles/general/claude-code/harness-run/checklist.md +10 -9
  14. package/harness/bundles/general/claude-code/harness-run/protocols.md +2 -0
  15. package/harness/bundles/general/claude-code/harness-run/reference.md +19 -16
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +2 -2
  19. package/harness/bundles/general/claude-code/harness-test/checklist.md +2 -2
  20. package/harness/bundles/general/claude-code/harness-test/pitfalls.md +3 -3
  21. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -2
  22. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +18 -25
  23. package/harness/bundles/general/claude-code/protocols/evidence-based-reporting-protocol.md +5 -5
  24. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +7 -7
  25. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +10 -9
  26. package/harness/bundles/general/claude-code/protocols/sensitive-info-protocol.md +3 -3
  27. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +5 -6
  28. package/harness/bundles/general/claude-code/scripts/harness_archive.py +1043 -1108
  29. package/harness/bundles/general/claude-code/scripts/harness_context.py +8 -2
  30. package/harness/bundles/general/claude-code/scripts/harness_events.py +5 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +79 -2
  32. package/harness/bundles/general/claude-code/scripts/harness_profile.py +7 -0
  33. package/harness/bundles/general/claude-code/scripts/harness_state.py +22 -3
  34. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  35. package/harness/bundles/general/codebuddy/README.md +14 -16
  36. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +43 -46
  37. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +10 -10
  38. package/harness/bundles/general/codebuddy/harness-archive/reference.md +29 -22
  39. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +1 -7
  40. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
  44. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  45. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +5 -3
  46. package/harness/bundles/general/codebuddy/harness-run/checklist.md +10 -9
  47. package/harness/bundles/general/codebuddy/harness-run/protocols.md +2 -0
  48. package/harness/bundles/general/codebuddy/harness-run/reference.md +19 -16
  49. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  50. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  51. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +2 -2
  52. package/harness/bundles/general/codebuddy/harness-test/checklist.md +2 -2
  53. package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +3 -3
  54. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -2
  55. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +18 -25
  56. package/harness/bundles/general/codebuddy/protocols/evidence-based-reporting-protocol.md +5 -5
  57. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +7 -7
  58. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +10 -9
  59. package/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +3 -3
  60. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +5 -6
  61. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +1043 -1108
  62. package/harness/bundles/general/codebuddy/scripts/harness_context.py +8 -2
  63. package/harness/bundles/general/codebuddy/scripts/harness_events.py +5 -0
  64. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +79 -2
  65. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +7 -0
  66. package/harness/bundles/general/codebuddy/scripts/harness_state.py +22 -3
  67. package/harness/bundles/general/codex/.harness-build.json +1 -1
  68. package/harness/bundles/general/codex/README.md +14 -16
  69. package/harness/bundles/general/codex/harness-archive/SKILL.md +43 -46
  70. package/harness/bundles/general/codex/harness-archive/checklist.md +10 -10
  71. package/harness/bundles/general/codex/harness-archive/reference.md +29 -22
  72. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +1 -7
  73. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  75. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  76. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
  77. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  78. package/harness/bundles/general/codex/harness-run/SKILL.md +5 -3
  79. package/harness/bundles/general/codex/harness-run/checklist.md +10 -9
  80. package/harness/bundles/general/codex/harness-run/protocols.md +2 -0
  81. package/harness/bundles/general/codex/harness-run/reference.md +19 -16
  82. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  83. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  84. package/harness/bundles/general/codex/harness-test/SKILL.md +2 -2
  85. package/harness/bundles/general/codex/harness-test/checklist.md +2 -2
  86. package/harness/bundles/general/codex/harness-test/pitfalls.md +3 -3
  87. package/harness/bundles/general/codex/harness-test/reference.md +2 -2
  88. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +18 -25
  89. package/harness/bundles/general/codex/protocols/evidence-based-reporting-protocol.md +5 -5
  90. package/harness/bundles/general/codex/protocols/ledger-protocol.md +7 -7
  91. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +10 -9
  92. package/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +3 -3
  93. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +5 -6
  94. package/harness/bundles/general/codex/scripts/harness_archive.py +1043 -1108
  95. package/harness/bundles/general/codex/scripts/harness_context.py +8 -2
  96. package/harness/bundles/general/codex/scripts/harness_events.py +5 -0
  97. package/harness/bundles/general/codex/scripts/harness_ledger.py +79 -2
  98. package/harness/bundles/general/codex/scripts/harness_profile.py +7 -0
  99. package/harness/bundles/general/codex/scripts/harness_state.py +22 -3
  100. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  101. package/harness/bundles/general/cursor/README.md +14 -16
  102. package/harness/bundles/general/cursor/harness-archive/SKILL.md +43 -46
  103. package/harness/bundles/general/cursor/harness-archive/checklist.md +10 -10
  104. package/harness/bundles/general/cursor/harness-archive/reference.md +29 -22
  105. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +1 -7
  106. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  107. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  108. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  109. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
  110. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  111. package/harness/bundles/general/cursor/harness-run/SKILL.md +5 -3
  112. package/harness/bundles/general/cursor/harness-run/checklist.md +10 -9
  113. package/harness/bundles/general/cursor/harness-run/protocols.md +2 -0
  114. package/harness/bundles/general/cursor/harness-run/reference.md +19 -16
  115. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  116. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  117. package/harness/bundles/general/cursor/harness-test/SKILL.md +2 -2
  118. package/harness/bundles/general/cursor/harness-test/checklist.md +2 -2
  119. package/harness/bundles/general/cursor/harness-test/pitfalls.md +3 -3
  120. package/harness/bundles/general/cursor/harness-test/reference.md +2 -2
  121. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +18 -25
  122. package/harness/bundles/general/cursor/protocols/evidence-based-reporting-protocol.md +5 -5
  123. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +7 -7
  124. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +10 -9
  125. package/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +3 -3
  126. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +5 -6
  127. package/harness/bundles/general/cursor/scripts/harness_archive.py +1043 -1108
  128. package/harness/bundles/general/cursor/scripts/harness_context.py +8 -2
  129. package/harness/bundles/general/cursor/scripts/harness_events.py +5 -0
  130. package/harness/bundles/general/cursor/scripts/harness_ledger.py +79 -2
  131. package/harness/bundles/general/cursor/scripts/harness_profile.py +7 -0
  132. package/harness/bundles/general/cursor/scripts/harness_state.py +22 -3
  133. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  134. package/harness/bundles/java/claude-code/README.md +14 -16
  135. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
  136. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +43 -46
  137. package/harness/bundles/java/claude-code/harness-archive/checklist.md +10 -10
  138. package/harness/bundles/java/claude-code/harness-archive/reference.md +29 -22
  139. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +1 -7
  140. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  141. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  142. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  143. package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -2
  144. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
  145. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  146. package/harness/bundles/java/claude-code/harness-run/SKILL.md +5 -3
  147. package/harness/bundles/java/claude-code/harness-run/protocols.md +2 -0
  148. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  149. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  150. package/harness/bundles/java/claude-code/harness-test/SKILL.md +2 -2
  151. package/harness/bundles/java/claude-code/harness-test/checklist.md +2 -2
  152. package/harness/bundles/java/claude-code/harness-test/pitfalls.md +3 -3
  153. package/harness/bundles/java/claude-code/harness-test/reference.md +2 -2
  154. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +18 -25
  155. package/harness/bundles/java/claude-code/protocols/evidence-based-reporting-protocol.md +5 -5
  156. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +7 -7
  157. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +10 -9
  158. package/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +3 -3
  159. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +5 -6
  160. package/harness/bundles/java/claude-code/scripts/harness_archive.py +1043 -1108
  161. package/harness/bundles/java/claude-code/scripts/harness_context.py +8 -2
  162. package/harness/bundles/java/claude-code/scripts/harness_events.py +5 -0
  163. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +79 -2
  164. package/harness/bundles/java/claude-code/scripts/harness_profile.py +7 -0
  165. package/harness/bundles/java/claude-code/scripts/harness_state.py +22 -3
  166. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  167. package/harness/bundles/java/codebuddy/README.md +14 -16
  168. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
  169. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +43 -46
  170. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +10 -10
  171. package/harness/bundles/java/codebuddy/harness-archive/reference.md +29 -22
  172. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +1 -7
  173. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  174. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  175. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  176. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
  177. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
  178. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  179. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +5 -3
  180. package/harness/bundles/java/codebuddy/harness-run/protocols.md +2 -0
  181. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  182. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  183. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +2 -2
  184. package/harness/bundles/java/codebuddy/harness-test/checklist.md +2 -2
  185. package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +3 -3
  186. package/harness/bundles/java/codebuddy/harness-test/reference.md +2 -2
  187. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +18 -25
  188. package/harness/bundles/java/codebuddy/protocols/evidence-based-reporting-protocol.md +5 -5
  189. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +7 -7
  190. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +10 -9
  191. package/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +3 -3
  192. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +5 -6
  193. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +1043 -1108
  194. package/harness/bundles/java/codebuddy/scripts/harness_context.py +8 -2
  195. package/harness/bundles/java/codebuddy/scripts/harness_events.py +5 -0
  196. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +79 -2
  197. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +7 -0
  198. package/harness/bundles/java/codebuddy/scripts/harness_state.py +22 -3
  199. package/harness/bundles/java/codex/.harness-build.json +1 -1
  200. package/harness/bundles/java/codex/README.md +14 -16
  201. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
  202. package/harness/bundles/java/codex/harness-archive/SKILL.md +43 -46
  203. package/harness/bundles/java/codex/harness-archive/checklist.md +10 -10
  204. package/harness/bundles/java/codex/harness-archive/reference.md +29 -22
  205. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +1 -7
  206. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  207. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  208. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  209. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
  210. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
  211. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  212. package/harness/bundles/java/codex/harness-run/SKILL.md +5 -3
  213. package/harness/bundles/java/codex/harness-run/protocols.md +2 -0
  214. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  215. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  216. package/harness/bundles/java/codex/harness-test/SKILL.md +2 -2
  217. package/harness/bundles/java/codex/harness-test/checklist.md +2 -2
  218. package/harness/bundles/java/codex/harness-test/pitfalls.md +3 -3
  219. package/harness/bundles/java/codex/harness-test/reference.md +2 -2
  220. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +18 -25
  221. package/harness/bundles/java/codex/protocols/evidence-based-reporting-protocol.md +5 -5
  222. package/harness/bundles/java/codex/protocols/ledger-protocol.md +7 -7
  223. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +10 -9
  224. package/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +3 -3
  225. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +5 -6
  226. package/harness/bundles/java/codex/scripts/harness_archive.py +1043 -1108
  227. package/harness/bundles/java/codex/scripts/harness_context.py +8 -2
  228. package/harness/bundles/java/codex/scripts/harness_events.py +5 -0
  229. package/harness/bundles/java/codex/scripts/harness_ledger.py +79 -2
  230. package/harness/bundles/java/codex/scripts/harness_profile.py +7 -0
  231. package/harness/bundles/java/codex/scripts/harness_state.py +22 -3
  232. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  233. package/harness/bundles/java/cursor/README.md +14 -16
  234. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
  235. package/harness/bundles/java/cursor/harness-archive/SKILL.md +43 -46
  236. package/harness/bundles/java/cursor/harness-archive/checklist.md +10 -10
  237. package/harness/bundles/java/cursor/harness-archive/reference.md +29 -22
  238. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +1 -7
  239. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
  243. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
  244. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  245. package/harness/bundles/java/cursor/harness-run/SKILL.md +5 -3
  246. package/harness/bundles/java/cursor/harness-run/protocols.md +2 -0
  247. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  248. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-test/SKILL.md +2 -2
  250. package/harness/bundles/java/cursor/harness-test/checklist.md +2 -2
  251. package/harness/bundles/java/cursor/harness-test/pitfalls.md +3 -3
  252. package/harness/bundles/java/cursor/harness-test/reference.md +2 -2
  253. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +18 -25
  254. package/harness/bundles/java/cursor/protocols/evidence-based-reporting-protocol.md +5 -5
  255. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +7 -7
  256. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +10 -9
  257. package/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +3 -3
  258. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +5 -6
  259. package/harness/bundles/java/cursor/scripts/harness_archive.py +1043 -1108
  260. package/harness/bundles/java/cursor/scripts/harness_context.py +8 -2
  261. package/harness/bundles/java/cursor/scripts/harness_events.py +5 -0
  262. package/harness/bundles/java/cursor/scripts/harness_ledger.py +79 -2
  263. package/harness/bundles/java/cursor/scripts/harness_profile.py +7 -0
  264. package/harness/bundles/java/cursor/scripts/harness_state.py +22 -3
  265. package/harness/manifests/general/claude-code.json +35 -39
  266. package/harness/manifests/general/codebuddy.json +35 -39
  267. package/harness/manifests/general/codex.json +35 -39
  268. package/harness/manifests/general/cursor.json +35 -39
  269. package/harness/manifests/java/claude-code.json +35 -39
  270. package/harness/manifests/java/codebuddy.json +35 -39
  271. package/harness/manifests/java/codex.json +35 -39
  272. package/harness/manifests/java/cursor.json +35 -39
  273. package/hunter-workflow-family.json +5 -5
  274. package/package.json +1 -1
  275. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +0 -299
  276. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +0 -299
  277. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +0 -299
  278. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +0 -299
  279. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +0 -299
  280. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +0 -299
  281. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +0 -299
  282. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +0 -299
@@ -3,8 +3,8 @@
3
3
 
4
4
  Subcommands:
5
5
  status — pre-archive gate checks (read-only)
6
- finalize — single-process archive: manifest → move → collect → render
7
- validate → after-manifest → publish → ZIP upload/service
6
+ finalize — single-process archive: manifest → move → collect → validate
7
+ after-manifest → publish → ZIP upload/service
8
8
  replay — read-only re-collect + validate for historical archives
9
9
 
10
10
  Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
@@ -27,6 +27,7 @@ import subprocess
27
27
  import sys
28
28
  import tarfile
29
29
  import tempfile
30
+ import time
30
31
  import uuid
31
32
  import zipfile
32
33
  from collections.abc import Mapping
@@ -43,7 +44,6 @@ if hasattr(sys.stderr, "reconfigure"):
43
44
 
44
45
  SCRIPTS_DIR = Path(__file__).resolve().parent
45
46
  SKILLS_ROOT = SCRIPTS_DIR.parent
46
- RENDER_SCRIPT = SKILLS_ROOT / "harness-archive" / "templates" / "render-summary.mjs"
47
47
  SUMMARY_TEMPLATE = (
48
48
  SKILLS_ROOT / "harness-archive" / "templates" / "summary-data-template.json"
49
49
  )
@@ -1908,8 +1908,13 @@ def find_test_reports(change_dir: Path) -> list[Path]:
1908
1908
  "reports/test/*.md",
1909
1909
  ]
1910
1910
  found: list[Path] = []
1911
+ seen: set[Path] = set()
1911
1912
  for pattern in patterns:
1912
- found.extend(sorted(change_dir.glob(pattern)))
1913
+ for path in sorted(change_dir.glob(pattern)):
1914
+ if path in seen:
1915
+ continue
1916
+ seen.add(path)
1917
+ found.append(path)
1913
1918
  return found
1914
1919
 
1915
1920
 
@@ -2185,6 +2190,21 @@ def check_status(
2185
2190
  checks["project_root"] = str(project)
2186
2191
  state_root = resolve_archive_state_root(change_dir)
2187
2192
  checks["state_root"] = str(state_root)
2193
+ range_adoption = validate_existing_range_adoption(change_dir)
2194
+ checks["range_adoption"] = range_adoption
2195
+ if not range_adoption.get("ok"):
2196
+ blockers.append(
2197
+ {
2198
+ "code": str(
2199
+ range_adoption.get("reasonCode")
2200
+ or "EXISTING_RANGE_RECEIPT_INVALID"
2201
+ ),
2202
+ "message": str(
2203
+ range_adoption.get("message")
2204
+ or "既有提交范围认领收据无效。"
2205
+ ),
2206
+ }
2207
+ )
2188
2208
 
2189
2209
  exact_byte = check_archive_exact_byte_policy(project)
2190
2210
  checks["archive_exact_byte"] = exact_byte
@@ -2203,14 +2223,33 @@ def check_status(
2203
2223
  )
2204
2224
  checks["sensitive_evidence_publication"] = sensitive_gate
2205
2225
  if not sensitive_gate.get("ok"):
2206
- blockers.append({
2207
- "code": str(sensitive_gate.get("reasonCode") or "SECRET_SCAN_GATE_BLOCKED"),
2208
- "message": str(
2209
- sensitive_gate.get("nextAction")
2210
- or sensitive_gate.get("error")
2211
- or "sensitive evidence publication gate failed"
2212
- ),
2213
- })
2226
+ sensitive_issue_codes = {
2227
+ str(item.get("code") or "")
2228
+ for item in sensitive_gate.get("issues") or []
2229
+ if isinstance(item, dict)
2230
+ }
2231
+ digest_only_drift = sensitive_issue_codes == {
2232
+ "SECRET_SCAN_TREE_DIGEST_MISMATCH"
2233
+ }
2234
+ if digest_only_drift and not sensitive_gate.get("unresolvedFailures"):
2235
+ warnings.append(
2236
+ {
2237
+ "code": "SECRET_SCAN_RECEIPT_REFRESH_REQUIRED",
2238
+ "message": (
2239
+ "归档事件或锁文件变化使敏感信息扫描收据过期;"
2240
+ "正式归档会重新扫描并绑定当前字节。"
2241
+ ),
2242
+ }
2243
+ )
2244
+ else:
2245
+ blockers.append({
2246
+ "code": str(sensitive_gate.get("reasonCode") or "SECRET_SCAN_GATE_BLOCKED"),
2247
+ "message": str(
2248
+ sensitive_gate.get("nextAction")
2249
+ or sensitive_gate.get("error")
2250
+ or "sensitive evidence publication gate failed"
2251
+ ),
2252
+ })
2214
2253
 
2215
2254
  # --- H-4 formal-layer minimum set ---
2216
2255
  plans_dir = change_dir / "plans"
@@ -2239,6 +2278,28 @@ def check_status(
2239
2278
 
2240
2279
  ledger = load_ledger(change_dir)
2241
2280
  checks["verification_ledger"] = ledger is not None
2281
+ immutable_base = _immutable_change_base_from_snapshot(change_dir)
2282
+ ledger_base = (
2283
+ str(ledger.get("baseCommit") or "").strip()
2284
+ if isinstance(ledger, dict)
2285
+ else ""
2286
+ )
2287
+ checks["change_base"] = immutable_base or None
2288
+ if (
2289
+ immutable_base
2290
+ and ledger_base
2291
+ and immutable_base != ledger_base
2292
+ and not range_adoption.get("present")
2293
+ ):
2294
+ blockers.append(
2295
+ {
2296
+ "code": "CHANGE_BASE_IDENTITY_MISMATCH",
2297
+ "message": (
2298
+ "计划阶段保存的变更基线与验证账本不一致;"
2299
+ "请先恢复正确身份,不得在归档阶段自动改写。"
2300
+ ),
2301
+ }
2302
+ )
2242
2303
  if ledger is None:
2243
2304
  target = warnings if closure_disposition != "completed" else blockers
2244
2305
  target.append(
@@ -2297,7 +2358,10 @@ def check_status(
2297
2358
  hash_source: str | None = None
2298
2359
  if ledger is None:
2299
2360
  ledger = load_ledger(change_dir)
2300
- if worktree_requested(change_dir) and ledger:
2361
+ if range_adoption.get("ok") and range_adoption.get("present"):
2362
+ expected_hash = str(range_adoption.get("tipCommit") or "").strip() or None
2363
+ hash_source = "meta/archive-range-adoption.json"
2364
+ if expected_hash is None and worktree_requested(change_dir) and ledger:
2301
2365
  merge_hash = ledger.get("mergeFinalHash") or (
2302
2366
  ledger.get("merge") or {}
2303
2367
  ).get("finalHash")
@@ -2493,7 +2557,6 @@ def check_status(
2493
2557
  ),
2494
2558
  })
2495
2559
 
2496
- archivable = len(blockers) == 0
2497
2560
  candidate_codes = {
2498
2561
  "PRODUCT_CI_NOT_GREEN",
2499
2562
  "PRODUCT_CANDIDATE_NOT_VERIFIED",
@@ -2501,6 +2564,71 @@ def check_status(
2501
2564
  "PROJECT_RELEASE_POLICY_BLOCKED",
2502
2565
  "REMOTE_CI_DOWNGRADE_REFUSED",
2503
2566
  }
2567
+ report_adequacy: dict[str, Any]
2568
+ release_summary: dict[str, Any] | None = None
2569
+ try:
2570
+ release_summary = collect_summary_data(
2571
+ change_dir, write=False, for_replay=False
2572
+ )
2573
+ release_summary["archiveIntent"] = archive_intent
2574
+ release_summary["closureDisposition"] = closure_disposition
2575
+ release_summary["closureReason"] = closure_reason
2576
+ report_adequacy = validate_report_adequacy(release_summary)
2577
+ except Exception as exc: # noqa: BLE001 — status must remain read-only
2578
+ report_adequacy = {
2579
+ "ok": False,
2580
+ "code": "REPORT_ADEQUACY_UNAVAILABLE",
2581
+ "message": str(exc),
2582
+ "issues": [
2583
+ {
2584
+ "code": "REPORT_ADEQUACY_UNAVAILABLE",
2585
+ "severity": "error",
2586
+ "message": str(exc),
2587
+ }
2588
+ ],
2589
+ }
2590
+
2591
+ adequacy_messages_zh = {
2592
+ "ARCHIVE_BASE_EQUALS_FEATURE_TIP": (
2593
+ "变更基线与产品提交相同,本次没有可归档的产品增量。"
2594
+ "如需封存已有提交,请先使用受控的既有范围认领;"
2595
+ "如变更未完成,请选择废弃或被替代并填写中文原因。"
2596
+ ),
2597
+ "IDENTITY_BASE_MISSING": "缺少不可变的变更基线,无法证明归档范围。",
2598
+ "DIFF_ZERO_WITH_NONEMPTY_COMMIT": "提交范围非空,但报告中的文件变更数为 0。",
2599
+ "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP": "归档差异小于声明的产品范围。",
2600
+ "ARCHIVE_NOFF_MERGE_DELTA_ONLY": "归档只覆盖了合并提交差异,未覆盖完整变更。",
2601
+ "REPORT_ADEQUACY_UNAVAILABLE": "无法生成归档充分性报告。",
2602
+ }
2603
+ for issue in report_adequacy.get("issues") or []:
2604
+ if not isinstance(issue, dict) or issue.get("severity") != "error":
2605
+ continue
2606
+ code = str(issue.get("code") or "REPORT_ADEQUACY_FAILED")
2607
+ if any(item.get("code") == code for item in blockers):
2608
+ continue
2609
+ blockers.append(
2610
+ {
2611
+ "code": code,
2612
+ "message": adequacy_messages_zh.get(
2613
+ code,
2614
+ str(issue.get("message") or "归档报告不完整"),
2615
+ ),
2616
+ }
2617
+ )
2618
+ for issue in report_adequacy.get("issues") or []:
2619
+ if not isinstance(issue, dict) or issue.get("severity") != "warning":
2620
+ continue
2621
+ code = str(issue.get("code") or "REPORT_ADEQUACY_WARNING")
2622
+ if any(item.get("code") == code for item in warnings):
2623
+ continue
2624
+ warnings.append(
2625
+ {
2626
+ "code": code,
2627
+ "message": str(issue.get("message") or "归档报告需要注意"),
2628
+ }
2629
+ )
2630
+
2631
+ archivable = len(blockers) == 0
2504
2632
  archive_integrity_ok = not any(
2505
2633
  item.get("code") not in candidate_codes for item in blockers
2506
2634
  )
@@ -2512,34 +2640,23 @@ def check_status(
2512
2640
  else "ARCHIVE_INTEGRITY_FAILED"
2513
2641
  ),
2514
2642
  "message": (
2515
- "archive preconditions are complete"
2643
+ "归档前置条件完整"
2516
2644
  if archive_integrity_ok
2517
- else "archive preconditions contain non-candidate blockers"
2645
+ else "归档前置条件存在阻断项"
2518
2646
  ),
2519
2647
  }
2520
- try:
2521
- release_summary = collect_summary_data(
2522
- change_dir, write=False, for_replay=False
2523
- )
2524
- release_summary["archiveIntent"] = archive_intent
2525
- release_summary["closureDisposition"] = closure_disposition
2526
- release_summary["closureReason"] = closure_reason
2527
- report_adequacy = validate_report_adequacy(release_summary)
2648
+ if release_summary is not None:
2528
2649
  release_decision = evaluate_release_eligibility(
2529
2650
  change_dir,
2530
2651
  release_summary,
2531
2652
  archive_integrity=archive_integrity,
2532
2653
  report_adequacy=report_adequacy,
2533
2654
  )
2534
- except Exception as exc: # noqa: BLE001 — status must remain read-only
2655
+ else:
2535
2656
  release_decision = compose_release_decision(
2536
2657
  {
2537
2658
  "archiveIntegrity": archive_integrity,
2538
- "reportAdequacy": {
2539
- "ok": False,
2540
- "code": "REPORT_ADEQUACY_UNAVAILABLE",
2541
- "message": str(exc),
2542
- },
2659
+ "reportAdequacy": report_adequacy,
2543
2660
  "candidateVerification": ci_gate,
2544
2661
  }
2545
2662
  )
@@ -2573,21 +2690,37 @@ def archive_auto_gate(
2573
2690
  *,
2574
2691
  allow_missing_review: bool = False,
2575
2692
  archive_intent: str = "release-candidate",
2693
+ closure_disposition: str = "completed",
2694
+ closure_reason: str = "",
2576
2695
  ) -> dict[str, Any]:
2577
2696
  """Read-only proof that a post-submit/merge archive may run unattended."""
2578
2697
  status = check_status(
2579
2698
  change_dir,
2580
2699
  allow_missing_review=allow_missing_review,
2581
2700
  archive_intent=archive_intent,
2701
+ closure_disposition=closure_disposition,
2702
+ closure_reason=closure_reason,
2582
2703
  )
2583
2704
  if not status.get("archivable"):
2705
+ report_adequacy = (
2706
+ (status.get("releaseDecision") or {}).get("checks") or {}
2707
+ ).get("reportAdequacy") or {}
2708
+ adequacy_failed = report_adequacy.get("ok") is False
2584
2709
  return {
2585
2710
  "ok": False,
2586
2711
  "action": "auto-gate",
2587
2712
  "autoArchiveAllowed": False,
2588
- "reasonCode": "ARCHIVE_PRECONDITIONS_UNSATISFIED",
2713
+ "reasonCode": (
2714
+ "ARCHIVE_REPORT_ADEQUACY_FAILED"
2715
+ if adequacy_failed
2716
+ else "ARCHIVE_PRECONDITIONS_UNSATISFIED"
2717
+ ),
2589
2718
  "status": status,
2590
- "nextAction": "Resolve archive status blockers, then run `harness_archive.py auto-gate` again.",
2719
+ "nextAction": (
2720
+ "归档报告不完整,请按 blockers 中的中文建议处理后重试。"
2721
+ if adequacy_failed
2722
+ else "请先处理归档阻断项,再重新执行归档。"
2723
+ ),
2591
2724
  }
2592
2725
 
2593
2726
  state_root = resolve_archive_state_root(change_dir)
@@ -2609,7 +2742,7 @@ def archive_auto_gate(
2609
2742
  }
2610
2743
 
2611
2744
  git_state = snapshot.get("git")
2612
- snapshot_base = (
2745
+ snapshot_base = snapshot.get("changeBase") or (
2613
2746
  (git_state.get("base") or git_state.get("baseCommit"))
2614
2747
  if isinstance(git_state, dict)
2615
2748
  else snapshot.get("base") or snapshot.get("baseCommit")
@@ -2685,10 +2818,131 @@ def archive_auto_gate(
2685
2818
  "status": status,
2686
2819
  "snapshotPath": str(snapshot_path),
2687
2820
  "snapshotBase": snapshot_base,
2688
- "nextAction": "Run `harness_archive.py finalize` with the same change directory and intent; no AskQuestion confirmation is required.",
2821
+ "nextAction": "运行 `harness_archive.py execute`;它会复用本次状态结果,不再重复扫描。",
2689
2822
  }
2690
2823
 
2691
2824
 
2825
+ def execute_archive(
2826
+ change_dir: Path,
2827
+ archive_root: Path,
2828
+ *,
2829
+ durable_root: Path | None = None,
2830
+ retention_policy: str = "unspecified",
2831
+ skip_ingest: bool = False,
2832
+ allow_missing_review: bool = False,
2833
+ archive_intent: str = "release-candidate",
2834
+ closure_disposition: str = "completed",
2835
+ closure_reason: str = "",
2836
+ ) -> tuple[int, dict[str, Any]]:
2837
+ """Run one status collection, boundary gate, and finalize operation."""
2838
+ change_dir = change_dir.resolve()
2839
+ started = time.perf_counter()
2840
+ try:
2841
+ state_root = resolve_archive_state_root(change_dir)
2842
+ except (OSError, ValueError):
2843
+ state_root = change_dir
2844
+ if state_root.is_dir():
2845
+ try:
2846
+ append_event(
2847
+ state_root,
2848
+ phase="archive",
2849
+ type_="phase.prepare.start",
2850
+ note="开始一次性检查归档条件",
2851
+ )
2852
+ except OSError:
2853
+ pass
2854
+ candidate_certification: dict[str, Any] | None = None
2855
+ if load_product_candidate_ci(change_dir) is None:
2856
+ try:
2857
+ receipt = certify_local_candidate(
2858
+ change_dir,
2859
+ project=find_project_root(change_dir),
2860
+ )
2861
+ subject = (
2862
+ receipt.get("subject")
2863
+ if isinstance(receipt.get("subject"), dict)
2864
+ else {}
2865
+ )
2866
+ candidate_certification = {
2867
+ "ok": True,
2868
+ "code": "LOCAL_CANDIDATE_CERTIFIED",
2869
+ "assurance": receipt.get("assurance"),
2870
+ "subject": subject,
2871
+ "reusedValidations": (
2872
+ receipt.get("verification", {}).get("reusedValidations", [])
2873
+ if isinstance(receipt.get("verification"), dict)
2874
+ else []
2875
+ ),
2876
+ }
2877
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
2878
+ candidate_certification = {
2879
+ "ok": False,
2880
+ "code": "LOCAL_CANDIDATE_CERTIFICATION_UNAVAILABLE",
2881
+ "message": str(exc),
2882
+ }
2883
+ gate = archive_auto_gate(
2884
+ change_dir,
2885
+ allow_missing_review=allow_missing_review,
2886
+ archive_intent=archive_intent,
2887
+ closure_disposition=closure_disposition,
2888
+ closure_reason=closure_reason,
2889
+ )
2890
+ elapsed_ms = max(0, int(round((time.perf_counter() - started) * 1000)))
2891
+ if state_root.is_dir():
2892
+ try:
2893
+ append_event(
2894
+ state_root,
2895
+ phase="archive",
2896
+ type_="phase.prepare.end",
2897
+ status="OK" if gate.get("ok") else "BLOCKED",
2898
+ code=str(gate.get("reasonCode") or "ARCHIVE_PREPARE_FINISHED"),
2899
+ message=(
2900
+ "归档条件检查通过"
2901
+ if gate.get("ok")
2902
+ else str(gate.get("nextAction") or "归档条件未满足")
2903
+ ),
2904
+ duration_ms=elapsed_ms,
2905
+ note="一次性归档预检完成",
2906
+ )
2907
+ except OSError:
2908
+ pass
2909
+ if not gate.get("ok"):
2910
+ status = gate.get("status") if isinstance(gate.get("status"), dict) else {}
2911
+ blocked_payload = {
2912
+ "ok": False,
2913
+ "action": "execute",
2914
+ "reasonCode": gate.get("reasonCode"),
2915
+ "error": gate.get("nextAction"),
2916
+ "issues": list(status.get("blockers") or []),
2917
+ "preflight": gate,
2918
+ "change_dir": str(change_dir),
2919
+ "archive_dir": str(archive_root.resolve() / f"{today_date()}-{change_dir.name}"),
2920
+ "original_preserved": change_dir.is_dir(),
2921
+ "finalStatus": "BLOCKED",
2922
+ "preparationDurationMs": elapsed_ms,
2923
+ }
2924
+ if candidate_certification is not None:
2925
+ blocked_payload["candidateCertification"] = candidate_certification
2926
+ return 1, blocked_payload
2927
+ code, payload = cmd_finalize(
2928
+ change_dir,
2929
+ archive_root,
2930
+ durable_root=durable_root,
2931
+ retention_policy=retention_policy,
2932
+ skip_ingest=skip_ingest,
2933
+ allow_missing_review=allow_missing_review,
2934
+ archive_intent=archive_intent,
2935
+ closure_disposition=closure_disposition,
2936
+ closure_reason=closure_reason,
2937
+ preflight_status=gate["status"],
2938
+ )
2939
+ payload["preflight"] = gate
2940
+ payload["preparationDurationMs"] = elapsed_ms
2941
+ if candidate_certification is not None:
2942
+ payload["candidateCertification"] = candidate_certification
2943
+ return code, payload
2944
+
2945
+
2692
2946
  # ---------------------------------------------------------------------------
2693
2947
  # collect
2694
2948
  # ---------------------------------------------------------------------------
@@ -3198,7 +3452,11 @@ def build_verification_projection(
3198
3452
  "apiTests": _ledger_api_tests(effective_ledger, change_dir=change_dir),
3199
3453
  }
3200
3454
  db_compatibility = _ledger_db_compat(effective_ledger)
3201
- if db_compatibility["status"] == "NOT_RUN" and change_dir is not None:
3455
+ if db_compatibility["status"] in {
3456
+ "NOT_RUN",
3457
+ "UNKNOWN",
3458
+ "EVIDENCE_MISSING",
3459
+ } and change_dir is not None:
3202
3460
  gate_policy_path = change_dir / "meta" / "gate-policy.json"
3203
3461
  try:
3204
3462
  gate_policy = read_json(gate_policy_path) if gate_policy_path.is_file() else None
@@ -3206,15 +3464,22 @@ def build_verification_projection(
3206
3464
  gate_policy = None
3207
3465
  if isinstance(gate_policy, dict):
3208
3466
  capabilities = gate_policy.get("capabilities")
3209
- required = gate_policy.get("requiredValidations")
3210
- if (
3211
- isinstance(capabilities, list)
3212
- and "database" not in capabilities
3213
- and (not isinstance(required, list) or "dbCompatibility" not in required)
3214
- ):
3467
+ required_names: set[str] = set()
3468
+ for field in ("requiredValidations", "requiredValidationsByPhase"):
3469
+ required = gate_policy.get(field)
3470
+ if isinstance(required, list):
3471
+ required_names.update(str(item) for item in required)
3472
+ elif isinstance(required, dict):
3473
+ for phase_items in required.values():
3474
+ if isinstance(phase_items, list):
3475
+ required_names.update(str(item) for item in phase_items)
3476
+ has_database_capability = (
3477
+ isinstance(capabilities, list) and "database" in capabilities
3478
+ )
3479
+ if not has_database_capability and "dbCompatibility" not in required_names:
3215
3480
  db_compatibility = {
3216
3481
  "status": "NOT_APPLICABLE",
3217
- "reason": "项目能力画像未声明数据库能力",
3482
+ "reason": "门禁策略未声明数据库能力或数据库兼容性验证要求",
3218
3483
  "source": "capability-profile",
3219
3484
  }
3220
3485
  projection["dbCompatibility"] = db_compatibility["status"]
@@ -3893,6 +4158,303 @@ def _review_summary(
3893
4158
  return base
3894
4159
 
3895
4160
 
4161
+ def _archive_range_adoption_path(change_dir: Path) -> Path:
4162
+ return change_dir.resolve() / "meta" / "archive-range-adoption.json"
4163
+
4164
+
4165
+ def _archive_range_receipt_id(payload: dict[str, Any]) -> str:
4166
+ canonical = {
4167
+ key: value for key, value in payload.items() if key != "receiptId"
4168
+ }
4169
+ raw = json.dumps(
4170
+ canonical,
4171
+ ensure_ascii=False,
4172
+ sort_keys=True,
4173
+ separators=(",", ":"),
4174
+ ).encode("utf-8")
4175
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
4176
+
4177
+
4178
+ def _evaluate_existing_range(
4179
+ change_dir: Path,
4180
+ *,
4181
+ base: str,
4182
+ tip: str,
4183
+ ) -> dict[str, Any]:
4184
+ project = find_project_root(change_dir)
4185
+ base_code, resolved_base, base_error = git_run(
4186
+ project, "rev-parse", "--verify", f"{base}^{{commit}}"
4187
+ )
4188
+ tip_code, resolved_tip, tip_error = git_run(
4189
+ project, "rev-parse", "--verify", f"{tip}^{{commit}}"
4190
+ )
4191
+ if base_code != 0 or not resolved_base:
4192
+ return {
4193
+ "ok": False,
4194
+ "reasonCode": "EXISTING_RANGE_BASE_INVALID",
4195
+ "message": f"无法解析既有范围基线:{base_error or base}",
4196
+ }
4197
+ if tip_code != 0 or not resolved_tip:
4198
+ return {
4199
+ "ok": False,
4200
+ "reasonCode": "EXISTING_RANGE_TIP_INVALID",
4201
+ "message": f"无法解析既有范围终点:{tip_error or tip}",
4202
+ }
4203
+ if resolved_base == resolved_tip:
4204
+ return {
4205
+ "ok": False,
4206
+ "reasonCode": "EXISTING_RANGE_EMPTY",
4207
+ "message": "既有提交范围为空,不能认领为已完成产品变更。",
4208
+ }
4209
+ ancestor_code, _, ancestor_error = git_run(
4210
+ project, "merge-base", "--is-ancestor", resolved_base, resolved_tip
4211
+ )
4212
+ if ancestor_code != 0:
4213
+ return {
4214
+ "ok": False,
4215
+ "reasonCode": "EXISTING_RANGE_NOT_ANCESTOR",
4216
+ "message": f"范围基线不是终点的祖先:{ancestor_error or resolved_base}",
4217
+ }
4218
+ identity = resolve_product_archive_identity(change_dir, project=project)
4219
+ expected_tip = str(
4220
+ identity.get("featureTip") or identity.get("productCommit") or ""
4221
+ ).strip()
4222
+ if expected_tip:
4223
+ expected_code, resolved_expected, _ = git_run(
4224
+ project, "rev-parse", "--verify", f"{expected_tip}^{{commit}}"
4225
+ )
4226
+ if expected_code == 0 and resolved_expected != resolved_tip:
4227
+ return {
4228
+ "ok": False,
4229
+ "reasonCode": "EXISTING_RANGE_TIP_MISMATCH",
4230
+ "message": (
4231
+ "既有范围终点与当前产品提交不一致:"
4232
+ f"expected={resolved_expected} actual={resolved_tip}"
4233
+ ),
4234
+ }
4235
+ try:
4236
+ ownership = hl.compute_ownership_diff(
4237
+ project,
4238
+ base=resolved_base,
4239
+ head=resolved_tip,
4240
+ change_dir=change_dir,
4241
+ )
4242
+ except (OSError, ValueError, RuntimeError) as exc:
4243
+ return {
4244
+ "ok": False,
4245
+ "reasonCode": "EXISTING_RANGE_OWNERSHIP_UNAVAILABLE",
4246
+ "message": f"无法验证既有范围归属:{exc}",
4247
+ }
4248
+ foreign = list(ownership.get("foreignPaths") or [])
4249
+ owned = list(ownership.get("files") or [])
4250
+ if foreign:
4251
+ return {
4252
+ "ok": False,
4253
+ "reasonCode": "EXISTING_RANGE_FOREIGN_PATHS",
4254
+ "message": "既有范围包含不属于本变更的文件。",
4255
+ "foreignPaths": foreign,
4256
+ }
4257
+ if not owned:
4258
+ return {
4259
+ "ok": False,
4260
+ "reasonCode": "EXISTING_RANGE_NO_PRODUCT_FILES",
4261
+ "message": "既有范围未包含本变更声明的产品文件。",
4262
+ }
4263
+ return {
4264
+ "ok": True,
4265
+ "reasonCode": "EXISTING_RANGE_VALID",
4266
+ "project": project,
4267
+ "baseCommit": resolved_base,
4268
+ "tipCommit": resolved_tip,
4269
+ "ownership": ownership,
4270
+ }
4271
+
4272
+
4273
+ def validate_existing_range_adoption(change_dir: Path) -> dict[str, Any]:
4274
+ path = _archive_range_adoption_path(change_dir)
4275
+ if not path.is_file():
4276
+ return {
4277
+ "ok": True,
4278
+ "present": False,
4279
+ "reasonCode": "EXISTING_RANGE_NOT_ADOPTED",
4280
+ "receiptPath": str(path),
4281
+ }
4282
+ try:
4283
+ receipt = read_json(path)
4284
+ except (OSError, json.JSONDecodeError, TypeError) as exc:
4285
+ return {
4286
+ "ok": False,
4287
+ "present": True,
4288
+ "reasonCode": "EXISTING_RANGE_RECEIPT_INVALID",
4289
+ "message": str(exc),
4290
+ "receiptPath": str(path),
4291
+ }
4292
+ if not isinstance(receipt, dict):
4293
+ return {
4294
+ "ok": False,
4295
+ "present": True,
4296
+ "reasonCode": "EXISTING_RANGE_RECEIPT_INVALID",
4297
+ "message": "既有范围认领收据必须是 JSON 对象。",
4298
+ "receiptPath": str(path),
4299
+ }
4300
+ issues: list[str] = []
4301
+ if receipt.get("schemaVersion") != 1:
4302
+ issues.append("schemaVersion")
4303
+ if receipt.get("action") != "adopt-existing-range":
4304
+ issues.append("action")
4305
+ if receipt.get("changeName") != change_dir.name:
4306
+ issues.append("changeName")
4307
+ if receipt.get("confirmedByUser") is not True:
4308
+ issues.append("confirmedByUser")
4309
+ reason = str(receipt.get("reason") or "").strip()
4310
+ if not reason or re.search(r"[\u4e00-\u9fff]", reason) is None:
4311
+ issues.append("reason")
4312
+ if receipt.get("receiptId") != _archive_range_receipt_id(receipt):
4313
+ issues.append("receiptId")
4314
+ if issues:
4315
+ return {
4316
+ "ok": False,
4317
+ "present": True,
4318
+ "reasonCode": "EXISTING_RANGE_RECEIPT_INVALID",
4319
+ "message": "既有范围认领收据字段无效:" + ", ".join(issues),
4320
+ "receiptPath": str(path),
4321
+ }
4322
+ evaluated = _evaluate_existing_range(
4323
+ change_dir,
4324
+ base=str(receipt.get("baseCommit") or ""),
4325
+ tip=str(receipt.get("tipCommit") or ""),
4326
+ )
4327
+ if not evaluated.get("ok"):
4328
+ return {
4329
+ **evaluated,
4330
+ "present": True,
4331
+ "receiptPath": str(path),
4332
+ }
4333
+ project = evaluated["project"]
4334
+ repository_id = hp.repository_identity(project)
4335
+ ownership = evaluated["ownership"]
4336
+ if receipt.get("repositoryId") != repository_id:
4337
+ issues.append("repositoryId")
4338
+ if receipt.get("diffHash") != ownership.get("diffHash"):
4339
+ issues.append("diffHash")
4340
+ if receipt.get("ownershipHash") != ownership.get("ownershipHash"):
4341
+ issues.append("ownershipHash")
4342
+ if list(receipt.get("files") or []) != list(ownership.get("files") or []):
4343
+ issues.append("files")
4344
+ if issues:
4345
+ return {
4346
+ "ok": False,
4347
+ "present": True,
4348
+ "reasonCode": "EXISTING_RANGE_RECEIPT_STALE",
4349
+ "message": "既有范围认领收据与当前仓库不一致:" + ", ".join(issues),
4350
+ "receiptPath": str(path),
4351
+ }
4352
+ return {
4353
+ "ok": True,
4354
+ "present": True,
4355
+ "reasonCode": "EXISTING_RANGE_ADOPTED",
4356
+ "receiptPath": str(path),
4357
+ "receipt": receipt,
4358
+ "baseCommit": evaluated["baseCommit"],
4359
+ "tipCommit": evaluated["tipCommit"],
4360
+ "files": list(ownership.get("files") or []),
4361
+ }
4362
+
4363
+
4364
+ def adopt_existing_range(
4365
+ change_dir: Path,
4366
+ *,
4367
+ base: str,
4368
+ tip: str,
4369
+ reason: str,
4370
+ confirmed: bool,
4371
+ ) -> dict[str, Any]:
4372
+ change_dir = change_dir.resolve()
4373
+ path = _archive_range_adoption_path(change_dir)
4374
+ if not confirmed:
4375
+ return {
4376
+ "ok": False,
4377
+ "action": "adopt-existing-range",
4378
+ "reasonCode": "EXISTING_RANGE_CONFIRMATION_REQUIRED",
4379
+ "message": "认领既有提交范围必须先获得用户明确确认。",
4380
+ "receiptPath": str(path),
4381
+ }
4382
+ reason = reason.strip()
4383
+ if not reason or re.search(r"[\u4e00-\u9fff]", reason) is None:
4384
+ return {
4385
+ "ok": False,
4386
+ "action": "adopt-existing-range",
4387
+ "reasonCode": "EXISTING_RANGE_REASON_REQUIRED",
4388
+ "message": "请提供可读的中文认领原因。",
4389
+ "receiptPath": str(path),
4390
+ }
4391
+ evaluated = _evaluate_existing_range(
4392
+ change_dir,
4393
+ base=base,
4394
+ tip=tip,
4395
+ )
4396
+ if not evaluated.get("ok"):
4397
+ return {
4398
+ **evaluated,
4399
+ "action": "adopt-existing-range",
4400
+ "receiptPath": str(path),
4401
+ }
4402
+ project = evaluated["project"]
4403
+ ownership = evaluated["ownership"]
4404
+ if path.is_file():
4405
+ existing = validate_existing_range_adoption(change_dir)
4406
+ existing_receipt = (
4407
+ existing.get("receipt")
4408
+ if isinstance(existing.get("receipt"), dict)
4409
+ else {}
4410
+ )
4411
+ if (
4412
+ existing.get("ok")
4413
+ and existing_receipt.get("baseCommit") == evaluated["baseCommit"]
4414
+ and existing_receipt.get("tipCommit") == evaluated["tipCommit"]
4415
+ and existing_receipt.get("reason") == reason
4416
+ ):
4417
+ return {
4418
+ "ok": True,
4419
+ "action": "adopt-existing-range",
4420
+ "reasonCode": "EXISTING_RANGE_ADOPTED",
4421
+ "idempotent": True,
4422
+ "receiptPath": str(path),
4423
+ "receipt": existing_receipt,
4424
+ }
4425
+ return {
4426
+ "ok": False,
4427
+ "action": "adopt-existing-range",
4428
+ "reasonCode": "EXISTING_RANGE_RECEIPT_CONFLICT",
4429
+ "message": "已有不同的既有范围认领收据,拒绝覆盖。",
4430
+ "receiptPath": str(path),
4431
+ }
4432
+ receipt = {
4433
+ "schemaVersion": 1,
4434
+ "action": "adopt-existing-range",
4435
+ "changeName": change_dir.name,
4436
+ "repositoryId": hp.repository_identity(project),
4437
+ "baseCommit": evaluated["baseCommit"],
4438
+ "tipCommit": evaluated["tipCommit"],
4439
+ "diffHash": ownership["diffHash"],
4440
+ "ownershipHash": ownership["ownershipHash"],
4441
+ "files": list(ownership.get("files") or []),
4442
+ "reason": reason,
4443
+ "confirmedByUser": True,
4444
+ "recordedAt": now_iso(),
4445
+ }
4446
+ receipt["receiptId"] = _archive_range_receipt_id(receipt)
4447
+ write_json(path, receipt)
4448
+ return {
4449
+ "ok": True,
4450
+ "action": "adopt-existing-range",
4451
+ "reasonCode": "EXISTING_RANGE_ADOPTED",
4452
+ "idempotent": False,
4453
+ "receiptPath": str(path),
4454
+ "receipt": receipt,
4455
+ }
4456
+
4457
+
3896
4458
  def _base_from_state_snapshot(change_dir: Path) -> str:
3897
4459
  """Read archive-boundary base from meta/state-snapshot.json when present."""
3898
4460
  for relative in (
@@ -3908,6 +4470,9 @@ def _base_from_state_snapshot(change_dir: Path) -> str:
3908
4470
  continue
3909
4471
  if not isinstance(payload, dict):
3910
4472
  continue
4473
+ change_base = str(payload.get("changeBase") or "").strip()
4474
+ if change_base and change_base != NOT_AVAILABLE:
4475
+ return change_base
3911
4476
  git_info = payload.get("git")
3912
4477
  if isinstance(git_info, dict):
3913
4478
  base = str(git_info.get("base") or git_info.get("baseCommit") or "").strip()
@@ -3919,6 +4484,27 @@ def _base_from_state_snapshot(change_dir: Path) -> str:
3919
4484
  return ""
3920
4485
 
3921
4486
 
4487
+ def _immutable_change_base_from_snapshot(change_dir: Path) -> str:
4488
+ for root in archive_read_roots(change_dir):
4489
+ for relative in (
4490
+ Path("meta") / "state-snapshot.json",
4491
+ Path("state-snapshot.json"),
4492
+ ):
4493
+ path = root / relative
4494
+ if not path.is_file():
4495
+ continue
4496
+ try:
4497
+ payload = read_json(path)
4498
+ except (OSError, json.JSONDecodeError, TypeError):
4499
+ continue
4500
+ if not isinstance(payload, dict):
4501
+ continue
4502
+ value = str(payload.get("changeBase") or "").strip()
4503
+ if value and value != NOT_AVAILABLE:
4504
+ return value
4505
+ return ""
4506
+
4507
+
3922
4508
  def _resolve_base_commit(
3923
4509
  ledger: dict[str, Any] | None,
3924
4510
  change_dir: Path,
@@ -3936,8 +4522,22 @@ def _resolve_base_commit(
3936
4522
 
3937
4523
  Latest merge phase context must not override the full change boundary.
3938
4524
  """
4525
+ def stable_base(value: Any) -> str:
4526
+ candidate = str(value or "").strip()
4527
+ if candidate.upper() in {"HEAD", "FETCH_HEAD", "ORIG_HEAD"}:
4528
+ return ""
4529
+ if candidate.startswith(("refs/", "@{")):
4530
+ return ""
4531
+ return candidate
4532
+
4533
+ adoption = validate_existing_range_adoption(change_dir)
4534
+ if adoption.get("ok") and adoption.get("present"):
4535
+ adopted_base = stable_base(adoption.get("baseCommit"))
4536
+ if adopted_base:
4537
+ return adopted_base
4538
+
3939
4539
  if ledger:
3940
- base = str(ledger.get("baseCommit") or "").strip()
4540
+ base = stable_base(ledger.get("baseCommit"))
3941
4541
  if base and base != NOT_AVAILABLE:
3942
4542
  return base
3943
4543
 
@@ -3959,7 +4559,7 @@ def _resolve_base_commit(
3959
4559
  continue
3960
4560
  if not isinstance(payload, dict):
3961
4561
  continue
3962
- base = str(payload.get("baseCommit") or "").strip()
4562
+ base = stable_base(payload.get("baseCommit"))
3963
4563
  if base and base != NOT_AVAILABLE:
3964
4564
  return base
3965
4565
 
@@ -3996,9 +4596,18 @@ def _final_commit_from_sources(
3996
4596
  if str(event.get("phase") or "").lower() not in {"submit", "merge", "archive"}:
3997
4597
  continue
3998
4598
  text = " ".join(str(event.get(key) or "") for key in ("note", "message", "command"))
3999
- match = commit_pattern.search(text)
4000
- if match:
4001
- return match.group(0)
4599
+ for match in commit_pattern.finditer(text):
4600
+ candidate = match.group(0)
4601
+ code, resolved, _ = git_run(
4602
+ project,
4603
+ "rev-parse",
4604
+ "--verify",
4605
+ f"{candidate}^{{commit}}",
4606
+ )
4607
+ resolved_lines = str(resolved or "").splitlines()
4608
+ resolved_hash = resolved_lines[0].strip() if resolved_lines else ""
4609
+ if code == 0 and re.fullmatch(r"[0-9a-f]{40}", resolved_hash, re.IGNORECASE):
4610
+ return resolved_hash
4002
4611
  if existing and existing.get("finalCommit"):
4003
4612
  return str(existing["finalCommit"])
4004
4613
  code, head, _ = git_run(project, "rev-parse", "HEAD")
@@ -4669,618 +5278,6 @@ def collect_summary_data(
4669
5278
 
4670
5279
 
4671
5280
  # ---------------------------------------------------------------------------
4672
- # render
4673
- # ---------------------------------------------------------------------------
4674
-
4675
-
4676
- def resolve_node_path(project_root: Path) -> str | None:
4677
- profile = project_root / ".harness" / "config" / "build-profile.json"
4678
- if profile.is_file():
4679
- try:
4680
- data = read_json(profile)
4681
- node = (data.get("toolPaths") or {}).get("node")
4682
- if node and Path(str(node)).exists():
4683
- return str(node)
4684
- except (OSError, json.JSONDecodeError, TypeError):
4685
- pass
4686
- return shutil.which("node")
4687
-
4688
-
4689
- def _render_legacy_fallback_html(summary: dict[str, Any]) -> str:
4690
- """Render escaped, deterministic HTML with every validate-required fact.
4691
-
4692
- Used when the Node renderer is unavailable or fails. No timestamps / random
4693
- data; all dynamic values are HTML-escaped via _html_escape.
4694
- """
4695
- def esc(v: Any) -> str:
4696
- return _html_escape("" if v is None else str(v))
4697
-
4698
- parts: list[str] = [
4699
- "<!DOCTYPE html>",
4700
- '<html lang="zh-CN"><head><meta charset="utf-8">',
4701
- "<title>harness final-summary (python fallback)</title>",
4702
- "</head><body>",
4703
- "<h1>变更最终报告(Python fallback 渲染)</h1>",
4704
- f'<h2 id="changeName">{esc(summary.get("changeName"))}</h2>',
4705
- f'<p><strong>finalStatus</strong>: '
4706
- f'<span id="finalStatus">{esc(summary.get("finalStatus"))}</span></p>',
4707
- f'<div id="finalStatusReasons"><strong>finalStatusReasons</strong><ul>',
4708
- ]
4709
- for reason in summary.get("finalStatusReasons") or []:
4710
- parts.append(f"<li>{esc(reason)}</li>")
4711
- parts.append("</ul></div>")
4712
- if summary.get("riskTier"):
4713
- parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
4714
- parts.append("<h3>Current Outcome</h3>")
4715
- parts.append(
4716
- f"<p>finalStatus={esc(summary.get('finalStatus'))}</p>"
4717
- )
4718
-
4719
- candidate = (
4720
- summary.get("candidateVerification")
4721
- if isinstance(summary.get("candidateVerification"), dict)
4722
- else {}
4723
- )
4724
- parts.append("<h3>Candidate Claim / Attestation</h3>")
4725
- parts.append(
4726
- f"<p>assurance={esc(candidate.get('assurance'))} "
4727
- f"code={esc(candidate.get('code'))} "
4728
- f"provider={esc(candidate.get('provider'))}</p>"
4729
- )
4730
-
4731
- integrity = (
4732
- summary.get("archiveIntegrity")
4733
- if isinstance(summary.get("archiveIntegrity"), dict)
4734
- else {}
4735
- )
4736
- parts.append("<h3>Archive Integrity</h3>")
4737
- parts.append(
4738
- f"<p>ok={esc(integrity.get('ok'))} "
4739
- f"code={esc(integrity.get('code'))} "
4740
- f"checksumStatus={esc(integrity.get('checksumStatus'))}</p>"
4741
- )
4742
-
4743
- decision = (
4744
- summary.get("releaseDecision")
4745
- if isinstance(summary.get("releaseDecision"), dict)
4746
- else {}
4747
- )
4748
- parts.append("<h3>Release Eligibility</h3>")
4749
- parts.append(
4750
- f"<p>releaseEligible={esc(decision.get('releaseEligible'))} "
4751
- f"code={esc(decision.get('code'))}</p><ul>"
4752
- )
4753
- checks = decision.get("checks")
4754
- checks = checks if isinstance(checks, dict) else {}
4755
- for name, check in checks.items():
4756
- check = check if isinstance(check, dict) else {}
4757
- parts.append(
4758
- f"<li>{esc(name)}: ok={esc(check.get('ok'))} "
4759
- f"code={esc(check.get('code'))}</li>"
4760
- )
4761
- parts.append("</ul>")
4762
-
4763
- timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
4764
- if timing:
4765
- parts.append("<h3>Timing</h3>")
4766
- parts.append(
4767
- "<p id=\"timingColumns\">"
4768
- f"stageActiveExecution={esc(timing.get('stageActiveExecutionMs'))} · "
4769
- f"stageWallClockSpan={esc(timing.get('stageWallClockSpanMs'))} · "
4770
- f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
4771
- "</p>"
4772
- )
4773
- parts.append(
4774
- "<p id=\"timingConservation\">"
4775
- f"attributedStageUnionMs={esc(timing.get('attributedStageUnionMs'))} · "
4776
- f"externalWaitMs={esc(timing.get('externalWaitMs'))} · "
4777
- f"pausedMs={esc(timing.get('pausedMs'))} · "
4778
- "agentOrToolUnattributedMs="
4779
- f"{esc(timing.get('agentOrToolUnattributedMs'))} · "
4780
- f"conservationDeltaMs={esc(timing.get('conservationDeltaMs'))}"
4781
- "</p>"
4782
- )
4783
- parts.append(
4784
- f"<p><strong>reportCutoffAt</strong>: "
4785
- f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
4786
- )
4787
- parts.append(
4788
- "<p><small>durations.totalMinutes is active-only; "
4789
- "do not treat it as workflow wall clock.</small></p>"
4790
- )
4791
- parts.append("<h3>History Quality</h3><ul>")
4792
- attempts = timing.get("attempts")
4793
- attempts = attempts if isinstance(attempts, list) else []
4794
- for attempt in attempts:
4795
- attempt = attempt if isinstance(attempt, dict) else {}
4796
- parts.append(
4797
- f"<li>{esc(attempt.get('phase'))}: "
4798
- f"{esc(attempt.get('terminalStatus') or attempt.get('status'))} "
4799
- f"durationMs={esc(attempt.get('durationMs'))}</li>"
4800
- )
4801
- if not attempts:
4802
- parts.append("<li>no typed attempts recorded</li>")
4803
- parts.append("</ul>")
4804
-
4805
- remote_cost = (
4806
- summary.get("remoteCost")
4807
- if isinstance(summary.get("remoteCost"), dict)
4808
- else {}
4809
- )
4810
- remote_totals = remote_cost.get("totals")
4811
- remote_totals = remote_totals if isinstance(remote_totals, dict) else {}
4812
- parts.append("<h3>Remote Cost</h3>")
4813
- parts.append(
4814
- f"<p>runnerMinutes={esc(remote_totals.get('runnerMinutes'))} "
4815
- f"queueWaitMs={esc(remote_totals.get('queueWaitMs'))} "
4816
- f"artifactBytes={esc(remote_totals.get('artifactBytes'))} "
4817
- f"duplicateRunCount={esc(remote_totals.get('duplicateRunCount'))}</p>"
4818
- )
4819
-
4820
- storage = (
4821
- summary.get("artifactStorage")
4822
- if isinstance(summary.get("artifactStorage"), dict)
4823
- else {}
4824
- )
4825
- parts.append("<h3>Artifact Storage</h3>")
4826
- parts.append(
4827
- f"<p>artifactCount={esc(storage.get('artifactCount'))} "
4828
- f"bytesAdded={esc(storage.get('bytesAdded'))} "
4829
- f"bytesReused={esc(storage.get('bytesReused'))} "
4830
- f"bytesPruned={esc(storage.get('bytesPruned'))}</p>"
4831
- )
4832
-
4833
- projection = (
4834
- summary.get("projection")
4835
- if isinstance(summary.get("projection"), dict)
4836
- else {}
4837
- )
4838
- parts.append("<h3>Projection / Fallback</h3>")
4839
- parts.append(
4840
- f"<p>mode={esc(projection.get('mode'))} "
4841
- f"code={esc(projection.get('code'))} "
4842
- f"remediation={esc(projection.get('remediation'))}</p>"
4843
- )
4844
-
4845
- if summary.get("productCommit") or summary.get("productTreeHash"):
4846
- parts.append("<h3>Identity</h3>")
4847
- parts.append(
4848
- f"<p>productCommit={esc(summary.get('productCommit'))} "
4849
- f"productTreeHash={esc(summary.get('productTreeHash'))} "
4850
- f"archiveCommit={esc(summary.get('archiveCommit'))}</p>"
4851
- )
4852
-
4853
- pipeline = summary.get("reportPipeline") or {}
4854
- cmds = pipeline.get("commands") or []
4855
- parts.append("<h3>Commands</h3><ul>")
4856
- for c in cmds:
4857
- parts.append(
4858
- f"<li><code>{esc(c.get('command'))}</code> "
4859
- f"exitCode={esc(c.get('exit_code'))}</li>"
4860
- )
4861
- parts.append("</ul>")
4862
-
4863
- ver = summary.get("verification") or {}
4864
- unit = ver.get("unitTests") or {}
4865
- api = ver.get("apiTests") or {}
4866
- browser = ver.get("browserE2E") or {}
4867
- parts.append("<h3>Verification</h3>")
4868
- parts.append(
4869
- "<p>unitTests: run={run} failures={failures} errors={errors} "
4870
- "skipped={skipped} passRate={passRate} status={status}</p>".format(
4871
- run=esc(unit.get("run")),
4872
- failures=esc(unit.get("failures")),
4873
- errors=esc(unit.get("errors")),
4874
- skipped=esc(unit.get("skipped")),
4875
- passRate=esc(unit.get("passRate")),
4876
- status=esc(unit.get("status")),
4877
- )
4878
- )
4879
- parts.append(
4880
- "<p>apiTests: status={status} total={total} passed={passed} "
4881
- "failed={failed} blocked={blocked}</p>".format(
4882
- status=esc(api.get("status")),
4883
- total=esc(api.get("total")),
4884
- passed=esc(api.get("passed")),
4885
- failed=esc(api.get("failed")),
4886
- blocked=esc(api.get("blocked")),
4887
- )
4888
- )
4889
- parts.append(
4890
- "<p>browserE2E: status={status} total={total} passed={passed} "
4891
- "failed={failed} skipped={skipped} retries={retries}</p>".format(
4892
- status=esc(browser.get("status")),
4893
- total=esc(browser.get("total")),
4894
- passed=esc(browser.get("passed")),
4895
- failed=esc(browser.get("failed")),
4896
- skipped=esc(browser.get("skipped")),
4897
- retries=esc(browser.get("retries")),
4898
- )
4899
- )
4900
- parts.append(f"<p>dbCompatibility: {esc(ver.get('dbCompatibility'))}</p>")
4901
-
4902
- parts.append("<h3>Changed Files</h3><ul>")
4903
- for f in summary.get("changedFiles") or []:
4904
- parts.append(
4905
- f"<li>{esc(f.get('path'))} +{esc(f.get('insertions'))} "
4906
- f"-{esc(f.get('deletions'))}</li>"
4907
- )
4908
- parts.append("</ul>")
4909
-
4910
- am = summary.get("archiveManifest") or {}
4911
- parts.append("<h3>Archive Manifest</h3>")
4912
- parts.append(
4913
- "<p>movedFiles={moved} generatedFiles={gen} totalArchiveFiles={total} "
4914
- "checksumStatus={cs}</p>".format(
4915
- moved=esc(am.get("movedFiles")),
4916
- gen=esc(am.get("generatedFiles")),
4917
- total=esc(am.get("totalArchiveFiles")),
4918
- cs=esc(am.get("checksumStatus")),
4919
- )
4920
- )
4921
-
4922
- def _list_section(title: str, items: Any) -> None:
4923
- parts.append(f"<h3>{esc(title)}</h3><ul>")
4924
- for it in items or []:
4925
- parts.append(f"<li>{esc(it)}</li>")
4926
- parts.append("</ul>")
4927
-
4928
- _list_section("Known Risks", summary.get("knownRisks"))
4929
- _list_section("Manual Actions", summary.get("manualActions"))
4930
- _list_section("Maintenance Notes", summary.get("maintenanceNotes"))
4931
-
4932
- parts.append("</body></html>")
4933
- return "\n".join(parts) + "\n"
4934
-
4935
-
4936
- def render_fallback_html(summary: dict[str, Any]) -> str:
4937
- """Render the same executive information architecture without Node."""
4938
-
4939
- def esc(value: Any) -> str:
4940
- return _html_escape("" if value is None else str(value))
4941
-
4942
- def rec(value: Any) -> dict[str, Any]:
4943
- return value if isinstance(value, dict) else {}
4944
-
4945
- def seq(value: Any) -> list[Any]:
4946
- return value if isinstance(value, list) else []
4947
-
4948
- normalized = rec(summary.get("normalizedReport"))
4949
- outcomes = rec(normalized.get("outcomes"))
4950
- current = rec(outcomes.get("current")) or {
4951
- "status": summary.get("finalStatus"),
4952
- "reasons": seq(summary.get("finalStatusReasons")),
4953
- "stages": rec(summary.get("stageStatus")),
4954
- "knownRisks": seq(summary.get("knownRisks")),
4955
- }
4956
- release = rec(outcomes.get("release")) or {
4957
- "intent": summary.get("archiveIntent"),
4958
- "decision": "NOT_REQUESTED"
4959
- if summary.get("archiveIntent") == "record-only"
4960
- else rec(summary.get("releaseDecision")).get("code"),
4961
- "eligible": rec(summary.get("releaseDecision")).get("releaseEligible"),
4962
- }
4963
- identity = rec(normalized.get("identity")) or rec(summary.get("changeIdentity"))
4964
- verification = rec(normalized.get("verification")) or rec(
4965
- summary.get("verification")
4966
- )
4967
- timing = rec(normalized.get("timing")) or rec(summary.get("timing"))
4968
- measurements = rec(normalized.get("measurements"))
4969
- record_only = (
4970
- release.get("intent") == "record-only"
4971
- or summary.get("archiveIntent") == "record-only"
4972
- )
4973
- product_commit = (
4974
- identity.get("productCommit")
4975
- or summary.get("productCommit")
4976
- or summary.get("finalCommit")
4977
- or "N/A"
4978
- )
4979
- labels = [
4980
- ("后端", ("unitTests", "dbCompatibility")),
4981
- ("Geo", ("geo",)),
4982
- ("前端", ("frontend",)),
4983
- ("浏览器", ("browserE2E",)),
4984
- ("API", ("apiTests",)),
4985
- ]
4986
- status_labels = {
4987
- "OK": "通过",
4988
- "PASS": "通过",
4989
- "PASSED": "通过",
4990
- "CONDITIONAL_OK": "有条件通过",
4991
- "WARN": "警告",
4992
- "ADVISORY": "建议",
4993
- "FAIL": "失败",
4994
- "FAILED": "失败",
4995
- "ERROR": "错误",
4996
- "BLOCKED": "阻塞",
4997
- "NOT_RUN": "未运行",
4998
- "SKIPPED": "已跳过",
4999
- "NOT_APPLICABLE": "不适用",
5000
- "UNKNOWN": "未知",
5001
- }
5002
-
5003
- def status_label(value: Any) -> str:
5004
- status = str(value or "UNKNOWN").upper()
5005
- return status_labels.get(status, status)
5006
-
5007
- def verification_status(value: Any) -> str:
5008
- if isinstance(value, str):
5009
- return value
5010
- item = rec(value)
5011
- if item.get("status"):
5012
- return str(item["status"])
5013
- if int(item.get("failures") or 0) + int(item.get("errors") or 0):
5014
- return "FAIL"
5015
- if int(item.get("run") or item.get("total") or 0):
5016
- return "OK"
5017
- return "NOT_RUN"
5018
-
5019
- def describe(value: Any) -> str:
5020
- if isinstance(value, str):
5021
- return value
5022
- item = rec(value)
5023
- return str(
5024
- item.get("title")
5025
- or item.get("message")
5026
- or item.get("summary")
5027
- or item.get("action")
5028
- or item.get("remediation")
5029
- or item
5030
- )
5031
-
5032
- def measurement_label(value: Any) -> str:
5033
- item = rec(value)
5034
- if item.get("state") in {"unknown", "not_applicable"}:
5035
- return "N/A"
5036
- if item.get("state") == "zero":
5037
- return "0"
5038
- if item.get("state") == "known":
5039
- return str(item.get("value"))
5040
- return "N/A" if value is None or value == "" else str(value)
5041
-
5042
- risks = (
5043
- seq(current.get("findings"))
5044
- or seq(current.get("knownRisks"))
5045
- or seq(summary.get("knownRisks"))
5046
- )
5047
- actions = seq(summary.get("manualActions"))
5048
- groups: list[str] = []
5049
- passed = 0
5050
- for label, keys in labels:
5051
- statuses = [
5052
- verification_status(verification[key])
5053
- for key in keys
5054
- if key in verification
5055
- ]
5056
- status = (
5057
- "FAIL"
5058
- if any(value in {"FAIL", "ERROR", "BLOCKED"} for value in statuses)
5059
- else "WARN"
5060
- if any("WARN" in value or "CONDITIONAL" in value for value in statuses)
5061
- else "NOT_APPLICABLE"
5062
- if statuses and all(value == "NOT_APPLICABLE" for value in statuses)
5063
- else "OK"
5064
- if statuses
5065
- and all(
5066
- value in {"OK", "PASS", "PASSED", "NOT_APPLICABLE"}
5067
- for value in statuses
5068
- )
5069
- else "NOT_RUN"
5070
- )
5071
- if status == "OK":
5072
- passed += 1
5073
- details = " · ".join(
5074
- str(rec(verification.get(key)).get("passRate") or status)
5075
- for key in keys
5076
- if key in verification
5077
- ) or "未配置该组验证"
5078
- raw_details = " · ".join(
5079
- f"{key} · status={verification_status(verification[key])}"
5080
- + (
5081
- f" · failed={rec(verification[key]).get('failed')}"
5082
- if rec(verification[key]).get("failed") is not None
5083
- else ""
5084
- )
5085
- for key in keys
5086
- if key in verification
5087
- )
5088
- groups.append(
5089
- f'<article class="verify" title="{esc(raw_details)}"><strong>{esc(label)}</strong>'
5090
- f'<span title="{esc(status)}">{esc(status_label(status))}</span><small>{esc(details)}</small></article>'
5091
- )
5092
-
5093
- commands = seq(rec(summary.get("reportPipeline")).get("commands"))
5094
- command_rows = "".join(
5095
- f"<tr><td>{esc(item.get('phase') or '-')}</td>"
5096
- f"<td><code>{esc(item.get('command'))}</code></td>"
5097
- f"<td>{esc('OK' if int(item.get('exit_code', item.get('exitCode', 1))) == 0 else 'FAIL')}</td></tr>"
5098
- for item in commands
5099
- if isinstance(item, dict)
5100
- ) or '<tr><td colspan="3">没有命令证据</td></tr>'
5101
- files = seq(summary.get("changedFiles"))
5102
- file_rows = "".join(
5103
- f"<tr><td><code>{esc(item.get('path') or item.get('file'))}</code></td>"
5104
- f"<td>+{esc(item.get('insertions') or 0)}</td>"
5105
- f"<td>-{esc(item.get('deletions') or 0)}</td></tr>"
5106
- for item in files
5107
- if isinstance(item, dict)
5108
- ) or '<tr><td colspan="3">没有变更文件证据</td></tr>'
5109
- risk_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in risks) or "<li>当前没有未处置风险</li>"
5110
- action_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in actions) or "<li>无需人工后续动作</li>"
5111
- release_card = (
5112
- ""
5113
- if record_only
5114
- else "<article class=\"card\"><h2>发布与候选</h2>"
5115
- f"<p>候选证明 / 发布资格:{esc(release.get('decision') or 'NOT_EVALUATED')}</p></article>"
5116
- )
5117
- remote = rec(measurements.get("remoteCost"))
5118
- storage = rec(measurements.get("artifactStorage"))
5119
- remote_totals = rec(remote.get("totals"))
5120
- projection = rec(summary.get("projection"))
5121
- archive_integrity = rec(summary.get("archiveIntegrity"))
5122
- durability = rec(summary.get("archiveDurability"))
5123
- durability_status = str(
5124
- durability.get("status") or "ARCHIVED_LOCAL_ONLY"
5125
- )
5126
- scenario_coverage = rec(summary.get("scenarioCoverage"))
5127
- unexecuted_scenarios = [
5128
- str(item) for item in seq(scenario_coverage.get("unexecuted"))
5129
- ]
5130
- scenario_status = str(
5131
- scenario_coverage.get("code")
5132
- or ("SCENARIO_COVERAGE_OK" if scenario_coverage.get("ok") else "NOT_RUN")
5133
- )
5134
- total_files = rec(summary.get("archiveManifest")).get("totalArchiveFiles")
5135
- total_files_text = "" if total_files is None else str(total_files)
5136
-
5137
- return f"""<!doctype html>
5138
- <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5139
- <title>Harness 最终报告 · {esc(summary.get("changeName"))}</title>
5140
- <style>
5141
- :root{{--bg:#f4f6f8;--card:#fff;--text:#172033;--muted:#667085;--line:#dfe5ec}}
5142
- @media(prefers-color-scheme:dark){{:root{{--bg:#0b1119;--card:#121a26;--text:#eef3f9;--muted:#9aa8ba;--line:#2a384a}}}}
5143
- *{{box-sizing:border-box}}html,body{{max-width:100%;overflow-x:hidden}}body{{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif}}main{{width:min(1120px,calc(100% - 32px));margin:24px auto}}.hero,.card,.metric,.verify,details{{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}}.hero h1{{margin:4px 0}}.metrics,.groups{{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin:10px 0}}.metric strong,.verify strong{{display:block;font-size:17px}}.verify small{{display:block;color:var(--muted)}}.risk{{display:grid;grid-template-columns:1fr 1fr;gap:10px}}.card{{margin:10px 0}}details{{margin:9px 0}}summary{{cursor:pointer;font-weight:700}}details>div{{overflow:auto}}table{{width:100%;border-collapse:collapse}}td,th{{padding:8px;border-bottom:1px solid var(--line);text-align:left}}code{{overflow-wrap:anywhere}}@media(max-width:600px){{main{{width:calc(100% - 18px);margin:9px auto}}.metrics,.groups,.risk{{grid-template-columns:1fr}}}}
5144
- </style></head><body><main>
5145
- <section class="hero"><small>HARNESS · 管理结论</small><h1>{esc(summary.get("changeName"))}</h1>
5146
- <p>{esc(summary.get("businessGoal") or "未记录业务目标")}</p>
5147
- <strong title="{esc(current.get("status"))}">{esc(status_label(current.get("status")))}</strong>
5148
- <p id="finalStatusReasons">{esc(" · ".join(str(item) for item in seq(current.get("reasons"))))}</p>
5149
- {"<p>归档意图:仅记录 · 未请求发布</p>" if record_only else ""}</section>
5150
- <section class="metrics"><article class="metric"><small>产品提交</small><strong><code>{esc(str(product_commit)[:10])}</code></strong></article>
5151
- <article class="metric"><small>验证概览</small><strong>{passed}/5 组通过</strong></article>
5152
- <article class="metric"><small>风险与动作</small><strong>{len(risks)} / {len(actions)}</strong></article>
5153
- <article class="metric"><small>全流程耗时</small><strong>{esc(timing.get("workflowWallClockMs") or "N/A")}</strong></article>
5154
- <article class="metric"><small>归档文件</small><strong>{esc(total_files_text or "N/A")}</strong></article></section>
5155
- <article class="card"><h2>验证概览</h2><div class="groups">{"".join(groups)}</div></article>
5156
- <article class="card"><h2>场景执行闭环</h2>
5157
- <p>结构化运行回执:{esc(scenario_status)}</p>
5158
- <p>未执行 / 未通过:{esc(", ".join(unexecuted_scenarios) or "无")}</p></article>
5159
- <article class="card"><h2>风险与动作</h2><div class="risk"><section><h3>当前风险</h3><ul>{risk_rows}</ul></section><section><h3>人工动作</h3><ul>{action_rows}</ul></section></div></article>
5160
- {release_card}
5161
- <details><summary>技术证据 · 时间守恒</summary><div><p>conservationDeltaMs={esc(timing.get("conservationDeltaMs") if "conservationDeltaMs" in timing else "N/A")}</p>
5162
- <p>workflowWallClock={esc(timing.get("workflowWallClockMs") if "workflowWallClockMs" in timing else "N/A")}</p>
5163
- <p>stageActiveExecution={esc(timing.get("stageActiveExecutionMs") if "stageActiveExecutionMs" in timing else "N/A")}</p>
5164
- <p>stageWallClockSpan={esc(timing.get("stageWallClockSpanMs") if "stageWallClockSpanMs" in timing else "N/A")}</p>
5165
- <p>reportCutoffAt={esc(timing.get("reportCutoffAt") or "N/A")}</p>
5166
- <p>远端 runner 成本:{esc(measurement_label(remote_totals.get("runnerMinutes") or remote.get("runnerMinutes")))}</p>
5167
- <p>新增制品字节:{esc(measurement_label(storage.get("bytesAdded")))}</p></div></details>
5168
- <details><summary>技术证据 · 发布与归档治理</summary><div>
5169
- <p>归档完整性:{esc(archive_integrity.get("code") or rec(summary.get("archiveManifest")).get("checksumStatus") or "N/A")}</p>
5170
- <p>归档持久性:{esc(durability_status)} · {esc(durability.get("risk") or "已通过持久介质回读校验")}</p>
5171
- <p>保留策略:{esc(durability.get("retentionPolicy") or "N/A")}</p>
5172
- <p>历史质量:未闭合尝试 {esc(timing.get("unclosedAttemptCount") if "unclosedAttemptCount" in timing else "N/A")}</p>
5173
- <p>投影状态:{esc(projection.get("code") or projection.get("mode") or "N/A")}</p></div></details>
5174
- <details><summary>技术证据 · 变更文件</summary><div><table><tbody>{file_rows}</tbody></table></div></details>
5175
- <details><summary>技术证据 · 命令</summary><div><table><tbody>{command_rows}</tbody></table></div></details>
5176
- <details><summary>技术元数据</summary><div><p>productCommit=<code>{esc(product_commit)}</code></p>
5177
- <p>schemaVersion={esc(summary.get("schemaVersion"))}</p></div></details>
5178
- </main></body></html>
5179
- """
5180
-
5181
-
5182
-
5183
- # P1 slim-files: final-summary.html is a regenerable presentation layer over
5184
- # summary-data.json. `finalize --no-html` skips local HTML rendering entirely
5185
- # (a remote platform can render from summary-data.json); render failures are
5186
- # downgraded to warnings instead of rolling back the archive.
5187
- RENDER_HTML_ENABLED = True
5188
-
5189
-
5190
- def render_final_summary(
5191
- change_dir: Path,
5192
- summary_path: Path,
5193
- ) -> dict[str, Any]:
5194
- """Render final-summary.html via Node; fall back to a Python renderer.
5195
-
5196
- Returns ``{ok, renderer, fallbackReason, out_path}``:
5197
- - Node success -> renderer="node".
5198
- - Node unavailable/timeout/non-zero/no-file -> Python fallback; success ->
5199
- renderer="python-fallback" (fallbackReason carries the node failure cause).
5200
- - Both fail or produce no file -> ok=False (caller downgrades to warning).
5201
- - Rendering disabled (--no-html) -> ok=True with renderer="skipped".
5202
- """
5203
- out_path = change_dir / "reports" / "final" / "final-summary.html"
5204
- if not RENDER_HTML_ENABLED:
5205
- return {
5206
- "ok": True,
5207
- "renderer": "skipped",
5208
- "fallbackReason": "",
5209
- "out_path": str(out_path),
5210
- "skipped": True,
5211
- }
5212
- project = find_project_root(change_dir)
5213
- node = resolve_node_path(project)
5214
- fallback_reason = ""
5215
-
5216
- if node and RENDER_SCRIPT.is_file():
5217
- out_path.parent.mkdir(parents=True, exist_ok=True)
5218
- try:
5219
- proc = subprocess.run(
5220
- [
5221
- node,
5222
- str(RENDER_SCRIPT),
5223
- "--summary",
5224
- str(summary_path),
5225
- "--out",
5226
- str(out_path),
5227
- ],
5228
- capture_output=True,
5229
- text=True,
5230
- encoding="utf-8",
5231
- errors="replace",
5232
- timeout=60,
5233
- check=False,
5234
- )
5235
- if proc.returncode == 0 and out_path.is_file():
5236
- return {
5237
- "ok": True,
5238
- "renderer": "node",
5239
- "fallbackReason": "",
5240
- "out_path": str(out_path),
5241
- }
5242
- fallback_reason = (
5243
- f"node render exit {proc.returncode}: "
5244
- f"{(proc.stderr or proc.stdout or '')[:200]}"
5245
- )
5246
- except (OSError, subprocess.TimeoutExpired) as exc:
5247
- fallback_reason = f"node render failed: {exc}"
5248
- else:
5249
- fallback_reason = (
5250
- "node unavailable" if not node else f"renderer missing: {RENDER_SCRIPT}"
5251
- )
5252
-
5253
- # Python fallback
5254
- try:
5255
- summary = read_json(summary_path)
5256
- html = render_fallback_html(summary)
5257
- out_path.parent.mkdir(parents=True, exist_ok=True)
5258
- out_path.write_text(html, encoding="utf-8", newline="\n")
5259
- except (OSError, json.JSONDecodeError, ValueError) as exc:
5260
- return {
5261
- "ok": False,
5262
- "renderer": "none",
5263
- "fallbackReason": f"{fallback_reason}; fallback failed: {exc}",
5264
- "out_path": str(out_path),
5265
- }
5266
- if out_path.is_file():
5267
- return {
5268
- "ok": True,
5269
- "renderer": "python-fallback",
5270
- "fallbackReason": fallback_reason,
5271
- "out_path": str(out_path),
5272
- }
5273
- return {
5274
- "ok": False,
5275
- "renderer": "none",
5276
- "fallbackReason": f"{fallback_reason}; fallback produced no file",
5277
- "out_path": str(out_path),
5278
- }
5279
-
5280
-
5281
- # ---------------------------------------------------------------------------
5282
- # validate
5283
- # ---------------------------------------------------------------------------
5284
5281
 
5285
5282
 
5286
5283
  def _manifest_self_stats(
@@ -5312,22 +5309,24 @@ def _append_finalize_failure_terminal(
5312
5309
  *,
5313
5310
  operation_id: str,
5314
5311
  ) -> None:
5315
- """Persist one failed finalize attempt on the authoritative change source."""
5312
+ """Record a blocked preparation without manufacturing an archive attempt."""
5316
5313
  if not authoritative_change_dir.is_dir():
5317
5314
  return
5318
5315
  try:
5319
5316
  append_event(
5320
5317
  authoritative_change_dir,
5321
5318
  phase="archive",
5322
- type_="phase.start",
5323
- note=f"finalize operation {operation_id} failed before publish",
5319
+ type_="phase.prepare.start",
5320
+ note="开始检查归档条件",
5324
5321
  )
5325
5322
  append_event(
5326
5323
  authoritative_change_dir,
5327
5324
  phase="archive",
5328
- type_="phase.end",
5329
- status="FAIL",
5330
- note=f"finalize operation {operation_id} discarded: {message}",
5325
+ type_="phase.prepare.end",
5326
+ status="BLOCKED",
5327
+ code="ARCHIVE_PREPARE_BLOCKED",
5328
+ message=f"归档条件未满足:{message}",
5329
+ note=f"归档操作 {operation_id} 未进入正式执行",
5331
5330
  )
5332
5331
  except OSError:
5333
5332
  pass
@@ -5501,302 +5500,62 @@ def validate_source_consistency(
5501
5500
  "message": f"summary {field} does not match frozen source projection",
5502
5501
  }
5503
5502
  )
5504
-
5505
- # 4. Manifest structure/checksums and summary semantics.
5506
- before_path = change_dir / "evidence" / "archive-manifest-before.json"
5507
- if before_path.is_file():
5508
- try:
5509
- manifest = read_json(before_path)
5510
- entries = manifest.get("files") if isinstance(manifest, dict) else None
5511
- valid = isinstance(entries, list) and manifest.get("fileCount") == len(entries)
5512
- seen: set[str] = set()
5513
- if valid:
5514
- for entry in entries:
5515
- rel = str(entry.get("path") or "") if isinstance(entry, dict) else ""
5516
- digest = str(entry.get("sha256") or "") if isinstance(entry, dict) else ""
5517
- if (
5518
- not rel
5519
- or rel in seen
5520
- or re.fullmatch(r"[0-9a-f]{64}", digest) is None
5521
- ):
5522
- valid = False
5523
- break
5524
- seen.add(rel)
5525
- target = (change_dir / rel).resolve()
5526
- if not target.is_relative_to(change_dir.resolve()) or not target.is_file():
5527
- valid = False
5528
- break
5529
- if not _manifest_path_excluded(rel) and sha256_file(target) != digest:
5530
- valid = False
5531
- break
5532
- if not valid:
5533
- issues.append(
5534
- {
5535
- "code": "manifest-invalid",
5536
- "severity": "error",
5537
- "message": "archive-manifest-before structure or checksum is invalid",
5538
- }
5539
- )
5540
- except (OSError, json.JSONDecodeError):
5541
- issues.append(
5542
- {
5543
- "code": "manifest-invalid",
5544
- "severity": "error",
5545
- "message": "archive-manifest-before is unreadable",
5546
- }
5547
- )
5548
-
5549
- # 5. Artifact paths must resolve to immutable files inside the archive.
5550
- root = change_dir.resolve()
5551
- for artifact in summary.get("artifacts") or []:
5552
- raw = str(artifact.get("path") or "") if isinstance(artifact, dict) else ""
5553
- candidate = (root / raw).resolve() if raw else root
5554
- if not raw or not candidate.is_relative_to(root) or not candidate.is_file():
5555
- issues.append(
5556
- {
5557
- "code": "artifact-missing",
5558
- "severity": "error",
5559
- "message": f"artifact path is missing or outside archive: {raw}",
5560
- }
5561
- )
5562
-
5563
- errors = [i for i in issues if i.get("severity") == "error"]
5564
- warnings = [i for i in issues if i.get("severity") != "error"]
5565
- return {
5566
- "ok": len(errors) == 0,
5567
- "issues": issues,
5568
- "error_count": len(errors),
5569
- "warning_count": len(warnings),
5570
- }
5571
-
5572
-
5573
- def validate_summary(
5574
- summary: dict[str, Any],
5575
- html_path: Path | None,
5576
- *,
5577
- render_skipped: bool = False,
5578
- ) -> dict[str, Any]:
5579
- """Validate final-summary covers summary-data key facts (in-process)."""
5580
- issues: list[dict[str, str]] = []
5581
-
5582
- change_id = str(summary.get("changeName") or "")
5583
- html = ""
5584
- if html_path and html_path.is_file():
5585
- try:
5586
- html = html_path.read_text(encoding="utf-8-sig")
5587
- except OSError as exc:
5588
- issues.append(
5589
- {
5590
- "code": "missing-final-report",
5591
- "severity": "error",
5592
- "message": f"cannot read final-summary: {exc}",
5593
- }
5594
- )
5595
- else:
5596
- # P1 slim-files: final-summary.html 是 summary-data.json 的可再生展示
5597
- # 投影,不再是硬产物(summary-data + manifest + adequacy 仍 fail-closed)。
5598
- # 缺 HTML 恒为 warning,不阻断归档收尾;平台端可按 summary-data 渲染。
5599
- _ = render_skipped # kept for call-site compatibility
5600
- issues.append(
5601
- {
5602
- "code": "missing-final-report",
5603
- "severity": "warning",
5604
- "message": "reports/final/final-summary.html not found",
5605
- }
5606
- )
5607
-
5608
- def has_text(needle: str) -> bool:
5609
- if not needle or needle == NOT_AVAILABLE:
5610
- return True
5611
- return needle in html
5612
-
5613
- # change id
5614
- if html and change_id and not has_text(change_id):
5615
- issues.append(
5616
- {
5617
- "code": "missing-change-id",
5618
- "severity": "error",
5619
- "message": f"final-summary missing change id '{change_id}'",
5620
- }
5621
- )
5622
-
5623
- # key commands — stock render-summary.mjs does not embed reportPipeline.commands.
5624
- # Require commands to be present in summary-data; HTML absence is warning only.
5625
- commands = (summary.get("reportPipeline") or {}).get("commands") or []
5626
- if html:
5627
- for cmd in commands[:8]:
5628
- c = str(cmd.get("command") or "").strip()
5629
- if not c:
5630
- continue
5631
- fragment = c if len(c) <= 60 else c[:60]
5632
- token = c.split()[-1] if c.split() else c
5633
- in_html = (
5634
- fragment in html
5635
- or _html_escape(fragment) in html
5636
- or (len(token) >= 4 and (token in html or _html_escape(token) in html))
5637
- )
5638
- if not in_html:
5639
- issues.append(
5640
- {
5641
- "code": "missing-command",
5642
- "severity": "warning",
5643
- "message": (
5644
- f"final-summary HTML omits command (renderer may not "
5645
- f"embed commands): {c}"
5646
- ),
5647
- }
5648
- )
5649
-
5650
- # verification
5651
- ver = summary.get("verification") or {}
5652
- unit = ver.get("unitTests") or {}
5653
- api = ver.get("apiTests") or {}
5654
- browser = ver.get("browserE2E") or {}
5655
- pass_rate = unit.get("passRate")
5656
- if pass_rate and pass_rate != NOT_AVAILABLE and str(pass_rate) not in html:
5657
- issues.append(
5658
- {
5659
- "code": "missing-verification",
5660
- "severity": "warning",
5661
- "message": f"unitTests.passRate '{pass_rate}' not in final-summary",
5662
- }
5663
- )
5664
- api_status = str(api.get("status") or "")
5665
- if api_status and api_status not in {"", NOT_AVAILABLE} and api_status not in html:
5666
- # Renderer shows api status; soft warning
5667
- if api_status in {"USER_SKIPPED", "BLOCKED_BY_DBA", "NOT_RUN", "PARTIAL"}:
5668
- issues.append(
5669
- {
5670
- "code": "missing-verification",
5671
- "severity": "warning",
5672
- "message": f"apiTests.status '{api_status}' not visible in final-summary",
5673
- }
5674
- )
5675
- browser_status = str(browser.get("status") or "")
5676
- if (
5677
- browser_status
5678
- and browser_status not in {"", NOT_AVAILABLE}
5679
- and browser_status not in html
5680
- ):
5681
- issues.append(
5682
- {
5683
- "code": "missing-verification",
5684
- "severity": "warning",
5685
- "message": (
5686
- f"browserE2E.status '{browser_status}' not visible "
5687
- "in final-summary"
5688
- ),
5689
- }
5690
- )
5691
-
5692
- # artifacts / summary-data path hints
5693
- am = summary.get("archiveManifest") or {}
5694
- total = am.get("totalArchiveFiles")
5695
- if total is not None and str(total) not in html:
5696
- issues.append(
5697
- {
5698
- "code": "missing-artifact",
5699
- "severity": "warning",
5700
- "message": "archiveManifest.totalArchiveFiles not reflected in final-summary",
5701
- }
5702
- )
5703
-
5704
- # risks / manual actions — empty arrays are OK (placeholder)
5705
- for risk in summary.get("knownRisks") or []:
5706
- # UT-016/RET-28: canonical projection — structured risk objects
5707
- # project to their message; str(dict) never matches rendered HTML.
5708
- if isinstance(risk, dict):
5709
- text = str(risk.get("message") or risk.get("summary") or "").strip()
5710
- else:
5711
- text = str(risk)
5712
- if text and text not in html and _html_escape(text) not in html:
5503
+
5504
+ # 4. Manifest structure/checksums and summary semantics.
5505
+ before_path = change_dir / "evidence" / "archive-manifest-before.json"
5506
+ if before_path.is_file():
5507
+ try:
5508
+ manifest = read_json(before_path)
5509
+ entries = manifest.get("files") if isinstance(manifest, dict) else None
5510
+ valid = isinstance(entries, list) and manifest.get("fileCount") == len(entries)
5511
+ seen: set[str] = set()
5512
+ if valid:
5513
+ for entry in entries:
5514
+ rel = str(entry.get("path") or "") if isinstance(entry, dict) else ""
5515
+ digest = str(entry.get("sha256") or "") if isinstance(entry, dict) else ""
5516
+ if (
5517
+ not rel
5518
+ or rel in seen
5519
+ or re.fullmatch(r"[0-9a-f]{64}", digest) is None
5520
+ ):
5521
+ valid = False
5522
+ break
5523
+ seen.add(rel)
5524
+ target = (change_dir / rel).resolve()
5525
+ if not target.is_relative_to(change_dir.resolve()) or not target.is_file():
5526
+ valid = False
5527
+ break
5528
+ if not _manifest_path_excluded(rel) and sha256_file(target) != digest:
5529
+ valid = False
5530
+ break
5531
+ if not valid:
5713
5532
  issues.append(
5714
5533
  {
5715
- "code": "missing-risk",
5716
- "severity": "warning",
5717
- "message": f"knownRisk not in final-summary: {text[:80]}",
5534
+ "code": "manifest-invalid",
5535
+ "severity": "error",
5536
+ "message": "archive-manifest-before structure or checksum is invalid",
5718
5537
  }
5719
5538
  )
5720
-
5721
- # status contradiction
5722
- final_status = str(summary.get("finalStatus") or "")
5723
- browser_conditional = {
5724
- "USER_SKIPPED",
5725
- "BLOCKED",
5726
- "BLOCKED_BY_ENV",
5727
- "NOT_RUN",
5728
- "PARTIAL",
5729
- }
5730
- has_skip = api_status == "USER_SKIPPED" or browser_status in browser_conditional or str(
5731
- ver.get("dbCompatibility") or ""
5732
- ) == "BLOCKED_BY_DBA"
5733
- has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
5734
- has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
5735
- has_fail_ver = (
5736
- has_fail_ver
5737
- or int(browser.get("failed") or 0) > 0
5738
- or browser_status == "FAIL"
5739
- )
5740
- stage = summary.get("stageStatus") or {}
5741
- has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
5742
-
5743
- if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
5539
+ except (OSError, json.JSONDecodeError):
5744
5540
  issues.append(
5745
5541
  {
5746
- "code": "status-contradiction",
5542
+ "code": "manifest-invalid",
5747
5543
  "severity": "error",
5748
- "message": (
5749
- "finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
5750
- "failed verification present"
5751
- ),
5544
+ "message": "archive-manifest-before is unreadable",
5752
5545
  }
5753
5546
  )
5754
- if has_skip and html:
5755
- if re.search(r">\s*OK\s*<", html) and "CONDITIONAL" not in html.upper():
5756
- if final_status != "CONDITIONAL_OK":
5757
- issues.append(
5758
- {
5759
- "code": "status-contradiction",
5760
- "severity": "error",
5761
- "message": "final-summary shows pure OK despite USER_SKIPPED/BLOCKED",
5762
- }
5763
- )
5764
- else:
5765
- # No HTML (and not render_skipped handled above): still check data-level status rules
5766
- ver = summary.get("verification") or {}
5767
- unit = ver.get("unitTests") or {}
5768
- api = ver.get("apiTests") or {}
5769
- browser = ver.get("browserE2E") or {}
5770
- api_status = str(api.get("status") or "")
5771
- browser_status = str(browser.get("status") or "")
5772
- final_status = str(summary.get("finalStatus") or "")
5773
- has_skip = api_status == "USER_SKIPPED" or browser_status in {
5774
- "USER_SKIPPED",
5775
- "BLOCKED",
5776
- "BLOCKED_BY_ENV",
5777
- "NOT_RUN",
5778
- "PARTIAL",
5779
- } or str(
5780
- ver.get("dbCompatibility") or ""
5781
- ) == "BLOCKED_BY_DBA"
5782
- has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
5783
- has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
5784
- has_fail_ver = (
5785
- has_fail_ver
5786
- or int(browser.get("failed") or 0) > 0
5787
- or browser_status == "FAIL"
5788
- )
5789
- stage = summary.get("stageStatus") or {}
5790
- has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
5791
- if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
5547
+
5548
+ # 5. Artifact paths must resolve to immutable files inside the archive.
5549
+ root = change_dir.resolve()
5550
+ for artifact in summary.get("artifacts") or []:
5551
+ raw = str(artifact.get("path") or "") if isinstance(artifact, dict) else ""
5552
+ candidate = (root / raw).resolve() if raw else root
5553
+ if not raw or not candidate.is_relative_to(root) or not candidate.is_file():
5792
5554
  issues.append(
5793
5555
  {
5794
- "code": "status-contradiction",
5556
+ "code": "artifact-missing",
5795
5557
  "severity": "error",
5796
- "message": (
5797
- "finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
5798
- "failed verification present"
5799
- ),
5558
+ "message": f"artifact path is missing or outside archive: {raw}",
5800
5559
  }
5801
5560
  )
5802
5561
 
@@ -5810,14 +5569,55 @@ def validate_summary(
5810
5569
  }
5811
5570
 
5812
5571
 
5813
- def _html_escape(text: str) -> str:
5814
- return (
5815
- text.replace("&", "&amp;")
5816
- .replace("<", "&lt;")
5817
- .replace(">", "&gt;")
5818
- .replace('"', "&quot;")
5819
- .replace("'", "&#39;")
5572
+ def validate_summary_data(summary: dict[str, Any]) -> dict[str, Any]:
5573
+ """Validate canonical summary data without creating an HTML projection."""
5574
+ issues: list[dict[str, str]] = []
5575
+ verification = summary.get("verification") or {}
5576
+ verification = verification if isinstance(verification, dict) else {}
5577
+ unit = verification.get("unitTests") or {}
5578
+ api = verification.get("apiTests") or {}
5579
+ browser = verification.get("browserE2E") or {}
5580
+ unit = unit if isinstance(unit, dict) else {}
5581
+ api = api if isinstance(api, dict) else {}
5582
+ browser = browser if isinstance(browser, dict) else {}
5583
+ api_status = str(api.get("status") or "")
5584
+ browser_status = str(browser.get("status") or "")
5585
+ final_status = str(summary.get("finalStatus") or "")
5586
+ has_skip = api_status == "USER_SKIPPED" or browser_status in {
5587
+ "USER_SKIPPED",
5588
+ "BLOCKED",
5589
+ "BLOCKED_BY_ENV",
5590
+ "NOT_RUN",
5591
+ "PARTIAL",
5592
+ } or str(verification.get("dbCompatibility") or "") == "BLOCKED_BY_DBA"
5593
+ has_failed_verification = (
5594
+ int(unit.get("failures") or 0) > 0
5595
+ or int(unit.get("errors") or 0) > 0
5596
+ or int(api.get("failed") or 0) > 0
5597
+ or int(browser.get("failed") or 0) > 0
5598
+ or browser_status == "FAIL"
5820
5599
  )
5600
+ stage = summary.get("stageStatus") or {}
5601
+ stage = stage if isinstance(stage, dict) else {}
5602
+ has_failed_stage = any(str(value).upper() == "FAIL" for value in stage.values())
5603
+ if (has_skip or has_failed_verification or has_failed_stage) and final_status == "OK":
5604
+ issues.append(
5605
+ {
5606
+ "code": "status-contradiction",
5607
+ "severity": "error",
5608
+ "message": (
5609
+ "finalStatus is pure OK but skipped, blocked, or failed "
5610
+ "verification is present"
5611
+ ),
5612
+ }
5613
+ )
5614
+ errors = [item for item in issues if item.get("severity") == "error"]
5615
+ return {
5616
+ "ok": not errors,
5617
+ "issues": issues,
5618
+ "error_count": len(errors),
5619
+ "warning_count": len(issues) - len(errors),
5620
+ }
5821
5621
 
5822
5622
 
5823
5623
  # ---------------------------------------------------------------------------
@@ -5834,6 +5634,10 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5834
5634
  a lossy projection of itself.
5835
5635
  """
5836
5636
  issues: list[dict[str, str]] = []
5637
+ closure_disposition = str(
5638
+ summary.get("closureDisposition") or "completed"
5639
+ ).strip()
5640
+ unfinished_closure = closure_disposition in {"abandoned", "superseded"}
5837
5641
 
5838
5642
  # H-12: prefer top-level baseCommit + diffStat; gitFacts is a mirror.
5839
5643
  base = str(summary.get("baseCommit") or "").strip()
@@ -5858,7 +5662,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5858
5662
  final_present = bool(
5859
5663
  (final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
5860
5664
  )
5861
- if final_present and base_missing:
5665
+ if final_present and base_missing and not unfinished_closure:
5862
5666
  issues.append({
5863
5667
  "code": "IDENTITY_BASE_MISSING",
5864
5668
  "severity": "error",
@@ -5875,6 +5679,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5875
5679
  and final != NOT_AVAILABLE
5876
5680
  and base != final
5877
5681
  and files_changed == 0
5682
+ and not unfinished_closure
5878
5683
  ):
5879
5684
  issues.append({
5880
5685
  "code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
@@ -5902,13 +5707,14 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5902
5707
  if not feature_tip:
5903
5708
  feature_tip = str(identity_doc_early.get("productCommit") or "").strip()
5904
5709
 
5905
- if (
5710
+ collapsed_boundary = (
5906
5711
  base
5907
5712
  and base != NOT_AVAILABLE
5908
5713
  and feature_tip
5909
5714
  and feature_tip != NOT_AVAILABLE
5910
5715
  and base == feature_tip
5911
- ):
5716
+ )
5717
+ if collapsed_boundary and not unfinished_closure:
5912
5718
  issues.append({
5913
5719
  "code": "ARCHIVE_BASE_EQUALS_FEATURE_TIP",
5914
5720
  "severity": "error",
@@ -5917,6 +5723,15 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5917
5723
  "archive boundary collapsed to an empty/merge-only delta"
5918
5724
  ),
5919
5725
  })
5726
+ elif unfinished_closure and files_changed == 0:
5727
+ issues.append({
5728
+ "code": "NO_PRODUCT_DELTA",
5729
+ "severity": "warning",
5730
+ "message": (
5731
+ "未完成变更没有产生产品增量;仅封存过程、原因与已有证据,"
5732
+ "不得作为发布候选。"
5733
+ ),
5734
+ })
5920
5735
 
5921
5736
  ownership = summary.get("ownershipDiff") or summary.get("ownership")
5922
5737
  ownership = ownership if isinstance(ownership, dict) else {}
@@ -5929,7 +5744,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5929
5744
  changed_files = summary.get("changedFiles")
5930
5745
  changed_count = len(changed_files) if isinstance(changed_files, list) else files_changed
5931
5746
 
5932
- if ownership_count > 0 and changed_count == 0:
5747
+ if ownership_count > 0 and changed_count == 0 and not unfinished_closure:
5933
5748
  issues.append({
5934
5749
  "code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
5935
5750
  "severity": "error",
@@ -5939,7 +5754,12 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5939
5754
  "but does not represent the full change"
5940
5755
  ),
5941
5756
  })
5942
- elif ownership_count > 0 and changed_count > 0 and changed_count * 3 < ownership_count:
5757
+ elif (
5758
+ ownership_count > 0
5759
+ and changed_count > 0
5760
+ and changed_count * 3 < ownership_count
5761
+ and not unfinished_closure
5762
+ ):
5943
5763
  issues.append({
5944
5764
  "code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
5945
5765
  "severity": "error",
@@ -5950,7 +5770,11 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5950
5770
  })
5951
5771
 
5952
5772
  merge_parents = summary.get("mergeParents") or summary.get("mergeParentHashes")
5953
- if isinstance(merge_parents, list) and len(merge_parents) >= 2:
5773
+ if (
5774
+ isinstance(merge_parents, list)
5775
+ and len(merge_parents) >= 2
5776
+ and not unfinished_closure
5777
+ ):
5954
5778
  first_parent = str(merge_parents[0] or "").strip()
5955
5779
  if (
5956
5780
  base
@@ -6173,7 +5997,15 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
6173
5997
  }
6174
5998
  )
6175
5999
 
6176
- return {"ok": not issues, "issues": issues}
6000
+ error_count = sum(
6001
+ 1 for issue in issues if str(issue.get("severity") or "error") == "error"
6002
+ )
6003
+ return {
6004
+ "ok": error_count == 0,
6005
+ "issues": issues,
6006
+ "error_count": error_count,
6007
+ "warning_count": len(issues) - error_count,
6008
+ }
6177
6009
 
6178
6010
 
6179
6011
  RELEASE_CHECKS = (
@@ -7466,6 +7298,7 @@ def cmd_finalize(
7466
7298
  archive_intent: str = "release-candidate",
7467
7299
  closure_disposition: str = "completed",
7468
7300
  closure_reason: str = "",
7301
+ preflight_status: dict[str, Any] | None = None,
7469
7302
  ) -> tuple[int, dict[str, Any]]:
7470
7303
  """Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
7471
7304
  if archive_intent not in {"release-candidate", "record-only"}:
@@ -7536,6 +7369,11 @@ def cmd_finalize(
7536
7369
  if resolved_state_dir.resolve() != original_change_dir.resolve()
7537
7370
  else None
7538
7371
  )
7372
+ authoritative_event_dir = (
7373
+ split_state_dir
7374
+ if split_state_dir is not None and split_state_dir.is_dir()
7375
+ else original_change_dir
7376
+ )
7539
7377
  operation_id = f"a-{uuid.uuid4().hex[:12]}"
7540
7378
  operation_root = project_root / ".harness" / "archive-operations"
7541
7379
  operation_temp_dir = operation_root / "staging" / operation_id / change_dir.name
@@ -7545,13 +7383,22 @@ def cmd_finalize(
7545
7383
  shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
7546
7384
  payload["original_preserved"] = original_change_dir.is_dir()
7547
7385
  payload["finalStatus"] = "FAIL"
7548
- _append_finalize_failure_terminal(
7549
- split_state_dir
7550
- if split_state_dir is not None and split_state_dir.is_dir()
7551
- else original_change_dir,
7552
- str(payload.get("error") or "archive finalize failed"),
7553
- operation_id=operation_id,
7554
- )
7386
+ if authoritative_event_dir.is_dir():
7387
+ try:
7388
+ append_event(
7389
+ authoritative_event_dir,
7390
+ phase="archive",
7391
+ type_="phase.end",
7392
+ status="FAIL",
7393
+ code=str(
7394
+ payload.get("reasonCode")
7395
+ or "ARCHIVE_FINALIZE_FAILED"
7396
+ ),
7397
+ message=str(payload.get("error") or "归档执行失败"),
7398
+ note=f"归档操作 {operation_id} 失败,原变更目录已保留",
7399
+ )
7400
+ except OSError:
7401
+ pass
7555
7402
  try:
7556
7403
  write_json(
7557
7404
  operation_record,
@@ -7595,6 +7442,65 @@ def cmd_finalize(
7595
7442
  payload["error"] = f"archive target already exists: {archive_dir}"
7596
7443
  return 1, payload
7597
7444
 
7445
+ status = preflight_status or check_status(
7446
+ original_change_dir,
7447
+ allow_missing_review=allow_missing_review,
7448
+ archive_intent=archive_intent,
7449
+ closure_disposition=closure_disposition,
7450
+ closure_reason=closure_reason,
7451
+ )
7452
+ payload["steps"]["preflight"] = status
7453
+ if not status.get("archivable"):
7454
+ report_adequacy = (
7455
+ (status.get("releaseDecision") or {}).get("checks") or {}
7456
+ ).get("reportAdequacy") or {}
7457
+ adequacy_failed = report_adequacy.get("ok") is False
7458
+ payload["reasonCode"] = (
7459
+ "ARCHIVE_REPORT_ADEQUACY_FAILED"
7460
+ if adequacy_failed
7461
+ else "ARCHIVE_PRECONDITIONS_UNSATISFIED"
7462
+ )
7463
+ payload["error"] = (
7464
+ "归档报告不完整,未进入暂存与发布阶段。"
7465
+ if adequacy_failed
7466
+ else "归档前置条件未满足,未进入暂存与发布阶段。"
7467
+ )
7468
+ payload["issues"] = [
7469
+ {
7470
+ "code": str(
7471
+ item.get("code") or "ARCHIVE_PRECONDITION_FAILED"
7472
+ ).replace("-", "_").upper(),
7473
+ "severity": "error",
7474
+ "message": str(item.get("message") or payload["error"]),
7475
+ }
7476
+ for item in status.get("blockers") or []
7477
+ if isinstance(item, dict)
7478
+ ]
7479
+ payload["original_preserved"] = original_change_dir.is_dir()
7480
+ payload["finalStatus"] = "BLOCKED"
7481
+ _append_finalize_failure_terminal(
7482
+ split_state_dir
7483
+ if split_state_dir is not None and split_state_dir.is_dir()
7484
+ else original_change_dir,
7485
+ payload["error"],
7486
+ operation_id=operation_id,
7487
+ )
7488
+ return 1, payload
7489
+
7490
+ # The formal archive attempt starts after the one-shot preflight succeeds,
7491
+ # before scans and staging begin. Keeping this start in authoritative state
7492
+ # makes the monitor show the real duration and exactly one attempt.
7493
+ if authoritative_event_dir.is_dir():
7494
+ try:
7495
+ append_event(
7496
+ authoritative_event_dir,
7497
+ phase="archive",
7498
+ type_="phase.start",
7499
+ note="归档条件已通过,开始扫描、暂存与发布",
7500
+ )
7501
+ except OSError as exc:
7502
+ warnings.append(f"archive phase start append failed: {exc}")
7503
+
7598
7504
  source_sensitive_refresh = hruntime.refresh_sensitive_evidence_scan_receipt(
7599
7505
  original_change_dir,
7600
7506
  persist=False,
@@ -7615,8 +7521,7 @@ def cmd_finalize(
7615
7521
  "message": payload["error"],
7616
7522
  }
7617
7523
  ]
7618
- payload["original_preserved"] = original_change_dir.is_dir()
7619
- payload["finalStatus"] = "FAIL"
7524
+ _restore_finalize_failure()
7620
7525
  return 1, payload
7621
7526
 
7622
7527
  source_sensitive_gate = validate_sensitive_evidence_publication_gate(
@@ -7642,8 +7547,7 @@ def cmd_finalize(
7642
7547
  "message": payload["error"],
7643
7548
  }
7644
7549
  ]
7645
- payload["original_preserved"] = original_change_dir.is_dir()
7646
- payload["finalStatus"] = "FAIL"
7550
+ _restore_finalize_failure()
7647
7551
  return 1, payload
7648
7552
 
7649
7553
  exact_byte = check_archive_exact_byte_policy(project_root)
@@ -7674,15 +7578,7 @@ def cmd_finalize(
7674
7578
  ),
7675
7579
  }
7676
7580
  ]
7677
- payload["original_preserved"] = original_change_dir.is_dir()
7678
- payload["finalStatus"] = "FAIL"
7679
- _append_finalize_failure_terminal(
7680
- split_state_dir
7681
- if split_state_dir is not None and split_state_dir.is_dir()
7682
- else original_change_dir,
7683
- payload["error"],
7684
- operation_id=operation_id,
7685
- )
7581
+ _restore_finalize_failure()
7686
7582
  return 1, payload
7687
7583
 
7688
7584
  payload["steps"]["service_stop_before_staging"] = run_service_stop(
@@ -7801,9 +7697,6 @@ def cmd_finalize(
7801
7697
  except OSError as exc:
7802
7698
  warnings.append(f"event append failed: {exc}")
7803
7699
 
7804
- # Step 9 starts here: phase.start
7805
- _safe_append(phase="archive", type_="phase.start", note="finalize start")
7806
-
7807
7700
  # --- 0. cleanup transients (before before-manifest) ---
7808
7701
  try:
7809
7702
  cleanup_result = _cleanup_transients(work_dir)
@@ -8125,32 +8018,12 @@ def cmd_finalize(
8125
8018
  payload["ok"] = False
8126
8019
  return 1, payload
8127
8020
 
8128
- # --- 6. render (Node, else Python fallback) ---
8129
- render_result = render_final_summary(work_dir, summary_path)
8130
- payload["steps"]["render"] = render_result
8131
- if not render_result.get("ok"):
8132
- # final-summary.html 是 summary-data.json 的可再生展示层:渲染失败
8133
- # 降级为 warning,归档继续(平台端可按 summary-data 重新渲染)。
8134
- msg = str(render_result.get("fallbackReason") or "render failed")
8135
- warnings.append(f"final-summary render failed (archive continues): {msg}")
8136
- renderer = render_result.get("renderer")
8137
- if renderer == "python-fallback" and render_result.get("fallbackReason"):
8138
- warnings.append(
8139
- f"node render unavailable; used python-fallback: "
8140
- f"{render_result.get('fallbackReason')}"
8141
- )
8142
-
8143
- html_path = work_dir / "reports" / "final" / "final-summary.html"
8144
-
8145
- # --- 7. renderer consistency (layer 2) ---
8021
+ # --- 6. canonical summary validation ---
8146
8022
  try:
8147
8023
  summary = read_json(summary_path)
8148
8024
  except (OSError, json.JSONDecodeError):
8149
8025
  pass
8150
- validate_result = validate_summary(
8151
- summary,
8152
- html_path if html_path.is_file() else None,
8153
- )
8026
+ validate_result = validate_summary_data(summary)
8154
8027
  payload["steps"]["validate"] = validate_result
8155
8028
  summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
8156
8029
  "issues"
@@ -8169,10 +8042,10 @@ def cmd_finalize(
8169
8042
  warnings.append(f"archive-meta write failed: {exc}")
8170
8043
  payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
8171
8044
 
8172
- # --- 9/10. final summary stats + render, then LAST manifest (IA-7) ---
8173
- # Post-manifest rewrites of covered bytes are forbidden. We update summary /
8174
- # HTML first, regenerate after-manifest last, then verify on-disk hashes.
8175
- # If summary/html must still change after that, they are excluded with reasons.
8045
+ # --- 9/10. final summary stats, then LAST manifest (IA-7) ---
8046
+ # Post-manifest rewrites of covered bytes are forbidden. We update the
8047
+ # summary first, regenerate after-manifest last, then verify on-disk hashes.
8048
+ # If summary must still change after that, it is excluded with a reason.
8176
8049
  summary = read_json(summary_path)
8177
8050
  summary["archiveManifest"] = {
8178
8051
  "movedFiles": 0,
@@ -8181,14 +8054,8 @@ def cmd_finalize(
8181
8054
  "checksumStatus": "PENDING",
8182
8055
  }
8183
8056
  write_json(summary_path, summary)
8184
- render_result = render_final_summary(work_dir, summary_path)
8185
- if not render_result.get("ok"):
8186
- warnings.append(
8187
- "final summary re-render failed (archive continues): "
8188
- f"{render_result.get('fallbackReason') or render_result.get('error')}"
8189
- )
8190
8057
  summary = read_json(summary_path)
8191
- validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
8058
+ validate_result = validate_summary_data(summary)
8192
8059
  payload["steps"]["validate"] = validate_result
8193
8060
  summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
8194
8061
  "issues"
@@ -8214,25 +8081,15 @@ def cmd_finalize(
8214
8081
  "reports/final/summary-data.json": (
8215
8082
  "archiveManifest stats written after coverage snapshot"
8216
8083
  ),
8217
- "reports/final/final-summary.html": (
8218
- "re-rendered after coverage snapshot to display manifest stats"
8219
- ),
8220
8084
  },
8221
8085
  **_manifest_self_stats(work_dir, after_manifest, after_path),
8222
8086
  }
8223
8087
  write_json(summary_path, summary)
8224
- render_result = render_final_summary(work_dir, summary_path)
8225
- if not render_result.get("ok"):
8226
- warnings.append(
8227
- "post-manifest summary render failed (archive continues): "
8228
- f"{render_result.get('fallbackReason') or render_result.get('error')}"
8229
- )
8230
8088
  coverage = verify_manifest_byte_coverage(
8231
8089
  work_dir,
8232
8090
  after_manifest,
8233
8091
  exclude_paths=[
8234
8092
  "reports/final/summary-data.json",
8235
- "reports/final/final-summary.html",
8236
8093
  ],
8237
8094
  exclusion_reasons=summary["archiveManifest"]["exclusionReasons"],
8238
8095
  )
@@ -8288,12 +8145,8 @@ def cmd_finalize(
8288
8145
  ]
8289
8146
  payload["releaseEligible"] = summary["releaseEligible"]
8290
8147
  write_json(summary_path, summary)
8291
- # Re-render once more for checksumStatus display; remain excluded.
8292
- render_final_summary(work_dir, summary_path)
8293
8148
  summary = read_json(summary_path)
8294
- validate_result = validate_summary(
8295
- summary, html_path if html_path.is_file() else None
8296
- )
8149
+ validate_result = validate_summary_data(summary)
8297
8150
  payload["steps"]["validate"] = validate_result
8298
8151
  summary.setdefault("reportPipeline", {})["validationIssues"] = (
8299
8152
  validate_result.get("issues") or []
@@ -8407,7 +8260,6 @@ def cmd_finalize(
8407
8260
  shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
8408
8261
  work_dir = archive_dir
8409
8262
  summary_path = work_dir / "reports" / "final" / "summary-data.json"
8410
- html_path = work_dir / "reports" / "final" / "final-summary.html"
8411
8263
  payload["steps"]["move"] = {"ok": True, "to": str(archive_dir)}
8412
8264
  except OSError as exc:
8413
8265
  payload["error"] = f"publish failed: {exc}"
@@ -8508,7 +8360,6 @@ def cmd_finalize(
8508
8360
  payload["finalStatus"] = "OK"
8509
8361
  payload["warnings"] = warnings
8510
8362
  payload["summary_data"] = str(summary_path)
8511
- payload["final_summary"] = str(html_path) if html_path.is_file() else None
8512
8363
  return 0, payload
8513
8364
 
8514
8365
 
@@ -8845,6 +8696,59 @@ def _deterministic_zip_info(path: str) -> zipfile.ZipInfo:
8845
8696
  return info
8846
8697
 
8847
8698
 
8699
+ _WINDOWS_LOCAL_PATH_RE = re.compile(
8700
+ r"\b[A-Za-z]:\\(?:[^\s<>:\"|?*]+\\)*[^\s<>:\"|?*]*"
8701
+ )
8702
+
8703
+
8704
+ def _archive_remote_json_value(value: Any) -> Any:
8705
+ """Remove machine-local absolute paths from the remote JSON projection."""
8706
+ if isinstance(value, dict):
8707
+ return {
8708
+ key: _archive_remote_json_value(item)
8709
+ for key, item in value.items()
8710
+ }
8711
+ if isinstance(value, list):
8712
+ return [_archive_remote_json_value(item) for item in value]
8713
+ if not isinstance(value, str):
8714
+ return value
8715
+ if re.match(r"^[A-Za-z]:[\\/]", value) or value.startswith("\\\\"):
8716
+ return "<local-path>"
8717
+ return _WINDOWS_LOCAL_PATH_RE.sub("<local-path>", value)
8718
+
8719
+
8720
+ def _archive_remote_json_bytes(raw: bytes) -> bytes:
8721
+ value = json.loads(raw)
8722
+ projected = _archive_remote_json_value(value)
8723
+ return (
8724
+ json.dumps(
8725
+ projected,
8726
+ ensure_ascii=False,
8727
+ sort_keys=True,
8728
+ separators=(",", ":"),
8729
+ )
8730
+ + "\n"
8731
+ ).encode("utf-8")
8732
+
8733
+
8734
+ def resolve_npx_launcher() -> list[str]:
8735
+ """Resolve npx without asking CreateProcess to execute a Windows .CMD shim."""
8736
+ npx = shutil.which("npx")
8737
+ if not npx:
8738
+ raise FileNotFoundError("npx is not installed or not available on PATH")
8739
+ if os.name != "nt":
8740
+ return [npx]
8741
+
8742
+ node = shutil.which("node")
8743
+ if not node:
8744
+ raise FileNotFoundError("node is required to launch npx on Windows")
8745
+ npx_path = Path(npx)
8746
+ npx_cli = npx_path.parent / "node_modules" / "npm" / "bin" / "npx-cli.js"
8747
+ if not npx_cli.is_file():
8748
+ raise FileNotFoundError(f"npx-cli.js not found beside Node.js: {npx_cli}")
8749
+ return [node, str(npx_cli)]
8750
+
8751
+
8848
8752
  def build_archive_package(
8849
8753
  project_root: Path,
8850
8754
  archive_dir: Path,
@@ -8875,7 +8779,7 @@ def build_archive_package(
8875
8779
  # All semantic archive inputs are text and must round-trip as UTF-8.
8876
8780
  raw.decode("utf-8")
8877
8781
  if media_type == "application/json":
8878
- json.loads(raw)
8782
+ raw = _archive_remote_json_bytes(raw)
8879
8783
  content_by_path[package_path] = raw
8880
8784
  entries.append(
8881
8785
  {
@@ -9104,8 +9008,21 @@ def auto_push_archive_core(
9104
9008
 
9105
9009
  # The published CLI owns credentials, project binding, durable receipt checks,
9106
9010
  # and retries. Generic `push` is intentionally not used for archive bytes.
9011
+ try:
9012
+ launcher = resolve_npx_launcher()
9013
+ except OSError as exc:
9014
+ reason_code = "ARCHIVE_UPLOAD_DEFERRED"
9015
+ try:
9016
+ persist_receipt(upload_status="pending", reason_code=reason_code)
9017
+ except OSError:
9018
+ pass
9019
+ return {
9020
+ **base_result,
9021
+ "reasonCode": reason_code,
9022
+ "warning": f"归档自动上传已延后:{exc}",
9023
+ }
9107
9024
  command = [
9108
- "npx",
9025
+ *launcher,
9109
9026
  "--yes",
9110
9027
  "hunter-harness",
9111
9028
  "archive",
@@ -9359,13 +9276,7 @@ def cmd_replay(
9359
9276
  # Collect without writing into the archive
9360
9277
  summary = collect_summary_data(archive_dir, write=False, for_replay=True)
9361
9278
 
9362
- html_path = archive_dir / "reports" / "final" / "final-summary.html"
9363
- render_skipped = not html_path.is_file()
9364
- validate_result = validate_summary(
9365
- summary,
9366
- html_path if html_path.is_file() else None,
9367
- render_skipped=render_skipped,
9368
- )
9279
+ validate_result = validate_summary_data(summary)
9369
9280
  summary.setdefault("reportPipeline", {})["validationIssues"] = (
9370
9281
  validate_result.get("issues") or []
9371
9282
  )
@@ -9423,6 +9334,20 @@ def cmd_auto_gate_cli(args: argparse.Namespace) -> int:
9423
9334
  resolve_path(args.change_dir),
9424
9335
  allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
9425
9336
  archive_intent=str(getattr(args, "intent", "release-candidate")),
9337
+ closure_disposition=str(getattr(args, "closure", "completed")),
9338
+ closure_reason=str(getattr(args, "closure_reason", "")),
9339
+ )
9340
+ emit_json(result)
9341
+ return 0 if result.get("ok") else 1
9342
+
9343
+
9344
+ def cmd_adopt_existing_range_cli(args: argparse.Namespace) -> int:
9345
+ result = adopt_existing_range(
9346
+ resolve_path(args.change_dir),
9347
+ base=str(args.base),
9348
+ tip=str(args.tip),
9349
+ reason=str(args.reason),
9350
+ confirmed=bool(getattr(args, "confirm_existing_range", False)),
9426
9351
  )
9427
9352
  emit_json(result)
9428
9353
  return 0 if result.get("ok") else 1
@@ -9462,9 +9387,6 @@ def cmd_certify_local_cli(args: argparse.Namespace) -> int:
9462
9387
 
9463
9388
 
9464
9389
  def cmd_finalize_cli(args: argparse.Namespace) -> int:
9465
- global RENDER_HTML_ENABLED
9466
- if getattr(args, "no_html", False):
9467
- RENDER_HTML_ENABLED = False
9468
9390
  change_dir = resolve_path(args.change_dir)
9469
9391
  archive_root = resolve_path(args.archive_root)
9470
9392
  durable_root = (
@@ -9489,6 +9411,29 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
9489
9411
  return code
9490
9412
 
9491
9413
 
9414
+ def cmd_execute_cli(args: argparse.Namespace) -> int:
9415
+ durable_root = (
9416
+ resolve_path(args.durable_root)
9417
+ if getattr(args, "durable_root", None)
9418
+ else None
9419
+ )
9420
+ code, payload = execute_archive(
9421
+ resolve_path(args.change_dir),
9422
+ resolve_path(args.archive_root),
9423
+ durable_root=durable_root,
9424
+ retention_policy=str(
9425
+ getattr(args, "retention_policy", "unspecified")
9426
+ ),
9427
+ skip_ingest=bool(args.skip_ingest),
9428
+ allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
9429
+ archive_intent=str(getattr(args, "intent", "release-candidate")),
9430
+ closure_disposition=str(getattr(args, "closure", "completed")),
9431
+ closure_reason=str(getattr(args, "closure_reason", "")),
9432
+ )
9433
+ emit_json(payload)
9434
+ return code
9435
+
9436
+
9492
9437
  def cmd_restore_durable_cli(args: argparse.Namespace) -> int:
9493
9438
  receipt_path = resolve_path(args.receipt)
9494
9439
  target_root = resolve_path(args.target_root)
@@ -9516,50 +9461,12 @@ def cmd_replay_cli(args: argparse.Namespace) -> int:
9516
9461
  return code
9517
9462
 
9518
9463
 
9519
- def _render_html_to(
9520
- change_dir: Path,
9521
- summary_path: Path,
9522
- out_path: Path,
9523
- ) -> dict[str, Any]:
9524
- """Render summary HTML to an arbitrary path (node first, python fallback).
9525
-
9526
- Unlike ``render_final_summary`` this never touches the canonical
9527
- ``reports/final/final-summary.html`` — repair renders into staging.
9528
- """
9529
- project = find_project_root(change_dir)
9530
- node = resolve_node_path(project)
9531
- out_path.parent.mkdir(parents=True, exist_ok=True)
9532
- if node and RENDER_SCRIPT.is_file():
9533
- try:
9534
- proc = subprocess.run(
9535
- [node, str(RENDER_SCRIPT), "--summary", str(summary_path), "--out", str(out_path)],
9536
- capture_output=True,
9537
- text=True,
9538
- encoding="utf-8",
9539
- errors="replace",
9540
- timeout=60,
9541
- check=False,
9542
- )
9543
- if proc.returncode == 0 and out_path.is_file():
9544
- return {"ok": True, "renderer": "node", "out_path": str(out_path)}
9545
- except (OSError, subprocess.TimeoutExpired):
9546
- pass
9547
- try:
9548
- summary = read_json(summary_path)
9549
- html = render_fallback_html(summary)
9550
- out_path.write_text(html, encoding="utf-8", newline="\n")
9551
- except (OSError, json.JSONDecodeError, ValueError) as exc:
9552
- return {"ok": False, "renderer": "none", "error": str(exc)}
9553
- return {"ok": True, "renderer": "python-fallback", "out_path": str(out_path)}
9554
-
9555
-
9556
9464
  def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9557
9465
  """Versioned repair (task 11 / RET-40).
9558
9466
 
9559
- Re-collect a candidate from the frozen sources, run both validators, and
9560
- only then write an immutable ``derived/v<N>/`` plus a repair record. The
9561
- original summary/HTML/manifest is never overwritten; the authoritative
9562
- pointer moves only when both validators pass.
9467
+ Re-collect a candidate from the frozen sources, validate its source and
9468
+ canonical data, then write an immutable ``derived/v<N>/`` plus a repair
9469
+ record. The original summary and manifest are never overwritten.
9563
9470
  """
9564
9471
  payload: dict[str, Any] = {
9565
9472
  "ok": False,
@@ -9596,25 +9503,18 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9596
9503
  payload["error"] = f"repair collect failed: {exc}"
9597
9504
  return 1, payload
9598
9505
 
9599
- # 2. stage outside the archive; run both validators on the candidate.
9506
+ # 2. stage outside the archive; validate canonical data only.
9600
9507
  staging = Path(tempfile.mkdtemp(prefix="harness-repair-"))
9601
9508
  try:
9602
9509
  staged_summary = staging / "summary-data.json"
9603
9510
  write_json(staged_summary, candidate)
9604
9511
  source_result = validate_source_consistency(archive_dir, candidate)
9605
- render_result = _render_html_to(
9606
- archive_dir, staged_summary, staging / "final-summary.html"
9607
- )
9608
- staged_html = staging / "final-summary.html"
9609
- renderer_result = validate_summary(
9610
- candidate,
9611
- staged_html if staged_html.is_file() else None,
9612
- )
9512
+ summary_result = validate_summary_data(candidate)
9613
9513
  payload["validators"] = {
9614
9514
  "source": source_result,
9615
- "renderer": renderer_result,
9515
+ "summary": summary_result,
9616
9516
  }
9617
- if not (source_result.get("ok") and renderer_result.get("ok")):
9517
+ if not (source_result.get("ok") and summary_result.get("ok")):
9618
9518
  payload["error"] = "repair validators failed; derived version not written"
9619
9519
  return 1, payload
9620
9520
 
@@ -9637,8 +9537,6 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9637
9537
  target_manifest = staged_version_dir / manifest_name
9638
9538
  shutil.copy2(source_manifest, target_manifest)
9639
9539
  frozen_manifest_hashes[manifest_name] = "sha256:" + sha256_file(target_manifest)
9640
- if staged_html.is_file():
9641
- shutil.copy2(staged_html, staged_version_dir / "final-summary.html")
9642
9540
  record = {
9643
9541
  "version": version,
9644
9542
  "createdAt": now_iso(),
@@ -9647,7 +9545,7 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9647
9545
  "frozenManifestHashes": frozen_manifest_hashes,
9648
9546
  "validators": {
9649
9547
  "source": {"ok": bool(source_result.get("ok")), "issues": source_result.get("issues") or []},
9650
- "renderer": {"ok": bool(renderer_result.get("ok")), "issues": renderer_result.get("issues") or []},
9548
+ "summary": {"ok": bool(summary_result.get("ok")), "issues": summary_result.get("issues") or []},
9651
9549
  },
9652
9550
  }
9653
9551
  write_json(staged_version_dir / "repair-record.json", record)
@@ -9723,8 +9621,26 @@ def build_parser() -> argparse.ArgumentParser:
9723
9621
  choices=("release-candidate", "record-only"),
9724
9622
  default="release-candidate",
9725
9623
  )
9624
+ p_auto_gate.add_argument(
9625
+ "--closure",
9626
+ choices=("completed", "abandoned", "superseded"),
9627
+ default="completed",
9628
+ )
9629
+ p_auto_gate.add_argument("--closure-reason", default="")
9726
9630
  p_auto_gate.set_defaults(func=cmd_auto_gate_cli)
9727
9631
 
9632
+ p_adopt = sub.add_parser(
9633
+ "adopt-existing-range",
9634
+ help="explicitly bind an already committed product range to this change",
9635
+ )
9636
+ p_adopt.add_argument("--change-dir", required=True)
9637
+ p_adopt.add_argument("--base", required=True)
9638
+ p_adopt.add_argument("--tip", required=True)
9639
+ p_adopt.add_argument("--reason", required=True)
9640
+ p_adopt.add_argument("--confirm-existing-range", action="store_true")
9641
+ p_adopt.add_argument("--json", action="store_true", default=True)
9642
+ p_adopt.set_defaults(func=cmd_adopt_existing_range_cli)
9643
+
9728
9644
  p_cert = sub.add_parser(
9729
9645
  "certify-local",
9730
9646
  help="create candidate receipt from existing full ledger evidence without rerunning tests",
@@ -9769,14 +9685,33 @@ def build_parser() -> argparse.ArgumentParser:
9769
9685
  default="release-candidate",
9770
9686
  help="release-candidate enforces candidate evidence; record-only archives facts without release eligibility",
9771
9687
  )
9772
- p_fin.add_argument(
9773
- "--no-html",
9774
- action="store_true",
9775
- help="skip local final-summary.html rendering (summary-data.json stays canonical)",
9776
- )
9777
9688
  p_fin.add_argument("--json", action="store_true", default=True)
9778
9689
  p_fin.set_defaults(func=cmd_finalize_cli)
9779
9690
 
9691
+ p_execute = sub.add_parser(
9692
+ "execute",
9693
+ help="run one archive preflight, boundary gate, finalize, ZIP, and upload flow",
9694
+ )
9695
+ p_execute.add_argument("--change-dir", required=True)
9696
+ p_execute.add_argument("--archive-root", required=True)
9697
+ p_execute.add_argument("--durable-root", default=None)
9698
+ p_execute.add_argument(
9699
+ "--closure",
9700
+ choices=("completed", "abandoned", "superseded"),
9701
+ default="completed",
9702
+ )
9703
+ p_execute.add_argument("--closure-reason", default="")
9704
+ p_execute.add_argument("--retention-policy", default="unspecified")
9705
+ p_execute.add_argument("--skip-ingest", action="store_true")
9706
+ p_execute.add_argument("--allow-missing-review", action="store_true")
9707
+ p_execute.add_argument(
9708
+ "--intent",
9709
+ choices=("release-candidate", "record-only"),
9710
+ default="release-candidate",
9711
+ )
9712
+ p_execute.add_argument("--json", action="store_true", default=True)
9713
+ p_execute.set_defaults(func=cmd_execute_cli)
9714
+
9780
9715
  p_restore = sub.add_parser(
9781
9716
  "restore-durable",
9782
9717
  help="verify a durable receipt and restore its archive without overwriting",