@hunter-harness/workflow-harness 0.2.69 → 0.2.71

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/README.md +1 -1
  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 +3 -1
  8. package/harness/bundles/general/claude-code/harness-plan/reference.md +31 -1
  9. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  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 +254 -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/README.md +1 -1
  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 +3 -1
  33. package/harness/bundles/general/codebuddy/harness-plan/reference.md +31 -1
  34. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  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 +254 -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/README.md +1 -1
  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 +3 -1
  58. package/harness/bundles/general/codex/harness-plan/reference.md +31 -1
  59. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  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 +254 -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/README.md +1 -1
  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 +3 -1
  83. package/harness/bundles/general/cursor/harness-plan/reference.md +31 -1
  84. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  85. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  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 +254 -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/README.md +1 -1
  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 +3 -1
  110. package/harness/bundles/java/claude-code/harness-plan/reference.md +31 -1
  111. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  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 +254 -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/README.md +1 -1
  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 +3 -1
  135. package/harness/bundles/java/codebuddy/harness-plan/reference.md +31 -1
  136. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  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 +254 -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/README.md +1 -1
  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 +3 -1
  160. package/harness/bundles/java/codex/harness-plan/reference.md +31 -1
  161. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  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 +254 -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/README.md +1 -1
  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 +3 -1
  185. package/harness/bundles/java/cursor/harness-plan/reference.md +31 -1
  186. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  187. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  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 +254 -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 +27 -27
  202. package/harness/manifests/general/codebuddy.json +27 -27
  203. package/harness/manifests/general/codex.json +27 -27
  204. package/harness/manifests/general/cursor.json +27 -27
  205. package/harness/manifests/java/claude-code.json +27 -27
  206. package/harness/manifests/java/codebuddy.json +27 -27
  207. package/harness/manifests/java/codex.json +27 -27
  208. package/harness/manifests/java/cursor.json +27 -27
  209. package/hunter-workflow-family.json +5 -5
  210. package/package.json +1 -1
@@ -1614,6 +1614,35 @@ def _scenario_receipt_error(code: str, message: str) -> dict[str, Any]:
1614
1614
  return {"ok": False, "code": code, "error": message}
1615
1615
 
1616
1616
 
