@hunter-harness/workflow-harness 0.4.9 → 0.4.11

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 (242) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +5 -3
  5. package/harness/bundles/general/claude-code/harness-execute/testing-pitfalls-java.md +7 -0
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +7 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -3
  9. package/harness/bundles/general/claude-code/harness-plan/checklist.md +1 -0
  10. package/harness/bundles/general/claude-code/harness-plan/reference.md +8 -0
  11. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-review/SKILL.md +12 -1
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/scripts/harness_context.py +71 -8
  17. package/harness/bundles/general/claude-code/scripts/harness_events.py +29 -19
  18. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +37 -1
  19. package/harness/bundles/general/claude-code/scripts/harness_gate.py +124 -10
  20. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +84 -4
  21. package/harness/bundles/general/claude-code/scripts/harness_process.py +18 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +47 -1
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +5 -3
  27. package/harness/bundles/general/codebuddy/harness-execute/testing-pitfalls-java.md +7 -0
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +7 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -3
  31. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +1 -0
  32. package/harness/bundles/general/codebuddy/harness-plan/reference.md +8 -0
  33. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +12 -1
  36. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/scripts/harness_context.py +71 -8
  39. package/harness/bundles/general/codebuddy/scripts/harness_events.py +29 -19
  40. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +37 -1
  41. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +124 -10
  42. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +84 -4
  43. package/harness/bundles/general/codebuddy/scripts/harness_process.py +18 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +47 -1
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-execute/SKILL.md +5 -3
  49. package/harness/bundles/general/codex/harness-execute/testing-pitfalls-java.md +7 -0
  50. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +7 -1
  51. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -3
  53. package/harness/bundles/general/codex/harness-plan/checklist.md +1 -0
  54. package/harness/bundles/general/codex/harness-plan/reference.md +8 -0
  55. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/scripts/harness_context.py +71 -8
  61. package/harness/bundles/general/codex/scripts/harness_events.py +29 -19
  62. package/harness/bundles/general/codex/scripts/harness_fixback.py +37 -1
  63. package/harness/bundles/general/codex/scripts/harness_gate.py +124 -10
  64. package/harness/bundles/general/codex/scripts/harness_ledger.py +84 -4
  65. package/harness/bundles/general/codex/scripts/harness_process.py +18 -0
  66. package/harness/bundles/general/codex/scripts/harness_runtime.py +47 -1
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-execute/SKILL.md +5 -3
  71. package/harness/bundles/general/cursor/harness-execute/testing-pitfalls-java.md +7 -0
  72. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +7 -1
  73. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  74. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -3
  75. package/harness/bundles/general/cursor/harness-plan/checklist.md +1 -0
  76. package/harness/bundles/general/cursor/harness-plan/reference.md +8 -0
  77. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/scripts/harness_context.py +71 -8
  83. package/harness/bundles/general/cursor/scripts/harness_events.py +29 -19
  84. package/harness/bundles/general/cursor/scripts/harness_fixback.py +37 -1
  85. package/harness/bundles/general/cursor/scripts/harness_gate.py +124 -10
  86. package/harness/bundles/general/cursor/scripts/harness_ledger.py +84 -4
  87. package/harness/bundles/general/cursor/scripts/harness_process.py +18 -0
  88. package/harness/bundles/general/cursor/scripts/harness_runtime.py +47 -1
  89. package/harness/bundles/general/pi/.harness-build.json +1 -1
  90. package/harness/bundles/general/pi/harness-archive/SKILL.md +1 -1
  91. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +1 -1
  92. package/harness/bundles/general/pi/harness-execute/SKILL.md +5 -3
  93. package/harness/bundles/general/pi/harness-execute/testing-pitfalls-java.md +7 -0
  94. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +7 -1
  95. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +1 -1
  96. package/harness/bundles/general/pi/harness-plan/SKILL.md +3 -3
  97. package/harness/bundles/general/pi/harness-plan/checklist.md +1 -0
  98. package/harness/bundles/general/pi/harness-plan/reference.md +8 -0
  99. package/harness/bundles/general/pi/harness-pull/SKILL.md +1 -1
  100. package/harness/bundles/general/pi/harness-push/SKILL.md +1 -1
  101. package/harness/bundles/general/pi/harness-review/SKILL.md +1 -1
  102. package/harness/bundles/general/pi/harness-submit/SKILL.md +1 -1
  103. package/harness/bundles/general/pi/harness-sync/SKILL.md +1 -1
  104. package/harness/bundles/general/pi/scripts/harness_context.py +71 -8
  105. package/harness/bundles/general/pi/scripts/harness_events.py +29 -19
  106. package/harness/bundles/general/pi/scripts/harness_fixback.py +37 -1
  107. package/harness/bundles/general/pi/scripts/harness_gate.py +124 -10
  108. package/harness/bundles/general/pi/scripts/harness_ledger.py +84 -4
  109. package/harness/bundles/general/pi/scripts/harness_process.py +18 -0
  110. package/harness/bundles/general/pi/scripts/harness_runtime.py +47 -1
  111. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  112. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  113. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +5 -3
  116. package/harness/bundles/java/claude-code/harness-execute/testing-pitfalls-java.md +1 -0
  117. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +7 -1
  118. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  119. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  120. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -3
  121. package/harness/bundles/java/claude-code/harness-plan/checklist.md +1 -0
  122. package/harness/bundles/java/claude-code/harness-plan/reference.md +8 -0
  123. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-review/SKILL.md +12 -1
  126. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  127. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  128. package/harness/bundles/java/claude-code/scripts/harness_context.py +71 -8
  129. package/harness/bundles/java/claude-code/scripts/harness_events.py +29 -19
  130. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +37 -1
  131. package/harness/bundles/java/claude-code/scripts/harness_gate.py +124 -10
  132. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +84 -4
  133. package/harness/bundles/java/claude-code/scripts/harness_process.py +18 -0
  134. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +47 -1
  135. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  136. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  138. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  139. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +5 -3
  140. package/harness/bundles/java/codebuddy/harness-execute/testing-pitfalls-java.md +1 -0
  141. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +7 -1
  142. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  143. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -3
  145. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +1 -0
  146. package/harness/bundles/java/codebuddy/harness-plan/reference.md +8 -0
  147. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  148. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +12 -1
  150. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/scripts/harness_context.py +71 -8
  153. package/harness/bundles/java/codebuddy/scripts/harness_events.py +29 -19
  154. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +37 -1
  155. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +124 -10
  156. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +84 -4
  157. package/harness/bundles/java/codebuddy/scripts/harness_process.py +18 -0
  158. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +47 -1
  159. package/harness/bundles/java/codex/.harness-build.json +1 -1
  160. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  161. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  163. package/harness/bundles/java/codex/harness-execute/SKILL.md +5 -3
  164. package/harness/bundles/java/codex/harness-execute/testing-pitfalls-java.md +1 -0
  165. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +7 -1
  166. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  168. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -3
  169. package/harness/bundles/java/codex/harness-plan/checklist.md +1 -0
  170. package/harness/bundles/java/codex/harness-plan/reference.md +8 -0
  171. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  173. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  174. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  175. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  176. package/harness/bundles/java/codex/scripts/harness_context.py +71 -8
  177. package/harness/bundles/java/codex/scripts/harness_events.py +29 -19
  178. package/harness/bundles/java/codex/scripts/harness_fixback.py +37 -1
  179. package/harness/bundles/java/codex/scripts/harness_gate.py +124 -10
  180. package/harness/bundles/java/codex/scripts/harness_ledger.py +84 -4
  181. package/harness/bundles/java/codex/scripts/harness_process.py +18 -0
  182. package/harness/bundles/java/codex/scripts/harness_runtime.py +47 -1
  183. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  184. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  185. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  186. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  187. package/harness/bundles/java/cursor/harness-execute/SKILL.md +5 -3
  188. package/harness/bundles/java/cursor/harness-execute/testing-pitfalls-java.md +1 -0
  189. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +7 -1
  190. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  191. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  192. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -3
  193. package/harness/bundles/java/cursor/harness-plan/checklist.md +1 -0
  194. package/harness/bundles/java/cursor/harness-plan/reference.md +8 -0
  195. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  196. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  197. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  198. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  199. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  200. package/harness/bundles/java/cursor/scripts/harness_context.py +71 -8
  201. package/harness/bundles/java/cursor/scripts/harness_events.py +29 -19
  202. package/harness/bundles/java/cursor/scripts/harness_fixback.py +37 -1
  203. package/harness/bundles/java/cursor/scripts/harness_gate.py +124 -10
  204. package/harness/bundles/java/cursor/scripts/harness_ledger.py +84 -4
  205. package/harness/bundles/java/cursor/scripts/harness_process.py +18 -0
  206. package/harness/bundles/java/cursor/scripts/harness_runtime.py +47 -1
  207. package/harness/bundles/java/pi/.harness-build.json +1 -1
  208. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +1 -1
  209. package/harness/bundles/java/pi/harness-archive/SKILL.md +1 -1
  210. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +1 -1
  211. package/harness/bundles/java/pi/harness-execute/SKILL.md +5 -3
  212. package/harness/bundles/java/pi/harness-execute/testing-pitfalls-java.md +1 -0
  213. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +7 -1
  214. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +1 -1
  215. package/harness/bundles/java/pi/harness-package/SKILL.md +1 -1
  216. package/harness/bundles/java/pi/harness-plan/SKILL.md +3 -3
  217. package/harness/bundles/java/pi/harness-plan/checklist.md +1 -0
  218. package/harness/bundles/java/pi/harness-plan/reference.md +8 -0
  219. package/harness/bundles/java/pi/harness-pull/SKILL.md +1 -1
  220. package/harness/bundles/java/pi/harness-push/SKILL.md +1 -1
  221. package/harness/bundles/java/pi/harness-review/SKILL.md +1 -1
  222. package/harness/bundles/java/pi/harness-submit/SKILL.md +1 -1
  223. package/harness/bundles/java/pi/harness-sync/SKILL.md +1 -1
  224. package/harness/bundles/java/pi/scripts/harness_context.py +71 -8
  225. package/harness/bundles/java/pi/scripts/harness_events.py +29 -19
  226. package/harness/bundles/java/pi/scripts/harness_fixback.py +37 -1
  227. package/harness/bundles/java/pi/scripts/harness_gate.py +124 -10
  228. package/harness/bundles/java/pi/scripts/harness_ledger.py +84 -4
  229. package/harness/bundles/java/pi/scripts/harness_process.py +18 -0
  230. package/harness/bundles/java/pi/scripts/harness_runtime.py +47 -1
  231. package/harness/manifests/general/claude-code.json +22 -22
  232. package/harness/manifests/general/codebuddy.json +22 -22
  233. package/harness/manifests/general/codex.json +22 -22
  234. package/harness/manifests/general/cursor.json +22 -22
  235. package/harness/manifests/general/pi.json +22 -22
  236. package/harness/manifests/java/claude-code.json +24 -24
  237. package/harness/manifests/java/codebuddy.json +24 -24
  238. package/harness/manifests/java/codex.json +24 -24
  239. package/harness/manifests/java/cursor.json +24 -24
  240. package/harness/manifests/java/pi.json +24 -24
  241. package/hunter-workflow-family.json +2 -2
  242. package/package.json +1 -1
