@hunter-harness/workflow-harness 0.2.60 → 0.2.62

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 (218) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/README.md +7 -7
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +35 -35
  4. package/harness/bundles/general/claude-code/harness-archive/checklist.md +5 -2
  5. package/harness/bundles/general/claude-code/harness-archive/reference.md +23 -7
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -4
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +2 -2
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +2 -2
  15. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +7 -3
  16. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +1 -1
  17. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +6 -4
  18. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +3 -2
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +829 -68
  20. package/harness/bundles/general/claude-code/scripts/harness_context.py +8 -2
  21. package/harness/bundles/general/claude-code/scripts/harness_events.py +5 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +191 -26
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +79 -2
  24. package/harness/bundles/general/claude-code/scripts/harness_profile.py +7 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_state.py +22 -3
  26. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  27. package/harness/bundles/general/codebuddy/README.md +7 -7
  28. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +35 -35
  29. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +5 -2
  30. package/harness/bundles/general/codebuddy/harness-archive/reference.md +23 -7
  31. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
  35. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  36. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +2 -2
  37. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  38. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +2 -2
  40. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +7 -3
  41. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +1 -1
  42. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +6 -4
  43. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +3 -2
  44. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +829 -68
  45. package/harness/bundles/general/codebuddy/scripts/harness_context.py +8 -2
  46. package/harness/bundles/general/codebuddy/scripts/harness_events.py +5 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +191 -26
  48. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +79 -2
  49. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +7 -0
  50. package/harness/bundles/general/codebuddy/scripts/harness_state.py +22 -3
  51. package/harness/bundles/general/codex/.harness-build.json +1 -1
  52. package/harness/bundles/general/codex/README.md +7 -7
  53. package/harness/bundles/general/codex/harness-archive/SKILL.md +35 -35
  54. package/harness/bundles/general/codex/harness-archive/checklist.md +5 -2
  55. package/harness/bundles/general/codex/harness-archive/reference.md +23 -7
  56. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
  60. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  61. package/harness/bundles/general/codex/harness-run/SKILL.md +2 -2
  62. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  63. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-test/SKILL.md +2 -2
  65. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +7 -3
  66. package/harness/bundles/general/codex/protocols/ledger-protocol.md +1 -1
  67. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +6 -4
  68. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +3 -2
  69. package/harness/bundles/general/codex/scripts/harness_archive.py +829 -68
  70. package/harness/bundles/general/codex/scripts/harness_context.py +8 -2
  71. package/harness/bundles/general/codex/scripts/harness_events.py +5 -0
  72. package/harness/bundles/general/codex/scripts/harness_events_sync.py +191 -26
  73. package/harness/bundles/general/codex/scripts/harness_ledger.py +79 -2
  74. package/harness/bundles/general/codex/scripts/harness_profile.py +7 -0
  75. package/harness/bundles/general/codex/scripts/harness_state.py +22 -3
  76. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  77. package/harness/bundles/general/cursor/README.md +7 -7
  78. package/harness/bundles/general/cursor/harness-archive/SKILL.md +35 -35
  79. package/harness/bundles/general/cursor/harness-archive/checklist.md +5 -2
  80. package/harness/bundles/general/cursor/harness-archive/reference.md +23 -7
  81. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  83. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
  85. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  86. package/harness/bundles/general/cursor/harness-run/SKILL.md +2 -2
  87. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  88. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-test/SKILL.md +2 -2
  90. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +7 -3
  91. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +1 -1
  92. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +6 -4
  93. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +3 -2
  94. package/harness/bundles/general/cursor/scripts/harness_archive.py +829 -68
  95. package/harness/bundles/general/cursor/scripts/harness_context.py +8 -2
  96. package/harness/bundles/general/cursor/scripts/harness_events.py +5 -0
  97. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +191 -26
  98. package/harness/bundles/general/cursor/scripts/harness_ledger.py +79 -2
  99. package/harness/bundles/general/cursor/scripts/harness_profile.py +7 -0
  100. package/harness/bundles/general/cursor/scripts/harness_state.py +22 -3
  101. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  102. package/harness/bundles/java/claude-code/README.md +7 -7
  103. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
  104. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +35 -35
  105. package/harness/bundles/java/claude-code/harness-archive/checklist.md +5 -2
  106. package/harness/bundles/java/claude-code/harness-archive/reference.md +23 -7
  107. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  110. package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -2
  111. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
  112. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  113. package/harness/bundles/java/claude-code/harness-run/SKILL.md +2 -2
  114. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  115. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-test/SKILL.md +2 -2
  117. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +7 -3
  118. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +1 -1
  119. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +6 -4
  120. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +3 -2
  121. package/harness/bundles/java/claude-code/scripts/harness_archive.py +829 -68
  122. package/harness/bundles/java/claude-code/scripts/harness_context.py +8 -2
  123. package/harness/bundles/java/claude-code/scripts/harness_events.py +5 -0
  124. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +191 -26
  125. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +79 -2
  126. package/harness/bundles/java/claude-code/scripts/harness_profile.py +7 -0
  127. package/harness/bundles/java/claude-code/scripts/harness_state.py +22 -3
  128. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  129. package/harness/bundles/java/codebuddy/README.md +7 -7
  130. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
  131. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +35 -35
  132. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +5 -2
  133. package/harness/bundles/java/codebuddy/harness-archive/reference.md +23 -7
  134. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  136. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
  138. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
  139. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  140. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +2 -2
  141. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  142. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  143. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +2 -2
  144. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +7 -3
  145. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +1 -1
  146. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +6 -4
  147. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +3 -2
  148. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +829 -68
  149. package/harness/bundles/java/codebuddy/scripts/harness_context.py +8 -2
  150. package/harness/bundles/java/codebuddy/scripts/harness_events.py +5 -0
  151. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +191 -26
  152. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +79 -2
  153. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +7 -0
  154. package/harness/bundles/java/codebuddy/scripts/harness_state.py +22 -3
  155. package/harness/bundles/java/codex/.harness-build.json +1 -1
  156. package/harness/bundles/java/codex/README.md +7 -7
  157. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
  158. package/harness/bundles/java/codex/harness-archive/SKILL.md +35 -35
  159. package/harness/bundles/java/codex/harness-archive/checklist.md +5 -2
  160. package/harness/bundles/java/codex/harness-archive/reference.md +23 -7
  161. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  163. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  164. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
  165. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
  166. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  167. package/harness/bundles/java/codex/harness-run/SKILL.md +2 -2
  168. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  169. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  170. package/harness/bundles/java/codex/harness-test/SKILL.md +2 -2
  171. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +7 -3
  172. package/harness/bundles/java/codex/protocols/ledger-protocol.md +1 -1
  173. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +6 -4
  174. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +3 -2
  175. package/harness/bundles/java/codex/scripts/harness_archive.py +829 -68
  176. package/harness/bundles/java/codex/scripts/harness_context.py +8 -2
  177. package/harness/bundles/java/codex/scripts/harness_events.py +5 -0
  178. package/harness/bundles/java/codex/scripts/harness_events_sync.py +191 -26
  179. package/harness/bundles/java/codex/scripts/harness_ledger.py +79 -2
  180. package/harness/bundles/java/codex/scripts/harness_profile.py +7 -0
  181. package/harness/bundles/java/codex/scripts/harness_state.py +22 -3
  182. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  183. package/harness/bundles/java/cursor/README.md +7 -7
  184. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
  185. package/harness/bundles/java/cursor/harness-archive/SKILL.md +35 -35
  186. package/harness/bundles/java/cursor/harness-archive/checklist.md +5 -2
  187. package/harness/bundles/java/cursor/harness-archive/reference.md +23 -7
  188. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  189. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  190. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  191. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
  192. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
  193. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  194. package/harness/bundles/java/cursor/harness-run/SKILL.md +2 -2
  195. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  196. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  197. package/harness/bundles/java/cursor/harness-test/SKILL.md +2 -2
  198. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +7 -3
  199. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +1 -1
  200. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +6 -4
  201. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +3 -2
  202. package/harness/bundles/java/cursor/scripts/harness_archive.py +829 -68
  203. package/harness/bundles/java/cursor/scripts/harness_context.py +8 -2
  204. package/harness/bundles/java/cursor/scripts/harness_events.py +5 -0
  205. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +191 -26
  206. package/harness/bundles/java/cursor/scripts/harness_ledger.py +79 -2
  207. package/harness/bundles/java/cursor/scripts/harness_profile.py +7 -0
  208. package/harness/bundles/java/cursor/scripts/harness_state.py +22 -3
  209. package/harness/manifests/general/claude-code.json +27 -27
  210. package/harness/manifests/general/codebuddy.json +27 -27
  211. package/harness/manifests/general/codex.json +27 -27
  212. package/harness/manifests/general/cursor.json +27 -27
  213. package/harness/manifests/java/claude-code.json +29 -29
  214. package/harness/manifests/java/codebuddy.json +29 -29
  215. package/harness/manifests/java/codex.json +29 -29
  216. package/harness/manifests/java/cursor.json +29 -29
  217. package/hunter-workflow-family.json +5 -5
  218. package/package.json +1 -1
