"""Pure path computation for an okstra run.

Inputs: project root, identity (project-id/task-group/task-id), task-type,
workspace root, optional run-seq override.

Output: dict with every path value the bash render-context used to expose as
environment variables (TASK_ROOT, RUN_DIR, RUN_MANIFESTS_DIR, RUN_MANIFEST_PATH,
FINAL_REPORT_PATH, ...). 호출자가 이 dict 를 그대로 인자로 사용하거나
`write_run_context()` 로 디스크에 박는다.

이 모듈은 read-only 한 path 계산만 담당한다. 디렉터리 생성, 파일 쓰기,
seq advancement 같은 부수효과는 `run_context.py` 가 담당한다.
"""
from __future__ import annotations

import os
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Optional

from okstra_project.dirs import (
    DISCOVERY_RELATIVE,
    TASK_MANIFEST_FILENAME,
    OKSTRA_RELATIVE,
    TASKS_RELATIVE,
    okstra_home,
)
from okstra_project.state import slugify

__all__ = [
    "OKSTRA_RELATIVE",
    "TASKS_RELATIVE",
    "DISCOVERY_RELATIVE",
    "RunRef",
    "runs_dir_of",
    "compute_run_paths",
    "next_run_seq",
    "resolve_under_root",
    "task_dir",
    "task_runs_dir",
    "container_paths",
    "okstra_home",
    "find_asset_root",
]


def find_asset_root(
    relative: Sequence[str],
    *,
    start: Optional[Path] = None,
    is_present: Callable[[Path], bool] = Path.is_file,
) -> Optional[Path]:
    """Return the runtime root that carries ``relative``, or None.

    okstra's assets ship in three layouts that put the same tree at a different
    depth relative to this package: a repo checkout has `scripts/okstra_ctl/`,
    the built runtime has `runtime/python/okstra_ctl/`, and an install has
    `~/.okstra/lib/python/okstra_ctl/` with the asset trees two levels further
    up at `~/.okstra/`. Probing for the asset therefore beats counting parents,
    which is right for exactly one of the three.

    Pass ``is_present=Path.is_dir`` when ``relative`` names a directory.

    This reads `OKSTRA_HOME` straight from the environment rather than calling
    `okstra_home()`, and the difference is load-bearing: `okstra_home()` falls
    back to `~/.okstra` when the variable is unset, which would let an
    installed copy shadow the checkout a developer is running from. An unset
    variable — or one whose tree lacks the asset — falls through to the walk.
    """
    override = os.environ.get("OKSTRA_HOME")
    if override:
        root = Path(override)
        if is_present(root.joinpath(*relative)):
            return root

    here = Path(start or __file__).resolve()
    for parent in [here, *here.parents]:
        if is_present(parent.joinpath(*relative)):
            return parent
    return None


_STAGED_TASK_TYPES = ("implementation", "final-verification")
# 발견용: 정본 `final-report-*.data.json` 과, 옛 원장 행이 가리키는 열람본.
_REPORT_NAME_RE = re.compile(r"^final-report-.+\.(?:md|data\.json)$")
# seq 추출용: `final-report-<task-type>-<NNN>.data.json` (옛 열람본 이름도).
_REPORT_SEQ_RE = re.compile(
    r"^final-report-.+-(?P<seq>\d{3,})\.(?:md|data\.json)$"
)
_TASKS_SEGMENTS = Path(TASKS_RELATIVE).parts


def _newest_report(reports_dir: Path) -> Optional[Path]:
    """reports 디렉터리에서 mtime 최신 final-report. 동률이면 basename 큰 쪽."""
    if not reports_dir.is_dir():
        return None
    files = [
        entry for entry in reports_dir.iterdir()
        if entry.is_file() and _REPORT_NAME_RE.match(entry.name)
    ]
    records = [entry for entry in files if entry.name.endswith(".data.json")]
    found = records or files
    if not found:
        return None
    return max(found, key=lambda p: (p.stat().st_mtime, p.name))


def _project_root_of(task_root: Path) -> Optional[Path]:
    """canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.

    검증기는 이 형태가 아닌 트리(테스트 픽스처, 이식된 번들)에도 돌아간다.
    그때는 project root 를 알 수 없으므로 지어내지 않고 None 을 돌려준다 —
    경로 유래 ref 는 task_root 를 고정하므로 계산에 필요하지도 않다.
    """
    parts = task_root.parts
    if len(parts) > len(_TASKS_SEGMENTS) + 2 and parts[-4:-2] == _TASKS_SEGMENTS:
        return Path(*parts[:-4])
    return None