1617
+ def _resolve_receipt_path(
1618
+ raw: str, change_dir: Path
1619
+ ) -> tuple[Path | None, list[Path]]:
1620
+ """Resolve --scenario-receipt-file against CWD *and* the change dir.
1621
+
1622
+ Skills write receipts under ``<change-dir>/runtime/``, so a bare
1623
+ ``runtime/scenario-receipt-*.json`` is the natural thing to pass. Resolving
1624
+ only against the CWD made that fail with a bare ENOENT. Return the first
1625
+ existing candidate plus every path tried, so the error can name them.
1626
+ """
1627
+ candidate = Path(raw).expanduser()
1628
+ if candidate.is_absolute():
1629
+ resolved = candidate.resolve()
1630
+ return (resolved if resolved.is_file() else None), [resolved]
1631
+ candidates = [
1632
+ (Path.cwd() / candidate).resolve(),
1633
+ (change_dir / candidate).resolve(),
1634
+ ]
1635
+ # De-duplicate while preserving order (CWD == change_dir is common).
1636
+ unique: list[Path] = []
1637
+ for item in candidates:
1638
+ if item not in unique:
1639
+ unique.append(item)
1640
+ for item in unique:
1641
+ if item.is_file():
1642
+ return item, unique
1643
+ return None, unique
1644
+
1645
+
1617
1646
  def _canonical_string_list(
1618
1647
  receipt: dict[str, Any],
1619
1648
  field: str,
@@ -2786,17 +2815,37 @@ def cmd_record(args: argparse.Namespace) -> int:
2786
2815
  if manifest_schema >= 2 and not _nonempty_str(receipt_file):
2787
2816
  return emit_error(
2788
2817
  "scenario-manifest schemaVersion 2 requires "
2789
- "--scenario-receipt-file",
2818
+ "--scenario-receipt-file; generate a skeleton with: "
2819
+ "harness_ledger.py scenario-receipt-template "
2820
+ f"--change-dir {change_dir} --scenario-ids "
2821
+ f"{','.join(ids)} --runner <name> --out "
2822
+ "runtime/scenario-receipt-<verification>.json",
2790
2823
  as_json=as_json,
2791
2824
  error_code="SCENARIO_RECEIPT_REQUIRED",
2825
+ extra={"template": "scenario-receipt-template"},
2792
2826
  )
2793
2827
  if _nonempty_str(receipt_file):
2828
+ resolved_receipt, receipt_candidates = _resolve_receipt_path(
2829
+ str(receipt_file), change_dir
2830
+ )
2831
+ if resolved_receipt is None:
2832
+ return emit_error(
2833
+ "scenario receipt not found; tried: "
2834
+ + ", ".join(str(c) for c in receipt_candidates),
2835
+ as_json=as_json,
2836
+ error_code="SCENARIO_RECEIPT_NOT_FOUND",
2837
+ extra={
2838
+ "triedPaths": [str(c) for c in receipt_candidates],
2839
+ "hint": (
2840
+ "--scenario-receipt-file accepts an absolute "
2841
+ "path, a CWD-relative path, or a path relative "
2842
+ "to --change-dir"
2843
+ ),
2844
+ },
2845
+ )
2794
2846
  try:
2795
2847
  receipt_payload = json.loads(
2796
- Path(str(receipt_file))
2797
- .expanduser()
2798
- .resolve()
2799
- .read_text(encoding="utf-8-sig")
2848
+ resolved_receipt.read_text(encoding="utf-8-sig")
2800
2849
  )
2801
2850
  except (OSError, json.JSONDecodeError) as exc:
2802
2851
  return emit_error(
@@ -2953,6 +3002,160 @@ def cmd_record(args: argparse.Namespace) -> int:
2953
3002
  return 0
2954
3003
 
2955
3004
 
3005
+ def cmd_scenario_receipt_template(args: argparse.Namespace) -> int:
3006
+ """Emit a schema-v2 receipt skeleton built from the scenario manifest.
3007
+
3008
+ Without this, agents had to read harness_ledger.py source to learn the
3009
+ receipt shape. The skeleton is already valid for `record`; the caller only
3010
+ has to correct `status` for any test that did not pass.
3011
+ """
3012
+ as_json = bool(args.json)
3013
+ change_dir = resolve_path(args.change_dir)
3014
+ ids = [s.strip() for s in str(args.scenario_ids).split(",") if s.strip()]
3015
+ if not ids:
3016
+ return emit_error(
3017
+ "--scenario-ids must list at least one scenario ID",
3018
+ as_json=as_json,
3019
+ error_code="SCENARIO_IDS_REQUIRED",
3020
+ )
3021
+ if len(ids) != len(set(ids)):
3022
+ return emit_error(
3023
+ "scenario IDs must be unique",
3024
+ as_json=as_json,
3025
+ error_code="SCENARIO_ID_DUPLICATE",
3026
+ )
3027
+ manifest_path = change_dir / "meta" / "scenario-manifest.json"
3028
+ try:
3029
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
3030
+ except FileNotFoundError:
3031
+ return emit_error(
3032
+ f"scenario manifest does not exist: {manifest_path}",
3033
+ as_json=as_json,
3034
+ error_code="SCENARIO_MANIFEST_MISSING",
3035
+ )
3036
+ except (OSError, json.JSONDecodeError) as exc:
3037
+ return emit_error(
3038
+ f"scenario manifest unreadable: {exc}",
3039
+ as_json=as_json,
3040
+ error_code="SCENARIO_MANIFEST_INVALID",
3041
+ )
3042
+ scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
3043
+ if not isinstance(scenarios, list):
3044
+ return emit_error(
3045
+ "scenario-manifest.json must contain a scenarios array",
3046
+ as_json=as_json,
3047
+ error_code="SCENARIO_MANIFEST_INVALID",
3048
+ )
3049
+ by_id = {
3050
+ str(item.get("id") or "").strip(): item
3051
+ for item in scenarios
3052
+ if isinstance(item, dict) and _nonempty_str(item.get("id"))
3053
+ }
3054
+ unknown = sorted(set(ids) - set(by_id))
3055
+ if unknown:
3056
+ return emit_error(
3057
+ "scenario IDs are not declared in scenario-manifest.json: "
3058
+ + ", ".join(unknown),
3059
+ as_json=as_json,
3060
+ error_code="SCENARIO_ID_UNKNOWN",
3061
+ )
3062
+ status = str(args.status).strip().upper()
3063
+ if status not in {"PASSED", "FAILED", "SKIPPED"}:
3064
+ return emit_error(
3065
+ "--status must be one of PASSED|FAILED|SKIPPED",
3066
+ as_json=as_json,
3067
+ error_code="SCENARIO_RECEIPT_INVALID",
3068
+ )
3069
+ attempt = int(args.attempt)
3070
+ if attempt < 1:
3071
+ return emit_error(
3072
+ "--attempt must be a positive integer",
3073
+ as_json=as_json,
3074
+ error_code="SCENARIO_RECEIPT_INVALID",
3075
+ )
3076
+
3077
+ identities: list[tuple[str, str, str]] = []
3078
+ incomplete: list[str] = []
3079
+ for scenario_id in ids:
3080
+ scenario = by_id[scenario_id]
3081
+ if any(
3082
+ not _nonempty_str(scenario.get(field))
3083
+ for field in ("executableTestId", "testFile", "testTitle")
3084
+ ):
3085
+ incomplete.append(scenario_id)
3086
+ continue
3087
+ identities.append(
3088
+ (
3089
+ str(scenario["executableTestId"]).strip(),
3090
+ str(scenario["testFile"]).strip(),
3091
+ str(scenario["testTitle"]).strip(),
3092
+ )
3093
+ )
3094
+ if incomplete:
3095
+ return emit_error(
3096
+ "scenarios are missing executableTestId/testFile/testTitle in the "
3097
+ "manifest: " + ", ".join(incomplete),
3098
+ as_json=as_json,
3099
+ error_code="SCENARIO_MANIFEST_INVALID",
3100
+ extra={"incomplete": incomplete},
3101
+ )
3102
+
3103
+ runner: dict[str, Any] = {"name": str(args.runner).strip()}
3104
+ if _nonempty_str(getattr(args, "runner_version", None)):
3105
+ runner["version"] = str(args.runner_version).strip()
3106
+ receipt = {
3107
+ "schemaVersion": 1,
3108
+ "runner": runner,
3109
+ "attempt": attempt,
3110
+ "declared": [test_id for test_id, _, _ in identities],
3111
+ "selected": [test_id for test_id, _, _ in identities],
3112
+ "collected": [
3113
+ {"testId": test_id, "file": file, "title": title}
3114
+ for test_id, file, title in identities
3115
+ ],
3116
+ "executed": [
3117
+ {
3118
+ "testId": test_id,
3119
+ "file": file,
3120
+ "title": title,
3121
+ "attempt": attempt,
3122
+ "status": status,
3123
+ }
3124
+ for test_id, file, title in identities
3125
+ ],
3126
+ }
3127
+ body = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
3128
+
3129
+ out_raw = getattr(args, "out", None)
3130
+ if not _nonempty_str(out_raw):
3131
+ sys.stdout.write(body)
3132
+ return 0
3133
+ out_path = Path(str(out_raw)).expanduser()
3134
+ if not out_path.is_absolute():
3135
+ out_path = change_dir / out_path
3136
+ out_path = out_path.resolve()
3137
+ try:
3138
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3139
+ out_path.write_text(body, encoding="utf-8", newline="\n")
3140
+ except OSError as exc:
3141
+ return emit_error(
3142
+ f"cannot write scenario receipt: {exc}",
3143
+ as_json=as_json,
3144
+ error_code="SCENARIO_RECEIPT_WRITE_FAILED",
3145
+ )
3146
+ payload = {
3147
+ "ok": True,
3148
+ "action": "scenario-receipt-template",
3149
+ "path": str(out_path),
3150
+ "scenarioIds": ids,
3151
+ }
3152
+ if as_json:
3153
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
3154
+ else:
3155
+ sys.stdout.write(f"{out_path}\n")
3156
+ return 0
3157
+
3158
+
2956
3159
  def cmd_diff_hash(args: argparse.Namespace) -> int:
2957
3160
  as_json = bool(args.json)
2958
3161
  repo_raw = getattr(args, "repo", None)
@@ -3234,6 +3437,42 @@ def build_parser() -> argparse.ArgumentParser:
3234
3437
  )
3235
3438
  p_diff.set_defaults(func=cmd_diff_hash)
3236
3439
 
3440
+ p_receipt = sub.add_parser(
3441
+ "scenario-receipt-template",
3442
+ parents=[shared_json],
3443
+ help=(
3444
+ "emit a schema-v2 scenario execution receipt skeleton "
3445
+ "pre-filled from meta/scenario-manifest.json"
3446
+ ),
3447
+ )
3448
+ p_receipt.add_argument("--change-dir", required=True)
3449
+ p_receipt.add_argument(
3450
+ "--scenario-ids",
3451
+ required=True,
3452
+ help="comma-separated scenario IDs to include (must exist in the manifest)",
3453
+ )
3454
+ p_receipt.add_argument(
3455
+ "--runner",
3456
+ required=True,
3457
+ help="test runner name recorded in the receipt (e.g. vitest / maven-surefire)",
3458
+ )
3459
+ p_receipt.add_argument("--runner-version", default=None)
3460
+ p_receipt.add_argument("--attempt", type=int, default=1)
3461
+ p_receipt.add_argument(
3462
+ "--status",
3463
+ default="PASSED",
3464
+ help="per-test status written into executed[] (default: PASSED)",
3465
+ )
3466
+ p_receipt.add_argument(
3467
+ "--out",
3468
+ default=None,
3469
+ help=(
3470
+ "write the skeleton to this path instead of stdout; relative paths "
3471
+ "resolve against --change-dir (same rule as --scenario-receipt-file)"
3472
+ ),
3473
+ )
3474
+ p_receipt.set_defaults(func=cmd_scenario_receipt_template)
3475
+
3237
3476
  return parser
3238
3477
 
3239
3478
 
@@ -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(
@@ -1299,6 +1301,72 @@ def _append_terminal(change_dir: Path, run_id: str, attempt: int) -> tuple[int,
1299
1301
  return code, stderr.getvalue().strip()
1300
1302
 
1301
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
+
1302
1370
  def finalize_plan(
1303
1371
  change_dir: Path,
1304
1372
  staging: Path,
@@ -1306,6 +1374,8 @@ def finalize_plan(
1306
1374
  change_name: str,
1307
1375
  run_id: str,
1308
1376
  attempt: int,
1377
+ allow_amend: bool = False,
1378
+ amend_reason: str | None = None,
1309
1379
  ) -> dict[str, Any]:
1310
1380
  change_dir = change_dir.resolve()
1311
1381
  staging = staging.resolve()
@@ -1331,6 +1401,7 @@ def finalize_plan(
1331
1401
  receipt_path = change_dir / "meta" / "plan-finalization.json"
1332
1402
  lock_path = change_dir / "meta" / "plan-finalize.lock"
1333
1403
  receipt: dict[str, Any] | None = None
1404
+ supersedes: dict[str, Any] | None = None
1334
1405
  if receipt_path.is_file():
1335
1406
  try:
1336
1407
  loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
@@ -1338,11 +1409,22 @@ def finalize_plan(
1338
1409
  except (OSError, json.JSONDecodeError) as exc:
1339
1410
  return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1340
1411
  if receipt and receipt.get("artifactsHash") != validation["artifactsHash"]:
1341
- return _result_error(
1342
- "PLAN_FINALIZATION_HASH_CONFLICT",
1343
- "finalizer was already invoked with a different artifact set",
1344
- )
1345
- 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 (
1346
1428
  receipt
1347
1429
  and receipt.get("status") == "finalized"
1348
1430
  and _terminal_exists(change_dir, run_id, attempt)
@@ -1365,21 +1447,33 @@ def finalize_plan(
1365
1447
  return _result_error("PLAN_FINALIZATION_LOCKED", f"lock exists: {lock_path}")
1366
1448
 
1367
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]] = []
1368
1453
  terminal_committed = False
1369
1454
  try:
1370
1455
  for rel_text in validation["files"]:
1371
1456
  source = staging / rel_text
1372
1457
  target = change_dir / rel_text
1373
- 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
+ ):
1374
1463
  return _result_error(
1375
- "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",
1376
1467
  )
1377
1468
 
1378
1469
  for rel_text in validation["files"]:
1379
1470
  source = staging / rel_text
1380
1471
  target = change_dir / rel_text
1381
- if target.exists():
1382
- 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
1383
1477
  target.parent.mkdir(parents=True, exist_ok=True)
1384
1478
  fd, raw_tmp = tempfile.mkstemp(
1385
1479
  prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
@@ -1389,7 +1483,10 @@ def finalize_plan(
1389
1483
  try:
1390
1484
  shutil.copyfile(source, tmp)
1391
1485
  os.replace(tmp, target)
1392
- created.append(target)
1486
+ if existed:
1487
+ replaced.append((target, original))
1488
+ else:
1489
+ created.append(target)
1393
1490
  finally:
1394
1491
  tmp.unlink(missing_ok=True)
1395
1492
 
@@ -1403,8 +1500,11 @@ def finalize_plan(
1403
1500
  "tasks": tasks,
1404
1501
  "foundationGate": "approved",
1405
1502
  }
1503
+ if checkpoints_path.is_file():
1504
+ replaced.append((checkpoints_path, checkpoints_path.read_bytes()))
1505
+ else:
1506
+ created.append(checkpoints_path)
1406
1507
  _atomic_write_json(checkpoints_path, checkpoints_payload)
1407
- created.append(checkpoints_path)
1408
1508
 
1409
1509
  # C9: write scenario-manifest.json with parsed scenarios.
1410
1510
  scenarios = validation.get("scenarios") or []
@@ -1421,8 +1521,11 @@ def finalize_plan(
1421
1521
  for scenario in scenarios
1422
1522
  ],
1423
1523
  }
1524
+ if manifest_path.is_file():
1525
+ replaced.append((manifest_path, manifest_path.read_bytes()))
1526
+ else:
1527
+ created.append(manifest_path)
1424
1528
  _atomic_write_json(manifest_path, manifest_payload)
1425
- created.append(manifest_path)
1426
1529
 
1427
1530
  pending_receipt = {
1428
1531
  "schemaVersion": SCHEMA_VERSION,
@@ -1433,12 +1536,14 @@ def finalize_plan(
1433
1536
  "runId": run_id,
1434
1537
  "attempt": attempt,
1435
1538
  }
1539
+ if supersedes is not None:
1540
+ pending_receipt["supersedes"] = supersedes
1541
+ pending_receipt["amendReason"] = amend_reason or ""
1436
1542
  _atomic_write_json(receipt_path, pending_receipt)
1437
1543
  terminal_code, terminal_error = _append_terminal(change_dir, run_id, attempt)
1438
1544
  terminal_committed = _terminal_exists(change_dir, run_id, attempt)
1439
1545
  if terminal_code != 0 and not terminal_committed:
1440
- for target in reversed(created):
1441
- target.unlink(missing_ok=True)
1546
+ _rollback_publish(created, replaced)
1442
1547
  receipt_path.unlink(missing_ok=True)
1443
1548
  return _result_error(
1444
1549
  "PLAN_TERMINAL_APPEND_FAILED",
@@ -1446,9 +1551,9 @@ def finalize_plan(
1446
1551
  )
1447
1552
  pending_receipt["status"] = "finalized"
1448
1553
  _atomic_write_json(receipt_path, pending_receipt)
1449
- return {
1554
+ result: dict[str, Any] = {
1450
1555
  "ok": True,
1451
- "action": "finalize",
1556
+ "action": "republish" if supersedes is not None else "finalize",
1452
1557
  "idempotent": False,
1453
1558
  "artifactsHash": validation["artifactsHash"],
1454
1559
  "files": validation["files"],
@@ -1456,23 +1561,145 @@ def finalize_plan(
1456
1561
  "artifactRef": "meta/plan-finalization.json",
1457
1562
  "executionLogPath": str(harness_events.execution_log_path(change_dir)),
1458
1563
  }
1564
+ if supersedes is not None:
1565
+ result["supersedes"] = supersedes
1566
+ result["scenarioCount"] = len(scenarios)
1567
+ result["taskCount"] = len(tasks)
1568
+ return result
1459
1569
  except OSError as exc:
1460
1570
  if terminal_committed or _terminal_exists(change_dir, run_id, attempt):
1461
1571
  return _result_error(
1462
1572
  "PLAN_FINALIZATION_RECOVERY_REQUIRED",
1463
1573
  f"terminal committed; retry finalization to complete receipt: {exc}",
1464
1574
  )
1465
- for target in reversed(created):
1466
- target.unlink(missing_ok=True)
1575
+ _rollback_publish(created, replaced)
1467
1576
  receipt_path.unlink(missing_ok=True)
1468
1577
  return _result_error("PLAN_FINALIZATION_IO_ERROR", str(exc))
1469
1578
  finally:
1470
1579
  lock_path.unlink(missing_ok=True)
1471
1580
 
1472
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
+
1473
1683
  def build_parser() -> argparse.ArgumentParser:
1474
1684
  parser = argparse.ArgumentParser(prog="harness_plan_finalize.py")
1475
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")
1476
1703
  finalize = sub.add_parser("finalize")
1477
1704
  finalize.add_argument("--change-dir", required=True)
1478
1705
  finalize.add_argument("--staging-dir", required=True)
@@ -1490,6 +1717,14 @@ def main(argv: list[str] | None = None) -> int:
1490
1717
  args = build_parser().parse_args(argv)
1491
1718
  if args.command == "verify":
1492
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
+ )
1493
1728
  else:
1494
1729
  result = finalize_plan(
1495
1730
  Path(args.change_dir),