#!/usr/bin/env python3
"""Verdict sidecar writer for the Recoil Workspace learning loop (v0).

Captures JT's per-take approve/reject verdict + three-way taxonomy + sub-tags
+ reason text, writing shot-indexed JSON sidecars next to the existing
{shot_id}_meta.yaml in projects/{project}/output/video/ep_NNN/.

Scope boundary: this module is READ/WRITE ONLY for verdict data. It does not
consume verdicts at dispatch time (per SYNTHESIS Phase 7 non-goals). The
existing LearningEngine retry-strategy pipeline is untouched.

File naming: {shot_id}_take{N}_verdict.json  (SYNTHESIS §B locked — shot-indexed,
not flat JSONL).
"""

from __future__ import annotations

import fcntl
import json
import logging
import os
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator, Optional

import difflib
import yaml  # PyYAML is already a transitive dep via step_runner._write_seeddance_meta_sidecar

log = logging.getLogger(__name__)

# ── Path setup ──────────────────────────────────────────────────
_RECOIL_ROOT = Path(__file__).resolve().parent.parent
_PIPELINE_CONFIG = _RECOIL_ROOT / "pipeline" / "config" / "editorial_tags.json"

# Resolve projects root via SSOT; env override lives inside projects_root().
# `as SCHEMA_VERSION` rebind keeps this module's existing public attribute
# name byte-stable for every existing import site.
import sys as _sys  # noqa: E402
if str(_RECOIL_ROOT) not in _sys.path:
    _sys.path.insert(0, str(_RECOIL_ROOT))
from recoil.core.paths import ProjectPaths  # noqa: E402
from recoil.pipeline._lib.schema_versions import VERDICT_SCHEMA_VERSION as SCHEMA_VERSION  # noqa: E402
from recoil.core.exceptions import (  # noqa: E402
    EditorialConfigCorruptError,
    VerdictAutofillError,
    VerdictCorruptError,
)

WRITER_VERSION = "verdict.py@v0"

VALID_VERDICTS = frozenset({"approve", "reject"})
VALID_TAXONOMIES = frozenset({"validator-escape", "taste-shaped", "strategic"})
VALID_REASON_SOURCES = frozenset({"jt_confirmed", "jt_corrected", "jt_added", "claude_only"})
VALID_JT_ACTIONS = frozenset({"confirm", "correct", "qualify", "skip"})


def _load_editorial_tags() -> set[str]:
    """Load the 17-tag vocabulary; unknown tags are flagged, not rejected."""
    try:
        data = json.loads(_PIPELINE_CONFIG.read_text(encoding="utf-8"))
        return set(data.get("rejection", [])) | set(data.get("approval", []))
    except (FileNotFoundError, json.JSONDecodeError):
        # Fallback: never crash the writer; just observe
        return set()


_EDITORIAL_TAGS_CACHE: Optional[set[str]] = None


def _editorial_tags() -> set[str]:
    global _EDITORIAL_TAGS_CACHE
    if _EDITORIAL_TAGS_CACHE is None:
        _EDITORIAL_TAGS_CACHE = _load_editorial_tags()
    return _EDITORIAL_TAGS_CACHE


# ── Helpers ─────────────────────────────────────────────────────
def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _slugify_tag(t: str) -> str:
    return "".join(c if c.isalnum() or c == "_" else "_" for c in t.strip().lower()).strip("_")


def _verdict_lock_path(verdict_path: Path) -> Path:
    """Per-verdict lock file: .{verdict_name}.lock (hidden sibling).

    Mirrors the per-sidecar lock pattern in workspace/sidecar.py so that
    concurrent writers against the SAME verdict serialize, but writes against
    different verdict files proceed in parallel.
    """
    return verdict_path.parent / f".{verdict_path.name}.lock"


def _atomic_write_json(path: Path, data: dict) -> None:
    """Cross-process safe atomic write via per-verdict fcntl.flock + os.replace.

    Phase 20 SSOT: the inner tempfile + os.replace + EINVAL-tolerant fsync is
    now `atomic_write_json` from `recoil.core.atomic_write`. The lock-file
    management around it stays here because verdict writes need per-file
    serialization (concurrent writers against the SAME verdict file would
    otherwise interleave) — that's a verdict-domain concern, not an SSOT one.
    """
    from recoil.core.atomic_write import atomic_write_json
    lock_path = _verdict_lock_path(path)
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
    try:
        fcntl.flock(lock_fd, fcntl.LOCK_EX)
        atomic_write_json(path, data)
    finally:
        fcntl.flock(lock_fd, fcntl.LOCK_UN)
        os.close(lock_fd)


