@hunter-harness/workflow-harness 0.2.6 → 0.2.8
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/contracts/workflow-policy.json +169 -0
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +9 -6
- package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
- package/harness/bundles/general/claude-code/harness-run/reference.md +5 -2
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +7 -5
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/claude-code/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +41 -16
- package/harness/bundles/general/claude-code/scripts/harness_change.py +872 -0
- package/harness/bundles/general/claude-code/scripts/harness_events.py +26 -0
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +1038 -0
- package/harness/bundles/general/claude-code/scripts/harness_service.py +34 -1
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +160 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +169 -0
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +9 -6
- package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
- package/harness/bundles/general/codebuddy/harness-run/reference.md +5 -2
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +7 -5
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/codebuddy/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +41 -16
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +872 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +26 -0
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1038 -0
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +34 -1
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +160 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/workflow-policy.json +169 -0
- package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +9 -6
- package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
- package/harness/bundles/general/codex/harness-run/reference.md +5 -2
- package/harness/bundles/general/codex/harness-submit/SKILL.md +7 -5
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/codex/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +41 -16
- package/harness/bundles/general/codex/scripts/harness_change.py +872 -0
- package/harness/bundles/general/codex/scripts/harness_events.py +26 -0
- package/harness/bundles/general/codex/scripts/harness_gate.py +1038 -0
- package/harness/bundles/general/codex/scripts/harness_service.py +34 -1
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +160 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +169 -0
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +9 -6
- package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
- package/harness/bundles/general/cursor/harness-run/reference.md +5 -2
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +7 -5
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/cursor/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +41 -16
- package/harness/bundles/general/cursor/scripts/harness_change.py +872 -0
- package/harness/bundles/general/cursor/scripts/harness_events.py +26 -0
- package/harness/bundles/general/cursor/scripts/harness_gate.py +1038 -0
- package/harness/bundles/general/cursor/scripts/harness_service.py +34 -1
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +160 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +169 -0
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +7 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +6 -3
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +9 -6
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +7 -5
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/claude-code/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +41 -16
- package/harness/bundles/java/claude-code/scripts/harness_change.py +872 -0
- package/harness/bundles/java/claude-code/scripts/harness_events.py +26 -0
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +1038 -0
- package/harness/bundles/java/claude-code/scripts/harness_service.py +34 -1
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +160 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +169 -0
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +7 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +6 -3
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +9 -6
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +7 -5
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/codebuddy/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +41 -16
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +872 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +26 -0
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1038 -0
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +34 -1
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +160 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/workflow-policy.json +169 -0
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +7 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +6 -3
- package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +9 -6
- package/harness/bundles/java/codex/harness-submit/SKILL.md +7 -5
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/codex/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +41 -16
- package/harness/bundles/java/codex/scripts/harness_change.py +872 -0
- package/harness/bundles/java/codex/scripts/harness_events.py +26 -0
- package/harness/bundles/java/codex/scripts/harness_gate.py +1038 -0
- package/harness/bundles/java/codex/scripts/harness_service.py +34 -1
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +160 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +169 -0
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +7 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -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-ingest/scripts/harness_knowledge.py +26 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +6 -3
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +9 -6
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +7 -5
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/cursor/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +41 -16
- package/harness/bundles/java/cursor/scripts/harness_change.py +872 -0
- package/harness/bundles/java/cursor/scripts/harness_events.py +26 -0
- package/harness/bundles/java/cursor/scripts/harness_gate.py +1038 -0
- package/harness/bundles/java/cursor/scripts/harness_service.py +34 -1
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +210 -14
- package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +160 -0
- package/harness/manifests/general/claude-code.json +40 -20
- package/harness/manifests/general/codebuddy.json +40 -20
- package/harness/manifests/general/codex.json +40 -20
- package/harness/manifests/general/cursor.json +40 -20
- package/harness/manifests/java/claude-code.json +40 -20
- package/harness/manifests/java/codebuddy.json +40 -20
- package/harness/manifests/java/codex.json +40 -20
- package/harness/manifests/java/cursor.json +40 -20
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -194,6 +194,8 @@ def resolve_service_start(
|
|
|
194
194
|
change_name: str | None = None,
|
|
195
195
|
worktree_root: Path | None = None,
|
|
196
196
|
overlay_path: str | None = None,
|
|
197
|
+
leased_port: int | None = None,
|
|
198
|
+
lease_owner: str | None = None,
|
|
197
199
|
) -> dict[str, Any]:
|
|
198
200
|
"""从模板 serviceStart + runtime context 生成 resolved serviceStart。
|
|
199
201
|
|
|
@@ -220,6 +222,16 @@ def resolve_service_start(
|
|
|
220
222
|
# runtime profile 注入:持久 profile 留空时用 change_name;非空则保留
|
|
221
223
|
if not str(resolved.get("profile") or "").strip():
|
|
222
224
|
resolved["profile"] = change_name or "local-dev"
|
|
225
|
+
if leased_port is not None:
|
|
226
|
+
if not lease_owner:
|
|
227
|
+
raise ValueError("lease owner is required when leased port is provided")
|
|
228
|
+
resolved["leasedPort"] = leased_port
|
|
229
|
+
resolved["leaseOwner"] = lease_owner
|
|
230
|
+
resolved["port"] = leased_port
|
|
231
|
+
for field in ("command", "healthUrl", "healthFile", "overlayPath"):
|
|
232
|
+
value = resolved.get(field)
|
|
233
|
+
if isinstance(value, str):
|
|
234
|
+
resolved[field] = value.replace("{leasedPort}", str(leased_port))
|
|
223
235
|
# worktree_root 预留给未来相对 overlay 路径解析(spec §3.6 state snapshot);
|
|
224
236
|
# 不写入返回值,避免污染 session。
|
|
225
237
|
_ = worktree_root
|
|
@@ -914,7 +926,7 @@ def build_session(
|
|
|
914
926
|
) -> dict[str, Any]:
|
|
915
927
|
profile_name = service_start.get("profile") or "local-dev"
|
|
916
928
|
overlay = service_start.get("overlayPath") or ""
|
|
917
|
-
|
|
929
|
+
session = {
|
|
918
930
|
"pid": pid,
|
|
919
931
|
"startedBy": "AI",
|
|
920
932
|
"moduleInputsHash": module_inputs_hash,
|
|
@@ -925,6 +937,10 @@ def build_session(
|
|
|
925
937
|
"startedAt": started_at or now_iso(),
|
|
926
938
|
"command": command,
|
|
927
939
|
}
|
|
940
|
+
if isinstance(service_start.get("leasedPort"), int):
|
|
941
|
+
session["leasedPort"] = service_start["leasedPort"]
|
|
942
|
+
session["leaseOwner"] = service_start.get("leaseOwner")
|
|
943
|
+
return session
|
|
928
944
|
|
|
929
945
|
|
|
930
946
|
def compute_module_hash(
|
|
@@ -1015,12 +1031,16 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1015
1031
|
# 含 worktree/change 陈旧持久值时拒绝(修复输入端陈旧 profile)。
|
|
1016
1032
|
change_name = getattr(args, "change_name", None) or change_dir.name
|
|
1017
1033
|
overlay = getattr(args, "overlay", None)
|
|
1034
|
+
leased_port = getattr(args, "leased_port", None)
|
|
1035
|
+
lease_owner = str(getattr(args, "lease_owner", None) or "").strip() or None
|
|
1018
1036
|
try:
|
|
1019
1037
|
service_start = resolve_service_start(
|
|
1020
1038
|
profile,
|
|
1021
1039
|
change_name=change_name,
|
|
1022
1040
|
worktree_root=project,
|
|
1023
1041
|
overlay_path=overlay,
|
|
1042
|
+
leased_port=leased_port,
|
|
1043
|
+
lease_owner=lease_owner,
|
|
1024
1044
|
)
|
|
1025
1045
|
except ValueError as exc:
|
|
1026
1046
|
return emit_error(str(exc), as_json=as_json)
|
|
@@ -1084,6 +1104,8 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1084
1104
|
and session.get("startCommandHash") == current_cmd_hash
|
|
1085
1105
|
and session.get("profile") == current_profile
|
|
1086
1106
|
and session.get("overlayPath") == current_overlay
|
|
1107
|
+
and session.get("leasedPort") == service_start.get("leasedPort")
|
|
1108
|
+
and session.get("leaseOwner") == service_start.get("leaseOwner")
|
|
1087
1109
|
)
|
|
1088
1110
|
if fingerprint_match:
|
|
1089
1111
|
payload = {
|
|
@@ -1389,6 +1411,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1389
1411
|
default=None,
|
|
1390
1412
|
help="runtime overlay path injected into resolved serviceStart",
|
|
1391
1413
|
)
|
|
1414
|
+
p_ensure.add_argument(
|
|
1415
|
+
"--leased-port",
|
|
1416
|
+
type=int,
|
|
1417
|
+
default=None,
|
|
1418
|
+
help="port allocated by harness_change.py lease-port",
|
|
1419
|
+
)
|
|
1420
|
+
p_ensure.add_argument(
|
|
1421
|
+
"--lease-owner",
|
|
1422
|
+
default=None,
|
|
1423
|
+
help="run id that owns --leased-port",
|
|
1424
|
+
)
|
|
1392
1425
|
p_ensure.add_argument("--json", action="store_true")
|
|
1393
1426
|
p_ensure.set_defaults(func=cmd_ensure)
|
|
1394
1427
|
|
|
@@ -24,6 +24,7 @@ from typing import Any
|
|
|
24
24
|
SCHEMA_VERSION = 1
|
|
25
25
|
MODE = "force-track-touched"
|
|
26
26
|
MANIFEST_REL = Path("evidence") / "test-tracking.json"
|
|
27
|
+
SNAPSHOT_REL = Path("evidence") / "test-guard-snapshot.json"
|
|
27
28
|
PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
|
|
28
29
|
REASONS = ("tdd-created", "stale-test-repair", "test-updated")
|
|
29
30
|
|
|
@@ -125,7 +126,7 @@ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
|
|
|
125
126
|
while descriptor is None:
|
|
126
127
|
try:
|
|
127
128
|
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
128
|
-
except FileExistsError as exc:
|
|
129
|
+
except (FileExistsError, PermissionError) as exc:
|
|
129
130
|
if time.monotonic() >= deadline:
|
|
130
131
|
raise LockUnavailable(str(path)) from exc
|
|
131
132
|
time.sleep(0.01)
|
|
@@ -137,22 +138,41 @@ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
|
|
|
137
138
|
path.unlink(missing_ok=True)
|
|
138
139
|
|
|
139
140
|
|
|
141
|
+
def _state_project_root(project: Path) -> Path:
|
|
142
|
+
result = _git(project, "rev-parse", "--git-common-dir")
|
|
143
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
144
|
+
return project
|
|
145
|
+
common = Path(result.stdout.strip())
|
|
146
|
+
if not common.is_absolute():
|
|
147
|
+
common = project / common
|
|
148
|
+
resolved = common.resolve()
|
|
149
|
+
return resolved.parent if resolved.name == ".git" else project
|
|
150
|
+
|
|
151
|
+
|
|
140
152
|
def _change_dir(project: Path, change_dir: Path | str) -> Path | None:
|
|
153
|
+
state_project = _state_project_root(project)
|
|
141
154
|
candidate = Path(change_dir)
|
|
142
155
|
if not candidate.is_absolute():
|
|
143
|
-
candidate =
|
|
156
|
+
candidate = (
|
|
157
|
+
state_project / candidate
|
|
158
|
+
if candidate.parts[:2] == (".harness", "changes")
|
|
159
|
+
else project / candidate
|
|
160
|
+
)
|
|
144
161
|
resolved = candidate.resolve()
|
|
145
|
-
|
|
162
|
+
allowed_roots = {
|
|
163
|
+
(project / ".harness" / "changes").resolve(),
|
|
164
|
+
(state_project / ".harness" / "changes").resolve(),
|
|
165
|
+
}
|
|
166
|
+
return resolved if any(_inside(resolved, root) for root in allowed_roots) else None
|
|
146
167
|
|
|
147
168
|
|
|
148
|
-
def _manifest_path(
|
|
169
|
+
def _manifest_path(change_root: Path) -> Path | None:
|
|
149
170
|
evidence = change_root / MANIFEST_REL.parent
|
|
150
171
|
manifest = change_root / MANIFEST_REL
|
|
151
172
|
expected_evidence = evidence.absolute()
|
|
152
173
|
resolved_evidence = evidence.resolve()
|
|
153
174
|
if (
|
|
154
|
-
not _inside(change_root.resolve()
|
|
155
|
-
or not _inside(resolved_evidence, project)
|
|
175
|
+
not _inside(resolved_evidence, change_root.resolve())
|
|
156
176
|
or os.path.normcase(str(resolved_evidence))
|
|
157
177
|
!= os.path.normcase(str(expected_evidence))
|
|
158
178
|
):
|
|
@@ -160,9 +180,12 @@ def _manifest_path(project: Path, change_root: Path) -> Path | None:
|
|
|
160
180
|
return manifest
|
|
161
181
|
|
|
162
182
|
|
|
163
|
-
def _manifest_target_inside(
|
|
183
|
+
def _manifest_target_inside(change_root: Path, manifest: Path) -> bool:
|
|
164
184
|
resolved = manifest.resolve()
|
|
165
|
-
return
|
|
185
|
+
return (
|
|
186
|
+
_inside(resolved, change_root.resolve())
|
|
187
|
+
and _inside(resolved, manifest.parent.absolute())
|
|
188
|
+
)
|
|
166
189
|
|
|
167
190
|
|
|
168
191
|
def _profile_config(project: Path) -> tuple[list[str], list[str]] | None:
|
|
@@ -325,13 +348,13 @@ def record(
|
|
|
325
348
|
assert path is not None and rel is not None
|
|
326
349
|
validated.append((path, rel))
|
|
327
350
|
|
|
328
|
-
manifest_path = _manifest_path(
|
|
351
|
+
manifest_path = _manifest_path(change_root)
|
|
329
352
|
if manifest_path is None:
|
|
330
353
|
return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
|
|
331
354
|
lock_path = manifest_path.with_name(manifest_path.name + ".lock")
|
|
332
355
|
try:
|
|
333
356
|
with _exclusive_lock(lock_path, wait_seconds=5.0):
|
|
334
|
-
if not _manifest_target_inside(
|
|
357
|
+
if not _manifest_target_inside(change_root, manifest_path):
|
|
335
358
|
return _result(
|
|
336
359
|
False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", []
|
|
337
360
|
)
|
|
@@ -378,10 +401,10 @@ def record(
|
|
|
378
401
|
|
|
379
402
|
|
|
380
403
|
def _stage_locked(
|
|
381
|
-
project_root: Path, manifest_path: Path, index_path: Path
|
|
404
|
+
project_root: Path, change_root: Path, manifest_path: Path, index_path: Path
|
|
382
405
|
) -> dict[str, Any]:
|
|
383
406
|
action = "stage"
|
|
384
|
-
if not _manifest_target_inside(
|
|
407
|
+
if not _manifest_target_inside(change_root, manifest_path):
|
|
385
408
|
return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
|
|
386
409
|
try:
|
|
387
410
|
manifest = _read_json(manifest_path)
|
|
@@ -444,13 +467,164 @@ def _stage_locked(
|
|
|
444
467
|
temp_index.unlink(missing_ok=True)
|
|
445
468
|
|
|
446
469
|
|
|
470
|
+
def _glob_files_for_pattern(base: Path, pattern: str) -> list[Path]:
|
|
471
|
+
normalized = pattern.replace("\\", "/")
|
|
472
|
+
if normalized.endswith("/**"):
|
|
473
|
+
prefix = normalized[:-3].rstrip("/")
|
|
474
|
+
root = base / prefix
|
|
475
|
+
if not root.is_dir():
|
|
476
|
+
return []
|
|
477
|
+
return [path for path in root.rglob("*") if path.is_file()]
|
|
478
|
+
return [path for path in base.glob(normalized) if path.is_file()]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _enumerate_allowed_test_files(project_root: Path) -> dict[str, str]:
|
|
482
|
+
"""Map repo-relative test path -> sha256 for all allowed existing files."""
|
|
483
|
+
found: dict[str, str] = {}
|
|
484
|
+
config = _profile_config(project_root)
|
|
485
|
+
if config is None:
|
|
486
|
+
patterns = ["test/**", "tests/**", "src/test/**"]
|
|
487
|
+
else:
|
|
488
|
+
patterns, _excluded = config
|
|
489
|
+
base = project_root.resolve()
|
|
490
|
+
seen: set[str] = set()
|
|
491
|
+
for pattern in patterns:
|
|
492
|
+
for match in _glob_files_for_pattern(base, pattern):
|
|
493
|
+
resolved = match.resolve()
|
|
494
|
+
if not _inside(resolved, base):
|
|
495
|
+
continue
|
|
496
|
+
rel = resolved.relative_to(base).as_posix()
|
|
497
|
+
if rel in seen or not _allowed_test_path(project_root, rel):
|
|
498
|
+
continue
|
|
499
|
+
seen.add(rel)
|
|
500
|
+
found[rel] = _sha256(resolved)
|
|
501
|
+
return found
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
505
|
+
action = "begin"
|
|
506
|
+
project_root = Path(project).resolve()
|
|
507
|
+
change_root = _change_dir(project_root, change_dir)
|
|
508
|
+
if change_root is None:
|
|
509
|
+
return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
|
|
510
|
+
manifest_path = _manifest_path(change_root)
|
|
511
|
+
if manifest_path is None:
|
|
512
|
+
return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
|
|
513
|
+
snapshot_path = change_root / SNAPSHOT_REL
|
|
514
|
+
if not _manifest_target_inside(change_root, snapshot_path):
|
|
515
|
+
return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
|
|
516
|
+
|
|
517
|
+
files = _enumerate_allowed_test_files(project_root)
|
|
518
|
+
snapshot = {
|
|
519
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
520
|
+
"mode": MODE,
|
|
521
|
+
"projectRoot": str(project_root),
|
|
522
|
+
"capturedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
523
|
+
"files": [
|
|
524
|
+
{"path": rel, "sha256": digest, "ignored": _is_ignored(project_root, rel)}
|
|
525
|
+
for rel, digest in sorted(files.items())
|
|
526
|
+
],
|
|
527
|
+
}
|
|
528
|
+
_write_json(snapshot_path, snapshot)
|
|
529
|
+
return _result(
|
|
530
|
+
True,
|
|
531
|
+
action,
|
|
532
|
+
"SNAPSHOT_CAPTURED",
|
|
533
|
+
list(files),
|
|
534
|
+
snapshotPath=str(snapshot_path),
|
|
535
|
+
fileCount=len(files),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
540
|
+
action = "close"
|
|
541
|
+
project_root = Path(project).resolve()
|
|
542
|
+
change_root = _change_dir(project_root, change_dir)
|
|
543
|
+
if change_root is None:
|
|
544
|
+
return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
|
|
545
|
+
snapshot_path = change_root / SNAPSHOT_REL
|
|
546
|
+
if not snapshot_path.is_file():
|
|
547
|
+
return _result(False, action, "SNAPSHOT_MISSING", [])
|
|
548
|
+
try:
|
|
549
|
+
snapshot = _read_json(snapshot_path)
|
|
550
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
551
|
+
return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
|
|
552
|
+
if (
|
|
553
|
+
not isinstance(snapshot, dict)
|
|
554
|
+
or snapshot.get("schemaVersion") != SCHEMA_VERSION
|
|
555
|
+
or snapshot.get("projectRoot") != str(project_root)
|
|
556
|
+
):
|
|
557
|
+
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
558
|
+
|
|
559
|
+
before_entries = snapshot.get("files")
|
|
560
|
+
if not isinstance(before_entries, list):
|
|
561
|
+
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
562
|
+
before: dict[str, dict[str, Any]] = {}
|
|
563
|
+
for item in before_entries:
|
|
564
|
+
if not isinstance(item, dict):
|
|
565
|
+
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
566
|
+
rel = item.get("path")
|
|
567
|
+
digest = item.get("sha256")
|
|
568
|
+
if not isinstance(rel, str) or not rel or not isinstance(digest, str):
|
|
569
|
+
return _result(False, action, "SNAPSHOT_INVALID", [])
|
|
570
|
+
path, normalized, error = _validate_file(project_root, rel)
|
|
571
|
+
if error:
|
|
572
|
+
return _result(False, action, error, [rel])
|
|
573
|
+
if normalized != rel or path is None:
|
|
574
|
+
return _result(False, action, "PATH_ESCAPE", [rel])
|
|
575
|
+
before[rel] = item
|
|
576
|
+
|
|
577
|
+
current = _enumerate_allowed_test_files(project_root)
|
|
578
|
+
for rel in current:
|
|
579
|
+
path, normalized, error = _validate_file(project_root, rel)
|
|
580
|
+
if error:
|
|
581
|
+
return _result(False, action, error, [rel])
|
|
582
|
+
if normalized != rel or path is None:
|
|
583
|
+
return _result(False, action, "PATH_ESCAPE", [rel])
|
|
584
|
+
|
|
585
|
+
touched: list[tuple[str, str]] = []
|
|
586
|
+
for rel, digest in current.items():
|
|
587
|
+
if rel not in before:
|
|
588
|
+
touched.append((rel, "tdd-created"))
|
|
589
|
+
continue
|
|
590
|
+
if before[rel]["sha256"] != digest:
|
|
591
|
+
touched.append((rel, "test-updated"))
|
|
592
|
+
|
|
593
|
+
recorded: list[str] = []
|
|
594
|
+
for rel, reason in touched:
|
|
595
|
+
result = record(project_root, change_root, [str(project_root / rel)], reason)
|
|
596
|
+
if not result.get("ok"):
|
|
597
|
+
code = result.get("code", "RECORD_FAILED")
|
|
598
|
+
if code == "TEST_PATH_NOT_ALLOWED":
|
|
599
|
+
return _result(False, action, "UNCLASSIFIABLE_TEST", [rel])
|
|
600
|
+
return result
|
|
601
|
+
recorded.append(rel)
|
|
602
|
+
|
|
603
|
+
return _result(
|
|
604
|
+
True,
|
|
605
|
+
action,
|
|
606
|
+
"CLOSED",
|
|
607
|
+
recorded,
|
|
608
|
+
recordedCount=len(recorded),
|
|
609
|
+
unchangedPreexisting=len(before) - sum(1 for rel, _ in touched if rel in before),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def mark(
|
|
614
|
+
project: Path | str,
|
|
615
|
+
change_dir: Path | str,
|
|
616
|
+
files: list[str],
|
|
617
|
+
) -> dict[str, Any]:
|
|
618
|
+
return record(project, change_dir, files, "stale-test-repair")
|
|
619
|
+
|
|
620
|
+
|
|
447
621
|
def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
448
622
|
action = "stage"
|
|
449
623
|
project_root = Path(project).resolve()
|
|
450
624
|
change_root = _change_dir(project_root, change_dir)
|
|
451
625
|
if change_root is None:
|
|
452
626
|
return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
|
|
453
|
-
manifest_path = _manifest_path(
|
|
627
|
+
manifest_path = _manifest_path(change_root)
|
|
454
628
|
if manifest_path is None:
|
|
455
629
|
return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
|
|
456
630
|
|
|
@@ -468,7 +642,9 @@ def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
|
|
|
468
642
|
manifest_lock = manifest_path.with_name(manifest_path.name + ".lock")
|
|
469
643
|
try:
|
|
470
644
|
with _exclusive_lock(manifest_lock, wait_seconds=0.0):
|
|
471
|
-
return _stage_locked(
|
|
645
|
+
return _stage_locked(
|
|
646
|
+
project_root, change_root, manifest_path, index_path
|
|
647
|
+
)
|
|
472
648
|
except LockUnavailable:
|
|
473
649
|
return _result(False, action, "MANIFEST_LOCKED", [])
|
|
474
650
|
except LockUnavailable:
|
|
@@ -488,10 +664,30 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
488
664
|
stage_parser.add_argument("--project", required=True)
|
|
489
665
|
stage_parser.add_argument("--change-dir", required=True)
|
|
490
666
|
stage_parser.add_argument("--json", action="store_true")
|
|
667
|
+
begin_parser = sub.add_parser("begin")
|
|
668
|
+
begin_parser.add_argument("--project", required=True)
|
|
669
|
+
begin_parser.add_argument("--change-dir", required=True)
|
|
670
|
+
begin_parser.add_argument("--json", action="store_true")
|
|
671
|
+
close_parser = sub.add_parser("close")
|
|
672
|
+
close_parser.add_argument("--project", required=True)
|
|
673
|
+
close_parser.add_argument("--change-dir", required=True)
|
|
674
|
+
close_parser.add_argument("--json", action="store_true")
|
|
675
|
+
mark_parser = sub.add_parser("mark")
|
|
676
|
+
mark_parser.add_argument("--project", required=True)
|
|
677
|
+
mark_parser.add_argument("--change-dir", required=True)
|
|
678
|
+
mark_parser.add_argument("--files", required=True)
|
|
679
|
+
mark_parser.add_argument("--json", action="store_true")
|
|
491
680
|
args = parser.parse_args(argv)
|
|
492
681
|
if args.action == "record":
|
|
493
682
|
files = [item.strip() for item in args.files.split(",") if item.strip()]
|
|
494
683
|
result = record(args.project, args.change_dir, files, args.reason)
|
|
684
|
+
elif args.action == "begin":
|
|
685
|
+
result = begin(args.project, args.change_dir)
|
|
686
|
+
elif args.action == "close":
|
|
687
|
+
result = close(args.project, args.change_dir)
|
|
688
|
+
elif args.action == "mark":
|
|
689
|
+
files = [item.strip() for item in args.files.split(",") if item.strip()]
|
|
690
|
+
result = mark(args.project, args.change_dir, files)
|
|
495
691
|
else:
|
|
496
692
|
result = stage(args.project, args.change_dir)
|
|
497
693
|
print(json.dumps(result, ensure_ascii=False, indent=2 if args.json else None))
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Strict loader for harness/contracts/workflow-policy.json.
|
|
3
|
+
|
|
4
|
+
Unknown fields at any depth fail validation. Python 3.10+, stdlib only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
POLICY_REL = Path("harness") / "contracts" / "workflow-policy.json"
|
|
14
|
+
|
|
15
|
+
TOP_LEVEL_KEYS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"schemaVersion",
|
|
18
|
+
"riskTiers",
|
|
19
|
+
"skills",
|
|
20
|
+
"requiredArtifacts",
|
|
21
|
+
"requiredValidations",
|
|
22
|
+
"conditionalStages",
|
|
23
|
+
"interactionWhitelist",
|
|
24
|
+
"checkpointRules",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
RISK_TIER_KEYS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"description",
|
|
31
|
+
"defaultPhases",
|
|
32
|
+
"requiredValidations",
|
|
33
|
+
"conditionalStages",
|
|
34
|
+
"upgradeTriggers",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SKILL_KEYS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"phase",
|
|
41
|
+
"inputs",
|
|
42
|
+
"artifacts",
|
|
43
|
+
"events",
|
|
44
|
+
"allowedInteractions",
|
|
45
|
+
"capabilities",
|
|
46
|
+
"profiles",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
CONDITIONAL_STAGE_KEYS = frozenset({"tiers", "signals"})
|
|
51
|
+
CHECKPOINT_RULE_KEYS = frozenset(
|
|
52
|
+
{
|
|
53
|
+
"afterTasks",
|
|
54
|
+
"beforeTasks",
|
|
55
|
+
"blocking",
|
|
56
|
+
"requiredReport",
|
|
57
|
+
"reviewerTool",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PolicyValidationError(ValueError):
|
|
63
|
+
"""Raised when workflow-policy.json contains unknown or invalid fields."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _reject_unknown(data: dict[str, Any], allowed: frozenset[str], path: str) -> None:
|
|
67
|
+
unknown = sorted(set(data) - allowed)
|
|
68
|
+
if unknown:
|
|
69
|
+
raise PolicyValidationError(
|
|
70
|
+
f"unknown field(s) at {path}: {', '.join(unknown)}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _require_type(value: Any, expected: type | tuple[type, ...], path: str) -> None:
|
|
75
|
+
if not isinstance(value, expected):
|
|
76
|
+
names = (
|
|
77
|
+
expected.__name__
|
|
78
|
+
if isinstance(expected, type)
|
|
79
|
+
else "|".join(t.__name__ for t in expected)
|
|
80
|
+
)
|
|
81
|
+
raise PolicyValidationError(f"{path} must be {names}, got {type(value).__name__}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_policy(data: Any) -> dict[str, Any]:
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise PolicyValidationError("workflow policy root must be an object")
|
|
87
|
+
_reject_unknown(data, TOP_LEVEL_KEYS, "root")
|
|
88
|
+
_require_type(data.get("schemaVersion"), int, "schemaVersion")
|
|
89
|
+
if data["schemaVersion"] != 1:
|
|
90
|
+
raise PolicyValidationError("unsupported schemaVersion")
|
|
91
|
+
|
|
92
|
+
risk_tiers = data["riskTiers"]
|
|
93
|
+
_require_type(risk_tiers, dict, "riskTiers")
|
|
94
|
+
for tier_name, tier in risk_tiers.items():
|
|
95
|
+
path = f"riskTiers.{tier_name}"
|
|
96
|
+
_require_type(tier, dict, path)
|
|
97
|
+
_reject_unknown(tier, RISK_TIER_KEYS, path)
|
|
98
|
+
_require_type(tier["description"], str, f"{path}.description")
|
|
99
|
+
_require_type(tier["defaultPhases"], list, f"{path}.defaultPhases")
|
|
100
|
+
_require_type(tier["requiredValidations"], list, f"{path}.requiredValidations")
|
|
101
|
+
_require_type(tier["conditionalStages"], list, f"{path}.conditionalStages")
|
|
102
|
+
_require_type(tier["upgradeTriggers"], list, f"{path}.upgradeTriggers")
|
|
103
|
+
|
|
104
|
+
skills = data["skills"]
|
|
105
|
+
_require_type(skills, dict, "skills")
|
|
106
|
+
for skill_name, skill in skills.items():
|
|
107
|
+
path = f"skills.{skill_name}"
|
|
108
|
+
_require_type(skill, dict, path)
|
|
109
|
+
_reject_unknown(skill, SKILL_KEYS, path)
|
|
110
|
+
_require_type(skill["phase"], str, f"{path}.phase")
|
|
111
|
+
for key in ("inputs", "artifacts", "events", "allowedInteractions", "capabilities"):
|
|
112
|
+
_require_type(skill[key], list, f"{path}.{key}")
|
|
113
|
+
profiles = skill.get("profiles", ["general", "java"])
|
|
114
|
+
_require_type(profiles, list, f"{path}.profiles")
|
|
115
|
+
if not profiles or any(item not in {"general", "java"} for item in profiles):
|
|
116
|
+
raise PolicyValidationError(f"{path}.profiles must contain general and/or java")
|
|
117
|
+
|
|
118
|
+
for section in ("requiredArtifacts", "requiredValidations"):
|
|
119
|
+
section_data = data[section]
|
|
120
|
+
_require_type(section_data, dict, section)
|
|
121
|
+
for phase, items in section_data.items():
|
|
122
|
+
_require_type(items, list, f"{section}.{phase}")
|
|
123
|
+
|
|
124
|
+
conditional = data["conditionalStages"]
|
|
125
|
+
_require_type(conditional, dict, "conditionalStages")
|
|
126
|
+
for stage_name, stage in conditional.items():
|
|
127
|
+
path = f"conditionalStages.{stage_name}"
|
|
128
|
+
_require_type(stage, dict, path)
|
|
129
|
+
_reject_unknown(stage, CONDITIONAL_STAGE_KEYS, path)
|
|
130
|
+
_require_type(stage["tiers"], list, f"{path}.tiers")
|
|
131
|
+
_require_type(stage["signals"], list, f"{path}.signals")
|
|
132
|
+
|
|
133
|
+
interaction = data["interactionWhitelist"]
|
|
134
|
+
_require_type(interaction, dict, "interactionWhitelist")
|
|
135
|
+
for key, items in interaction.items():
|
|
136
|
+
_require_type(items, list, f"interactionWhitelist.{key}")
|
|
137
|
+
|
|
138
|
+
checkpoints = data["checkpointRules"]
|
|
139
|
+
_require_type(checkpoints, dict, "checkpointRules")
|
|
140
|
+
for checkpoint_id, rule in checkpoints.items():
|
|
141
|
+
path = f"checkpointRules.{checkpoint_id}"
|
|
142
|
+
_require_type(rule, dict, path)
|
|
143
|
+
_reject_unknown(rule, CHECKPOINT_RULE_KEYS, path)
|
|
144
|
+
_require_type(rule["afterTasks"], list, f"{path}.afterTasks")
|
|
145
|
+
_require_type(rule["beforeTasks"], list, f"{path}.beforeTasks")
|
|
146
|
+
_require_type(rule["blocking"], bool, f"{path}.blocking")
|
|
147
|
+
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def policy_path_for_repo(repo_root: Path) -> Path:
|
|
152
|
+
return repo_root / POLICY_REL
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_policy(repo_root: Path) -> dict[str, Any]:
|
|
156
|
+
path = policy_path_for_repo(repo_root)
|
|
157
|
+
if not path.is_file():
|
|
158
|
+
raise FileNotFoundError(f"workflow policy missing: {path}")
|
|
159
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
160
|
+
return validate_policy(data)
|