@hunter-harness/workflow-harness 0.2.85 → 0.2.86

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/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-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -2
  7. package/harness/bundles/general/claude-code/harness-plan/checklist.md +18 -79
  8. package/harness/bundles/general/claude-code/harness-plan/protocols.md +3 -1
  9. package/harness/bundles/general/claude-code/harness-plan/reference.md +37 -8
  10. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/SKILL.md +6 -4
  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/harness-test/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_change.py +25 -7
  18. package/harness/bundles/general/claude-code/scripts/harness_context.py +62 -19
  19. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +4 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_gate.py +208 -72
  21. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +43 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_paths.py +60 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_phase.py +24 -6
  24. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +124 -1
  25. package/harness/bundles/general/claude-code/scripts/harness_review.py +25 -4
  26. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  27. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -2
  32. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +18 -79
  33. package/harness/bundles/general/codebuddy/harness-plan/protocols.md +3 -1
  34. package/harness/bundles/general/codebuddy/harness-plan/reference.md +37 -8
  35. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +6 -4
  39. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_change.py +25 -7
  43. package/harness/bundles/general/codebuddy/scripts/harness_context.py +62 -19
  44. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +4 -0
  45. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +208 -72
  46. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +43 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_paths.py +60 -0
  48. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +24 -6
  49. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +124 -1
  50. package/harness/bundles/general/codebuddy/scripts/harness_review.py +25 -4
  51. package/harness/bundles/general/codex/.harness-build.json +1 -1
  52. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -2
  57. package/harness/bundles/general/codex/harness-plan/checklist.md +18 -79
  58. package/harness/bundles/general/codex/harness-plan/protocols.md +3 -1
  59. package/harness/bundles/general/codex/harness-plan/reference.md +37 -8
  60. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-run/SKILL.md +6 -4
  64. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/scripts/harness_change.py +25 -7
  68. package/harness/bundles/general/codex/scripts/harness_context.py +62 -19
  69. package/harness/bundles/general/codex/scripts/harness_fixback.py +4 -0
  70. package/harness/bundles/general/codex/scripts/harness_gate.py +208 -72
  71. package/harness/bundles/general/codex/scripts/harness_ledger.py +43 -0
  72. package/harness/bundles/general/codex/scripts/harness_paths.py +60 -0
  73. package/harness/bundles/general/codex/scripts/harness_phase.py +24 -6
  74. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +124 -1
  75. package/harness/bundles/general/codex/scripts/harness_review.py +25 -4
  76. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  77. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -2
  82. package/harness/bundles/general/cursor/harness-plan/checklist.md +18 -79
  83. package/harness/bundles/general/cursor/harness-plan/protocols.md +3 -1
  84. package/harness/bundles/general/cursor/harness-plan/reference.md +37 -8
  85. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  86. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  87. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-run/SKILL.md +6 -4
  89. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  90. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  91. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/scripts/harness_change.py +25 -7
  93. package/harness/bundles/general/cursor/scripts/harness_context.py +62 -19
  94. package/harness/bundles/general/cursor/scripts/harness_fixback.py +4 -0
  95. package/harness/bundles/general/cursor/scripts/harness_gate.py +208 -72
  96. package/harness/bundles/general/cursor/scripts/harness_ledger.py +43 -0
  97. package/harness/bundles/general/cursor/scripts/harness_paths.py +60 -0
  98. package/harness/bundles/general/cursor/scripts/harness_phase.py +24 -6
  99. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +124 -1
  100. package/harness/bundles/general/cursor/scripts/harness_review.py +25 -4
  101. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  102. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  106. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -2
  109. package/harness/bundles/java/claude-code/harness-plan/checklist.md +18 -79
  110. package/harness/bundles/java/claude-code/harness-plan/protocols.md +3 -1
  111. package/harness/bundles/java/claude-code/harness-plan/reference.md +37 -8
  112. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  113. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-run/SKILL.md +6 -4
  116. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  118. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  119. package/harness/bundles/java/claude-code/scripts/harness_change.py +25 -7
  120. package/harness/bundles/java/claude-code/scripts/harness_context.py +62 -19
  121. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +4 -0
  122. package/harness/bundles/java/claude-code/scripts/harness_gate.py +208 -72
  123. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +43 -0
  124. package/harness/bundles/java/claude-code/scripts/harness_paths.py +60 -0
  125. package/harness/bundles/java/claude-code/scripts/harness_phase.py +24 -6
  126. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +124 -1
  127. package/harness/bundles/java/claude-code/scripts/harness_review.py +25 -4
  128. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  129. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  130. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  131. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -2
  136. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +18 -79
  137. package/harness/bundles/java/codebuddy/harness-plan/protocols.md +3 -1
  138. package/harness/bundles/java/codebuddy/harness-plan/reference.md +37 -8
  139. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  140. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  141. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  142. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +6 -4
  143. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  145. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/scripts/harness_change.py +25 -7
  147. package/harness/bundles/java/codebuddy/scripts/harness_context.py +62 -19
  148. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +4 -0
  149. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +208 -72
  150. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +43 -0
  151. package/harness/bundles/java/codebuddy/scripts/harness_paths.py +60 -0
  152. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +24 -6
  153. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +124 -1
  154. package/harness/bundles/java/codebuddy/scripts/harness_review.py +25 -4
  155. package/harness/bundles/java/codex/.harness-build.json +1 -1
  156. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  157. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  158. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  159. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  160. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  161. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -2
  163. package/harness/bundles/java/codex/harness-plan/checklist.md +18 -79
  164. package/harness/bundles/java/codex/harness-plan/protocols.md +3 -1
  165. package/harness/bundles/java/codex/harness-plan/reference.md +37 -8
  166. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  168. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  169. package/harness/bundles/java/codex/harness-run/SKILL.md +6 -4
  170. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  171. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  173. package/harness/bundles/java/codex/scripts/harness_change.py +25 -7
  174. package/harness/bundles/java/codex/scripts/harness_context.py +62 -19
  175. package/harness/bundles/java/codex/scripts/harness_fixback.py +4 -0
  176. package/harness/bundles/java/codex/scripts/harness_gate.py +208 -72
  177. package/harness/bundles/java/codex/scripts/harness_ledger.py +43 -0
  178. package/harness/bundles/java/codex/scripts/harness_paths.py +60 -0
  179. package/harness/bundles/java/codex/scripts/harness_phase.py +24 -6
  180. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +124 -1
  181. package/harness/bundles/java/codex/scripts/harness_review.py +25 -4
  182. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  183. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  185. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  186. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -2
  190. package/harness/bundles/java/cursor/harness-plan/checklist.md +18 -79
  191. package/harness/bundles/java/cursor/harness-plan/protocols.md +3 -1
  192. package/harness/bundles/java/cursor/harness-plan/reference.md +37 -8
  193. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  194. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  195. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  196. package/harness/bundles/java/cursor/harness-run/SKILL.md +6 -4
  197. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  198. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  199. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  200. package/harness/bundles/java/cursor/scripts/harness_change.py +25 -7
  201. package/harness/bundles/java/cursor/scripts/harness_context.py +62 -19
  202. package/harness/bundles/java/cursor/scripts/harness_fixback.py +4 -0
  203. package/harness/bundles/java/cursor/scripts/harness_gate.py +208 -72
  204. package/harness/bundles/java/cursor/scripts/harness_ledger.py +43 -0
  205. package/harness/bundles/java/cursor/scripts/harness_paths.py +60 -0
  206. package/harness/bundles/java/cursor/scripts/harness_phase.py +24 -6
  207. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +124 -1
  208. package/harness/bundles/java/cursor/scripts/harness_review.py +25 -4
  209. package/harness/manifests/general/claude-code.json +26 -26
  210. package/harness/manifests/general/codebuddy.json +26 -26
  211. package/harness/manifests/general/codex.json +26 -26
  212. package/harness/manifests/general/cursor.json +26 -26
  213. package/harness/manifests/java/claude-code.json +28 -28
  214. package/harness/manifests/java/codebuddy.json +28 -28
  215. package/harness/manifests/java/codex.json +28 -28
  216. package/harness/manifests/java/cursor.json +28 -28
  217. package/hunter-workflow-family.json +4 -4
  218. package/package.json +1 -1