@dataclass(frozen=True)
class RunRef:
    """하나의 run 을 identity 로 지명하는 read-side 참조.

    산출물 경로를 종류별로 계산해 돌려주되 내용은 읽지 않는다 — 파싱은
    consumers / stage_targets / run_context 같은 기존 로더가 계속 소유한다.

    stage 세그먼트는 아티팩트 종류마다 다르게 적용된다. run 디렉터리는
    stage-isolated 이지만 carry 사이드카는 flat 이므로(stage N+1 이 N 의 사이드카를
    N 의 run 레이아웃을 모른 채 찾아야 한다), 그 예외는 `carry()` 안에만 산다.
    호출자가 `run_dir` 에서 손으로 올라가면 안 된다.

    `compute_run_paths()` 가 같은 레이아웃을 write-side 에서 계산한다. 둘의 합의는
    tests/contract/test_task_path_ssot.py 가 잠근다.
    """

    project_root: Optional[Path]
    task_group: str
    task_id: str
    task_type: str
    seq: Optional[int] = None
    stage: Optional[int] = None
    # 경로에서 만든 ref 는 받은 task_root 를 고정한다. identity 로 재계산하면
    # canonical 하지 않은 트리에서 조용히 다른 곳을 가리킨다.
    task_root_override: Optional[Path] = None
    # 경로에서 만든 ref 는 찾은 파일을 그대로 고정한다 — 레거시 타임스탬프
    # 이름은 identity 로 재구성할 수 없다.
    report_override: Optional[Path] = None

    @property
    def task_root(self) -> Path:
        if self.task_root_override is not None:
            return self.task_root_override
        if self.project_root is None:
            raise ValueError("RunRef has neither a project_root nor a task_root")
        return task_dir(self.project_root, self.task_group, self.task_id)

    @property
    def runs_dir(self) -> Path:
        return runs_dir_of(self.task_root)

    @property
    def run_dir(self) -> Path:
        segment = slugify(self.task_type)
        run_dir = self.runs_dir / segment
        if segment in _STAGED_TASK_TYPES and self.stage is not None:
            return run_dir / f"stage-{int(self.stage)}"
        return run_dir

    @property
    def reports_dir(self) -> Path:
        return self.run_dir / "reports"

    @property
    def carry_dir(self) -> Path:
        """implementation 의 carry 는 stage-SHARED 라 flat 이다(§carry 참고)."""
        segment = slugify(self.task_type)
        if segment == "implementation":
            return self.runs_dir / segment / "carry"
        return self.run_dir / "carry"

    @property
    def report(self) -> Path:
        if self.report_override is not None:
            return self.report_override
        return self.reports_dir / f"final-report{self._suffix}.data.json"

    @property
    def manifest(self) -> Path:
        return self.run_dir / "manifests" / f"run-manifest{self._suffix}.json"

    def carry(self, stage: int) -> Path:
        """stage 의 carry 사이드카. implementation 은 stage-SHARED(flat)다."""
        return self.carry_dir / f"stage-{int(stage)}.json"

    @property
    def _suffix(self) -> str:
        if self.seq is None:
            raise ValueError(
                "RunRef.seq is unset — a seq-less ref can name the run directory "
                "but not an artifact file. Use RunRef.latest() or pass seq."
            )
        return f"-{slugify(self.task_type)}-{int(self.seq):03d}"

    @classmethod
    def from_task_root(
        cls,
        task_root: Path,
        task_type: str,
        *,
        seq: Optional[int] = None,
        stage: Optional[int] = None,
    ) -> "RunRef":
        """task_root 경로만 쥔 호출자를 위한 어댑터.

        받은 task_root 를 고정하므로 canonical 하지 않은 트리에서도 왕복한다.
        복원되는 group/id 는 slug 세그먼트다.
        """
        task_root = Path(task_root)
        return cls(
            project_root=_project_root_of(task_root),
            task_group=task_root.parent.name,
            task_id=task_root.name,
            task_type=task_type,
            seq=seq,
            stage=stage,
            task_root_override=task_root,
        )

    @classmethod
    def from_run_dir(cls, run_dir: Path, *, seq: Optional[int] = None) -> "RunRef":
        """run 디렉터리에서 정체를 복원한다.

        `<task_root>/runs/<task-type>[/stage-<N>]` 를 해석한다. `runs` 앵커가
        없으면 ValueError — 관대한 폴백이 필요한 호출자는 직접 감싸라.
        """
        run_dir = Path(run_dir)
        stage = None
        if run_dir.name.startswith("stage-"):
            stage = int(run_dir.name[len("stage-"):])
            run_dir = run_dir.parent
        if run_dir.parent.name != "runs":
            raise ValueError(f"not an okstra run directory: {run_dir}")

        return cls.from_task_root(
            run_dir.parent.parent, run_dir.name, seq=seq, stage=stage
        )

    @classmethod
    def from_report_path(cls, report_path: Path) -> "RunRef":
        """final-report 경로에서 정체를 복원한다.

        복원되는 task-group/task-id 는 slug 세그먼트다. slugify 가 멱등이라
        경로 왕복에는 영향이 없지만, 원래 표기가 필요하면 task-manifest 를 읽어라.
        """
        report_path = Path(report_path)
        if not _REPORT_NAME_RE.match(report_path.name):
            raise ValueError(f"not a final-report path: {report_path}")
        matched = _REPORT_SEQ_RE.match(report_path.name)
        ref = cls.from_run_dir(
            report_path.parent.parent,
            seq=int(matched.group("seq")) if matched else None,
        )
        return replace(ref, report_override=report_path)

    def sibling(self, task_type: str) -> "RunRef":
        """같은 task 의 다른 task-type 을 가리키는 ref.

        seq/stage 는 이 run 의 것이므로 형제에게 물려주지 않는다.
        """
        return replace(
            self, task_type=task_type, seq=None, stage=None, report_override=None
        )

    @classmethod
    def latest(
        cls, project_root: Path, task_group: str, task_id: str, task_type: str
    ) -> Optional["RunRef"]:
        """task-type 의 최신 final-report 를 가리키는 ref. 없으면 None.

        최신 기준은 mtime 이고, 동률이면 basename 이 큰 쪽이다 — bash
        `find_latest_final_report` 와 wizard 가 쓰던 규칙을 그대로 옮긴 것이다.
        stage 하위 디렉터리는 훑지 않는다(옮겨온 두 구현과 동일 범위).
        """
        ref = cls(
            project_root=Path(project_root), task_group=task_group,
            task_id=task_id, task_type=task_type,
        )
        best = _newest_report(ref.reports_dir)
        return None if best is None else cls.from_report_path(best)

    @classmethod
    def latest_under(
        cls,
        task_root: Path,
        task_types: Optional[tuple[str, ...]] = None,
    ) -> Optional["RunRef"]:
        """task_root 아래 여러 task-type 을 가로질러 최신 final-report 를 찾는다.

        `task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
        훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).

        task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
        (bash resume-clarification)가 쓴다. 그 호출자는 언제나 flat 분석 phase 만
        넘기므로 stage 하위는 훑지 않는다 — flat `reports/` 고정이 그 계약이다.
        """
        runs_dir = runs_dir_of(task_root)
        if task_types is None:
            if not runs_dir.is_dir():
                return None
            task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
        candidates: list[Path] = []
        for task_type in task_types:
            found = _newest_report(runs_dir / task_type / "reports")
            if found is not None:
                candidates.append(found)
        if not candidates:
            return None
        return cls.from_report_path(
            max(candidates, key=lambda p: (p.stat().st_mtime, p.name))
        )


