@hunter-harness/workflow-harness 0.2.67 → 0.2.70

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 (210) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +401 -88
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -2
  8. package/harness/bundles/general/claude-code/harness-plan/reference.md +32 -2
  9. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +73 -0
  10. package/harness/bundles/general/claude-code/harness-push/SKILL.md +73 -0
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +3 -1
  13. package/harness/bundles/general/claude-code/harness-run/reference.md +20 -0
  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 +3 -1
  17. package/harness/bundles/general/claude-code/harness-test/pitfalls.md +34 -3
  18. package/harness/bundles/general/claude-code/harness-test/reference.md +9 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_context.py +27 -7
  20. package/harness/bundles/general/claude-code/scripts/harness_events.py +12 -3
  21. package/harness/bundles/general/claude-code/scripts/harness_gate.py +127 -11
  22. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +244 -5
  23. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +373 -19
  24. package/harness/bundles/general/claude-code/scripts/harness_service.py +69 -15
  25. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +48 -1
  26. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  27. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +401 -88
  28. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -2
  33. package/harness/bundles/general/codebuddy/harness-plan/reference.md +32 -2
  34. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +55 -0
  35. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +55 -0
  36. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +3 -1
  38. package/harness/bundles/general/codebuddy/harness-run/reference.md +20 -0
  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 +3 -1
  42. package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +34 -3
  43. package/harness/bundles/general/codebuddy/harness-test/reference.md +9 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_context.py +27 -7
  45. package/harness/bundles/general/codebuddy/scripts/harness_events.py +12 -3
  46. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +127 -11
  47. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +244 -5
  48. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +373 -19
  49. package/harness/bundles/general/codebuddy/scripts/harness_service.py +69 -15
  50. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +48 -1
  51. package/harness/bundles/general/codex/.harness-build.json +1 -1
  52. package/harness/bundles/general/codex/contracts/workflow-policy.json +401 -88
  53. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -2
  58. package/harness/bundles/general/codex/harness-plan/reference.md +32 -2
  59. package/harness/bundles/general/codex/harness-pull/SKILL.md +55 -0
  60. package/harness/bundles/general/codex/harness-push/SKILL.md +55 -0
  61. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-run/SKILL.md +3 -1
  63. package/harness/bundles/general/codex/harness-run/reference.md +20 -0
  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 +3 -1
  67. package/harness/bundles/general/codex/harness-test/pitfalls.md +34 -3
  68. package/harness/bundles/general/codex/harness-test/reference.md +9 -0
  69. package/harness/bundles/general/codex/scripts/harness_context.py +27 -7
  70. package/harness/bundles/general/codex/scripts/harness_events.py +12 -3
  71. package/harness/bundles/general/codex/scripts/harness_gate.py +127 -11
  72. package/harness/bundles/general/codex/scripts/harness_ledger.py +244 -5
  73. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +373 -19
  74. package/harness/bundles/general/codex/scripts/harness_service.py +69 -15
  75. package/harness/bundles/general/codex/scripts/harness_test_guard.py +48 -1
  76. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  77. package/harness/bundles/general/cursor/contracts/workflow-policy.json +401 -88
  78. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -2
  83. package/harness/bundles/general/cursor/harness-plan/reference.md +32 -2
  84. package/harness/bundles/general/cursor/harness-pull/SKILL.md +55 -0
  85. package/harness/bundles/general/cursor/harness-push/SKILL.md +55 -0
  86. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  87. package/harness/bundles/general/cursor/harness-run/SKILL.md +3 -1
  88. package/harness/bundles/general/cursor/harness-run/reference.md +20 -0
  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 +3 -1
  92. package/harness/bundles/general/cursor/harness-test/pitfalls.md +34 -3
  93. package/harness/bundles/general/cursor/harness-test/reference.md +9 -0
  94. package/harness/bundles/general/cursor/scripts/harness_context.py +27 -7
  95. package/harness/bundles/general/cursor/scripts/harness_events.py +12 -3
  96. package/harness/bundles/general/cursor/scripts/harness_gate.py +127 -11
  97. package/harness/bundles/general/cursor/scripts/harness_ledger.py +244 -5
  98. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +373 -19
  99. package/harness/bundles/general/cursor/scripts/harness_service.py +69 -15
  100. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +48 -1
  101. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  102. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +401 -88
  103. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  106. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -2
  110. package/harness/bundles/java/claude-code/harness-plan/reference.md +32 -2
  111. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +73 -0
  112. package/harness/bundles/java/claude-code/harness-push/SKILL.md +73 -0
  113. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/harness-run/SKILL.md +3 -1
  115. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
  118. package/harness/bundles/java/claude-code/harness-test/pitfalls.md +34 -3
  119. package/harness/bundles/java/claude-code/scripts/harness_context.py +27 -7
  120. package/harness/bundles/java/claude-code/scripts/harness_events.py +12 -3
  121. package/harness/bundles/java/claude-code/scripts/harness_gate.py +127 -11
  122. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +244 -5
  123. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +373 -19
  124. package/harness/bundles/java/claude-code/scripts/harness_service.py +69 -15
  125. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +48 -1
  126. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  127. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +401 -88
  128. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  130. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  131. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -2
  135. package/harness/bundles/java/codebuddy/harness-plan/reference.md +32 -2
  136. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +55 -0
  137. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +55 -0
  138. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  139. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +3 -1
  140. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  141. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  142. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
  143. package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +34 -3
  144. package/harness/bundles/java/codebuddy/scripts/harness_context.py +27 -7
  145. package/harness/bundles/java/codebuddy/scripts/harness_events.py +12 -3
  146. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +127 -11
  147. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +244 -5
  148. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +373 -19
  149. package/harness/bundles/java/codebuddy/scripts/harness_service.py +69 -15
  150. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +48 -1
  151. package/harness/bundles/java/codex/.harness-build.json +1 -1
  152. package/harness/bundles/java/codex/contracts/workflow-policy.json +401 -88
  153. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  154. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  156. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  157. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  158. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  159. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -2
  160. package/harness/bundles/java/codex/harness-plan/reference.md +32 -2
  161. package/harness/bundles/java/codex/harness-pull/SKILL.md +55 -0
  162. package/harness/bundles/java/codex/harness-push/SKILL.md +55 -0
  163. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  164. package/harness/bundles/java/codex/harness-run/SKILL.md +3 -1
  165. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  166. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
  168. package/harness/bundles/java/codex/harness-test/pitfalls.md +34 -3
  169. package/harness/bundles/java/codex/scripts/harness_context.py +27 -7
  170. package/harness/bundles/java/codex/scripts/harness_events.py +12 -3
  171. package/harness/bundles/java/codex/scripts/harness_gate.py +127 -11
  172. package/harness/bundles/java/codex/scripts/harness_ledger.py +244 -5
  173. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +373 -19
  174. package/harness/bundles/java/codex/scripts/harness_service.py +69 -15
  175. package/harness/bundles/java/codex/scripts/harness_test_guard.py +48 -1
  176. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  177. package/harness/bundles/java/cursor/contracts/workflow-policy.json +401 -88
  178. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  179. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  180. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  181. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  182. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  183. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -2
  185. package/harness/bundles/java/cursor/harness-plan/reference.md +32 -2
  186. package/harness/bundles/java/cursor/harness-pull/SKILL.md +55 -0
  187. package/harness/bundles/java/cursor/harness-push/SKILL.md +55 -0
  188. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  189. package/harness/bundles/java/cursor/harness-run/SKILL.md +3 -1
  190. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  191. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  192. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
  193. package/harness/bundles/java/cursor/harness-test/pitfalls.md +34 -3
  194. package/harness/bundles/java/cursor/scripts/harness_context.py +27 -7
  195. package/harness/bundles/java/cursor/scripts/harness_events.py +12 -3
  196. package/harness/bundles/java/cursor/scripts/harness_gate.py +127 -11
  197. package/harness/bundles/java/cursor/scripts/harness_ledger.py +244 -5
  198. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +373 -19
  199. package/harness/bundles/java/cursor/scripts/harness_service.py +69 -15
  200. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +48 -1
  201. package/harness/manifests/general/claude-code.json +36 -26
  202. package/harness/manifests/general/codebuddy.json +36 -26
  203. package/harness/manifests/general/codex.json +36 -26
  204. package/harness/manifests/general/cursor.json +36 -26
  205. package/harness/manifests/java/claude-code.json +36 -26
  206. package/harness/manifests/java/codebuddy.json +36 -26
  207. package/harness/manifests/java/codex.json +36 -26
  208. package/harness/manifests/java/cursor.json +36 -26
  209. package/hunter-workflow-family.json +11 -7
  210. package/package.json +1 -1
