"""Состояние прогона и журнал доказательств.

Единственный модуль, которому разрешено писать в `.chekan/`. Всё остальное ходит
сюда, чтобы схема проверялась ровно в одном месте.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path

STATE_VERSION = 1

PHASES = ["preflight", "manifest", "briefing", "spec", "plan", "build", "accept"]
TIERS = ["T0", "T1", "T2", "T3"]
MODES = ["auto", "normal", "manual"]

REQ_STATUSES = ["open", "in-spec", "in-task", "done", "placeholder", "deferred", "dropped"]
TASK_STATUSES = ["pending", "in-progress", "review", "repair", "done", "failed"]
STAGE_STATUSES = ["pending", "active", "done", "skipped"]
QUESTION_STATUSES = ["waiting", "answered"]

# Учёт расхода. Токены инструменту взять неоткуда: их называет исполнитель, и
# сверить его не с чем. Время и число прогонов инструмент меряет сам. Поэтому у
# каждого числа рядом лежит источник, и он проверяется схемой — иначе сообщённое
# однажды пройдёт по отчёту как измеренное.
SPEND_MEASURED = "измерено"
SPEND_REPORTED = "со слов исполнителя"


class StateError(Exception):
    """Состояние не прошло проверку. Текст пишется пользователю дословно."""


def harness() -> str | None:
    """Какой агент работает — из окружения.

    Имя МОДЕЛИ в переменных не публикуется ни одним из харнессов, поэтому его
    можно только сообщить явно (`--model`). Подставлять правдоподобное нельзя:
    отчёт о том, кто делал работу, обязан быть фактом.
    """
    for var in ("AI_AGENT", "CLAUDE_CODE_ENTRYPOINT"):
        value = os.environ.get(var)
        if value:
            return value
    if (
        os.environ.get("ANTIGRAVITY_AGENT")
        or os.environ.get("ANTIGRAVITY_CONVERSATION_ID")
        or os.environ.get("ANTIGRAVITY_PROJECT_ID")
        or os.environ.get("__CFBundleIdentifier") == "com.google.antigravity"
    ):
        return "antigravity"
    if os.environ.get("CLAUDECODE"):
        return "claude-code"
    if os.environ.get("CURSOR_AGENT") or os.environ.get("CURSOR_TRACE_ID"):
        return "cursor"
    if os.environ.get("CODEX_AGENT"):
        return "codex"
    if os.environ.get("GEMINI_CLI") or os.environ.get("GEMINI_API_KEY"):
        return "gemini"
    return None


def now() -> str:
    return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")


STATE_DIR = ".chekan"


def find_root(start: Path | None = None) -> Path:
    """Корень проекта: ближайший вверх каталог с `STATE_DIR` или с `.git/`."""
    cur = (start or Path.cwd()).resolve()
    for candidate in [cur, *cur.parents]:
        if (candidate / STATE_DIR).is_dir():
            return candidate
    for candidate in [cur, *cur.parents]:
        if (candidate / ".git").exists():
            return candidate
    return cur


def git(root: Path, *args: str) -> str:
    try:
        out = subprocess.run(
            ["git", *args], cwd=root, capture_output=True, text=True, timeout=30
        )
    except (OSError, subprocess.SubprocessError):
        return ""
    return out.stdout.strip() if out.returncode == 0 else ""


def git_sha(root: Path) -> str | None:
    return git(root, "rev-parse", "HEAD") or None


def slugify(text: str, limit: int = 48) -> str:
    translit = {
        "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",
        "ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",
        "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
        "ф": "f", "х": "h", "ц": "c", "ч": "ch", "ш": "sh", "щ": "sch", "ъ": "",
        "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
    }
    out = []
    for ch in text.lower():
        if ch in translit:
            out.append(translit[ch])
        elif ch.isalnum() and ch.isascii():
            out.append(ch)
        else:
            out.append("-")
    slug = "".join(out)
    while "--" in slug:
        slug = slug.replace("--", "-")
    slug = slug.strip("-")[:limit].strip("-")
    return slug or "run"


# Имя прогона и бриф — разные вещи (R20). Имя короткое, потому что его показывают
# целиком: длину режет тот, кто имя даёт, а не тот, кто его рисует.
TITLE_LIMIT = 60
# Звено короче этого — не имя, а обрывок («Чекан:»), и такая граница пропускается.
TITLE_MIN = 12
_TITLE_STOP = re.compile(r"[:;]|\.(?=\s|$)")


def run_title(brief: str, limit: int = TITLE_LIMIT) -> str:
    """Короткое имя прогона из брифа: первое смысловое звено (R20.1).

    Звено — то, что стоит до первого двоеточия, точки или точки с запятой, и не
    длиннее шестидесяти знаков. Многоточия в конце нет намеренно: имя короткое
    само по себе, панели нечего резать, а полный текст лежит в брифе.
    """
    text = " ".join(str(brief or "").split())
    if not text:
        return "Прогон"
    head = text
    for stop in _TITLE_STOP.finditer(text):
        if stop.start() > TITLE_MIN:
            head = text[: stop.start()]
            break
    if len(head) > limit:
        head = head[:limit].rstrip()
        space = head.rfind(" ")
        if space > TITLE_MIN:
            head = head[:space].rstrip()
    return head.rstrip(" ,-—") or "Прогон"


def new_state(slug: str, title: str, mode: str, tier: str) -> dict:
    return {
        "version": STATE_VERSION,
        "slug": slug,
        "title": title,
        "mode": mode,
        "tier": tier,
        "phase": "preflight",
        "started_at": now(),
        "updated_at": now(),
        "finished_at": None,
        "brief_file": "brief.md",
        "memory_file": None,
        "stages": {p: {"status": "pending", "started_at": None, "finished_at": None} for p in PHASES},
        "gates": {},
        "requirements": [],
        "tasks": [],
        # Отрезки простоя. Пока прогон стоит, у последнего "to" равен None.
        # Хранятся отрезками, а не суммой: длительность задачи считается как
        # её собственный отрезок минус то, что пришлось на паузу, — а это
        # знание про пересечение, суммой его не заменить.
        "pauses": [],
        # Вопросы владельцу и ответы на них. Лежат в состоянии, а не отдельным
        # файлом: вопрос останавливает прогон, и «вопрос заведён» с «прогон стоит»
        # обязаны попадать на диск одной записью.
        "questions": [],
        # Идентификатор вопроса, из-за которого прогон стоит. Именно он отличает
        # ожидание ответа от ручной паузы.
        "waiting_for": None,
    }


def _is_count(value) -> bool:
    return isinstance(value, int) and not isinstance(value, bool) and value >= 0


def _spend_problems(tid: str, spend) -> list[str]:
    """Проверка записи о расходе. Главное здесь — источник каждого числа.

    Схема не даёт назвать токены измеренными: смешать сообщённое с измеренным
    можно ровно один раз, дальше отчёт врёт молча и вида не подаёт.
    """
    if not isinstance(spend, dict):
        return [f"таск {tid}: расход должен быть объектом, а не {type(spend).__name__}"]
    p: list[str] = []

    tokens = spend.get("tokens")
    src = spend.get("tokens_source")
    if tokens is None:
        if src is not None:
            p.append(f"таск {tid}: расхода токенов нет, а источник у него указан ({src!r})")
    else:
        if not _is_count(tokens):
            p.append(
                f"таск {tid}: расход токенов — целое неотрицательное число "
                f"(вход плюс выход), а не {tokens!r}"
            )
        if src != SPEND_REPORTED:
            p.append(
                f"таск {tid}: у токенов источник обязан быть {SPEND_REPORTED!r}, а стоит "
                f"{src!r}. Инструмент их не мерил и выдавать за измеренные не может"
            )

    for field, label in (("duration_ms", "время работы"), ("runs", "число прогонов")):
        if not _is_count(spend.get(field)):
            p.append(f"таск {tid}: {label} инструмент меряет сам — ожидалось целое, а не {spend.get(field)!r}")
        got = spend.get(field.replace("_ms", "") + "_source")
        if got != SPEND_MEASURED:
            p.append(f"таск {tid}: у поля «{label}» источник обязан быть {SPEND_MEASURED!r}, а стоит {got!r}")

    model = spend.get("model")
    if model is not None and (not isinstance(model, str) or not model):
        p.append(f"таск {tid}: модель — непустая строка или null, а не {model!r}")
    return p


def _fail(problems: list[str]) -> None:
    if problems:
        raise StateError("состояние не прошло проверку:\n  - " + "\n  - ".join(problems))


def validate(state: dict) -> None:
    """Проверка схемы. Вызывается перед КАЖДОЙ записью на диск."""
    p: list[str] = []

    if not isinstance(state, dict):
        raise StateError("состояние не является объектом")
    if state.get("version") != STATE_VERSION:
        p.append(f"version должен быть {STATE_VERSION}, а не {state.get('version')!r}")
    for key in ("slug", "title", "started_at", "updated_at"):
        if not isinstance(state.get(key), str) or not state[key]:
            p.append(f"{key} должен быть непустой строкой")
    if state.get("mode") not in MODES:
        p.append(f"mode вне {MODES}: {state.get('mode')!r}")
    if state.get("tier") not in TIERS:
        p.append(f"tier вне {TIERS}: {state.get('tier')!r}")
    if state.get("phase") not in PHASES:
        p.append(f"phase вне {PHASES}: {state.get('phase')!r}")

    stages = state.get("stages")
    if not isinstance(stages, dict):
        p.append("stages должен быть объектом")
    else:
        for name in PHASES:
            st = stages.get(name)
            if not isinstance(st, dict):
                p.append(f"stages.{name} отсутствует")
            elif st.get("status") not in STAGE_STATUSES:
                p.append(f"stages.{name}.status вне {STAGE_STATUSES}: {st.get('status')!r}")

    seen_req: set[str] = set()
    reqs = state.get("requirements")
    if not isinstance(reqs, list):
        p.append("requirements должен быть списком")
    else:
        for i, r in enumerate(reqs):
            if not isinstance(r, dict):
                p.append(f"requirements[{i}] не объект")
                continue
            rid = r.get("id")
            if not isinstance(rid, str) or not rid:
                p.append(f"requirements[{i}].id пуст")
            elif rid in seen_req:
                p.append(f"дубль требования {rid}")
            else:
                seen_req.add(rid)
            if r.get("status") not in REQ_STATUSES:
                p.append(f"{rid}: status вне {REQ_STATUSES}: {r.get('status')!r}")
            if not isinstance(r.get("quote", ""), str):
                p.append(f"{rid}: quote должна быть строкой")
            if r.get("status") == "dropped" and not r.get("dropped_by_user"):
                p.append(f"{rid}: статус dropped может поставить только владелец")

    seen_task: set[str] = set()
    tasks = state.get("tasks")
    if not isinstance(tasks, list):
        p.append("tasks должен быть списком")
    else:
        for i, t in enumerate(tasks):
            if not isinstance(t, dict):
                p.append(f"tasks[{i}] не объект")
                continue
            tid = t.get("id")
            if not isinstance(tid, str) or not tid:
                p.append(f"tasks[{i}].id пуст")
            elif tid in seen_task:
                p.append(f"дубль таска {tid}")
            else:
                seen_task.add(tid)
            if t.get("status") not in TASK_STATUSES:
                p.append(f"таск {tid}: status вне {TASK_STATUSES}: {t.get('status')!r}")
            for dep in t.get("deps", []) or []:
                if dep == tid:
                    p.append(f"таск {tid} зависит сам от себя")
            for rid in t.get("requirements", []) or []:
                if rid not in seen_req:
                    p.append(f"таск {tid} ссылается на несуществующее требование {rid}")
            if t.get("spend") is not None:
                p += _spend_problems(tid, t["spend"])

    # Старые прогоны заведены до появления пауз — ключа у них нет, и это законно.
    pauses = state.get("pauses", [])
    if not isinstance(pauses, list):
        p.append("pauses должен быть списком")
    else:
        for i, span in enumerate(pauses):
            if not isinstance(span, dict):
                p.append(f"pauses[{i}] не объект")
                continue
            if not isinstance(span.get("from"), str) or not span["from"]:
                p.append(f"pauses[{i}].from должен быть непустой строкой")
            if span.get("to") is not None and not isinstance(span.get("to"), str):
                p.append(f"pauses[{i}].to должен быть строкой или null")
        open_spans = [s for s in pauses if isinstance(s, dict) and s.get("to") is None]
        if len(open_spans) > 1:
            p.append("больше одной незакрытой паузы — состояние противоречиво")
        if open_spans and pauses and pauses[-1].get("to") is not None:
            p.append("незакрытая пауза должна быть последней")

    # Вопросы владельцу. Ключа нет у прогонов, заведённых до их появления.
    questions = state.get("questions", [])
    q_open: list[str] = []
    q_ids: set[str] = set()
    if not isinstance(questions, list):
        p.append("questions должен быть списком")
        questions = []
    else:
        for i, q in enumerate(questions):
            if not isinstance(q, dict):
                p.append(f"questions[{i}] не объект")
                continue
            qid = q.get("id")
            if not isinstance(qid, str) or not qid:
                p.append(f"questions[{i}].id пуст")
                continue
            if qid in q_ids:
                p.append(f"дубль вопроса {qid}")
            q_ids.add(qid)
            if not isinstance(q.get("text"), str) or not q["text"].strip():
                p.append(f"вопрос {qid}: текст пуст — спрашивать нечего")
            if not isinstance(q.get("asked_at"), str) or not q["asked_at"]:
                p.append(f"вопрос {qid}: asked_at должен быть непустой строкой")
            if q.get("status") not in QUESTION_STATUSES:
                p.append(f"вопрос {qid}: status вне {QUESTION_STATUSES}: {q.get('status')!r}")
            if q.get("status") == "waiting":
                q_open.append(qid)
                if q.get("answer"):
                    p.append(f"вопрос {qid} ждёт ответа, но ответ у него уже записан")
            elif q.get("status") == "answered":
                ans = q.get("answer")
                if not isinstance(ans, dict):
                    p.append(f"вопрос {qid} отвечен, но ответа рядом нет")
                else:
                    for field in ("text", "at", "by"):
                        if not isinstance(ans.get(field), str) or not ans[field]:
                            p.append(f"вопрос {qid}: в ответе пусто поле {field}")

    # Ожидание ответа — не мнение, а состояние: признак, пауза и сам вопрос
    # обязаны говорить одно и то же. Разойдутся — прогон «стоит», а задачи
    # берутся в работу, и никто этого не замечает.
    waiting_for = state.get("waiting_for")
    spans = pauses if isinstance(pauses, list) else []
    open_pause = bool(spans) and isinstance(spans[-1], dict) and spans[-1].get("to") is None
    if len(q_open) > 1:
        p.append("больше одного вопроса ждёт ответа: " + ", ".join(q_open))
    if waiting_for is not None:
        if not isinstance(waiting_for, str):
            p.append("waiting_for должен быть строкой или null")
        elif waiting_for not in q_ids:
            p.append(f"waiting_for указывает на несуществующий вопрос {waiting_for}")
        elif waiting_for not in q_open:
            p.append(f"waiting_for указывает на {waiting_for}, а тот уже отвечен")
        elif not open_pause:
            p.append(f"прогон ждёт ответа на {waiting_for}, но не стоит на паузе")
    elif q_open:
        p.append(f"вопрос {q_open[0]} ждёт ответа, но признака ожидания в состоянии нет")

    if isinstance(tasks, list):
        for cycle in _find_cycles(tasks):
            p.append("цикл в зависимостях тасков: " + " → ".join(cycle))

    _fail(p)


def _ms(stamp: str | None) -> int | None:
    """ISO-строка в миллисекунды. Формат пишем сами, но чужие прогоны бывают."""
    if not isinstance(stamp, str) or not stamp:
        return None
    text = stamp.replace("Z", "+00:00")
    try:
        return int(datetime.fromisoformat(text).timestamp() * 1000)
    except ValueError:
        return None


def paused_ms_between(state: dict, start: str | None, end: str | None = None) -> int:
    """Сколько из отрезка [start, end] прогон простоял на паузе.

    Нужно, чтобы длительность задачи не росла, пока никто не работает: иначе
    медиана и оценка «осталось» врут ровно на время простоя, а владелец на них
    смотрит. Незакрытая пауза считается до сих пор.
    """
    a = _ms(start)
    if a is None:
        return 0
    b = _ms(end) if end else int(datetime.now(timezone.utc).timestamp() * 1000)
    if b is None or b <= a:
        return 0
    total = 0
    for span in state.get("pauses", []) or []:
        if not isinstance(span, dict):
            continue
        pa = _ms(span.get("from"))
        if pa is None:
            continue
        pb = _ms(span.get("to")) if span.get("to") else b
        if pb is None or pb <= pa:
            continue
        overlap = min(b, pb) - max(a, pa)
        if overlap > 0:
            total += overlap
    return total


def task_work_ms(state: dict, task: dict) -> int:
    """Время работы над задачей: от взятия до закрытия минус простой.

    Простой вычитается по той же причине, что и в оценке «осталось»: пока прогон
    стоит, никто не работает, и записывать паузу расходом — то же враньё, что
    записывать чужие токены своими.
    """
    start = task.get("started_at")
    a = _ms(start)
    if a is None:
        return 0
    end = task.get("closed_at")
    b = _ms(end) if end else int(datetime.now(timezone.utc).timestamp() * 1000)
    if b is None or b <= a:
        return 0
    return max(0, (b - a) - paused_ms_between(state, start, end))


def _find_cycles(tasks: list[dict]) -> list[list[str]]:
    graph = {t.get("id"): list(t.get("deps") or []) for t in tasks if isinstance(t, dict)}
    cycles: list[list[str]] = []
    state: dict[str, int] = {}

    def walk(node: str, path: list[str]) -> None:
        if state.get(node) == 2:
            return
        if state.get(node) == 1:
            if node in path:
                cycles.append(path[path.index(node):] + [node])
            return
        state[node] = 1
        for nxt in graph.get(node, []):
            if nxt in graph:
                walk(nxt, path + [node])
        state[node] = 2

    for node in list(graph):
        walk(node, [])
    return cycles


def is_pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError):
        return False


@contextmanager
def _evidence_lock(run_dir: Path):
    """Замок журнала доказательств.

    Отдельный от замка состояния файл: состояние правится длинными транзакциями,
    и запирать дозапись журнала тем же замком значило бы ждать их без причины —
    а вложенный захват того же файла в одном процессе просто зависает.
    """
    import fcntl

    run_dir.mkdir(parents=True, exist_ok=True)
    fh = open(run_dir / ".evidence.lock", "a+")
    try:
        fcntl.flock(fh, fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(fh, fcntl.LOCK_UN)
    finally:
        fh.close()


class Run:
    """Один прогон автопилота. Владеет чтением и записью состояния."""

    def __init__(self, root: Path, slug: str):
        self.root = root
        self.slug = slug
        self.dir = root / STATE_DIR / slug
        self.state_path = self.dir / "state.json"
        self.evidence_path = self.dir / "evidence.jsonl"
        self._state: dict | None = None
        self._manifest_broken = False

    # --- поиск и создание ---

    @staticmethod
    def active_slug(root: Path) -> str | None:
        pointer = root / STATE_DIR / "active"
        if not pointer.is_file():
            return None
        try:
            slug = pointer.read_text(encoding="utf-8").strip()
        except OSError:
            return None
        # Слаг подставляется в путь, поэтому «..» и разделители уводили бы запись
        # за пределы каталога прогонов.
        if not slug or "/" in slug or "\\" in slug or slug.startswith("."):
            return None
        return slug if (root / STATE_DIR / slug / "state.json").is_file() else None

    @classmethod
    def load(cls, root: Path | None = None) -> "Run":
        root = root or find_root()
        slug = cls.active_slug(root)
        if not slug:
            raise StateError(
                "активного прогона нет. Начните с `chekan init \"<что нужно построить>\"`"
            )
        run = cls(root, slug)
        run.state  # noqa: B018 — тут же валидируем
        return run

    @classmethod
    def create(cls, root: Path, title: str, mode: str, tier: str, brief: str | None = None) -> "Run":
        # Бриф ложится в brief.md целиком, имя прогона сворачивается до звена.
        # Одной строкой они были до R20: заголовком становился весь абзац.
        text = (title if brief is None else brief).strip()
        title = run_title(title)
        slug = slugify(title)
        base = root / STATE_DIR
        if (base / slug / "state.json").is_file():
            n = 2
            while (base / f"{slug}-{n}" / "state.json").is_file():
                n += 1
            slug = f"{slug}-{n}"
        if (base / slug).exists() and any((base / slug).iterdir()):
            # Каталог остался от прогона, чьё состояние потеряно. Переиспользовать
            # его нельзя: новый таск закрылся бы доказательством из чужого прогона.
            n = 2
            while (base / f"{slug}-{n}").exists():
                n += 1
            slug = f"{slug}-{n}"
        run = cls(root, slug)
        run.dir.mkdir(parents=True, exist_ok=True)
        (run.dir / "tasks").mkdir(exist_ok=True)
        run.evidence_path.touch()
        run.save(new_state(slug, title, mode, tier))
        (run.dir / "brief.md").write_text(text + "\n", encoding="utf-8")
        (base / "active").write_text(slug + "\n", encoding="utf-8")
        return run

    # --- имя и бриф ---

    def brief(self) -> str:
        """Полный текст брифа. Имя прогона его не заменяет и не обрезает."""
        path = self.dir / (self.state.get("brief_file") or "brief.md")
        try:
            return path.read_text(encoding="utf-8").strip()
        except OSError:
            return self.state["title"]

    def _fold_title(self, state: dict) -> None:
        """Прогон, заведённый до R20: в `title` лежал весь бриф.

        Имя сворачивается по тому же правилу, что и у новых, но не молча: полный
        текст сначала оказывается в брифе, и только потом состояние про него
        забывает. Файл брифа у таких прогонов уже есть — тогда трогать нечего.
        """
        full = state.get("title")
        if not isinstance(full, str) or len(full) <= TITLE_LIMIT:
            return
        path = self.dir / (state.get("brief_file") or "brief.md")
        if not path.is_file():
            try:
                path.write_text(full + "\n", encoding="utf-8")
            except OSError:
                return  # не сохранив бриф, имя не сворачиваем: текст потеряется
        state["title"] = run_title(full)

    # --- состояние ---

    @property
    def state(self) -> dict:
        if self._state is None:
            if not self.state_path.is_file():
                bak = self.state_path.with_suffix(".json.bak")
                if bak.is_file():
                    try:
                        self._state = json.loads(bak.read_text(encoding="utf-8"))
                        validate(self._state)
                        self._fold_title(self._state)
                        return self._state
                    except Exception:
                        pass
                raise StateError(f"нет файла состояния: {self.state_path}")
            try:
                self._state = json.loads(self.state_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError as exc:
                bak = self.state_path.with_suffix(".json.bak")
                if bak.is_file():
                    try:
                        self._state = json.loads(bak.read_text(encoding="utf-8"))
                        validate(self._state)
                        self._fold_title(self._state)
                        return self._state
                    except Exception:
                        pass
                raise StateError(
                    f"{self.state_path} — битый JSON ({exc}). "
                    "Состояние правится только через `chekan`, руками его трогать нельзя."
                ) from exc
            validate(self._state)
            self._fold_title(self._state)
        return self._state

    def _lock(self):
        import fcntl
        from contextlib import contextmanager

        @contextmanager
        def guard():
            self.dir.mkdir(parents=True, exist_ok=True)
            lock_file = self.dir / ".lock"
            fh = open(lock_file, "a+")
            try:
                try:
                    fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
                except (OSError, BlockingIOError):
                    try:
                        lock_content = lock_file.read_text(encoding="utf-8").strip()
                        if lock_content.isdigit():
                            recorded_pid = int(lock_content)
                            if not is_pid_alive(recorded_pid):
                                fh.close()
                                lock_file.unlink(missing_ok=True)
                                fh = open(lock_file, "a+")
                    except Exception:
                        pass
                    fcntl.flock(fh, fcntl.LOCK_EX)
                try:
                    fh.seek(0)
                    fh.truncate()
                    fh.write(f"{os.getpid()}\n")
                    fh.flush()
                except Exception:
                    pass
                yield
            finally:
                try:
                    fcntl.flock(fh, fcntl.LOCK_UN)
                finally:
                    fh.close()

        return guard()

    def mutate(self):
        """Атомарная правка состояния: блокировка охватывает чтение И запись.

        Запирать только запись бесполезно: два процесса читают одно и то же
        состояние, потом по очереди пишут целиком, и правка первого пропадает —
        причём обе команды рапортуют успех, а манифест согласован с усечённым
        состоянием, так что потеря не видна нигде.
        """
        import shutil
        from contextlib import contextmanager

        @contextmanager
        def transaction():
            with self._lock():
                self._state = None  # перечитать с диска ВНУТРИ блокировки
                state = self.state
                yield state
                state["updated_at"] = now()
                validate(state)
                if self.state_path.is_file():
                    bak = self.state_path.with_suffix(".json.bak")
                    try:
                        shutil.copyfile(self.state_path, bak)
                    except Exception:
                        pass
                tmp = self.state_path.with_suffix(".json.tmp")
                tmp.write_text(
                    json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
                )
                os.replace(tmp, self.state_path)
                self._state = state
            self._write_manifest(state)

        return transaction()

    def save(self, state: dict | None = None) -> None:
        import shutil

        state = state if state is not None else self.state
        state["updated_at"] = now()
        validate(state)
        tmp = self.state_path.with_suffix(".json.tmp")
        with self._lock():
            if self.state_path.is_file():
                bak = self.state_path.with_suffix(".json.bak")
                try:
                    shutil.copyfile(self.state_path, bak)
                except Exception:
                    pass
            tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
            os.replace(tmp, self.state_path)
        self._state = state
        self._write_manifest(state)

    def _write_manifest(self, state: dict) -> None:
        """Пересобрать `manifest.md` — читаемую проекцию состояния.

        Единственная точка врезки: всё, что меняет требования, идёт через `save()`,
        поэтому файл нельзя оставить старым, не обойдя сохранение состояния.
        """
        from . import manifest  # локально: manifest берёт константы отсюда, наверху был бы цикл

        path = self.dir / "manifest.md"
        try:
            path.write_text(manifest.render(state), encoding="utf-8")
        except Exception as exc:
            # Состояние уже на диске и оно источник истины; проекция догонит на следующем save().
            # Причина одна на всю поломку, а сохранений за прогон десятки — жалуемся один раз,
            # пока запись не пройдёт, иначе предупреждение утонет в собственных повторах.
            if not self._manifest_broken:
                reason = getattr(exc, "strerror", None) or exc  # текст OSError повторяет путь
                print(
                    f"внимание: не удалось обновить {path} ({reason}). "
                    "Состояние сохранено, файл отстал от него.",
                    file=sys.stderr,
                )
                self._manifest_broken = True
            return
        self._manifest_broken = False

    # --- фазы ---

    def enter_phase(self, name: str) -> None:
        if name not in PHASES:
            raise StateError(f"неизвестная фаза {name!r}")
        st = self.state
        cur = st["phase"]
        if cur != name and st["stages"][cur]["status"] == "active":
            st["stages"][cur]["status"] = "done"
            st["stages"][cur]["finished_at"] = now()
        st["phase"] = name
        stage = st["stages"][name]
        if stage["status"] in ("pending", "skipped"):
            stage["status"] = "active"
            stage["started_at"] = now()
        self.save(st)

    def skip_phase(self, name: str, reason: str) -> None:
        st = self.state
        st["stages"][name] = {
            "status": "skipped",
            "started_at": now(),
            "finished_at": now(),
            "reason": reason,
        }
        self.save(st)

    # --- требования ---

    # --- пауза ---

    def is_paused(self) -> bool:
        pauses = self.state.get("pauses", []) or []
        return bool(pauses) and pauses[-1].get("to") is None

    def paused_since(self) -> str | None:
        return self.state["pauses"][-1]["from"] if self.is_paused() else None

    def waiting_for(self) -> str | None:
        """Идентификатор вопроса, из-за которого прогон стоит. Иначе None.

        Этим ожидание отличается от ручной паузы: `is_paused()` истинно в обоих
        случаях, а причина — только здесь.
        """
        qid = self.state.get("waiting_for")
        return qid if isinstance(qid, str) and qid else None

    def pause(self) -> bool:
        """Возвращает False, если прогон уже стоял: повтор не должен плодить отрезки."""
        if self.is_paused():
            return False
        with self.mutate() as st:
            st.setdefault("pauses", []).append({"from": now(), "to": None})
        return True

    def resume(self) -> bool:
        """Возвращает False, если снимать нечего — или если снимать нельзя.

        Пауза ожидания ответа руками не снимается: пока вопроса не разобрали,
        «продолжить» значит продолжить без ответа, ради которого и остановились.
        Снимает её сам ответ (`chekan answer`).
        """
        if self.waiting_for():
            return False
        if not self.is_paused():
            return False
        with self.mutate() as st:
            st["pauses"][-1]["to"] = now()
        return True

    def requirement(self, rid: str) -> dict | None:
        return next((r for r in self.state["requirements"] if r["id"] == rid), None)

    def add_requirement(self, rid: str, quote: str, note: str = "") -> dict:
        with self.mutate() as st:
            if any(r["id"] == rid for r in st["requirements"]):
                raise StateError(f"требование {rid} уже есть")
            req = {"id": rid, "quote": quote, "note": note, "status": "open", "tasks": []}
            st["requirements"].append(req)
        return req

    def set_requirement_status(self, rid: str, status: str, by_user: bool = False) -> None:
        if status not in REQ_STATUSES:
            raise StateError(f"статус {status!r} вне {REQ_STATUSES}")
        st = self.state
        req = next((r for r in st["requirements"] if r["id"] == rid), None)
        if req is None:
            raise StateError(f"нет требования {rid}")
        if status == "dropped" and not by_user:
            raise StateError(
                f"{rid}: снять требование может только владелец. "
                "Нужен явный `chekan req drop` с цитатой его решения."
            )
        req["status"] = status
        if status == "dropped":
            req["dropped_by_user"] = True
        self.save(st)

    # --- таски ---

    def task(self, tid: str) -> dict | None:
        return next((t for t in self.state["tasks"] if t["id"] == tid), None)

    def add_task(
        self,
        tid: str,
        title: str,
        requirements: list[str],
        deps: list[str] | None = None,
        zone: str = "",
        verify: str = "",
    ) -> dict:
        st = self.state
        if self.task(tid):
            raise StateError(f"таск {tid} уже есть")
        task = {
            "id": tid,
            "title": title,
            "status": "pending",
            "requirements": requirements,
            "deps": deps or [],
            "zone": zone,
            "verify": verify,
            "commit": None,
            "agent": harness(),
            "handoffs": 0,
            "repairs": 0,
        }
        st["tasks"].append(task)
        for rid in requirements:
            req = next((r for r in st["requirements"] if r["id"] == rid), None)
            if req is not None:
                req.setdefault("tasks", [])
                if tid not in req["tasks"]:
                    req["tasks"].append(tid)
                if req["status"] in ("open", "in-spec"):
                    req["status"] = "in-task"
        self.save(st)
        return task

    def set_task_model(self, tid: str, model: str) -> None:
        with self.mutate() as st:
            task = next((t for t in st["tasks"] if t["id"] == tid), None)
            if task is None:
                raise StateError(f"нет таска {tid}")
            task["model"] = model

    def record_spend(self, tid: str, tokens: int | None = None) -> dict:
        """Записать расход по задаче. Зовётся при закрытии, после `closed_at`.

        Модель, время работы и число прогонов инструмент берёт у себя. Токены —
        только те, что назвал исполнитель: не назвал, значит расход не измерен,
        и это записывается словом, а не нулём.
        """
        if tokens is not None and not _is_count(tokens):
            raise StateError(
                "расход токенов — целое неотрицательное число: сумма входа и выхода "
                f"за задачу. Дано: {tokens!r}"
            )
        runs = sum(1 for row in self.evidence(tid) if row.get("exit") is not None)
        with self.mutate() as st:
            task = next((t for t in st["tasks"] if t["id"] == tid), None)
            if task is None:
                raise StateError(f"нет таска {tid}")
            spend = {
                "model": task.get("model") or None,
                "agent": task.get("agent") or harness(),
                "duration_ms": task_work_ms(st, task),
                "duration_source": SPEND_MEASURED,
                "runs": runs,
                "runs_source": SPEND_MEASURED,
                "tokens": tokens,
                "tokens_source": SPEND_REPORTED if tokens is not None else None,
            }
            task["spend"] = spend
        return spend

    def spend_rows(self) -> list[dict]:
        """Расход по задачам одной таблицей — источник чисел для отчёта и панели.

        Строка появляется у каждой закрытой задачи, даже если записи о расходе
        у неё нет: пропустить такую значит выдать неполную сумму за полную.
        """
        rows = []
        for t in self.state["tasks"]:
            spend = t.get("spend")
            if not isinstance(spend, dict) and t.get("status") != "done":
                continue
            spend = spend if isinstance(spend, dict) else {}
            rows.append(
                {
                    "id": t["id"],
                    "title": t.get("title", ""),
                    "model": spend.get("model") or t.get("model"),
                    "agent": spend.get("agent") or t.get("agent"),
                    "tokens": spend.get("tokens"),
                    "tokens_source": spend.get("tokens_source"),
                    "duration_ms": spend.get("duration_ms"),
                    "runs": spend.get("runs"),
                    "recorded": bool(spend),
                }
            )
        return rows

    def set_task_status(self, tid: str, status: str) -> None:
        if status not in TASK_STATUSES:
            raise StateError(f"статус {status!r} вне {TASK_STATUSES}")
        with self.mutate() as st:
            task = next((t for t in st["tasks"] if t["id"] == tid), None)
            if task is None:
                raise StateError(f"нет таска {tid}")
            task["status"] = status
            if status == "in-progress" and not task.get("started_at"):
                task["started_at"] = now()
                task["agent"] = harness()
            if status != "done":
                # Иначе провалённый таск оставлял требование закрытым, и владелец
                # читал в манифесте «закрыто» о работе, которая провалилась.
                task["closed_at"] = None
                for rid in task.get("requirements", []):
                    req = next((r for r in st["requirements"] if r["id"] == rid), None)
                    if req and req["status"] == "done":
                        req["status"] = "in-task"

    def ready_tasks(self) -> list[dict]:
        """Таски, у которых закрылись все зависимости."""
        done = {t["id"] for t in self.state["tasks"] if t["status"] == "done"}
        return [
            t
            for t in self.state["tasks"]
            if t["status"] == "pending" and all(d in done for d in t.get("deps", []))
        ]

    # --- журнал доказательств ---

    def evidence(self, task: str | None = None) -> list[dict]:
        if not self.evidence_path.is_file():
            return []
        rows = []
        for line in self.evidence_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                row = json.loads(line)
            except json.JSONDecodeError:
                continue
            if task is None or row.get("task") == task:
                rows.append(row)
        return rows

    def append_evidence(self, row: dict) -> None:
        # Отрыв (R07) завёл параллельных писателей: длинная строка доказательства
        # не влезает в один системный вызов, и без замка два прогона дописывают
        # журнал вперемешку — строка рвётся и перестаёт читаться как JSON.
        with _evidence_lock(self.dir):
            with self.evidence_path.open("a", encoding="utf-8") as fh:
                fh.write(json.dumps(row, ensure_ascii=False) + "\n")
                fh.flush()

    def green_evidence(self, tid: str) -> dict | None:
        """Последнее успешное доказательство для таска, если оно есть."""
        for row in reversed(self.evidence(tid)):
            if row.get("exit") == 0:
                return row
        return None