def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
    """task root 경로: ``<project>/.okstra/tasks/<group-seg>/<id-seg>``.

    raw group/id 를 받아 내부에서 slugify 한다 — compute_run_paths 가 쓰는 것과
    동일한 segment 규칙이다. 과거 sequence/wizard 가 이 구조를 손으로 재유도해
    silent drift 위험이 있던 것을 이 SSOT 로 모은다."""
    return Path(project_root) / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)


def task_runs_dir(project_root: Path, task_group: str, task_id: str) -> Path:
    """task 의 runs 디렉터리: ``task_dir/runs``."""
    return runs_dir_of(task_dir(project_root, task_group, task_id))


def runs_dir_of(task_root: Path) -> Path:
    """task_root 만 쥔 호출자를 위한 runs 디렉터리 접근자.

    task-type 을 모르는 스캐너(backfill·error-log glob·context-cost)가 쓴다.
    `runs` 세그먼트 리터럴은 이 함수와 `compute_run_paths` 에만 존재한다."""
    return Path(task_root) / "runs"


def task_manifest_file(task_root: Path) -> Path:
    """task 의 lifecycle manifest 경로.

    task-type 을 모르는 호출자(recap·time-report·error-report·work-categories)가
    쓰는 진입점이다. `RunRef.manifest` 는 한 *run* 의 run-manifest 이고, `report_finalize.task_manifest_path`
    는 run manifest 의 fallback 체인을 타는 해소기다 — 셋 다 다른 것이므로 이름을 나눈다."""
    return Path(task_root) / TASK_MANIFEST_FILENAME


def task_timeline_file(task_root: Path) -> Path:
    """task 의 history timeline 경로."""
    return Path(task_root) / "history" / "timeline.json"


def container_paths(project_root: Path, task_group: str, task_id: str) -> dict:
    """container 산출물 경로 묶음(순수 계산, mkdir 금지).

    task root 하위 ``container/`` 디렉터리에 deploy 산출물을 모은다. base 가
    ``task_dir`` 에서 파생되므로 라벨(compose project name)↔디스크 경로의 slug
    규칙이 자동으로 일치한다."""
    base = task_dir(project_root, task_group, task_id) / "container"
    return {
        "container_dir": base,
        "env_override": base / "env.override",
        "registry": base / "registry.json",
        "registry_lock": base / "registry.json.lock",
        "deploy_state": base / "deploy-state.json",
        "watchers_dir": base / "watchers",
    }