@@ -564,18 +564,36 @@ def _lease_expired(lease: dict[str, Any]) -> bool:
564
564
  return dt.datetime.now().astimezone() >= exp_dt
565
565
 
566
566
 
567
- def inspect_lease(project_root: Path, change_id: str) -> dict[str, Any] | None:
568
- """Return the current non-expired lease without mutating it."""
567
+ def inspect_lease_state(project_root: Path, change_id: str) -> dict[str, Any]:
568
+ """Read the lease without mutating it, keeping the three failure modes apart.
569
+
570
+ ``inspect_lease`` collapses absent / expired / corrupt into ``None``, so a
571
+ caller cannot tell "nobody ever held this" from "the phase outlived its
572
+ TTL". Those need different answers: an expired lease still carries the
573
+ runId that proves who owns the phase, a corrupt one proves nothing, and an
574
+ absent one may just mean the phase already closed.
575
+
576
+ state is one of ``active`` | ``expired`` | ``absent`` | ``corrupt``. The
577
+ lease dict is returned for ``active`` and ``expired``; ``None`` otherwise.
578
+ """
569
579
  path = _lease_path(project_root, change_id)
570
580
  if not path.is_file():
571
- return None
581
+ return {"state": "absent", "lease": None}
572
582
  try:
573
583
  lease = _read_json(path)
574
584
  except (OSError, json.JSONDecodeError):
575
- return None
576
- if not isinstance(lease, dict) or _lease_expired(lease):
577
- return None
578
- return lease
585
+ return {"state": "corrupt", "lease": None}
586
+ if not isinstance(lease, dict):
587
+ return {"state": "corrupt", "lease": None}
588
+ if _lease_expired(lease):
589
+ return {"state": "expired", "lease": lease}
590
+ return {"state": "active", "lease": lease}
591
+
592
+
593
+ def inspect_lease(project_root: Path, change_id: str) -> dict[str, Any] | None:
594
+ """Return the current non-expired lease without mutating it."""
595
+ state = inspect_lease_state(project_root, change_id)
596
+ return state["lease"] if state["state"] == "active" else None
579
597
 
580
598
 