@@ -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
@@ -1907,8 +1908,13 @@ def find_test_reports(change_dir: Path) -> list[Path]:
1907
1908
  "reports/test/*.md",
1908
1909
  ]
1909
1910
  found: list[Path] = []
1911
+ seen: set[Path] = set()
1910
1912
  for pattern in patterns:
1911
- 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)
1912
1918
  return found
1913
1919
 
1914
1920
 
@@ -2184,6 +2190,21 @@ def check_status(
2184
2190
  checks["project_root"] = str(project)
2185
2191
  state_root = resolve_archive_state_root(change_dir)
2186
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
+ )
2187
2208
 
2188
2209
  exact_byte = check_archive_exact_byte_policy(project)
2189
2210
  checks["archive_exact_byte"] = exact_byte
@@ -2202,14 +2223,33 @@ def check_status(
2202
2223
  )
2203
2224
  checks["sensitive_evidence_publication"] = sensitive_gate
2204
2225
  if not sensitive_gate.get("ok"):
2205
- blockers.append({
2206
- "code": str(sensitive_gate.get("reasonCode") or "SECRET_SCAN_GATE_BLOCKED"),
2207
- "message": str(
2208
- sensitive_gate.get("nextAction")
2209
- or sensitive_gate.get("error")
2210
- or "sensitive evidence publication gate failed"
2211
- ),
2212
- })
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
+ })
2213
2253
 