def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
    """run_seq_dir 안에서 `*-<task-type>-NNN[.<ext>]` 항목을 스캔해 다음 seq 번호를
    돌려준다. 디렉터리 부재 시 1.

    파일과 디렉터리를 함께 센다. 이 카운터가 배정하는 자리 중 하나는
    `prompts/duty-contracts-<task-type>-NNN/` 이라는 **디렉터리**다(확장자도 없다).
    파일만 세면 중단된 prepare 가 남긴 그 디렉터리가 카운터에 안 잡히고, 다음 런이
    같은 seq 를 배정받아 남은 스냅샷 위에 착지한다. digest 가 다르면
    `run._snapshot_duty_catalog` 이 매번 같은 자리에서 PrepareError 를 내므로,
    잔재를 손으로 치우기 전까지 그 task 의 prepare 가 영구히 막힌다.
    """
    pattern = re.compile(
        rf"-{re.escape(task_type_segment)}-(\d{{3,}})(?:\.[^.]+)?$"
    )
    max_seq = 0
    if run_seq_dir.is_dir():
        for entry in os.listdir(run_seq_dir):
            m = pattern.search(entry)
            if m:
                n = int(m.group(1))
                if n > max_seq:
                    max_seq = n
    return max_seq + 1


def _rel(project_root: Path, target: Path) -> str:
    """target 을 project_root 기준 상대경로 문자열로 변환. project_root 바깥이면
    절대경로 그대로 돌려준다(workspace-root 같은 경우)."""
    try:
        return str(target.resolve().relative_to(project_root.resolve()))
    except (ValueError, OSError):
        return str(target)


def resolve_under_root(
    project_root: str | Path | None, rel: str | None
) -> Path | None:
    """글로벌 인덱스의 신뢰 불가 상대경로 필드(runDirRel/finalReportRecordRel/
    teamStatePath 등)를 project_root 기준 절대경로로 푼다(_rel 의 역).

    이 필드들은 신뢰할 수 없어 절대경로나 '..' 가 들어오면 결합 결과가
    project_root 밖을 가리킬 수 있다(`Path('/p') / '/etc'` → `/etc`). 루트 하위로
    떨어지지 않거나 입력이 비면 None 을 돌려준다. 결합 결과 자체는 resolve 하지
    않고 그대로 돌려준다 — pane 태그 매칭이 compute_run_paths 의 un-resolved
    RUN_DIR 문자열에 의존하므로 containment 검사에만 resolve 를 쓴다(절대경로가
    필요한 호출자는 반환값에 직접 .resolve() 한다)."""
    if not project_root or not rel:
        return None
    root = Path(project_root)
    target = root / rel
    if not target.resolve().is_relative_to(root.resolve()):
        return None
    return target