581
599
  def _claim_lease_locked(
@@ -21,6 +21,7 @@ if str(SCRIPTS_DIR) not in sys.path:
21
21
  sys.path.insert(0, str(SCRIPTS_DIR))
22
22
 
23
23
  import harness_ledger as hl # noqa: E402
24
+ import harness_paths as hpaths # noqa: E402
24
25
 
25
26
 
26
27
  PHASE_GRAPH = {
@@ -31,17 +32,8 @@ PHASE_GRAPH = {
31
32
  "submit": (),
32
33
  }
33
34
 
34
- WORKFLOW_PHASES = (
35
- "plan",
36
- "run",
37
- "test",
38
- "review",
39
- "package",
40
- "apidoc",
41
- "submit",
42
- "merge",
43
- "archive",
44
- )
35
+ # 唯一权威清单在 harness_paths(叶子模块,harness_phase 引的是同一个对象)。
36
+ WORKFLOW_PHASES = hpaths.WORKFLOW_PHASES
45
37
 
46
38
  DISPLAY_TITLE_MAX_LENGTH = 80
47
39
 
@@ -270,13 +262,24 @@ def _contract(project: Path, change: str) -> tuple[Path, dict[str, Any], Path]:
270
262
 
271
263
 
272
264
  def _phase_plan(contract_root: Path) -> tuple[list[str] | None, str]:
265
+ """读出阶段计划;无法采信时说明原因,而不是悄悄退成 legacy。
266
+
267
+ 以前这里遇到未知阶段名一律返回 (None, "legacy"):阶段计划整个失效,
268
+ `_allowed_next_phases` 退回硬编码的 PHASE_GRAPH,而调用方看不出发生过这件事。
269
+ 未知阶段名不是假想问题——阶段清单少一个 merge 时,worktree 变更的
270
+ plannedPhases 就会带着 merge 走到这里。
271
+
272
+ 未知名先过 LEGACY_PHASE_ALIASES 读时映射(阶段改名后历史 change 靠它继续可读),
273
+ 仍然解析不了才降级,并把原因与具体阶段名带在 source 里。
274
+ """
273
275
  policy_path = contract_root / "meta" / "gate-policy.json"
274
276
  if not policy_path.is_file():
275
277
  return None, "legacy"
276
278
  try:
277
279
  policy = _read_json(policy_path)
278
280
  except (OSError, ValueError, json.JSONDecodeError):
279
- return None, "legacy"
281
+ return None, "legacy:policy-unreadable"
282
+ unknown: list[str] = []
280
283
  for field, source in (
281
284
  ("plannedPhases", "change"),
282
285
  ("defaultPhases", "policy-default"),
@@ -285,12 +288,17 @@ def _phase_plan(contract_root: Path) -> tuple[list[str] | None, str]:
285
288
  if not isinstance(raw, list):
286
289
  continue
287
290
  phases = [str(item).strip() for item in raw if str(item).strip()]
288
- if (
289
- phases
290
- and len(phases) == len(set(phases))
291
- and all(item in WORKFLOW_PHASES for item in phases)
292
- ):
293
- return phases, source
291
+ if not phases or len(phases) != len(set(phases)):
292
+ continue
293
+ resolved = [hpaths.resolve_phase_name(item) for item in phases]
294
+ missing = [item for item, hit in zip(phases, resolved) if hit is None]
295
+ if missing:
296
+ unknown.extend(missing)
297
+ continue
298
+ # 映射后可能出现重复(两个旧名合并成同一个新阶段),保序去重。
299
+ return list(dict.fromkeys(str(item) for item in resolved)), source
300
+ if unknown:
301
+ return None, "legacy:unknown-phases=" + ",".join(dict.fromkeys(unknown))
294
302
  return None, "legacy"
295
303
 
296
304
 
@@ -405,6 +413,7 @@ def _paths(state_root: Path) -> dict[str, Path]:
405
413
  "begins": runtime / "transition-begins.ndjson",
406
414
  "current": runtime / "current-context.json",
407
415
  "lease": runtime / "context-lease.json",
416
+ "adoptions": runtime / "context-adoptions.ndjson",
408
417
  }
409
418
 
410
419
 
@@ -604,8 +613,14 @@ def _claim_prepared_context(
604
613
  )
605
614
  except (OSError, ValueError, json.JSONDecodeError):
606
615
  return {"ok": False, "code": "CONTEXT_CURRENT_INVALID"}
616
+ # 跨阶段推进必须有 receipt 证明它合法;同一个阶段换执行者不需要。
617
+ # 以前两者都要 receipt,于是上一阶段崩溃没能正常 close 时,接手的工具
618
+ # 连"继续把这个阶段干完"都做不到。并发保护不受影响:租约未过期而
619
+ # owner 不同,上面 CONTEXT_LEASE_HELD 已经拦下了,能走到这里只剩
620
+ # 「租约已过期」和「租约已被上一阶段 close 删除」两种情况。
621
+ adoption: dict[str, Any] | None = None
607
622
  if recovery is None and isinstance(current, dict) and (
608
- current.get("executor") != executor or current.get("phase") != phase
623
+ current.get("phase") != phase
609
624
  ):
610
625
  valid_receipt = (
611
626
  isinstance(latest, dict)
@@ -621,6 +636,19 @@ def _claim_prepared_context(
621
636
  "toExecutor": executor,
622
637
  "toPhase": phase,
623
638
  }
639
+ executor_changed = (
640
+ isinstance(current, dict) and current.get("executor") != executor
641
+ )
642
+ if recovery is not None or executor_changed:
643
+ adoption = {
644
+ "previousExecutor": (
645
+ current.get("executor") if isinstance(current, dict) else None
646
+ ),
647
+ "newExecutor": executor,
648
+ "reason": (
649
+ "lease_expired" if recovery is not None else "same_phase_handoff"
650
+ ),
651
+ }
624
652
 
625
653
  try:
626
654
  execution_root = _execution_root(project, contract_root, state_root)
@@ -657,6 +685,20 @@ def _claim_prepared_context(
657
685
  current_context["preparationId"] = preparation_id
658
686
  _write_json_atomic(paths["lease"], lease)
659
687
  _write_json_atomic(paths["current"], current_context)
688
+ if adoption is not None:
689
+ # 接管必须留痕。过期租约本来就允许任何执行者顶替,但以前只在返回体
690
+ # 里挂一个 recovery,退出进程就没了——事后查"这个阶段是谁接手的"
691
+ # 无据可依。收据形状照 harness_archive.adopt_existing_range。
692
+ adoption = {
693
+ "schemaVersion": 1,
694
+ "changeName": change,
695
+ "phase": phase,
696
+ **adoption,
697
+ "previousOwner": recovery.get("previousOwner") if recovery else None,
698
+ "expiredAt": recovery.get("expiredAt") if recovery else None,
699
+ "adoptedAt": now.isoformat(),
700
+ }
701
+ _append_ndjson(paths["adoptions"], adoption)
660
702
  return {
661
703
  "ok": True,
662
704
  "lease": lease,
@@ -664,6 +706,7 @@ def _claim_prepared_context(
664
706
  "transitions": transitions,
665
707
  "latestTransition": latest,
666
708
  "recovery": recovery,
709
+ "adoption": adoption,
667
710
  "executionRoot": str(execution_root),
668
711
  }
669
712
 
@@ -405,6 +405,10 @@ def _default_gate_begin(**kwargs: Any) -> dict[str, Any]:
405
405
  str(kwargs["run_id"]),
406
406
  "--note",
407
407
  "开始处理评审中确认需要修改的代码问题。",
408
+ # 显式标记这一轮是 review fixback。以前靠 gate 嗅探 note 里有没有
409
+ # "fixback" 字样——而上面这条 note 一个都没有,于是 fixback 的 run 事件
410
+ # 从来没被打上 trigger/from_phase。
411
+ "--fixback",
408
412
  ]
409
413
  for option, key in (
410
414
  ("--executor-tool", "executor_tool"),
@@ -157,6 +157,47 @@ GATE_WARNINGS_REL = Path("evidence") / "gate-warnings.ndjson"
157
157
  # phase close. Must stay a superset-ordering of hpf.VALID_OWNER_PHASES.
158
158
  SCENARIO_OWNER_PHASE_ORDER = ("plan", "run", "test", "review", "submit")
159
159
 
160
+ # ---------------------------------------------------------------------------
161
+ # 阶段能力表:每个阶段在 begin/close 里额外做什么,集中在这里声明。
162
+ #
163
+ # 这些开关以前是 cmd_begin / cmd_close 里散落的 `if args.phase == ...` —— 两个
164
+ # 五百行的函数,想知道"test 阶段关门时到底跑哪几项"要通读全文。更要紧的是
165
+ # run/test/review 的差异被摊平在控制流里,看不出它们其实共用同一套生命周期。
166
+ #
167
+ # 键的含义:
168
+ # plan_handoff begin 时校验 plan 已 finalize(只有进入实现的第一个阶段需要)
169
+ # test_guard begin 建测试基线快照、close 比对(改测试的阶段才需要)
170
+ # scenario_coverage close 时跑 C9 场景覆盖
171
+ # ledger_blocking close 时 ledger 校验失败是否阻断(否则只进 payload)
172
+ # review_outputs close 时校验 review sidecar 与 runId 绑定
173
+ # head_may_advance capsule 校验允许 HEAD 前移(会产生 commit 的阶段)
174
+ # projection_drift close 时 projection receipt 变化即硬失败(外发边界)
175
+ PHASE_GATE_RULES: dict[str, frozenset[str]] = {
176
+ "plan": frozenset(),
177
+ "run": frozenset({
178
+ "plan_handoff", "test_guard", "scenario_coverage",
179
+ "ledger_blocking", "head_may_advance",
180
+ }),
181
+ "test": frozenset({"test_guard", "scenario_coverage", "ledger_blocking"}),
182
+ "review": frozenset({"review_outputs"}),
183
+ "package": frozenset({"ledger_blocking"}),
184
+ "apidoc": frozenset(),
185
+ "submit": frozenset({"head_may_advance", "projection_drift"}),
186
+ "merge": frozenset({"head_may_advance"}),
187
+ "archive": frozenset({"projection_drift"}),
188
+ }
189
+ # 不在 WORKFLOW_PHASES 里、但 projection 门禁按发布阶段对待的名字。
190
+ _EXTRA_PROJECTION_DRIFT_PHASES = frozenset({"release", "deploy"})
191
+
192
+
193
+ def phase_gate_rule(phase: str | None, rule: str) -> bool:
194
+ """这个阶段是否启用某项门禁能力。未知阶段一律不启用(fail-safe)。"""
195
+ name = str(phase or "")
196
+ if rule == "projection_drift" and name in _EXTRA_PROJECTION_DRIFT_PHASES:
197
+ return True
198
+ resolved = hp.resolve_phase_name(name) or name
199
+ return rule in PHASE_GATE_RULES.get(resolved, frozenset())
200
+
160
201
 
161
202
  def gate_severity_mode(project: Path, change_dir: Path | None = None) -> str:
162
203
  """Resolve gate severity mode: env > change gate-policy > project config.
@@ -554,19 +595,39 @@ def load_checkpoints(change_dir: Path) -> dict[str, Any] | None:
554
595
  data = _read_json(path)
555
596
  except (OSError, json.JSONDecodeError):
556
597
  return None
598
+ # 刻意返回**原样**文档:cmd_checkpoint approve 会把这里的返回值写回盘,
599
+ # 返回归一化结构会用合成内容覆盖 v2 的哈希绑定产物(ARTIFACT_HASH_DRIFT)。
600
+ # 形状差异在只读的 checkpoint_status 里消化。
557
601
  return data if isinstance(data, dict) else None
558
602
 
559
603
 
560
604
  def checkpoint_status(checkpoints: dict[str, Any] | None, checkpoint_id: str) -> str:
605
+ """Read a checkpoint's status across the three shapes this file is written in.
606
+
607
+ `meta/implementation-checkpoints.json` 有三个写入者,形状各不相同:
608
+ `harness_change.migrate` 写 ``checkpoints[{id,status}]``;legacy plan finalize 写
609
+ 顶层 ``foundationGate``;v2 plan finalize 写 artifact 包装体,状态在
610
+ ``content.foundation_gate``。以前只认第一种,另外两种一律返回 "missing"
611
+ ——checkpoint 的状态对整条 v2 路径和 legacy finalize 之后都是读不出来的。
612
+
613
+ 只读归一化:调用方拿到的是状态,不是文档。写回路径(cmd_checkpoint approve)
614
+ 仍然操作原始文档,不能用归一化结构覆盖哈希绑定的 v2 产物。
615
+ """
561
616
  if not checkpoints:
562
617
  return "missing"
563
618
  items = checkpoints.get("checkpoints")
564
- if not isinstance(items, list):
619
+ if isinstance(items, list):
620
+ for item in items:
621
+ if isinstance(item, dict) and item.get("id") == checkpoint_id:
622
+ return str(item.get("status") or "pending")
565
623
  return "missing"
566
- for item in items:
567
- if isinstance(item, dict) and item.get("id") == checkpoint_id:
568
- return str(item.get("status") or "pending")
569
- return "missing"
624
+ if checkpoint_id != "foundation-gate":
625
+ return "missing"
626
+ unpacked = hpf.unpack_v2_implementation_checkpoints(checkpoints)
627
+ if unpacked is not None:
628
+ return str(unpacked["checkpoints"][0]["status"])
629
+ legacy = checkpoints.get("foundationGate")
630
+ return str(legacy).strip() if str(legacy or "").strip() else "missing"
570
631
 
571
632
 
572
633
  def foundation_gate_blocks(task_number: int | None, change_dir: Path) -> dict[str, Any] | None:
@@ -901,8 +962,13 @@ def validate_identity(
901
962
  core_hash = str(build.get("coreHash") or "").strip()
902
963
  if not agent or not core_hash:
903
964
  raise ValueError("BUNDLE_IDENTITY_INVALID: agent/coreHash is required")
904
- if executor_tool and executor_tool != agent:
905
- raise ValueError(f"BUNDLE_IDENTITY_MISMATCH: executor {executor_tool} uses {agent} bundle")
965
+
966
+ # 「这个 bundle 是否可信」和「现在是哪个工具在跑」是两件事。以前
967
+ # executor_tool != agent 直接 BUNDLE_IDENTITY_MISMATCH,于是 Codex 换
968
+ # CodeBuddy 接手同一个 change 会被当成 bundle 身份不符挡下来。下面每一项
969
+ # 校验用的都是 bundle 自称的 agent,与当前工具无关;工具名降级为审计字段,
970
+ # 随 phase.start 事件留痕。
971
+ executor_matches_bundle = not executor_tool or executor_tool == agent
906
972
 
907
973
  context = _read_json(project / ".harness" / "context-index.json")
908
974
  installed = _read_json(
@@ -953,6 +1019,8 @@ def validate_identity(
953
1019
  "adapter": agent,
954
1020
  "buildMarkerHash": actual_hash,
955
1021
  "contextIndexPresent": True,
1022
+ "executorTool": executor_tool or None,
1023
+ "executorMatchesBundle": executor_matches_bundle,
956
1024
  }
957
1025
 
958
1026
 
@@ -1295,8 +1363,13 @@ def classify_risk(
1295
1363
  stage: str,
1296
1364
  workflow: dict[str, Any] | None = None,
1297
1365
  ) -> dict[str, Any]:
1298
- tier = "full"
1299
- source = "default-full"
1366
+ # 无风险信号时的起步档。曾经是 full,于是每个普通变更都默认背上
1367
+ # plan→run→test→review→submit→archive 六阶段和 apiTest;风险信号推断只在
1368
+ # --stage post-run 下跑,而生产流程没有任何地方调用它,起步值实际就是终值。
1369
+ # standard 保留 compile/unitTest/unitTestFull 的证据闭环,只去掉默认的
1370
+ # review 阶段与 apiTest。下面的单调升级逻辑不变,有信号照样升到 full。
1371
+ tier = "standard"
1372
+ source = "default-standard"
1300
1373
  capabilities = _design_capabilities(change_dir)
1301
1374
  plan_path = change_dir / "plans"
1302
1375
  for candidate in sorted(plan_path.glob("*.md")) if plan_path.is_dir() else []:
@@ -1551,8 +1624,9 @@ def classify_defaults(
1551
1624
  change_id: str,
1552
1625
  stage: str,
1553
1626
  ) -> dict[str, Any]:
1554
- tier = "full"
1555
- source = "default-full"
1627
+ # change 目录不存在时的兜底,与 classify_risk 的起步档保持一致。
1628
+ tier = "standard"
1629
+ source = "default-standard"
1556
1630
  tier_policy = workflow["riskTiers"][tier]
1557
1631
  payload = {
1558
1632
  "ok": True,
@@ -1668,6 +1742,7 @@ def append_phase_event(
1668
1742
  executor_agent: str | None = None,
1669
1743
  executor_model: str | None = None,
1670
1744
  code: str | None = None,
1745
+ fixback: bool = False,
1671
1746
  ) -> dict[str, Any]:
1672
1747
  """Skill-facing phase lifecycle append (begin/close use this directly).
1673
1748
 
@@ -1720,8 +1795,17 @@ def append_phase_event(
1720
1795
  execution_mode=None,
1721
1796
  decision_reason_code=None,
1722
1797
  fallback_reason_code=None,
1723
- trigger=("fixback" if "fixback" in note.lower() else None),
1724
- from_phase=("review" if phase == "run" and "fixback" in note.lower() else None),
1798
+ # 显式信号优先。以前只嗅探 note 文本,而真正的 fixback 启动路径
1799
+ # (harness_fixback._default_gate_begin)写的 note
1800
+ # "开始处理评审中确认需要修改的代码问题。",一个 "fixback" 字都没有
1801
+ # ——于是 fixback 的 run 事件从来没被打上 trigger/from_phase。
1802
+ # note 嗅探保留为兼容回退:手工传 --note 带 fixback 的老用法仍然有效。
1803
+ trigger=("fixback" if fixback or "fixback" in note.lower() else None),
1804
+ from_phase=(
1805
+ "review"
1806
+ if phase == "run" and (fixback or "fixback" in note.lower())
1807
+ else None
1808
+ ),
1725
1809
  result_status=None,
1726
1810
  )
1727
1811
  event = he.build_event(args, existing)
@@ -2068,52 +2152,6 @@ def _resolve_project(args: argparse.Namespace) -> Path:
2068
2152
  return hc.resolve_main_project_root()
2069
2153
 
2070
2154
 
2071
- # 门禁消费的场景字段。v2 plan finalize 派生的 scenario_manifest 一个都没有:
2072
- # 它只带 scenario_id/coverage_dimension/execution_level/evidence_requirements/
2073
- # risk_level/task_refs/requirement_refs。
2074
- _V2_MANIFEST_REQUIRED_FIELDS = (
2075
- "id",
2076
- "priority",
2077
- "requiredEvidenceKind",
2078
- "ownerPhase",
2079
- "executableTestId",
2080
- "testFile",
2081
- "testTitle",
2082
- )
2083
-
2084
-
2085
- def _v2_artifact_manifest_gap(manifest: Any) -> dict[str, Any] | None:
2086
- """v2 plan artifact 包装的 scenario-manifest → 明确的不可消费错误。
2087
-
2088
- 不能"解包 + 字段改名"了事:包装体里既没有 priority 也没有
2089
- requiredEvidenceKind,`required_ids` 会算成空集,C9 随即返回
2090
- NO_LEDGER_REQUIRED_SCENARIOS——那是把证据门禁对所有 v2 计划静默关掉,
2091
- 比直接失败危险得多。所以这里点名缺口,让调用方回规划阶段补齐后重新发布。
2092
-
2093
- v2 场景契约补齐这些字段后,解包逻辑加在这里。
2094
- """
2095
- if not isinstance(manifest, dict) or manifest.get("artifact_type") != "scenario_manifest":
2096
- return None
2097
- content = manifest.get("content")
2098
- present: set[str] = set()
2099
- if isinstance(content, dict) and isinstance(content.get("scenarios"), list):
2100
- for scenario in content["scenarios"]:
2101
- if isinstance(scenario, dict):
2102
- present.update(scenario.keys())
2103
- return {
2104
- "ok": False,
2105
- "code": "SCENARIO_MANIFEST_V2_UNSUPPORTED",
2106
- "message": (
2107
- "meta/scenario-manifest.json 是 v2 plan artifact 包装体,缺少门禁消费的场景字段;"
2108
- "请在规划阶段补齐后重新发布,不要手改派生产物"
2109
- ),
2110
- "missingFields": [
2111
- field for field in _V2_MANIFEST_REQUIRED_FIELDS if field not in present
2112
- ],
2113
- "artifactType": "scenario_manifest",
2114
- }
2115
-
2116
-
2117
2155
  def _validate_scenario_coverage(
2118
2156
  change_dir: Path, phase: str | None = None
2119
2157
  ) -> dict[str, Any]:
@@ -2140,9 +2178,12 @@ def _validate_scenario_coverage(
2140
2178
  "code": "SCENARIO_MANIFEST_INVALID",
2141
2179
  "message": f"scenario-manifest.json unreadable: {exc}",
2142
2180
  }
2143
- v2_gap = _v2_artifact_manifest_gap(manifest)
2144
- if v2_gap is not None:
2145
- return v2_gap
2181
+ unpacked = hpf.unpack_v2_scenario_manifest(manifest)
2182
+ if unpacked is not None:
2183
+ if not unpacked.get("ok"):
2184
+ return unpacked
2185
+ # 解包成 legacy 形状后,下面的判定逻辑对 v2 与 legacy 完全一致。
2186
+ manifest = unpacked["manifest"]
2146
2187
  scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
2147
2188
  if not isinstance(scenarios, list):
2148
2189
  return {
@@ -2514,7 +2555,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
2514
2555
  severity_mode = gate_severity_mode(project, change_dir)
2515
2556
  gate_warnings: list[dict[str, Any]] = []
2516
2557
  plan_verification: dict[str, Any] | None = None
2517
- if args.phase == "run":
2558
+ if phase_gate_rule(args.phase, "plan_handoff"):
2518
2559
  plan_verification = validate_plan_handoff(change_dir)
2519
2560
  if not plan_verification.get("ok"):
2520
2561
  code = str(
@@ -2700,7 +2741,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
2700
2741
  if capsule is not None:
2701
2742
  guard_result = {"ok": True, "code": "SNAPSHOT_REUSED"}
2702
2743
  else:
2703
- if args.phase in {"run", "test"}:
2744
+ if phase_gate_rule(args.phase, "test_guard"):
2704
2745
  guard_result = htg.begin(execution_root, change_dir)
2705
2746
  if not guard_result.get("ok"):
2706
2747
  if gate_soft_allowed(severity_mode, args.phase, "test-guard"):
@@ -2767,6 +2808,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
2767
2808
  phase=args.phase,
2768
2809
  type_="phase.start",
2769
2810
  note=args.note or "",
2811
+ fixback=bool(getattr(args, "fixback", False)),
2770
2812
  identity=identity,
2771
2813
  run_id=run_id,
2772
2814
  executor_tool=executor_tool,
@@ -2857,6 +2899,83 @@ def cmd_close(args: argparse.Namespace) -> int:
2857
2899
 
2858
2900
  explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
2859
2901
  current_lease = hc.inspect_lease(project, resolved["changeId"])
2902
+ lease_lapsed: dict[str, Any] | None = None
2903
+ # 只有在"没有活跃租约"时才需要区分不存在/已过期/已损坏——三者要给的答案
2904
+ # 完全不同,而 inspect_lease 把它们一律压成 None。
2905
+ lease_state = (
2906
+ {"state": "active", "lease": current_lease}
2907
+ if current_lease is not None
2908
+ else hc.inspect_lease_state(project, resolved["changeId"])
2909
+ )
2910
+
2911
+ if lease_state["state"] == "corrupt":
2912
+ # 损坏的租约证明不了"没人抢占过",所以不自动恢复——那正是自动重取唯一
2913
+ # 的安全前提。
2914
+ return emit_error(
2915
+ "LEASE_INVALID",
2916
+ "phase lease file is unreadable; it cannot prove the phase was not taken over",
2917
+ as_json=as_json,
2918
+ extra={
2919
+ "phase": args.phase,
2920
+ "changeId": resolved["changeId"],
2921
+ "recoveryAction": (
2922
+ "租约文件已损坏,无法自动恢复。确认没有其他进程在跑同一个 change 后,"
2923
+ "用本阶段原 run-id 重新取得租约再重试 close:"
2924
+ "python <skills-root>/scripts/harness_change.py claim "
2925
+ f"--change {resolved['changeId']} --phase {args.phase} "
2926
+ "--run-id <原 run-id> --ttl-seconds 3600 --json"
2927
+ ),
2928
+ },
2929
+ )
2930
+
2931
+ if lease_state["state"] == "expired":
2932
+ expired_lease = lease_state["lease"] or {}
2933
+ expired_run_id = str(expired_lease.get("runId") or "")
2934
+ expected_run_id = explicit_run_id or expired_run_id
2935
+ # 租约过期、runId 还是本轮的,恰好证明没有第三方抢占过:抢占会重写
2936
+ # 租约文件把 runId 换掉。所以过期只说明这个阶段跑得比 TTL 久,不说明
2937
+ # 所有权变了——harness_context.close_transition 早就是这么推理的
2938
+ # (只记 leaseLapsed 照常收尾),这里把 Gate 侧对齐过去,不再要求
2939
+ # 用户手工 claim 一遍再原样重试。
2940
+ if (
2941
+ expired_run_id
2942
+ and expired_run_id == expected_run_id
2943
+ and str(expired_lease.get("phase") or "") == args.phase
2944
+ ):
2945
+ reacquired = hc.claim_lease(
2946
+ project,
2947
+ change_id=str(resolved["changeId"]),
2948
+ phase=args.phase,
2949
+ run_id=expired_run_id,
2950
+ ttl_seconds=int(getattr(args, "ttl_seconds", 0) or 3600),
2951
+ )
2952
+ if not reacquired.get("ok"):
2953
+ return emit_error(
2954
+ str(reacquired.get("code") or "LEASE_CONFLICT"),
2955
+ "expired phase lease could not be reacquired for close",
2956
+ as_json=as_json,
2957
+ extra={
2958
+ "phase": args.phase,
2959
+ "changeId": resolved["changeId"],
2960
+ "holder": reacquired.get("holder"),
2961
+ },
2962
+ )
2963
+ current_lease = reacquired.get("lease")
2964
+ lease_lapsed = {
2965
+ "acquiredAt": expired_lease.get("acquiredAt"),
2966
+ "expiresAt": expired_lease.get("expiresAt"),
2967
+ "reacquiredAt": (current_lease or {}).get("refreshedAt")
2968
+ or (current_lease or {}).get("acquiredAt"),
2969
+ "runId": expired_run_id,
2970
+ }
2971
+ else:
2972
+ return emit_error(
2973
+ "LEASE_OWNER_MISMATCH",
2974
+ "expired lease belongs to a different run or phase",
2975
+ as_json=as_json,
2976
+ extra={"holder": expired_lease},
2977
+ )
2978
+
2860
2979
  if current_lease is None:
2861
2980
  terminal = _latest_phase_end(change_dir, args.phase, explicit_run_id)
2862
2981
  if (
@@ -2911,8 +3030,10 @@ def cmd_close(args: argparse.Namespace) -> int:
2911
3030
  "changeId": resolved["changeId"],
2912
3031
  "resumeRunId": resume_run_id,
2913
3032
  "recoveryAction": (
2914
- "阶段租约已过期(TTL begin 的 --ttl-seconds 决定,默认 3600 秒)或已被释放。"
2915
- "用本阶段原 run-id 重新取得租约后原样重试 close:"
3033
+ "本阶段没有任何租约记录——过期租约现在由 close 自动重取,所以这里说明的是"
3034
+ "租约从未建立或已被释放。先确认 gate begin 是否真的跑过(events.ndjson 里"
3035
+ f"应有 phase={args.phase} 的 phase.start)。确有本轮 run-id 时用它重新取得"
3036
+ "租约再原样重试 close:"
2916
3037
  "python <skills-root>/scripts/harness_change.py claim "
2917
3038
  f"--change {resolved['changeId']} --phase {args.phase} "
2918
3039
  f"--run-id {resume_run_id or '<原 run-id>'} --ttl-seconds 3600 --json"
@@ -2994,7 +3115,7 @@ def cmd_close(args: argparse.Namespace) -> int:
2994
3115
  run_id=run_id,
2995
3116
  project=project,
2996
3117
  execution_root=execution_root,
2997
- allow_head_advance=args.phase in {"run", "submit", "merge"},
3118
+ allow_head_advance=phase_gate_rule(args.phase, "head_may_advance"),
2998
3119
  )
2999
3120
  except ValueError as exc:
3000
3121
  if gate_soft_allowed(severity_mode, args.phase, "capsule"):
@@ -3049,7 +3170,7 @@ def cmd_close(args: argparse.Namespace) -> int:
3049
3170
  and captured_projection.get("receiptHash")
3050
3171
  and captured_projection.get("receiptHash")
3051
3172
  != projection.get("receiptHash")
3052
- and args.phase in {"submit", "archive", "release", "deploy"}
3173
+ and phase_gate_rule(args.phase, "projection_drift")
3053
3174
  ):
3054
3175
  mismatch = {
3055
3176
  "code": "PROJECTION_CHANGED_DURING_PHASE",
@@ -3076,7 +3197,7 @@ def cmd_close(args: argparse.Namespace) -> int:
3076
3197
  change_dir, args.phase, policy, execution_root=execution_root,
3077
3198
  phase_status=args.status,
3078
3199
  )
3079
- if not ledger_result.get("ok") and args.phase in {"run", "test", "package"}:
3200
+ if not ledger_result.get("ok") and phase_gate_rule(args.phase, "ledger_blocking"):
3080
3201
  persist_close_failure(
3081
3202
  change_dir,
3082
3203
  args.phase,
@@ -3102,7 +3223,7 @@ def cmd_close(args: argparse.Namespace) -> int:
3102
3223
  extra={k: v for k, v in ledger_result.items() if k not in {"ok", "message", "code"}},
3103
3224
  )
3104
3225
 
3105
- if args.phase == "review":
3226
+ if phase_gate_rule(args.phase, "review_outputs"):
3106
3227
  review_outputs = validate_review_outputs_for_close(change_dir, run_id)
3107
3228
  if not review_outputs.get("ok"):
3108
3229
  persist_close_failure(
@@ -3134,7 +3255,7 @@ def cmd_close(args: argparse.Namespace) -> int:
3134
3255
  # C9: scenario coverage check — every P0/ledger scenario *due by this phase*
3135
3256
  # must have a ledger entry. Scenarios with a later ownerPhase are deferred,
3136
3257
  # not missing (see _validate_scenario_coverage).
3137
- if args.phase in {"run", "test"}:
3258
+ if phase_gate_rule(args.phase, "scenario_coverage"):
3138
3259
  coverage = _validate_scenario_coverage(change_dir, args.phase)
3139
3260
  if not coverage.get("ok"):
3140
3261
  if gate_soft_allowed(severity_mode, args.phase, "scenario-coverage"):
@@ -3192,7 +3313,7 @@ def cmd_close(args: argparse.Namespace) -> int:
3192
3313
  write_phase_capsule(change_dir, args.phase, run_id, capsule)
3193
3314
 
3194
3315
  guard_result = None
3195
- if args.phase in {"run", "test"}:
3316
+ if phase_gate_rule(args.phase, "test_guard"):
3196
3317
  if close_transaction.get("guardClosed"):
3197
3318
  guard_result = {"ok": True, "code": "ALREADY_CLOSED", "reused": True}
3198
3319
  else:
@@ -3247,6 +3368,7 @@ def cmd_close(args: argparse.Namespace) -> int:
3247
3368
  type_="phase.end",
3248
3369
  status=close_status,
3249
3370
  note=args.note or "",
3371
+ fixback=bool(getattr(args, "fixback", False)),
3250
3372
  run_id=run_id,
3251
3373
  )
3252
3374
  except BaseException as exc:
@@ -3364,6 +3486,10 @@ def cmd_close(args: argparse.Namespace) -> int:
3364
3486
  "gateRecovery": recovery_result,
3365
3487
  "scratchSwept": scratch_swept,
3366
3488
  }
3489
+ if lease_lapsed is not None:
3490
+ # 与 context transition receipt 的 leaseLapsed 同名同义:记录这个阶段
3491
+ # 跑过了 TTL 且租约是自动重取的,不隐藏。
3492
+ payload["leaseLapsed"] = lease_lapsed
3367
3493
  emit(payload, as_json=as_json)
3368
3494
  return 0
3369
3495
 
@@ -3551,6 +3677,11 @@ def build_parser() -> argparse.ArgumentParser:
3551
3677
  p_begin.add_argument("--ttl-seconds", type=int, default=3600)
3552
3678
  p_begin.add_argument("--task", type=int, default=None)
3553
3679
  p_begin.add_argument("--note", default="")
3680
+ p_begin.add_argument(
3681
+ "--fixback",
3682
+ action="store_true",
3683
+ help="This phase is a review fixback round (tags the lifecycle event).",
3684
+ )
3554
3685
  p_begin.add_argument("--executor-tool", default=None)
3555
3686
  p_begin.add_argument("--executor-agent", default=None)
3556
3687
  p_begin.add_argument("--executor-model", default=None)
@@ -3571,6 +3702,11 @@ def build_parser() -> argparse.ArgumentParser:
3571
3702
  p_close.add_argument("--run-id", default=None)
3572
3703
  p_close.add_argument("--task", type=int, default=None)
3573
3704
  p_close.add_argument("--note", default="")
3705
+ p_close.add_argument(
3706
+ "--fixback",
3707
+ action="store_true",
3708
+ help="This phase is a review fixback round (tags the lifecycle event).",
3709
+ )
3574
3710
  p_close.add_argument("--to-phase", default=None)
3575
3711
  p_close.add_argument("--executor", default=None)
3576
3712
  p_close.add_argument("--artifact", action="append", default=[])