@@ -478,6 +478,55 @@ def _phase_has_ended(event_paths: list[Path], phase: str) -> bool:
478
478
  )
479
479
 
480
480
 
481
+ def _ensure_phase_end_event(
482
+ contract_root: Path,
483
+ *,
484
+ phase: str,
485
+ status: str,
486
+ executor: str,
487
+ ) -> dict[str, Any] | None:
488
+ """补齐 phase.start/end 事件对(P0-2)。
489
+
490
+ 平台 Run 计时由 events-sync 上报的 phase.start/end 事件对驱动。不经
491
+ gate close 收尾的阶段(典型:plan 走 finalize + context close)只写
492
+ transitions 收据,phase.end 永远缺席,平台计时永不停——连续两个 change
493
+ 复现,属结构性缺口(2026-08-30 sales-insight-agent demo-datasource 实测)。
494
+ close_transition 是所有跨阶段推进的共同通道,在此按事件配对补齐:
495
+ 有 start 无 end → 复用 start 的 run_id/attempt 代写 end;已有 end(gate
496
+ close 先行写入)或连 start 都没有 → 不动,返回 None。
497
+ """
498
+ import harness_events as he
499
+
500
+ events_file = contract_root / "events.ndjson"
501
+ events = _read_ndjson(events_file)
502
+ starts = [
503
+ item
504
+ for item in events
505
+ if hpaths.resolve_phase_name(item.get("phase")) == phase
506
+ and item.get("type") == "phase.start"
507
+ ]
508
+ if not starts or any(
509
+ hpaths.resolve_phase_name(item.get("phase")) == phase
510
+ and item.get("type") == "phase.end"
511
+ for item in events
512
+ ):
513
+ return None
514
+ start = starts[-1]
515
+ result = he.append_event(
516
+ contract_root,
517
+ phase=phase,
518
+ type_="phase.end",
519
+ run_id=str(start.get("run_id") or f"{phase}_unknown"),
520
+ attempt=int(start.get("attempt") or 1),
521
+ status=status,
522
+ executor_tool=executor,
523
+ note="context close 自动补齐 phase.end(平台计时事件对配对)",
524
+ )
525
+ if not result.get("ok"):
526
+ return {"code": "PHASE_END_PAIR_FAILED", "detail": result}
527
+ return {"code": "PHASE_END_AUTO_PAIRED", "event": result.get("event")}
528
+
529
+
481
530
  def _reselect_review_fixback(
482
531
  project: Path,
483
532
  change: str,
@@ -1334,6 +1383,12 @@ def close_transition(
1334
1383
  and latest.get("artifacts") == artifact_entries
1335
1384
  and (executor is None or latest.get("executor") == executor)
1336
1385
  ):
1386
+ paired = _ensure_phase_end_event(
1387
+ contract_root,
1388
+ phase=from_phase,
1389
+ status=status,
1390
+ executor=executor or str(latest.get("executor") or "unknown"),
1391
+ )
1337
1392
  return {
1338
1393
  "ok": True,
1339
1394
  "code": "TRANSITION_ALREADY_CLOSED",
@@ -1341,6 +1396,7 @@ def close_transition(
1341
1396
  "receipt": latest,
1342
1397
  "path": str(paths["transitions"]),
1343
1398
  "invalidation": None,
1399
+ "phaseEndPair": paired,
1344
1400
  }
1345
1401
  return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
1346
1402
  try:
@@ -1397,6 +1453,12 @@ def close_transition(
1397
1453
  receipt["receiptHash"] = _payload_hash(receipt)
1398
1454
  _append_ndjson(paths["transitions"], receipt)
1399
1455
  paths["lease"].unlink(missing_ok=True)
1456
+ paired = _ensure_phase_end_event(
1457
+ contract_root,
1458
+ phase=from_phase,
1459
+ status=status,
1460
+ executor=effective_executor,
1461
+ )
1400
1462
  invalidation = None
1401
1463
  if to_phase == "execute" and from_phase in {"execute", "review"}:
1402
1464
  invalidation = _invalidate_for_fixback(
@@ -1411,6 +1473,7 @@ def close_transition(
1411
1473
  "receipt": receipt,
1412
1474
  "path": str(paths["transitions"]),
1413
1475
  "invalidation": invalidation,
1476
+ "phaseEndPair": paired,
1414
1477
  }
1415
1478
 
1416
1479
 
@@ -1898,7 +1961,7 @@ def build_parser() -> argparse.ArgumentParser:
1898
1961
  prepare = sub.add_parser("prepare")
1899
1962
  prepare.add_argument("--json", action="store_true")
1900
1963
  prepare.add_argument("--project", required=True, type=Path)
1901
- prepare.add_argument("--change")
1964
+ prepare.add_argument("--change", "--change-dir", dest="change")
1902
1965
  prepare.add_argument("--phase", required=True)
1903
1966
  prepare.add_argument("--executor", required=True)
1904
1967
  prepare.add_argument("--title")
@@ -1907,7 +1970,7 @@ def build_parser() -> argparse.ArgumentParser:
1907
1970
  bootstrap = sub.add_parser("bootstrap-plan")
1908
1971
  bootstrap.add_argument("--json", action="store_true")
1909
1972
  bootstrap.add_argument("--project", required=True, type=Path)
1910
- bootstrap.add_argument("--change", required=True)
1973
+ bootstrap.add_argument("--change", "--change-dir", dest="change", required=True)
1911
1974
  bootstrap.add_argument("--executor", required=True)
1912
1975
  bootstrap.add_argument("--title")
1913
1976
  bootstrap.add_argument("--stage", default="plan", choices=["plan", "post-run"])
@@ -1915,7 +1978,7 @@ def build_parser() -> argparse.ArgumentParser:
1915
1978
  close = sub.add_parser("close")
1916
1979
  close.add_argument("--json", action="store_true")
1917
1980
  close.add_argument("--project", required=True, type=Path)
1918
- close.add_argument("--change", required=True)
1981
+ close.add_argument("--change", "--change-dir", dest="change", required=True)
1919
1982
  close.add_argument("--from-phase", required=True)
1920
1983
  close.add_argument("--to-phase", required=True)
1921
1984
  close.add_argument("--executor", required=True)
@@ -1924,7 +1987,7 @@ def build_parser() -> argparse.ArgumentParser:
1924
1987
  begin = sub.add_parser("begin")
1925
1988
  begin.add_argument("--json", action="store_true")
1926
1989
  begin.add_argument("--project", required=True, type=Path)
1927
- begin.add_argument("--change", required=True)
1990
+ begin.add_argument("--change", "--change-dir", dest="change", required=True)
1928
1991
  begin.add_argument("--phase", required=True)
1929
1992
  begin.add_argument("--executor", required=True)
1930
1993
  handoff = sub.add_parser(
@@ -1933,7 +1996,7 @@ def build_parser() -> argparse.ArgumentParser:
1933
1996
  )
1934
1997
  handoff.add_argument("--json", action="store_true")
1935
1998
  handoff.add_argument("--project", required=True, type=Path)
1936
- handoff.add_argument("--change", required=True)
1999
+ handoff.add_argument("--change", "--change-dir", dest="change", required=True)
1937
2000
  handoff.add_argument("--to-phase", required=True)
1938
2001
  handoff.add_argument(
1939
2002
  "--from-phase",
@@ -1946,20 +2009,20 @@ def build_parser() -> argparse.ArgumentParser:
1946
2009
  )
1947
2010
  renew.add_argument("--json", action="store_true")
1948
2011
  renew.add_argument("--project", required=True, type=Path)
1949
- renew.add_argument("--change", required=True)
2012
+ renew.add_argument("--change", "--change-dir", dest="change", required=True)
1950
2013
  renew.add_argument("--executor", required=True)
1951
2014
  renew.add_argument("--ttl-seconds", type=int, default=3600)
1952
2015
  configure = sub.add_parser("configure-plan")
1953
2016
  configure.add_argument("--json", action="store_true")
1954
2017
  configure.add_argument("--project", required=True, type=Path)
1955
- configure.add_argument("--change", required=True)
2018
+ configure.add_argument("--change", "--change-dir", dest="change", required=True)
1956
2019
  configure.add_argument("--phases", required=True)
1957
2020
  configure.add_argument("--operator", required=True)
1958
2021
  configure.add_argument("--reason", required=True)
1959
2022
  view = sub.add_parser("view")
1960
2023
  view.add_argument("--json", action="store_true")
1961
2024
  view.add_argument("--project", required=True, type=Path)
1962
- view.add_argument("--change", required=True)
2025
+ view.add_argument("--change", "--change-dir", dest="change", required=True)
1963
2026
  return parser
1964
2027
 
1965
2028
 
@@ -884,22 +884,32 @@ def build_event(args: argparse.Namespace, existing: list[dict[str, Any]]) -> dic
884
884
  def validate_append_event(args: argparse.Namespace) -> tuple[str, str] | None:
885
885
  """Validate type-specific append fields before any file is mutated."""
886
886
  event_type = str(args.type)
887
- for field in _EVENT_REQUIRED_FIELDS.get(event_type, ()):
888
- value = getattr(args, field, None)
889
- if value is None or (isinstance(value, str) and not value.strip()):
890
- return (
891
- "EVENT_REQUIRED_FIELD",
892
- f"EVENT_REQUIRED_FIELD: {event_type} requires --{field.replace('_', '-')}",
893
- )
887
+ missing = [
888
+ f"--{field.replace('_', '-')}"
889
+ for field in _EVENT_REQUIRED_FIELDS.get(event_type, ())
890
+ if getattr(args, field, None) is None
891
+ or (isinstance(getattr(args, field, None), str)
892
+ and not getattr(args, field).strip())
893
+ ]
894
+ if missing:
895
+ # 一次性列出全部缺失字段——逐个报错会让调用方每个字段往返一轮(E-3)
896
+ return (
897
+ "EVENT_REQUIRED_FIELD",
898
+ f"EVENT_REQUIRED_FIELD: {event_type} requires " + ", ".join(missing),
899
+ )
894
900
  allowed = _EVENT_ALLOWED_FIELDS[event_type]
895
- for field in OPTIONAL_FIELDS:
896
- value = getattr(args, field, None)
897
- if value is not None and field not in allowed:
898
- return (
899
- "EVENT_FIELD_NOT_ALLOWED",
900
- "EVENT_FIELD_NOT_ALLOWED: "
901
- f"{event_type} does not accept --{field.replace('_', '-')}",
902
- )
901
+ disallowed = [
902
+ f"--{field.replace('_', '-')}"
903
+ for field in OPTIONAL_FIELDS
904
+ if getattr(args, field, None) is not None and field not in allowed
905
+ ]
906
+ if disallowed:
907
+ # 一次列出全部不被接受的字段——逐个报错会让调用方每字段往返一轮(R-4)
908
+ return (
909
+ "EVENT_FIELD_NOT_ALLOWED",
910
+ f"EVENT_FIELD_NOT_ALLOWED: {event_type} does not accept "
911
+ + ", ".join(disallowed),
912
+ )
903
913
  for field, length in (("trace_id", 32), ("span_id", 16), ("parent_span_id", 16)):
904
914
  value = getattr(args, field, None)
905
915
  if value is not None and not re.fullmatch(rf"[0-9a-f]{{{length}}}", str(value)):
@@ -2146,7 +2156,7 @@ def build_parser() -> argparse.ArgumentParser:
2146
2156
  parents=[common],
2147
2157
  help="append one event and auto-render execution-log",
2148
2158
  )
2149
- p_append.add_argument("--change-dir", required=True)
2159
+ p_append.add_argument("--change-dir", "--change", dest="change_dir", required=True)
2150
2160
  p_append.add_argument("--phase", required=True)
2151
2161
  p_append.add_argument("--type", required=True)
2152
2162
  p_append.add_argument("--command", default=None)
@@ -2200,7 +2210,7 @@ def build_parser() -> argparse.ArgumentParser:
2200
2210
  parents=[common],
2201
2211
  help="validate and append many events under one lock (H-15)",
2202
2212
  )
2203
- p_batch.add_argument("--change-dir", required=True)
2213
+ p_batch.add_argument("--change-dir", "--change", dest="change_dir", required=True)
2204
2214
  p_batch.add_argument(
2205
2215
  "--file",
2206
2216
  required=True,
@@ -2213,7 +2223,7 @@ def build_parser() -> argparse.ArgumentParser:
2213
2223
  parents=[common],
2214
2224
  help="re-render execution-log.md from events.ndjson",
2215
2225
  )
2216
- p_render.add_argument("--change-dir", required=True)
2226
+ p_render.add_argument("--change-dir", "--change", dest="change_dir", required=True)
2217
2227
  p_render.set_defaults(func=cmd_render)
2218
2228
 
2219
2229
  p_summary = sub.add_parser(
@@ -2221,7 +2231,7 @@ def build_parser() -> argparse.ArgumentParser:
2221
2231
  parents=[common],
2222
2232
  help="summarize phases/issues from events.ndjson",
2223
2233
  )
2224
- p_summary.add_argument("--change-dir", required=True)
2234
+ p_summary.add_argument("--change-dir", "--change", dest="change_dir", required=True)
2225
2235
  p_summary.set_defaults(func=cmd_summary)
2226
2236
 
2227
2237
  return parser
@@ -75,6 +75,14 @@ def _write_json(path: Path, value: dict[str, Any]) -> None:
75
75
  temporary.unlink(missing_ok=True)
76
76
 
77
77
 
78
+ def _read_json(path: Path) -> Any:
79
+ try:
80
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
81
+ except (OSError, json.JSONDecodeError):
82
+ return None
83
+ return value
84
+
85
+
78
86
  def _load(change_dir: Path, batch_id: str) -> dict[str, Any]:
79
87
  path = _batch_path(change_dir, batch_id)
80
88
  try:
@@ -1186,7 +1194,21 @@ def evidence_template(
1186
1194
  "provenance": provenance,
1187
1195
  }
1188
1196
  target = Path(out) if out else Path("runtime") / f"fixback-{resolved_id}.json"
1189
- path = target if target.is_absolute() else (change_dir / target)
1197
+ if target.is_absolute():
1198
+ path = target
1199
+ else:
1200
+ # F-3:与 ledger --out 同一类坑——相对路径已含 change-dir 前缀时直接拼
1201
+ # change_dir 会产生嵌套幽灵目录(2026-08-31 实测)。前缀命中按 cwd 解析。
1202
+ cwd = Path.cwd().resolve()
1203
+ change_resolved = change_dir.resolve()
1204
+ if _is_within(change_resolved, [cwd]):
1205
+ change_rel = change_resolved.relative_to(cwd)
1206
+ if target.parts[: len(change_rel.parts)] == change_rel.parts:
1207
+ path = cwd / target
1208
+ else:
1209
+ path = change_dir / target
1210
+ else:
1211
+ path = change_dir / target
1190
1212
  path = path.resolve()
1191
1213
  if not _is_within(path, [change_dir.resolve(), state_root, _project_root(change_dir)]):
1192
1214
  return {
@@ -1708,6 +1730,20 @@ def close_batch(
1708
1730
  }
1709
1731
  )
1710
1732
  _write_json(_batch_path(change_dir, batch_id), batch)
1733
+ # F-5:批次关闭会同步把托管会话置为 CLOSED——否则 session 永远 ACTIVE,
1734
+ # 后续 launch-review 被 CONTEXT_PREPARATION_ACTIVE 幂等保护误拦(2026-08-31
1735
+ # demo-datasource 实测)。
1736
+ session_file = _session_path(change_dir)
1737
+ session = _read_json(session_file)
1738
+ if (
1739
+ isinstance(session, dict)
1740
+ and session.get("batchId") == batch_id
1741
+ and session.get("status") == "ACTIVE"
1742
+ ):
1743
+ session["status"] = "CLOSED"
1744
+ session["nextStep"] = "done"
1745
+ session["updatedAt"] = closed_at
1746
+ _write_json(session_file, session)
1711
1747
  return batch
1712
1748
 
1713
1749
 
@@ -2594,6 +2594,64 @@ def _normalize_gate_phase(args: argparse.Namespace, as_json: bool) -> int | None
2594
2594
  )
2595
2595
 
2596
2596
 
2597
+ def _verification_target_coverage_warning(
2598
+ change_dir: Path, phase: str
2599
+ ) -> dict[str, Any] | None:
2600
+ """E-5:场景表引用未声明的 verification target 时提前 WARN。
2601
+
2602
+ 场景带 execution_level(unit/integration/…),但 build-profile 的
2603
+ verificationGraph.targets 未声明对应 target 时,问题要等到 ledger record
2604
+ 才以 unsupported verification 报错——太迟。gate begin 提前列出待补清单。
2605
+ 映射约定:unit → unitTest;其他 level → 任一以其名开头的 target。
2606
+ """
2607
+ if phase != "execute":
2608
+ return None
2609
+ manifest_path = change_dir / "meta" / "scenario-manifest.json"
2610
+ profile_path = change_dir / "meta" / "build-profile.json"
2611
+ if not profile_path.is_file():
2612
+ # build-profile 常规位置在项目 .harness/config/;change meta 下优先
2613
+ profile_path = change_dir.parents[2] / ".harness" / "config" / "build-profile.json"
2614
+ if not manifest_path.is_file() or not profile_path.is_file():
2615
+ return None
2616
+ try:
2617
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
2618
+ profile = json.loads(profile_path.read_text(encoding="utf-8-sig"))
2619
+ except (OSError, json.JSONDecodeError):
2620
+ return None
2621
+ content = manifest.get("content") if isinstance(manifest, dict) else None
2622
+ scenarios = content.get("scenarios") if isinstance(content, dict) else None
2623
+ if not isinstance(scenarios, list):
2624
+ return None
2625
+ levels = sorted({
2626
+ str(s.get("execution_level")).strip()
2627
+ for s in scenarios
2628
+ if isinstance(s, dict) and str(s.get("execution_level") or "").strip()
2629
+ })
2630
+ graph = profile.get("verificationGraph")
2631
+ targets = graph.get("targets") if isinstance(graph, dict) else None
2632
+ target_names = set(targets.keys()) if isinstance(targets, dict) else set()
2633
+ uncovered = [
2634
+ level for level in levels
2635
+ if level != "unit"
2636
+ and not any(name.lower().startswith(level.lower()) for name in target_names)
2637
+ ]
2638
+ # unit 场景由 unitTest 覆盖;只在完全没有 unitTest 时才算缺口
2639
+ if "unit" in levels and "unitTest" not in target_names:
2640
+ uncovered.append("unit(→unitTest)")
2641
+ if not uncovered:
2642
+ return None
2643
+ return {
2644
+ "code": "VERIFICATION_TARGETS_UNDECLARED",
2645
+ "message": (
2646
+ "场景表涉及未声明的验证级别: " + ", ".join(uncovered) +
2647
+ ";ledger record 将报 unsupported verification。请在 "
2648
+ ".harness/config/build-profile.json 的 verificationGraph.targets "
2649
+ "声明对应 target(如 integrationTest)"
2650
+ ),
2651
+ "uncoveredLevels": uncovered,
2652
+ }
2653
+
2654
+
2597
2655
  def cmd_begin(args: argparse.Namespace) -> int:
2598
2656
  as_json = bool(args.json)
2599
2657
  normalized = _normalize_gate_phase(args, as_json)
@@ -2911,6 +2969,14 @@ def cmd_begin(args: argparse.Namespace) -> int:
2911
2969
  )
