@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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/README.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-plan/reference.md +31 -1
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-run/reference.md +20 -0
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/claude-code/harness-test/reference.md +9 -0
- package/harness/bundles/general/claude-code/scripts/harness_context.py +27 -7
- package/harness/bundles/general/claude-code/scripts/harness_events.py +12 -3
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/general/claude-code/scripts/harness_service.py +69 -15
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/README.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-plan/reference.md +31 -1
- package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-run/reference.md +20 -0
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/codebuddy/harness-test/reference.md +9 -0
- package/harness/bundles/general/codebuddy/scripts/harness_context.py +27 -7
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +12 -3
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +69 -15
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/README.md +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-plan/reference.md +31 -1
- package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-run/reference.md +20 -0
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/codex/harness-test/reference.md +9 -0
- package/harness/bundles/general/codex/scripts/harness_context.py +27 -7
- package/harness/bundles/general/codex/scripts/harness_events.py +12 -3
- package/harness/bundles/general/codex/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/codex/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/general/codex/scripts/harness_service.py +69 -15
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/README.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-plan/reference.md +31 -1
- package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-run/reference.md +20 -0
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/cursor/harness-test/reference.md +9 -0
- package/harness/bundles/general/cursor/scripts/harness_context.py +27 -7
- package/harness/bundles/general/cursor/scripts/harness_events.py +12 -3
- package/harness/bundles/general/cursor/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/general/cursor/scripts/harness_service.py +69 -15
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/README.md +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-plan/reference.md +31 -1
- package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/claude-code/scripts/harness_context.py +27 -7
- package/harness/bundles/java/claude-code/scripts/harness_events.py +12 -3
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/java/claude-code/scripts/harness_service.py +69 -15
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/README.md +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-plan/reference.md +31 -1
- package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/codebuddy/scripts/harness_context.py +27 -7
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +12 -3
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +69 -15
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/README.md +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-plan/reference.md +31 -1
- package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/codex/scripts/harness_context.py +27 -7
- package/harness/bundles/java/codex/scripts/harness_events.py +12 -3
- package/harness/bundles/java/codex/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/codex/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/java/codex/scripts/harness_service.py +69 -15
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/README.md +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-plan/reference.md +31 -1
- package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/cursor/scripts/harness_context.py +27 -7
- package/harness/bundles/java/cursor/scripts/harness_events.py +12 -3
- package/harness/bundles/java/cursor/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +254 -19
- package/harness/bundles/java/cursor/scripts/harness_service.py +69 -15
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +48 -1
- package/harness/manifests/general/claude-code.json +27 -27
- package/harness/manifests/general/codebuddy.json +27 -27
- package/harness/manifests/general/codex.json +27 -27
- package/harness/manifests/general/cursor.json +27 -27
- package/harness/manifests/java/claude-code.json +27 -27
- package/harness/manifests/java/codebuddy.json +27 -27
- package/harness/manifests/java/codex.json +27 -27
- package/harness/manifests/java/cursor.json +27 -27
- package/hunter-workflow-family.json +5 -5
- 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
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|