2214
2254
  # --- H-4 formal-layer minimum set ---
2215
2255
  plans_dir = change_dir / "plans"
@@ -2238,6 +2278,28 @@ def check_status(
2238
2278
 
2239
2279
  ledger = load_ledger(change_dir)
2240
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
+ )
2241
2303
  if ledger is None:
2242
2304
  target = warnings if closure_disposition != "completed" else blockers
2243
2305
  target.append(
@@ -2296,7 +2358,10 @@ def check_status(
2296
2358
  hash_source: str | None = None
2297
2359
  if ledger is None:
2298
2360
  ledger = load_ledger(change_dir)
2299
- 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:
2300
2365
  merge_hash = ledger.get("mergeFinalHash") or (
2301
2366
  ledger.get("merge") or {}
2302
2367
  ).get("finalHash")
@@ -2492,7 +2557,6 @@ def check_status(
2492
2557
  ),
2493
2558
  })
2494
2559
 
2495
- archivable = len(blockers) == 0
2496
2560
  candidate_codes = {
2497
2561
  "PRODUCT_CI_NOT_GREEN",
2498
2562
  "PRODUCT_CANDIDATE_NOT_VERIFIED",
@@ -2500,6 +2564,71 @@ def check_status(
2500
2564
  "PROJECT_RELEASE_POLICY_BLOCKED",
2501
2565
  "REMOTE_CI_DOWNGRADE_REFUSED",
2502
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
2503
2632
  archive_integrity_ok = not any(
2504
2633
  item.get("code") not in candidate_codes for item in blockers
2505
2634
  )
@@ -2511,34 +2640,23 @@ def check_status(
2511
2640
  else "ARCHIVE_INTEGRITY_FAILED"
2512
2641
  ),
2513
2642
  "message": (
2514
- "archive preconditions are complete"
2643
+ "归档前置条件完整"
2515
2644
  if archive_integrity_ok
2516
- else "archive preconditions contain non-candidate blockers"
2645
+ else "归档前置条件存在阻断项"
2517
2646
  ),
2518
2647
  }
2519
- try:
2520
- release_summary = collect_summary_data(
2521
- change_dir, write=False, for_replay=False
2522
- )
2523
- release_summary["archiveIntent"] = archive_intent
2524
- release_summary["closureDisposition"] = closure_disposition
2525
- release_summary["closureReason"] = closure_reason
2526
- report_adequacy = validate_report_adequacy(release_summary)
2648
+ if release_summary is not None:
2527
2649
  release_decision = evaluate_release_eligibility(
2528
2650
  change_dir,
2529
2651
  release_summary,
2530
2652
  archive_integrity=archive_integrity,
2531
2653
  report_adequacy=report_adequacy,
2532
2654
  )
2533
- except Exception as exc: # noqa: BLE001 — status must remain read-only
2655
+ else:
2534
2656
  release_decision = compose_release_decision(
2535
2657
  {
2536
2658
  "archiveIntegrity": archive_integrity,
2537
- "reportAdequacy": {
2538
- "ok": False,
2539
- "code": "REPORT_ADEQUACY_UNAVAILABLE",
2540
- "message": str(exc),
2541
- },
2659
+ "reportAdequacy": report_adequacy,
2542
2660
  "candidateVerification": ci_gate,
2543
2661
  }
2544
2662
  )
@@ -2572,21 +2690,37 @@ def archive_auto_gate(
2572
2690
  *,
2573
2691
  allow_missing_review: bool = False,
2574
2692
  archive_intent: str = "release-candidate",
2693
+ closure_disposition: str = "completed",
2694
+ closure_reason: str = "",
2575
2695
  ) -> dict[str, Any]:
2576
2696
  """Read-only proof that a post-submit/merge archive may run unattended."""
2577
2697
  status = check_status(
2578
2698
  change_dir,
2579
2699
  allow_missing_review=allow_missing_review,
2580
2700
  archive_intent=archive_intent,
2701
+ closure_disposition=closure_disposition,
2702
+ closure_reason=closure_reason,
2581
2703
  )
2582
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
2583
2709
  return {
2584
2710
  "ok": False,
2585
2711
  "action": "auto-gate",
2586
2712
  "autoArchiveAllowed": False,
2587
- "reasonCode": "ARCHIVE_PRECONDITIONS_UNSATISFIED",
2713
+ "reasonCode": (
2714
+ "ARCHIVE_REPORT_ADEQUACY_FAILED"
2715
+ if adequacy_failed
2716
+ else "ARCHIVE_PRECONDITIONS_UNSATISFIED"
2717
+ ),
2588
2718
  "status": status,
2589
- "nextAction": "Resolve archive status blockers, then run `harness_archive.py auto-gate` again.",
2719
+ "nextAction": (
2720
+ "归档报告不完整,请按 blockers 中的中文建议处理后重试。"
2721
+ if adequacy_failed
2722
+ else "请先处理归档阻断项,再重新执行归档。"
2723
+ ),
2590
2724
  }
2591
2725
 
2592
2726
  state_root = resolve_archive_state_root(change_dir)
@@ -2608,7 +2742,7 @@ def archive_auto_gate(
2608
2742
  }
2609
2743
 
2610
2744
  git_state = snapshot.get("git")
2611
- snapshot_base = (
2745
+ snapshot_base = snapshot.get("changeBase") or (
2612
2746
  (git_state.get("base") or git_state.get("baseCommit"))
2613
2747
  if isinstance(git_state, dict)
2614
2748
  else snapshot.get("base") or snapshot.get("baseCommit")
@@ -2684,10 +2818,131 @@ def archive_auto_gate(
2684
2818
  "status": status,
2685
2819
  "snapshotPath": str(snapshot_path),
2686
2820
  "snapshotBase": snapshot_base,
2687
- "nextAction": "Run `harness_archive.py finalize` with the same change directory and intent; no AskQuestion confirmation is required.",
2821
+ "nextAction": "运行 `harness_archive.py execute`;它会复用本次状态结果,不再重复扫描。",
2688
2822
  }
2689
2823
 
2690
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
+
2691
2946
  # ---------------------------------------------------------------------------
2692
2947
  # collect
2693
2948
  # ---------------------------------------------------------------------------
@@ -3209,15 +3464,22 @@ def build_verification_projection(
3209
3464
  gate_policy = None
3210
3465
  if isinstance(gate_policy, dict):
3211
3466
  capabilities = gate_policy.get("capabilities")
3212
- required = gate_policy.get("requiredValidations")
3213
- if (
3214
- isinstance(capabilities, list)
3215
- and "database" not in capabilities
3216
- and (not isinstance(required, list) or "dbCompatibility" not in required)
3217
- ):
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:
3218
3480
  db_compatibility = {
3219
3481
  "status": "NOT_APPLICABLE",
3220
- "reason": "项目能力画像未声明数据库能力",
3482
+ "reason": "门禁策略未声明数据库能力或数据库兼容性验证要求",
3221
3483
  "source": "capability-profile",
3222
3484
  }
3223
3485
  projection["dbCompatibility"] = db_compatibility["status"]
@@ -3896,6 +4158,303 @@ def _review_summary(
3896
4158
  return base
3897
4159
 
3898
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
+
3899
4458
  def _base_from_state_snapshot(change_dir: Path) -> str:
3900
4459
  """Read archive-boundary base from meta/state-snapshot.json when present."""
3901
4460
  for relative in (
@@ -3911,6 +4470,9 @@ def _base_from_state_snapshot(change_dir: Path) -> str:
3911
4470
  continue
3912
4471
  if not isinstance(payload, dict):
3913
4472
  continue
4473
+ change_base = str(payload.get("changeBase") or "").strip()
4474
+ if change_base and change_base != NOT_AVAILABLE:
4475
+ return change_base
3914
4476
  git_info = payload.get("git")
3915
4477
  if isinstance(git_info, dict):
3916
4478
  base = str(git_info.get("base") or git_info.get("baseCommit") or "").strip()
@@ -3922,6 +4484,27 @@ def _base_from_state_snapshot(change_dir: Path) -> str:
3922
4484
  return ""
3923
4485
 
3924
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
+
3925
4508
  def _resolve_base_commit(
3926
4509
  ledger: dict[str, Any] | None,
3927
4510
  change_dir: Path,
@@ -3947,6 +4530,12 @@ def _resolve_base_commit(
3947
4530
  return ""
3948
4531
  return candidate
3949
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
+
3950
4539
  if ledger:
3951
4540
  base = stable_base(ledger.get("baseCommit"))
3952
4541
  if base and base != NOT_AVAILABLE:
@@ -5045,6 +5634,10 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5045
5634
  a lossy projection of itself.
5046
5635
  """
5047
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"}
5048
5641
 
5049
5642
  # H-12: prefer top-level baseCommit + diffStat; gitFacts is a mirror.
5050
5643
  base = str(summary.get("baseCommit") or "").strip()
@@ -5069,7 +5662,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5069
5662
  final_present = bool(
5070
5663
  (final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
5071
5664
  )
5072
- if final_present and base_missing:
5665
+ if final_present and base_missing and not unfinished_closure:
5073
5666
  issues.append({
5074
5667
  "code": "IDENTITY_BASE_MISSING",
5075
5668
  "severity": "error",
@@ -5086,6 +5679,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5086
5679
  and final != NOT_AVAILABLE
5087
5680
  and base != final
5088
5681
  and files_changed == 0
5682
+ and not unfinished_closure
5089
5683
  ):
5090
5684
  issues.append({
5091
5685
  "code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
@@ -5113,13 +5707,14 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5113
5707
  if not feature_tip:
5114
5708
  feature_tip = str(identity_doc_early.get("productCommit") or "").strip()
5115
5709
 
5116
- if (
5710
+ collapsed_boundary = (
5117
5711
  base
5118
5712
  and base != NOT_AVAILABLE
5119
5713
  and feature_tip
5120
5714
  and feature_tip != NOT_AVAILABLE
5121
5715
  and base == feature_tip
5122
- ):
5716
+ )
5717
+ if collapsed_boundary and not unfinished_closure:
5123
5718
  issues.append({
5124
5719
  "code": "ARCHIVE_BASE_EQUALS_FEATURE_TIP",
5125
5720
  "severity": "error",
@@ -5128,6 +5723,15 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5128
5723
  "archive boundary collapsed to an empty/merge-only delta"
5129
5724
  ),
5130
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
+ })
5131
5735
 
5132
5736
  ownership = summary.get("ownershipDiff") or summary.get("ownership")
5133
5737
  ownership = ownership if isinstance(ownership, dict) else {}
@@ -5140,7 +5744,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5140
5744
  changed_files = summary.get("changedFiles")
5141
5745
  changed_count = len(changed_files) if isinstance(changed_files, list) else files_changed
5142
5746
 
5143
- if ownership_count > 0 and changed_count == 0:
5747
+ if ownership_count > 0 and changed_count == 0 and not unfinished_closure:
5144
5748
  issues.append({
5145
5749
  "code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
5146
5750
  "severity": "error",
@@ -5150,7 +5754,12 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5150
5754
  "but does not represent the full change"
5151
5755
  ),
5152
5756
  })
5153
- 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
+ ):
5154
5763
  issues.append({
5155
5764
  "code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
5156
5765
  "severity": "error",
@@ -5161,7 +5770,11 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5161
5770
  })
5162
5771
 
5163
5772
  merge_parents = summary.get("mergeParents") or summary.get("mergeParentHashes")
5164
- 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
+ ):
5165
5778
  first_parent = str(merge_parents[0] or "").strip()
5166
5779
  if (
5167
5780
  base
@@ -5384,7 +5997,15 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5384
5997
  }
5385
5998
  )
5386
5999
 
5387
- 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
+ }
5388
6009
 
5389
6010
 
5390
6011
  RELEASE_CHECKS = (
@@ -6677,6 +7298,7 @@ def cmd_finalize(
6677
7298
  archive_intent: str = "release-candidate",
6678
7299
  closure_disposition: str = "completed",
6679
7300
  closure_reason: str = "",
7301
+ preflight_status: dict[str, Any] | None = None,
6680
7302
  ) -> tuple[int, dict[str, Any]]:
6681
7303
  """Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
6682
7304
  if archive_intent not in {"release-candidate", "record-only"}:
@@ -6747,6 +7369,11 @@ def cmd_finalize(
6747
7369
  if resolved_state_dir.resolve() != original_change_dir.resolve()
6748
7370
  else None
6749
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
+ )
6750
7377
  operation_id = f"a-{uuid.uuid4().hex[:12]}"
6751
7378
  operation_root = project_root / ".harness" / "archive-operations"
6752
7379
  operation_temp_dir = operation_root / "staging" / operation_id / change_dir.name
@@ -6756,13 +7383,22 @@ def cmd_finalize(
6756
7383
  shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
6757
7384
  payload["original_preserved"] = original_change_dir.is_dir()
6758
7385
  payload["finalStatus"] = "FAIL"
6759
- _append_finalize_failure_terminal(
6760
- split_state_dir
6761
- if split_state_dir is not None and split_state_dir.is_dir()
6762
- else original_change_dir,
6763
- str(payload.get("error") or "archive finalize failed"),
6764
- operation_id=operation_id,
6765
- )
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
6766
7402
  try:
6767
7403
  write_json(
6768
7404
  operation_record,
@@ -6806,6 +7442,65 @@ def cmd_finalize(
6806
7442
  payload["error"] = f"archive target already exists: {archive_dir}"
6807
7443
  return 1, payload
6808
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
+
6809
7504
  source_sensitive_refresh = hruntime.refresh_sensitive_evidence_scan_receipt(
6810
7505
  original_change_dir,
6811
7506
  persist=False,
@@ -6826,8 +7521,7 @@ def cmd_finalize(
6826
7521
  "message": payload["error"],
6827
7522
  }
6828
7523
  ]
6829
- payload["original_preserved"] = original_change_dir.is_dir()
6830
- payload["finalStatus"] = "FAIL"
7524
+ _restore_finalize_failure()
6831
7525
  return 1, payload
6832
7526
 
6833
7527
  source_sensitive_gate = validate_sensitive_evidence_publication_gate(
@@ -6853,8 +7547,7 @@ def cmd_finalize(
6853
7547
  "message": payload["error"],
6854
7548
  }
6855
7549
  ]
6856
- payload["original_preserved"] = original_change_dir.is_dir()
6857
- payload["finalStatus"] = "FAIL"
7550
+ _restore_finalize_failure()
6858
7551
  return 1, payload
6859
7552
 
6860
7553
  exact_byte = check_archive_exact_byte_policy(project_root)
@@ -6885,15 +7578,7 @@ def cmd_finalize(
6885
7578
  ),
6886
7579
  }
6887
7580
  ]
6888
- payload["original_preserved"] = original_change_dir.is_dir()
6889
- payload["finalStatus"] = "FAIL"
6890
- _append_finalize_failure_terminal(
6891
- split_state_dir
6892
- if split_state_dir is not None and split_state_dir.is_dir()
6893
- else original_change_dir,
6894
- payload["error"],
6895
- operation_id=operation_id,
6896
- )
7581
+ _restore_finalize_failure()
6897
7582
  return 1, payload
6898
7583
 
6899
7584
  payload["steps"]["service_stop_before_staging"] = run_service_stop(
@@ -7012,9 +7697,6 @@ def cmd_finalize(
7012
7697
  except OSError as exc:
7013
7698
  warnings.append(f"event append failed: {exc}")
7014
7699
 
7015
- # Step 9 starts here: phase.start
7016
- _safe_append(phase="archive", type_="phase.start", note="finalize start")
7017
-
7018
7700
  # --- 0. cleanup transients (before before-manifest) ---
7019
7701
  try:
7020
7702
  cleanup_result = _cleanup_transients(work_dir)
@@ -8652,6 +9334,20 @@ def cmd_auto_gate_cli(args: argparse.Namespace) -> int:
8652
9334
  resolve_path(args.change_dir),
8653
9335
  allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
8654
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)),
8655
9351
  )
8656
9352
  emit_json(result)
8657
9353
  return 0 if result.get("ok") else 1
@@ -8715,6 +9411,29 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
8715
9411
  return code
8716
9412
 
8717
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
+
8718
9437
  def cmd_restore_durable_cli(args: argparse.Namespace) -> int:
8719
9438
  receipt_path = resolve_path(args.receipt)
8720
9439
  target_root = resolve_path(args.target_root)
@@ -8902,8 +9621,26 @@ def build_parser() -> argparse.ArgumentParser:
8902
9621
  choices=("release-candidate", "record-only"),
8903
9622
  default="release-candidate",
8904
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="")
8905
9630
  p_auto_gate.set_defaults(func=cmd_auto_gate_cli)
8906
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
+
8907
9644
  p_cert = sub.add_parser(
8908
9645
  "certify-local",
8909
9646
  help="create candidate receipt from existing full ledger evidence without rerunning tests",
@@ -8951,6 +9688,30 @@ def build_parser() -> argparse.ArgumentParser:
8951
9688
  p_fin.add_argument("--json", action="store_true", default=True)
8952
9689
  p_fin.set_defaults(func=cmd_finalize_cli)
8953
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
+
8954
9715
  p_restore = sub.add_parser(
8955
9716
  "restore-durable",
8956
9717
  help="verify a durable receipt and restore its archive without overwriting",