2912
2970
  raise
2913
2971
 
2972
+ coverage_warning = _verification_target_coverage_warning(change_dir, args.phase)
2973
+ if coverage_warning is not None:
2974
+ gate_warnings.append(coverage_warning)
2975
+ print(
2976
+ f"[harness-gate] WARNING {coverage_warning['code']}: "
2977
+ f"{coverage_warning['message']}",
2978
+ file=sys.stderr,
2979
+ )
2914
2980
  payload = {
2915
2981
  "ok": True,
2916
2982
  "code": "PHASE_BEGUN",
@@ -3039,6 +3105,20 @@ def _resume_closed_phase(
3039
3105
  for candidate in hctx.allowed_next_phases(project, change_id, args.phase)
3040
3106
  if hp.resolve_phase_name(candidate) != args.phase
3041
3107
  ]
3108
+ # F-4:续跑路径同样——fixback 回环的 execute 关门应回 review 的后继
3109
+ if args.phase == "execute" and isinstance(transitions, list) and transitions:
3110
+ latest_t = transitions[-1]
3111
+ if (
3112
+ isinstance(latest_t, dict)
3113
+ and hp.resolve_phase_name(latest_t.get("fromPhase")) == "review"
3114
+ and hp.resolve_phase_name(latest_t.get("toPhase")) == "execute"
3115
+ and latest_t.get("trigger") == "review-fixback"
3116
+ ):
3117
+ candidates = [
3118
+ candidate
3119
+ for candidate in hctx.allowed_next_phases(project, change_id, "review")
3120
+ if hp.resolve_phase_name(candidate) != args.phase
3121
+ ]
3042
3122
  if to_phase is None and len(candidates) == 1:
3043
3123
  # 计划后继唯一(排除 fixback 自环):close 的意图没有歧义,直接补跑 handoff
3044
3124
  to_phase = candidates[0]
@@ -3625,10 +3705,25 @@ def cmd_close(args: argparse.Namespace) -> int:
3625
3705
  and context_view.get("ok")
3626
3706
  and isinstance(context_view.get("current"), dict)
3627
3707
  ):
