@hunter-harness/workflow-harness 0.2.54 → 0.2.55
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 +2 -2
- package/harness/bundles/general/claude-code/harness-plan/checklist.md +2 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +6 -4
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/claude-code/harness-run/reference.md +2 -2
- 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 +5 -3
- package/harness/bundles/general/claude-code/harness-test/reference.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +46 -7
- package/harness/bundles/general/claude-code/scripts/harness_context.py +71 -22
- package/harness/bundles/general/claude-code/scripts/harness_events.py +40 -1
- package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +165 -1
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +2 -0
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +37 -44
- 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 +2 -2
- package/harness/bundles/general/codebuddy/harness-plan/checklist.md +2 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +6 -4
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/codebuddy/harness-run/reference.md +2 -2
- 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 +5 -3
- package/harness/bundles/general/codebuddy/harness-test/reference.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +46 -7
- package/harness/bundles/general/codebuddy/scripts/harness_context.py +71 -22
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +40 -1
- package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +165 -1
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +2 -0
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +37 -44
- 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 +2 -2
- package/harness/bundles/general/codex/harness-plan/checklist.md +2 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/codex/harness-run/reference.md +2 -2
- 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 +5 -3
- package/harness/bundles/general/codex/harness-test/reference.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +46 -7
- package/harness/bundles/general/codex/scripts/harness_context.py +71 -22
- package/harness/bundles/general/codex/scripts/harness_events.py +40 -1
- package/harness/bundles/general/codex/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/general/codex/scripts/harness_gate.py +165 -1
- package/harness/bundles/general/codex/scripts/harness_ledger.py +2 -0
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +37 -44
- 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 +2 -2
- package/harness/bundles/general/cursor/harness-plan/checklist.md +2 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/cursor/harness-run/reference.md +2 -2
- 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 +5 -3
- package/harness/bundles/general/cursor/harness-test/reference.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +46 -7
- package/harness/bundles/general/cursor/scripts/harness_context.py +71 -22
- package/harness/bundles/general/cursor/scripts/harness_events.py +40 -1
- package/harness/bundles/general/cursor/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/general/cursor/scripts/harness_gate.py +165 -1
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +2 -0
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +37 -44
- 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 +2 -2
- package/harness/bundles/java/claude-code/harness-plan/checklist.md +2 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +6 -4
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
- 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 +5 -3
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +46 -7
- package/harness/bundles/java/claude-code/scripts/harness_context.py +71 -22
- package/harness/bundles/java/claude-code/scripts/harness_events.py +40 -1
- package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +165 -1
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +2 -0
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +37 -44
- 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 +2 -2
- package/harness/bundles/java/codebuddy/harness-plan/checklist.md +2 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +6 -4
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
- 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 +5 -3
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +46 -7
- package/harness/bundles/java/codebuddy/scripts/harness_context.py +71 -22
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +40 -1
- package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +165 -1
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +2 -0
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +37 -44
- 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 +2 -2
- package/harness/bundles/java/codex/harness-plan/checklist.md +2 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
- 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 +5 -3
- package/harness/bundles/java/codex/scripts/harness_archive.py +46 -7
- package/harness/bundles/java/codex/scripts/harness_context.py +71 -22
- package/harness/bundles/java/codex/scripts/harness_events.py +40 -1
- package/harness/bundles/java/codex/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/java/codex/scripts/harness_gate.py +165 -1
- package/harness/bundles/java/codex/scripts/harness_ledger.py +2 -0
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +37 -44
- 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 +2 -2
- package/harness/bundles/java/cursor/harness-plan/checklist.md +2 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
- 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 +5 -3
- package/harness/bundles/java/cursor/scripts/harness_archive.py +46 -7
- package/harness/bundles/java/cursor/scripts/harness_context.py +71 -22
- package/harness/bundles/java/cursor/scripts/harness_events.py +40 -1
- package/harness/bundles/java/cursor/scripts/harness_events_sync.py +93 -14
- package/harness/bundles/java/cursor/scripts/harness_gate.py +165 -1
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +2 -0
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +2 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +37 -44
- package/harness/manifests/general/claude-code.json +24 -24
- package/harness/manifests/general/codebuddy.json +24 -24
- package/harness/manifests/general/codex.json +24 -24
- package/harness/manifests/general/cursor.json +24 -24
- package/harness/manifests/java/claude-code.json +24 -24
- package/harness/manifests/java/codebuddy.json +24 -24
- package/harness/manifests/java/codex.json +24 -24
- package/harness/manifests/java/cursor.json +24 -24
- package/hunter-workflow-family.json +5 -4
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import argparse
|
|
|
17
17
|
import datetime as dt
|
|
18
18
|
import hashlib
|
|
19
19
|
import io
|
|
20
|
+
import ipaddress
|
|
20
21
|
import json
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
@@ -31,6 +32,7 @@ import zipfile
|
|
|
31
32
|
from collections.abc import Mapping
|
|
32
33
|
from pathlib import Path
|
|
33
34
|
from typing import Any
|
|
35
|
+
from urllib.parse import urlsplit
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
if hasattr(sys.stdout, "reconfigure"):
|
|
@@ -192,9 +194,9 @@ def append_event(
|
|
|
192
194
|
for key, value in fields.items():
|
|
193
195
|
if value is not None:
|
|
194
196
|
event[key] = value
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
all_events =
|
|
197
|
+
result = he.append_with_auto_seal(path, event, existing_events=existing)
|
|
198
|
+
event = result["event"]
|
|
199
|
+
all_events = he.load_events(path)
|
|
198
200
|
he.write_execution_log(change_dir, he.render_execution_log(all_events))
|
|
199
201
|
return event
|
|
200
202
|
|
|
@@ -3053,6 +3055,25 @@ def build_verification_projection(
|
|
|
3053
3055
|
"apiTests": _ledger_api_tests(effective_ledger, change_dir=change_dir),
|
|
3054
3056
|
}
|
|
3055
3057
|
db_compatibility = _ledger_db_compat(effective_ledger)
|
|
3058
|
+
if db_compatibility["status"] == "NOT_RUN" and change_dir is not None:
|
|
3059
|
+
gate_policy_path = change_dir / "meta" / "gate-policy.json"
|
|
3060
|
+
try:
|
|
3061
|
+
gate_policy = read_json(gate_policy_path) if gate_policy_path.is_file() else None
|
|
3062
|
+
except (OSError, json.JSONDecodeError):
|
|
3063
|
+
gate_policy = None
|
|
3064
|
+
if isinstance(gate_policy, dict):
|
|
3065
|
+
capabilities = gate_policy.get("capabilities")
|
|
3066
|
+
required = gate_policy.get("requiredValidations")
|
|
3067
|
+
if (
|
|
3068
|
+
isinstance(capabilities, list)
|
|
3069
|
+
and "database" not in capabilities
|
|
3070
|
+
and (not isinstance(required, list) or "dbCompatibility" not in required)
|
|
3071
|
+
):
|
|
3072
|
+
db_compatibility = {
|
|
3073
|
+
"status": "NOT_APPLICABLE",
|
|
3074
|
+
"reason": "项目能力画像未声明数据库能力",
|
|
3075
|
+
"source": "capability-profile",
|
|
3076
|
+
}
|
|
3056
3077
|
projection["dbCompatibility"] = db_compatibility["status"]
|
|
3057
3078
|
projection["dbCompatibilityEvidence"] = db_compatibility
|
|
3058
3079
|
api_contract = validations.get("apiContract")
|
|
@@ -8377,6 +8398,27 @@ def _read_optional_text(path: Path) -> str:
|
|
|
8377
8398
|
return ""
|
|
8378
8399
|
|
|
8379
8400
|
|
|
8401
|
+
def _archive_server_url_allowed(value: str | None) -> bool:
|
|
8402
|
+
"""Require TLS remotely while allowing explicit loopback HTTP for local testing."""
|
|
8403
|
+
if value is None or not value.strip() or re.search(r"\s", value):
|
|
8404
|
+
return False
|
|
8405
|
+
try:
|
|
8406
|
+
parsed = urlsplit(value)
|
|
8407
|
+
host = (parsed.hostname or "").lower()
|
|
8408
|
+
except ValueError:
|
|
8409
|
+
return False
|
|
8410
|
+
if parsed.scheme.lower() == "https":
|
|
8411
|
+
return bool(host)
|
|
8412
|
+
if parsed.scheme.lower() != "http":
|
|
8413
|
+
return False
|
|
8414
|
+
if host == "localhost":
|
|
8415
|
+
return True
|
|
8416
|
+
try:
|
|
8417
|
+
return ipaddress.ip_address(host).is_loopback
|
|
8418
|
+
except ValueError:
|
|
8419
|
+
return False
|
|
8420
|
+
|
|
8421
|
+
|
|
8380
8422
|
def _resolve_archive_remote_credentials(
|
|
8381
8423
|
project_root: Path,
|
|
8382
8424
|
environment: Mapping[str, str],
|
|
@@ -8403,10 +8445,7 @@ def _resolve_archive_remote_credentials(
|
|
|
8403
8445
|
else ""
|
|
8404
8446
|
)
|
|
8405
8447
|
token_available = bool(env_token or local_token)
|
|
8406
|
-
url_available =
|
|
8407
|
-
server_url
|
|
8408
|
-
and re.fullmatch(r"https://[^\s]+", server_url, flags=re.IGNORECASE)
|
|
8409
|
-
)
|
|
8448
|
+
url_available = _archive_server_url_allowed(server_url)
|
|
8410
8449
|
return {
|
|
8411
8450
|
"configured": bool(url_available and token_available),
|
|
8412
8451
|
"serverUrl": server_url if url_available else None,
|
|
@@ -474,7 +474,7 @@ def close_transition(
|
|
|
474
474
|
*,
|
|
475
475
|
from_phase: str,
|
|
476
476
|
to_phase: str,
|
|
477
|
-
executor: str,
|
|
477
|
+
executor: str | None,
|
|
478
478
|
artifacts: list[str] | None = None,
|
|
479
479
|
status: str = "OK",
|
|
480
480
|
) -> dict[str, Any]:
|
|
@@ -491,13 +491,81 @@ def close_transition(
|
|
|
491
491
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
492
492
|
return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
|
|
493
493
|
paths = _paths(state_root)
|
|
494
|
+
|
|
495
|
+
artifact_entries: list[dict[str, Any]] = []
|
|
496
|
+
for raw in artifacts or []:
|
|
497
|
+
requested = Path(raw)
|
|
498
|
+
candidates = (
|
|
499
|
+
[requested]
|
|
500
|
+
if requested.is_absolute()
|
|
501
|
+
else [project / requested, contract_root / requested]
|
|
502
|
+
)
|
|
503
|
+
valid: list[Path] = []
|
|
504
|
+
for candidate in candidates:
|
|
505
|
+
try:
|
|
506
|
+
resolved_candidate = candidate.resolve()
|
|
507
|
+
except OSError:
|
|
508
|
+
continue
|
|
509
|
+
if resolved_candidate in valid:
|
|
510
|
+
continue
|
|
511
|
+
if resolved_candidate.is_relative_to(project) and resolved_candidate.is_file():
|
|
512
|
+
valid.append(resolved_candidate)
|
|
513
|
+
if len(valid) > 1:
|
|
514
|
+
return {
|
|
515
|
+
"ok": False,
|
|
516
|
+
"code": "TRANSITION_ARTIFACT_AMBIGUOUS",
|
|
517
|
+
"path": raw,
|
|
518
|
+
"candidates": [item.relative_to(project).as_posix() for item in valid],
|
|
519
|
+
"message": "relative artifact exists under both project and change roots",
|
|
520
|
+
}
|
|
521
|
+
if not valid:
|
|
522
|
+
return {
|
|
523
|
+
"ok": False,
|
|
524
|
+
"code": "TRANSITION_ARTIFACT_INVALID",
|
|
525
|
+
"path": raw,
|
|
526
|
+
"acceptedBases": [str(project), str(contract_root)],
|
|
527
|
+
"examples": [
|
|
528
|
+
str(contract_root / "meta" / "plan-finalization.json"),
|
|
529
|
+
f".harness/changes/{change}/meta/plan-finalization.json",
|
|
530
|
+
"meta/plan-finalization.json",
|
|
531
|
+
],
|
|
532
|
+
}
|
|
533
|
+
resolved_artifact = valid[0]
|
|
534
|
+
artifact_entries.append(
|
|
535
|
+
{
|
|
536
|
+
"path": resolved_artifact.relative_to(project).as_posix(),
|
|
537
|
+
"sha256": _sha256(resolved_artifact),
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
transitions = _read_ndjson(paths["transitions"])
|
|
494
542
|
if not paths["lease"].is_file():
|
|
543
|
+
latest = transitions[-1] if transitions else None
|
|
544
|
+
if (
|
|
545
|
+
isinstance(latest, dict)
|
|
546
|
+
and latest.get("fromPhase") == from_phase
|
|
547
|
+
and latest.get("toPhase") == to_phase
|
|
548
|
+
and latest.get("status") == status
|
|
549
|
+
and latest.get("artifacts") == artifact_entries
|
|
550
|
+
and (executor is None or latest.get("executor") == executor)
|
|
551
|
+
):
|
|
552
|
+
return {
|
|
553
|
+
"ok": True,
|
|
554
|
+
"code": "TRANSITION_ALREADY_CLOSED",
|
|
555
|
+
"idempotent": True,
|
|
556
|
+
"receipt": latest,
|
|
557
|
+
"path": str(paths["transitions"]),
|
|
558
|
+
"invalidation": None,
|
|
559
|
+
}
|
|
495
560
|
return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
|
|
496
561
|
try:
|
|
497
562
|
lease = _read_json(paths["lease"])
|
|
498
563
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
499
564
|
return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
|
|
500
|
-
|
|
565
|
+
effective_executor = executor or str(lease.get("owner") or "")
|
|
566
|
+
if not effective_executor:
|
|
567
|
+
return {"ok": False, "code": "CONTEXT_EXECUTOR_REQUIRED"}
|
|
568
|
+
if lease.get("owner") != effective_executor or lease.get("phase") != from_phase:
|
|
501
569
|
return {
|
|
502
570
|
"ok": False,
|
|
503
571
|
"code": "CONTEXT_LEASE_MISMATCH",
|
|
@@ -508,25 +576,6 @@ def close_transition(
|
|
|
508
576
|
if expiry is None or _now() >= expiry:
|
|
509
577
|
return {"ok": False, "code": "CONTEXT_LEASE_EXPIRED"}
|
|
510
578
|
|
|
511
|
-
artifact_entries: list[dict[str, Any]] = []
|
|
512
|
-
for raw in artifacts or []:
|
|
513
|
-
path = Path(raw)
|
|
514
|
-
if not path.is_absolute():
|
|
515
|
-
path = contract_root / path
|
|
516
|
-
resolved = path.resolve()
|
|
517
|
-
if not resolved.is_relative_to(project) or not resolved.is_file():
|
|
518
|
-
return {
|
|
519
|
-
"ok": False,
|
|
520
|
-
"code": "TRANSITION_ARTIFACT_INVALID",
|
|
521
|
-
"path": str(path),
|
|
522
|
-
}
|
|
523
|
-
artifact_entries.append(
|
|
524
|
-
{
|
|
525
|
-
"path": resolved.relative_to(project).as_posix(),
|
|
526
|
-
"sha256": _sha256(resolved),
|
|
527
|
-
}
|
|
528
|
-
)
|
|
529
|
-
transitions = _read_ndjson(paths["transitions"])
|
|
530
579
|
previous_hash = transitions[-1].get("receiptHash") if transitions else None
|
|
531
580
|
attempt = (
|
|
532
581
|
1
|
|
@@ -543,7 +592,7 @@ def close_transition(
|
|
|
543
592
|
"fromPhase": from_phase,
|
|
544
593
|
"toPhase": to_phase,
|
|
545
594
|
"status": status,
|
|
546
|
-
"executor":
|
|
595
|
+
"executor": effective_executor,
|
|
547
596
|
"productCommit": _head(project),
|
|
548
597
|
"artifacts": artifact_entries,
|
|
549
598
|
"attempt": attempt,
|
|
@@ -41,7 +41,7 @@ def _project_root_for_change(change_dir: Path) -> Path | None:
|
|
|
41
41
|
return None
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def
|
|
44
|
+
def nudge_remote_sync(change_dir: Path) -> None:
|
|
45
45
|
"""Best-effort wake-up after the event append has been flushed to disk."""
|
|
46
46
|
project_root = _project_root_for_change(change_dir)
|
|
47
47
|
if project_root is None:
|
|
@@ -55,6 +55,11 @@ def _nudge_remote_sync(change_dir: Path) -> None:
|
|
|
55
55
|
return
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def _nudge_remote_sync(change_dir: Path) -> None:
|
|
59
|
+
"""Compatibility wrapper for older callers and focused tests."""
|
|
60
|
+
nudge_remote_sync(change_dir)
|
|
61
|
+
|
|
62
|
+
|
|
58
63
|
if hasattr(sys.stdout, "reconfigure"):
|
|
59
64
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
60
65
|
if hasattr(sys.stderr, "reconfigure"):
|
|
@@ -121,6 +126,12 @@ OPTIONAL_FIELDS = (
|
|
|
121
126
|
"renamed_from",
|
|
122
127
|
"renamed_to",
|
|
123
128
|
"change_uuid",
|
|
129
|
+
"execution_mode",
|
|
130
|
+
"decision_reason_code",
|
|
131
|
+
"fallback_reason_code",
|
|
132
|
+
"trigger",
|
|
133
|
+
"from_phase",
|
|
134
|
+
"result_status",
|
|
124
135
|
)
|
|
125
136
|
|
|
126
137
|
_PROVENANCE_FIELDS = frozenset(
|
|
@@ -139,6 +150,12 @@ _PROVENANCE_FIELDS = frozenset(
|
|
|
139
150
|
"orchestration_active_ms",
|
|
140
151
|
"wall_clock_ms",
|
|
141
152
|
"user_wait_ms",
|
|
153
|
+
"execution_mode",
|
|
154
|
+
"decision_reason_code",
|
|
155
|
+
"fallback_reason_code",
|
|
156
|
+
"trigger",
|
|
157
|
+
"from_phase",
|
|
158
|
+
"result_status",
|
|
142
159
|
}
|
|
143
160
|
)
|
|
144
161
|
_EVENT_ALLOWED_FIELDS = {
|
|
@@ -1698,6 +1715,22 @@ def append_with_auto_seal(
|
|
|
1698
1715
|
else []
|
|
1699
1716
|
)
|
|
1700
1717
|
)
|
|
1718
|
+
if event_type in {"phase.start", "phase.end", "phase.auto_sealed"} and phase:
|
|
1719
|
+
phase_attempts = split_phase_attempts(
|
|
1720
|
+
[item for item in loaded if item.get("phase") == phase]
|
|
1721
|
+
)
|
|
1722
|
+
latest_attempt = max(
|
|
1723
|
+
(
|
|
1724
|
+
int(item.get("attempt"))
|
|
1725
|
+
for item in phase_attempts
|
|
1726
|
+
if isinstance(item.get("attempt"), int)
|
|
1727
|
+
),
|
|
1728
|
+
default=0,
|
|
1729
|
+
)
|
|
1730
|
+
if not isinstance(event.get("attempt"), int) or int(event["attempt"]) <= 0:
|
|
1731
|
+
event["attempt"] = (
|
|
1732
|
+
latest_attempt + 1 if event_type == "phase.start" else latest_attempt or 1
|
|
1733
|
+
)
|
|
1701
1734
|
if event_type == "phase.start" and phase:
|
|
1702
1735
|
auto_sealed = seal_open_phase_attempts(loaded, phase=phase)
|
|
1703
1736
|
for seal_event in auto_sealed:
|
|
@@ -1967,6 +2000,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1967
2000
|
p_append.add_argument("--orchestration-active-ms", type=int, default=None)
|
|
1968
2001
|
p_append.add_argument("--wall-clock-ms", type=int, default=None)
|
|
1969
2002
|
p_append.add_argument("--user-wait-ms", type=int, default=None)
|
|
2003
|
+
p_append.add_argument("--execution-mode", default=None)
|
|
2004
|
+
p_append.add_argument("--decision-reason-code", default=None)
|
|
2005
|
+
p_append.add_argument("--fallback-reason-code", default=None)
|
|
2006
|
+
p_append.add_argument("--trigger", default=None)
|
|
2007
|
+
p_append.add_argument("--from-phase", default=None)
|
|
2008
|
+
p_append.add_argument("--result-status", default=None)
|
|
1970
2009
|
p_append.add_argument(
|
|
1971
2010
|
"--legacy-lenient",
|
|
1972
2011
|
action="store_true",
|
|
@@ -51,6 +51,15 @@ WHITELIST = {
|
|
|
51
51
|
"duration_ms",
|
|
52
52
|
"schemaVersion",
|
|
53
53
|
"summary",
|
|
54
|
+
"executor_tool",
|
|
55
|
+
"executor_agent",
|
|
56
|
+
"executor_model",
|
|
57
|
+
"execution_mode",
|
|
58
|
+
"decision_reason_code",
|
|
59
|
+
"fallback_reason_code",
|
|
60
|
+
"trigger",
|
|
61
|
+
"from_phase",
|
|
62
|
+
"result_status",
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
MAX_BATCH_SIZE = 100
|
|
@@ -110,20 +119,43 @@ def _summary_text(value: Any) -> str:
|
|
|
110
119
|
return text[:EVENT_SUMMARY_MAX_LENGTH].rstrip()
|
|
111
120
|
|
|
112
121
|
|
|
122
|
+
_MACHINE_DETAIL_RE = re.compile(
|
|
123
|
+
r"(?:\s*[;;,,]?\s*)"
|
|
124
|
+
r"(?:artifactsHash|artifactHash|receiptHash|content_sha256|contentSha256)"
|
|
125
|
+
r"\s*[:=]\s*(?:sha256:)?[0-9a-f]{32,64}",
|
|
126
|
+
re.IGNORECASE,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _readable_summary_text(value: Any) -> str:
|
|
131
|
+
text = _summary_text(value)
|
|
132
|
+
if not text:
|
|
133
|
+
return ""
|
|
134
|
+
text = _MACHINE_DETAIL_RE.sub("", text)
|
|
135
|
+
text = re.sub(
|
|
136
|
+
r"\bfinalize\s+ok\b",
|
|
137
|
+
"规划产物已校验并发布",
|
|
138
|
+
text,
|
|
139
|
+
flags=re.IGNORECASE,
|
|
140
|
+
)
|
|
141
|
+
text = re.sub(r"\s*([;;,,])\s*([;;,,])", r"\1", text)
|
|
142
|
+
return _summary_text(text.strip(" ;;,,"))
|
|
143
|
+
|
|
144
|
+
|
|
113
145
|
def build_event_summary(event: dict[str, Any]) -> str:
|
|
114
|
-
explicit =
|
|
146
|
+
explicit = _readable_summary_text(event.get("summary"))
|
|
115
147
|
if explicit:
|
|
116
148
|
return explicit
|
|
117
149
|
|
|
118
150
|
event_type = _summary_text(event.get("type"))
|
|
119
151
|
phase = _summary_text(event.get("phase"))
|
|
120
152
|
phase_label = PHASE_LABELS.get(phase, phase or "当前")
|
|
121
|
-
note =
|
|
122
|
-
message =
|
|
123
|
-
decision =
|
|
124
|
-
reason =
|
|
125
|
-
name =
|
|
126
|
-
code =
|
|
153
|
+
note = _readable_summary_text(event.get("note"))
|
|
154
|
+
message = _readable_summary_text(event.get("message"))
|
|
155
|
+
decision = _readable_summary_text(event.get("decision"))
|
|
156
|
+
reason = _readable_summary_text(event.get("reason"))
|
|
157
|
+
name = _readable_summary_text(event.get("name"))
|
|
158
|
+
code = _readable_summary_text(event.get("code"))
|
|
127
159
|
status = _summary_text(event.get("status"))
|
|
128
160
|
|
|
129
161
|
if event_type == "decision" and decision:
|
|
@@ -754,6 +786,39 @@ def _discover_changes(project_root: Path) -> list[Path]:
|
|
|
754
786
|
return sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
|
|
755
787
|
|
|
756
788
|
|
|
789
|
+
def _change_has_open_phase(change_dir: Path) -> bool:
|
|
790
|
+
"""Project the append-only stream into open/closed phase state."""
|
|
791
|
+
try:
|
|
792
|
+
path = Path(harness_paths.resolve_state_dir_for_contract(change_dir)) / "events.ndjson"
|
|
793
|
+
except (OSError, ValueError):
|
|
794
|
+
return False
|
|
795
|
+
if not path.is_file():
|
|
796
|
+
return False
|
|
797
|
+
open_phases: dict[str, bool] = {}
|
|
798
|
+
try:
|
|
799
|
+
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
800
|
+
if not line.strip():
|
|
801
|
+
continue
|
|
802
|
+
event = json.loads(line)
|
|
803
|
+
if not isinstance(event, dict):
|
|
804
|
+
continue
|
|
805
|
+
phase = event.get("phase")
|
|
806
|
+
event_type = event.get("type")
|
|
807
|
+
if not isinstance(phase, str) or not phase:
|
|
808
|
+
continue
|
|
809
|
+
if event_type == "phase.start":
|
|
810
|
+
open_phases[phase] = True
|
|
811
|
+
elif event_type in {"phase.end", "phase.auto_sealed", "workflow.end", "run.end"}:
|
|
812
|
+
open_phases[phase] = False
|
|
813
|
+
except (OSError, json.JSONDecodeError):
|
|
814
|
+
return False
|
|
815
|
+
return any(open_phases.values())
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _project_has_open_phase(project_root: Path) -> bool:
|
|
819
|
+
return any(_change_has_open_phase(path) for path in _discover_changes(project_root))
|
|
820
|
+
|
|
821
|
+
|
|
757
822
|
def _run_agent_session(project_root: Path) -> dict[str, Any]:
|
|
758
823
|
attempts = 0
|
|
759
824
|
uploaded = 0
|
|
@@ -766,14 +831,28 @@ def _run_agent_session(project_root: Path) -> dict[str, Any]:
|
|
|
766
831
|
if all(item.get("ok") for item in results):
|
|
767
832
|
time.sleep(0.6)
|
|
768
833
|
if generation == _read_nudge_generation(project_root):
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
834
|
+
if not _project_has_open_phase(project_root):
|
|
835
|
+
return {
|
|
836
|
+
"ok": True,
|
|
837
|
+
"uploaded": uploaded,
|
|
838
|
+
"quarantined": quarantined,
|
|
839
|
+
"attempts": attempts + 1,
|
|
840
|
+
}
|
|
841
|
+
# Keep a finite workflow lease alive while a phase is open.
|
|
842
|
+
# A phase.end nudge is observed on the next pass and releases
|
|
843
|
+
# the worker naturally; no terminal window is created.
|
|
844
|
+
time.sleep(10.0)
|
|
845
|
+
heartbeat_results = [
|
|
846
|
+
sync_change(project_root, path, heartbeat_only=True)
|
|
847
|
+
for path in _discover_changes(project_root)
|
|
848
|
+
]
|
|
849
|
+
if all(item.get("ok") for item in heartbeat_results):
|
|
850
|
+
attempts = 0
|
|
851
|
+
continue
|
|
852
|
+
results = heartbeat_results
|
|
775
853
|
attempts = 0
|
|
776
|
-
|
|
854
|
+
if generation != _read_nudge_generation(project_root):
|
|
855
|
+
continue
|
|
777
856
|
time.sleep(min(16.0, float(2 ** attempts)))
|
|
778
857
|
attempts += 1
|
|
779
858
|
return {
|
|
@@ -30,6 +30,7 @@ if str(SCRIPTS_DIR) not in sys.path:
|
|
|
30
30
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
31
31
|
|
|
32
32
|
import harness_change as hc # noqa: E402
|
|
33
|
+
import harness_context as hctx # noqa: E402
|
|
33
34
|
import harness_events as he # noqa: E402
|
|
34
35
|
import harness_events_sync as hes # noqa: E402
|
|
35
36
|
import harness_ledger as hl # noqa: E402
|
|
@@ -1602,6 +1603,18 @@ def append_phase_event(
|
|
|
1602
1603
|
auto_sealed: list[dict[str, Any]] = []
|
|
1603
1604
|
with he.event_file_lock(lock_path):
|
|
1604
1605
|
existing = he.load_events(events_file)
|
|
1606
|
+
phase_attempts = he.split_phase_attempts(
|
|
1607
|
+
[event for event in existing if event.get("phase") == phase]
|
|
1608
|
+
)
|
|
1609
|
+
latest_attempt = max(
|
|
1610
|
+
(
|
|
1611
|
+
int(item.get("attempt"))
|
|
1612
|
+
for item in phase_attempts
|
|
1613
|
+
if isinstance(item.get("attempt"), int)
|
|
1614
|
+
),
|
|
1615
|
+
default=0,
|
|
1616
|
+
)
|
|
1617
|
+
event_attempt = latest_attempt + 1 if type_ == "phase.start" else latest_attempt or 1
|
|
1605
1618
|
args = argparse.Namespace(
|
|
1606
1619
|
phase=phase,
|
|
1607
1620
|
type=type_,
|
|
@@ -1619,12 +1632,18 @@ def append_phase_event(
|
|
|
1619
1632
|
decision=None,
|
|
1620
1633
|
reason=None,
|
|
1621
1634
|
run_id=run_id,
|
|
1622
|
-
attempt=
|
|
1635
|
+
attempt=event_attempt,
|
|
1623
1636
|
executor_tool=executor_tool,
|
|
1624
1637
|
executor_agent=executor_agent,
|
|
1625
1638
|
executor_model=executor_model,
|
|
1626
1639
|
handoff_from_tool=None,
|
|
1627
1640
|
handoff_reason=None,
|
|
1641
|
+
execution_mode=None,
|
|
1642
|
+
decision_reason_code=None,
|
|
1643
|
+
fallback_reason_code=None,
|
|
1644
|
+
trigger=("fixback" if "fixback" in note.lower() else None),
|
|
1645
|
+
from_phase=("review" if phase == "run" and "fixback" in note.lower() else None),
|
|
1646
|
+
result_status=None,
|
|
1628
1647
|
)
|
|
1629
1648
|
event = he.build_event(args, existing)
|
|
1630
1649
|
if identity:
|
|
@@ -1655,6 +1674,7 @@ def append_phase_event(
|
|
|
1655
1674
|
content = he.render_execution_log(events)
|
|
1656
1675
|
log_path = he.write_execution_log(change_dir, content)
|
|
1657
1676
|
rendered = True
|
|
1677
|
+
he.nudge_remote_sync(change_dir)
|
|
1658
1678
|
return {
|
|
1659
1679
|
"ok": True,
|
|
1660
1680
|
"eventId": event.get("id"),
|
|
@@ -1702,6 +1722,80 @@ def _phase_event_exists(change_dir: Path, phase: str, type_: str, run_id: str) -
|
|
|
1702
1722
|
)
|
|
1703
1723
|
|
|
1704
1724
|
|
|
1725
|
+
def _latest_phase_end(
|
|
1726
|
+
change_dir: Path,
|
|
1727
|
+
phase: str,
|
|
1728
|
+
run_id: str | None = None,
|
|
1729
|
+
) -> dict[str, Any] | None:
|
|
1730
|
+
for event in reversed(he.load_events(he.events_path(change_dir))):
|
|
1731
|
+
if event.get("phase") != phase or event.get("type") != "phase.end":
|
|
1732
|
+
continue
|
|
1733
|
+
if run_id and event.get("run_id") != run_id:
|
|
1734
|
+
continue
|
|
1735
|
+
return event
|
|
1736
|
+
return None
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def _terminal_matches_context_session(
|
|
1740
|
+
project: Path,
|
|
1741
|
+
change_id: str,
|
|
1742
|
+
phase: str,
|
|
1743
|
+
terminal: dict[str, Any],
|
|
1744
|
+
) -> bool:
|
|
1745
|
+
view = hctx.context_view(project, change_id)
|
|
1746
|
+
current = view.get("current") if isinstance(view, dict) else None
|
|
1747
|
+
if not isinstance(current, dict) or current.get("phase") != phase:
|
|
1748
|
+
return False
|
|
1749
|
+
prepared_at = he.parse_timestamp(current.get("preparedAt"))
|
|
1750
|
+
terminal_at = he.parse_timestamp(
|
|
1751
|
+
terminal.get("timestamp") or terminal.get("occurred_at")
|
|
1752
|
+
)
|
|
1753
|
+
return (
|
|
1754
|
+
prepared_at is not None
|
|
1755
|
+
and terminal_at is not None
|
|
1756
|
+
and terminal_at >= prepared_at
|
|
1757
|
+
)
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _close_context_handoff(
|
|
1761
|
+
project: Path,
|
|
1762
|
+
change_id: str,
|
|
1763
|
+
args: argparse.Namespace,
|
|
1764
|
+
*,
|
|
1765
|
+
status: str,
|
|
1766
|
+
) -> dict[str, Any] | None:
|
|
1767
|
+
to_phase = getattr(args, "to_phase", None)
|
|
1768
|
+
if not to_phase:
|
|
1769
|
+
return None
|
|
1770
|
+
try:
|
|
1771
|
+
return hctx.close_transition(
|
|
1772
|
+
project,
|
|
1773
|
+
change_id,
|
|
1774
|
+
from_phase=args.phase,
|
|
1775
|
+
to_phase=str(to_phase),
|
|
1776
|
+
executor=getattr(args, "executor", None),
|
|
1777
|
+
artifacts=list(getattr(args, "artifact", None) or []),
|
|
1778
|
+
status=status,
|
|
1779
|
+
)
|
|
1780
|
+
except Exception as exc: # noqa: BLE001 — phase close remains locally durable
|
|
1781
|
+
return {
|
|
1782
|
+
"ok": False,
|
|
1783
|
+
"code": "CONTEXT_HANDOFF_FAILED",
|
|
1784
|
+
"message": str(exc),
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _sync_after_phase_close(project: Path, change_dir: Path) -> dict[str, Any]:
|
|
1789
|
+
try:
|
|
1790
|
+
return hes.auto_events_sync(project, change_dir)
|
|
1791
|
+
except Exception as exc: # noqa: BLE001 — remote sync is recoverable
|
|
1792
|
+
return {
|
|
1793
|
+
"ok": False,
|
|
1794
|
+
"code": "PLATFORM_EVENTS_SYNC_WARN",
|
|
1795
|
+
"warning": f"events-sync hook failed: {exc}",
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
|
|
1705
1799
|
def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
|
|
1706
1800
|
"""C9: validate all ledger-required scenarios are covered by ledger entries.
|
|
1707
1801
|
|
|
@@ -2266,6 +2360,48 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
2266
2360
|
explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
|
|
2267
2361
|
current_lease = hc.inspect_lease(project, resolved["changeId"])
|
|
2268
2362
|
if current_lease is None:
|
|
2363
|
+
terminal = _latest_phase_end(change_dir, args.phase, explicit_run_id)
|
|
2364
|
+
if (
|
|
2365
|
+
getattr(args, "to_phase", None)
|
|
2366
|
+
and terminal is not None
|
|
2367
|
+
and _terminal_matches_context_session(
|
|
2368
|
+
project, str(resolved["changeId"]), args.phase, terminal
|
|
2369
|
+
)
|
|
2370
|
+
):
|
|
2371
|
+
terminal_status = str(terminal.get("status") or args.status)
|
|
2372
|
+
handoff = _close_context_handoff(
|
|
2373
|
+
project,
|
|
2374
|
+
str(resolved["changeId"]),
|
|
2375
|
+
args,
|
|
2376
|
+
status=terminal_status,
|
|
2377
|
+
)
|
|
2378
|
+
monitor = _sync_after_phase_close(project, change_dir)
|
|
2379
|
+
if not isinstance(handoff, dict) or not handoff.get("ok"):
|
|
2380
|
+
return emit_error(
|
|
2381
|
+
"PHASE_HANDOFF_PENDING",
|
|
2382
|
+
"phase gate is closed, but context handoff still needs attention",
|
|
2383
|
+
as_json=as_json,
|
|
2384
|
+
extra={
|
|
2385
|
+
"localCloseComplete": True,
|
|
2386
|
+
"retryable": True,
|
|
2387
|
+
"contextHandoff": handoff,
|
|
2388
|
+
"platformMonitor": monitor,
|
|
2389
|
+
},
|
|
2390
|
+
)
|
|
2391
|
+
emit(
|
|
2392
|
+
{
|
|
2393
|
+
"ok": True,
|
|
2394
|
+
"code": "PHASE_CLOSE_RESUMED",
|
|
2395
|
+
"phase": args.phase,
|
|
2396
|
+
"status": terminal_status,
|
|
2397
|
+
"changeId": resolved["changeId"],
|
|
2398
|
+
"localCloseComplete": True,
|
|
2399
|
+
"contextHandoff": handoff,
|
|
2400
|
+
"platformMonitor": monitor,
|
|
2401
|
+
},
|
|
2402
|
+
as_json=as_json,
|
|
2403
|
+
)
|
|
2404
|
+
return 0
|
|
2269
2405
|
return emit_error("LEASE_ABSENT", "no active lease for phase close", as_json=as_json)
|
|
2270
2406
|
run_id = explicit_run_id or str(current_lease.get("runId") or "")
|
|
2271
2407
|
if str(current_lease.get("runId")) != run_id or str(current_lease.get("phase")) != args.phase:
|
|
@@ -2597,6 +2733,29 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
2597
2733
|
capsule["closeStatus"] = close_status
|
|
2598
2734
|
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
2599
2735
|
|
|
2736
|
+
context_handoff = _close_context_handoff(
|
|
2737
|
+
project,
|
|
2738
|
+
str(resolved["changeId"]),
|
|
2739
|
+
args,
|
|
2740
|
+
status=close_status,
|
|
2741
|
+
)
|
|
2742
|
+
platform_monitor = _sync_after_phase_close(project, change_dir)
|
|
2743
|
+
if isinstance(context_handoff, dict) and not context_handoff.get("ok"):
|
|
2744
|
+
return emit_error(
|
|
2745
|
+
"PHASE_HANDOFF_PENDING",
|
|
2746
|
+
"phase gate closed locally, but context handoff failed; retry the same close command",
|
|
2747
|
+
as_json=as_json,
|
|
2748
|
+
extra={
|
|
2749
|
+
"localCloseComplete": True,
|
|
2750
|
+
"retryable": True,
|
|
2751
|
+
"phase": args.phase,
|
|
2752
|
+
"status": close_status,
|
|
2753
|
+
"changeId": resolved["changeId"],
|
|
2754
|
+
"contextHandoff": context_handoff,
|
|
2755
|
+
"platformMonitor": platform_monitor,
|
|
2756
|
+
},
|
|
2757
|
+
)
|
|
2758
|
+
|
|
2600
2759
|
payload = {
|
|
2601
2760
|
"ok": True,
|
|
2602
2761
|
"code": close_code,
|
|
@@ -2612,6 +2771,8 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
2612
2771
|
"lease": release,
|
|
2613
2772
|
"gateSeverityMode": severity_mode,
|
|
2614
2773
|
"gateWarnings": gate_warnings,
|
|
2774
|
+
"contextHandoff": context_handoff,
|
|
2775
|
+
"platformMonitor": platform_monitor,
|
|
2615
2776
|
}
|
|
2616
2777
|
emit(payload, as_json=as_json)
|
|
2617
2778
|
return 0
|
|
@@ -2806,6 +2967,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2806
2967
|
p_close.add_argument("--run-id", default=None)
|
|
2807
2968
|
p_close.add_argument("--task", type=int, default=None)
|
|
2808
2969
|
p_close.add_argument("--note", default="")
|
|
2970
|
+
p_close.add_argument("--to-phase", default=None)
|
|
2971
|
+
p_close.add_argument("--executor", default=None)
|
|
2972
|
+
p_close.add_argument("--artifact", action="append", default=[])
|
|
2809
2973
|
p_close.set_defaults(func=cmd_close)
|
|
2810
2974
|
|
|
2811
2975
|
p_classify = sub.add_parser("classify", parents=[shared])
|
|
@@ -1160,6 +1160,8 @@ _DYNAMIC_OWN_DIRS = ("events.ndjson", "logs", "evidence", "reports", "runtime",
|
|
|
1160
1160
|
|
|
1161
1161
|
def _matches_ownership_path(rel: str, declared: Any) -> bool:
|
|
1162
1162
|
scope = str(declared).replace("\\", "/").strip("/")
|
|
1163
|
+
if scope.endswith("/**"):
|
|
1164
|
+
scope = scope[:-3].rstrip("/")
|
|
1163
1165
|
if not scope:
|
|
1164
1166
|
return False
|
|
1165
1167
|
normalized = rel.replace("\\", "/").strip("/")
|