903 lines
35 KiB
Python
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())
|