3708
+ # F-4:fixback 回环(最新交接是 review→execute 且 trigger=
3709
+ # review-fixback)关门时,后继不是再来一轮 review,而是 review
3710
+ # 当时的计划后继(submit)。不按此处理会把 transitions 链写成
3711
+ # …→review→execute→review 的语义错乱(2026-08-31 实测)。
3712
+ source_phase = args.phase
3713
+ transitions = context_view.get("transitions") or []
3714
+ if args.phase == "execute" and transitions:
3715
+ latest = transitions[-1]
3716
+ if (
3717
+ isinstance(latest, dict)
3718
+ and hp.resolve_phase_name(latest.get("fromPhase")) == "review"
3719
+ and hp.resolve_phase_name(latest.get("toPhase")) == "execute"
3720
+ and latest.get("trigger") == "review-fixback"
3721
+ ):
3722
+ source_phase = "review"
3628
3723
  forward = [
3629
3724
  candidate
3630
3725
  for candidate in hctx.allowed_next_phases(
3631
- project, str(resolved["changeId"]), args.phase
3726
+ project, str(resolved["changeId"]), source_phase
3632
3727
  )
3633
3728
  if hp.resolve_phase_name(candidate) != args.phase
3634
3729
  ]
@@ -3800,11 +3895,25 @@ def cmd_classify(args: argparse.Namespace) -> int:
3800
3895
  payload["classifiedAt"] = classified_at