def _verdict_dir(
    project: str,
    episode_id: str,
    media_path: str | Path | None = None,
) -> Path:
    """Return the .verdicts/ subdirectory for an episode (auto-creates).

    All modes route verdict files into a hidden .verdicts/ subdir so they
    don't clutter the source media directory. Shared helper from namespacing build.
    """
    from recoil.workspace.verdict_helpers import verdicts_dir as _verdicts_dir

    paths = ProjectPaths.for_project(project)
    if media_path:
        project_root = paths.project_root.resolve()
        candidate = Path(media_path)
        if not candidate.is_absolute():
            candidate = project_root / candidate
        candidate = candidate.resolve()
        try:
            candidate.relative_to(project_root)
            episode_dir = candidate.parent
            return _verdicts_dir(episode_dir)
        except ValueError:
            log.warning("verdict: ignoring media_path outside project root: %s", candidate)

    episode_dir = paths.renders_dir / episode_id
    return _verdicts_dir(episode_dir)


def _verdict_filename(shot_id: str, take_number: int | None) -> str:
    """Mode-aware verdict filename. Microdrama: includes _take{N}; client mode: no take suffix."""
    from recoil.workspace.verdict_helpers import verdict_filename as _vfilename

    return _vfilename(shot_id, take_number)


# ── Writer / Reader / Finder ────────────────────────────────────
def write_verdict(
    project: str,
    episode_id: str,                 # e.g. "ep_001"
    shot_id: str,
    take_number: int | None,         # None for client_deliverable mode
    verdict: str,                    # "approve" | "reject"
    taxonomy: str,                   # "validator-escape" | "taste-shaped" | "strategic"
    sub_tags: list[str],             # validated against editorial_tags.json
    reason_text: str,
    reason_source: str,              # "jt_confirmed" | "jt_corrected" | "jt_added" | "claude_only"
    auto_filled: dict,
    confirmation: dict,
    session_id: Optional[str] = None,
    machine_id: Optional[str] = None,
    generation_id: Optional[str] = None,
    media_path: str | Path | None = None,
) -> Optional[Path]:
    """Write a shot-indexed verdict sidecar next to the video/meta.yaml.

    Validation: verdict, taxonomy, reason_source, and confirmation.jt_action
    must be in their respective enums. Unknown sub_tags are kept (slugified)
    and flagged via `_unknown_sub_tags`. Atomic write.

    Returns: Path to the written verdict file.
    """
    if verdict not in VALID_VERDICTS:
        raise ValueError(f"verdict must be one of {VALID_VERDICTS}, got {verdict!r}")
    if taxonomy not in VALID_TAXONOMIES:
        raise ValueError(f"taxonomy must be one of {VALID_TAXONOMIES}, got {taxonomy!r}")
    if reason_source not in VALID_REASON_SOURCES:
        raise ValueError(f"reason_source must be one of {VALID_REASON_SOURCES}, got {reason_source!r}")
    jt_action = confirmation.get("jt_action", "skip")
    if jt_action not in VALID_JT_ACTIONS:
        raise ValueError(f"confirmation.jt_action must be one of {VALID_JT_ACTIONS}, got {jt_action!r}")

    # Capability guard — projects whose mode does not capture verdicts skip silently.
    # Microdrama and client_deliverable both pass; sandbox-style modes (deferred
    # for v0) would return None here.
    from recoil.core.project import get_project
    if not get_project(project).captures_verdicts:
        return None

    known = _editorial_tags()
    normalized_sub_tags: list[str] = []
    unknown_sub_tags: list[str] = []
    for t in sub_tags or []:
        slug = _slugify_tag(t)
        if slug in known or not known:
            # If tag vocab is unavailable, keep as-is and do not flag
            normalized_sub_tags.append(slug)
        else:
            normalized_sub_tags.append(slug)
            unknown_sub_tags.append(slug)

    payload: dict = {
        "schema_version": SCHEMA_VERSION,
        "shot_id": shot_id,
        # take_number is absent (not null) for non-take-semantic modes (client_deliverable)
        **({"take_number": int(take_number)} if take_number is not None else {}),
        "generation_id": generation_id,

        "verdict": verdict,
        "verdict_timestamp": _now_iso(),

        "taxonomy": taxonomy,
        "sub_tags": normalized_sub_tags,
        "reason_text": reason_text or "",
        "reason_source": reason_source,

        "auto_filled": auto_filled or {},
        "confirmation": confirmation or {},

        "session_id": session_id,
        "machine_id": machine_id,
        "writer_version": WRITER_VERSION,
    }
    if unknown_sub_tags:
        payload["_unknown_sub_tags"] = unknown_sub_tags

    out = _verdict_dir(project, episode_id, media_path) / _verdict_filename(
        shot_id, take_number
    )
    _atomic_write_json(out, payload)
    return out