@@ -728,7 +728,9 @@ def _validate_plan_start(
728
728
  if not matching:
729
729
  return _result_error(
730
730
  "PHASE_START_MISSING",
731
- "no matching plan phase.start event found for finalizer runId/attempt",
731
+ "no matching plan phase.start event found for finalizer runId/attempt; "
732
+ "append one with the same run id and attempt before finalizing, or use "
733
+ "the `republish` subcommand which allocates and appends both for you",
732
734
  )
733
735
  if len(matching) > 1:
734
736
  return _result_error(
@@ -796,6 +798,121 @@ def _receipt_artifact_targets(
796
798
  return targets, None
797
799
 
798
800
 
801
+ def _verify_plan_v2(change_dir: Path) -> dict[str, Any] | None:
802
+ """v2 finalizer(TS)证据的只读验收。
803
+
804
+ v2 不产生 meta/plan-finalization.json;canonical 证据是
805
+ meta/plan-finalization-transactions/ + meta/publication-journals/。
806
+ 本路径只做结构验收:最新事务终态、匹配 journal 的 committed/readback、
807
+ durable targets 存在且字节哈希与 binding 一致。内容质量由 TS 质量层裁决,
808
+ Python 不重复审判。无 v2 证据时返回 None(调用方回退 RECEIPT_MISSING)。
809
+ """
810
+ transactions_dir = change_dir / "meta" / "plan-finalization-transactions"
811
+ if not transactions_dir.is_dir():
812
+ return None
813
+ transactions = sorted(
814
+ transactions_dir.glob("*.json"),
815
+ key=lambda path: path.stat().st_mtime,
816
+ )
817
+ if not transactions:
818
+ return None
819
+ latest: dict[str, Any] | None = None
820
+ for candidate in reversed(transactions):
821
+ try:
822
+ payload = json.loads(candidate.read_text(encoding="utf-8-sig"))
823
+ except (OSError, json.JSONDecodeError):
824
+ continue
825
+ if isinstance(payload, dict):
826
+ latest = payload
827
+ break
828
+ if latest is None:
829
+ return None
830
+ if latest.get("change_key") != change_dir.name:
831
+ return _result_error(
832
+ "RECEIPT_CHANGE_NAME_INVALID",
833
+ "v2 transaction change_key does not match the change directory",
834
+ )
835
+ if latest.get("status") != "publication_committed_event_complete":
836
+ return _result_error(
837
+ "RECEIPT_NOT_FINALIZED",
838
+ f"v2 transaction status is {latest.get('status')!r}, "
839
+ "expected 'publication_committed_event_complete'",
840
+ )
841
+ operation_id = str(latest.get("operation_id") or "")
842
+ journals_dir = change_dir / "meta" / "publication-journals"
843
+ journal: dict[str, Any] | None = None
844
+ if journals_dir.is_dir():
845
+ for candidate in sorted(journals_dir.glob("*.json")):
846
+ try:
847
+ payload = json.loads(candidate.read_text(encoding="utf-8-sig"))
848
+ except (OSError, json.JSONDecodeError):
849
+ continue
850
+ if isinstance(payload, dict) and payload.get("operation_id") == operation_id:
851
+ journal = payload
852
+ break
853
+ if journal is None:
854
+ return _result_error(
855
+ "PUBLICATION_JOURNAL_MISSING",
856
+ f"no publication journal matches v2 operation {operation_id!r}",
857
+ )
858
+ if journal.get("state") != "committed" or journal.get("readback") != "verified":
859
+ return _result_error(
860
+ "PUBLICATION_JOURNAL_NOT_COMMITTED",
861
+ "v2 publication journal is not committed with verified readback",
862
+ )
863
+ binding = journal.get("binding")
864
+ if not isinstance(binding, dict):
865
+ return _result_error("PUBLICATION_BINDING_INVALID", "v2 journal binding missing")
866
+ payload_hashes = binding.get("expected_payload_hashes")
867
+ ownership_paths = binding.get("ownership_paths")
868
+ if not isinstance(payload_hashes, dict) or not isinstance(ownership_paths, list):
869
+ return _result_error("PUBLICATION_BINDING_INVALID", "v2 binding fields malformed")
870
+ required = {
871
+ f"plans/{change_dir.name}-design.md",
872
+ f"plans/{change_dir.name}-plan.md",
873
+ f"plans/{change_dir.name}-implementation-detail.md",
874
+ f"plans/{change_dir.name}-test-scenarios.md",
875
+ "meta/gate-policy.json",
876
+ "meta/worktree.json",
877
+ }
878
+ missing_required = sorted(required - set(ownership_paths))
879
+ if missing_required:
880
+ return _result_error(
881
+ "RECEIPT_FILES_INCOMPLETE",
882
+ "v2 journal omits required artifacts: " + ", ".join(missing_required),
883
+ )
884
+ for rel, expected_hash in payload_hashes.items():
885
+ if not isinstance(expected_hash, str) or not expected_hash.startswith("sha256:"):
886
+ return _result_error("PUBLICATION_BINDING_INVALID", f"{rel}: malformed hash")
887
+ target = change_dir / rel
888
+ try:
889
+ resolved = target.resolve()
890
+ resolved.relative_to(change_dir.resolve())
891
+ except (OSError, ValueError):
892
+ return _result_error(
893
+ "RECEIPT_FILE_PATH_INVALID",
894
+ f"v2 artifact path resolves outside the change directory: {rel}",
895
+ )
896
+ if not target.is_file():
897
+ return _result_error("ARTIFACT_MISSING", f"published artifact missing: {rel}")
898
+ actual_hash = "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
899
+ if actual_hash != expected_hash:
900
+ return _result_error(
901
+ "ARTIFACT_HASH_DRIFT",
902
+ f"{rel}: content hash {actual_hash} != v2 binding {expected_hash}",
903
+ )
904
+ return {
905
+ "ok": True,
906
+ "code": "PLAN_V2_VERIFIED",
907
+ "v2": True,
908
+ "operationId": operation_id,
909
+ "runId": latest.get("run_id"),
910
+ "attempt": latest.get("attempt"),
911
+ "artifactCount": len(payload_hashes),
912
+ "status": latest.get("status"),
913
+ }
914
+
915
+
799
916
  def verify_plan(change_dir: Path) -> dict[str, Any]:
800
917
  """Read-only verification of a finalized plan (retro §5.8).
801
918
 
@@ -807,6 +924,10 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
807
924
  change_dir = change_dir.resolve()
808
925
  receipt_path = change_dir / "meta" / "plan-finalization.json"
809
926
  if not receipt_path.is_file():
927
+ # v2 finalizer 证据路径(canonical 事实源是 transactions + journals)
928
+ v2_result = _verify_plan_v2(change_dir)
929
+ if v2_result is not None:
930
+ return v2_result
810
931
  return _result_error("RECEIPT_MISSING", f"receipt not found: {receipt_path}")
811
932
 
812
933
  try:
@@ -1180,6 +1301,72 @@ def _append_terminal(change_dir: Path, run_id: str, attempt: int) -> tuple[int,
1180
1301
  return code, stderr.getvalue().strip()
1181
1302
 
1182
1303
 
1304
+ def _rollback_publish(
1305
+ created: list[Path], replaced: list[tuple[Path, bytes]]
1306
+ ) -> None:
1307
+ """Undo a failed publish: delete new files, restore overwritten ones."""
1308
+ for target in reversed(created):
1309
+ target.unlink(missing_ok=True)
1310
+ for target, original in reversed(replaced):
1311
+ try:
1312
+ target.write_bytes(original)
1313
+ except OSError:
1314
+ # Best effort: the caller already reports the publish failure.
1315
+ pass
1316
+
1317
+
1318
+ def _plan_attempts(change_dir: Path) -> list[int]:
1319
+ """Every attempt number already used by a plan lifecycle event."""
1320
+ try:
1321
+ events = harness_events.load_events(harness_events.events_path(change_dir))
1322
+ except (OSError, ValueError):
1323
+ return []
1324
+ attempts: list[int] = []
1325
+ for event in events:
1326
+ if event.get("phase") != "plan":
1327
+ continue
1328
+ value = event.get("attempt")
1329
+ if isinstance(value, int) and not isinstance(value, bool):
1330
+ attempts.append(value)
1331
+ return attempts
1332
+
1333
+
1334
+ def _run_id_in_use(change_dir: Path, run_id: str) -> bool:
1335
+ try:
1336
+ events = harness_events.load_events(harness_events.events_path(change_dir))
1337
+ except (OSError, ValueError):
1338
+ return False
1339
+ return any(event.get("run_id") == run_id for event in events)
1340
+
1341
+
1342
+ def _append_plan_start(
1343
+ change_dir: Path, run_id: str, attempt: int, reason: str
1344
+ ) -> tuple[int, str]:
1345
+ stdout = io.StringIO()
1346
+ stderr = io.StringIO()
1347
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1348
+ code = harness_events.main(
1349
+ [
1350
+ "append",
1351
+ "--change-dir",
1352
+ str(change_dir),
1353
+ "--phase",
1354
+ "plan",
1355
+ "--type",
1356
+ "phase.start",
1357
+ # phase.start accepts only `note` beyond provenance fields.
1358
+ "--note",
1359
+ reason,
1360
+ "--run-id",
1361
+ run_id,
1362
+ "--attempt",
1363
+ str(attempt),
1364
+ "--json",
1365
+ ]
1366
+ )
1367
+ return code, stderr.getvalue().strip()
1368
+
1369
+
1183
1370
  def finalize_plan(
1184
1371
  change_dir: Path,
1185
1372
  staging: Path,
@@ -1187,6 +1374,8 @@ def finalize_plan(
1187
1374
  change_name: str,
1188
1375
  run_id: str,
1189
1376
  attempt: int,
1377
+ allow_amend: bool = False,
1378
+ amend_reason: str | None = None,
1190
1379
  ) -> dict[str, Any]:
1191
1380
  change_dir = change_dir.resolve()
1192
1381
  staging = staging.resolve()
@@ -1212,6 +1401,7 @@ def finalize_plan(
1212
1401
  receipt_path = change_dir / "meta" / "plan-finalization.json"
1213
1402
  lock_path = change_dir / "meta" / "plan-finalize.lock"
1214
1403
  receipt: dict[str, Any] | None = None
1404
+ supersedes: dict[str, Any] | None = None
1215
1405
  if receipt_path.is_file():
1216
1406
  try:
1217
1407
  loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
@@ -1219,11 +1409,22 @@ def finalize_plan(
1219
1409
  except (OSError, json.JSONDecodeError) as exc:
1220
1410
  return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1221
1411
  if receipt and receipt.get("artifactsHash") != validation["artifactsHash"]:
1222
- return _result_error(
1223
- "PLAN_FINALIZATION_HASH_CONFLICT",
1224
- "finalizer was already invoked with a different artifact set",
1225
- )
1226
- if (
1412
+ if not allow_amend:
1413
+ return _result_error(
1414
+ "PLAN_FINALIZATION_HASH_CONFLICT",
1415
+ "finalizer was already invoked with a different artifact set; "
1416
+ "to publish an approved amendment run: harness_plan_finalize.py "
1417
+ "republish --change-dir <dir> --staging-dir <dir> --change "
1418
+ f"{change_name} --run-id <fresh-run-id> --reason \"<why>\"",
1419
+ )
1420
+ # Authorized amendment: keep the superseded identity in the new
1421
+ # receipt so the published history stays auditable.
1422
+ supersedes = {
1423
+ "artifactsHash": receipt.get("artifactsHash"),
1424
+ "runId": receipt.get("runId"),
1425
+ "attempt": receipt.get("attempt"),
1426
+ }
1427
+ elif (
1227
1428
  receipt
1228
1429
  and receipt.get("status") == "finalized"
1229
1430
  and _terminal_exists(change_dir, run_id, attempt)
@@ -1246,21 +1447,33 @@ def finalize_plan(
1246
1447
  return _result_error("PLAN_FINALIZATION_LOCKED", f"lock exists: {lock_path}")
1247
1448
 
1248
1449
  created: list[Path] = []
1450
+ # Amendment overwrites replace already-published bytes. Keep the originals
1451
+ # so a failed publish restores them instead of deleting the published plan.
1452
+ replaced: list[tuple[Path, bytes]] = []
1249
1453
  terminal_committed = False
1250
1454
  try:
1251
1455
  for rel_text in validation["files"]:
1252
1456
  source = staging / rel_text
1253
1457
  target = change_dir / rel_text
1254
- if target.exists() and target.read_bytes() != source.read_bytes():
1458
+ if (
1459
+ not allow_amend
1460
+ and target.exists()
1461
+ and target.read_bytes() != source.read_bytes()
1462
+ ):
1255
1463
  return _result_error(
1256
- "PLAN_TARGET_CONFLICT", f"refusing to overwrite {rel_text}"
1464
+ "PLAN_TARGET_CONFLICT",
1465
+ f"refusing to overwrite {rel_text}; publish an approved "
1466
+ "amendment with the `republish` subcommand instead",
1257
1467
  )
1258
1468
 
1259
1469
  for rel_text in validation["files"]:
1260
1470
  source = staging / rel_text
1261
1471
  target = change_dir / rel_text
1262
- if target.exists():
1263
- continue
1472
+ existed = target.exists()
1473
+ if existed:
1474
+ original = target.read_bytes()
1475
+ if not allow_amend or original == source.read_bytes():
1476
+ continue
1264
1477
  target.parent.mkdir(parents=True, exist_ok=True)
1265
1478
  fd, raw_tmp = tempfile.mkstemp(
1266
1479
  prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
@@ -1270,7 +1483,10 @@ def finalize_plan(
1270
1483
  try:
1271
1484
  shutil.copyfile(source, tmp)
1272
1485
  os.replace(tmp, target)
1273
- created.append(target)
1486
+ if existed:
1487
+ replaced.append((target, original))
1488
+ else:
1489
+ created.append(target)
1274
1490
  finally:
1275
1491
  tmp.unlink(missing_ok=True)
1276
1492
 
@@ -1284,8 +1500,11 @@ def finalize_plan(
1284
1500
  "tasks": tasks,
1285
1501
  "foundationGate": "approved",
1286
1502
  }
1503
+ if checkpoints_path.is_file():
1504
+ replaced.append((checkpoints_path, checkpoints_path.read_bytes()))
1505
+ else:
1506
+ created.append(checkpoints_path)
1287
1507
  _atomic_write_json(checkpoints_path, checkpoints_payload)
1288
- created.append(checkpoints_path)
1289
1508
 
1290
1509
  # C9: write scenario-manifest.json with parsed scenarios.
1291
1510
  scenarios = validation.get("scenarios") or []
@@ -1302,8 +1521,11 @@ def finalize_plan(
1302
1521
  for scenario in scenarios
1303
1522
  ],
1304
1523
  }
1524
+ if manifest_path.is_file():
1525
+ replaced.append((manifest_path, manifest_path.read_bytes()))
1526
+ else:
1527
+ created.append(manifest_path)
1305
1528
  _atomic_write_json(manifest_path, manifest_payload)
1306
- created.append(manifest_path)
1307
1529
 
1308
1530
  pending_receipt = {
1309
1531
  "schemaVersion": SCHEMA_VERSION,
@@ -1314,12 +1536,14 @@ def finalize_plan(
1314
1536
  "runId": run_id,
1315
1537
  "attempt": attempt,
1316
1538
  }
1539
+ if supersedes is not None:
1540
+ pending_receipt["supersedes"] = supersedes
1541
+ pending_receipt["amendReason"] = amend_reason or ""
1317
1542
  _atomic_write_json(receipt_path, pending_receipt)
1318
1543
  terminal_code, terminal_error = _append_terminal(change_dir, run_id, attempt)
1319
1544
  terminal_committed = _terminal_exists(change_dir, run_id, attempt)
1320
1545
  if terminal_code != 0 and not terminal_committed:
1321
- for target in reversed(created):
1322
- target.unlink(missing_ok=True)
1546
+ _rollback_publish(created, replaced)
1323
1547
  receipt_path.unlink(missing_ok=True)
1324
1548
  return _result_error(
1325
1549
  "PLAN_TERMINAL_APPEND_FAILED",
@@ -1327,9 +1551,9 @@ def finalize_plan(
1327
1551
  )
1328
1552
  pending_receipt["status"] = "finalized"
1329
1553
  _atomic_write_json(receipt_path, pending_receipt)
1330
- return {
1554
+ result: dict[str, Any] = {
1331
1555
  "ok": True,
1332
- "action": "finalize",
1556
+ "action": "republish" if supersedes is not None else "finalize",
1333
1557
  "idempotent": False,
1334
1558
  "artifactsHash": validation["artifactsHash"],
1335
1559
  "files": validation["files"],
@@ -1337,23 +1561,145 @@ def finalize_plan(
1337
1561
  "artifactRef": "meta/plan-finalization.json",
1338
1562
  "executionLogPath": str(harness_events.execution_log_path(change_dir)),
1339
1563
  }
1564
+ if supersedes is not None:
1565
+ result["supersedes"] = supersedes
1566
+ result["scenarioCount"] = len(scenarios)
1567
+ result["taskCount"] = len(tasks)
1568
+ return result
1340
1569
  except OSError as exc:
1341
1570
  if terminal_committed or _terminal_exists(change_dir, run_id, attempt):
1342
1571
  return _result_error(
1343
1572
  "PLAN_FINALIZATION_RECOVERY_REQUIRED",
1344
1573
  f"terminal committed; retry finalization to complete receipt: {exc}",
1345
1574
  )
1346
- for target in reversed(created):
1347
- target.unlink(missing_ok=True)
1575
+ _rollback_publish(created, replaced)
1348
1576
  receipt_path.unlink(missing_ok=True)
1349
1577
  return _result_error("PLAN_FINALIZATION_IO_ERROR", str(exc))
1350
1578
  finally:
1351
1579
  lock_path.unlink(missing_ok=True)
1352
1580
 
1353
1581
 
1582
+ def republish_plan(
1583
+ change_dir: Path,
1584
+ staging: Path,
1585
+ *,
1586
+ change_name: str,
1587
+ run_id: str,
1588
+ reason: str,
1589
+ ) -> dict[str, Any]:
1590
+ """Publish an approved amendment to an already-finalized plan.
1591
+
1592
+ Amending a published plan (adding a scenario, correcting a task) used to
1593
+ require reverse-engineering a five-step lifecycle dance across three
1594
+ modules — new run id, incremented attempt, stale receipt removal, dangling
1595
+ attempt sealing — and every wrong turn produced a different error code
1596
+ (PLAN_FINALIZATION_HASH_CONFLICT / PHASE_START_MISSING /
1597
+ EVENT_ATTEMPT_CONFLICT / PHASE_ALREADY_CLOSED). Agents that gave up
1598
+ hand-edited meta/scenario-manifest.json, creating exactly the drift the
1599
+ hash guard exists to prevent.
1600
+
1601
+ This performs the whole sequence atomically and keeps the superseded
1602
+ identity in the receipt, so amendment stays auditable rather than silent.
1603
+ """
1604
+ change_dir = change_dir.resolve()
1605
+ staging = staging.resolve()
1606
+
1607
+ if not str(reason).strip():
1608
+ return _result_error(
1609
+ "PLAN_AMEND_REASON_REQUIRED",
1610
+ "--reason is required: an amendment to a published plan must record why",
1611
+ )
1612
+
1613
+ receipt_path = change_dir / "meta" / "plan-finalization.json"
1614
+ if not receipt_path.is_file():
1615
+ return _result_error(
1616
+ "PLAN_NOT_FINALIZED",
1617
+ "no plan-finalization.json to amend; use `finalize` for the first "
1618
+ "publication",
1619
+ )
1620
+
1621
+ if _run_id_in_use(change_dir, run_id):
1622
+ return _result_error(
1623
+ "PLAN_AMEND_RUN_ID_IN_USE",
1624
+ f"run id {run_id} already appears in this change's events; "
1625
+ "an amendment needs a fresh run id",
1626
+ )
1627
+
1628
+ # Nothing to amend is a no-op, not a new attempt: probe the staged hash
1629
+ # before writing any lifecycle event.
1630
+ reclassify = _reclassify_gate_policy(staging, change_name)
1631
+ if not reclassify.get("ok"):
1632
+ return _result_error(
1633
+ "CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed")
1634
+ )
1635
+ probe = validate_staging(staging, change_name)
1636
+ if not probe["ok"]:
1637
+ return probe
1638
+ try:
1639
+ published = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1640
+ except (OSError, json.JSONDecodeError) as exc:
1641
+ return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1642
+ if (
1643
+ isinstance(published, dict)
1644
+ and published.get("status") == "finalized"
1645
+ and published.get("artifactsHash") == probe["artifactsHash"]
1646
+ ):
1647
+ return {
1648
+ "ok": True,
1649
+ "action": "republish",
1650
+ "idempotent": True,
1651
+ "artifactsHash": probe["artifactsHash"],
1652
+ "files": probe["files"],
1653
+ "receiptPath": str(receipt_path),
1654
+ "artifactRef": "meta/plan-finalization.json",
1655
+ "note": "published plan already matches staging; nothing to amend",
1656
+ }
1657
+
1658
+ attempt = max(_plan_attempts(change_dir), default=0) + 1
1659
+ start_code, start_error = _append_plan_start(
1660
+ change_dir, run_id, attempt, f"plan amendment: {reason}"
1661
+ )
1662
+ if start_code != 0:
1663
+ return _result_error(
1664
+ "PLAN_AMEND_START_FAILED",
1665
+ start_error or "failed to append plan phase.start for the amendment",
1666
+ )
1667
+
1668
+ result = finalize_plan(
1669
+ change_dir,
1670
+ staging,
1671
+ change_name=change_name,
1672
+ run_id=run_id,
1673
+ attempt=attempt,
1674
+ allow_amend=True,
1675
+ amend_reason=reason,
1676
+ )
1677
+ if isinstance(result, dict):
1678
+ result.setdefault("runId", run_id)
1679
+ result.setdefault("attempt", attempt)
1680
+ return result
1681
+
1682
+
1354
1683
  def build_parser() -> argparse.ArgumentParser:
1355
1684
  parser = argparse.ArgumentParser(prog="harness_plan_finalize.py")
1356
1685
  sub = parser.add_subparsers(dest="command", required=True)
1686
+ republish = sub.add_parser(
1687
+ "republish",
1688
+ help=(
1689
+ "publish an approved amendment to an already-finalized plan "
1690
+ "(allocates a fresh attempt and records what it supersedes)"
1691
+ ),
1692
+ )
1693
+ republish.add_argument("--change-dir", required=True)
1694
+ republish.add_argument("--staging-dir", required=True)
1695
+ republish.add_argument("--change", required=True)
1696
+ republish.add_argument(
1697
+ "--run-id", required=True, help="a fresh run id, unused by this change"
1698
+ )
1699
+ republish.add_argument(
1700
+ "--reason", required=True, help="why the published plan is being amended"
1701
+ )
1702
+ republish.add_argument("--json", action="store_true")
1357
1703
  finalize = sub.add_parser("finalize")
1358
1704
  finalize.add_argument("--change-dir", required=True)
1359
1705
  finalize.add_argument("--staging-dir", required=True)
@@ -1371,6 +1717,14 @@ def main(argv: list[str] | None = None) -> int:
1371
1717
  args = build_parser().parse_args(argv)
1372
1718
  if args.command == "verify":
1373
1719
  result = verify_plan(Path(args.change_dir))
1720
+ elif args.command == "republish":
1721
+ result = republish_plan(
1722
+ Path(args.change_dir),
1723
+ Path(args.staging_dir),
1724
+ change_name=args.change,
1725
+ run_id=args.run_id,
1726
+ reason=args.reason,
1727
+ )
1374
1728
  else:
1375
1729
  result = finalize_plan(
1376
1730
  Path(args.change_dir),
@@ -1462,6 +1462,10 @@ def _job_id_path(change_dir: Path) -> Path:
1462
1462
  return _runtime_dir(change_dir) / "_harness_service.job.id"
1463
1463
 
1464
1464
 
1465
+ def _breakaway_note_path(change_dir: Path) -> Path:
1466
+ return _runtime_dir(change_dir) / "_harness_service.breakaway.json"
1467
+
1468
+
1465
1469
  def _child_pid_path(change_dir: Path) -> Path:
1466
1470
  return _runtime_dir(change_dir) / "_harness_service.child.pid"
1467
1471
 
@@ -1574,20 +1578,56 @@ def _start_detached_service_windows(
1574
1578
  str(pid_path),
1575
1579
  job_id,
1576
1580
  ]
1577
- win_flags = (
1578
- _DETACHED_PROCESS
1579
- | _CREATE_NEW_PROCESS_GROUP
1580
- | _CREATE_NO_WINDOW
1581
- | _CREATE_BREAKAWAY_FROM_JOB
1582
- )
1583
- launcher_proc = subprocess.Popen(
1584
- launcher_args,
1585
- cwd=str(cwd),
1586
- stdin=subprocess.DEVNULL,
1587
- stdout=subprocess.DEVNULL,
1588
- stderr=subprocess.DEVNULL,
1589
- creationflags=win_flags,
1590
- close_fds=False,
1581
+ base_flags = _DETACHED_PROCESS | _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW
1582
+ # CREATE_BREAKAWAY_FROM_JOB fails with ERROR_ACCESS_DENIED (WinError 5)
1583
+ # whenever the *caller* already runs inside a Job Object that was created
1584
+ # without JOB_OBJECT_LIMIT_BREAKAWAY_OK — which is exactly how agent CLIs
1585
+ # (Claude Code / CodeBuddy / Codex) contain their child processes. Breakaway
1586
+ # is an optimisation (the service outlives the agent session), not a
1587
+ # requirement, so fall back to spawning inside the caller's job rather than
1588
+ # failing the whole start.
1589
+ launcher_proc = None
1590
+ breakaway = True
1591
+ try:
1592
+ launcher_proc = subprocess.Popen(
1593
+ launcher_args,
1594
+ cwd=str(cwd),
1595
+ stdin=subprocess.DEVNULL,
1596
+ stdout=subprocess.DEVNULL,
1597
+ stderr=subprocess.DEVNULL,
1598
+ creationflags=base_flags | _CREATE_BREAKAWAY_FROM_JOB,
1599
+ close_fds=False,
1600
+ )
1601
+ except OSError as exc:
1602
+ if getattr(exc, "winerror", None) != 5:
1603
+ raise
1604
+ breakaway = False
1605
+ launcher_proc = subprocess.Popen(
1606
+ launcher_args,
1607
+ cwd=str(cwd),
1608
+ stdin=subprocess.DEVNULL,
1609
+ stdout=subprocess.DEVNULL,
1610
+ stderr=subprocess.DEVNULL,
1611
+ creationflags=base_flags,
1612
+ close_fds=False,
1613
+ )
1614
+ _breakaway_note_path(change_dir).write_text(
1615
+ json.dumps(
1616
+ {
1617
+ "breakawayFromJob": breakaway,
1618
+ "note": (
1619
+ None
1620
+ if breakaway
1621
+ else "spawned inside the caller's Job Object "
1622
+ "(CREATE_BREAKAWAY_FROM_JOB denied); the service is "
1623
+ "terminated when the agent session ends"
1624
+ ),
1625
+ },
1626
+ ensure_ascii=False,
1627
+ )
1628
+ + "\n",
1629
+ encoding="utf-8",
1630
+ newline="\n",
1591
1631
  )
1592
1632
  _launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
1593
1633
  _wait_for_child_pid(pid_path)
@@ -2400,7 +2440,21 @@ def _start_and_record(
2400
2440
  }
2401
2441
  )
2402
2442
  write_json(mutation_state_path(change_dir), mutation_state)
2403
- return emit_error(f"failed to start service: {exc}", as_json=as_json)
2443
+ if getattr(exc, "winerror", None) == 5:
2444
+ return emit_error(
2445
+ "failed to start service: Windows denied process creation "
2446
+ f"({exc}). The agent shell blocks detached spawns; start the "
2447
+ "service from a terminal the agent does not own (or use the "
2448
+ "documented nohup fallback) and re-run `ensure` so the harness "
2449
+ "adopts the running instance.",
2450
+ as_json=as_json,
2451
+ reasonCode="SERVICE_SPAWN_DENIED",
2452
+ )
2453
+ return emit_error(
2454
+ f"failed to start service: {exc}",
2455
+ as_json=as_json,
2456
+ reasonCode="SERVICE_START_FAILED",
2457
+ )
2404
2458
 
2405
2459
  # Give the OS a moment to register the process before identity/create-time reads
2406
2460
  time.sleep(0.15)