3801
3896
 
3802
3897
  if change_dir is not None and change_dir.is_dir():
3803
- policy_doc = gate_policy_document(payload)
3804
- policy_path = change_dir / "meta" / "gate-policy.json"
3805
- _write_json(policy_path, policy_doc)
3806
- payload["policyPersisted"] = True
3807
- payload["policyPath"] = str(policy_path)
3898
+ finalized = (change_dir / "meta" / "plan-profile.json").is_file()
3899
+ if finalized and not getattr(args, "force", False):
3900
+ # P1-1:classify 形态像只读查询,实际是写操作。已发布的 change
3901
+ # 被误跑 classify 会覆盖 configure-plan 写入的工作副本(2026-08-30
3902
+ # demo-datasource 实测 plannedPhases 被清)。默认拒写并显式提示。
3903
+ payload["policyPersisted"] = False
3904
+ payload["warning"] = (
3905
+ "change 已发布(meta/plan-profile.json 存在),classify 不再重写 "
3906
+ "meta/gate-policy.json 工作副本;确需重算请显式传 --force"
3907
+ )
3908
+ payload["writeGuarded"] = True
3909
+ else:
3910
+ policy_doc = gate_policy_document(payload)
3911
+ policy_path = change_dir / "meta" / "gate-policy.json"
3912
+ _write_json(policy_path, policy_doc)
3913
+ payload["policyPersisted"] = True
3914
+ payload["policyPath"] = str(policy_path)
3915
+ if finalized:
3916
+ payload["warning"] = "--force:已发布 change 的工作副本已被重写"
3808
3917
  else:
3809
3918
  payload["policyPersisted"] = False
3810
3919
  payload["warning"] = (
@@ -3930,7 +4039,7 @@ def build_parser() -> argparse.ArgumentParser:
3930
4039
 
3931
4040
  p_begin = sub.add_parser("begin", parents=[shared])
3932
4041
  p_begin.add_argument("--phase", required=True)
3933
- p_begin.add_argument("--change", default=None)
4042
+ p_begin.add_argument("--change", "--change-dir", dest="change", default=None)
3934
4043
  p_begin.add_argument(
3935
4044
  "--project",
3936
4045
  default=None,
@@ -3960,7 +4069,7 @@ def build_parser() -> argparse.ArgumentParser:
3960
4069
  help="关闭阶段门禁(关门只调本命令;上下文交接由 --to-phase 内联完成,不要再单独调 harness_context.py close)",
3961
4070
  )
3962
4071
  p_close.add_argument("--phase", required=True)
3963
- p_close.add_argument("--change", default=None)
4072
+ p_close.add_argument("--change", "--change-dir", dest="change", default=None)
3964
4073
  p_close.add_argument(
3965
4074
  "--project",
3966
4075
  default=None,
@@ -3985,7 +4094,7 @@ def build_parser() -> argparse.ArgumentParser:
3985
4094
 
3986
4095
  p_classify = sub.add_parser("classify", parents=[shared])
3987
4096
  p_classify.add_argument("--project", default=None, type=Path, help=_PROJECT_ROOT_HELP)
3988
- p_classify.add_argument("--change", default=None)
4097
+ p_classify.add_argument("--change", "--change-dir", dest="change", default=None)
3989
4098
  p_classify.add_argument("--stage", required=True, choices=["plan", "post-run"])
3990
4099
  p_classify.add_argument(
3991
4100
  "--tier-override",
@@ -3993,13 +4102,18 @@ def build_parser() -> argparse.ArgumentParser:
3993
4102
  choices=["fast", "standard", "full"],
3994
4103
  )
3995
4104
  p_classify.add_argument("--override-by", default="user")
4105
+ p_classify.add_argument(
4106
+ "--force",
4107
+ action="store_true",
4108
+ help="已发布 change 也强制重写 gate-policy.json 工作副本",
4109
+ )
3996
4110
  p_classify.set_defaults(func=cmd_classify)
3997
4111
 
3998
4112
  p_checkpoint = sub.add_parser("checkpoint", parents=[shared])
3999
4113
  p_checkpoint.add_argument("--project", default=None, type=Path, help=_PROJECT_ROOT_HELP)
4000
4114
  p_checkpoint.add_argument("checkpoint_action", choices=["status", "approve"])
4001
4115
  p_checkpoint.add_argument("--id", required=True)
4002
- p_checkpoint.add_argument("--change", default=None)
4116
+ p_checkpoint.add_argument("--change", "--change-dir", dest="change", default=None)
4003
4117
  p_checkpoint.add_argument("--reviewer", default=None)
4004
4118
  p_checkpoint.set_defaults(func=cmd_checkpoint)
4005
4119
 
@@ -1624,6 +1624,15 @@ def evidence_summary(entry: dict[str, Any]) -> dict[str, Any]:
1624
1624
  }
1625
1625
 
1626
1626
 
1627
+ def _is_relative_to(path: Path, base: Path) -> bool:
1628
+ """Python 3.9 兼容的 is_relative_to。"""
1629
+ try:
1630
+ path.relative_to(base)
1631
+ return True
1632
+ except ValueError:
1633
+ return False
1634
+
1635
+
1627
1636
  def _nonempty_str(value: Any) -> bool:
1628
1637
  return isinstance(value, str) and bool(value.strip())
1629
1638
 
@@ -3056,6 +3065,12 @@ def cmd_record(args: argparse.Namespace) -> int:
3056
3065
  "scenarioCoverage": entry.get("scenarioCoverage"),
3057
3066
  "migrationReceipt": migration_receipt,
3058
3067
  }