def read_verdict(path: Path) -> Optional[dict]:
    """Read a verdict JSON sidecar.

    Returns None if no verdict file exists at ``path``. Raises
    ``VerdictCorruptError`` per Tenet 6 if the file exists but its JSON is
    malformed — a silent ``return None`` would mask corruption as
    "no verdict yet."
    """
    try:
        return json.loads(Path(path).read_text(encoding="utf-8"))
    except FileNotFoundError:
        return None
    except json.JSONDecodeError as e:
        log.warning("verdict: corrupt at %s (%s)", path, e)
        raise VerdictCorruptError(str(path), message=str(e)) from e


def find_verdict(project: str, episode_id: str, shot_id: str, take_number: int | None) -> Optional[Path]:
    candidate = _verdict_dir(project, episode_id) / _verdict_filename(shot_id, take_number)
    return candidate if candidate.is_file() else None


def iter_verdicts(project: str) -> Iterator[Path]:
    """Yield every {shot_id}_take{N}_verdict.json under projects/{project}/output/video/."""
    root = ProjectPaths.for_project(project).renders_dir
    if not root.is_dir():
        return
    for p in root.rglob("*_verdict.json"):
        if p.is_file() and p.name.endswith("_verdict.json"):
            yield p


# ── Auto-fill helper (Phase 2) ──────────────────────────────────

# Synonym map: chat-word → editorial sub-tag
_CHAT_SYNONYMS: dict[str, str] = {
    "flat": "flat_lighting",
    "soft": "flat_lighting",
    "washed": "flat_lighting",
    "drifted": "identity_loss",
    "drift": "identity_loss",
    "wrong face": "identity_loss",
    "identity": "identity_loss",
    "floaty": "bad_physics",
    "glitched": "bad_physics",
    "physics": "bad_physics",
    "blocking": "blocking_error",
    "position": "blocking_error",
    "expression": "wrong_expression",
    "continuity": "continuity_drift",
    "clean": "too_clean",
    "pristine": "too_clean",
    "camera": "wrong_camera",
    "angle": "wrong_camera",
    "slow": "pacing_slow",
    "sluggish": "pacing_slow",
    # approvals
    "landed": "good_camera",
    "nailed": "strong_identity",
    "perfect": "first_take",
    "stunning": "good_lighting",
}


def _meta_yaml_path(project: str, episode_id: str, shot_id: str, take_number: int) -> Optional[Path]:
    """Locate the meta.yaml for a take.

    Search order (deterministic):
      1. {shot_id}_take{N}_meta.yaml   (explicit take variant)
      2. {shot_id}_meta.yaml           (primary meta — most common)
      3. glob {shot_id}*_meta.yaml     (fallback; pick most recent by mtime)

    NOTE: meta.yaml files live in the episode dir (next to .mp4 files),
    NOT in the .verdicts/ subdir. We resolve via _verdict_dir(...).parent
    to share the projects-root resolution while pointing at the correct dir.
    """
    d = _verdict_dir(project, episode_id).parent
    candidates = [
        d / f"{shot_id}_take{take_number}_meta.yaml",
        d / f"{shot_id}_meta.yaml",
    ]
    for c in candidates:
        if c.is_file():
            return c
    fallback = sorted(
        (p for p in d.glob(f"{shot_id}*_meta.yaml") if p.is_file()),
        key=lambda p: p.stat().st_mtime,
        reverse=True,
    )
    return fallback[0] if fallback else None


