gito/agent-ops/skills/common/sync-agent-ui/scripts/sync_state.py

903 lines
35 KiB
Python

#!/usr/bin/env python3
"""Atomically manage plan- and Milestone-required agent-ui work mappings."""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
import re
import stat
import tempfile
from contextlib import contextmanager
from datetime import datetime
from functools import wraps
from pathlib import Path, PurePosixPath
from typing import Any
SCHEMA_VERSION = 4
SHA_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
STATUS_PATH_PATTERN = re.compile(
r"^agent-ui/definition/(?:views|components)/[a-z0-9][a-z0-9/-]*/index\.md$"
)
FRAME_PATH_PATTERN = re.compile(
r"^agent-ui/frame/views/[a-z0-9][a-z0-9/-]*/index\.md$"
)
SCOPE_PATTERN = re.compile(r"^(?:all|view:[a-z0-9][a-z0-9-]*|component:[a-z0-9][a-z0-9/-]*)$")
MILESTONE_PATH_PATTERN = re.compile(
r"^agent-roadmap/phase/[a-z0-9][a-z0-9-]*/milestones/"
r"[a-z0-9][a-z0-9-]*\.md$"
)
class StateError(ValueError):
pass
@contextmanager
def _state_lock(path: Path):
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
fcntl.flock(directory_fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(directory_fd, fcntl.LOCK_UN)
os.close(directory_fd)
def _locked_state_command(handler):
@wraps(handler)
def wrapped(args: argparse.Namespace) -> dict[str, Any]:
with _state_lock(args.state):
return handler(args)
return wrapped
def _stable_unique(values: list[str]) -> list[str]:
return list(dict.fromkeys(values))
def _path_trees_overlap(left: str, right: str) -> bool:
left_path = PurePosixPath(left)
right_path = PurePosixPath(right)
return (
left_path == right_path
or left_path in right_path.parents
or right_path in left_path.parents
)
def _require_relative_path(value: str, *, prefix: str | None = None) -> str:
path = PurePosixPath(value)
if not value or path.is_absolute() or ".." in path.parts or "." in path.parts:
raise StateError(f"invalid repository-relative path: {value!r}")
normalized = path.as_posix()
if prefix and not normalized.startswith(prefix):
raise StateError(f"path must start with {prefix!r}: {value!r}")
return normalized
def _require_task_path(value: str) -> str:
normalized = _require_relative_path(value, prefix="agent-task/")
if normalized == "agent-task" or normalized.startswith("agent-task/archive/"):
raise StateError(f"task path must be the original active path: {value!r}")
return normalized.rstrip("/")
def _require_milestone_path(value: str) -> str:
normalized = _require_relative_path(value, prefix="agent-roadmap/phase/")
if not MILESTONE_PATH_PATTERN.fullmatch(normalized):
raise StateError(f"milestone path must be an active Milestone document: {value!r}")
return normalized
def _require_timestamp(value: str) -> str:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise StateError(f"invalid ISO-8601 timestamp: {value!r}") from exc
if parsed.tzinfo is None:
raise StateError(f"timestamp must include a timezone: {value!r}")
return value
def _require_string_list(value: Any, *, field: str) -> list[str]:
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
raise StateError(f"{field} must be a list of non-empty strings")
return _stable_unique(value)
def _require_object_list(value: Any, *, field: str) -> list[dict[str, Any]]:
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise StateError(f"{field} must be a list of objects")
return value
def _normalize_pending_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("pending_code_work entries must be objects")
legacy_paths = _require_string_list(raw.get("agent_ui_paths", []), field="agent_ui_paths")
status_paths = _require_string_list(raw.get("status_paths", []), field="status_paths")
frame_paths = _require_string_list(raw.get("frame_paths", []), field="frame_paths")
if legacy_paths:
status_paths.extend(path for path in legacy_paths if not path.startswith("agent-ui/frame/"))
frame_paths.extend(path for path in legacy_paths if path.startswith("agent-ui/frame/"))
status_paths = [
_require_relative_path(path, prefix="agent-ui/definition/")
for path in _stable_unique(status_paths)
]
frame_paths = [
_require_relative_path(path, prefix="agent-ui/frame/")
for path in _stable_unique(frame_paths)
]
if not status_paths:
raise StateError("pending_code_work requires at least one status_path")
if not all(STATUS_PATH_PATTERN.fullmatch(path) for path in status_paths):
raise StateError("status_paths must be active view/component index.md paths")
if not all(FRAME_PATH_PATTERN.fullmatch(path) for path in frame_paths):
raise StateError("frame_paths must be active frame-view index.md paths")
if set(status_paths) & set(frame_paths):
raise StateError("status_paths and frame_paths must be disjoint")
code_paths = [
_require_relative_path(path)
for path in _require_string_list(raw.get("code_paths", []), field="code_paths")
]
if not code_paths:
raise StateError("pending_code_work requires at least one code_path")
if any(path.startswith(("agent-ui/", "agent-task/")) for path in code_paths):
raise StateError("code_paths must not point to agent-ui or agent-task artifacts")
scope = str(raw.get("scope", "")).strip() or "all"
if not SCOPE_PATTERN.fullmatch(scope):
raise StateError(f"invalid sync scope: {scope!r}")
return {
"task_path": _require_task_path(str(raw.get("task_path", ""))),
"scope": scope,
"status_paths": status_paths,
"frame_paths": frame_paths,
"code_paths": code_paths,
"verification_requirements": _require_string_list(
raw.get("verification_requirements", []),
field="verification_requirements",
),
"prepared_at": _require_timestamp(str(raw.get("prepared_at", ""))),
"state": "pending",
}
def _normalize_pending_milestone_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("pending_milestone_work entries must be objects")
entry = _normalize_pending_entry(
{
**raw,
"task_path": "agent-task/milestone-placeholder",
}
)
return {
"milestone_path": _require_milestone_path(str(raw.get("milestone_path", ""))),
"scope": entry["scope"],
"status_paths": entry["status_paths"],
"frame_paths": entry["frame_paths"],
"code_paths": entry["code_paths"],
"verification_requirements": entry["verification_requirements"],
"prepared_at": entry["prepared_at"],
"state": "pending",
}
def _normalize_reconciled_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("reconciled_code_work entries must be objects")
scope = str(raw.get("scope", "")).strip() or "all"
if not SCOPE_PATTERN.fullmatch(scope):
raise StateError(f"invalid reconciled sync scope: {scope!r}")
entry = {
"task_path": _require_task_path(str(raw.get("task_path", ""))),
"completion_log": _require_relative_path(
str(raw.get("completion_log", "")),
prefix="agent-task/archive/",
),
"scope": scope,
"status_paths": [
_require_relative_path(path, prefix="agent-ui/definition/")
for path in _require_string_list(raw.get("status_paths", []), field="status_paths")
],
"frame_paths": [
_require_relative_path(path, prefix="agent-ui/frame/")
for path in _require_string_list(raw.get("frame_paths", []), field="frame_paths")
],
"code_paths": [
_require_relative_path(path)
for path in _require_string_list(raw.get("code_paths", []), field="code_paths")
],
"validation_evidence": str(raw.get("validation_evidence", "")).strip(),
"reconciled_at": _require_timestamp(str(raw.get("reconciled_at", ""))),
"sync_result_head": raw.get("sync_result_head"),
}
if not entry["status_paths"] or not entry["code_paths"] or not entry["validation_evidence"]:
raise StateError("reconciled_code_work entry is incomplete")
if not all(STATUS_PATH_PATTERN.fullmatch(path) for path in entry["status_paths"]):
raise StateError("reconciled status_paths must be active view/component index.md paths")
if not all(FRAME_PATH_PATTERN.fullmatch(path) for path in entry["frame_paths"]):
raise StateError("reconciled frame_paths must be active frame-view index.md paths")
if any(path.startswith(("agent-ui/", "agent-task/")) for path in entry["code_paths"]):
raise StateError("reconciled code_paths must not point to agent-ui or agent-task artifacts")
if entry["sync_result_head"] is not None and not SHA_PATTERN.fullmatch(
str(entry["sync_result_head"])
):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
return entry
def _normalize_reconciled_milestone_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("reconciled_milestone_work entries must be objects")
synthetic = _normalize_reconciled_entry(
{
**raw,
"task_path": "agent-task/milestone-placeholder",
"completion_log": "agent-task/archive/2000/01/milestone-placeholder/complete.log",
}
)
closure_evidence = str(raw.get("closure_evidence", "")).strip()
if not closure_evidence:
raise StateError("reconciled_milestone_work requires closure_evidence")
return {
"milestone_path": _require_milestone_path(str(raw.get("milestone_path", ""))),
"closure_evidence": closure_evidence,
"scope": synthetic["scope"],
"status_paths": synthetic["status_paths"],
"frame_paths": synthetic["frame_paths"],
"code_paths": synthetic["code_paths"],
"validation_evidence": synthetic["validation_evidence"],
"reconciled_at": synthetic["reconciled_at"],
"sync_result_head": synthetic["sync_result_head"],
}
def _normalize_state(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("sync state must be a JSON object")
version = raw.get("schema_version")
if version not in {1, 2, 3, 4}:
raise StateError(f"unsupported schema_version: {version!r}")
state = dict(raw)
state["schema_version"] = SCHEMA_VERSION
state["pending_code_work"] = [
_normalize_pending_entry(entry)
for entry in _require_object_list(
raw.get("pending_code_work", []),
field="pending_code_work",
)
]
state["reconciled_code_work"] = [
_normalize_reconciled_entry(entry)
for entry in _require_object_list(
raw.get("reconciled_code_work", []),
field="reconciled_code_work",
)
]
state["pending_milestone_work"] = [
_normalize_pending_milestone_entry(entry)
for entry in _require_object_list(
raw.get("pending_milestone_work", []),
field="pending_milestone_work",
)
]
state["reconciled_milestone_work"] = [
_normalize_reconciled_milestone_entry(entry)
for entry in _require_object_list(
raw.get("reconciled_milestone_work", []),
field="reconciled_milestone_work",
)
]
pending_keys = [entry["task_path"] for entry in state["pending_code_work"]]
if len(pending_keys) != len(set(pending_keys)):
raise StateError("pending_code_work contains duplicate task_path values")
milestone_keys = [
entry["milestone_path"] for entry in state["pending_milestone_work"]
]
if len(milestone_keys) != len(set(milestone_keys)):
raise StateError("pending_milestone_work contains duplicate milestone_path values")
pending_entries = state["pending_code_work"] + state["pending_milestone_work"]
pending_status_paths = [
path for entry in pending_entries for path in entry["status_paths"]
]
if len(pending_status_paths) != len(set(pending_status_paths)):
raise StateError("pending work assigns one status_path to multiple owners")
pending_frame_paths = [
path for entry in pending_entries for path in entry["frame_paths"]
]
if len(pending_frame_paths) != len(set(pending_frame_paths)):
raise StateError("pending work assigns one frame_path to multiple owners")
reconciled_keys = [
(entry["task_path"], entry["completion_log"])
for entry in state["reconciled_code_work"]
]
if len(reconciled_keys) != len(set(reconciled_keys)):
raise StateError("reconciled_code_work contains duplicate task/completion pairs")
reconciled_milestone_keys = [
(entry["milestone_path"], entry["closure_evidence"])
for entry in state["reconciled_milestone_work"]
]
if len(reconciled_milestone_keys) != len(set(reconciled_milestone_keys)):
raise StateError(
"reconciled_milestone_work contains duplicate milestone/evidence pairs"
)
notes = raw.get("notes", [])
state["notes"] = _require_string_list(notes, field="notes")
return state
def _load_state(path: Path, expected_sha256: str) -> tuple[dict[str, Any], str]:
if not path.is_file():
raise StateError(f"sync state does not exist: {path}")
if not re.fullmatch(r"[0-9a-f]{64}", expected_sha256):
raise StateError("expected_sha256 must be a 64-character lowercase hex digest")
payload = path.read_bytes()
actual_sha256 = hashlib.sha256(payload).hexdigest()
if actual_sha256 != expected_sha256:
raise StateError(
"sync state changed after inspection: "
f"expected={expected_sha256} actual={actual_sha256}"
)
try:
raw = json.loads(payload)
except json.JSONDecodeError as exc:
raise StateError(f"invalid sync state JSON: {exc}") from exc
return _normalize_state(raw), actual_sha256
def _write_state(path: Path, state: dict[str, Any]) -> str:
payload = (json.dumps(state, ensure_ascii=False, indent=2) + "\n").encode()
mode = stat.S_IMODE(path.stat().st_mode)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
temporary = Path(handle.name)
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
temporary = None
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return hashlib.sha256(payload).hexdigest()
def _pending_entry_from_args(args: argparse.Namespace) -> dict[str, Any]:
return _normalize_pending_entry(
{
"task_path": args.task_path,
"scope": args.scope,
"status_paths": args.status_path,
"frame_paths": args.frame_path,
"code_paths": args.code_path,
"verification_requirements": args.verification_requirement,
"prepared_at": args.prepared_at,
"state": "pending",
}
)
def _pending_milestone_entry_from_args(args: argparse.Namespace) -> dict[str, Any]:
return _normalize_pending_milestone_entry(
{
"milestone_path": args.milestone_path,
"scope": args.scope,
"status_paths": args.status_path,
"frame_paths": args.frame_path,
"code_paths": args.code_path,
"verification_requirements": args.verification_requirement,
"prepared_at": args.prepared_at,
"state": "pending",
}
)
@_locked_state_command
def _prepare(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
requested = _pending_entry_from_args(args)
previous_task_path = (
_require_task_path(args.previous_task_path) if args.previous_task_path else None
)
if previous_task_path == requested["task_path"]:
raise StateError("previous_task_path must differ from task_path")
matches = [
index
for index, entry in enumerate(state["pending_code_work"])
if entry["task_path"] == requested["task_path"]
]
if len(matches) > 1:
raise StateError(f"multiple pending entries for {requested['task_path']}")
if previous_task_path is not None:
previous_matches = [
index
for index, entry in enumerate(state["pending_code_work"])
if entry["task_path"] == previous_task_path
]
if len(previous_matches) != 1:
raise StateError(
f"expected exactly one previous pending entry for {previous_task_path}, "
f"found {len(previous_matches)}"
)
if matches:
raise StateError(f"new task_path already has a pending entry: {requested['task_path']}")
existing = state["pending_code_work"][previous_matches[0]]
semantic_fields = (
"scope",
"status_paths",
"frame_paths",
"code_paths",
"verification_requirements",
"state",
)
changed = any(existing[field] != requested[field] for field in semantic_fields)
if changed and not args.replace:
raise StateError(
"rebound mapping changes scope or evidence; verify the closure child "
"and rerun with --replace"
)
del state["pending_code_work"][previous_matches[0]]
state["pending_code_work"].append(requested)
status = "rebound"
elif matches:
index = matches[0]
existing = state["pending_code_work"][index]
semantic_fields = (
"task_path",
"scope",
"status_paths",
"frame_paths",
"code_paths",
"verification_requirements",
"state",
)
if all(existing[field] == requested[field] for field in semantic_fields):
status = "already_prepared"
elif args.replace:
state["pending_code_work"][index] = requested
status = "replaced"
else:
raise StateError(
f"pending entry differs for {requested['task_path']}; "
"verify the new plan scope and rerun with --replace"
)
else:
state["pending_code_work"].append(requested)
status = "prepared"
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": status,
"task_path": requested["task_path"],
"previous_task_path": previous_task_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
@_locked_state_command
def _prepare_milestone(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
requested = _pending_milestone_entry_from_args(args)
if not Path(requested["milestone_path"]).is_file():
raise StateError(
f"active Milestone does not exist: {requested['milestone_path']}"
)
matches = [
index
for index, entry in enumerate(state["pending_milestone_work"])
if entry["milestone_path"] == requested["milestone_path"]
]
if len(matches) > 1:
raise StateError(
f"multiple pending entries for {requested['milestone_path']}"
)
if matches:
index = matches[0]
existing = state["pending_milestone_work"][index]
semantic_fields = (
"milestone_path",
"scope",
"status_paths",
"frame_paths",
"code_paths",
"verification_requirements",
"state",
)
if all(existing[field] == requested[field] for field in semantic_fields):
status = "already_prepared"
elif args.replace:
state["pending_milestone_work"][index] = requested
status = "replaced"
else:
raise StateError(
f"pending entry differs for {requested['milestone_path']}; "
"verify the Milestone scope and rerun with --replace"
)
else:
state["pending_milestone_work"].append(requested)
status = "prepared"
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": status,
"milestone_path": requested["milestone_path"],
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
def _completion_log_location(path: Path) -> tuple[Path, str]:
workspace = Path.cwd().resolve()
resolved = path.resolve()
try:
relative = resolved.relative_to(workspace)
except ValueError as exc:
raise StateError(f"completion log is outside the current workspace: {path}") from exc
normalized = _require_relative_path(relative.as_posix(), prefix="agent-task/archive/")
return resolved, normalized
def _validate_completion_log(path: Path, task_path: str) -> str:
resolved, normalized = _completion_log_location(path)
if not resolved.is_file():
raise StateError(f"completion log does not exist: {path}")
archive_parts = PurePosixPath(normalized).parts
if len(archive_parts) < 6 or archive_parts[-1] != "complete.log":
raise StateError("completion log must be under agent-task/archive/YYYY/MM/<task>/")
if not re.fullmatch(r"[0-9]{4}", archive_parts[2]) or not re.fullmatch(
r"(?:0[1-9]|1[0-2])",
archive_parts[3],
):
raise StateError("completion log archive path must contain YYYY/MM")
archived_task_parts = archive_parts[4:-1]
original_task_parts = PurePosixPath(task_path.removeprefix("agent-task/")).parts
same_parent = archived_task_parts[:-1] == original_task_parts[:-1]
final_name = archived_task_parts[-1] if archived_task_parts else ""
original_final_name = original_task_parts[-1]
suffix_match = re.fullmatch(re.escape(original_final_name) + r"(?:_[0-9]+)?", final_name)
if not same_parent or suffix_match is None:
raise StateError("completion log archive path does not match task_path")
text = resolved.read_text()
task_name = task_path.removeprefix("agent-task/")
if f"# Complete - {task_name}" not in text.splitlines()[:3]:
raise StateError("completion log task header does not match task_path")
if "## 루프 이력" not in text:
raise StateError("completion log has no loop history")
terminal = re.compile(r"^\|[^|]*\|[^|]*\|\s*(?:PASS|RESOLVED|PASS/RESOLVED)\s*\|", re.MULTILINE)
if not terminal.search(text):
raise StateError("completion log has no terminal PASS/RESOLVED row")
return normalized
@_locked_state_command
def _reconcile(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
task_path = _require_task_path(args.task_path)
completion_log = _validate_completion_log(args.completion_log, task_path)
completed = [
entry
for entry in state["reconciled_code_work"]
if entry["task_path"] == task_path and entry["completion_log"] == completion_log
]
if completed:
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "already_reconciled",
"task_path": task_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
matches = [
index
for index, entry in enumerate(state["pending_code_work"])
if entry["task_path"] == task_path
]
if len(matches) != 1:
raise StateError(f"expected exactly one pending entry for {task_path}, found {len(matches)}")
pending = state["pending_code_work"][matches[0]]
sync_result_head = args.sync_result_head
if sync_result_head is not None and not SHA_PATTERN.fullmatch(sync_result_head):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
reconciled = _normalize_reconciled_entry(
{
"task_path": task_path,
"completion_log": completion_log,
"scope": pending["scope"],
"status_paths": pending["status_paths"],
"frame_paths": pending["frame_paths"],
"code_paths": pending["code_paths"],
"validation_evidence": args.validation_evidence,
"reconciled_at": args.reconciled_at,
"sync_result_head": sync_result_head,
}
)
del state["pending_code_work"][matches[0]]
state["reconciled_code_work"].append(reconciled)
state["last_sync_mode"] = "plan-reconcile"
state["last_synced_at"] = args.reconciled_at
state["agent_ui_paths"] = pending["status_paths"] + pending["frame_paths"]
state["code_paths"] = pending["code_paths"]
if sync_result_head is not None:
state["last_synced_head"] = sync_result_head
baseline_evidence = f"baseline=updated:{sync_result_head}"
else:
baseline_evidence = "baseline=preserved:no validated sync_result_head"
state["notes"].append(
f"plan reconcile: task={task_path}; completion={completion_log}; "
f"validation={args.validation_evidence}; {baseline_evidence}"
)
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "reconciled",
"task_path": task_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
@_locked_state_command
def _reconcile_milestone(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
milestone_path = _require_milestone_path(args.milestone_path)
closure_evidence = args.closure_evidence.strip()
if not closure_evidence:
raise StateError("closure_evidence must be non-empty")
matches = [
index
for index, entry in enumerate(state["pending_milestone_work"])
if entry["milestone_path"] == milestone_path
]
if not matches:
completed = [
entry
for entry in state["reconciled_milestone_work"]
if entry["milestone_path"] == milestone_path
and entry["closure_evidence"] == closure_evidence
]
if completed:
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "already_reconciled",
"milestone_path": milestone_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
if len(matches) != 1:
raise StateError(
f"expected exactly one pending entry for {milestone_path}, "
f"found {len(matches)}"
)
if not Path(milestone_path).is_file():
raise StateError(f"active Milestone does not exist: {milestone_path}")
pending = state["pending_milestone_work"][matches[0]]
sync_result_head = args.sync_result_head
if sync_result_head is not None and not SHA_PATTERN.fullmatch(sync_result_head):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
reconciled = _normalize_reconciled_milestone_entry(
{
"milestone_path": milestone_path,
"closure_evidence": closure_evidence,
"scope": pending["scope"],
"status_paths": pending["status_paths"],
"frame_paths": pending["frame_paths"],
"code_paths": pending["code_paths"],
"validation_evidence": args.validation_evidence,
"reconciled_at": args.reconciled_at,
"sync_result_head": sync_result_head,
}
)
del state["pending_milestone_work"][matches[0]]
state["reconciled_milestone_work"].append(reconciled)
state["last_sync_mode"] = "milestone-reconcile"
state["last_synced_at"] = args.reconciled_at
state["agent_ui_paths"] = pending["status_paths"] + pending["frame_paths"]
state["code_paths"] = pending["code_paths"]
if sync_result_head is not None:
state["last_synced_head"] = sync_result_head
baseline_evidence = f"baseline=updated:{sync_result_head}"
else:
baseline_evidence = "baseline=preserved:no validated sync_result_head"
state["notes"].append(
f"milestone reconcile: milestone={milestone_path}; "
f"closure={closure_evidence}; validation={args.validation_evidence}; "
f"{baseline_evidence}"
)
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "reconciled",
"milestone_path": milestone_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
@_locked_state_command
def _record_sync(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
if not SHA_PATTERN.fullmatch(args.sync_result_head):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
synced_at = _require_timestamp(args.synced_at)
agent_ui_paths = [
_require_relative_path(path, prefix="agent-ui/")
for path in _stable_unique(args.agent_ui_path)
]
code_paths = [
_require_relative_path(path) for path in _stable_unique(args.code_path)
]
if not agent_ui_paths:
raise StateError("record-sync requires agent_ui_paths")
if args.sync_mode in {"incremental", "full"} and not code_paths:
raise StateError("incremental/full record-sync requires code_paths")
if any(path.startswith(("agent-ui/", "agent-task/")) for path in code_paths):
raise StateError("record-sync code_paths must not point to agent-ui or agent-task artifacts")
if args.sync_mode in {"incremental", "full"}:
pending_paths = {
path
for entry in state["pending_code_work"] + state["pending_milestone_work"]
for path in entry["status_paths"] + entry["frame_paths"]
}
overlapping_paths = sorted(
pending_path
for pending_path in pending_paths
if any(
_path_trees_overlap(pending_path, sync_path)
for sync_path in agent_ui_paths
)
)
if overlapping_paths:
raise StateError(
"incremental/full record-sync overlaps pending work paths: "
+ ", ".join(overlapping_paths)
)
state["last_sync_mode"] = args.sync_mode
state["last_synced_head"] = args.sync_result_head
state["last_synced_at"] = synced_at
state["agent_ui_paths"] = agent_ui_paths
state["code_paths"] = code_paths
state["notes"] = _stable_unique(state["notes"] + args.note)
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "sync_recorded",
"before_sha256": before_sha256,
"after_sha256": after_sha256,
"sync_result_head": args.sync_result_head,
}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
prepare = subparsers.add_parser("prepare")
prepare.add_argument("--state", type=Path, required=True)
prepare.add_argument("--expected-sha256", required=True)
prepare.add_argument("--task-path", required=True)
prepare.add_argument("--previous-task-path")
prepare.add_argument("--scope", default="all")
prepare.add_argument("--status-path", action="append", default=[], required=True)
prepare.add_argument("--frame-path", action="append", default=[])
prepare.add_argument("--code-path", action="append", default=[], required=True)
prepare.add_argument("--verification-requirement", action="append", default=[])
prepare.add_argument("--prepared-at", required=True)
prepare.add_argument("--replace", action="store_true")
prepare.set_defaults(handler=_prepare)
prepare_milestone = subparsers.add_parser("prepare-milestone")
prepare_milestone.add_argument("--state", type=Path, required=True)
prepare_milestone.add_argument("--expected-sha256", required=True)
prepare_milestone.add_argument("--milestone-path", required=True)
prepare_milestone.add_argument("--scope", default="all")
prepare_milestone.add_argument(
"--status-path",
action="append",
default=[],
required=True,
)
prepare_milestone.add_argument("--frame-path", action="append", default=[])
prepare_milestone.add_argument(
"--code-path",
action="append",
default=[],
required=True,
)
prepare_milestone.add_argument(
"--verification-requirement",
action="append",
default=[],
)
prepare_milestone.add_argument("--prepared-at", required=True)
prepare_milestone.add_argument("--replace", action="store_true")
prepare_milestone.set_defaults(handler=_prepare_milestone)
reconcile = subparsers.add_parser("reconcile")
reconcile.add_argument("--state", type=Path, required=True)
reconcile.add_argument("--expected-sha256", required=True)
reconcile.add_argument("--task-path", required=True)
reconcile.add_argument("--completion-log", type=Path, required=True)
reconcile.add_argument("--validation-evidence", required=True)
reconcile.add_argument("--reconciled-at", required=True)
reconcile.add_argument("--sync-result-head")
reconcile.set_defaults(handler=_reconcile)
reconcile_milestone = subparsers.add_parser("reconcile-milestone")
reconcile_milestone.add_argument("--state", type=Path, required=True)
reconcile_milestone.add_argument("--expected-sha256", required=True)
reconcile_milestone.add_argument("--milestone-path", required=True)
reconcile_milestone.add_argument("--closure-evidence", required=True)
reconcile_milestone.add_argument("--validation-evidence", required=True)
reconcile_milestone.add_argument("--reconciled-at", required=True)
reconcile_milestone.add_argument("--sync-result-head")
reconcile_milestone.set_defaults(handler=_reconcile_milestone)
record_sync = subparsers.add_parser("record-sync")
record_sync.add_argument("--state", type=Path, required=True)
record_sync.add_argument("--expected-sha256", required=True)
record_sync.add_argument(
"--sync-mode",
choices=("incremental", "full", "baseline", "migration"),
required=True,
)
record_sync.add_argument("--sync-result-head", required=True)
record_sync.add_argument("--synced-at", required=True)
record_sync.add_argument("--agent-ui-path", action="append", default=[], required=True)
record_sync.add_argument("--code-path", action="append", default=[])
record_sync.add_argument("--note", action="append", default=[])
record_sync.set_defaults(handler=_record_sync)
return parser
def main() -> int:
args = _parser().parse_args()
try:
result = args.handler(args)
except (OSError, StateError) as exc:
print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False))
return 2
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())