def compute_run_paths(
    *,
    project_root: Path,
    workspace_root: Path,
    project_id: str,
    task_group: str,
    task_id: str,
    task_type: str,
    run_seq_override: Optional[int] = None,
    stage: Optional[int] = None,
) -> dict:
    """주어진 identity 와 task-type 에 대해 모든 path/segment 값을 계산해
    dict 로 돌려준다. 부수효과 없음.

    run_seq_override: 정수가 주어지면 7개 seq 모두 그 값으로 강제. okstra-ctl
    rerun 처럼 외부에서 seq 를 미리 reserve 한 경로용. 미지정 시 디스크 스캔
    결과로 결정.
    """
    project_root = Path(project_root)
    workspace_root = Path(workspace_root)

    task_group_segment = slugify(task_group)
    task_id_segment = slugify(task_id)
    task_type_segment = slugify(task_type)

    if not task_group_segment or not task_id_segment:
        raise ValueError(
            "task-group and task-id must contain at least one alphanumeric character")
    if not task_type_segment:
        raise ValueError("task-type must contain at least one alphanumeric character")

    okstra_root = project_root / OKSTRA_RELATIVE
    tasks_root = project_root / TASKS_RELATIVE
    discovery_dir = project_root / DISCOVERY_RELATIVE

    task_root = tasks_root / task_group_segment / task_id_segment
    task_manifest = task_manifest_file(task_root)
    task_index = task_root / "task-index.md"
    instruction_set = task_root / "instruction-set"
    analysis_packet = instruction_set / "analysis-packet.md"
    task_qa = task_root / "qa"
    runs_dir = runs_dir_of(task_root)
    history_dir = task_root / "history"
    timeline_file = task_timeline_file(task_root)
    recap_dir = task_root / "recap"
    recap_log = recap_dir / "recap-log.jsonl"

    run_dir = runs_dir / task_type_segment
    # Stage isolation: each stage's run artifacts live in a dedicated
    # `stage-<N>` subtree (mirrors the per-stage worktree) so two concurrent
    # runs of the same task-key never share reports/state/worker-results.
    # Applies to `implementation` and single-stage `final-verification`
    # (whole-task final-verification has stage=None and stays flat).
    # consumers.jsonl + the worktree registry stay at the task-type level (the
    # shared stage ledger / occupancy SSOT); they are computed OUTSIDE this
    # function and are intentionally NOT stage-scoped. Other task-types have no
    # stage concept, so their run_dir is unchanged.
    if (task_type_segment in ("implementation", "final-verification")
            and stage is not None):
        run_dir = run_dir / f"stage-{int(stage)}"
    run_manifests = run_dir / "manifests"
    run_state = run_dir / "state"
    run_prompts = run_dir / "prompts"
    run_reports = run_dir / "reports"
    run_status = run_dir / "status"
    run_sessions = run_dir / "sessions"
    run_logs = run_dir / "logs"
    worker_results = run_dir / "worker-results"
    # The carry sidecar ledger is implementation-only and stage-SHARED, like
    # consumers.jsonl: every reader/writer (consumers.py,
    # implementation_outcome.py, design_prep.py, the deliverable profile) uses
    # the flat `runs/implementation/carry/`, because stage N+1 must find stage
    # N's sidecar without knowing N's run layout. Never stage-scope it. Other
    # task types keep the (unused) run-local spelling so their run dirs don't
    # grow a stray task-type-level carry/.
    run_carry = (
        runs_dir / task_type_segment / "carry"
        if task_type_segment == "implementation"
        else run_dir / "carry"
    )

    if run_seq_override is not None:
        seq_int = int(run_seq_override)
        seqs = {k: f"{seq_int:03d}" for k in (
            "manifests", "prompts", "reports", "status", "state",
            "sessions", "worker_results")}
    else:
        seqs = {
            "manifests": f"{next_run_seq(run_manifests, task_type_segment):03d}",
            "prompts": f"{next_run_seq(run_prompts, task_type_segment):03d}",
            "reports": f"{next_run_seq(run_reports, task_type_segment):03d}",
            "status": f"{next_run_seq(run_status, task_type_segment):03d}",
            "state": f"{next_run_seq(run_state, task_type_segment):03d}",
            "sessions": f"{next_run_seq(run_sessions, task_type_segment):03d}",
            "worker_results": f"{next_run_seq(worker_results, task_type_segment):03d}",
        }

    suffixes = {k: f"-{task_type_segment}-{v}" for k, v in seqs.items()}
    run_file_suffix = suffixes["reports"]

    run_manifest_file = run_manifests / f"run-manifest{suffixes['manifests']}.json"
    run_prompt_snapshot = run_prompts / f"lead-execution-prompt{suffixes['prompts']}.md"
    duty_contract_root = (
        run_prompts / f"duty-contracts{suffixes['prompts']}"
    )
    lead_instructions = (
        run_prompts / f"lead-instructions{suffixes['prompts']}.md"
    )
    lead_prompt_metadata = run_prompt_snapshot.with_name(
        run_prompt_snapshot.name + ".meta.json"
    )
    invocation_reservation_root = run_prompts / ".agent-invocations"
    claude_worker_prompt = run_prompts / f"claude-worker-prompt{suffixes['prompts']}.md"
    codex_worker_prompt = run_prompts / f"codex-worker-prompt{suffixes['prompts']}.md"
    antigravity_worker_prompt = run_prompts / f"antigravity-worker-prompt{suffixes['prompts']}.md"
    report_writer_worker_prompt = run_prompts / f"report-writer-worker-prompt{suffixes['prompts']}.md"
    final_report = run_reports / f"final-report{suffixes['reports']}.data.json"
    final_status = run_status / f"final{suffixes['status']}.status"
    team_state = run_state / f"team-state{suffixes['state']}.json"
    active_run_context = run_state / f"active-run-context{suffixes['state']}.json"
    lead_events = run_state / f"lead-events-{task_type_segment}-{seqs['state']}.jsonl"
    convergence_state = (
        run_state / f"convergence-{task_type_segment}-{seqs['state']}.json"
    )
    final_report_template = instruction_set / "final-report-template.md"
    final_report_schema = instruction_set / "final-report-schema.json"
    reference_expectations = instruction_set / "reference-expectations.md"
    claude_resume_command = run_sessions / f"claude-resume{suffixes['sessions']}.sh"
    latest_task_file = discovery_dir / "latest-task.json"
    task_catalog_file = discovery_dir / "task-catalog.json"
    claude_worker_result = worker_results / f"claude-worker{suffixes['worker_results']}.md"
    codex_worker_result = worker_results / f"codex-worker{suffixes['worker_results']}.md"
    antigravity_worker_result = worker_results / f"antigravity-worker{suffixes['worker_results']}.md"
    report_writer_worker_result = worker_results / f"report-writer-worker{suffixes['worker_results']}.md"
    report_writer_narrative = worker_results / f"report-writer-narrative{suffixes['worker_results']}.md"
    approval_decisions = run_state / f"approval-decisions-{task_type_segment}-{seqs['state']}.json"
    design_preparation = run_state / f"design-preparation-{task_type_segment}-{seqs['state']}.json"
    plan_body_verification = run_state / f"plan-body-verification-{task_type_segment}-{seqs['state']}.json"

    run_errors_log = run_logs / f"errors-{task_type_segment}-{seqs['state']}.jsonl"
    runtime_home = okstra_home()

    claude_worker_errors_sidecar = worker_results / f"claude-worker-errors{suffixes['worker_results']}.json"
    codex_worker_errors_sidecar = worker_results / f"codex-worker-errors{suffixes['worker_results']}.json"
    antigravity_worker_errors_sidecar = worker_results / f"antigravity-worker-errors{suffixes['worker_results']}.json"
    report_writer_worker_errors_sidecar = worker_results / f"report-writer-worker-errors{suffixes['worker_results']}.json"

    run_validator_script = workspace_root / "validators" / "validate-run.py"

    abs_paths = {
        "PROJECT_ID": project_id,
        "PROJECT_ROOT": str(project_root),
        "WORKSPACE_ROOT": str(workspace_root),
        "TASK_GROUP": task_group,
        "TASK_ID": task_id,
        "TASK_KEY": f"{project_id}:{task_group}:{task_id}",
        "TASK_TYPE": task_type,
        "TASK_GROUP_SEGMENT": task_group_segment,
        "TASK_ID_SEGMENT": task_id_segment,
        "TASK_TYPE_SEGMENT": task_type_segment,
        "OKSTRA_ROOT": str(okstra_root),
        "OKSTRA_TASKS_ROOT": str(tasks_root),
        "ANALYSIS_WORKER_PREAMBLE_PATH": str(
            runtime_home / "templates" / "worker-prompt-preamble.md"
        ),
        "IMPLEMENTATION_WORKER_PREAMBLE_PATH": str(
            runtime_home / "templates" / "implementation-worker-preamble.md"
        ),
        "REPORT_WRITER_PREAMBLE_PATH": str(
            runtime_home / "templates" / "report-writer-prompt-preamble.md"
        ),
        "WORKER_ERROR_CONTRACT_PATH": str(
            runtime_home / "templates" / "worker-error-contract.md"
        ),
        "OKSTRA_LEAD_CONTRACT_PATH": str(
            runtime_home / "prompts" / "lead" / "okstra-lead-contract.md"
        ),
        "OKSTRA_CONTEXT_LOADER_PATH": str(
            runtime_home / "prompts" / "lead" / "context-loader.md"
        ),
        "OKSTRA_TEAM_CONTRACT_PATH": str(
            runtime_home / "prompts" / "lead" / "team-contract.md"
        ),
        "OKSTRA_CONVERGENCE_PATH": str(
            runtime_home / "prompts" / "lead" / "convergence.md"
        ),
        "OKSTRA_PLAN_BODY_VERIFICATION_PATH": str(
            runtime_home / "prompts" / "lead" / "plan-body-verification.md"
        ),
        "OKSTRA_REPORT_WRITER_PATH": str(
            runtime_home / "prompts" / "lead" / "report-writer.md"
        ),
        "OKSTRA_CODING_PREFLIGHT_DIR": str(
            runtime_home / "prompts" / "coding-preflight"
        ),
        "OKSTRA_DISCOVERY_DIR": str(discovery_dir),
        "TASK_ROOT": str(task_root),
        "TASK_MANIFEST_PATH": str(task_manifest),
        "TASK_INDEX_PATH": str(task_index),
        "INSTRUCTION_SET_PATH": str(instruction_set),
        "ANALYSIS_PACKET_PATH": str(analysis_packet),
        "TASK_QA_PATH": str(task_qa),
        "RUNS_DIR": str(runs_dir),
        "HISTORY_DIR": str(history_dir),
        "TIMELINE_PATH": str(timeline_file),
        "RECAP_DIR": str(recap_dir),
        "RECAP_LOG_PATH": str(recap_log),
        "RUN_DIR": str(run_dir),
        "RUN_STAGE": "" if stage is None else str(int(stage)),
        "RUN_MANIFESTS_DIR": str(run_manifests),
        "RUN_STATE_DIR": str(run_state),
        "RUN_PROMPTS_DIR": str(run_prompts),
        "RUN_REPORTS_DIR": str(run_reports),
        "RUN_STATUS_DIR": str(run_status),
        "RUN_SESSIONS_DIR": str(run_sessions),
        "RUN_LOGS_DIR": str(run_logs),
        "WORKER_RESULTS_PATH": str(worker_results),
        "RUN_CARRY_PATH": str(run_carry),
        "RUN_MANIFEST_PATH": str(run_manifest_file),
        "RUN_PROMPT_SNAPSHOT_FILE": str(run_prompt_snapshot),
        "DUTY_CONTRACT_ROOT": str(duty_contract_root),
        "LEAD_INSTRUCTIONS_PATH": str(lead_instructions),
        "LEAD_PROMPT_METADATA_PATH": str(lead_prompt_metadata),
        "INVOCATION_RESERVATION_ROOT": str(invocation_reservation_root),
        "CLAUDE_WORKER_PROMPT_FILE": str(claude_worker_prompt),
        "CODEX_WORKER_PROMPT_FILE": str(codex_worker_prompt),
        "ANTIGRAVITY_WORKER_PROMPT_FILE": str(antigravity_worker_prompt),
        "REPORT_WRITER_WORKER_PROMPT_FILE": str(report_writer_worker_prompt),
        "FINAL_REPORT_PATH": str(final_report),
        "FINAL_STATUS_PATH": str(final_status),
        "TEAM_STATE_PATH": str(team_state),
        "ACTIVE_RUN_CONTEXT_PATH": str(active_run_context),
        "LEAD_EVENTS_PATH": str(lead_events),
        "FINAL_REPORT_TEMPLATE_PATH": str(final_report_template),
        "FINAL_REPORT_SCHEMA_PATH": str(final_report_schema),
        "REFERENCE_EXPECTATIONS_FILE": str(reference_expectations),
        "CLAUDE_RESUME_COMMAND_PATH": str(claude_resume_command),
        "OKSTRA_LATEST_TASK_FILE": str(latest_task_file),
        "OKSTRA_TASK_CATALOG_FILE": str(task_catalog_file),
        "CLAUDE_WORKER_RESULT_FILE": str(claude_worker_result),
        "CODEX_WORKER_RESULT_FILE": str(codex_worker_result),
        "ANTIGRAVITY_WORKER_RESULT_FILE": str(antigravity_worker_result),
        "REPORT_WRITER_WORKER_RESULT_FILE": str(report_writer_worker_result),
        "REPORT_WRITER_NARRATIVE_FILE": str(report_writer_narrative),
        "APPROVAL_DECISIONS_PATH": str(approval_decisions),
        "DESIGN_PREPARATION_PATH": str(design_preparation),
        "CONVERGENCE_STATE_PATH": str(convergence_state),
        "PLAN_BODY_VERIFICATION_STATE_PATH": str(plan_body_verification),
        "RUN_ERRORS_LOG_PATH": str(run_errors_log),
        "CLAUDE_WORKER_ERRORS_SIDECAR_PATH": str(claude_worker_errors_sidecar),
        "CODEX_WORKER_ERRORS_SIDECAR_PATH": str(codex_worker_errors_sidecar),
        "ANTIGRAVITY_WORKER_ERRORS_SIDECAR_PATH": str(antigravity_worker_errors_sidecar),
        "REPORT_WRITER_WORKER_ERRORS_SIDECAR_PATH": str(report_writer_worker_errors_sidecar),
        "RUN_VALIDATOR_PATH": str(run_validator_script),
        "RUN_MANIFEST_FILENAME": run_manifest_file.name,
        "RUN_PROMPT_SNAPSHOT_FILENAME": run_prompt_snapshot.name,
        "LEAD_INSTRUCTIONS_FILENAME": lead_instructions.name,
        "LEAD_PROMPT_METADATA_FILENAME": lead_prompt_metadata.name,
        "FINAL_REPORT_FILENAME": final_report.name,
        "FINAL_STATUS_FILENAME": final_status.name,
        "CLAUDE_RESUME_COMMAND_FILENAME": claude_resume_command.name,
        "RUN_FILE_SUFFIX": run_file_suffix,
        "RUN_MANIFESTS_SEQ": seqs["manifests"],
        "RUN_PROMPTS_SEQ": seqs["prompts"],
        "RUN_REPORTS_SEQ": seqs["reports"],
        "RUN_STATUS_SEQ": seqs["status"],
        "RUN_STATE_SEQ": seqs["state"],
        "RUN_SESSIONS_SEQ": seqs["sessions"],
        "WORKER_RESULTS_SEQ": seqs["worker_results"],
        "LATEST_RUN_PATH": str(run_dir),
    }
    rel_pairs = [
        ("OKSTRA_DISCOVERY_RELATIVE_PATH", discovery_dir),
        ("OKSTRA_LATEST_TASK_RELATIVE_PATH", latest_task_file),
        ("OKSTRA_TASK_CATALOG_RELATIVE_PATH", task_catalog_file),
        ("TASK_ROOT_RELATIVE_PATH", task_root),
        ("TASK_MANIFEST_RELATIVE_PATH", task_manifest),
        ("TASK_INDEX_RELATIVE_PATH", task_index),
        ("INSTRUCTION_SET_RELATIVE_PATH", instruction_set),
        ("ANALYSIS_PACKET_RELATIVE_PATH", analysis_packet),
        ("RUNS_RELATIVE_PATH", runs_dir),
        ("HISTORY_RELATIVE_PATH", history_dir),
        ("TIMELINE_RELATIVE_PATH", timeline_file),
        ("RECAP_DIR_RELATIVE_PATH", recap_dir),
        ("RECAP_LOG_RELATIVE_PATH", recap_log),
        ("RUN_DIR_RELATIVE_PATH", run_dir),
        ("RUN_MANIFESTS_RELATIVE_PATH", run_manifests),
        ("RUN_STATE_RELATIVE_PATH", run_state),
        ("RUN_PROMPTS_RELATIVE_PATH", run_prompts),
        ("RUN_REPORTS_RELATIVE_PATH", run_reports),
        ("RUN_STATUS_RELATIVE_PATH", run_status),
        ("RUN_SESSIONS_RELATIVE_PATH", run_sessions),
        ("RUN_MANIFEST_RELATIVE_PATH", run_manifest_file),
        ("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", run_prompt_snapshot),
        ("DUTY_CONTRACT_ROOT_RELATIVE_PATH", duty_contract_root),
        ("LEAD_INSTRUCTIONS_RELATIVE_PATH", lead_instructions),
        ("LEAD_PROMPT_METADATA_RELATIVE_PATH", lead_prompt_metadata),
        ("INVOCATION_RESERVATION_ROOT_RELATIVE_PATH", invocation_reservation_root),
        ("CLAUDE_WORKER_PROMPT_RELATIVE_PATH", claude_worker_prompt),
        ("CODEX_WORKER_PROMPT_RELATIVE_PATH", codex_worker_prompt),
        ("ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH", antigravity_worker_prompt),
        ("REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH", report_writer_worker_prompt),
        ("FINAL_REPORT_RECORD_RELATIVE_PATH", final_report),
        ("FINAL_STATUS_RELATIVE_PATH", final_status),
        ("TEAM_STATE_RELATIVE_PATH", team_state),
        # `render.py` 가 이 키로 매니페스트의 `convergenceStatePath` 를 채운다.
        # `path_hints` 만 내보내던 동안 그 필드가 빈 문자열로 렌더돼,
        # `report-finalize` 가 "report contract 3.0 requires convergenceStatePath"
        # 로 죽었다 — 수렴 상태 파일 자체는 정상 생성돼 있었다.
        ("CONVERGENCE_STATE_RELATIVE_PATH", convergence_state),
        ("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", active_run_context),
        ("LEAD_EVENTS_RELATIVE_PATH", lead_events),
        ("WORKER_RESULTS_RELATIVE_PATH", worker_results),
        ("RUN_CARRY_RELATIVE_PATH", run_carry),
        ("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", final_report_template),
        ("FINAL_REPORT_SCHEMA_RELATIVE_PATH", final_report_schema),
        ("REFERENCE_EXPECTATIONS_RELATIVE_PATH", reference_expectations),
        ("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", claude_resume_command),
        ("RUN_VALIDATOR_RELATIVE_PATH", run_validator_script),
        ("CLAUDE_WORKER_RESULT_RELATIVE_PATH", claude_worker_result),
        ("CODEX_WORKER_RESULT_RELATIVE_PATH", codex_worker_result),
        ("ANTIGRAVITY_WORKER_RESULT_RELATIVE_PATH", antigravity_worker_result),
        ("REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH", report_writer_worker_result),
        ("REPORT_WRITER_NARRATIVE_RELATIVE_PATH", report_writer_narrative),
        ("APPROVAL_DECISIONS_RELATIVE_PATH", approval_decisions),
        ("DESIGN_PREPARATION_RELATIVE_PATH", design_preparation),
        ("PLAN_BODY_VERIFICATION_STATE_RELATIVE_PATH", plan_body_verification),
        ("RUN_ERRORS_LOG_RELATIVE_PATH", run_errors_log),
        ("CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", claude_worker_errors_sidecar),
        ("CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", codex_worker_errors_sidecar),
        ("ANTIGRAVITY_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", antigravity_worker_errors_sidecar),
        ("REPORT_WRITER_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", report_writer_worker_errors_sidecar),
        ("LATEST_RUN_RELATIVE_PATH", run_dir),
    ]
    rel_paths = {key: _rel(project_root, target) for key, target in rel_pairs}
    return {**abs_paths, **rel_paths}