def _load_meta_yaml(path: Path) -> Optional[dict]:
    """Load the editorial-tags / meta YAML for a take.

    Returns None when the file is genuinely missing. Raises
    ``EditorialConfigCorruptError`` per Tenet 6 when the file exists but
    its YAML is malformed or unreadable — silent ``return None`` would
    allow taxonomy/tag mismatches to ship.
    """
    try:
        data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
        return data if isinstance(data, dict) else None
    except FileNotFoundError:
        return None
    except (yaml.YAMLError, OSError) as e:
        log.warning("verdict: editorial-tags YAML corrupt at %s (%s)", path, e)
        raise EditorialConfigCorruptError(str(path), message=str(e)) from e


def _prompt_delta(current: str, prior: Optional[str]) -> Optional[str]:
    """Return a one-line '+added; -removed' summary, or None if no prior take."""
    if not prior:
        return None
    current_lines = [ln.strip() for ln in (current or "").splitlines() if ln.strip()]
    prior_lines = [ln.strip() for ln in (prior or "").splitlines() if ln.strip()]
    diff = list(difflib.ndiff(prior_lines, current_lines))
    added = [ln[2:] for ln in diff if ln.startswith("+ ")]
    removed = [ln[2:] for ln in diff if ln.startswith("- ")]
    # Keep summary terse — first 80 chars of each side
    add_str = "; ".join(added)[:80]
    rem_str = "; ".join(removed)[:80]
    if not added and not removed:
        return "identical"
    parts = []
    if added:
        parts.append(f"+{add_str}")
    if removed:
        parts.append(f"-{rem_str}")
    return " | ".join(parts)


def _guess_reason_from_chat(chat_turns: list[dict]) -> tuple[Optional[str], list[str]]:
    """Scan the last N chat turns for editorial-vocabulary tokens.

    Returns (one_sentence_guess, matched_sub_tags).
    """
    if not chat_turns:
        return None, []
    known = _editorial_tags()
    hits: list[str] = []
    for turn in chat_turns[-8:]:
        text = (turn.get("content") or turn.get("text") or "").lower()
        if not text:
            continue
        for word, tag in _CHAT_SYNONYMS.items():
            if word in text and tag in known and tag not in hits:
                hits.append(tag)
        for tag in known:
            if tag.replace("_", " ") in text and tag not in hits:
                hits.append(tag)
    if not hits:
        return None, []
    # One-sentence guess picks the first hit for narrative compactness
    guess = f"Chat context suggests {hits[0]} ({', '.join(hits)})."
    return guess, hits


def _try_execution_store_lookup(project: str, shot_id: str, take_number: int) -> dict:
    """Best-effort lookup into ExecutionStore for focus_character + location_id.

    Tenet 6:
    - ImportError on the ExecutionStore module is a sanctioned-fallback
      condition (return {} after logging) — the module legitimately may not
      be importable in some test/CLI contexts.
    - All other failures (store init, store lookup) raise
      ``VerdictAutofillError`` so the caller surfaces the autofill
      degradation explicitly instead of silently returning empty.
    """
    try:
        import sys
        if str(_RECOIL_ROOT) not in sys.path:
            sys.path.insert(0, str(_RECOIL_ROOT))
        from recoil.execution.execution_store import ExecutionStore  # type: ignore
    except ImportError as e:
        log.warning("verdict autofill: ExecutionStore unimportable (%s)", e)
        return {}

    try:
        store = ExecutionStore(project=project)
    except Exception as e:
        log.exception("verdict autofill: ExecutionStore init failed for %s", project)
        raise VerdictAutofillError(
            source="executionstore_init", message=str(e),
        ) from e

    try:
        try:
            shot = store.get_shot(shot_id)
            if not shot:
                return {}
            takes = shot.get("takes") or []
            # take_number is 1-based; clamp into range
            idx = max(0, min(len(takes) - 1, int(take_number) - 1)) if takes else -1
            if idx < 0:
                return {}
            take = takes[idx] or {}
            snap = take.get("inputs_snapshot") or {}

            focus = None
            chars = snap.get("characters") or []
            if isinstance(chars, list) and chars:
                first = chars[0] or {}
                focus = first.get("char_id") or first.get("display_name")
            focus = focus or snap.get("identity_anchor")

            return {
                "focus_character": focus,
                "location_id": snap.get("location_id"),
            }
        except VerdictAutofillError:
            raise
        except Exception as e:
            log.exception(
                "verdict autofill: lookup failure for project=%s shot=%s",
                project, shot_id,
            )
            raise VerdictAutofillError(
                source="executionstore_lookup", message=str(e),
            ) from e
    finally:
        try:
            store.close()
        except Exception:
            pass