3068
+ zero_warn = _zero_tests_with_selector_warning(
3069
+ str(args.command), args.evidence, project_root
3070
+ )
3071
+ if zero_warn is not None:
3072
+ payload["warnings"] = [zero_warn]
3073
+ print(f"[harness-ledger] WARNING {zero_warn}", file=sys.stderr)
3059
3074
  emit_compact_or_verbose(
3060
3075
  payload, as_json=as_json, verbose=verbose,
3061
3076
  compact_fn=_compact_record_payload,
@@ -3063,6 +3078,47 @@ def cmd_record(args: argparse.Namespace) -> int:
3063
3078
  return 0
3064
3079
 
3065
3080
 
3081
+ def _zero_tests_with_selector_warning(
3082
+ command: str, evidence: str | None, project_root: Path | None
3083
+ ) -> str | None:
3084
+ """选择器存在却 0 命中 → WARN(E-2)。
3085
+
3086
+ surefire 插件级 ``<excludedGroups>`` 会压过命令行空值覆盖,
3087
+ `-Dgroups=X` 选择器 0 命中仍 exit 0——只看退出码就是假阳性
3088
+ (2026-08-30 demo-datasource 集成测试静默 0 执行实测)。
3089
+ 证据文本出现 Tests run: 0 且命令含选择器时,record 给出醒目警告。
3090
+ """
3091
+ if not evidence:
3092
+ return None
3093
+ selectors = ("-Dgroups=", "-Dtest=", "--groups", "--filter", "-DfailIfNoSpecifiedTests")
3094
+ if not any(selector in str(command) for selector in selectors):
3095
+ return None
3096
+ candidates = [Path(evidence)]
3097
+ if project_root is not None and not Path(evidence).is_absolute():
3098
+ candidates.insert(0, project_root / evidence)
3099
+ text: str | None = None
3100
+ for candidate in candidates:
3101
+ try:
3102
+ if candidate.is_file() and candidate.stat().st_size <= 4 * 1024 * 1024:
3103
+ text = candidate.read_text(encoding="utf-8", errors="replace")
3104
+ break
3105
+ except OSError:
3106
+ continue
3107
+ if text is None:
3108
+ return None
3109
+ zero = re.search(
3110
+ r"Tests run:\s*0,\s*Failures:\s*0,\s*Errors:\s*0", text
3111
+ )
3112
+ ran_something = re.search(r"Tests run:\s*[1-9]", text)
3113
+ if zero is not None and ran_something is None:
3114
+ return (
3115
+ "ZERO_TESTS_WITH_SELECTOR: 命令含测试选择器但 Tests run=0(exit 0 不代表通过);"
3116
+ "常见于 surefire 插件级 excludedGroups 压过命令行覆盖——检查 pom 是否写死 "
3117
+ "<excludedGroups>(应改为 ${excludedGroups} 属性占位)"
3118
+ )
3119
+ return None
3120
+
3121
+
3066
3122
  def cmd_scenario_receipt_template(args: argparse.Namespace) -> int:
3067
3123
  """Emit a schema-v2 receipt skeleton built from the scenario manifest.
3068
3124
 
@@ -3202,8 +3258,32 @@ def cmd_scenario_receipt_template(args: argparse.Namespace) -> int:
3202
3258
  return 0
3203
3259
  out_path = Path(str(out_raw)).expanduser()
3204
3260
  if not out_path.is_absolute():
3205
- out_path = change_dir / out_path
3261
+ # 防双重拼接(E-1):调用方按 CLI 惯例传 cwd 相对路径时(如
3262
+ # .harness/changes/<cn>/evidence/x.json),直接拼 change_dir 会产生嵌套
3263
+ # 幽灵目录。相对路径若以 change-dir 自身前缀开头,按 cwd 解析;否则按
3264
+ # 文档规则相对 change-dir 解析。
3265
+ cwd = Path.cwd().resolve()
3266
+ change_resolved = change_dir.resolve()
3267
+ if _is_relative_to(change_resolved, cwd):
3268
+ change_rel = change_resolved.relative_to(cwd)
3269
+ if out_path.parts[: len(change_rel.parts)] == change_rel.parts:
3270
+ out_path = cwd / out_path
3271
+ else:
3272
+ out_path = change_dir / out_path
3273
+ else:
3274
+ out_path = change_dir / out_path
3206
3275
  out_path = out_path.resolve()
3276
+ change_resolved = change_dir.resolve()
3277
+ cwd = Path.cwd().resolve()
3278
+ if not (
3279
+ _is_relative_to(out_path, change_resolved)
3280
+ or _is_relative_to(out_path, cwd)
3281
+ ):
3282
+ return emit_error(
3283
+ "--out 解析后越出项目目录: " + str(out_path),
3284
+ as_json=as_json,
3285
+ error_code="SCENARIO_RECEIPT_OUT_OF_PROJECT",
3286
+ )
3207
3287
  try:
3208
3288
  out_path.parent.mkdir(parents=True, exist_ok=True)
3209
3289
  out_path.write_text(body, encoding="utf-8", newline="\n")
@@ -3321,7 +3401,7 @@ def build_parser() -> argparse.ArgumentParser:
3321
3401
  p_hash.set_defaults(func=cmd_hash)
3322
3402
 
3323
3403
  p_reuse = sub.add_parser("can-reuse", parents=[shared_json], help="decide whether a verification can be reused")
3324
- p_reuse.add_argument("--change-dir", required=True)
3404
+ p_reuse.add_argument("--change-dir", "--change", dest="change_dir", required=True)
3325
3405
  p_reuse.add_argument(
3326
3406
  "--verification",
3327
3407
  required=True,
@@ -3389,7 +3469,7 @@ def build_parser() -> argparse.ArgumentParser:
3389
3469
  p_reuse.set_defaults(func=cmd_can_reuse)
3390
3470
 
3391
3471
  p_record = sub.add_parser("record", parents=[shared_json], help="write validation result into ledger")
3392
- p_record.add_argument("--change-dir", required=True)
3472
+ p_record.add_argument("--change-dir", "--change", dest="change_dir", required=True)
3393
3473
  p_record.add_argument("--verification", required=True)
3394
3474
  p_record.add_argument("--status", required=True)
3395
3475
  p_record.add_argument("--command", required=True)
@@ -3515,7 +3595,7 @@ def build_parser() -> argparse.ArgumentParser:
3515
3595
  "pre-filled from meta/scenario-manifest.json"
3516
3596
  ),
3517
3597
  )
3518
- p_receipt.add_argument("--change-dir", required=True)
3598
+ p_receipt.add_argument("--change-dir", "--change", dest="change_dir", required=True)
3519
3599
  p_receipt.add_argument(
3520
3600
  "--scenario-ids",
3521
3601
  required=True,