@hunter-harness/workflow-harness 0.2.13 → 0.2.15
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/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 +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/reference.md +3 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-submit/checklist.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 +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/claude-code/scripts/harness_change.py +310 -16
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/claude-code/scripts/harness_events.py +113 -1
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/claude-code/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/claude-code/scripts/harness_state.py +12 -4
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +86 -13
- package/harness/bundles/general/codebuddy/.harness-build.json +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 +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +310 -16
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +113 -1
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/codebuddy/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/codebuddy/scripts/harness_state.py +12 -4
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +86 -13
- package/harness/bundles/general/codex/.harness-build.json +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 +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/reference.md +3 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/codex/scripts/harness_change.py +310 -16
- package/harness/bundles/general/codex/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/codex/scripts/harness_events.py +113 -1
- package/harness/bundles/general/codex/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/codex/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/codex/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/codex/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/codex/scripts/harness_state.py +12 -4
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +86 -13
- package/harness/bundles/general/cursor/.harness-build.json +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 +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/reference.md +3 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/cursor/scripts/harness_change.py +310 -16
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/cursor/scripts/harness_events.py +113 -1
- package/harness/bundles/general/cursor/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/cursor/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/cursor/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/cursor/scripts/harness_state.py +12 -4
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +86 -13
- package/harness/bundles/java/claude-code/.harness-build.json +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 +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-submit/checklist.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 +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/claude-code/scripts/harness_change.py +310 -16
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/claude-code/scripts/harness_events.py +113 -1
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/claude-code/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/claude-code/scripts/harness_state.py +12 -4
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +86 -13
- package/harness/bundles/java/codebuddy/.harness-build.json +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 +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +310 -16
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +113 -1
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/codebuddy/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/codebuddy/scripts/harness_state.py +12 -4
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +86 -13
- package/harness/bundles/java/codex/.harness-build.json +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 +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/codex/scripts/harness_change.py +310 -16
- package/harness/bundles/java/codex/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/codex/scripts/harness_events.py +113 -1
- package/harness/bundles/java/codex/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/codex/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/codex/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/codex/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/codex/scripts/harness_state.py +12 -4
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +86 -13
- package/harness/bundles/java/cursor/.harness-build.json +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 +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/cursor/scripts/harness_change.py +310 -16
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/cursor/scripts/harness_events.py +113 -1
- package/harness/bundles/java/cursor/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/cursor/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/cursor/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/cursor/scripts/harness_state.py +12 -4
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +86 -13
- package/harness/manifests/general/claude-code.json +25 -25
- package/harness/manifests/general/codebuddy.json +25 -25
- package/harness/manifests/general/codex.json +25 -25
- package/harness/manifests/general/cursor.json +25 -25
- package/harness/manifests/java/claude-code.json +26 -26
- package/harness/manifests/java/codebuddy.json +26 -26
- package/harness/manifests/java/codex.json +26 -26
- package/harness/manifests/java/cursor.json +26 -26
- package/hunter-workflow-family.json +2 -2
- package/package.json +15 -15
|
@@ -683,6 +683,34 @@ def check_status(
|
|
|
683
683
|
else:
|
|
684
684
|
blockers.append(tier_issue)
|
|
685
685
|
|
|
686
|
+
# --- artifact preflight (retro §5.31) ---
|
|
687
|
+
# Classify artifact events before destructive finalize: informational (OK),
|
|
688
|
+
# canonicalizable (same-change repo-relative path, warning), or blocking
|
|
689
|
+
# (cross-change/absolute/escaping path, fail closed).
|
|
690
|
+
try:
|
|
691
|
+
preflight = artifact_preflight(change_dir)
|
|
692
|
+
except Exception as exc: # noqa: BLE001 — preflight must not crash status
|
|
693
|
+
preflight = {"ok": False, "items": [], "blocking": [], "error": str(exc)}
|
|
694
|
+
checks["artifact_preflight"] = preflight
|
|
695
|
+
for item in preflight.get("blocking") or []:
|
|
696
|
+
blockers.append({
|
|
697
|
+
"code": "artifact-path-blocking",
|
|
698
|
+
"message": (
|
|
699
|
+
f"artifact event {item.get('eventId', '')} path "
|
|
700
|
+
f"{item.get('path', '')}: {item.get('reason', 'blocking')}"
|
|
701
|
+
),
|
|
702
|
+
})
|
|
703
|
+
for item in preflight.get("items") or []:
|
|
704
|
+
if item.get("category") == "canonicalizable":
|
|
705
|
+
warnings.append({
|
|
706
|
+
"code": "artifact-path-canonicalizable",
|
|
707
|
+
"message": (
|
|
708
|
+
f"artifact event {item.get('eventId', '')} path "
|
|
709
|
+
f"{item.get('path', '')} is repo-relative; "
|
|
710
|
+
f"canonical={item.get('canonicalPath', '')}"
|
|
711
|
+
),
|
|
712
|
+
})
|
|
713
|
+
|
|
686
714
|
archivable = len(blockers) == 0
|
|
687
715
|
return {
|
|
688
716
|
"ok": True,
|
|
@@ -2799,6 +2827,130 @@ def _html_escape(text: str) -> str:
|
|
|
2799
2827
|
# ---------------------------------------------------------------------------
|
|
2800
2828
|
|
|
2801
2829
|
|
|
2830
|
+
def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
2831
|
+
"""Validate that final summary is factually complete (retro §5.32).
|
|
2832
|
+
|
|
2833
|
+
The archive validator must not return all-green when the summary is
|
|
2834
|
+
factually incomplete. This gate checks independent sources (git facts,
|
|
2835
|
+
typed metrics, stage status) rather than comparing the summary against
|
|
2836
|
+
a lossy projection of itself.
|
|
2837
|
+
"""
|
|
2838
|
+
issues: list[dict[str, str]] = []
|
|
2839
|
+
|
|
2840
|
+
git_facts = summary.get("gitFacts") or {}
|
|
2841
|
+
if isinstance(git_facts, dict):
|
|
2842
|
+
base = str(git_facts.get("baseCommit") or "").strip()
|
|
2843
|
+
final = str(git_facts.get("finalCommit") or "").strip()
|
|
2844
|
+
files_changed = int(git_facts.get("filesChanged") or 0)
|
|
2845
|
+
# base != final but diff=0 → factually incomplete.
|
|
2846
|
+
if base and final and base != final and files_changed == 0:
|
|
2847
|
+
issues.append({
|
|
2848
|
+
"code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
|
|
2849
|
+
"severity": "error",
|
|
2850
|
+
"message": f"final commit {final} differs from base {base} but filesChanged=0",
|
|
2851
|
+
})
|
|
2852
|
+
|
|
2853
|
+
# Typed metrics missing despite test report artifacts present.
|
|
2854
|
+
verification = summary.get("verification") or {}
|
|
2855
|
+
if isinstance(verification, dict):
|
|
2856
|
+
unit = verification.get("unitTests") or {}
|
|
2857
|
+
api = verification.get("apiTests") or {}
|
|
2858
|
+
artifacts = summary.get("artifacts") or []
|
|
2859
|
+
has_test_report = any(
|
|
2860
|
+
isinstance(a, dict) and "test" in str(a.get("path") or "").lower()
|
|
2861
|
+
for a in artifacts
|
|
2862
|
+
) if isinstance(artifacts, list) else False
|
|
2863
|
+
unit_pass = int(unit.get("passed") or 0) if isinstance(unit, dict) else 0
|
|
2864
|
+
api_status = str(api.get("status") or "") if isinstance(api, dict) else ""
|
|
2865
|
+
if has_test_report and unit_pass == 0 and api_status in {"", "not_available"}:
|
|
2866
|
+
issues.append({
|
|
2867
|
+
"code": "TYPED_METRICS_MISSING",
|
|
2868
|
+
"severity": "error",
|
|
2869
|
+
"message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
|
|
2870
|
+
})
|
|
2871
|
+
|
|
2872
|
+
# stageStatus contradicts event reducer.
|
|
2873
|
+
stage = summary.get("stageStatus") or {}
|
|
2874
|
+
stage_from_events = summary.get("stageStatusFromEvents") or {}
|
|
2875
|
+
if isinstance(stage, dict) and isinstance(stage_from_events, dict):
|
|
2876
|
+
for phase, status in stage.items():
|
|
2877
|
+
event_status = stage_from_events.get(phase)
|
|
2878
|
+
if event_status is not None and str(status) != str(event_status):
|
|
2879
|
+
issues.append({
|
|
2880
|
+
"code": "STAGE_STATUS_CONTRADICTION",
|
|
2881
|
+
"severity": "error",
|
|
2882
|
+
"message": f"stageStatus.{phase}={status} but event reducer says {event_status}",
|
|
2883
|
+
})
|
|
2884
|
+
|
|
2885
|
+
return {"ok": not issues, "issues": issues}
|
|
2886
|
+
|
|
2887
|
+
|
|
2888
|
+
def artifact_preflight(change_dir: Path) -> dict[str, Any]:
|
|
2889
|
+
"""Classify artifact events before destructive finalize (retro §5.31).
|
|
2890
|
+
|
|
2891
|
+
Returns per-artifact classification: informational (no path, OK),
|
|
2892
|
+
canonicalizable (same-change repo-relative path, auto-normalize), or
|
|
2893
|
+
blocking (cross-change/absolute/escaping path, fail closed).
|
|
2894
|
+
"""
|
|
2895
|
+
change_dir = change_dir.resolve()
|
|
2896
|
+
events_p = change_dir / "events.ndjson"
|
|
2897
|
+
items: list[dict[str, Any]] = []
|
|
2898
|
+
blocking: list[dict[str, Any]] = []
|
|
2899
|
+
if not events_p.is_file():
|
|
2900
|
+
return {"ok": True, "items": [], "blocking": []}
|
|
2901
|
+
change_id = change_dir.name
|
|
2902
|
+
for line in events_p.read_text(encoding="utf-8").splitlines():
|
|
2903
|
+
try:
|
|
2904
|
+
event = json.loads(line)
|
|
2905
|
+
except json.JSONDecodeError:
|
|
2906
|
+
continue
|
|
2907
|
+
if not isinstance(event, dict) or event.get("type") != "artifact":
|
|
2908
|
+
continue
|
|
2909
|
+
path = str(event.get("path") or "").strip()
|
|
2910
|
+
kind = str(event.get("kind") or "").strip()
|
|
2911
|
+
event_id = str(event.get("id") or "")
|
|
2912
|
+
if not path:
|
|
2913
|
+
items.append({
|
|
2914
|
+
"eventId": event_id,
|
|
2915
|
+
"category": "informational",
|
|
2916
|
+
"kind": kind or "informational",
|
|
2917
|
+
"note": str(event.get("note") or "")[:80],
|
|
2918
|
+
})
|
|
2919
|
+
continue
|
|
2920
|
+
# Check for escaping/cross-change paths.
|
|
2921
|
+
parts = path.replace("\\", "/").split("/")
|
|
2922
|
+
if ".." in parts or path.startswith("/") or re.match(r"^[A-Za-z]:", path):
|
|
2923
|
+
item = {
|
|
2924
|
+
"eventId": event_id,
|
|
2925
|
+
"category": "blocking",
|
|
2926
|
+
"path": path,
|
|
2927
|
+
"reason": "absolute or escaping path",
|
|
2928
|
+
}
|
|
2929
|
+
items.append(item)
|
|
2930
|
+
blocking.append(item)
|
|
2931
|
+
continue
|
|
2932
|
+
# Same-change repo-relative path: canonicalizable.
|
|
2933
|
+
prefix = f".harness/changes/{change_id}/"
|
|
2934
|
+
if path.startswith(prefix):
|
|
2935
|
+
canonical = path[len(prefix):]
|
|
2936
|
+
items.append({
|
|
2937
|
+
"eventId": event_id,
|
|
2938
|
+
"category": "canonicalizable",
|
|
2939
|
+
"path": path,
|
|
2940
|
+
"canonicalPath": canonical,
|
|
2941
|
+
"correction": f"append correction --target-event-id {event_id} --target-field path --new-value-json \"{canonical}\"",
|
|
2942
|
+
})
|
|
2943
|
+
continue
|
|
2944
|
+
# Change-relative path: OK.
|
|
2945
|
+
items.append({
|
|
2946
|
+
"eventId": event_id,
|
|
2947
|
+
"category": "file-backed",
|
|
2948
|
+
"path": path,
|
|
2949
|
+
"exists": (change_dir / path).is_file(),
|
|
2950
|
+
})
|
|
2951
|
+
return {"ok": not blocking, "items": items, "blocking": blocking}
|
|
2952
|
+
|
|
2953
|
+
|
|
2802
2954
|
def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
|
|
2803
2955
|
results: dict[str, Any] = {"ran": False, "steps": [], "warnings": []}
|
|
2804
2956
|
if not KNOWLEDGE_SCRIPT.is_file():
|
|
@@ -3107,6 +3259,36 @@ def cmd_finalize(
|
|
|
3107
3259
|
note="review missing on full tier (allowed by user)",
|
|
3108
3260
|
)
|
|
3109
3261
|
|
|
3262
|
+
# --- 2c. artifact preflight (retro §5.31) ---
|
|
3263
|
+
# Classify artifact events before freeze: blocking paths must fail closed
|
|
3264
|
+
# before the staged operation commits a phase.end.
|
|
3265
|
+
try:
|
|
3266
|
+
preflight = artifact_preflight(work_dir)
|
|
3267
|
+
except Exception as exc: # noqa: BLE001 — preflight must not crash finalize
|
|
3268
|
+
preflight = {"ok": False, "items": [], "blocking": [], "error": str(exc)}
|
|
3269
|
+
payload["steps"]["artifact_preflight"] = {
|
|
3270
|
+
"ok": bool(preflight.get("ok")),
|
|
3271
|
+
"blockingCount": len(preflight.get("blocking") or []),
|
|
3272
|
+
"itemsCount": len(preflight.get("items") or []),
|
|
3273
|
+
}
|
|
3274
|
+
if preflight.get("blocking"):
|
|
3275
|
+
payload["error"] = (
|
|
3276
|
+
f"artifact preflight blocking: "
|
|
3277
|
+
f"{len(preflight['blocking'])} path(s) cannot be archived"
|
|
3278
|
+
)
|
|
3279
|
+
payload["issues"] = [
|
|
3280
|
+
{
|
|
3281
|
+
"code": "ARTIFACT_PATH_BLOCKING",
|
|
3282
|
+
"severity": "error",
|
|
3283
|
+
"message": f"{item.get('path', '')}: {item.get('reason', 'blocking')}",
|
|
3284
|
+
}
|
|
3285
|
+
for item in preflight["blocking"]
|
|
3286
|
+
]
|
|
3287
|
+
_restore_finalize_failure()
|
|
3288
|
+
payload["warnings"] = warnings
|
|
3289
|
+
payload["ok"] = False
|
|
3290
|
+
return 1, payload
|
|
3291
|
+
|
|
3110
3292
|
# --- 3. candidate phase.end + freeze cutoff (RET-19 freeze-first) ---
|
|
3111
3293
|
# This terminal exists only inside the isolated operation staging tree. It
|
|
3112
3294
|
# becomes authoritative atomically at publish; validation failure discards
|
|
@@ -3169,6 +3351,28 @@ def cmd_finalize(
|
|
|
3169
3351
|
payload["ok"] = False
|
|
3170
3352
|
return 1, payload
|
|
3171
3353
|
|
|
3354
|
+
# --- 5b. report adequacy (retro §5.32) ---
|
|
3355
|
+
# Validate that the summary is factually complete: no diff=0 with non-empty
|
|
3356
|
+
# commit, no missing typed metrics despite test reports, no stageStatus
|
|
3357
|
+
# contradictions. Fail closed rather than archiving an incomplete summary.
|
|
3358
|
+
adequacy_result = validate_report_adequacy(summary)
|
|
3359
|
+
payload["steps"]["report_adequacy"] = adequacy_result
|
|
3360
|
+
summary.setdefault("reportPipeline", {})["reportAdequacy"] = {
|
|
3361
|
+
"ok": bool(adequacy_result.get("ok")),
|
|
3362
|
+
"issues": adequacy_result.get("issues") or [],
|
|
3363
|
+
}
|
|
3364
|
+
try:
|
|
3365
|
+
write_json(summary_path, summary)
|
|
3366
|
+
except OSError as exc:
|
|
3367
|
+
warnings.append(f"could not write reportAdequacy: {exc}")
|
|
3368
|
+
if not adequacy_result.get("ok"):
|
|
3369
|
+
payload["issues"] = adequacy_result.get("issues") or []
|
|
3370
|
+
payload["error"] = "report adequacy failed; staged operation discarded"
|
|
3371
|
+
_restore_finalize_failure()
|
|
3372
|
+
payload["warnings"] = warnings
|
|
3373
|
+
payload["ok"] = False
|
|
3374
|
+
return 1, payload
|
|
3375
|
+
|
|
3172
3376
|
# --- 6. render (Node, else Python fallback) ---
|
|
3173
3377
|
render_result = render_final_summary(work_dir, summary_path)
|
|
3174
3378
|
payload["steps"]["render"] = render_result
|
|
@@ -224,6 +224,60 @@ def _layout_fields(project_root: Path, change_id: str) -> dict[str, Any]:
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def read_concurrency_mode(project_root: Path) -> str:
|
|
228
|
+
"""Return the configured concurrency mode (retro §5.2).
|
|
229
|
+
|
|
230
|
+
Defaults to ``single-active`` when no config declares a mode. Supported
|
|
231
|
+
values: ``single-active`` (default, blocks a second active Change),
|
|
232
|
+
``isolated-multi-active`` (allows multiple active Changes but all
|
|
233
|
+
Change-scoped commands require ``--change``).
|
|
234
|
+
"""
|
|
235
|
+
cfg = project_root / ".harness" / "config.json"
|
|
236
|
+
if cfg.is_file():
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(cfg.read_text(encoding="utf-8"))
|
|
239
|
+
if isinstance(data, dict):
|
|
240
|
+
mode = data.get("concurrencyMode")
|
|
241
|
+
if isinstance(mode, str) and mode in {
|
|
242
|
+
"single-active",
|
|
243
|
+
"isolated-multi-active",
|
|
244
|
+
}:
|
|
245
|
+
return mode
|
|
246
|
+
except (OSError, json.JSONDecodeError):
|
|
247
|
+
pass
|
|
248
|
+
return "single-active"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def check_concurrency_block(
|
|
252
|
+
project_root: Path, change_id: str
|
|
253
|
+
) -> dict[str, Any] | None:
|
|
254
|
+
"""Return a blocking payload when a second active Change is forbidden.
|
|
255
|
+
|
|
256
|
+
Returns ``None`` when the begin may proceed. In ``single-active`` mode a
|
|
257
|
+
second active Change (any other than ``change_id``) blocks begin; in
|
|
258
|
+
``isolated-multi-active`` mode multiple active Changes are allowed.
|
|
259
|
+
"""
|
|
260
|
+
mode = read_concurrency_mode(project_root)
|
|
261
|
+
if mode == "isolated-multi-active":
|
|
262
|
+
return None
|
|
263
|
+
active = list_active_changes(project_root)
|
|
264
|
+
others = [entry for entry in active if entry.get("changeId") != change_id]
|
|
265
|
+
if not others:
|
|
266
|
+
return None
|
|
267
|
+
return {
|
|
268
|
+
"ok": False,
|
|
269
|
+
"code": "SINGLE_ACTIVE_BLOCKED",
|
|
270
|
+
"message": (
|
|
271
|
+
"single-active concurrency mode: another active Change exists; "
|
|
272
|
+
"use portfolio/decompose to sequence multiple Changes, or switch "
|
|
273
|
+
"to isolated-multi-active after ensuring full Change-scoped isolation"
|
|
274
|
+
),
|
|
275
|
+
"concurrencyMode": mode,
|
|
276
|
+
"activeChanges": active,
|
|
277
|
+
"blockingChanges": others,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
227
281
|
def resolve_change(
|
|
228
282
|
project_root: Path,
|
|
229
283
|
change_id: str | None,
|
|
@@ -536,7 +590,9 @@ def lease_port(
|
|
|
536
590
|
}
|
|
537
591
|
for port in range(start, end + 1):
|
|
538
592
|
if port not in used:
|
|
593
|
+
lease_id = str(uuid.uuid4())
|
|
539
594
|
entry = {
|
|
595
|
+
"leaseId": lease_id,
|
|
540
596
|
"changeId": change_id,
|
|
541
597
|
"runId": run_id,
|
|
542
598
|
"pid": os.getpid(),
|
|
@@ -549,7 +605,7 @@ def lease_port(
|
|
|
549
605
|
leases.append(entry)
|
|
550
606
|
registry["leases"] = leases
|
|
551
607
|
_write_json(registry_path, registry)
|
|
552
|
-
return {"ok": True, "code": "PORT_LEASED", "port": port, "lease": entry}
|
|
608
|
+
return {"ok": True, "code": "PORT_LEASED", "port": port, "leaseId": lease_id, "lease": entry}
|
|
553
609
|
return {
|
|
554
610
|
"ok": False,
|
|
555
611
|
"code": "PORT_RANGE_EXHAUSTED",
|
|
@@ -562,7 +618,16 @@ def release_port(
|
|
|
562
618
|
*,
|
|
563
619
|
change_id: str,
|
|
564
620
|
run_id: str,
|
|
621
|
+
port: int | None = None,
|
|
622
|
+
lease_id: str | None = None,
|
|
565
623
|
) -> dict[str, Any]:
|
|
624
|
+
"""Release port leases by subset (retro §5.16).
|
|
625
|
+
|
|
626
|
+
- `--lease-id`: release only the matching leaseId.
|
|
627
|
+
- `--port`: release only the matching port (must match changeId+runId).
|
|
628
|
+
- Neither: release all leases matching (changeId, runId) — does NOT require
|
|
629
|
+
all leases under changeId to have the same runId.
|
|
630
|
+
"""
|
|
566
631
|
registry_path = project_root / PORTS_REL / "registry.json"
|
|
567
632
|
if not registry_path.is_file():
|
|
568
633
|
return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
|
|
@@ -574,27 +639,84 @@ def release_port(
|
|
|
574
639
|
leases = registry.get("leases") if isinstance(registry, dict) else None
|
|
575
640
|
if not isinstance(leases, list):
|
|
576
641
|
return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": "leases is not a list"}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
642
|
+
|
|
643
|
+
# Select the subset to release based on lease_id, port, or (changeId, runId)
|
|
644
|
+
if lease_id is not None:
|
|
645
|
+
to_release = [
|
|
646
|
+
item for item in leases
|
|
647
|
+
if isinstance(item, dict) and str(item.get("leaseId")) == lease_id
|
|
648
|
+
]
|
|
649
|
+
# Validate that the lease belongs to this change
|
|
650
|
+
for item in to_release:
|
|
651
|
+
if str(item.get("changeId")) != change_id:
|
|
652
|
+
return {
|
|
653
|
+
"ok": False,
|
|
654
|
+
"code": "PORT_LEASE_OWNER_MISMATCH",
|
|
655
|
+
"message": "leaseId does not belong to this change",
|
|
656
|
+
"holder": item,
|
|
657
|
+
}
|
|
658
|
+
elif port is not None:
|
|
659
|
+
to_release = [
|
|
660
|
+
item for item in leases
|
|
661
|
+
if isinstance(item, dict)
|
|
662
|
+
and item.get("port") == port
|
|
663
|
+
and str(item.get("changeId")) == change_id
|
|
664
|
+
]
|
|
665
|
+
# Validate runId matches
|
|
666
|
+
for item in to_release:
|
|
667
|
+
if str(item.get("runId")) != run_id:
|
|
668
|
+
return {
|
|
669
|
+
"ok": False,
|
|
670
|
+
"code": "PORT_LEASE_OWNER_MISMATCH",
|
|
671
|
+
"message": f"port {port} owned by different runId",
|
|
672
|
+
"holder": item,
|
|
673
|
+
"conflictingOwners": [
|
|
674
|
+
{"runId": str(i.get("runId")), "port": i.get("port")}
|
|
675
|
+
for i in leases
|
|
676
|
+
if isinstance(i, dict)
|
|
677
|
+
and i.get("port") == port
|
|
678
|
+
and str(i.get("changeId")) == change_id
|
|
679
|
+
],
|
|
680
|
+
}
|
|
681
|
+
else:
|
|
682
|
+
# Release all matching (changeId, runId) — subset release
|
|
683
|
+
to_release = [
|
|
684
|
+
item for item in leases
|
|
685
|
+
if isinstance(item, dict)
|
|
686
|
+
and str(item.get("changeId")) == change_id
|
|
687
|
+
and str(item.get("runId")) == run_id
|
|
688
|
+
]
|
|
689
|
+
|
|
690
|
+
if not to_release:
|
|
691
|
+
# Check if there are other leases under this changeId (different runId)
|
|
692
|
+
other_owned = [
|
|
693
|
+
item for item in leases
|
|
694
|
+
if isinstance(item, dict)
|
|
695
|
+
and str(item.get("changeId")) == change_id
|
|
696
|
+
and str(item.get("runId")) != run_id
|
|
697
|
+
]
|
|
698
|
+
if other_owned:
|
|
699
|
+
return {
|
|
700
|
+
"ok": False,
|
|
701
|
+
"code": "PORT_LEASE_OWNER_MISMATCH",
|
|
702
|
+
"message": "no matching leases for this runId; other runIds exist under this changeId",
|
|
703
|
+
"conflictingOwners": [
|
|
704
|
+
{"runId": str(i.get("runId")), "port": i.get("port"), "leaseId": i.get("leaseId")}
|
|
705
|
+
for i in other_owned
|
|
706
|
+
],
|
|
707
|
+
}
|
|
582
708
|
return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
"message": "run id does not match port lease owner",
|
|
588
|
-
"holder": owned[0],
|
|
589
|
-
}
|
|
590
|
-
released_ports = [item.get("port") for item in owned]
|
|
591
|
-
registry["leases"] = [item for item in leases if item not in owned]
|
|
709
|
+
|
|
710
|
+
released_ports = [item.get("port") for item in to_release]
|
|
711
|
+
released_ids = [item.get("leaseId") for item in to_release]
|
|
712
|
+
registry["leases"] = [item for item in leases if item not in to_release]
|
|
592
713
|
_write_json(registry_path, registry)
|
|
593
714
|
return {
|
|
594
715
|
"ok": True,
|
|
595
716
|
"code": "PORT_LEASE_RELEASED",
|
|
596
717
|
"changeId": change_id,
|
|
597
718
|
"ports": released_ports,
|
|
719
|
+
"leaseIds": released_ids,
|
|
598
720
|
}
|
|
599
721
|
|
|
600
722
|
|
|
@@ -794,7 +916,13 @@ def cmd_lease_port(args: argparse.Namespace) -> int:
|
|
|
794
916
|
|
|
795
917
|
def cmd_release_port(args: argparse.Namespace) -> int:
|
|
796
918
|
project = resolve_main_project_root()
|
|
797
|
-
payload = release_port(
|
|
919
|
+
payload = release_port(
|
|
920
|
+
project,
|
|
921
|
+
change_id=args.change,
|
|
922
|
+
run_id=args.run_id,
|
|
923
|
+
port=getattr(args, "port", None),
|
|
924
|
+
lease_id=getattr(args, "lease_id", None),
|
|
925
|
+
)
|
|
798
926
|
if payload.get("ok"):
|
|
799
927
|
emit(payload, as_json=bool(args.json))
|
|
800
928
|
return 0
|
|
@@ -827,6 +955,161 @@ def cmd_integration_lock(args: argparse.Namespace) -> int:
|
|
|
827
955
|
)
|
|
828
956
|
|
|
829
957
|
|
|
958
|
+
def _ensure_change_identity(change_dir: Path) -> dict[str, str]:
|
|
959
|
+
"""Ensure meta/change-identity.json exists with a stable UUID (retro §5.5).
|
|
960
|
+
|
|
961
|
+
Returns the identity dict with changeUuid and changeName.
|
|
962
|
+
"""
|
|
963
|
+
identity_path = change_dir / "meta" / "change-identity.json"
|
|
964
|
+
if identity_path.is_file():
|
|
965
|
+
try:
|
|
966
|
+
data = json.loads(identity_path.read_text(encoding="utf-8-sig"))
|
|
967
|
+
if isinstance(data, dict) and data.get("changeUuid"):
|
|
968
|
+
return data
|
|
969
|
+
except (OSError, json.JSONDecodeError):
|
|
970
|
+
pass
|
|
971
|
+
# Generate new identity
|
|
972
|
+
identity = {
|
|
973
|
+
"schemaVersion": 1,
|
|
974
|
+
"changeUuid": str(uuid.uuid4()),
|
|
975
|
+
"changeName": change_dir.name,
|
|
976
|
+
"createdAt": now_iso(),
|
|
977
|
+
}
|
|
978
|
+
identity_path.parent.mkdir(parents=True, exist_ok=True)
|
|
979
|
+
identity_path.write_text(
|
|
980
|
+
json.dumps(identity, ensure_ascii=False, indent=2) + "\n",
|
|
981
|
+
encoding="utf-8",
|
|
982
|
+
newline="\n",
|
|
983
|
+
)
|
|
984
|
+
return identity
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def cmd_rename(args: argparse.Namespace) -> int:
|
|
988
|
+
"""Rename a change atomically: directory, pointers, worktree, identity (retro §5.5).
|
|
989
|
+
|
|
990
|
+
Appends a change.rename event; does not rewrite history.
|
|
991
|
+
"""
|
|
992
|
+
project = resolve_main_project_root()
|
|
993
|
+
changes_root = project / ".harness" / "changes"
|
|
994
|
+
old_dir = changes_root / args.change
|
|
995
|
+
new_dir = changes_root / args.to
|
|
996
|
+
|
|
997
|
+
if not old_dir.is_dir():
|
|
998
|
+
return emit_error(
|
|
999
|
+
"CHANGE_NOT_FOUND",
|
|
1000
|
+
f"change directory not found: {old_dir}",
|
|
1001
|
+
as_json=bool(args.json),
|
|
1002
|
+
)
|
|
1003
|
+
if new_dir.exists():
|
|
1004
|
+
return emit_error(
|
|
1005
|
+
"CHANGE_ALREADY_EXISTS",
|
|
1006
|
+
f"target change directory already exists: {new_dir}",
|
|
1007
|
+
as_json=bool(args.json),
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# Ensure identity exists before rename
|
|
1011
|
+
identity = _ensure_change_identity(old_dir)
|
|
1012
|
+
old_uuid = identity.get("changeUuid", "")
|
|
1013
|
+
|
|
1014
|
+
# Rename directory
|
|
1015
|
+
import shutil
|
|
1016
|
+
shutil.move(str(old_dir), str(new_dir))
|
|
1017
|
+
|
|
1018
|
+
# Update knowledge-context.json.changeId if present
|
|
1019
|
+
kc_path = new_dir / "meta" / "knowledge-context.json"
|
|
1020
|
+
if kc_path.is_file():
|
|
1021
|
+
try:
|
|
1022
|
+
kc = json.loads(kc_path.read_text(encoding="utf-8-sig"))
|
|
1023
|
+
if isinstance(kc, dict):
|
|
1024
|
+
kc["changeId"] = args.to
|
|
1025
|
+
kc["changeUuid"] = old_uuid
|
|
1026
|
+
kc_path.write_text(
|
|
1027
|
+
json.dumps(kc, ensure_ascii=False, indent=2) + "\n",
|
|
1028
|
+
encoding="utf-8",
|
|
1029
|
+
newline="\n",
|
|
1030
|
+
)
|
|
1031
|
+
except (OSError, json.JSONDecodeError):
|
|
1032
|
+
pass
|
|
1033
|
+
|
|
1034
|
+
# Update worktree.json path/branch if present
|
|
1035
|
+
wt_path = new_dir / "meta" / "worktree.json"
|
|
1036
|
+
if wt_path.is_file():
|
|
1037
|
+
try:
|
|
1038
|
+
wt = json.loads(wt_path.read_text(encoding="utf-8-sig"))
|
|
1039
|
+
if isinstance(wt, dict):
|
|
1040
|
+
old_name = args.change
|
|
1041
|
+
new_name = args.to
|
|
1042
|
+
# Update path and branch if they contain old name
|
|
1043
|
+
if isinstance(wt.get("path"), str) and old_name in wt["path"]:
|
|
1044
|
+
wt["path"] = wt["path"].replace(old_name, new_name)
|
|
1045
|
+
if isinstance(wt.get("branch"), str) and old_name in wt["branch"]:
|
|
1046
|
+
wt["branch"] = wt["branch"].replace(old_name, new_name)
|
|
1047
|
+
wt_path.write_text(
|
|
1048
|
+
json.dumps(wt, ensure_ascii=False, indent=2) + "\n",
|
|
1049
|
+
encoding="utf-8",
|
|
1050
|
+
newline="\n",
|
|
1051
|
+
)
|
|
1052
|
+
except (OSError, json.JSONDecodeError):
|
|
1053
|
+
pass
|
|
1054
|
+
|
|
1055
|
+
# Update change-identity.json.changeName
|
|
1056
|
+
identity_path = new_dir / "meta" / "change-identity.json"
|
|
1057
|
+
if identity_path.is_file():
|
|
1058
|
+
try:
|
|
1059
|
+
ident = json.loads(identity_path.read_text(encoding="utf-8-sig"))
|
|
1060
|
+
if isinstance(ident, dict):
|
|
1061
|
+
ident["changeName"] = args.to
|
|
1062
|
+
ident["renamedFrom"] = args.change
|
|
1063
|
+
identity_path.write_text(
|
|
1064
|
+
json.dumps(ident, ensure_ascii=False, indent=2) + "\n",
|
|
1065
|
+
encoding="utf-8",
|
|
1066
|
+
newline="\n",
|
|
1067
|
+
)
|
|
1068
|
+
except (OSError, json.JSONDecodeError):
|
|
1069
|
+
pass
|
|
1070
|
+
|
|
1071
|
+
# Append change.rename event (does not rewrite history)
|
|
1072
|
+
try:
|
|
1073
|
+
import harness_events
|
|
1074
|
+
harness_events.append_event(
|
|
1075
|
+
new_dir,
|
|
1076
|
+
phase="plan",
|
|
1077
|
+
type_="change.rename",
|
|
1078
|
+
renamed_from=args.change,
|
|
1079
|
+
renamed_to=args.to,
|
|
1080
|
+
change_uuid=old_uuid,
|
|
1081
|
+
note=f"change renamed from {args.change} to {args.to}",
|
|
1082
|
+
)
|
|
1083
|
+
except Exception:
|
|
1084
|
+
pass # event append failure should not block rename
|
|
1085
|
+
|
|
1086
|
+
payload = {
|
|
1087
|
+
"ok": True,
|
|
1088
|
+
"code": "RENAMED",
|
|
1089
|
+
"changeUuid": old_uuid,
|
|
1090
|
+
"renamedFrom": args.change,
|
|
1091
|
+
"renamedTo": args.to,
|
|
1092
|
+
"changeDir": str(new_dir),
|
|
1093
|
+
}
|
|
1094
|
+
emit(payload, as_json=bool(args.json))
|
|
1095
|
+
return 0
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def cmd_ensure_identity(args: argparse.Namespace) -> int:
|
|
1099
|
+
"""Ensure meta/change-identity.json exists with a stable UUID (retro §5.5)."""
|
|
1100
|
+
project = resolve_main_project_root()
|
|
1101
|
+
change_dir = project / ".harness" / "changes" / args.change
|
|
1102
|
+
if not change_dir.is_dir():
|
|
1103
|
+
return emit_error(
|
|
1104
|
+
"CHANGE_NOT_FOUND",
|
|
1105
|
+
f"change directory not found: {change_dir}",
|
|
1106
|
+
as_json=bool(args.json),
|
|
1107
|
+
)
|
|
1108
|
+
identity = _ensure_change_identity(change_dir)
|
|
1109
|
+
emit(identity, as_json=bool(args.json))
|
|
1110
|
+
return 0
|
|
1111
|
+
|
|
1112
|
+
|
|
830
1113
|
def build_parser() -> argparse.ArgumentParser:
|
|
831
1114
|
parser = argparse.ArgumentParser(prog="harness_change.py")
|
|
832
1115
|
parser.add_argument("--json", action="store_true")
|
|
@@ -869,6 +1152,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
869
1152
|
p_port_release = sub.add_parser("release-port", parents=[shared])
|
|
870
1153
|
p_port_release.add_argument("--change", required=True)
|
|
871
1154
|
p_port_release.add_argument("--run-id", required=True)
|
|
1155
|
+
p_port_release.add_argument("--port", type=int, default=None)
|
|
1156
|
+
p_port_release.add_argument("--lease-id", default=None)
|
|
872
1157
|
p_port_release.set_defaults(func=cmd_release_port)
|
|
873
1158
|
|
|
874
1159
|
p_lock = sub.add_parser("integration-lock", parents=[shared])
|
|
@@ -877,6 +1162,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
877
1162
|
p_lock.add_argument("--ttl-seconds", type=int, default=3600)
|
|
878
1163
|
p_lock.set_defaults(func=cmd_integration_lock)
|
|
879
1164
|
|
|
1165
|
+
p_rename = sub.add_parser("rename", parents=[shared])
|
|
1166
|
+
p_rename.add_argument("--change", required=True)
|
|
1167
|
+
p_rename.add_argument("--to", required=True)
|
|
1168
|
+
p_rename.set_defaults(func=cmd_rename)
|
|
1169
|
+
|
|
1170
|
+
p_identity = sub.add_parser("ensure-identity", parents=[shared])
|
|
1171
|
+
p_identity.add_argument("--change", required=True)
|
|
1172
|
+
p_identity.set_defaults(func=cmd_ensure_identity)
|
|
1173
|
+
|
|
880
1174
|
return parser
|
|
881
1175
|
|
|
882
1176
|
|