def build_auto_filled(
    project: str,
    shot_id: str,
    take_number: int,
    chat_context_window: Optional[list[dict]] = None,
    episode_id: str = "ep_001",
) -> dict:
    """Populate the verdict.auto_filled dict from existing artifacts.

    Never crashes on missing data — returns explicit None/[] fields with a
    `_meta_yaml_missing` marker when meta.yaml is absent.
    """
    meta_path = _meta_yaml_path(project, episode_id, shot_id, take_number)
    # build_auto_filled's contract: "Never crashes on missing data". Phase E
    # made _load_meta_yaml raise EditorialConfigCorruptError on corrupt YAML;
    # treat that as "missing meta" for autofill purposes (the user can still
    # verdict the take; the corrupt YAML is logged separately).
    if meta_path:
        try:
            meta = _load_meta_yaml(meta_path)
        except EditorialConfigCorruptError as e:
            log.warning("build_auto_filled: corrupt meta yaml at %s — %s", meta_path, e)
            meta = None
    else:
        meta = None

    out: dict = {
        "model": None,
        "prompt_text": None,
        "prompt_word_count": None,
        "prompt_delta_from_prior_take": None,
        "ref_set": {
            "character_refs": [],
            "start_frame": None,
            "reference_videos": [],
        },
        "params": {"duration": None, "aspect_ratio": None},
        "dispatch_timestamp": None,
        "guessed_reason_from_chat": None,
        "focus_character": None,   # Phase 6 populates
        "location_id": None,       # Phase 6 populates
    }

    if meta is None:
        out["_meta_yaml_missing"] = True
    else:
        gen = meta.get("generation", {}) or {}
        inputs = gen.get("inputs", {}) or {}
        params = gen.get("parameters", {}) or {}
        out["model"] = gen.get("model")
        out["prompt_text"] = gen.get("prompt_text")
        out["prompt_word_count"] = gen.get("prompt_word_count")
        out["dispatch_timestamp"] = gen.get("timestamp")
        out["ref_set"]["character_refs"] = inputs.get("character_refs") or []
        out["ref_set"]["start_frame"] = inputs.get("start_frame")
        rv = inputs.get("reference_videos")
        if rv is None:
            out["ref_set"]["reference_videos"] = []
            out["_reference_videos_inferred"] = True
        else:
            out["ref_set"]["reference_videos"] = rv
        out["params"]["duration"] = params.get("duration")
        out["params"]["aspect_ratio"] = params.get("aspect_ratio")

    # prompt_delta_from_prior_take. Same docstring contract as the first
    # _load_meta_yaml call: corrupt prior YAML degrades to "no prior prompt"
    # rather than crashing the autofill.
    if take_number > 1 and meta is not None:
        prior_path = _meta_yaml_path(project, episode_id, shot_id, take_number - 1)
        prior_meta = None
        if prior_path:
            try:
                prior_meta = _load_meta_yaml(prior_path)
            except EditorialConfigCorruptError as e:
                log.warning(
                    "build_auto_filled: corrupt prior meta yaml at %s — %s",
                    prior_path, e,
                )
        prior_prompt = (prior_meta or {}).get("generation", {}).get("prompt_text")
        out["prompt_delta_from_prior_take"] = _prompt_delta(out.get("prompt_text") or "", prior_prompt)

    # guessed reason
    if chat_context_window:
        guess, _tags = _guess_reason_from_chat(chat_context_window)
        out["guessed_reason_from_chat"] = guess

    # build_auto_filled's contract: "Never crashes on missing data". Phase E
    # made _try_execution_store_lookup raise VerdictAutofillError on real
    # store failures; treat that as "no autofill bits available" and continue
    # — the user can still verdict the take without character/location autofill.
    try:
        store_bits = _try_execution_store_lookup(project, shot_id, take_number)
    except VerdictAutofillError as e:
        log.warning(
            "build_auto_filled: execution-store lookup failed for project=%s shot=%s take=%s — %s",
            project, shot_id, take_number, e,
        )
        store_bits = {}
    if store_bits:
        out["focus_character"] = store_bits.get("focus_character")
        out["location_id"] = store_bits.get("location_id")

    return out


# ── Phase 5: session-end summary ────────────────────────────────


def summarize_session_verdicts(project: str, since_iso: str) -> str:
    """One-paragraph summary of verdicts captured since `since_iso`.

    Returns '' when no verdicts fall in the window — the caller should then
    append nothing (the opt-out behavior per PLAN §5.2).

    Strategic-tier verdicts get a `**STRATEGIC NOTE:**` callout line after
    the main paragraph (PLAN §5.3, SYNTHESIS Risk #4).
    """
    try:
        since_dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
    except (ValueError, TypeError):
        return ""
    entries: list[dict] = []
    for path in iter_verdicts(project):
        try:
            data = read_verdict(path)
        except VerdictCorruptError as e:
            log.warning("summarize_session_verdicts: skipping corrupt verdict %s — %s", path, e)
            continue
        if not data:
            continue
        try:
            ts = datetime.fromisoformat(str(data.get("verdict_timestamp", "")).replace("Z", "+00:00"))
        except (ValueError, TypeError):
            continue
        if ts < since_dt:
            continue
        entries.append(data)

    if not entries:
        return ""

    approves = [e for e in entries if e.get("verdict") == "approve"]
    rejects = [e for e in entries if e.get("verdict") == "reject"]
    strategic = [e for e in entries if e.get("taxonomy") == "strategic"]

    # Top sub-tags across rejects
    reject_tags = Counter(tag for e in rejects for tag in (e.get("sub_tags") or []))
    top_reject = reject_tags.most_common(3)
    top_str = ", ".join(f"`{tag}` ({count})" for tag, count in top_reject) if top_reject else "—"

    # Confirm-vs-correct ratio
    jt_actions = Counter((e.get("confirmation") or {}).get("jt_action", "skip") for e in entries)
    confirmed = jt_actions.get("confirm", 0)
    corrected = jt_actions.get("correct", 0) + jt_actions.get("qualify", 0)
    total = len(entries)

    # Episode(s) touched — coarse, multi-ep sessions still get text
    ep_hint = "ep_001"
    today = datetime.now(timezone.utc).date().isoformat()

    para = (
        f"**Session {today} ({project} {ep_hint}):** {total} verdicts captured "
        f"({len(approves)} approve, {len(rejects)} reject). "
        f"Top reject sub-tags: {top_str}. "
        f"Strategic notes: {len(strategic)}. "
        f"Confirm-vs-correct ratio: {confirmed}/{total} confirmed, {corrected}/{total} corrected."
    )
    if strategic:
        notes = [
            f"**STRATEGIC NOTE:** {s.get('shot_id', '?')}: "
            f"{s.get('reason_text', '(no reason text)')}"
            for s in strategic
        ]
        return "\n".join([para, *notes])
    return para


# ── Two-week test criteria (PLAN §6.1) ──────────────────────────
# These are the three queries the v0 corpus must support via ad-hoc
# Claude reads (no CLI is shipped in v0):
#
#   Q1. Top 3 reject reasons for [character] shots?
#       → iter_verdicts → filter verdict=="reject" AND
#         auto_filled.focus_character == target → Counter(sub_tags)
#
#   Q2. What ref strategy won most often for [location] interiors?
#       → iter_verdicts → filter verdict=="approve" AND
#         auto_filled.location_id == target → histogram over
#         auto_filled.ref_set composition
#
#   Q3. Did validator-escape tags surface real validator gaps?
#       → iter_verdicts → filter taxonomy=="validator-escape" →
#         group by sub_tags → cross-ref against StartFrameCritic /
#         VideoFrameCritic failure modes
#
# Pass criteria (PLAN §6.3): 3/3 yes → spec v1 retrieval tool.
# Any no → redesign capture before building downstream.
