"""Staged security audit over committed Git-tracked code."""

from __future__ import annotations

import hashlib
import json
import queue
import re
import shutil
import subprocess
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field, asdict, replace
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .input_dispatch import InputContext

from . import fmt
from .audit_ui import AuditUI, PhaseHandle
from .tool_call_repair import _byte_capped_preview
from .codeparse import (
    mask_noncode,
    strip_comments as _strip_comments,
    redact_string_contents as _redact_string_contents,
    string_literal_spans as _string_literal_spans,
    starts_inside_string as _starts_inside_string,
    is_zig_multiline_string_line as _is_zig_multiline_string_line,
)

# ---------------------------------------------------------------------------
# Provenance
# ---------------------------------------------------------------------------

AUDIT_PROVENANCE_URL = "https://swival.dev"

_AUDIT_TRUNCATION_MARKER = "\n\n[truncated — file too large for context window]"
_AUDIT_TRUNCATION_FLOOR = 200

_LARGE_SCOPE_THRESHOLD = 500
_DEFAULT_PATCH_MAX_TURNS = 50

_DEFAULT_METRICS: dict[str, int] = {
    "parse_failures": 0,
    "parse_failures_profile": 0,
    "parse_failures_triage": 0,
    "parse_failures_finding": 0,
    "parse_failures_expansion": 0,
    "repair_successes": 0,
    "repair_failures": 0,
    "analytical_retries": 0,
    "multiline_continuations": 0,
    "lenient_corrections": 0,
    "verifier_no_verdict": 0,
    "phase45_lens_retries": 0,
    "truncated_calls": 0,
    "empty_response_retries": 0,
    "high_none_retries": 0,
}

# ---------------------------------------------------------------------------
# Debug log
# ---------------------------------------------------------------------------

_debug_log_path: Path | None = None
_debug_log_lock = threading.Lock()


_current_ui = threading.local()


def _set_current_ui(ui: "AuditUI | None") -> None:
    _current_ui.ui = ui


def _get_current_ui() -> "AuditUI | None":
    return getattr(_current_ui, "ui", None)


def _set_current_worker_slot(slot: int | None) -> None:
    _current_ui.worker_slot = slot


def _get_current_worker_slot() -> int | None:
    return getattr(_current_ui, "worker_slot", None)


def _ui_info(ui: "AuditUI | None", msg: str) -> None:
    """Route an informational line through the audit UI when available."""
    target = ui if ui is not None else _get_current_ui()
    if target is not None:
        target.scrollback(msg)
    else:
        fmt.info(msg)


def _ui_warning(ui: "AuditUI | None", msg: str) -> None:
    """Route a warning line through the audit UI when available."""
    target = ui if ui is not None else _get_current_ui()
    if target is not None:
        target.warning(msg)
    else:
        fmt.warning(msg)


def _debug_log(event: str, **fields) -> None:
    if _debug_log_path is None:
        return
    entry = {"ts": time.time(), "event": event, **fields}
    line = json.dumps(entry, default=str) + "\n"
    with _debug_log_lock:
        with open(_debug_log_path, "a") as f:
            f.write(line)


# ---------------------------------------------------------------------------
# File extensions
# ---------------------------------------------------------------------------

_SOURCE_EXTS = frozenset(
    {
        ".d",
        ".py",
        ".js",
        ".ts",
        ".tsx",
        ".jsx",
        ".go",
        ".rs",
        ".java",
        ".kt",
        ".rb",
        ".php",
        ".c",
        ".cc",
        ".cpp",
        ".h",
        ".hpp",
        ".cs",
        ".swift",
        ".scala",
        ".sh",
        ".zig",
        ".pl",
        ".pm",
        ".psgi",
    }
)

_CONFIG_EXTS = frozenset(
    {
        ".json",
        ".toml",
        ".yaml",
        ".yml",
        ".xml",
        ".ini",
        ".conf",
        ".sql",
        ".graphql",
        ".proto",
        ".rego",
        ".tf",
        ".cue",
    }
)

_AUDITABLE_EXTS = _SOURCE_EXTS | _CONFIG_EXTS

# ---------------------------------------------------------------------------
# Attack-surface keywords (cheap heuristic for file ordering)
# ---------------------------------------------------------------------------

_ATTACK_SURFACE_PATTERNS: list[tuple[re.Pattern, int]] = [
    (re.compile(r"\b(exec|system|popen|subprocess|spawn|eval)\b", re.I), 5),
    (re.compile(r"\b(os\.path|open|fopen|readFile|writeFile|unlink|rmdir)\b", re.I), 4),
    (
        re.compile(
            r"\b(request|response|handler|route|endpoint|app\.(get|post|put|delete))\b",
            re.I,
        ),
        4,
    ),
    (re.compile(r"\b(auth|login|password|token|secret|credential|session)\b", re.I), 4),
    (
        re.compile(
            r"\b(parse|decode|deserialize|unmarshal|fromJSON|load|loads)\b", re.I
        ),
        3,
    ),
    (re.compile(r"\b(sql|query|execute|cursor|prepare|raw_sql)\b", re.I), 3),
    (re.compile(r"\b(render|template|jinja|mustache|handlebars)\b", re.I), 2),
    (re.compile(r"\b(lock|mutex|semaphore|thread|async|await|concurrent)\b", re.I), 2),
    (re.compile(r"\b(connect|socket|listen|bind|http|https|fetch|urllib)\b", re.I), 3),
    (re.compile(r"\b(transaction|commit|rollback|migrate)\b", re.I), 2),
]

# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------


def _coerce_focus(raw: object) -> list[str]:
    if raw is None:
        return []
    if isinstance(raw, str):
        return [raw]
    return list(raw)  # type: ignore[arg-type]


def _normalize_focus(entries: list[str]) -> list[str]:
    normalized = (
        e if any(c in e for c in "*?[") else (e.rstrip("/") or e) for e in entries
    )
    return list(dict.fromkeys(normalized))


@dataclass(frozen=True)
class AuditScope:
    branch: str
    commit: str
    tracked_files: list[str]
    mandatory_files: list[str]
    focus: list[str]

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, d: dict) -> AuditScope:
        focus = _normalize_focus(_coerce_focus(d.get("focus")))
        return cls(**{**d, "focus": focus})


@dataclass
class TriageRecord:
    path: str
    priority: str  # ESCALATE_HIGH | ESCALATE_MEDIUM | SKIP
    confidence: str
    bug_classes: list[str]
    summary: str
    relevant_symbols: list[str]
    suspicious_flows: list[str]
    needs_followup: bool
    promotion_reasons: list[str] = field(default_factory=list)
    triage_failure_mode: str | None = None
    confirmation_outcome: str | None = None


@dataclass
class FindingRecord:
    title: str
    finding_type: str
    severity: str
    locations: list[str]
    preconditions: list[str]
    proof: list[str]
    fix_outline: str
    source_file: str
    triage_decision: str | None = None  # "escalated" | "skipped" | None
    threat_model: str = ""


@dataclass
class VerifiedFinding:
    finding: FindingRecord
    correctness_reason: str
    rebuttal_reason: str
    reproducer: dict | None = None


@dataclass
class DeepReviewResult:
    path: str
    findings: list[FindingRecord] | None = None
    error: str | None = None


@dataclass
class VerificationResult:
    finding_key: str
    verified_finding: VerifiedFinding | None = None
    discarded: bool = False
    error: str | None = None
    attempts: int = 1


@dataclass
class PatchGenerationResult:
    patch_text: str | None = None
    error_code: str | None = None
    error: str | None = None


@dataclass
class AuditRunState:
    run_id: str
    scope: AuditScope
    queued_files: list[str]
    reviewed_files: set[str] = field(default_factory=set)
    triage_records: dict[str, TriageRecord] = field(default_factory=dict)
    candidate_files: list[str] = field(default_factory=list)
    deep_reviewed_files: set[str] = field(default_factory=set)
    proposed_findings: list[FindingRecord] = field(default_factory=list)
    verified_findings: list[VerifiedFinding] = field(default_factory=list)
    repo_profile: dict | None = None
    import_index: dict[str, list[str]] = field(default_factory=dict)
    # Despite the name, caller_index[f] is the set of files exporting symbols
    # that f references — i.e. f's dependencies, not f's callers. Use
    # `dependency_index` for new code; preserved as caller_index in saved state
    # for backward compatibility.
    caller_index: dict[str, list[str]] = field(default_factory=dict)
    # path -> {symbol: serialized SymbolSpan dict} for top-level definitions.
    symbol_spans_index: dict[str, dict[str, dict]] = field(default_factory=dict)
    attack_scores: dict[str, int] = field(default_factory=dict)
    artifact_dir: Path = field(default_factory=lambda: Path("audit-findings"))
    state_dir: Path = field(default_factory=lambda: Path(".swival/audit"))
    verification_state: dict[str, dict] = field(default_factory=dict)
    artifact_state: dict[str, dict] = field(default_factory=dict)
    phase: str = "init"
    metrics: dict[str, int] = field(default_factory=lambda: dict(_DEFAULT_METRICS))
    select_all: bool = False
    measure_triage: bool = False
    # When ``measure_triage`` is set, this records the candidate set that
    # *real* triage produced after Phase 2. Phase 3 then expands to the full
    # mandatory set; findings whose source path is not in this set are
    # tagged as having been a triage SKIP.
    measurement_escalated_paths: set[str] = field(default_factory=set)
    # Findings dropped by the Phase 4.5 adjudication gate after verification.
    # Each entry: {"title", "severity", "source_file", "reason"}. Kept so the
    # README totals can explain why verified and written counts differ.
    adjudication_discarded: list[dict] = field(default_factory=list)
    # path -> number of Phase 3 calls (3A inventory or 3B expansion) for that
    # file whose evidence was head-truncated to fit the context window. A file
    # listed here was deep-reviewed on partial evidence.
    truncated_files: dict[str, int] = field(default_factory=dict)
    # In-memory HEAD content cache (path -> content), never persisted. HEAD
    # is fixed for the run, so entries never invalidate; concurrent duplicate
    # fills from worker threads are idempotent dict writes.
    _content_cache: dict[str, str] = field(
        default_factory=dict, repr=False, compare=False
    )

    @property
    def dependency_index(self) -> dict[str, list[str]]:
        """Accurate-name alias for caller_index.

        caller_index[f] holds files that export symbols f references — i.e.
        f's dependencies — not callers of f. This alias avoids confusion in
        new code without invalidating saved state.
        """
        return self.caller_index

    def save(self) -> None:
        d = self.state_dir / self.run_id
        d.mkdir(parents=True, exist_ok=True)
        # Incremental saves run on the collector thread while worker threads
        # still insert keys into metrics, truncated_files, and attack_scores
        # (triage scoring fallback); dict() snapshots them in one C-level call
        # so json iteration can't see a resize.
        blob = {
            "run_id": self.run_id,
            "scope": self.scope.to_dict(),
            "queued_files": self.queued_files,
            "reviewed_files": sorted(self.reviewed_files),
            "triage_records": {k: asdict(v) for k, v in self.triage_records.items()},
            "candidate_files": self.candidate_files,
            "deep_reviewed_files": sorted(self.deep_reviewed_files),
            "proposed_findings": [asdict(f) for f in self.proposed_findings],
            "verified_findings": [
                {
                    "finding": asdict(vf.finding),
                    "correctness_reason": vf.correctness_reason,
                    "rebuttal_reason": vf.rebuttal_reason,
                    "reproducer": vf.reproducer,
                }
                for vf in self.verified_findings
            ],
            "repo_profile": self.repo_profile,
            "import_index": self.import_index,
            "caller_index": self.caller_index,
            "symbol_spans_index": self.symbol_spans_index,
            "attack_scores": dict(self.attack_scores),
            "verification_state": self.verification_state,
            "artifact_state": self.artifact_state,
            "phase": self.phase,
            "metrics": dict(self.metrics),
            "select_all": self.select_all,
            "measure_triage": self.measure_triage,
            "measurement_escalated_paths": sorted(self.measurement_escalated_paths),
            "adjudication_discarded": self.adjudication_discarded,
            "truncated_files": dict(self.truncated_files),
        }
        state_path = d / "state.json"
        tmp = state_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(blob, indent=2))
        tmp.replace(state_path)

    @classmethod
    def load(cls, state_dir: Path, run_id: str) -> AuditRunState:
        d = state_dir / run_id / "state.json"
        blob = json.loads(d.read_text())
        scope = AuditScope.from_dict(blob["scope"])
        triage_records = {
            k: TriageRecord(**v) for k, v in blob.get("triage_records", {}).items()
        }
        proposed = [FindingRecord(**f) for f in blob.get("proposed_findings", [])]
        verified = []
        for vf in blob.get("verified_findings", []):
            verified.append(
                VerifiedFinding(
                    finding=FindingRecord(**vf["finding"]),
                    correctness_reason=vf["correctness_reason"],
                    rebuttal_reason=vf["rebuttal_reason"],
                    reproducer=vf.get("reproducer"),
                )
            )
        if "artifact_state" not in blob:
            raise ValueError(
                f"audit state file at {state_dir / run_id / 'state.json'} predates "
                "the artifact_state field. Re-run /audit from scratch."
            )

        state = cls(
            run_id=blob["run_id"],
            scope=scope,
            queued_files=blob["queued_files"],
            reviewed_files=set(blob.get("reviewed_files", [])),
            triage_records=triage_records,
            candidate_files=blob.get("candidate_files", []),
            deep_reviewed_files=set(blob.get("deep_reviewed_files", [])),
            proposed_findings=proposed,
            verified_findings=verified,
            repo_profile=blob.get("repo_profile"),
            import_index=blob.get("import_index", {}),
            caller_index=blob.get("caller_index", {}),
            symbol_spans_index=blob.get("symbol_spans_index", {}),
            attack_scores=blob.get("attack_scores", {}),
            verification_state=blob.get("verification_state", {}),
            artifact_state=blob["artifact_state"],
            state_dir=state_dir,
            phase=blob.get("phase", "init"),
            # Merge so a state file saved before a metric existed still
            # supports bare `+= 1` on every current counter.
            metrics={**_DEFAULT_METRICS, **blob.get("metrics", {})},
            select_all=bool(blob.get("select_all", False)),
            measure_triage=bool(blob.get("measure_triage", False)),
            measurement_escalated_paths=set(
                blob.get("measurement_escalated_paths", [])
            ),
            adjudication_discarded=blob.get("adjudication_discarded", []),
            truncated_files=blob.get("truncated_files", {}),
        )
        return state

    @classmethod
    def find_resumable(
        cls,
        state_dir: Path,
        commit: str,
        focus: list[str] | None,
        include_done: bool = False,
    ) -> AuditRunState | None:
        if not state_dir.exists():
            return None
        want = None if focus is None else set(_normalize_focus(focus))
        best = None
        best_mtime = 0.0
        for entry in state_dir.iterdir():
            sf = entry / "state.json"
            if not sf.exists():
                continue
            try:
                blob = json.loads(sf.read_text())
            except (json.JSONDecodeError, OSError):
                continue
            if blob.get("scope", {}).get("commit") != commit:
                continue
            if want is not None:
                persisted = _normalize_focus(
                    _coerce_focus(blob.get("scope", {}).get("focus"))
                )
                if set(persisted) != want:
                    continue
            if blob.get("phase") == "done" and not include_done:
                continue
            mtime = sf.stat().st_mtime
            if mtime > best_mtime:
                try:
                    candidate = cls.load(state_dir, blob["run_id"])
                except ValueError as e:
                    fmt.warning(str(e))
                    continue
                best_mtime = mtime
                best = candidate
        return best


# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------


def _git(args: list[str], cwd: str) -> str:
    result = subprocess.run(
        ["git"] + args,
        capture_output=True,
        text=True,
        cwd=cwd,
        timeout=30,
    )
    if result.returncode != 0:
        raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
    return result.stdout.strip()


def _resolve_scope(base_dir: str, focus: list[str]) -> AuditScope:
    branch = _git(["branch", "--show-current"], base_dir) or "HEAD"
    commit = _git(["rev-parse", "HEAD"], base_dir)
    raw = _git(["ls-tree", "-r", "--name-only", "HEAD"], base_dir)
    tracked = raw.splitlines() if raw else []

    focus = _normalize_focus(focus)
    if focus:
        tracked = [f for f in tracked if any(_match_path_glob(f, p) for p in focus)]

    mandatory = [f for f in tracked if _is_auditable(f)]
    return AuditScope(
        branch=branch,
        commit=commit,
        tracked_files=tracked,
        mandatory_files=mandatory,
        focus=focus,
    )


def _match_path_glob(file: str, glob: str) -> bool:
    """True when ``file`` (a repo-relative path) is selected by ``glob``.

    Selection rules, in order:

    - exact match: ``src/foo.rs`` selects only ``src/foo.rs``.
    - prefix match (no wildcard): ``src`` and ``src/`` both select
      anything under ``src/``.
    - pathlib wildcard match via ``PurePosixPath.full_match``: ``*``
      does *not* cross ``/``, ``?`` matches a single non-separator
      character, ``**`` matches any number of intermediate directories,
      and ``[abc]`` is a character class. A wildcard pattern with no
      ``/`` is implicitly recursive: ``*.rs`` is rewritten to
      ``**/*.rs`` so it keeps selecting every ``.rs`` file at any depth.
      This means ``src/*.rs`` matches only direct ``.rs`` children of a
      top-level ``src/``, and ``src/**/*.rs`` is the recursive form.
    """
    from pathlib import PurePosixPath

    if file == glob:
        return True

    has_wildcard = any(c in glob for c in "*?[")
    if not has_wildcard:
        prefix = glob if glob.endswith("/") else glob + "/"
        return file.startswith(prefix)

    pattern = glob.rstrip("/") or glob
    if "/" not in pattern:
        pattern = f"**/{pattern}"
    return PurePosixPath(file).full_match(pattern)


def _is_auditable(path: str) -> bool:
    return Path(path).suffix.lower() in _AUDITABLE_EXTS


def _dirty_worktree_warning(base_dir: str, scope: AuditScope) -> str | None:
    """One-line warning when the working tree differs from the audited HEAD.

    /audit reviews committed content only; a user comparing against a naive
    working-tree prompt needs to know when the two diverge. NUL-delimited
    plumbing sidesteps the rename/deletion/quoting pitfalls of parsing
    ``git status --porcelain``.
    """
    try:
        diff_raw = _git(["diff", "--name-status", "-M", "-z", "HEAD"], base_dir)
        untracked_raw = _git(
            ["ls-files", "--others", "--exclude-standard", "-z"], base_dir
        )
    except (RuntimeError, OSError, subprocess.SubprocessError):
        return None

    # mandatory_files is already _is_auditable-filtered, so membership alone
    # decides scope here. --name-status records are NUL-delimited as: status,
    # path — except rename/copy (Rnnn/Cnnn), which carry two paths: old, then
    # new. A staged rename reports only under the *new* name with --name-only,
    # which is absent from the HEAD-derived mandatory set, so both sides must
    # be checked here.
    mandatory = set(scope.mandatory_files)
    changed: list[str] = []
    tokens = iter(diff_raw.split("\0"))
    for status in tokens:
        if not status:
            continue
        path = next(tokens, "")
        if status.startswith(("R", "C")):
            new = next(tokens, "")
            if path in mandatory or new in mandatory:
                changed.append(path)
        elif path in mandatory:
            changed.append(path)
    # Untracked files are by definition absent from mandatory_files, so they
    # are scoped by the focus globs directly; no focus means the whole repo.
    untracked = [
        p
        for p in untracked_raw.split("\0")
        if p
        and _is_auditable(p)
        and (not scope.focus or any(_match_path_glob(p, g) for g in scope.focus))
    ]

    if not changed and not untracked:
        return None
    parts = []
    if changed:
        parts.append(f"{len(changed)} tracked file(s) differ from HEAD")
    if untracked:
        parts.append(f"{len(untracked)} untracked file(s) are not audited")
    return f"audit reviews committed content at {scope.commit[:8]}; " + " and ".join(
        parts
    )


def _git_show(path: str, base_dir: str) -> str:
    result = subprocess.run(
        ["git", "show", f"HEAD:{path}"],
        capture_output=True,
        cwd=base_dir,
        timeout=30,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"git show HEAD:{path} failed: {result.stderr.decode(errors='replace').strip()}"
        )
    return result.stdout.decode(errors="replace")


# ---------------------------------------------------------------------------
# Attack-surface scoring
# ---------------------------------------------------------------------------


def _score_attack_surface(content: str) -> int:
    score = 0
    for pattern, weight in _ATTACK_SURFACE_PATTERNS:
        if pattern.search(content):
            score += weight
    return score


def _order_by_attack_surface(
    files: list[str], content_cache: dict[str, str]
) -> tuple[list[str], dict[str, int]]:
    """Return (files ordered by descending score, score map)."""
    score_map: dict[str, int] = {}
    scored: list[tuple[int, str]] = []
    for f in files:
        score = _score_attack_surface(content_cache.get(f, ""))
        score_map[f] = score
        scored.append((-score, f))
    scored.sort(key=lambda t: (t[0], t[1]))
    return [f for _, f in scored], score_map


# ---------------------------------------------------------------------------
# Import / caller context extraction
# ---------------------------------------------------------------------------

_IMPORT_RE = re.compile(
    r"(?:"
    # Go/JS quoted-path imports. Modifier words ahead of the path cover
    # `import _ "pkg"` (Go blank), `import log "pkg"` (Go alias),
    # `import api from "./api"` (JS), `import type Foo from "./foo"` (TS).
    r"^\s*import\s+(?:[\w.]+\s+)*['\"]([^'\"]+)['\"]"
    # JS/TS with `from`. Requires whitespace before `from` so `Array.from(...)`
    # is not treated as an import. The body excludes quote and backtick
    # characters so a string or template literal containing the word `from`
    # does not register as an import.
    r"|^\s*(?:const|let|var|import|export)\s+[^'\"`\n]*?\sfrom\s+['\"]([^'\"]+)['\"]"
    r"|^\s*from\s+([\w.]+)\s+import"  # Python: from foo import bar
    # Python/Java/Kotlin/Scala/Swift `import NAME` with optional
    # language-specific modifiers (Java `static`, Swift
    # `struct|class|enum|protocol|typealias|func|var|let`).
    # `\w+(?:\.\w+)*` is a proper dotted identifier so trailing `.*` in
    # `import static java.lang.Math.*` doesn't capture a trailing dot.
    r"|^\s*import\s+(?:(?:static|struct|class|enum|protocol|typealias|func|var|let)\s+)?(\w+(?:\.\w+)*)"
    r"|require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)"  # Node require()
    r"|^\s*#include\s*(?:\"([^\"]+)\"|<([^>]+)>)"  # C/C++
    r"|^\s*using\s+(?:static\s+|namespace\s+)?([\w.]+)\s*;"  # C# / C++ using
    r"|^\s*require\s+['\"]([^'\"]+)['\"]"  # Perl/Ruby/Lua: require "X"
    r"|^\s*(?:use|require|no)\s+([\w:\\]+)"  # Rust/PHP/Perl
    # Zig `@import(...)`. Match anywhere on a line; the post-filter in
    # `_extract_imports` discards matches whose `@` lands inside a string
    # literal span or on a Zig multiline-string line (`\\...`).
    r"|@import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)"
    r")",
    re.MULTILINE,
)

_GO_IMPORT_BLOCK_RE = re.compile(r"^\s*import\s*\((.*?)\)", re.DOTALL | re.MULTILINE)
_GO_IMPORT_STR_RE = re.compile(r'(?:[\w.]+\s+)?"([^"]+)"')

_EXPORT_RE = re.compile(
    r"(?:"
    # Python `def foo` / Ruby `def self.foo` or `def Cls.foo`.
    r"^def\s+(?:\w+\.)?(\w+)"
    # class declaration with up to 6 modifier words (public, abstract, data, ...).
    r"|^\s*(?:\w+\s+){0,6}class\s+(\w+)"
    r"|^export\s+(?:default\s+)?(?:function|class|const|let|var)\s+(\w+)"  # JS/TS export
    # function/func/fun with modifier words ahead (public, async, suspend, static, ...).
    r"|^\s*(?:\w+\s+){0,6}(?:function|func|fun)\s+(\w+)"
    # Rust/Zig `pub`. Optional `pub(crate)`/`pub(in path)`/`pub(super)` visibility,
    # then up to 4 modifier tokens (`async`, `unsafe`, `const`, `extern "C"`, ...),
    # then the item kind. Modifier tokens may be a word or a quoted string so
    # `pub extern "C" fn` works. Greedy count so `pub const fn foo` consumes
    # `const` as a modifier and `fn` as the kind.
    r"|^\s*pub(?:\([^)]+\))?\s+(?:(?:\w+|\"[^\"]+\")\s+){0,4}(?:fn|struct|trait|enum|const|static|type|mod|union|var)\s+(\w+)"
    r"|^\s*sub\s+(\w+)"  # Perl sub
    r"|^\s*package\s+(\w+(?:::\w+)*)\s*[;{]"  # Perl package
    r")",
    re.MULTILINE,
)


_PYTHON_FROM_DOT_IMPORT_RE = re.compile(
    r"^\s*from\s+(\.+)\s+import\s+([^\n#]+)",
    re.MULTILINE,
)


def _extract_imports(content: str) -> list[str]:
    no_comments = _strip_comments(content)
    string_spans = _string_literal_spans(no_comments)
    imports = []
    for m in _IMPORT_RE.finditer(no_comments):
        if _starts_inside_string(m.start(), string_spans):
            continue
        if _is_zig_multiline_string_line(no_comments, m.start()):
            continue
        cap_idx = next((i for i, g in enumerate(m.groups(), 1) if g is not None), None)
        if cap_idx is None:
            continue
        # Also probe the byte just before the captured path. For rules like
        # JS `import x from "./api"` the match begins at line start, so
        # `m.start()` won't catch a `from` keyword tucked inside a regex
        # literal on the same line; the character preceding the capture is
        # the surrounding quote/regex content and reveals that context.
        cap_start = m.start(cap_idx)
        if cap_start > 0 and _starts_inside_string(cap_start - 1, string_spans):
            continue
        imports.append(m.group(cap_idx))
    # Python `from . import a, b as c` — capture each imported name prefixed
    # with the leading dots so the relative-import resolver can find the
    # sibling module file.
    for m in _PYTHON_FROM_DOT_IMPORT_RE.finditer(no_comments):
        dots = m.group(1)
        names = m.group(2)
        if "(" in names:
            names = names.split("(", 1)[1]
        names = names.replace(")", "")
        for raw in names.split(","):
            name = raw.strip().split(" as ")[0].strip().rstrip(",")
            if name and name != "*" and name.replace("_", "").isalnum():
                imports.append(f"{dots}{name}")
    # Mask string interiors before the Go grouped-import scan so an
    # `import (...)` block trapped inside a raw-string literal (backticks)
    # is not picked up. Path extraction uses the original buffer via the
    # matched positions so real quoted paths are still recovered.
    masked = _redact_string_contents(no_comments)
    for block_m in _GO_IMPORT_BLOCK_RE.finditer(masked):
        start, end = block_m.start(1), block_m.end(1)
        original_block = no_comments[start:end]
        for s in _GO_IMPORT_STR_RE.finditer(original_block):
            imports.append(s.group(1))
    return imports


def _extract_exports(content: str) -> list[str]:
    no_comments = _strip_comments(content)
    string_spans = _string_literal_spans(no_comments)
    exports = []
    for m in _EXPORT_RE.finditer(no_comments):
        if _starts_inside_string(m.start(), string_spans):
            continue
        sym = next((g for g in m.groups() if g), None)
        if sym and not sym.startswith("_"):
            exports.append(sym)
    return exports


def _git_show_many(paths: list[str], base_dir: str) -> dict[str, str]:
    """Read many files from git in one ``git cat-file --batch`` process.

    Returns a ``path->content`` dict. Missing or non-blob entries are silently
    skipped, as is anything beyond a record that breaks the framing. Newlines
    in paths would corrupt the protocol, so they raise ``RuntimeError``.
    Callers that want a per-path fallback for any path the batch didn't return
    should walk the input list against the returned dict — see
    ``_load_file_contents``.
    """
    rejected = [p for p in paths if "\n" in p]
    if rejected:
        raise RuntimeError(f"refusing to batch paths with newlines: {rejected[:3]}")
    safe_paths = paths
    if not safe_paths:
        return {}

    proc = subprocess.Popen(
        ["git", "cat-file", "--batch"],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        cwd=base_dir,
    )

    def _writer():
        try:
            for p in safe_paths:
                proc.stdin.write(f"HEAD:{p}\n".encode())
            proc.stdin.close()
        except BrokenPipeError:
            pass

    writer = threading.Thread(target=_writer, daemon=True)
    writer.start()

    cache: dict[str, str] = {}
    out = proc.stdout
    try:
        for p in safe_paths:
            header = out.readline()
            if not header:
                break
            parts = header.rstrip(b"\n").split(b" ")
            if len(parts) >= 2 and parts[1] == b"missing":
                continue
            if len(parts) < 3:
                break
            objtype = parts[1]
            try:
                size = int(parts[2])
            except ValueError:
                break
            chunks: list[bytes] = []
            remaining = size
            while remaining > 0:
                chunk = out.read(remaining)
                if not chunk:
                    break
                chunks.append(chunk)
                remaining -= len(chunk)
            if remaining > 0:
                break
            sep = out.read(1)
            if sep != b"\n":
                break
            if objtype != b"blob":
                continue
            cache[p] = b"".join(chunks).decode(errors="replace")
    finally:
        writer.join(timeout=5)
        if proc.poll() is None:
            try:
                proc.terminate()
            except ProcessLookupError:
                pass
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait()

    return cache


def _load_file_contents(files: list[str], base_dir: str) -> dict[str, str]:
    """Read all files from git once and return a path->content cache.

    Tries ``git cat-file --batch`` for the whole list first. Any path the
    batch process didn't return (because the process died, a record was
    malformed and broke framing, or the path contained a newline) falls back
    to per-file ``git show``.
    """
    cache: dict[str, str] = {}
    try:
        cache = _git_show_many(files, base_dir)
    except RuntimeError:
        cache = {}

    missing = [f for f in files if f not in cache]
    for f in missing:
        try:
            cache[f] = _git_show(f, base_dir)
        except RuntimeError:
            pass
    return cache


def _cached_git_show(path: str, state: AuditRunState, base_dir: str) -> str:
    """``_git_show`` through the run-scoped content cache.

    HEAD is fixed for the run, so a hit never goes stale. Raises
    ``RuntimeError`` like ``_git_show`` when the path is unreadable.
    """
    cached = state._content_cache.get(path)
    if cached is not None:
        return cached
    content = _git_show(path, base_dir)
    state._content_cache[path] = content
    return content


def _ensure_symbol_spans_index(state: AuditRunState, base_dir: str) -> None:
    """Post-load repair for state files that predate ``symbol_spans_index``.

    ``AuditRunState.load()`` is pure JSON deserialization and cannot shell
    out; the rebuild lives here in the runner where ``base_dir`` is in hand.
    Deterministic: the run is pinned to ``scope.commit``.
    """
    if state.symbol_spans_index:
        return
    content_cache = _load_file_contents(state.scope.mandatory_files, base_dir)
    state._content_cache.update(content_cache)
    index = _build_symbol_spans_index(state.scope.mandatory_files, content_cache)
    index, dropped = _cap_symbol_spans_index(index, state.caller_index)
    state.symbol_spans_index = index
    if dropped:
        fmt.info(f"symbol span index capped: dropped {dropped} unreferenced file(s)")
    state.save()


def _repo_profile_json(state: AuditRunState) -> str:
    if state.repo_profile is None:
        return "{}"
    cached = getattr(state, "_repo_profile_json_cached", None)
    if cached is None:
        cached = json.dumps(state.repo_profile, indent=2)
        state._repo_profile_json_cached = cached
    return cached


_IDENT_RE = re.compile(r"\b\w+\b")

_RELATIVE_IMPORT_EXTS: tuple[str, ...] = (
    ".ts",
    ".tsx",
    ".js",
    ".jsx",
    ".mjs",
    ".cjs",
    ".zig",
    ".rs",
    ".py",
    ".go",
)
_RELATIVE_INDEX_FILES: tuple[str, ...] = (
    "index.ts",
    "index.tsx",
    "index.js",
    "index.jsx",
    "mod.rs",
    "lib.rs",
    "__init__.py",
)


def _resolve_relative_import(
    importer: str,
    imp: str,
    file_set: set[str],
    *,
    importer_ext: str | None = None,
    importer_dir: str | None = None,
) -> str | None:
    """Resolve a relative import string to an actual repo file path.

    Only attempts resolution when the import string is explicitly relative.
    Three flavors qualify:

    * leading ``./`` or ``../`` (JS/TS),
    * Zig importer with a ``.zig``-suffixed bare name (Zig has no leading-`./`
      convention; package imports like ``@import("std")`` stay external),
    * Python importer with leading-dot syntax (``from .lib import x`` →
      ``.lib``; the leading dots are converted to ``./``/``../``).

    Callers that resolve many imports for the same importer should pass
    ``importer_ext`` / ``importer_dir`` to skip the per-call posixpath work.
    """
    import posixpath

    if not imp:
        return None
    if imp.startswith(("/", "http:", "https:", "@")):
        return None
    if ":" in imp:
        return None

    if importer_ext is None:
        importer_ext = posixpath.splitext(importer)[1]

    if importer_ext == ".py" and imp.startswith("."):
        leading_dots = 0
        while leading_dots < len(imp) and imp[leading_dots] == ".":
            leading_dots += 1
        rest = imp[leading_dots:].replace(".", "/")
        parents = "../" * (leading_dots - 1)
        imp = f"./{parents}{rest}" if rest else (f"./{parents}".rstrip("/") or ".")

    explicit_relative = imp.startswith("./") or imp.startswith("../")
    zig_style = importer_ext == ".zig" and imp.endswith(".zig")
    if not (explicit_relative or zig_style):
        return None

    if importer_dir is None:
        importer_dir = posixpath.dirname(importer)
    base = posixpath.normpath(posixpath.join(importer_dir, imp))

    for candidate in _expand_import_candidates(base):
        if candidate in file_set and candidate != importer:
            return candidate
    return None


def _expand_import_candidates(base: str) -> list[str]:
    """Return file-path candidates derived from a normalized base path."""
    import posixpath

    candidates = [base]
    name = posixpath.basename(base)
    if "." not in name:
        candidates.extend(f"{base}{ext}" for ext in _RELATIVE_IMPORT_EXTS)
        candidates.extend(f"{base}/{idx}" for idx in _RELATIVE_INDEX_FILES)
    return candidates


_SYMBOL_SPANS_MAX_SYMBOLS = 50_000


def _build_symbol_spans_index(
    files: list[str],
    content_cache: dict[str, str],
) -> dict[str, dict[str, dict]]:
    """path -> {symbol: serialized SymbolSpan dict} for top-level definitions."""
    from .outline import symbol_spans

    index: dict[str, dict[str, dict]] = {}
    for f in files:
        content = content_cache.get(f)
        if content is None:
            continue
        spans = symbol_spans(content, f)
        if spans:
            index[f] = {name: span.to_dict() for name, span in spans.items()}
    return index


def _cap_symbol_spans_index(
    index: dict[str, dict[str, dict]],
    dependency_index: dict[str, list[str]],
) -> tuple[dict[str, dict[str, dict]], int]:
    """Bound the span index on pathological repos.

    Over ``_SYMBOL_SPANS_MAX_SYMBOLS`` total symbols, keep only files that
    appear in someone's dependency edge (the only files callee resolution can
    ever consult). Returns ``(index, dropped_file_count)``.
    """
    total = sum(len(v) for v in index.values())
    if total <= _SYMBOL_SPANS_MAX_SYMBOLS:
        return index, 0
    referenced: set[str] = set()
    for deps in dependency_index.values():
        referenced.update(deps)
    capped = {f: v for f, v in index.items() if f in referenced}
    return capped, len(index) - len(capped)


def _build_context_indices(
    files: list[str],
    content_cache: dict[str, str],
) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, dict[str, dict]]]:
    import posixpath

    import_index: dict[str, list[str]] = {}
    export_map: dict[str, list[str]] = {}

    for f in files:
        content = content_cache.get(f)
        if content is None:
            continue
        import_index[f] = _extract_imports(content)
        for sym in _extract_exports(content):
            export_map.setdefault(sym, []).append(f)

    export_keys = set(export_map)
    file_set = set(files)

    caller_index: dict[str, list[str]] = {}
    for f in files:
        content = content_cache.get(f)
        if content is None:
            continue
        tokens = {m.group() for m in _IDENT_RE.finditer(content)}
        callers: set[str] = set()
        for sym in tokens & export_keys:
            sources = export_map[sym]
            if f not in sources:
                callers.update(sources)
        importer_ext = posixpath.splitext(f)[1]
        importer_dir = posixpath.dirname(f)
        for imp in import_index.get(f, ()):
            if imp in file_set and imp != f:
                callers.add(imp)
            resolved = _resolve_relative_import(
                f,
                imp,
                file_set,
                importer_ext=importer_ext,
                importer_dir=importer_dir,
            )
            if resolved is not None and resolved != f:
                callers.add(resolved)
            sources = export_map.get(imp)
            if not sources:
                continue
            for source in sources:
                if source != f:
                    callers.add(source)
        if callers:
            caller_index[f] = sorted(callers)

    symbol_spans_index = _build_symbol_spans_index(files, content_cache)
    symbol_spans_index, dropped = _cap_symbol_spans_index(
        symbol_spans_index, caller_index
    )
    if dropped:
        _ui_info(
            None,
            f"symbol span index capped at {_SYMBOL_SPANS_MAX_SYMBOLS} symbols: "
            f"dropped {dropped} file(s) not referenced by any dependency edge",
        )

    return import_index, caller_index, symbol_spans_index


# ---------------------------------------------------------------------------
# Cross-file callee context
# ---------------------------------------------------------------------------

_CALLEE_BODY_CAP = 8_000
_CALLEE_BUNDLE_CAP = 24_000
# Maximum same-tier definitions inlined for one name; beyond this the name is
# dropped as too ambiguous and listed in the omission note.
_CALLEE_MAX_DEFS = 2

_CALLEE_SECTION_HEADER = (
    "Called function definitions (cross-file, exact committed bodies):"
)

# Sentinel rendered into the prompt when no callee resolves; callers compare
# against it to decide whether a section is worth appending at all.
_CALLEE_NONE = "(none)"

# One-line orientation for prompts whose evidence carries a callee section.
# Kept terse: it is a per-call token tax.
_CALLEE_PROMPT_NOTE = (
    '"Called function definitions" are best-effort exact committed bodies of '
    "cross-file functions the primary file calls; not every callee is "
    "resolved. Their line numbers refer to their own files and may be cited "
    "in location fields."
)

_CALL_SITE_RE = re.compile(r"\b(\w+)\s*\(")

# Control-flow and declaration keywords that read as `name(` but are never
# cross-file callees. Resolution against the dependency set would drop most
# of them anyway; the stoplist avoids dictionary churn on hot keywords.
_CALL_SITE_STOPLIST = frozenset(
    {
        "if",
        "elif",
        "else",
        "for",
        "while",
        "switch",
        "match",
        "case",
        "return",
        "catch",
        "except",
        "with",
        "do",
        "until",
        "unless",
        "def",
        "fn",
        "func",
        "function",
        "lambda",
        "class",
        "struct",
        "enum",
        "union",
        "impl",
        "trait",
        "interface",
        "sizeof",
        "typeof",
        "alignof",
        "new",
        "delete",
        "not",
        "and",
        "or",
        "in",
        "is",
        "yield",
        "await",
        "assert",
        "raise",
        "throw",
        "defer",
        "go",
        "select",
        "loop",
        "foreach",
    }
)


def _call_sites(content: str) -> dict[str, list[int]]:
    """Map called-name -> call-site line numbers (1-based, in order).

    Operates on ``mask_noncode(content)`` so names inside strings, comments,
    and regex literals are excluded and line numbers stay exact. Dotted and
    arrow paths yield their trailing segment (``obj.parse_token(`` ->
    ``parse_token``). Deliberately dumb: precision comes from resolution
    against the dependency set, not from the scanner.
    """
    masked = mask_noncode(content)
    sites: dict[str, list[int]] = {}
    line = 1
    last = 0
    for m in _CALL_SITE_RE.finditer(masked):
        name = m.group(1)
        if name in _CALL_SITE_STOPLIST or name[0].isdigit():
            continue
        line += masked.count("\n", last, m.start(1))
        last = m.start(1)
        lines = sites.setdefault(name, [])
        if not lines or lines[-1] != line:
            lines.append(line)
    return sites


def _tier1_import_files(path: str, state: AuditRunState) -> list[str]:
    """Files reached through explicit, resolvable import statements of ``path``.

    These are the high-confidence dependency edges the author wrote down:
    exact tracked paths, relative imports (JS/TS, Zig, Python leading-dot),
    and dotted Python module paths mapped onto the tree. Order-preserving
    and deduplicated.
    """
    import posixpath

    file_set = set(state.scope.mandatory_files)
    ext = posixpath.splitext(path)[1]
    directory = posixpath.dirname(path)
    out: list[str] = []
    seen: set[str] = set()

    def _add(candidate: str) -> None:
        if candidate != path and candidate not in seen and candidate in file_set:
            seen.add(candidate)
            out.append(candidate)

    for imp in state.import_index.get(path, ()):
        if imp in file_set:
            _add(imp)
            continue
        resolved = _resolve_relative_import(
            path, imp, file_set, importer_ext=ext, importer_dir=directory
        )
        if resolved is not None:
            _add(resolved)
            continue
        # Dotted (or bare) module name: try it as a tree path. Covers Python
        # absolute imports (`from pkg.mod import f` -> pkg/mod.py).
        if "/" not in imp and not imp.startswith("."):
            base = imp.replace(".", "/")
            _add(f"{base}.py")
            _add(f"{base}/__init__.py")
    return out


def _format_call_sites(calls: dict[str, list[int]], paths: list[str]) -> str:
    """Render call-site lines, per file when more than one primary calls it."""
    multi = len(calls) > 1
    rendered: list[str] = []
    for path in paths:
        lines = calls.get(path)
        if not lines:
            continue
        more = f", +{len(lines) - 5} more" if len(lines) > 5 else ""
        if multi:
            shown = ",".join(str(n) for n in lines[:5])
            rendered.append(f"{path}:{shown}{more}")
        else:
            shown = ", ".join(str(n) for n in lines[:5])
            rendered.append(f"lines {shown}{more}")
    return "; ".join(rendered)


def _render_callee_block(
    cand: dict,
    paths: list[str],
    state: AuditRunState,
    ctx: InputContext,
    lines_cache: dict[str, list[str]],
) -> str | None:
    """One delimited callee block, or None when the source is unreadable.

    ``lines_cache`` memoizes split dependency files across the blocks of one
    gathering pass, so a file contributing several callees splits once.
    """
    src_lines = lines_cache.get(cand["def_path"])
    if src_lines is None:
        try:
            content = _cached_git_show(cand["def_path"], state, ctx.base_dir)
        except RuntimeError:
            return None
        src_lines = lines_cache[cand["def_path"]] = content.splitlines()
    span = cand["span"]
    start = max(1, span.start)
    render_end = min(span.render_end, len(src_lines))
    body = "\n".join(src_lines[start - 1 : render_end])
    notes: list[str] = []
    capped = _byte_capped_preview(body, _CALLEE_BODY_CAP)
    if len(capped) < len(body):
        body = capped
        notes.append(
            f"[truncated at {_CALLEE_BODY_CAP} bytes; full definition at "
            f"{cand['def_path']}:{start}-{span.end}]"
        )
    elif span.truncated == "span-cap":
        notes.append(
            f"[truncated: span cap; definition continues past "
            f"{cand['def_path']}:{render_end}]"
        )
    header = (
        f"--- {cand['name']} ({cand['def_path']}:{start}-{span.end}, "
        f"called at {_format_call_sites(cand['calls'], paths)}) ---"
    )
    block = f"{header}\n{body}"
    if notes:
        block += "\n" + "\n".join(notes)
    return block


def _gather_callee_context_for_paths(
    paths: list[str],
    contents: dict[str, str],
    state: AuditRunState,
    ctx: InputContext,
    exclude_files: set[str] | frozenset[str] = frozenset(),
) -> str:
    """Cross-file callee definitions for a bundle of primary files.

    Resolution per primary file, in two tiers: explicit resolved imports
    first, the broader ``dependency_index`` only for names tier 1 left
    unresolved. Within the winning tier a name defined in more than
    ``_CALLEE_MAX_DEFS`` files is dropped as too ambiguous. Names defined in
    the primary itself, or in a file already present in the bundle
    (``exclude_files`` plus ``paths``), are skipped.

    One ``_CALLEE_BUNDLE_CAP`` byte budget covers the whole returned section.
    Admission priority: triage ``relevant_symbols`` first, then call count,
    then first-call-site order; rendering stays in first-call-site order and
    over-budget callees degrade to one-line entries. Returns
    ``_CALLEE_NONE`` when nothing resolves.
    """
    from .outline import SymbolSpan

    bundle_files = set(exclude_files) | set(paths)
    candidates: dict[tuple[str, str], dict] = {}
    ambiguous: set[str] = set()

    for path_idx, path in enumerate(paths):
        content = contents.get(path)
        if content is None:
            continue
        sites = _call_sites(content)
        if not sites:
            continue
        own = set(state.symbol_spans_index.get(path, ()))
        tier1 = _tier1_import_files(path, state)
        tier1_set = set(tier1)
        tier2 = [f for f in state.dependency_index.get(path, []) if f not in tier1_set]
        resolved: set[str] = set()
        for tier_files in (tier1, tier2):
            hits: dict[str, list[str]] = {}
            for dep in tier_files:
                if dep in bundle_files:
                    continue
                dep_spans = state.symbol_spans_index.get(dep)
                if not dep_spans:
                    continue
                for name in sites.keys() & dep_spans.keys():
                    if name in own or name in resolved:
                        continue
                    hits.setdefault(name, []).append(dep)
            for name, def_files in sorted(hits.items()):
                resolved.add(name)
                if len(def_files) > _CALLEE_MAX_DEFS:
                    ambiguous.add(name)
                    continue
                for dep in def_files:
                    key = (name, dep)
                    cand = candidates.get(key)
                    if cand is None:
                        cand = candidates[key] = {
                            "name": name,
                            "def_path": dep,
                            "span": SymbolSpan.from_dict(
                                state.symbol_spans_index[dep][name]
                            ),
                            "calls": {},
                            "order": (path_idx, sites[name][0]),
                        }
                    cand["calls"][path] = sites[name]

    if not candidates and not ambiguous:
        return _CALLEE_NONE

    relevant: set[str] = set()
    for path in paths:
        rec = state.triage_records.get(path)
        if rec is not None:
            relevant.update(rec.relevant_symbols)

    def _admission_key(cand: dict) -> tuple:
        total_calls = sum(len(v) for v in cand["calls"].values())
        return (0 if cand["name"] in relevant else 1, -total_calls, cand["order"])

    budget = _CALLEE_BUNDLE_CAP
    rendered: dict[tuple[str, str], str] = {}
    unreadable: set[tuple[str, str]] = set()
    lines_cache: dict[str, list[str]] = {}
    for cand in sorted(candidates.values(), key=_admission_key):
        key = (cand["name"], cand["def_path"])
        block = _render_callee_block(cand, paths, state, ctx, lines_cache)
        if block is None:
            unreadable.add(key)
            continue
        block_bytes = len(block.encode("utf-8"))
        if block_bytes <= budget:
            budget -= block_bytes
            rendered[key] = block

    parts: list[str] = []
    omitted_lines: list[str] = []
    for cand in sorted(candidates.values(), key=lambda c: c["order"]):
        key = (cand["name"], cand["def_path"])
        if key in unreadable:
            continue
        block = rendered.get(key)
        if block is not None:
            parts.append(block)
        else:
            span = cand["span"]
            omitted_lines.append(
                f"{cand['name']} ({cand['def_path']}:{span.start}-{span.end}) "
                f"[omitted: bundle budget]"
            )
    if omitted_lines:
        parts.append("\n".join(omitted_lines))
    if ambiguous:
        parts.append("(ambiguous, not shown: " + ", ".join(sorted(ambiguous)) + ")")
    return "\n\n".join(parts) if parts else _CALLEE_NONE


def _gather_callee_context(
    path: str,
    content: str,
    state: AuditRunState,
    ctx: InputContext,
    exclude_files: set[str] | frozenset[str] = frozenset(),
) -> str:
    """Single-primary wrapper over :func:`_gather_callee_context_for_paths`."""
    return _gather_callee_context_for_paths(
        [path], {path: content}, state, ctx, exclude_files
    )


# ---------------------------------------------------------------------------
# Structured-text record helpers (`@@ name @@` blocks with `key: value` lines)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class RecordSchema:
    """Describes one record type (the body of a @@ name @@ block)."""

    name: str
    required: tuple[str, ...] = ()
    optional: tuple[str, ...] = ()
    enums: dict[str, tuple[str, ...]] = field(default_factory=dict)
    booleans: tuple[str, ...] = ()
    repeated: dict[str, str] = field(default_factory=dict)
    multiline: tuple[str, ...] = ()


@dataclass(frozen=True)
class PhaseSchema:
    """Describes the expected response shape for one audit phase."""

    record: RecordSchema
    cardinality: str  # "one" | "zero_or_more"
    allow_none: bool = False


_RECORD_HEADER_RE = re.compile(r"^\s*@@\s*([a-zA-Z_]+)\s*@@\s*$")
_RECORD_KV_RE = re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*[:=]\s*(.*)$")
_RECORD_FENCE_RE = re.compile(
    r"^```(?:[\w-]+)?\s*\n(.*)\n```\s*$",
    re.DOTALL,
)
_ENTRY_POINT_HINT_RE = re.compile(
    r"(main|app|server|handler|index|cli|entry)", re.IGNORECASE
)

_EXT_TO_LANG: dict[str, str] = {
    ".py": "python",
    ".js": "javascript",
    ".mjs": "javascript",
    ".cjs": "javascript",
    ".ts": "typescript",
    ".tsx": "typescript",
    ".jsx": "javascript",
    ".go": "go",
    ".rs": "rust",
    ".java": "java",
    ".kt": "kotlin",
    ".rb": "ruby",
    ".php": "php",
    ".c": "c",
    ".h": "c",
    ".cc": "c++",
    ".cpp": "c++",
    ".cxx": "c++",
    ".hpp": "c++",
    ".hh": "c++",
    ".cs": "c#",
    ".swift": "swift",
    ".scala": "scala",
    ".sh": "shell",
    ".bash": "shell",
    ".zsh": "shell",
    ".zig": "zig",
    ".d": "d",
    ".m": "objective-c",
    ".mm": "objective-c++",
    ".lua": "lua",
    ".pl": "perl",
    ".pm": "perl",
    ".psgi": "perl",
    ".r": "r",
    ".ex": "elixir",
    ".exs": "elixir",
    ".erl": "erlang",
    ".clj": "clojure",
    ".ml": "ocaml",
    ".nim": "nim",
    ".S": "assembly",
    ".s": "assembly",
    ".asm": "assembly",
}


def _phase1_source_inventory(tracked: list[str]) -> str:
    """Deterministic per-language file counts plus a few sample paths.

    Gives the model concrete facts for the required `language` field even when
    no recognized manifest is present in scope.
    """
    by_lang: dict[str, list[str]] = {}
    other_exts: dict[str, int] = {}
    for f in tracked:
        suffix = Path(f).suffix
        lang = _EXT_TO_LANG.get(suffix)
        if lang is None and suffix:
            other_exts[suffix] = other_exts.get(suffix, 0) + 1
            continue
        if lang is None:
            continue
        by_lang.setdefault(lang, []).append(f)

    if not by_lang and not other_exts:
        return ""

    lines = ["--- source inventory ---"]
    ranked = sorted(by_lang.items(), key=lambda kv: -len(kv[1]))
    for lang, files in ranked:
        samples = ", ".join(files[:3])
        more = f" (+{len(files) - 3} more)" if len(files) > 3 else ""
        lines.append(f"{lang}: {len(files)} file(s); examples: {samples}{more}")
    if other_exts:
        top_other = sorted(other_exts.items(), key=lambda kv: -kv[1])[:5]
        rendered = ", ".join(f"{ext}={n}" for ext, n in top_other)
        lines.append(f"other extensions: {rendered}")
    return "\n".join(lines)


def _parse_records(
    text: str,
    schema: PhaseSchema,
    *,
    metrics: dict[str, int] | None = None,
) -> list[dict]:
    """Parse @@-block records into a list of dicts, validated against schema.

    Permissive on input: case-insensitive headers and keys, optional blank
    lines between records, `:` or `=` separator, indented continuations on
    declared multiline fields, fenced code-block wrapping unwrapped, preamble
    and trailer prose tolerated.

    Strict on output: required keys must be present and non-empty, declared
    enums validated, booleans coerced, cardinality enforced, scalar duplicates
    rejected.

    When ``metrics`` is provided, ``multiline_continuations`` is incremented
    by the number of records that used at least one continuation line — but
    only after parsing and validation succeed.

    Raises ``ValueError`` on any failure with a message naming the offending
    key/record.
    """
    if not text or not text.strip():
        raise ValueError("empty LLM response")

    cleaned = text.strip()
    fence = _RECORD_FENCE_RE.match(cleaned)
    if fence:
        cleaned = fence.group(1).strip()

    rec_schema = schema.record
    target_name = rec_schema.name.lower()
    known_keys: set[str] = {
        k.lower()
        for group in (
            rec_schema.required,
            rec_schema.optional,
            rec_schema.repeated,
            rec_schema.multiline,
            rec_schema.booleans,
        )
        for k in group
    }
    repeated_map = {k.lower(): v for k, v in rec_schema.repeated.items()}
    multiline_keys = {k.lower() for k in rec_schema.multiline}

    records: list[dict] = []
    current: dict | None = None
    last_key: str | None = None
    saw_none = False
    multiline_records = 0
    current_used_multiline = False

    def commit() -> None:
        nonlocal current, last_key, current_used_multiline, multiline_records
        if current is not None:
            if current_used_multiline:
                multiline_records += 1
            records.append(current)
        current = None
        last_key = None
        current_used_multiline = False

    for raw_line in cleaned.splitlines():
        if not raw_line.strip():
            last_key = None
            continue

        m = _RECORD_HEADER_RE.match(raw_line)
        if m:
            name = m.group(1).lower()
            commit()
            if name == "none":
                if not schema.allow_none:
                    raise ValueError("'@@ none @@' sentinel not permitted by schema")
                saw_none = True
                continue
            if name != target_name:
                raise ValueError(
                    f"unexpected record type '@@ {name} @@', "
                    f"expected '@@ {target_name} @@'"
                )
            current = {}
            continue

        if current is None:
            continue

        if raw_line.startswith("  "):
            if last_key is None:
                raise ValueError(
                    f"continuation line before any key in record {len(records)}"
                )
            if last_key not in multiline_keys:
                raise ValueError(
                    f"continuation on key '{last_key}' which is not multiline "
                    f"in record {len(records)}"
                )
            cont_text = raw_line.strip()
            existing = current.get(last_key, "")
            current[last_key] = f"{existing} {cont_text}" if existing else cont_text
            current_used_multiline = True
            continue

        kv = _RECORD_KV_RE.match(raw_line)
        if not kv:
            commit()
            continue

        key = kv.group(1).lower()
        value = kv.group(2).rstrip()

        if key not in known_keys:
            commit()
            continue

        if key in repeated_map:
            if value.strip():
                current.setdefault(key, []).append(value)
        else:
            if key in current:
                raise ValueError(f"duplicate key '{key}' in record {len(records)}")
            current[key] = value
        last_key = key

    commit()

    if saw_none and not records:
        return []

    if saw_none and records:
        if metrics is not None:
            metrics["lenient_corrections"] = metrics.get("lenient_corrections", 0) + 1
        _debug_log(
            "records_lenient_discard_none",
            record_type=target_name,
            kept=len(records),
        )

    validated = _validate_records(records, schema, repeated_map, metrics=metrics)
    if metrics is not None and multiline_records > 0:
        metrics["multiline_continuations"] = (
            metrics.get("multiline_continuations", 0) + multiline_records
        )
    return validated


def _validate_records(
    records: list[dict],
    schema: PhaseSchema,
    repeated_map: dict[str, str],
    metrics: dict[str, int] | None = None,
) -> list[dict]:
    rec_schema = schema.record
    enum_lc = {k.lower(): {a.lower() for a in v} for k, v in rec_schema.enums.items()}
    enum_orig = {k.lower(): v for k, v in rec_schema.enums.items()}
    booleans = {k.lower() for k in rec_schema.booleans}

    for idx, record in enumerate(records):
        for required in rec_schema.required:
            rk = required.lower()
            if rk in repeated_map:
                vals = record.get(rk, [])
                if not vals or not any(v and v.strip() for v in vals):
                    raise ValueError(
                        f"missing required key '{required}' in record {idx}"
                    )
            else:
                val = record.get(rk, "")
                if not val or not val.strip():
                    raise ValueError(
                        f"missing required key '{required}' in record {idx}"
                    )

        for key, allowed_lc in enum_lc.items():
            if key in record:
                v = record[key]
                values = v if isinstance(v, list) else [v]
                for val in values:
                    if val.strip().lower() not in allowed_lc:
                        raise ValueError(
                            f"invalid enum value '{val}' for key '{key}' "
                            f"in record {idx}; allowed: "
                            f"{sorted(enum_orig[key])}"
                        )

        for key in booleans:
            if key in record:
                raw_val = record[key].strip().lower()
                if raw_val in ("true", "yes", "1"):
                    record[key] = True
                elif raw_val in ("false", "no", "0"):
                    record[key] = False
                else:
                    raise ValueError(
                        f"invalid boolean '{record[key]}' for key '{key}' "
                        f"in record {idx}"
                    )

    if schema.cardinality == "one":
        if not records:
            raise ValueError(
                f"expected exactly one '@@ {rec_schema.name} @@' record, got 0"
            )
        if len(records) > 1:
            if metrics is not None:
                metrics["lenient_corrections"] = (
                    metrics.get("lenient_corrections", 0) + 1
                )
            _debug_log(
                "records_lenient_truncate",
                record_type=rec_schema.name,
                got=len(records),
            )
            del records[1:]
    elif schema.cardinality == "zero_or_more":
        if not records:
            msg = f"expected at least one '@@ {rec_schema.name} @@' record"
            if schema.allow_none:
                msg += " or the '@@ none @@' sentinel"
            raise ValueError(msg)

    for record in records:
        for singular, plural in repeated_map.items():
            if singular in record:
                record[plural] = record.pop(singular)
            elif plural not in record:
                record[plural] = []

    return records


def _bump_parse_failure(metrics: dict[str, int], record_name: str) -> None:
    """Increment both the aggregate and the per-record-type parse-failure counter.

    Per-type counters (``parse_failures_<name>``) are added to the aggregate so
    a high-level reader can still see total failures, while a debugger can see
    which phase contributed.
    """
    metrics["parse_failures"] = metrics.get("parse_failures", 0) + 1
    typed_key = f"parse_failures_{record_name}"
    metrics[typed_key] = metrics.get(typed_key, 0) + 1


_PARSE_FAILURE_BREAKDOWN: tuple[tuple[str, str], ...] = (
    ("parse_failures_profile", "profile"),
    ("parse_failures_triage", "triage"),
    ("parse_failures_finding", "finding"),
    ("parse_failures_expansion", "expansion"),
)

_METRIC_LABELS: tuple[tuple[str, str], ...] = (
    ("repair_successes", "repairs succeeded"),
    ("repair_failures", "repairs failed"),
    ("analytical_retries", "analytical retries"),
    ("multiline_continuations", "multiline continuations"),
    ("lenient_corrections", "lenient corrections"),
    ("verifier_no_verdict", "verifier answers without a verdict token"),
    ("phase45_lens_retries", "adjudication lens retries"),
    ("truncated_calls", "calls with truncated evidence"),
    ("empty_response_retries", "empty-response retries"),
    ("high_none_retries", "ESCALATE_HIGH second opinions"),
)


def _format_audit_metrics(metrics: dict[str, int]) -> str:
    """Render the audit metrics dict for the phase 3 summary line.

    `parse_failures` shows the aggregate count and, when typed counters are
    populated, a parenthesized breakdown by record type. Other counters render
    as plain ``"N label"`` clauses joined by commas.
    """
    parts: list[str] = []
    pf = metrics.get("parse_failures", 0)
    if pf:
        breakdown = ", ".join(
            f"{metrics.get(k, 0)} {label}"
            for k, label in _PARSE_FAILURE_BREAKDOWN
            if metrics.get(k)
        )
        if breakdown:
            parts.append(f"{pf} parse failures ({breakdown})")
        else:
            parts.append(f"{pf} parse failures")
    for k, label in _METRIC_LABELS:
        if metrics.get(k):
            parts.append(f"{metrics[k]} {label}")
    return ", ".join(parts)


_RECORD_REPAIR_SYSTEM = """\
You are a format repair tool for structured text records.

You will receive a malformed model output that was supposed to follow a
specific @@ block @@ format, the parse error that was raised, and a worked
example of the correct format.

Rules:
- Fix only format errors: missing keys, malformed headers, wrong enum values,
  unbalanced indentation.
- Do not add, remove, or modify any factual content.
- Do not invent new records, claims, or values not present in the original.
- Output only the repaired records. No prose, no fences, no commentary."""


def _repair_records_response(
    ctx: InputContext,
    malformed: str,
    error_msg: str,
    worked_example: str,
    schema: PhaseSchema,
    metrics: dict[str, int] | None = None,
) -> list[dict]:
    record_name = schema.record.name
    user_msg = (
        f"Parse error: {error_msg}\n\n"
        f"Required format example:\n{worked_example}\n\n"
        f"Malformed output:\n{malformed}\n\n"
        f"Re-emit the same content using the exact format shown above. "
        f"Do not introduce new {record_name} records, do not add new claims, "
        f"and do not change any factual content; only fix the format."
    )
    messages = [
        {"role": "system", "content": _RECORD_REPAIR_SYSTEM},
        {"role": "user", "content": user_msg},
    ]
    raw = _call_audit_llm(
        ctx, messages, trace_task=f"audit: records repair {record_name}"
    )
    return _parse_records(raw, schema, metrics=metrics)


def _parse_records_with_repair(
    ctx: InputContext,
    raw: str,
    schema: PhaseSchema,
    worked_example: str,
    metrics: dict[str, int],
) -> list[dict]:
    """Parse records, falling back to one LLM repair pass on failure."""
    try:
        result = _parse_records(raw, schema, metrics=metrics)
        _debug_log("records_parse_ok", record_type=schema.record.name)
        return result
    except ValueError as e:
        _bump_parse_failure(metrics, schema.record.name)
        error_msg = str(e)
        _debug_log(
            "records_parse_failed",
            record_type=schema.record.name,
            error=error_msg,
            raw_len=len(raw),
            raw_preview=raw[:3000],
        )
        _ui_info(None, f"  parse failed ({error_msg}), attempting repair...")
        try:
            result = _repair_records_response(
                ctx, raw, error_msg, worked_example, schema, metrics=metrics
            )
            metrics["repair_successes"] = metrics.get("repair_successes", 0) + 1
            _debug_log("records_repair_ok", record_type=schema.record.name)
            _ui_info(None, "  repair succeeded")
            return result
        except ValueError as repair_err:
            metrics["repair_failures"] = metrics.get("repair_failures", 0) + 1
            _debug_log(
                "records_repair_failed",
                record_type=schema.record.name,
                error=str(repair_err),
            )
            _ui_info(None, "  repair failed")
            raise


# ---------------------------------------------------------------------------
# Trace helper
# ---------------------------------------------------------------------------


def _write_audit_trace(
    ctx: InputContext, messages: list, task: str | None = None
) -> None:
    trace_dir = getattr(ctx, "trace_dir", None)
    if not trace_dir or not messages:
        return
    from .traces import write_trace_to_dir

    write_trace_to_dir(
        messages,
        trace_dir=trace_dir,
        base_dir=ctx.base_dir,
        model=ctx.loop_kwargs.get("model_id", "unknown"),
        task=task,
    )


# ---------------------------------------------------------------------------
# Direct LLM call wrapper
# ---------------------------------------------------------------------------


def _call_audit_llm(
    ctx: InputContext,
    messages: list[dict],
    temperature: float | None = None,
    trace_task: str | None = None,
    metrics: dict[str, int] | None = None,
    truncation_out: list | None = None,
) -> str:
    """Call the LLM with overflow/empty-response retry and head-truncation.

    When ``metrics`` is supplied, bumps ``truncated_calls`` once per call that
    shrank at least once and ``empty_response_retries`` once per empty
    response received. When ``truncation_out`` is supplied and the call
    shrank, appends exactly one record:
    ``{"shrinks": n, "final_limit": limit, "original_len": len(original_text)}``.
    """
    from .agent import call_llm, ContextOverflowError
    from ._msg import _msg_content

    kw = ctx.loop_kwargs
    llm_kwargs = kw.get("llm_kwargs", {})

    cache_info = None

    def _do_call(msgs):
        nonlocal cache_info
        msg, _finish, _activity, _retries, cache_info = call_llm(
            kw["api_base"],
            kw["model_id"],
            msgs,
            kw.get("max_output_tokens"),
            temperature,
            kw.get("top_p"),
            kw.get("seed"),
            None,  # tools
            False,  # verbose
            provider=llm_kwargs.get("provider", "lmstudio"),
            api_key=llm_kwargs.get("api_key"),
            user_agent=llm_kwargs.get("user_agent"),
            prompt_cache=True,
            aws_profile=llm_kwargs.get("aws_profile"),
            vertex_project=llm_kwargs.get("vertex_project"),
            vertex_location=llm_kwargs.get("vertex_location"),
        )
        return _msg_content(msg) or ""

    user_msg = messages[-1]
    original_text = user_msg.get("content", "")
    limit = len(original_text)
    messages = list(messages)
    overflowed_once = False
    shrinks = 0

    _debug_log(
        "llm_request",
        task=trace_task,
        system=messages[0].get("content", "")[:500] if messages else "",
        user_len=len(original_text),
    )

    def _shrink() -> None:
        nonlocal limit, shrinks
        limit = limit // 2
        shrinks += 1
        if shrinks == 1:
            pct = (100 * limit) // max(1, len(original_text))
            _ui_warning(
                None,
                f"evidence truncated to {pct}% for {trace_task or 'audit call'}",
            )
        messages[-1] = {
            **user_msg,
            "content": original_text[:limit] + _AUDIT_TRUNCATION_MARKER,
        }

    empty_retries = 0
    while True:
        try:
            content = _do_call(messages)
            if not content:
                empty_retries += 1
                if metrics is not None:
                    metrics["empty_response_retries"] = (
                        metrics.get("empty_response_retries", 0) + 1
                    )
                if empty_retries > 3:
                    break
                _debug_log("llm_empty", task=trace_task, limit=limit)
                # Empty output is usually transient, not a context problem:
                # the first retry resends the message unchanged. Subsequent
                # empties shrink, but only while the result stays above the
                # truncation floor — the floor stops shrinking, never the
                # retry itself.
                if empty_retries > 1 and limit // 2 >= _AUDIT_TRUNCATION_FLOOR:
                    _shrink()
                continue
            break
        except ContextOverflowError:
            _debug_log("llm_overflow", task=trace_task, limit=limit)
            if not overflowed_once:
                overflowed_once = True
                _write_audit_trace(
                    ctx,
                    messages
                    + [
                        {
                            "role": "assistant",
                            "content": "[context overflow — retrying with truncation]",
                        }
                    ],
                    task=(trace_task + " (overflow)" if trace_task else "overflow"),
                )
            if limit // 2 < _AUDIT_TRUNCATION_FLOOR:
                raise
            _shrink()

    if shrinks:
        if metrics is not None:
            metrics["truncated_calls"] = metrics.get("truncated_calls", 0) + 1
        if truncation_out is not None:
            truncation_out.append(
                {
                    "shrinks": shrinks,
                    "final_limit": limit,
                    "original_len": len(original_text),
                }
            )

    _debug_log(
        "llm_response",
        task=trace_task,
        response_len=len(content),
        response_preview=content[:2000],
        cache=cache_info,
    )

    trace_messages = list(messages) + [{"role": "assistant", "content": content}]
    _write_audit_trace(ctx, trace_messages, task=trace_task)
    return content


# ---------------------------------------------------------------------------
# Prompt templates
# ---------------------------------------------------------------------------

_PHASE1_PROFILE_SCHEMA = PhaseSchema(
    record=RecordSchema(
        name="profile",
        required=("language", "summary"),
        repeated={
            "language": "languages",
            "framework": "frameworks",
            "entry_point": "entry_points",
            "trust_boundary": "trust_boundaries",
            "persistence_layer": "persistence_layers",
            "auth_surface": "auth_surfaces",
            "dangerous_operation": "dangerous_operations",
        },
    ),
    cardinality="one",
    allow_none=False,
)

_PHASE1_WORKED_EXAMPLE = """\
@@ profile @@
language: python
language: rust
framework: pytest
framework: uv
entry_point: swival/agent.py
entry_point: swival/audit.py
trust_boundary: cli args
trust_boundary: mcp servers
trust_boundary: llm provider responses
persistence_layer: .swival/HISTORY.md
persistence_layer: sqlite cache
auth_surface: chatgpt oauth device flow
dangerous_operation: subprocess
dangerous_operation: file write
dangerous_operation: eval-like template
summary: a python cli coding agent with mcp client and audit pipeline."""

_PHASE1_SYSTEM = f"""\
You are preparing a compact repository profile for a staged security audit.

This phase does not find bugs.
Its only job is to extract reusable repository facts that improve later review.

You have no tools, no shell access, and no ability to run commands.
All the source code you need is included below. Do not request additional information.

Output format: a single `@@ profile @@` block. The keys, one per line:
- language: one language per line; repeat the line for each
- framework: one framework or build/test tool per line; repeat the line for each
- entry_point: one repo-relative path per line; repeat the line for each
- trust_boundary: one short string per line; repeat the line for each
- persistence_layer: one short string per line; repeat the line for each
- auth_surface: one short string per line; repeat the line for each
- dangerous_operation: one short string per line; repeat the line for each
- summary: one line, under 120 words

Use exactly the keys shown. Do not quote, escape, or wrap values. Each value
runs to the end of its line. Omit a repeated key entirely when there is
nothing to add for it; do not emit empty values. At least one `language` line
and a non-empty `summary` are required.

Worked example:

{_PHASE1_WORKED_EXAMPLE}

End of example. Now produce the real profile for the repository below.

Rules:
- Use only the provided committed repository evidence.
- Do not speculate.
- Do not mention findings, vulnerabilities, or risks unless needed to describe a trust boundary.
- Keep every field short and reusable in later prompts."""

_PHASE2_TRIAGE_SCHEMA = PhaseSchema(
    record=RecordSchema(
        name="triage",
        required=("priority", "confidence", "summary"),
        enums={
            "priority": ("ESCALATE_HIGH", "ESCALATE_MEDIUM", "SKIP"),
            "confidence": ("high", "medium", "low"),
        },
        booleans=("needs_followup",),
        repeated={
            "bug_class": "bug_classes",
            "relevant_symbol": "relevant_symbols",
            "suspicious_flow": "suspicious_flows",
        },
    ),
    cardinality="one",
    allow_none=False,
)

_PHASE2_WORKED_EXAMPLE = """\
@@ triage @@
priority: ESCALATE_MEDIUM
confidence: medium
summary: parses untrusted url before authentication checks
bug_class: input_validation
bug_class: trust_boundary_breaks
relevant_symbol: parse_url
relevant_symbol: authenticate
suspicious_flow: external request body reaches parse_url before any auth gate
needs_followup: false"""

_PHASE2_SYSTEM = f"""\
You are performing phase 2 security triage for one committed file with its direct local context.

Goal:
- decide whether this file deserves deep review
- optimize for precision over recall
- avoid false positives and confirmation bias

Allowed priority labels:
- ESCALATE_HIGH
- ESCALATE_MEDIUM
- SKIP

Scope is **security only**. This is not a general code-quality reviewer. A bug is in scope only when an untrusted actor (remote client, malicious peer, attacker-controlled file or backend, lower-privileged local user) has a concrete trigger that yields a concrete security-relevant outcome under today's code. Acceptable outcomes are limited to:
- denial of service triggered by attacker-controlled input (not by admin misconfiguration or normal shutdown sequencing)
- information disclosure (secrets, tokens, internal addresses, cross-tenant data, memory contents)
- integrity bypass an attacker can observe or weaponize (HTTP smuggling, cache poisoning, log/header injection, signature/auth desync)
- authentication bypass, authorization bypass, or privilege escalation
- arbitrary code or command execution (remote, local, or in-process)
- repudiation (attacker hides their actions from logs in a way that defeats audit)

Narrow exception — security_control_failure: a high or critical, deterministic logic bug **inside the code that itself implements a named security control** is in scope even when the attacker is implicit. The function's job must be the control: a signature or MAC verifier, an authentication check, an authorization decision, a sandbox or seccomp boundary, an access-control filter, a crypto primitive, or a parser whose declared purpose is accepting hostile input. The "gain" is the control failing open or fail-permissive. This exception does NOT apply to code that is merely called from a security path, that handles untrusted data without being the decision point, or where the control name is generic ("validation", "checks").

Out of scope and must SKIP:
- function-contract bugs with no observable adversarial outcome (a function returns success when it should not, but no untrusted caller benefits and no named security control fails open)
- shutdown-time hangs, missed wakeups, leaked locks during teardown — these are correctness issues unless a remote attacker can both trigger them and gain something
- resource-lifecycle, error-handling, data-integrity, concurrency, or invariant-violation bugs that lack a concrete untrusted trigger and a concrete attacker gain, and do not make a named security control fail open
- protocol-framing or short-write correctness bugs where the only victim is the same trust domain as the input (server-to-server within the same operator)
- DoS that requires an admin or operator to author the malicious config or input — the operator is trusted
- generic robustness, missing tests, defensive hardening, "this should also validate X" suggestions

Do NOT escalate defense-in-depth concerns. A finding is defense-in-depth (and must SKIP) when an existing control in the code already prevents the attack and the proposal is to add a redundant secondary control, tighten an already-sufficient check, or harden against an attacker who has no real path to reach the code.

Before escalating, you must be able to name three things: who the attacker is (and why they are untrusted), what they control as input, and what they gain. If the finding is a security_control_failure, "attacker" may be any caller of the control and "trigger" may be any input the control is meant to reject — but you must still name the specific control by purpose (e.g., "Ed25519 signature verifier", "JWT audience check", "seccomp policy enforcer"). If you cannot, SKIP.

Security review lenses to consider. These are hints, not sufficient reasons to escalate:
- project_specific_invariant_break
- authorization
- input_validation
- path_traversal
- command_execution
- serialization
- arithmetic
- cryptography
- secret_lifecycle
- memory_safety
- overflows
- injection
- protocol_desynchronization
- parser_differential
- canonicalization_mismatch
- stale_authorization_cache
- fail_open_fallback
- capability_confusion
- namespace_confusion
- downgrade_or_policy_bypass
- trust_boundary_breaks
- unsafe_data_flow
- dangerous_api_misuse
- business_logic
- cross_component_contracts
- sandbox_escapes
- state_amplification_dos: compact encoded/referenced/streamed input, including
  parse/decode loops that append or materialize one object per unit, creates
  disproportionate decoded objects, retained state, refcounts, queued work, or
  pre-rejection work because limits count bytes/frames, check too late, or
  there is no decoded-unit cap
- taxonomy_free_unknowns_with_security_impact

You have no tools, no shell access, and no ability to run commands.
All the source code you need is included below. Do not request additional information.

Output format: a single `@@ triage @@` block with these keys, one per line:
- priority: ESCALATE_HIGH | ESCALATE_MEDIUM | SKIP
- confidence: high | medium | low
- summary: short string, max 25 words
- bug_class: one taxonomy string per line; repeat the line for each class
- relevant_symbol: one symbol name per line; repeat the line for each symbol
- suspicious_flow: one short string per line; repeat the line for each flow
- needs_followup: true or false

Use exactly the keys shown. Do not quote, escape, or wrap values. Each value
runs to the end of its line. Omit `bug_class`, `relevant_symbol`, and
`suspicious_flow` lines entirely when there is nothing to add for them — do
not emit empty values.

Worked example:

{_PHASE2_WORKED_EXAMPLE}

End of example. Now produce the real triage for the file below.

Rules:
- Use only repository-grounded reasoning.
- Do not declare that a bug is proven in this phase.
- Prefer SKIP if escalation cannot be justified.
- Use ESCALATE_HIGH only when the evidence bundle contains a concrete suspicious path or invariant break worth deep review."""


_PHASE2_CONFIRM_SYSTEM = (
    _PHASE2_SYSTEM
    + """

This is a confirmation pass. The first triage marked the file SKIP with low
confidence. You now have additional evidence (file-level dependencies plus,
when available, the contents of the highest-attack-surface dependency, and
any direct entry-point reachability hint). Re-evaluate. Do not flip to
ESCALATE unless the new evidence reveals a concrete attacker-controlled path
that was not visible before."""
)

_SEVERITIES = ("low", "medium", "high", "critical")

_PHASE3A_FINDING_SCHEMA = PhaseSchema(
    record=RecordSchema(
        name="finding",
        required=(
            "title",
            "severity",
            "location",
            "attacker",
            "trigger",
            "impact",
            "claim",
        ),
        enums={"severity": _SEVERITIES},
    ),
    cardinality="zero_or_more",
    allow_none=True,
)

_PHASE3A_WORKED_EXAMPLE = """\
@@ finding @@
title: attacker-controlled length wraps allocation size
severity: high
location: src/decode.c:142
attacker: unauthenticated client sending a crafted request
trigger: header_len and payload_len are added before allocation
impact: heap write past the allocation, crashing or corrupting the request worker
claim: crafted lengths wrap the allocation size"""

_PHASE3A_SYSTEM = f"""\
You are performing phase 3 deep security review for one candidate file.

Review only the committed repository evidence provided.
Reject any claim that is not fully proven.

You have no tools, no shell access, and no ability to run commands.
All the source code you need is included below. Do not request additional information.
{_CALLEE_PROMPT_NOTE}

Output format: either one or more `@@ finding @@` blocks (described below), OR the single line `@@ none @@` if no in-scope finding exists. No other output shape is valid. If you emit any `@@ finding @@` block, do NOT also emit `@@ none @@` — the sentinel is only for the zero-findings case.

Each `@@ finding @@` block has these keys, one per line:
- title: short title
- severity: low | medium | high | critical
- location: path:line
- attacker: specific untrusted actor, under 15 words
- trigger: attacker-controlled input or action reaching the bug, under 20 words
- impact: concrete security outcome, under 20 words
- claim: one-line bug statement under 20 words

Use exactly the keys shown. Do not quote, escape, or wrap values. Each value
runs to the end of its line.

Severity (anchor to the target's deployment surface, not worst case):
- critical: unauth remote code execution, or a security-decision primitive returning the wrong answer for any-party input.
- high: untrusted actor lacking the privilege the bug grants, via normal input. E.g. auth bypass on a network service, local privesc, memory corruption from attacker bytes.
- medium: untrusted actor, but needs non-default config, specific layout, or crafted input; or significant impact when the attacker already has same-trust access.
- low: needs local access, admin/operator cooperation, or dev-only paths; or minor impact (verbose errors, transient local resource use).

For CLI tools, libraries, and local-only services, bugs only triggerable by the user running them are rarely high or critical. Tie-break downward. The security_control_failure carve-out below forces high or critical and overrides this when it applies.

For state-amplification DoS, default to medium unless the evidence shows unrecoverable process/service exhaustion, cross-tenant impact, or amplified state that persists after the attacker-controlled connection/request closes.

Worked example:

{_PHASE3A_WORKED_EXAMPLE}

End of example. Now produce the real findings for the file below.

Rules:
- Report zero findings rather than a speculative finding.
- Every finding must be provable from the provided repository evidence.
- At most 3 findings per file.
- Each claim under 20 words.
- Prefer the narrowest bug that the evidence directly proves.
- For undefined behavior or uninitialized-state bugs, describe the direct invariant violation or invalid read/write instead of assuming a specific runtime value or deterministic branch outcome unless the evidence proves it.
- Use exact path:line citations.
- Do not include best practices, missing tests, or generic hardening advice.
- The attacker, trigger, and impact fields are required evidence gates. Do not
  use vague placeholders like "user", "input", "crash", or "could be bad".
- Spend review effort on project-specific security invariants and uncommon
  flaws: parser differential behavior, canonicalization mismatches,
  cross-component contract drift, fail-open fallback paths, stale authorization
  caches, namespace or capability confusion, protocol desynchronization,
  downgrade paths, and privilege-boundary mistakes. Do not stop at common
  checklist categories.
- For denial-of-service candidates, actively inspect parse/decode loops where
  one attacker-controlled encoded/referenced/streamed unit appends or
  materializes one server object or unit of work. Do not dismiss because encoded
  bytes or frames are bounded; ask whether decoded-item count, materialized
  object count, or pre-rejection work is capped before creation.
- For this class, source evidence of unbounded per-unit materialization before
  such a cap is enough to emit a medium candidate; Phase 3B/4 will validate
  practicality.

Scope gate — apply to every candidate finding before emitting it:

A finding is in scope only if you can answer all three of these from the evidence, in one breath:
  1. Attacker: who is the untrusted actor? (remote client, malicious peer, attacker-controlled file/backend, lower-privileged local user)
  2. Trigger: what input or action under their control reaches the bug?
  3. Gain: which security-relevant outcome do they get? — limited to denial of service triggered by attacker input, information disclosure, integrity bypass an attacker can weaponize (smuggling, cache poisoning, log/header injection, auth desync), authentication or authorization bypass, privilege escalation, code/command execution, or repudiation.

If the answer to any of the three is missing, vague, or only achievable by an admin/operator/trusted process, omit the finding and emit `@@ none @@` for the file (or whatever findings remain) — UNLESS the carve-out below applies.

Narrow exception — security_control_failure: a high or critical, deterministic logic bug **inside code that itself implements a named security control** is in scope even when the attacker is implicit. The cited function or module must be the security decision point: a signature/MAC verifier, an authentication check, an authorization decision, a sandbox or seccomp boundary, an access-control filter, a crypto primitive, or a parser whose declared purpose is accepting hostile input. For these findings:
  - attacker may read "any caller of the control"
  - trigger may read "input the control is required to reject"
  - impact must read "<named control> fails open: <observable consequence>"
  - severity must be high or critical
  - the claim must name the specific control by purpose ("Ed25519 signature verifier", "JWT audience check", "seccomp filter compiler", not "validation" or "input handling")
This exception does NOT apply to code that is merely called from a security path, that handles untrusted data without being the decision point, or where the control name is generic.

Explicitly out of scope — emit nothing for these even when the bug is real:
- function-contract violations in helpers where no named security control fails open
- shutdown-time hangs, missed wakeups during teardown, leaked locks on cleanup paths
- resource-lifecycle, error-handling, data-integrity, concurrency, or invariant-violation bugs that lack an untrusted trigger AND do not make a named security control fail open
- protocol-framing, short-write, or partial-IO bugs whose only effect is local correctness within the same trust domain
- DoS that requires an admin or operator to author the malicious config, regex, or input
- defense-in-depth: "an additional check would be safer", "this should also validate X", "this could leak in some other deployment", "redundant guard missing"

Resource-lifetime findings are in scope only when the untrusted actor controls the resource creation and the delay/release condition; cleanup-only bugs without that trigger remain out of scope.

Only report bugs where the structured fields specifically answer attacker / trigger / gain — for example "unauthenticated open redirect on failed form auth" or "out-of-bounds read on attacker-supplied trailing CR" — or where the security_control_failure carve-out lets the cited function's own job (an Ed25519 verifier, a seccomp policy enforcer) supply the security context.

If no finding survives the scope gate, emit exactly the single line below and stop. Do not write any prose, explanation, apology, or summary — the literal sentinel is the only valid no-finding response:

@@ none @@"""

_PHASE3B_OUT_OF_SCOPE_TYPE = "out-of-scope"
_PHASE3B_SECURITY_CONTROL_FAILURE_TYPE = "security_control_failure"

_PHASE3B_SECURITY_TYPE_VALUES = (
    "denial of service",
    "information disclosure",
    "memory corruption",
    "out-of-bounds read",
    "out-of-bounds write",
    "authentication bypass",
    "authorization bypass",
    "privilege escalation",
    "code execution",
    "command execution",
    "injection",
    "header injection",
    "open redirect",
    "ssrf",
    "request smuggling",
    "cache poisoning",
    "log injection",
    "cryptographic flaw",
    "path traversal",
    "policy bypass",
    "repudiation",
    "cross-tenant isolation break",
    "sandbox escape",
    _PHASE3B_SECURITY_CONTROL_FAILURE_TYPE,
)

_PHASE3B_TYPE_VALUES = _PHASE3B_SECURITY_TYPE_VALUES + (_PHASE3B_OUT_OF_SCOPE_TYPE,)

_SECURITY_CONTROL_FAILURE_MIN_SEVERITIES = _SEVERITIES[_SEVERITIES.index("high") :]

_PHASE3B_EXPANSION_SCHEMA = PhaseSchema(
    record=RecordSchema(
        name="expansion",
        required=(
            "type",
            "attacker",
            "trigger",
            "impact",
            "preconditions",
            "proof",
            "fix_outline",
        ),
        enums={"type": _PHASE3B_TYPE_VALUES},
        multiline=("proof",),
    ),
    cardinality="one",
    allow_none=False,
)

_PHASE3B_WORKED_EXAMPLE = """\
@@ expansion @@
type: code execution
attacker: unauthenticated http client
trigger: query-string parameter reaches shell command construction
impact: arbitrary command execution as the server user
preconditions: caller passes a user-supplied string as the cmd argument
proof:
  The public request handler passes the parameter to run_shell at line 400
  without validation. At line 412 it reaches subprocess(shell=true), so shell
  metacharacters execute under the server account.
fix_outline: pass argv list and drop shell=true, or shlex.quote each segment"""

_PHASE3B_SECURITY_CONTROL_FAILURE_TEMPLATE = """\
type: security_control_failure
attacker: any caller of the signature verifier
trigger: signature buffer whose final byte is zero
impact: Ed25519 signature verifier fails open and accepts forged signatures
preconditions: caller invokes verify_sig with a 64-byte signature buffer
proof:
  verify_sig at crypto/ed25519.c:88 implements Ed25519 signature verification.
  At line 102 it returns 0 (accept) when an early return on sig[63] == 0
  short-circuits the compare loop, so any forged signature whose last byte is
  zero is accepted as valid. The function is the verification decision point;
  no other check guards its callers.
fix_outline: remove the early return and complete the constant-time compare"""

_PHASE3B_SYSTEM = f"""\
You are expanding one security finding with proof details.

You have no tools, no shell access, and no ability to run commands.
All the source code you need is included below. Do not request additional information.
{_CALLEE_PROMPT_NOTE}

Output format: exactly one `@@ expansion @@` block — never two, never a copy of the worked example below followed by your own. The block has these keys, one per line:
- type: one of {", ".join(_PHASE3B_TYPE_VALUES)} - pick the security impact label, never a generic-correctness label
- attacker: specific untrusted actor, under 15 words
- trigger: attacker-controlled input or action reaching the bug, under 20 words
- impact: concrete security outcome, under 20 words
- preconditions: minimum justified preconditions, under 20 words
- proof: propagation path, failing operation, and reachability - under 100
  words total. The proof value may span multiple lines: any line that begins
  with two or more spaces continues the proof value.
- fix_outline: smallest correct fix, under 20 words

Use exactly the keys shown. Do not quote, escape, or wrap values. Every value
runs to the end of its line, and only `proof:` may continue on indented lines.

Worked example:

{_PHASE3B_WORKED_EXAMPLE}

End of example. Now produce the real expansion for the finding below.

Rules:
- Use only the provided repository evidence.
- Prefer the narrowest bug that the evidence directly proves.
- For undefined behavior or uninitialized-state bugs, describe the direct invariant violation.
- Do not speculate beyond what the code proves.
- The attacker, trigger, and impact fields must restate the security scope in
  concrete terms. For every type other than `security_control_failure`, all
  three must point to a specific untrusted actor, an input or action under
  their control, and a security-relevant outcome they observe.
- The `type` value must be a security impact label. Do not pick a label
  whose closest plain-English fit would be "logic error", "data integrity",
  "error handling", "resource lifecycle", or "invariant violation"; if the bug
  matches one of those without further security framing, it is out of scope.
- For a denial-of-service finding where attacker-cheap encoded, referenced,
  streamed, or deferred input creates disproportionate decoded, materialized,
  or retained state, keep `type: denial of service` and make the proof carry
  this five-fact ledger:
  - unit: attacker-controlled compact action or input
  - resource: server state/work created per unit
  - limit: existing bound that appears relevant, or no decoded-unit cap visible
  - gap: dimension the bound misses or checks too late
  - timing: whether work happens before rejection or remains live afterward

`security_control_failure` rules — apply only when the cited code itself
implements a named security control:
- Severity must be `high` or `critical`. Lower-severity control failures are
  out of scope for this exception.
- The proof must name the specific control by its security purpose
  (for example: "Ed25519 signature verifier", "JWT audience authorization
  check", "seccomp filter compiler", "TLS hostname matcher"). Generic phrases
  like "validation", "input handling", "the parser" are not enough.
- The proof must show a deterministic path on which the control returns
  accept/allow/true for an input it must reject, or returns the wrong
  decision in a way an attacker can rely on. Speculative fail-open paths,
  partial-coverage gaps, and "could be hardened" notes do not qualify.
- attacker may read "any caller of the control"; trigger may read
  "input the control is required to reject"; impact must read
  "<named control> fails open: <observable consequence>".
- Do NOT use this type when the function only happens to be called from a
  security-sensitive path, when the bug is a robustness gap, when the proof
  is a contract violation in a helper that no security decision depends on,
  or when untrusted data merely passes through the function on its way
  somewhere else.

A correctly-shaped `security_control_failure` block looks like:

{_PHASE3B_SECURITY_CONTROL_FAILURE_TEMPLATE}

If the candidate is a real bug but out of security scope, still emit a valid
block so the pipeline can discard it:
  type: out-of-scope
  attacker: missing
  trigger: missing
  impact: missing
  preconditions: out-of-scope
  proof: out-of-scope because attacker, trigger, or security impact is not proven
  fix_outline: no security fix"""

_PHASE4_VERIFY_SYSTEM = """\
You are verifying one proposed security finding using the committed source code in an isolated worktree.

Your job is to determine whether the finding describes a real bug that can be triggered in practice. Treat the finding as a hypothesis, not as ground truth.

Rules:
- You may inspect the code only, or you may compile/run small proof-of-concept code if that helps.
- Use the committed source evidence and the isolated worktree only.
- If the exact claim is wrong but the evidence proves a narrower directly source-grounded local bug, prove that narrower bug instead.
- A proof counts if you can identify the trigger, reachability conditions, propagation path, failing operation or violated invariant, and practical impact from the code, or demonstrate equivalent runtime evidence.
- For undefined behavior, uninitialized-state, and memory-safety bugs, either source-based proof or convincing runtime evidence is acceptable.
- Reject only when the code does not support a practical trigger path.
- Reject defense-in-depth findings as NOTREPRODUCED. If an existing control in the committed code already blocks the attack, the finding is not real — even if adding another layer would be safer. The bar is a concrete attacker with a concrete input producing a concrete security-relevant outcome under today's code, not "this would be more robust if it also did X".
- Treat the proposed finding's attacker, trigger, and impact text as claims to
  verify, not as facts. A finding that reproduces a correctness failure but not
  those three security claims is NOTREPRODUCED.
- Reject as NOTREPRODUCED any finding that fails the security scope test, even if the underlying bug is real:
  - You must be able to name an untrusted actor (remote client, malicious peer, attacker-controlled file or backend, lower-privileged local user) who controls the trigger. If the only way to reach the bug is through an admin, operator, or otherwise trusted process authoring the malicious config, regex, or input, the finding is out of scope.
  - You must be able to name a security-relevant outcome from this list: denial of service triggered by attacker-controlled input, information disclosure, integrity bypass an attacker can weaponize (smuggling, cache poisoning, log/header injection, auth desync), authentication or authorization bypass, privilege escalation, arbitrary code/command execution, or repudiation. "The function returns success when it should not", "the protocol can desync between two trusted endpoints", "a worker thread hangs at shutdown", and "a missed wakeup leaves a producer asleep" are not security-relevant outcomes on their own.
  - Generic correctness, contract, robustness, error-handling, resource-lifecycle, missed-wakeup, partial-IO, or invariant-violation bugs without an untrusted trigger AND a concrete attacker gain must be rejected as NOTREPRODUCED. Reproducibility of the bug behavior is not enough; the security impact must also reproduce.
- For a denial-of-service finding whose proof presents a state-amplification five-fact ledger, accept only when source evidence or a bounded PoC proves that ledger and practical DoS. Do not require OOMing the machine.
- Narrow exception — `security_control_failure` findings: a high or critical, deterministic logic bug **inside code that itself implements a named security control** is in scope even when the attacker is implicit. Verify these claims, in order, before accepting:
  1. The cited function or module **is itself the security control** named in the proof — a signature/MAC verifier, an authentication check, an authorization decision, a sandbox or seccomp boundary, an access-control filter, a crypto primitive, or a parser whose declared purpose is accepting hostile input. Not "called from a security path", not "handles untrusted data on the way somewhere else".
  2. The proof names that specific control by purpose. Generic phrases like "validation", "input handling", or "the parser" do not satisfy this — reject as NOTREPRODUCED.
  3. The bug deterministically makes that control return accept/allow/true for an input it must reject (or otherwise return the wrong security decision). Speculative fail-open paths, partial-coverage gaps, and "this would be more robust if" arguments do not qualify — reject as NOTREPRODUCED.
  4. Severity is high or critical. If a proposed `security_control_failure` is low or medium severity, reject as NOTREPRODUCED. Do not reclassify it under the standard scope test during verification — a lower-severity bug with a concrete attacker, trigger, and gain belongs under its concrete impact type (`authorization bypass`, `information disclosure`, etc.), and that reclassification is phase 3B's job, not yours.
  If all four hold, treat the finding as REPRODUCED even though the attacker is "any caller of the control" and the trigger is "input the control is required to reject". If any one fails, reject as NOTREPRODUCED — including the case where the underlying logic bug is real but the function is merely security-adjacent rather than the control itself.
- End your final response with exactly one of these tokens on its own line:
  REPRODUCED
  NOTREPRODUCED"""

_PHASE45_REVIEW_SYSTEM = (
    """\
You are adjudicating one already-reproduced security finding. A prior phase built a proof-of-concept and accepted it. Your job is the opposite of that phase: try to REFUTE this finding. Default to false_positive unless the evidence forces otherwise. We would rather drop a real bug than ship a false positive.

{lens}

Judge against the project's realistic, expected threat model, not a worst-case hypothetical.

A finding is real only if, under today's committed code and this deployment surface, all of the following hold:
- a genuinely untrusted actor (remote client, malicious peer, attacker-controlled file or backend, lower-privileged local user) controls the trigger
- they gain a concrete security outcome: denial of service from attacker-controlled input, information disclosure, integrity or auth bypass an attacker can weaponize, privilege escalation, code or command execution, or repudiation
- no existing control in the committed code already blocks it (reject defense-in-depth)

Set threat_model_relevant to no when the only actor is the operator, an admin, or the user running a local CLI on their own machine, or when the attack is self-inflicted. Those are correctness issues, not vulnerabilities.

security_control_failure carve-out: a deterministic logic bug inside code that itself implements a named security control (a signature or MAC verifier, an authentication check, an authorization decision, a sandbox or seccomp boundary, a crypto primitive) is real even when the attacker is implicit, but only at high or critical severity, and only when the proof names the specific control by purpose. A function merely called from a security path does not qualify.

Recalibrate severity to the realistic outcome on this deployment surface:
- critical: unauthenticated remote code execution, or a security primitive returning the wrong answer for any-party input
- high: untrusted actor gains privilege via normal input (auth bypass on a network service, memory corruption from attacker bytes, remote DoS that exhausts the service)
- medium: needs non-default config, a specific layout, or crafted input, or the attacker already has same-trust access
- low: needs local access, admin cooperation, or dev-only paths, or the impact is minor
For CLI tools and local libraries, a bug only reachable by the user running them is rarely high or critical. Tie-break downward.

You have no tools and cannot run commands. Reason only over the evidence below.

Output exactly one `@@ verdict @@` block with these keys, one per line:
- verdict: real | false_positive
- threat_model_relevant: yes | no
- severity: low | medium | high | critical
- reason: one line under 25 words

Use exactly the keys shown. Do not quote, escape, or wrap values."""
    + (
        # Concatenated rather than embedded: the template literal goes through
        # .format(lens=...), where literal braces would be hazardous.
        "\n\n" + _CALLEE_PROMPT_NOTE
    )
)

_PHASE45_LENSES = (
    "Lens: reachability. Concentrate on whether an untrusted actor can actually reach this code path under realistic deployment. If the only path runs through trusted, admin, or self-invoked code, it is a false_positive.",
    "Lens: threat-model fit. Concentrate on whether this matters in the project's expected threat model. A self-inflicted issue, or one only the operator can trigger, is not a vulnerability even when the code is genuinely wrong.",
    "Lens: severity and defense-in-depth. Concentrate on whether the claimed severity is justified and whether an existing control already mitigates this. Recalibrate to the realistic worst case and treat redundant-hardening proposals as false_positive.",
)

_PHASE45_WORKED_EXAMPLE = """\
@@ verdict @@
verdict: false_positive
threat_model_relevant: no
severity: low
reason: only the local user running the CLI can trigger this, so it is self-inflicted rather than an attacker path"""

_PHASE45_VERDICT_SCHEMA = PhaseSchema(
    record=RecordSchema(
        name="verdict",
        required=("verdict", "threat_model_relevant", "severity", "reason"),
        enums={
            "verdict": ("real", "false_positive"),
            "threat_model_relevant": ("yes", "no"),
            "severity": _SEVERITIES,
        },
    ),
    cardinality="one",
    allow_none=False,
)

_PHASE45_CONSOLIDATE_SYSTEM = """\
You are finalizing one security finding that a refutation panel voted to keep. Produce the corrected, evidence-grounded version that the report and patch will be generated from.

You have no tools and cannot run commands. Use only the evidence and panel verdicts below.

Rules:
- Severity may only be lowered or left unchanged, never raised above the proposed severity. Anchor it to the realistic outcome on the stated deployment surface and tie-break downward for local-only or same-trust bugs.
- A security_control_failure finding must stay high or critical.
- Keep the title and type as narrow as the evidence proves. Prefer the most specific security impact label.
- Do not invent new attack surface. If the panel narrowed the realistic impact, reflect that.
- threat_model must state, in one or two lines, who the untrusted actor is and why this matters under the project's expected deployment, or state plainly that the realistic impact is limited.

Output exactly one `@@ ruling @@` block with these keys, one per line:
- title: short, specific finding title
- type: the security impact label (the narrowest that fits)
- severity: low | medium | high | critical
- precondition: one minimal precondition per line, repeat the line for each (omit if none)
- threat_model: realistic threat-model statement; continuation lines may start with two spaces
- fix_outline: smallest correct fix, under 20 words

Use exactly the keys shown. Do not quote, escape, or wrap values."""

_PHASE45_RULING_WORKED_EXAMPLE = """\
@@ ruling @@
title: unauthenticated open redirect on failed form auth
type: open redirect
severity: medium
precondition: form-based auth enabled in config
threat_model: a remote unauthenticated visitor is redirected to an attacker-chosen host after a failed login, usable for phishing
fix_outline: validate the redirect target against an allowlist before issuing the 302"""

_PHASE45_RULING_SCHEMA = PhaseSchema(
    record=RecordSchema(
        name="ruling",
        required=("title", "type", "severity", "threat_model"),
        optional=("fix_outline",),
        enums={"severity": _SEVERITIES},
        repeated={"precondition": "preconditions"},
        multiline=("threat_model",),
    ),
    cardinality="one",
    allow_none=False,
)

_PHASE5_REPORT_TEMPLATE = """\
You are writing the final markdown report for one reproduced and patched finding.

Use exactly this structure:
- # <short finding title>
- ## Classification
- ## Affected Locations
- ## Summary
- ## Provenance
- ## Preconditions
- ## Proof
- ## Why This Is A Real Bug
- ## Fix Requirement
- ## Patch Rationale
- ## Residual Risk
- ## Patch

You have no tools, no shell access, and no ability to run commands.
All the information you need is included below. Do not request additional information.
Your entire response must be a single markdown document and nothing else.

Rules:
- Confidence must be certain.
- Provenance must include a link to the Swival.dev Security Scanner URL: {provenance_url}
- Residual Risk must be `None` unless a narrow evidence-based concern remains.
- For a denial-of-service finding whose proof presents a state-amplification five-fact ledger, the Proof or Why This Is A Real Bug section must explain it.
- Be terse, factual, and evidence-driven."""

# ---------------------------------------------------------------------------
# Phase implementations
# ---------------------------------------------------------------------------


def _phase1_repo_profile(state: AuditRunState, ctx: InputContext) -> dict:
    """Build a compact repository profile from committed evidence."""
    evidence_parts = []
    inventory = _phase1_source_inventory(state.scope.tracked_files)
    if inventory:
        evidence_parts.append(inventory)

    manifest_names = {
        "package.json",
        "Cargo.toml",
        "go.mod",
        "pyproject.toml",
        "requirements.txt",
        "setup.py",
        "setup.cfg",
        "Makefile",
        "Makefile.am",
        "Makefile.in",
        "configure.ac",
        "CMakeLists.txt",
        "meson.build",
        "build.zig",
        "build.zig.zon",
        "Dockerfile",
        "docker-compose.yml",
        "docker-compose.yaml",
        "pom.xml",
        "build.gradle",
        "Gemfile",
    }
    for f in state.scope.tracked_files:
        if Path(f).name in manifest_names:
            try:
                content = _git_show(f, ctx.base_dir)
                evidence_parts.append(f"--- {f} ---\n{content[:2000]}")
            except RuntimeError:
                pass

    entry_hints = [
        f
        for f in state.scope.mandatory_files
        if _ENTRY_POINT_HINT_RE.search(Path(f).stem)
    ]
    for f in entry_hints[:5]:
        try:
            content = _git_show(f, ctx.base_dir)
            evidence_parts.append(
                f"--- {f} (entry point candidate) ---\n{content[:3000]}"
            )
        except RuntimeError:
            pass

    evidence = "\n\n".join(evidence_parts) if evidence_parts else "(no evidence)"
    suffix = f"Committed repository evidence:\n{evidence}"

    messages = [
        {"role": "system", "content": _PHASE1_SYSTEM},
        {"role": "user", "content": suffix},
    ]
    raw = _call_audit_llm(
        ctx,
        messages,
        trace_task="audit: phase 1 repo profile",
        metrics=state.metrics,
    )
    records = _parse_records_with_repair(
        ctx,
        raw,
        schema=_PHASE1_PROFILE_SCHEMA,
        worked_example=_PHASE1_WORKED_EXAMPLE,
        metrics=state.metrics,
    )
    return records[0]


def _triage_record_from_parsed(path: str, parsed: dict) -> TriageRecord:
    """Build a TriageRecord from a parsed Phase-2 LLM record dict."""
    return TriageRecord(
        path=path,
        priority=parsed["priority"].upper(),
        confidence=parsed["confidence"].lower(),
        bug_classes=parsed.get("bug_classes", []),
        summary=parsed["summary"],
        relevant_symbols=parsed.get("relevant_symbols", []),
        suspicious_flows=parsed.get("suspicious_flows", []),
        needs_followup=parsed.get("needs_followup", False),
    )


def _phase2_triage_one(
    path: str,
    state: AuditRunState,
    ctx: InputContext,
) -> TriageRecord:
    """Triage a single file."""
    try:
        content = _git_show(path, ctx.base_dir)
    except RuntimeError:
        return TriageRecord(
            path=path,
            priority="SKIP",
            confidence="low",
            bug_classes=[],
            summary="file not readable",
            relevant_symbols=[],
            suspicious_flows=[],
            needs_followup=False,
        )

    imports_summary = ", ".join(state.import_index.get(path, [])[:20]) or "(none)"
    callers_summary = ", ".join(state.caller_index.get(path, [])[:10]) or "(none)"
    # Read from the cached score map populated in Phase 1 so the prompt and
    # the promotion gate at _apply_promotions agree. Fall back to a fresh
    # score if the cache is empty (legacy state files predate the cache).
    if path in state.attack_scores:
        score = state.attack_scores[path]
    else:
        score = _score_attack_surface(content)
        state.attack_scores[path] = score

    profile_json = _repo_profile_json(state)

    suffix = (
        f"Repository profile:\n{profile_json}\n\n"
        f"Attack-surface metadata:\nscore={score}\n\n"
        f"Direct imports/includes:\n{imports_summary}\n\n"
        f"Direct callers:\n{callers_summary}\n\n"
        f"Committed primary file contents:\n{content}\n\n"
        f"The file is: {path}"
    )
    messages = [
        {"role": "system", "content": _PHASE2_SYSTEM},
        {"role": "user", "content": suffix},
    ]
    try:
        raw = _call_audit_llm(
            ctx,
            messages,
            trace_task=f"audit: phase 2 triage {path}",
            metrics=state.metrics,
        )
    except Exception as e:
        kind = type(e).__name__
        _debug_log(
            "triage_llm_failed",
            path=path,
            error=str(e),
            kind=kind,
        )
        return TriageRecord(
            path=path,
            priority="SKIP",
            confidence="low",
            bug_classes=[],
            summary=f"triage failed (llm call failed: {kind})",
            relevant_symbols=[],
            suspicious_flows=[],
            needs_followup=False,
            triage_failure_mode=f"llm_call_failed:{kind}",
        )

    try:
        records = _parse_records(raw, _PHASE2_TRIAGE_SCHEMA, metrics=state.metrics)
    except ValueError as e:
        _bump_parse_failure(state.metrics, _PHASE2_TRIAGE_SCHEMA.record.name)
        _debug_log(
            "records_parse_failed",
            record_type="triage",
            path=path,
            error=str(e),
            raw_len=len(raw),
            raw_preview=raw[:1000],
        )
        return TriageRecord(
            path=path,
            priority="SKIP",
            confidence="low",
            bug_classes=[],
            summary="triage failed (unparseable LLM response)",
            relevant_symbols=[],
            suspicious_flows=[],
            needs_followup=False,
            triage_failure_mode="parse_error",
        )

    return _triage_record_from_parsed(path, records[0])


# ---------------------------------------------------------------------------
# Phase 2 confirmation pass (low-confidence SKIPs)
# ---------------------------------------------------------------------------


def _phase2_confirm_one(
    path: str,
    state: AuditRunState,
    ctx: InputContext,
) -> TriageRecord | None:
    """Re-triage one path with richer evidence.

    Returns a new record when the second pass produces a verdict; returns
    None when the call itself fails so the caller can preserve the
    original SKIP record (the file is no worse off for the failed retry).
    """
    deps = state.dependency_index.get(path, [])[:30]
    deps_summary = ", ".join(deps) or "(none)"

    top_dep = ""
    top_dep_score = -1
    for d in deps:
        s = state.attack_scores.get(d, 0)
        if s > top_dep_score:
            top_dep_score = s
            top_dep = d

    paths_to_load = [path] + ([top_dep] if top_dep else [])
    cache = _load_file_contents(paths_to_load, ctx.base_dir)
    if path not in cache:
        return None
    content = cache[path]
    top_dep_excerpt = ""
    if top_dep:
        dep_content = cache.get(top_dep)
        if dep_content is None:
            top_dep = ""
        else:
            top_dep_excerpt = dep_content[:3000]

    entries = _entry_point_paths(state)
    reachable_from: list[str] = []
    for entry in entries:
        if path in state.dependency_index.get(entry, []):
            reachable_from.append(entry)
    reach_summary = (
        ", ".join(reachable_from) if reachable_from else "(no direct entry-point reach)"
    )

    score = state.attack_scores.get(path, 0)
    profile_json = _repo_profile_json(state)

    suffix = (
        f"Repository profile:\n{profile_json}\n\n"
        f"Attack-surface metadata:\nscore={score}\n\n"
        f"Files this calls into / depends on:\n{deps_summary}\n\n"
        f"Direct entry-point reachability:\n{reach_summary}\n\n"
    )
    if top_dep_excerpt:
        suffix += (
            f"Top dependency by score: {top_dep} (score={top_dep_score}). "
            f"Excerpt (first 3000 chars):\n{top_dep_excerpt}\n\n"
        )
    suffix += (
        f"Committed primary file contents:\n{content}\n\n"
        f"The file is: {path}\n\n"
        "First-pass triage marked this file SKIP with low confidence."
    )

    messages = [
        {"role": "system", "content": _PHASE2_CONFIRM_SYSTEM},
        {"role": "user", "content": suffix},
    ]
    try:
        raw = _call_audit_llm(
            ctx,
            messages,
            trace_task=f"audit: phase 2 confirm {path}",
            metrics=state.metrics,
        )
    except Exception as e:
        _debug_log("triage_confirm_failed", path=path, error=str(e))
        return None

    try:
        records = _parse_records(raw, _PHASE2_TRIAGE_SCHEMA, metrics=state.metrics)
    except ValueError as e:
        _debug_log("triage_confirm_parse_failed", path=path, error=str(e))
        return None

    return _triage_record_from_parsed(path, records[0])


# ---------------------------------------------------------------------------
# Phase 2 promotion (recover false negatives from triage)
# ---------------------------------------------------------------------------

_PROMOTION_SCORE_THRESHOLD = 8


def _trust_boundary_paths(state: AuditRunState) -> set[str]:
    """Return paths flagged as trust boundaries by Phase 1.

    The ``trust_boundary`` field is free-form text. Treat any value that
    matches a mandatory file path (or ends with one) as a path reference.
    """
    profile = state.repo_profile or {}
    boundaries = profile.get("trust_boundaries", []) or []
    mandatory = set(state.scope.mandatory_files)
    paths: set[str] = set()
    for entry in boundaries:
        if not isinstance(entry, str):
            continue
        if entry in mandatory:
            paths.add(entry)
            continue
        for f in mandatory:
            if entry.endswith(f) or f in entry.split():
                paths.add(f)
    return paths


def _entry_point_paths(state: AuditRunState) -> list[str]:
    profile = state.repo_profile or {}
    entries = profile.get("entry_points", []) or []
    mandatory = set(state.scope.mandatory_files)
    return [e for e in entries if isinstance(e, str) and e in mandatory]


def _compute_promotion_reasons(
    state: AuditRunState,
    force_review_matches: dict[str, str],
) -> dict[str, list[str]]:
    """Return ``{path: [reason, ...]}`` for files that should be promoted
    out of any SKIP verdict by deterministic signals."""
    reasons: dict[str, list[str]] = {}

    def _add(p: str, msg: str) -> None:
        reasons.setdefault(p, []).append(msg)

    # Score-based promotion.
    for p in state.queued_files:
        score = state.attack_scores.get(p, 0)
        if score >= _PROMOTION_SCORE_THRESHOLD:
            _add(p, f"attack-surface score {score}")

    # Entry points.
    entries = _entry_point_paths(state)
    for p in entries:
        _add(p, "phase 1 entry point")

    # Trust boundaries.
    for p in _trust_boundary_paths(state):
        _add(p, "phase 1 trust boundary")

    # One-hop reverse from each entry point: caller_index[entry] holds the
    # files that entry references. Anything an entry point reaches with a
    # non-zero attack score deserves a look.
    for entry in entries:
        for dep in state.caller_index.get(entry, []):
            if state.attack_scores.get(dep, 0) > 0:
                _add(dep, f"reached from entry point {entry}")

    # Triage said "needs followup" — promote outright.
    for p, rec in state.triage_records.items():
        if rec.needs_followup:
            _add(p, "triage needs_followup")

    # Triage infrastructure failure (LLM call failed, parse error, etc.).
    for p, rec in state.triage_records.items():
        if rec.triage_failure_mode is not None:
            _add(p, f"triage infrastructure failure: {rec.triage_failure_mode}")

    # Force-review (user override from swival.toml) and exact-path focus
    # (the user named the file on the /audit command line).
    for p, source in force_review_matches.items():
        if source == "focus":
            _add(p, "named explicitly in /audit focus")
        else:
            _add(p, f"forced via swival.toml ({source})")

    return reasons


def _apply_promotions(
    state: AuditRunState,
    force_review_matches: dict[str, str],
) -> dict[str, list[str]]:
    """Compute promotions and apply them to ``state.triage_records``.

    Synthesizes a record for any queued path missing from triage_records
    (belt-and-suspenders against silent drops). Returns the per-path
    promotion reasons that fired.
    """
    # Belt-and-suspenders: every queued file must have a record. If one
    # is missing here it means a worker silently dropped it; synthesize
    # a record marked as missing so it can be promoted on the same rule
    # as other infrastructure failures.
    for p in state.queued_files:
        if p not in state.triage_records:
            state.triage_records[p] = TriageRecord(
                path=p,
                priority="SKIP",
                confidence="low",
                bug_classes=[],
                summary="triage record missing after retries",
                relevant_symbols=[],
                suspicious_flows=[],
                needs_followup=False,
                triage_failure_mode="missing",
            )
            state.reviewed_files.add(p)

    promotions = _compute_promotion_reasons(state, force_review_matches)

    for path, why in promotions.items():
        rec = state.triage_records.get(path)
        if rec is None:
            continue
        for r in why:
            if r not in rec.promotion_reasons:
                rec.promotion_reasons.append(r)
        if rec.priority == "SKIP":
            rec.priority = "ESCALATE_MEDIUM"

    return promotions


def _emit_measure_triage_recall(state: AuditRunState) -> None:
    """Print the calibration mode recall section.

    Counts verified findings by ``triage_decision`` and breaks them out by
    severity. Findings whose source file was a Phase-2 SKIP are the false
    negatives this mode exists to surface.
    """
    by_decision_severity: dict[tuple[str, str], int] = {}
    for vf in state.verified_findings:
        decision = vf.finding.triage_decision or "unknown"
        sev = vf.finding.severity or "unknown"
        by_decision_severity[(decision, sev)] = (
            by_decision_severity.get((decision, sev), 0) + 1
        )

    _ui_info(None, "--- triage recall (--measure-triage) ---")
    _ui_info(
        None,
        f"  candidate set after triage: {len(state.measurement_escalated_paths)} files",
    )
    _ui_info(None, f"  expanded deep-review set:   {len(state.candidate_files)} files")
    n_escalated = sum(
        n for (d, _s), n in by_decision_severity.items() if d == "escalated"
    )
    n_skipped = sum(n for (d, _s), n in by_decision_severity.items() if d == "skipped")
    _ui_info(None, f"  verified findings on escalated files: {n_escalated}")
    _ui_info(
        None,
        f"  verified findings on SKIPped files:   {n_skipped} (false negatives)",
    )
    if n_skipped:
        for sev in _SEVERITIES + ("unknown",):
            n = by_decision_severity.get(("skipped", sev), 0)
            if n:
                _ui_info(None, f"    skipped × {sev}: {n}")
        for vf in state.verified_findings:
            if vf.finding.triage_decision == "skipped":
                _ui_info(
                    None,
                    f"    - {vf.finding.source_file}: "
                    f"[{vf.finding.severity}] {vf.finding.title}",
                )


# (reason_prefix, label, emitter). The emitter is fmt.warning for
# infrastructure-failure promotions because they signal a flaky model or
# upstream rather than a routine selection.
_PROMOTION_REASON_LABELS: tuple[tuple[str, str, str], ...] = (
    ("attack-surface score", "promoted by attack-surface score", "info"),
    ("phase 1 entry point", "promoted as phase 1 entry point", "info"),
    ("reached from entry point", "promoted by entry-point reachability", "info"),
    ("phase 1 trust boundary", "promoted as phase 1 trust boundary", "info"),
    ("triage needs_followup", "promoted by triage needs_followup", "info"),
    (
        "triage infrastructure failure",
        "promoted due to triage infrastructure failure",
        "warning",
    ),
    ("forced via swival.toml", "force-listed in swival.toml", "info"),
    ("named explicitly in /audit focus", "named explicitly in /audit focus", "info"),
)


def _emit_phase2_summary(
    state: AuditRunState,
    promotions: dict[str, list[str]],
) -> None:
    """Write the end-of-phase-2 status lines, broken down by promotion reason."""
    records = state.triage_records
    n_high = sum(1 for r in records.values() if r.priority == "ESCALATE_HIGH")
    n_medium = sum(1 for r in records.values() if r.priority == "ESCALATE_MEDIUM")
    candidate_set = set(state.candidate_files)
    by_llm = sum(
        1 for p, r in records.items() if p in candidate_set and not r.promotion_reasons
    )
    promotion_only = [p for p in candidate_set if records[p].promotion_reasons]

    counts: dict[str, int] = {}
    for prefix, _label, _emit in _PROMOTION_REASON_LABELS:
        counts[prefix] = sum(
            1
            for p in promotion_only
            if any(r.startswith(prefix) for r in records[p].promotion_reasons)
        )
    by_confirm = sum(
        1 for p in candidate_set if records[p].confirmation_outcome == "promoted"
    )

    _ui_info(
        None,
        f"phase 2 complete. {len(state.candidate_files)} files escalated "
        f"({n_high} high, {n_medium} medium)",
    )
    if promotions or by_confirm:
        _ui_info(None, f"  - {by_llm} by LLM triage")
        for prefix, label, emit in _PROMOTION_REASON_LABELS:
            n = counts[prefix]
            if not n:
                continue
            if emit == "warning":
                _ui_warning(None, f"  - {n} {label}")
            else:
                _ui_info(None, f"  - {n} {label}")
        if by_confirm:
            _ui_info(None, f"  - {by_confirm} recovered by confirmation pass")

    skipped = [p for p, r in records.items() if r.priority == "SKIP"]
    if skipped:
        ranked = sorted(skipped, key=lambda p: (-state.attack_scores.get(p, 0), p))[:5]
        top = ", ".join(f"{p}:{state.attack_scores.get(p, 0)}" for p in ranked)
        _ui_info(None, f"  {len(skipped)} files SKIPped (top by score: {top})")


def _phase3a_inventory(
    path: str,
    state: AuditRunState,
    ctx: InputContext,
    content: str,
    callee_context: str | None = None,
) -> list[dict]:
    """Phase 3a: compact finding inventory for one file.

    ``callee_context`` lets the per-file driver compute the callee section
    once and share it with the 3b expansion calls.
    """
    triage = state.triage_records.get(path)
    bug_classes = ", ".join(triage.bug_classes) if triage else "all"

    profile_json = _repo_profile_json(state)
    triage_json = json.dumps(asdict(triage), indent=2) if triage else "{}"
    if callee_context is None:
        callee_context = _gather_callee_context(path, content, state, ctx)

    # Ordered so blind head-truncation deletes the least valuable content
    # first: the callee section goes, then the tail of the file, never the
    # path declaration.
    suffix = (
        f"Primary file: {path}\n\n"
        f"Focus bug classes (triage hints, not limits): {bug_classes}\n\n"
        f"Repository profile:\n{profile_json}\n\n"
        f"Phase 2 triage result:\n{triage_json}\n\n"
        f"Committed evidence bundle:\n{content}\n\n"
        f"{_CALLEE_SECTION_HEADER}\n{callee_context}"
    )
    messages = [
        {"role": "system", "content": _PHASE3A_SYSTEM},
        {"role": "user", "content": suffix},
    ]
    truncation_out: list[dict] = []
    raw = _call_audit_llm(
        ctx,
        messages,
        trace_task=f"audit: phase 3a inventory {path}",
        metrics=state.metrics,
        truncation_out=truncation_out,
    )
    if truncation_out:
        state.truncated_files[path] = state.truncated_files.get(path, 0) + 1
    return _parse_records_with_repair(
        ctx,
        raw,
        schema=_PHASE3A_FINDING_SCHEMA,
        worked_example=_PHASE3A_WORKED_EXAMPLE,
        metrics=state.metrics,
    )


def _phase3b_expand_one(
    item: tuple[dict, str, str, AuditRunState, InputContext],
    callee_context: str | None = None,
) -> dict | None:
    """Phase 3b: expand one inventory finding with proof details.

    ``callee_context`` lets the per-file driver compute the callee section
    once and share it across all of a file's expansion calls.
    """
    finding_stub, path, content, state, ctx = item

    if callee_context is None:
        callee_context = _gather_callee_context(path, content, state, ctx)
    # The finding under expansion leads so head-truncation can never delete
    # it; the callee section trails as the first content to go.
    suffix = (
        f"Finding to expand:\n"
        f"  Title: {finding_stub.get('title', '')}\n"
        f"  Severity: {finding_stub.get('severity', '')}\n"
        f"  Location: {finding_stub.get('location', '')}\n"
        f"  Attacker: {finding_stub.get('attacker', '')}\n"
        f"  Trigger: {finding_stub.get('trigger', '')}\n"
        f"  Impact: {finding_stub.get('impact', '')}\n"
        f"  Claim: {finding_stub.get('claim', '')}\n\n"
        f"Committed evidence for {path}:\n{content}\n\n"
        f"{_CALLEE_SECTION_HEADER}\n{callee_context}"
    )
    messages = [
        {"role": "system", "content": _PHASE3B_SYSTEM},
        {"role": "user", "content": suffix},
    ]
    truncation_out: list[dict] = []
    raw = _call_audit_llm(
        ctx,
        messages,
        trace_task=f"audit: phase 3b expand {path}",
        metrics=state.metrics,
        truncation_out=truncation_out,
    )
    if truncation_out:
        state.truncated_files[path] = state.truncated_files.get(path, 0) + 1
    try:
        records = _parse_records_with_repair(
            ctx,
            raw,
            schema=_PHASE3B_EXPANSION_SCHEMA,
            worked_example=_PHASE3B_WORKED_EXAMPLE,
            metrics=state.metrics,
        )
    except ValueError:
        return None
    return records[0] if records else None


def _canonicalize_finding(
    inventory_item: dict,
    expansion: dict,
    source_file: str,
) -> FindingRecord:
    """Build a FindingRecord from compact inventory + expansion dicts."""
    severity = (inventory_item.get("severity") or "low").lower()
    if severity not in _SEVERITIES:
        severity = "low"

    location = inventory_item.get("location", source_file)
    preconditions_raw = expansion.get("preconditions", "")
    proof_parts = [
        f"attacker: {expansion.get('attacker', '')}",
        f"trigger: {expansion.get('trigger', '')}",
        f"impact: {expansion.get('impact', '')}",
        expansion.get("proof", ""),
    ]
    proof_raw = " ".join(p for p in proof_parts if p and not p.endswith(": "))

    return FindingRecord(
        title=inventory_item.get("title", "untitled"),
        finding_type=expansion.get("type", "unknown"),
        severity=severity,
        locations=[location] if isinstance(location, str) else location,
        preconditions=[preconditions_raw] if preconditions_raw else [],
        proof=[proof_raw] if proof_raw else [],
        fix_outline=expansion.get("fix_outline", ""),
        source_file=source_file,
    )


def _is_out_of_scope_expansion(expansion: dict) -> bool:
    return expansion.get("type", "").strip().lower() == _PHASE3B_OUT_OF_SCOPE_TYPE


def _scf_below_min_severity(finding_type: str, severity: str) -> bool:
    if finding_type.strip().lower() != _PHASE3B_SECURITY_CONTROL_FAILURE_TYPE:
        return False
    return severity.strip().lower() not in _SECURITY_CONTROL_FAILURE_MIN_SEVERITIES


def _phase3_deep_review(
    path: str,
    state: AuditRunState,
    ctx: InputContext,
    ui: AuditUI | None = None,
) -> list[FindingRecord]:
    """Deep review a single escalated file using inventory + expansion."""
    try:
        content = _git_show(path, ctx.base_dir)
    except RuntimeError:
        return []

    # Computed once per file: 3a and every 3b expansion share the same
    # primary content, so the callee section is identical across them.
    callee_context = _gather_callee_context(path, content, state, ctx)
    inventory = _phase3a_inventory(
        path, state, ctx, content, callee_context=callee_context
    )
    if not inventory:
        triage = state.triage_records.get(path)
        if triage is not None and triage.priority == "ESCALATE_HIGH":
            # `@@ none @@` is the easiest way for a small model to comply
            # with the format, so an empty inventory on the files triage
            # rated highest gets one identical re-ask. Sampling variance is
            # the mechanism; the prompt stays byte-identical (cache-friendly).
            state.metrics["high_none_retries"] += 1
            inventory = _phase3a_inventory(
                path, state, ctx, content, callee_context=callee_context
            )
    if not inventory:
        return []

    expansion_items = [(stub, path, content, state, ctx) for stub in inventory]
    expansions = []
    for item in expansion_items:
        try:
            expansions.append(_phase3b_expand_one(item, callee_context=callee_context))
        except Exception as e:
            _ui_warning(ui, f"expansion failed for {path}: {e}")
            expansions.append(None)

    findings = []
    failed_expansions = 0
    for stub, expansion in zip(inventory, expansions):
        if expansion is None:
            failed_expansions += 1
            continue
        if _is_out_of_scope_expansion(expansion):
            continue
        if _scf_below_min_severity(expansion.get("type", ""), stub.get("severity", "")):
            continue
        findings.append(_canonicalize_finding(stub, expansion, path))

    if failed_expansions > 0:
        if not findings:
            raise ValueError(
                f"all {failed_expansions} expansion(s) failed for {path} "
                f"({len(inventory)} inventory finding(s))"
            )
        _ui_warning(
            ui,
            f"  {path}: {failed_expansions}/{len(inventory)} expansion(s) failed, "
            f"{len(findings)} finding(s) retained",
        )
    return findings


def _deep_review_one(
    path: str,
    state: AuditRunState,
    ctx: InputContext,
    ui: AuditUI | None = None,
) -> DeepReviewResult:
    """Run deep review with repair-first retry policy.

    Parse failures trigger a cheap LLM repair pass (inside _parse_records_with_repair).
    If the entire pipeline still fails, one full analytical retry runs before
    giving up.
    """
    try:
        findings = _phase3_deep_review(path, state, ctx, ui=ui)
        return DeepReviewResult(path=path, findings=findings)
    except (ValueError, RuntimeError) as e:
        if isinstance(e, ValueError):
            state.metrics["analytical_retries"] += 1
        _ui_info(ui, f"  retrying deep review for {path} after error: {e}")
        try:
            findings = _phase3_deep_review(path, state, ctx, ui=ui)
            return DeepReviewResult(path=path, findings=findings)
        except (ValueError, RuntimeError) as e2:
            return DeepReviewResult(path=path, error=str(e2))


def _gather_evidence(
    finding: FindingRecord, state: AuditRunState, ctx: InputContext
) -> tuple[str, int]:
    """Collect committed file contents for all locations referenced by a finding.

    Appends one merged cross-file callee section covering every evidence
    file, under a single bundle budget; files already in the bundle never
    reappear as callee blocks.
    """
    contents: dict[str, str] = {}
    paths: list[str] = []
    parts = []
    for fpath in _evidence_file_paths(finding):
        try:
            content = _cached_git_show(fpath, state, ctx.base_dir)
        except RuntimeError:
            continue
        contents[fpath] = content
        paths.append(fpath)
        parts.append(f"--- {fpath} ---\n{content}")
    text = "\n\n".join(parts) if parts else "(no evidence available)"
    if paths:
        callee_context = _gather_callee_context_for_paths(paths, contents, state, ctx)
        if callee_context != _CALLEE_NONE:
            text += f"\n\n{_CALLEE_SECTION_HEADER}\n{callee_context}"
    return text, len(parts)


_REPRODUCE_KEYWORD = "REPRODUCED"
_NO_REPRODUCE_KEYWORD = "NOTREPRODUCED"


def _finding_key(finding: FindingRecord) -> str:
    """Stable content-based key for a finding, used for verification state and worktrees."""
    blob = json.dumps(asdict(finding), sort_keys=True)
    return hashlib.sha256(blob.encode()).hexdigest()[:12]


class _TransientVerifierError(Exception):
    """Raised when a verifier worker hits a transient provider or transport error."""


def _parse_verdict_line(answer: str) -> bool | None:
    """Final verdict-line parsing for the Phase 4 verifier answer.

    Scans non-empty lines from the end; the first line that (stripped) equals
    exactly REPRODUCED or NOTREPRODUCED is the verdict. Returns True/False
    accordingly, or None when the answer never commits to a verdict on its
    own line — including the empty answer.
    """
    for line in reversed(answer.splitlines()):
        token = line.strip()
        if not token:
            continue
        if token == _REPRODUCE_KEYWORD:
            return True
        if token == _NO_REPRODUCE_KEYWORD:
            return False
    return None


def _phase4c_reproduce(
    finding: FindingRecord,
    state: AuditRunState,
    ctx: InputContext,
    work_dir: Path,
    ui: AuditUI | None = None,
) -> dict | None:
    """Run a verifier agent. Returns proof dict or None (NOTREPRODUCED).

    Infrastructure failures (worktree setup, agent loop crashes) propagate
    as exceptions so callers can distinguish them from legitimate negative
    verdicts.
    """
    from .agent import run_agent_loop

    finding_json = json.dumps(asdict(finding), indent=2)
    locs = ", ".join(finding.locations) if finding.locations else finding.source_file
    _ui_info(ui, f"    verifier [{locs}]: collecting evidence for {finding.title}")
    evidence, n_files = _gather_evidence(finding, state, ctx)
    _ui_info(ui, f"    verifier [{locs}]: gathered {n_files} evidence file(s)")

    _ui_info(ui, f"    verifier [{locs}]: preparing isolated worktree")
    with _worktree(ctx.base_dir, work_dir):
        _ui_info(
            ui,
            f"    verifier [{locs}]: running verification agent "
            f"(severity={finding.severity})",
        )
        messages = [
            {"role": "system", "content": _PHASE4_VERIFY_SYSTEM},
            {
                "role": "user",
                "content": (
                    f"Proposed finding:\n{finding_json}\n\n"
                    f"Committed evidence bundle:\n{evidence}"
                ),
            },
        ]

        kw = _make_isolated_loop_kwargs(ctx, work_dir)

        target_ui = ui if ui is not None else _get_current_ui()
        worker_slot = _get_current_worker_slot()
        if target_ui is not None and worker_slot is not None:
            from .a2a_types import EVENT_STATUS_UPDATE

            def _on_event(kind: str, data: dict) -> None:
                if kind != EVENT_STATUS_UPDATE:
                    return
                turn = data.get("turn")
                if not isinstance(turn, int):
                    return
                max_turns = data.get("max_turns")
                target_ui.worker_turn(
                    worker_slot,
                    turn,
                    max_turns if isinstance(max_turns, int) else 0,
                )

            kw["event_callback"] = _on_event

        try:
            answer, exhausted = run_agent_loop(messages, ctx.tools, **kw)
        except (ConnectionError, TimeoutError, OSError) as e:
            raise _TransientVerifierError(str(e)) from e
        finally:
            _write_audit_trace(
                ctx, messages, task=f"audit: phase 4 verify {finding.title}"
            )

        # A valid final verdict line wins even when the loop exhausted its
        # turn budget: the model answered, just slowly. No verdict line —
        # empty answer, exhausted loop, or token never emitted — is an
        # infrastructure failure, not a negative verdict.
        answer = answer or ""
        verdict = _parse_verdict_line(answer)
        if verdict is None:
            state.metrics["verifier_no_verdict"] += 1
            _ui_info(ui, f"    verifier [{locs}]: no verdict token — {finding.title}")
            raise _TransientVerifierError(
                "verifier produced no verdict token"
                + (" (turn budget exhausted)" if exhausted else "")
            )
        if verdict:
            _ui_info(ui, f"    verifier [{locs}]: REPRODUCED — {finding.title}")
            return {"reproduced": True, "summary": answer[-1000:]}

        _ui_info(ui, f"    verifier [{locs}]: NOTREPRODUCED — {finding.title}")
        return None


def _evidence_file_paths(finding: FindingRecord) -> list[str]:
    """Return deduplicated repo-relative paths referenced by a finding."""
    seen: set[str] = set()
    paths: list[str] = []
    for loc in finding.locations:
        fpath = loc.split(":")[0]
        if fpath not in seen:
            seen.add(fpath)
            paths.append(fpath)
    if finding.source_file not in seen:
        paths.append(finding.source_file)
    return paths


def _phase5_patch(
    vf: VerifiedFinding,
    ctx: InputContext,
    state: AuditRunState,
    patch_max_turns: int = _DEFAULT_PATCH_MAX_TURNS,
    ui: AuditUI | None = None,
) -> PatchGenerationResult:
    """Generate a patch by running an agent loop in a worktree, then capturing git diff."""
    from .agent import run_agent_loop

    finding_json = json.dumps(asdict(vf.finding), indent=2)
    evidence, n_files = _gather_evidence(vf.finding, state, ctx)

    work_dir = Path(ctx.base_dir) / state.state_dir / state.run_id / "patch-gen"
    try:
        with _worktree(ctx.base_dir, work_dir):
            prompt = (
                f"Fix the following security finding with the smallest correct change. "
                f"Use edit_file to make the fix. Do not make unrelated changes.\n\n"
                f"{finding_json}\n\n"
                f"Committed source for affected files:\n{evidence}"
            )
            messages = [
                {
                    "role": "system",
                    "content": "You are fixing a security bug. Make the minimal correct fix using edit_file.",
                },
                {"role": "user", "content": prompt},
            ]

            kw = _make_isolated_loop_kwargs(ctx, work_dir, max_turns=patch_max_turns)

            # Pre-seed the file tracker so the agent can edit without a read_file
            # round-trip — the committed source is already in the prompt.
            tracker = kw.get("file_tracker")
            if tracker is not None:
                evidence_paths = _evidence_file_paths(vf.finding)
                for rel in evidence_paths:
                    tracker.record_read(str(work_dir / rel))

            try:
                _answer, exhausted = run_agent_loop(messages, ctx.tools, **kw)
            except Exception as e:
                _ui_info(ui, f"    patch: agent loop failed: {e}")
                return PatchGenerationResult(
                    error_code="patch_agent_error", error=f"agent loop failed: {e}"
                )
            finally:
                _write_audit_trace(
                    ctx, messages, task=f"audit: phase 5 patch {vf.finding.title}"
                )

            if exhausted:
                _ui_info(
                    ui, "    patch: turn budget exhausted, discarding incomplete work"
                )
                return PatchGenerationResult(
                    error_code="patch_turn_budget_exhausted",
                    error="turn budget exhausted",
                )

            diff = subprocess.run(
                ["git", "diff"],
                capture_output=True,
                cwd=str(work_dir),
                timeout=10,
            )
            patch_text = diff.stdout.decode(errors="replace").strip()
            if not patch_text:
                _ui_info(ui, "    patch: no changes produced")
                return PatchGenerationResult(
                    error_code="patch_no_diff", error="no changes produced"
                )
            return PatchGenerationResult(patch_text=patch_text + "\n")
    except RuntimeError as e:
        _ui_info(ui, f"    patch: worktree failed: {e}")
        return PatchGenerationResult(
            error_code="patch_worktree_error", error=f"worktree failed: {e}"
        )


def _phase5_report(
    vf: VerifiedFinding,
    patch_filename: str,
    patch_text: str,
    state: AuditRunState,
    ctx: InputContext,
) -> str:
    """Generate the markdown report."""
    finding_json = json.dumps(asdict(vf.finding), indent=2)
    reproducer_json = json.dumps(vf.reproducer, indent=2) if vf.reproducer else "{}"
    evidence, _n = _gather_evidence(vf.finding, state, ctx)

    system = _PHASE5_REPORT_TEMPLATE.format(provenance_url=AUDIT_PROVENANCE_URL)
    suffix = (
        f"Verified finding:\n{finding_json}\n\n"
        f"Reproducer summary:\n{reproducer_json}\n\n"
        f"Affected source:\n{evidence}\n\n"
        f"Patch ({patch_filename}):\n```diff\n{patch_text}```"
    )
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": suffix},
    ]
    return _call_audit_llm(
        ctx,
        messages,
        trace_task=f"audit: phase 5 report {vf.finding.title}",
        metrics=state.metrics,
    )


def _make_isolated_loop_kwargs(
    ctx: "InputContext",
    work_dir: Path,
    max_turns: int | None = None,
) -> dict:
    """Build loop kwargs for an isolated agent loop in a worktree."""
    from .thinking import ThinkingState
    from .todo import TodoState
    from .tracker import FileAccessTracker

    kw = dict(ctx.loop_kwargs)
    kw["base_dir"] = str(work_dir)
    kw["max_turns"] = max_turns if max_turns is not None else kw.get("max_turns", 100)
    kw["thinking_state"] = ThinkingState(verbose=False)
    kw["todo_state"] = TodoState(verbose=False)
    kw["snapshot_state"] = None
    kw["file_tracker"] = FileAccessTracker()
    kw["extra_write_roots"] = []
    kw["skill_read_roots"] = []
    kw["skills_catalog"] = {}
    kw["verbose"] = False
    for k in (
        "compaction_state",
        "mcp_manager",
        "a2a_manager",
        "subagent_manager",
        "report",
        "event_callback",
        "cancel_flag",
        "turn_state",
    ):
        kw.pop(k, None)
    return kw


class _worktree:
    """Context manager for a temporary git worktree from HEAD."""

    def __init__(self, base_dir: str, work_dir: Path):
        self.base_dir = base_dir
        self.work_dir = work_dir

    def _cleanup(self) -> None:
        try:
            _git(["worktree", "prune"], self.base_dir)
        except RuntimeError:
            pass
        if self.work_dir.exists():
            try:
                _git(
                    ["worktree", "remove", "--force", str(self.work_dir)],
                    self.base_dir,
                )
            except RuntimeError:
                pass
            if self.work_dir.exists():
                shutil.rmtree(self.work_dir, ignore_errors=True)

    def __enter__(self) -> Path:
        self.work_dir.parent.mkdir(parents=True, exist_ok=True)
        self._cleanup()
        _git(
            ["worktree", "add", "--detach", str(self.work_dir), "HEAD"],
            self.base_dir,
        )
        return self.work_dir

    def __exit__(self, *exc):
        self._cleanup()
        return False


# ---------------------------------------------------------------------------
# Single-finding verification (4a + 4b + 4c)
# ---------------------------------------------------------------------------


def _verify_single_finding(
    finding: FindingRecord,
    state: AuditRunState,
    ctx: InputContext,
    work_dir: Path,
    ui: AuditUI | None = None,
) -> VerifiedFinding | None:
    """Run the PoC-based verifier on one finding. Returns None if discarded."""
    reproducer = _phase4c_reproduce(finding, state, ctx, work_dir, ui=ui)
    if reproducer is None:
        _ui_info(ui, f"  discarded (no reproduction): {finding.title}")
        return None

    return VerifiedFinding(
        finding=finding,
        correctness_reason="verified by proof-of-concept reproduction",
        rebuttal_reason="not used; PoC verifier is authoritative",
        reproducer=reproducer,
    )


def _verify_one_finding(
    item: tuple[str, FindingRecord],
    state: AuditRunState,
    ctx: "InputContext",
    ui: AuditUI | None = None,
) -> VerificationResult:
    """Verify a single finding with retry on transient errors. Never raises."""
    finding_key, finding = item
    work_dir = (
        Path(ctx.base_dir)
        / state.state_dir
        / state.run_id
        / "verify"
        / finding_key
        / "work"
    )
    attempts = 0
    try:
        attempts += 1
        verified = _verify_single_finding(finding, state, ctx, work_dir, ui=ui)
    except _TransientVerifierError as e:
        _ui_info(
            ui,
            f"  [{finding_key}] retrying {finding.title} after transient error: {e}",
        )
        try:
            attempts += 1
            verified = _verify_single_finding(finding, state, ctx, work_dir, ui=ui)
        except Exception as e2:
            return VerificationResult(
                finding_key=finding_key, error=str(e2), attempts=attempts
            )
    except Exception as e:
        return VerificationResult(
            finding_key=finding_key, error=str(e), attempts=attempts
        )

    if verified is None:
        return VerificationResult(
            finding_key=finding_key, discarded=True, attempts=attempts
        )
    return VerificationResult(
        finding_key=finding_key, verified_finding=verified, attempts=attempts
    )


# ---------------------------------------------------------------------------
# Phase 4.5: adjudication gate
# ---------------------------------------------------------------------------


@dataclass
class AdjudicationResult:
    index: int
    kept: bool
    decision: str  # "keep" | "keep_with_changes" | "drop" | "error"
    finding: FindingRecord
    original_severity: str
    final_severity: str
    reason: str
    error: str | None = None


def _deployment_surface(state: AuditRunState) -> str:
    """Compact deployment-surface description for adjudication prompts."""
    profile = state.repo_profile or {}

    def _vals(key: str) -> list[str]:
        v = profile.get(key) or []
        if isinstance(v, str):
            v = [v]
        return [str(x) for x in v if x]

    lines: list[str] = []
    summary = profile.get("summary")
    if summary:
        lines.append(f"summary: {summary}")
    for key, label in (
        ("languages", "languages"),
        ("frameworks", "frameworks"),
        ("entry_points", "entry points"),
        ("trust_boundaries", "trust boundaries"),
        ("auth_surfaces", "auth surfaces"),
    ):
        vals = _vals(key)
        if vals:
            lines.append(f"{label}: {', '.join(vals)}")
    if not lines:
        return "(no repository profile available)"
    return "\n".join(f"- {ln}" for ln in lines)


def _finding_brief(finding: FindingRecord) -> str:
    """Compact human-readable view of a finding for adjudication prompts."""
    locs = ", ".join(finding.locations) if finding.locations else finding.source_file
    preconds = "; ".join(finding.preconditions) if finding.preconditions else "(none)"
    proof = " ".join(finding.proof) if finding.proof else "(none)"
    return (
        f"title: {finding.title}\n"
        f"type: {finding.finding_type}\n"
        f"severity: {finding.severity}\n"
        f"locations: {locs}\n"
        f"preconditions: {preconds}\n"
        f"proof: {proof}\n"
        f"proposed fix: {finding.fix_outline}"
    )


def _below_high(severity: str) -> bool:
    """True when ``severity`` is below the security_control_failure floor."""
    return _SEVERITY_RANK.get((severity or "").lower(), 99) > _SEVERITY_RANK["high"]


def _less_severe_of(a: str, b: str) -> str:
    """Return whichever of ``a`` / ``b`` is the less severe."""
    ra = _SEVERITY_RANK.get((a or "").lower(), len(_SEVERITY_RANK))
    rb = _SEVERITY_RANK.get((b or "").lower(), len(_SEVERITY_RANK))
    return a if ra >= rb else b


def _demote_only(original: str, proposed: str) -> str:
    """Return ``proposed`` only when it is no more severe than ``original``.

    Severity may be lowered or kept, never raised: the gate exists to correct
    overstated findings, not to inflate them.
    """
    o = _SEVERITY_RANK.get((original or "").lower(), len(_SEVERITY_RANK))
    p = _SEVERITY_RANK.get((proposed or "").lower(), len(_SEVERITY_RANK))
    return proposed if p >= o else original


def _consensus_severity(severities: list[str]) -> str | None:
    """Most common severity, tie-broken toward the lower severity."""
    counts: dict[str, int] = {}
    for s in severities:
        s = (s or "").lower()
        if s in _SEVERITIES:
            counts[s] = counts.get(s, 0) + 1
    if not counts:
        return None
    return max(counts, key=lambda s: (counts[s], _SEVERITY_RANK.get(s, 99)))


def _panel_drop_reason(verdicts: list[dict]) -> str:
    rejecting = [
        v
        for v in verdicts
        if v.get("verdict") != "real" or v.get("threat_model_relevant") != "yes"
    ]
    reasons = [v.get("reason", "") for v in rejecting if v.get("reason")]
    if reasons:
        return "; ".join(reasons[:2])[:300]
    return "panel majority judged this not a realistic vulnerability"


def _phase45_review_one(
    finding: FindingRecord,
    lens: str,
    surface: str,
    evidence: str,
    reproducer_summary: str,
    state: AuditRunState,
    ctx: InputContext,
) -> dict | None:
    """Run one adversarial reviewer (one lens). Returns a verdict dict or None."""
    user = (
        f"Deployment surface:\n{surface}\n\n"
        f"Finding under review:\n{_finding_brief(finding)}\n\n"
        f"Phase 4 reproduction summary:\n{reproducer_summary or '(none)'}\n\n"
        f"Committed evidence bundle:\n{evidence}"
    )
    messages = [
        {"role": "system", "content": _PHASE45_REVIEW_SYSTEM.format(lens=lens)},
        {"role": "user", "content": user},
    ]
    raw = _call_audit_llm(
        ctx,
        messages,
        trace_task=f"audit: phase 4.5 review {finding.title}",
        metrics=state.metrics,
    )
    try:
        records = _parse_records_with_repair(
            ctx,
            raw,
            schema=_PHASE45_VERDICT_SCHEMA,
            worked_example=_PHASE45_WORKED_EXAMPLE,
            metrics=state.metrics,
        )
    except ValueError:
        return None
    return records[0] if records else None


def _phase45_consolidate(
    finding: FindingRecord,
    verdicts: list[dict],
    surface: str,
    evidence: str,
    state: AuditRunState,
    ctx: InputContext,
) -> dict | None:
    """Produce the corrected finding fields from the panel verdicts."""
    panel_text = "\n".join(
        f"- verdict={v.get('verdict')} relevant={v.get('threat_model_relevant')} "
        f"severity={v.get('severity')} reason={v.get('reason')}"
        for v in verdicts
    )
    user = (
        f"Deployment surface:\n{surface}\n\n"
        f"Proposed finding:\n{_finding_brief(finding)}\n\n"
        f"Refutation panel verdicts:\n{panel_text}\n\n"
        f"Committed evidence bundle:\n{evidence}"
    )
    messages = [
        {"role": "system", "content": _PHASE45_CONSOLIDATE_SYSTEM},
        {"role": "user", "content": user},
    ]
    raw = _call_audit_llm(
        ctx,
        messages,
        trace_task=f"audit: phase 4.5 consolidate {finding.title}",
        metrics=state.metrics,
    )
    try:
        records = _parse_records_with_repair(
            ctx,
            raw,
            schema=_PHASE45_RULING_SCHEMA,
            worked_example=_PHASE45_RULING_WORKED_EXAMPLE,
            metrics=state.metrics,
        )
    except ValueError:
        return None
    return records[0] if records else None


def _adjudicate_one(
    item: tuple[int, VerifiedFinding],
    state: AuditRunState,
    ctx: InputContext,
    ui: AuditUI | None = None,
) -> AdjudicationResult:
    """Refute, recalibrate, and rewrite one verified finding."""
    index, vf = item
    finding = vf.finding
    original_severity = finding.severity
    surface = _deployment_surface(state)
    reproducer_summary = str(vf.reproducer.get("summary", "")) if vf.reproducer else ""

    try:
        evidence, _n = _gather_evidence(finding, state, ctx)
    except Exception as e:
        return AdjudicationResult(
            index=index,
            kept=True,
            decision="error",
            finding=finding,
            original_severity=original_severity,
            final_severity=original_severity,
            reason="adjudication skipped: evidence gather failed",
            error=str(e),
        )

    verdicts: list[dict] = []
    for lens in _PHASE45_LENSES:
        v = None
        for attempt in range(2):
            if attempt:
                state.metrics["phase45_lens_retries"] += 1
            try:
                v = _phase45_review_one(
                    finding, lens, surface, evidence, reproducer_summary, state, ctx
                )
            except Exception as e:
                _ui_info(ui, f"    adjudicate [{finding.title}]: reviewer error: {e}")
                v = None
            if v is not None:
                break
        if v is not None:
            verdicts.append(v)

    if len(verdicts) < 2:
        # 0 or 1 usable verdicts: a panel that mostly failed to respond has
        # not earned the right to overrule a reproduced finding.
        return AdjudicationResult(
            index=index,
            kept=True,
            decision="keep",
            finding=finding,
            original_severity=original_severity,
            final_severity=original_severity,
            reason=(
                f"adjudication inconclusive: only {len(verdicts)} usable "
                f"panel verdict(s)"
            ),
        )

    confirming = [
        v
        for v in verdicts
        if v.get("verdict") == "real" and v.get("threat_model_relevant") == "yes"
    ]
    # Strict majority of usable verdicts must confirm. Ties drop (refute by default).
    if len(confirming) * 2 <= len(verdicts):
        return AdjudicationResult(
            index=index,
            kept=False,
            decision="drop",
            finding=finding,
            original_severity=original_severity,
            final_severity=original_severity,
            reason=_panel_drop_reason(verdicts),
        )

    panel_sev = _consensus_severity([v.get("severity", "") for v in confirming])

    ruling = None
    try:
        ruling = _phase45_consolidate(finding, verdicts, surface, evidence, state, ctx)
    except Exception as e:
        _ui_info(ui, f"    adjudicate [{finding.title}]: consolidate error: {e}")

    updated = replace(finding)
    if ruling is not None:
        # Consolidation may not exceed what the skeptical panel agreed on: cap
        # the ruling severity by the panel consensus so a confident rewrite
        # cannot re-inflate a finding the panel already talked down.
        ruling_sev = ruling.get("severity") or original_severity
        proposed_sev = (
            _less_severe_of(ruling_sev, panel_sev) if panel_sev else ruling_sev
        )
        updated.title = ruling.get("title") or finding.title
        updated.finding_type = ruling.get("type") or finding.finding_type
        updated.fix_outline = ruling.get("fix_outline") or finding.fix_outline
        preconds = ruling.get("preconditions")
        if preconds:
            updated.preconditions = preconds
        tm = ruling.get("threat_model")
        if tm:
            updated.threat_model = tm
    else:
        proposed_sev = panel_sev or original_severity

    final_sev = _demote_only(original_severity, proposed_sev)

    # security_control_failure is a high/critical-only carve-out. Reconcile it
    # after demote-only so the floor never silently re-raises a demoted finding.
    scf = _PHASE3B_SECURITY_CONTROL_FAILURE_TYPE
    if finding.finding_type == scf:
        # A finding that was a genuine SCF keeps the high floor however it is
        # relabeled: consolidation may not launder a broken security control
        # into a low-severity finding by rewriting its type.
        if _below_high(final_sev):
            final_sev = "high"
    elif updated.finding_type == scf and _below_high(final_sev):
        # An SCF relabel applied to a below-high finding is not a valid SCF:
        # drop the relabel rather than leave an invalid sub-high SCF.
        updated.finding_type = finding.finding_type
    updated.severity = final_sev

    changed = (
        updated.title != finding.title
        or updated.finding_type != finding.finding_type
        or updated.severity != finding.severity
        or updated.fix_outline != finding.fix_outline
        or updated.preconditions != finding.preconditions
        or updated.threat_model != finding.threat_model
    )
    return AdjudicationResult(
        index=index,
        kept=True,
        decision="keep_with_changes" if changed else "keep",
        finding=updated,
        original_severity=original_severity,
        final_severity=final_sev,
        reason="; ".join(v.get("reason", "") for v in confirming if v.get("reason"))[
            :300
        ],
    )


def _phase45_adjudicate(
    state: AuditRunState,
    ctx: InputContext,
    ui: AuditUI,
    workers: int,
) -> None:
    """Drive the Phase 4.5 gate over all verified findings, mutating state."""
    findings = state.verified_findings
    items = list(enumerate(findings))
    # Rebuild the outcome tallies from state so the per-drop delta adjustments
    # below stay correct on --resume, where Phase 4 did not run in this process.
    verify_discarded = sum(
        1 for vs in state.verification_state.values() if vs.get("status") == "discarded"
    )
    ui.set_outcome_baseline(
        verified_severities=[vf.finding.severity for vf in findings],
        discarded=verify_discarded,
    )
    ph = _phase_open(ui, "adjudication", total=len(items))
    if not ui.is_live:
        fmt.info(f"phase 4.5: adjudicating {len(items)} verified findings...")

    def _fn(item):
        return _adjudicate_one(item, state, ctx, ui=ui)

    def _label(item):
        return item[1].finding.title

    results = _run_batch(
        _fn, items, max_workers=max(1, workers), ui=ui, label_for=_label
    )

    kept: list[VerifiedFinding] = []
    dropped = 0
    recalibrated = 0
    for i, result in enumerate(results):
        vf = findings[i]
        if result is None:
            kept.append(vf)
            ph.advance()
            continue
        if not result.kept:
            dropped += 1
            state.adjudication_discarded.append(
                {
                    "title": vf.finding.title,
                    "severity": result.original_severity,
                    "source_file": vf.finding.source_file,
                    "reason": result.reason,
                }
            )
            ui.tally(verified=-1, severity=result.original_severity)
            ui.tally(discarded=1)
            _ui_info(
                ui,
                f"  dropped (adjudication): {vf.finding.title} — {result.reason}",
            )
            ph.advance()
            continue
        kept.append(replace(vf, finding=result.finding))
        if result.final_severity != result.original_severity:
            recalibrated += 1
            ui.tally(verified=-1, severity=result.original_severity)
            ui.tally(verified=1, severity=result.final_severity)
            _ui_info(
                ui,
                f"  severity {result.original_severity} -> "
                f"{result.final_severity}: {result.finding.title}",
            )
        ph.advance()

    state.verified_findings = kept
    ph.complete(f"{len(kept)} kept · {dropped} dropped · {recalibrated} recalibrated")
    if not ui.is_live:
        fmt.info(
            f"phase 4.5 complete. {len(kept)} kept, {dropped} dropped, "
            f"{recalibrated} recalibrated."
        )


# ---------------------------------------------------------------------------
# Parallel batch helper
# ---------------------------------------------------------------------------


def _run_batch(
    fn,
    items,
    max_workers: int = 4,
    *,
    ui=None,
    label_for=None,
    on_result=None,
):
    """Run fn(item) in parallel, return results preserving order.

    When ``ui`` is provided, each task acquires a worker slot from a
    bounded queue, publishes a ``worker_started`` event with
    ``label_for(item)``, runs ``fn(item)``, then publishes
    ``worker_ended`` and returns the slot. Batch-level failures are
    routed through ``ui.warning`` instead of the global ``fmt.warning``.

    ``on_result(idx, item, result)`` is invoked on the calling thread as
    each item completes (in completion order, not submission order), while
    other workers keep running. Failed items are reported with ``None``.
    """
    results = [None] * len(items)
    slot_queue: queue.Queue[int] | None = None
    if ui is not None:
        slot_queue = queue.Queue()
        for slot in range(1, max(1, max_workers) + 1):
            slot_queue.put(slot)
    if label_for is None:
        label_for = str

    def _wrapped(item):
        prev_ui = _get_current_ui()
        if slot_queue is None:
            _set_current_ui(ui)
            try:
                return fn(item)
            finally:
                _set_current_ui(prev_ui)
        slot = slot_queue.get()
        _set_current_ui(ui)
        prev_slot = _get_current_worker_slot()
        _set_current_worker_slot(slot)
        try:
            try:
                label = label_for(item)
            except Exception:
                label = str(item)
            ui.worker_started(slot, label)
            try:
                return fn(item)
            finally:
                ui.worker_ended(slot)
        finally:
            _set_current_worker_slot(prev_slot)
            _set_current_ui(prev_ui)
            slot_queue.put(slot)

    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {pool.submit(_wrapped, item): i for i, item in enumerate(items)}
        for future in as_completed(futures):
            idx = futures[future]
            try:
                results[idx] = future.result()
            except Exception as e:
                _debug_log("batch_error", idx=idx, error=str(e))
                msg = f"batch item {idx} failed: {e}"
                if ui is not None:
                    ui.warning(msg)
                else:
                    fmt.warning(msg)
                results[idx] = None
            if on_result is not None:
                on_result(idx, items[idx], results[idx])
    return results


# ---------------------------------------------------------------------------
# Slug generation
# ---------------------------------------------------------------------------


def _make_slug(title: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
    return slug[:60] if slug else "finding"


def _artifact_key(vf: VerifiedFinding) -> str:
    return _finding_key(vf.finding)


def _artifact_filenames(index: int, finding: FindingRecord) -> tuple[str, str]:
    slug = _make_slug(finding.title)
    return f"{index:03d}-{slug}.patch", f"{index:03d}-{slug}.md"


def _ensure_artifact_state(state: AuditRunState) -> None:
    """Ensure each verified finding has a stable artifact entry."""
    current: dict[str, VerifiedFinding] = {
        _artifact_key(vf): vf for vf in state.verified_findings
    }
    for key in list(state.artifact_state):
        if key not in current:
            del state.artifact_state[key]

    for key, vf in current.items():
        if key in state.artifact_state:
            continue
        next_idx = (
            max(
                (int(entry["index"]) for entry in state.artifact_state.values()),
                default=0,
            )
            + 1
        )
        patch_filename, report_filename = _artifact_filenames(next_idx, vf.finding)
        state.artifact_state[key] = {
            "status": "pending",
            "index": next_idx,
            "patch_filename": patch_filename,
            "report_filename": report_filename,
            "attempts": 0,
            "last_error_code": None,
            "last_error": None,
            "last_patch_max_turns": None,
        }


def _parse_finding_selector(raw: str, total: int) -> set[int]:
    """Parse a 1-based finding selector into zero-based indexes."""
    selected: set[int] = set()
    if raw.strip() == "":
        raise ValueError("--finding requires a non-empty selector")
    for part in raw.split(","):
        part = part.strip()
        if not part:
            raise ValueError("--finding contains an empty selector")
        if "-" in part:
            pieces = part.split("-", 1)
            if not pieces[0] or not pieces[1]:
                raise ValueError(f"invalid --finding range {part!r}")
            try:
                start = int(pieces[0])
                end = int(pieces[1])
            except ValueError as e:
                raise ValueError(f"invalid --finding range {part!r}") from e
            if start > end:
                raise ValueError(f"invalid --finding range {part!r}")
            nums = range(start, end + 1)
        else:
            try:
                nums = (int(part),)
            except ValueError as e:
                raise ValueError(f"invalid --finding selector {part!r}") from e
        for n in nums:
            if n < 1 or n > total:
                raise ValueError(
                    f"--finding index {n} out of range; valid range is 1..{total}"
                )
            selected.add(n - 1)
    if not selected:
        raise ValueError("--finding did not select any findings")
    return selected


def _reset_artifact_targets_for_regen(
    state: AuditRunState, selected_indexes: set[int] | None
) -> None:
    _ensure_artifact_state(state)
    selected_keys = {
        _artifact_key(vf)
        for i, vf in enumerate(state.verified_findings)
        if selected_indexes is None or i in selected_indexes
    }
    for key in selected_keys:
        entry = state.artifact_state[key]
        entry["status"] = "pending"
        entry["last_error_code"] = None
        entry["last_error"] = None


def _artifact_summary(state: AuditRunState) -> tuple[int, int, int]:
    entries = [
        state.artifact_state[_artifact_key(vf)] for vf in state.verified_findings
    ]
    failed = sum(1 for e in entries if e.get("status") == "failed")
    pending = sum(1 for e in entries if e.get("status") == "pending")
    written = sum(1 for e in entries if e.get("status") == "written")
    return failed, pending, written


_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}


def _audit_totals(state: AuditRunState) -> dict[str, int]:
    """Single source of truth for the README's totals block.

    Pulled directly from the live state objects rather than ``state.metrics``,
    which is a parser/repair counter and would silently drift.
    """
    failed, pending, written = _artifact_summary(state)
    discarded = sum(
        1 for vs in state.verification_state.values() if vs.get("status") == "discarded"
    )
    return {
        "files_triaged": len(state.triage_records),
        "files_escalated": len(state.candidate_files),
        "files_deep_reviewed": len(state.deep_reviewed_files),
        "findings_proposed": len(state.proposed_findings),
        "findings_verified": len(state.verified_findings),
        "findings_discarded": discarded,
        "findings_dropped_adjudication": len(state.adjudication_discarded),
        "artifacts_written": written,
        "artifacts_failed": failed,
        "artifacts_pending": pending,
    }


def _escape_cell(text: str) -> str:
    """Make ``text`` safe for a single GFM table cell."""
    if text is None:
        return ""
    return text.replace("|", "\\|").replace("\n", " ").replace("\r", " ")


def _severity_sort_key(severity: str) -> tuple[int, str]:
    sev = (severity or "").lower()
    return (_SEVERITY_RANK.get(sev, len(_SEVERITY_RANK)), sev)


_SEVERITY_LABEL = {
    "critical": "Critical",
    "high": "High",
    "medium": "Medium",
    "low": "Low",
    "info": "Info",
}


def _severity_label(severity: str) -> str:
    sev = (severity or "").lower()
    return _SEVERITY_LABEL.get(sev, (severity or "Unknown").capitalize())


_FINDING_TYPE_ACRONYMS = {
    "dos": "DoS",
    "ssrf": "SSRF",
    "rce": "RCE",
    "xss": "XSS",
    "csrf": "CSRF",
    "sql": "SQL",
    "tls": "TLS",
    "api": "API",
    "uaf": "use-after-free",
}


def _humanize_finding_type(finding_type: str) -> str:
    """Turn a ``finding_type`` token into a sentence-case section header."""
    cleaned = (finding_type or "").replace("_", " ").replace("-", " ").strip()
    if not cleaned:
        return "Uncategorized"
    words = [_FINDING_TYPE_ACRONYMS.get(w, w) for w in cleaned.split()]
    header = " ".join(words)
    return header[:1].upper() + header[1:]


def _render_findings_readme(state: AuditRunState, *, repo_name: str = "audit") -> str:
    """Render the audit-findings README as deterministic Markdown.

    Pure function of ``state`` and ``repo_name``: no clock, no env, no
    filesystem reads. Every link is relative inside the artifact directory.
    ``repo_name`` is the human-readable label for the repo (typically the
    basename of the working tree) and is the only piece of identity that
    cannot be derived from ``state`` alone.
    """
    short_commit = (state.scope.commit or "")[:12] or "unknown"
    title_hook = repo_name or "audit"

    indexed: list[tuple[int, VerifiedFinding, dict]] = []
    for vf in state.verified_findings:
        entry = state.artifact_state[_artifact_key(vf)]
        indexed.append((int(entry["index"]), vf, entry))
    indexed.sort(key=lambda t: t[0])

    lines: list[str] = []
    lines.append(f"# {title_hook} Audit Findings")
    lines.append("")
    lines.append(
        f"Security audit of `{repo_name}` at commit `{short_commit}`. "
        "Each finding links to a detailed write-up (`NNN-*.md`) and a "
        "proposed patch (`NNN-*.patch`)."
    )
    lines.append("")

    lines.append("## Summary")
    lines.append("")
    sev_counts: dict[str, int] = {}
    for _, vf, _entry in indexed:
        sev = (vf.finding.severity or "").lower()
        sev_counts[sev] = sev_counts.get(sev, 0) + 1
    ordered = sorted(sev_counts.items(), key=lambda kv: _severity_sort_key(kv[0]))
    breakdown = ", ".join(f"{_severity_label(sev)}: {n}" for sev, n in ordered)
    total = len(indexed)
    if breakdown:
        lines.append(f"**Total findings: {total}** ({breakdown})")
    else:
        lines.append(f"**Total findings: {total}**")
    lines.append("")

    lines.append("## Findings")
    lines.append("")
    groups: dict[str, list[tuple[int, VerifiedFinding, dict]]] = {}
    for idx, vf, entry in indexed:
        ftype = (vf.finding.finding_type or "").strip().lower() or "uncategorized"
        groups.setdefault(ftype, []).append((idx, vf, entry))

    def _group_sort_key(ftype: str) -> tuple[int, str]:
        best = min(
            _SEVERITY_RANK.get((vf.finding.severity or "").lower(), len(_SEVERITY_RANK))
            for _, vf, _e in groups[ftype]
        )
        return (best, ftype)

    for ftype in sorted(groups, key=_group_sort_key):
        rows = sorted(groups[ftype], key=lambda t: t[0])
        lines.append(f"### {_humanize_finding_type(ftype)} ({len(rows)})")
        lines.append("")
        lines.append("| # | Finding | Severity | File | Patch |")
        lines.append("|---|---|---|---|---|")
        for idx, vf, entry in rows:
            status = str(entry.get("status", "pending"))
            report_filename = str(entry.get("report_filename", ""))
            patch_filename = str(entry.get("patch_filename", ""))
            if status == "written":
                num_cell = f"[{idx:03d}]({report_filename})"
                patch_cell = f"[patch]({patch_filename})"
            else:
                num_cell = f"{idx:03d}"
                patch_cell = _escape_cell(patch_filename)
            lines.append(
                "| "
                + " | ".join(
                    [
                        num_cell,
                        _escape_cell(vf.finding.title),
                        _severity_label(vf.finding.severity),
                        f"`{_escape_cell(vf.finding.source_file)}`",
                        patch_cell,
                    ]
                )
                + " |"
            )
        lines.append("")

    if state.adjudication_discarded:
        lines.append("## Dropped in adjudication")
        lines.append("")
        lines.append(
            "These findings reproduced in Phase 4 but the Phase 4.5 gate judged "
            "them false positives or out of a realistic threat model."
        )
        lines.append("")
        for entry in state.adjudication_discarded:
            title = _escape_cell(entry.get("title", "untitled"))
            sev = _escape_cell(entry.get("severity", ""))
            src = _escape_cell(entry.get("source_file", ""))
            reason = _escape_cell(entry.get("reason", ""))
            lines.append(f"- **{title}** ({sev}, {src}): {reason}")
        lines.append("")

    failed_entries = [
        (idx, vf, entry)
        for idx, vf, entry in indexed
        if entry.get("status") in ("failed", "pending")
    ]
    if failed_entries:
        lines.append("## Failed or pending artifacts")
        lines.append("")
        for idx, vf, entry in failed_entries:
            status = str(entry.get("status", "pending"))
            err_code = entry.get("last_error_code") or "n/a"
            err_msg = entry.get("last_error") or "n/a"
            title = _escape_cell(vf.finding.title)
            lines.append(f"- **{idx:03d}** ({status}): {title}")
            lines.append(f"  - error code: `{err_code}`")
            lines.append(f"  - error: {err_msg}")
            lines.append(
                f"  - retry: `/audit --regen --finding {idx} --patch-max-turns 75`"
            )
        lines.append("")
        lines.append(
            "Run `/audit --resume --patch-max-turns 75` to retry all incomplete "
            "artifacts at once."
        )
        lines.append("")

    lines.append("## Run details")
    lines.append("")
    lines.append(f"- run id: `{state.run_id}`")
    lines.append(f"- commit: `{state.scope.commit}`")
    lines.append(f"- branch: `{state.scope.branch}`")
    scope_files = len(state.scope.mandatory_files)
    if state.scope.focus:
        focus_str = ", ".join(f"`{f}`" for f in state.scope.focus)
        lines.append(f"- scope: {scope_files} file(s), focus: {focus_str}")
    else:
        lines.append(f"- scope: {scope_files} file(s)")
    totals = _audit_totals(state)
    lines.append(f"- files triaged: {totals['files_triaged']}")
    lines.append(f"- files escalated: {totals['files_escalated']}")
    lines.append(f"- files deep-reviewed: {totals['files_deep_reviewed']}")
    if state.truncated_files:
        truncated = ", ".join(f"`{p}`" for p in sorted(state.truncated_files))
        lines.append(
            f"- {len(state.truncated_files)} file(s) deep-reviewed with "
            f"truncated Phase 3 evidence (context window too small): {truncated}"
        )
    lines.append(f"- findings proposed: {totals['findings_proposed']}")
    lines.append(f"- findings verified: {totals['findings_verified']}")
    lines.append(f"- findings discarded: {totals['findings_discarded']}")
    lines.append(
        f"- findings dropped in adjudication: {totals['findings_dropped_adjudication']}"
    )
    lines.append(
        "- artifacts: "
        f"{totals['artifacts_written']} written, "
        f"{totals['artifacts_failed']} failed, "
        f"{totals['artifacts_pending']} pending"
    )
    lines.append("")

    lines.append("## How to read this directory")
    lines.append("")
    lines.append(
        "Each finding has a paired `NNN-<slug>.md` (the human report) and "
        "`NNN-<slug>.patch` (the proposed fix as a unified diff). The report "
        "carries the narrative; the patch is the suggested change. Regenerate "
        "a single finding's artifacts with "
        "`/audit --regen --finding N`."
    )
    lines.append("")

    return "\n".join(lines)


def _write_findings_readme(state: AuditRunState, base_dir: str) -> bool:
    """Write ``audit-findings/README.md`` if there is something to index.

    Returns ``True`` when a README was written, ``False`` when the gate
    rejected the run (no written artifact and nothing the adjudication gate
    dropped worth recording).

    When the gate rejects but a previous README is still on disk, the stale
    file is removed. Otherwise a failed ``--regen`` that flipped every
    written entry back to ``pending`` would leave the old README claiming
    those artifacts are still current.
    """
    artifact_dir = Path(base_dir) / state.artifact_dir
    target = artifact_dir / "README.md"
    repo_name = Path(base_dir).resolve().name or "audit"

    has_written = any(
        entry.get("status") == "written" for entry in state.artifact_state.values()
    )
    # An audit that drops every finding in adjudication still owes the reader a
    # README explaining what was thrown out and why.
    has_drops = bool(state.adjudication_discarded)
    if (not state.verified_findings or not has_written) and not has_drops:
        if target.exists():
            target.unlink()
        return False
    artifact_dir.mkdir(parents=True, exist_ok=True)
    text = _render_findings_readme(state, repo_name=repo_name)
    tmp = target.with_suffix(".tmp")
    tmp.write_text(text, encoding="utf-8")
    tmp.replace(target)
    return True


def _mark_artifact_failed(
    entry: dict,
    state: AuditRunState,
    fi: int,
    total: int,
    error_code: str,
    error_msg: str,
) -> None:
    entry["status"] = "failed"
    entry["last_error_code"] = error_code
    entry["last_error"] = error_msg
    state.save()
    _ui_info(None, f"  [{fi}/{total}] failed ({error_msg})")


# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------


def _load_audit_config(
    base_dir: str,
) -> tuple[list[str], dict[str, str], int | None]:
    """Load merged ``[audit]`` settings from global+project config.

    Returns ``(force_review_globs, sources, patch_max_turns)``. Errors loading
    config are non-fatal here: the rest of swival has already validated the file
    at startup, so failures are reported as warnings and treated as defaults.
    """
    try:
        from .config import (
            _load_single,
            global_config_dir,
            merge_audit_force_review,
            merge_audit_patch_max_turns,
        )

        global_path = global_config_dir() / "config.toml"
        project_path = Path(base_dir).resolve() / "swival.toml"
        global_audit = _load_single(global_path, str(global_path)).get("audit")
        project_audit = _load_single(project_path, str(project_path)).get("audit")
    except Exception as e:
        fmt.warning(f"audit: ignoring swival.toml audit config (load failed: {e})")
        return [], {}, None
    globs, sources = merge_audit_force_review(global_audit, project_audit)
    patch_max_turns = merge_audit_patch_max_turns(global_audit, project_audit)
    return globs, sources, patch_max_turns


def _resolve_force_review(
    globs: list[str],
    sources: dict[str, str],
    mandatory_files: list[str],
) -> tuple[dict[str, str], list[str]]:
    """Match force_review globs against the mandatory file list.

    Returns ``(matches, warnings)`` where ``matches[path]`` is the
    *strongest* origin tag of any glob that matched (project beats
    global on ties). ``warnings`` contains text for project-origin
    globs that matched zero files.
    """
    matches: dict[str, str] = {}
    warnings: list[str] = []
    for g in globs:
        source = sources.get(g, "project")
        matched = [f for f in mandatory_files if _match_path_glob(f, g)]
        for f in matched:
            if matches.get(f) != "project":
                matches[f] = source
        if not matched and source == "project":
            warnings.append(
                f"audit: swival.toml force_review glob {g!r} matched zero files"
            )
    return matches, warnings


def _exact_focus_paths(scope: AuditScope) -> list[str]:
    """Focus tokens that name an exact mandatory file.

    No wildcard, exact string match against the mandatory set, never a
    directory prefix (directory tokens express "look here", not "this file
    matters"). A user who names a specific file on the /audit command line
    has escalated it more strongly than any triage heuristic.
    """
    mandatory = set(scope.mandatory_files)
    return [f for f in scope.focus if not any(c in f for c in "*?[") and f in mandatory]


def run_audit_command(cmd_arg: str, ctx: InputContext) -> str:
    """Entry point for the /audit command. Returns summary text."""
    global _debug_log_path

    base_dir = ctx.base_dir
    workers = 4

    arg = cmd_arg.strip()
    resume = False
    regen = False
    debug = False
    select_all = False
    measure_triage = False
    patch_max_turns_cli: int | None = None
    finding_selector: str | None = None
    focus: list[str] | None = None

    parts = arg.split()
    filtered = []
    i = 0
    while i < len(parts):
        if parts[i] == "--resume":
            resume = True
        elif parts[i] == "--regen":
            regen = True
        elif parts[i] == "--debug":
            debug = True
        elif parts[i] == "--all":
            select_all = True
        elif parts[i] == "--measure-triage":
            measure_triage = True
        elif parts[i] == "--workers":
            if i + 1 >= len(parts):
                return "error: --workers requires an integer"
            i += 1
            try:
                workers = int(parts[i])
            except ValueError:
                return f"error: --workers requires an integer, got {parts[i]!r}"
        elif parts[i] == "--patch-max-turns":
            if i + 1 >= len(parts):
                return "error: --patch-max-turns requires an integer"
            i += 1
            try:
                patch_max_turns_cli = int(parts[i])
            except ValueError:
                return f"error: --patch-max-turns requires an integer, got {parts[i]!r}"
            if patch_max_turns_cli < 1:
                return "error: --patch-max-turns must be at least 1"
        elif parts[i] == "--finding":
            if finding_selector is not None:
                return "error: --finding may only be provided once; use --finding 2,5"
            if i + 1 >= len(parts) or parts[i + 1].startswith("-"):
                return "error: --finding requires a selector"
            i += 1
            finding_selector = parts[i]
        elif parts[i].startswith("-"):
            return (
                f"error: unknown option {parts[i]!r}. "
                f"Known flags: --resume, --regen, --debug, --all, "
                f"--measure-triage, --workers N, --patch-max-turns N, "
                f"--finding N."
            )
        else:
            filtered.append(parts[i])
        i += 1
    if filtered:
        focus = _normalize_focus(filtered)

    if finding_selector is not None and not regen:
        return "error: --finding requires --regen"

    force_review, force_review_sources, config_patch_max_turns = _load_audit_config(
        base_dir
    )
    patch_max_turns = (
        patch_max_turns_cli
        if patch_max_turns_cli is not None
        else config_patch_max_turns or _DEFAULT_PATCH_MAX_TURNS
    )

    if debug:
        log_dir = Path(base_dir) / ".swival" / "audit"
        log_dir.mkdir(parents=True, exist_ok=True)
        _debug_log_path = log_dir / "debug.jsonl"
        _debug_log("audit_start", args=cmd_arg.strip())
        fmt.info(f"debug log: {_debug_log_path}")
    else:
        _debug_log_path = None

    state_dir = Path(base_dir) / ".swival" / "audit"

    try:
        return _run_audit_phases(
            cmd_arg,
            ctx,
            base_dir,
            state_dir,
            workers,
            resume,
            regen,
            focus,
            select_all,
            force_review=force_review,
            force_review_sources=force_review_sources,
            measure_triage=measure_triage,
            patch_max_turns=patch_max_turns,
            finding_selector=finding_selector,
        )
    finally:
        _debug_log_path = None


_PHASE_TITLES: dict[str, tuple[str, str]] = {
    "inventory": ("Phase 1 · Inventory", "inventory"),
    "triage": ("Phase 2 · Triage", "triage"),
    "deep_review": ("Phase 3 · Deep Review", "deep_review"),
    "verification": ("Phase 4 · Verification", "verification"),
    "adjudication": ("Phase 4.5 · Adjudication", "verification"),
    "artifacts": ("Phase 5 · Artifacts", "artifacts"),
}


def _phase_open(
    ui: AuditUI, phase_key: str, *, total: int | None = None, label: str | None = None
) -> PhaseHandle:
    title, color_key = _PHASE_TITLES[phase_key]
    if label is not None:
        title = f"{title} · {label}"
    elif total is not None:
        title = f"{title} · {total} item{'s' if total != 1 else ''}"
    return ui.phase(title, total=total, color=fmt.phase_color(color_key))


def _run_pipeline(
    state: AuditRunState,
    ui: AuditUI,
    ctx: InputContext,
    base_dir: str,
    workers: int,
    *,
    resume: bool,
    force_review: list[str],
    force_review_sources: dict[str, str],
    patch_max_turns: int,
    selected_indexes: set[int] | None,
) -> str:
    """Drive phases 1-5 against a populated state, routing UI through ``ui``."""
    _set_current_ui(ui)
    try:
        return _run_pipeline_body(
            state,
            ui,
            ctx,
            base_dir,
            workers,
            resume=resume,
            force_review=force_review,
            force_review_sources=force_review_sources,
            patch_max_turns=patch_max_turns,
            selected_indexes=selected_indexes,
        )
    finally:
        _set_current_ui(None)


def _run_pipeline_body(
    state: AuditRunState,
    ui: AuditUI,
    ctx: InputContext,
    base_dir: str,
    workers: int,
    *,
    resume: bool,
    force_review: list[str],
    force_review_sources: dict[str, str],
    patch_max_turns: int,
    selected_indexes: set[int] | None,
) -> str:

    # Phase 1: scope + profile
    if state.phase == "init":
        n_files = len(state.scope.mandatory_files)
        ph1 = _phase_open(
            ui,
            "inventory",
            total=4,
            label=f"{n_files} file{'s' if n_files != 1 else ''}",
        )
        ph1.set_current(f"loading {len(state.scope.mandatory_files)} files")
        if not ui.is_live:
            fmt.info(
                f"phase 1: loading {len(state.scope.mandatory_files)} file contents..."
            )
        content_cache = _load_file_contents(state.scope.mandatory_files, base_dir)
        ph1.advance(current="building import/caller indices")
        if not ui.is_live:
            fmt.info("phase 1: building import/caller indices...")
        (
            state.import_index,
            state.caller_index,
            state.symbol_spans_index,
        ) = _build_context_indices(state.scope.mandatory_files, content_cache)
        state._content_cache.update(content_cache)
        ph1.advance(current="ordering by attack surface")
        if not ui.is_live:
            fmt.info("phase 1: ordering by attack surface...")
        state.queued_files, state.attack_scores = _order_by_attack_surface(
            state.scope.mandatory_files, content_cache
        )
        ph1.advance(current="calling LLM for repo profile")
        if not ui.is_live:
            fmt.info("phase 1: calling LLM for repo profile...")
        try:
            state.repo_profile = _phase1_repo_profile(state, ctx)
        except ValueError as e:
            _ui_warning(
                ui,
                f"phase 1: profile parse failed ({e}); "
                "continuing with empty profile (later phases lose enrichment)",
            )
            state.repo_profile = {}
            state.metrics["phase1_profile_failures"] = (
                state.metrics.get("phase1_profile_failures", 0) + 1
            )
        ph1.advance()
        state.phase = "triage"
        state.save()
        summary_str = state.repo_profile.get("summary", "")[:80]
        if not ui.is_live:
            fmt.info(f"phase 1 complete. profile: {summary_str}")
        ph1.complete(f"profile: {summary_str}" if summary_str else None)

    # Resume rule: re-apply updated force_review before the Phase-2 gate.
    if resume and state.phase in ("triage", "deep_review") and force_review:
        force_matches, force_warnings = _resolve_force_review(
            force_review, force_review_sources, state.scope.mandatory_files
        )
        for w in force_warnings:
            ui.warning(w)
        re_promoted = 0
        for path, source in force_matches.items():
            rec = state.triage_records.get(path)
            if rec is None:
                continue
            reason = f"forced via swival.toml ({source})"
            if reason in rec.promotion_reasons:
                continue
            rec.promotion_reasons.append(reason)
            if rec.priority == "SKIP":
                rec.priority = "ESCALATE_MEDIUM"
                if path not in state.candidate_files:
                    state.candidate_files.append(path)
                re_promoted += 1
        if re_promoted:
            ui.scrollback(
                f"resume: {re_promoted} file(s) promoted via updated "
                f"swival.toml force_review"
            )
            state.save()

    if state.phase == "triage" and state.select_all:
        state.candidate_files = list(state.queued_files)
        state.reviewed_files.update(state.queued_files)
        state.phase = "deep_review"
        state.save()
        ui.scrollback(
            f"phase 2: skipped (--all); {len(state.candidate_files)} files "
            f"queued for deep review"
        )

    if state.phase == "triage":

        def _triage(path):
            return _phase2_triage_one(path, state, ctx)

        def _make_triage_collector(ph):
            def _on_result(idx, item, rec):
                if rec is not None:
                    state.triage_records[rec.path] = rec
                    state.reviewed_files.add(rec.path)
                ph.advance()
                state.save()
                if not ui.is_live:
                    done = len(state.reviewed_files)
                    total = len(state.queued_files)
                    fmt.info(f"  triage progress: {done}/{total}")

            return _on_result

        pending = [f for f in state.queued_files if f not in state.reviewed_files]
        if pending:
            ph2 = _phase_open(ui, "triage", total=len(pending))
            if not ui.is_live:
                fmt.info(f"phase 2: triaging {len(pending)} files...")
            _run_batch(
                _triage,
                pending,
                max_workers=workers,
                ui=ui,
                label_for=str,
                on_result=_make_triage_collector(ph2),
            )
            ph2.complete(
                f"{len(state.reviewed_files)}/{len(state.queued_files)} reviewed"
            )

        for _triage_retry in range(2):
            still_pending = [
                f for f in state.queued_files if f not in state.reviewed_files
            ]
            if not still_pending:
                break
            ph2r = _phase_open(ui, "triage", total=len(still_pending))
            if not ui.is_live:
                fmt.info(
                    f"phase 2: retrying {len(still_pending)} files "
                    f"(attempt {_triage_retry + 2})..."
                )
            _run_batch(
                _triage,
                still_pending,
                max_workers=workers,
                ui=ui,
                label_for=str,
                on_result=_make_triage_collector(ph2r),
            )
            ph2r.complete(f"retry attempt {_triage_retry + 2}")

        force_matches, force_warnings = _resolve_force_review(
            force_review, force_review_sources, state.scope.mandatory_files
        )
        for w in force_warnings:
            ui.warning(w)
        for p in _exact_focus_paths(state.scope):
            force_matches.setdefault(p, "focus")
        promotions = _apply_promotions(state, force_matches)

        # Confirmation pass for low-confidence SKIPs.
        confirm_targets = [
            p
            for p, r in state.triage_records.items()
            if r.priority == "SKIP" and r.confidence == "low"
        ]
        if confirm_targets:
            ui.scrollback(
                f"phase 2: confirmation pass on {len(confirm_targets)} "
                f"low-confidence SKIP(s)..."
            )

            def _confirm(p):
                return _phase2_confirm_one(p, state, ctx)

            results = _run_batch(
                _confirm,
                confirm_targets,
                max_workers=workers,
                ui=ui,
                label_for=str,
            )
            for original_path, new_rec in zip(confirm_targets, results):
                old = state.triage_records[original_path]
                if new_rec is None:
                    old.confirmation_outcome = "kept"
                    continue
                if new_rec.priority in ("ESCALATE_HIGH", "ESCALATE_MEDIUM"):
                    new_rec.confirmation_outcome = "promoted"
                    new_rec.promotion_reasons = list(old.promotion_reasons)
                    state.triage_records[original_path] = new_rec
                else:
                    old.confirmation_outcome = "kept"
            state.save()

        state.candidate_files = [
            path
            for path, rec in state.triage_records.items()
            if rec.priority in ("ESCALATE_HIGH", "ESCALATE_MEDIUM")
        ]
        if state.measure_triage:
            state.measurement_escalated_paths = set(state.candidate_files)
            state.candidate_files = list(state.queued_files)
            ui.scrollback(
                f"phase 2 (measure-triage): expanding deep-review set from "
                f"{len(state.measurement_escalated_paths)} to "
                f"{len(state.candidate_files)} files"
            )
        state.phase = "deep_review"
        state.save()

        _emit_phase2_summary(state, promotions)

    # Phase 3: deep review
    if state.phase == "deep_review":

        def _review(path):
            return _deep_review_one(path, state, ctx, ui=ui)

        def _make_review_collector(total_files, ph):
            def _on_result(idx, item, result):
                if result is None:
                    ph.advance()
                    return
                done = len(state.deep_reviewed_files)
                if result.error is not None:
                    ui.warning(
                        f"  [{done}/{total_files}] failed: {result.path} "
                        f"({result.error[:80]})"
                    )
                    ph.advance()
                    return
                n = len(result.findings) if result.findings else 0
                if result.findings:
                    if state.measure_triage:
                        decision = (
                            "escalated"
                            if result.path in state.measurement_escalated_paths
                            else "skipped"
                        )
                        for f in result.findings:
                            f.triage_decision = decision
                    for f in result.findings:
                        ui.finding(f.severity, f.title, f.source_file)
                    state.proposed_findings.extend(result.findings)
                state.deep_reviewed_files.add(result.path)
                done = len(state.deep_reviewed_files)
                label = f"{n} finding(s)" if n else "no findings"
                if not ui.is_live:
                    fmt.info(f"  [{done}/{total_files}] {result.path}: {label}")
                state.save()
                ph.advance()

            return _on_result

        for _dr_attempt in range(3):
            pending = [
                f for f in state.candidate_files if f not in state.deep_reviewed_files
            ]
            if not pending:
                break
            total_files = len(state.candidate_files)
            reviewed_before = len(state.deep_reviewed_files)
            ph3 = _phase_open(ui, "deep_review", total=len(pending))
            if _dr_attempt == 0:
                if not ui.is_live:
                    fmt.info(f"phase 3: deep review of {len(pending)} files...")
            else:
                if not ui.is_live:
                    fmt.info(
                        f"phase 3: retrying {len(pending)} files "
                        f"(attempt {_dr_attempt + 1})..."
                    )

            _run_batch(
                _review,
                pending,
                max_workers=workers,
                ui=ui,
                label_for=str,
                on_result=_make_review_collector(total_files, ph3),
            )
            reviewed_this_attempt = len(state.deep_reviewed_files) - reviewed_before
            ph3.complete(
                f"{reviewed_this_attempt}/{len(pending)} reviewed this attempt "
                f"({len(state.deep_reviewed_files)}/{total_files} total)"
            )

        if any(f not in state.deep_reviewed_files for f in state.candidate_files):
            remaining = [
                f for f in state.candidate_files if f not in state.deep_reviewed_files
            ]
            msg = (
                f"Audit incomplete: {len(remaining)} escalated files failed deep "
                f"review after retries. Use /audit --resume to retry."
            )
            ui.incomplete(msg)
            return msg

        state.phase = "verification"
        state.save()
        metrics_summary = _format_audit_metrics(state.metrics)
        msg = f"phase 3 complete. {len(state.proposed_findings)} proposed findings." + (
            f" ({metrics_summary})" if metrics_summary else ""
        )
        if not ui.is_live:
            fmt.info(msg)

    # Phase 4: verification (parallel)
    if state.phase == "verification":
        seen_keys: set[str] = set()
        deduped: list[FindingRecord] = []
        deduped_keys: list[str] = []
        for f in state.proposed_findings:
            key = _finding_key(f)
            if key not in seen_keys:
                seen_keys.add(key)
                deduped.append(f)
                deduped_keys.append(key)
        if len(deduped) < len(state.proposed_findings):
            ui.scrollback(
                f"  deduplicated {len(state.proposed_findings)} proposed findings "
                f"to {len(deduped)}"
            )
            state.proposed_findings = deduped

        stale = [k for k in state.verification_state if k not in seen_keys]
        for k in stale:
            del state.verification_state[k]

        already_verified_keys = {
            _finding_key(vf.finding) for vf in state.verified_findings
        }
        for key in deduped_keys:
            if key not in state.verification_state:
                state.verification_state[key] = {
                    "status": "verified" if key in already_verified_keys else "pending",
                    "attempts": 0,
                    "last_error": None,
                    "summary": None,
                }

        max_verify_attempts = 3

        def _verify(item):
            return _verify_one_finding(item, state, ctx, ui=ui)

        def _verify_label(item):
            try:
                _key, f = item
                return f.title
            except (TypeError, ValueError):
                return str(item)

        for _v_attempt in range(max_verify_attempts):
            for vs in state.verification_state.values():
                if vs["status"] == "running":
                    vs["status"] = "pending"

            pending = [
                (key, f)
                for key, f in zip(deduped_keys, state.proposed_findings)
                if state.verification_state[key]["status"] in ("pending", "failed")
            ]

            if not pending:
                break

            verify_workers = min(workers, 2)
            ph4 = _phase_open(ui, "verification", total=len(pending))
            if _v_attempt == 0:
                if not ui.is_live:
                    fmt.info(
                        f"phase 4: verifying {len(pending)} findings "
                        f"with {verify_workers} workers..."
                    )
            else:
                if not ui.is_live:
                    fmt.info(
                        f"phase 4: retry {_v_attempt}/{max_verify_attempts - 1}, "
                        f"{len(pending)} findings remaining..."
                    )

            for key, _ in pending:
                state.verification_state[key]["status"] = "running"
            state.save()

            results = _run_batch(
                _verify,
                pending,
                max_workers=verify_workers,
                ui=ui,
                label_for=_verify_label,
            )

            verified_count = 0
            discarded_count = 0
            failed_count = 0
            total = len(pending)

            for i, result in enumerate(results):
                key = pending[i][0]
                finding = pending[i][1]
                finding_title = finding.title
                vs = state.verification_state[key]
                vs["attempts"] = vs.get("attempts", 0) + (
                    result.attempts if isinstance(result, VerificationResult) else 1
                )

                if result is None or (
                    isinstance(result, VerificationResult) and result.error is not None
                ):
                    vs["status"] = "failed"
                    vs["last_error"] = (
                        result.error
                        if isinstance(result, VerificationResult)
                        else "unexpected worker failure"
                    )
                    failed_count += 1
                    if not ui.is_live:
                        fmt.info(f"  [{i + 1}/{total}] failed: {finding_title}")
                elif result.discarded:
                    vs["status"] = "discarded"
                    vs["summary"] = "not reproduced"
                    discarded_count += 1
                    ui.tally(discarded=1)
                    if not ui.is_live:
                        fmt.info(f"  [{i + 1}/{total}] discarded: {finding_title}")
                elif result.verified_finding is not None:
                    vs["status"] = "verified"
                    vs["summary"] = "verified by proof-of-concept reproduction"
                    state.verified_findings.append(result.verified_finding)
                    verified_count += 1
                    ui.tally(verified=1, severity=finding.severity)
                    if not ui.is_live:
                        fmt.info(f"  [{i + 1}/{total}] verified: {finding_title}")
                    ui.finding(
                        finding.severity,
                        f"VERIFIED: {finding.title}",
                        finding.source_file,
                    )
                ph4.advance()
                state.save()

            if not ui.is_live:
                fmt.info(
                    f"  batch complete: {verified_count} verified, "
                    f"{discarded_count} discarded, {failed_count} failed"
                )
            ph4.complete(
                f"{verified_count} verified · {discarded_count} discarded · "
                f"{failed_count} failed"
            )

        remaining_failed = sum(
            1
            for vs in state.verification_state.values()
            if vs["status"] not in ("verified", "discarded")
        )
        if remaining_failed:
            ui.tally(failed=remaining_failed)

        non_terminal = [
            key
            for key, vs in state.verification_state.items()
            if vs["status"] not in ("verified", "discarded")
        ]
        if non_terminal:
            n_failed = sum(
                1
                for key in non_terminal
                if state.verification_state[key]["status"] == "failed"
            )
            msg = (
                f"Audit incomplete: {len(non_terminal)} findings not verified "
                f"after {max_verify_attempts} attempts ({n_failed} failed). "
                f"Use /audit --resume to retry."
            )
            ui.incomplete(msg)
            return msg

        seen_vf_keys: set[str] = set()
        deduped_vf: list[VerifiedFinding] = []
        for vf in state.verified_findings:
            vf_key = _finding_key(vf.finding)
            if vf_key not in seen_vf_keys:
                seen_vf_keys.add(vf_key)
                deduped_vf.append(vf)
        state.verified_findings = deduped_vf

        state.phase = "adjudication"
        state.save()
        if not ui.is_live:
            fmt.info(
                f"phase 4 complete. {len(state.verified_findings)} verified findings."
            )

    # Phase 4.5: adjudication gate
    if state.phase == "adjudication":
        if state.verified_findings and not state.measure_triage:
            _phase45_adjudicate(state, ctx, ui, workers)
        state.phase = "artifacts"
        state.save()

    # Phase 5: artifacts
    if state.phase == "artifacts":
        if state.verified_findings:
            artifact_dir = Path(base_dir) / state.artifact_dir
            artifact_dir.mkdir(parents=True, exist_ok=True)
            _ensure_artifact_state(state)

            targets = []
            total = len(state.verified_findings)
            for fi, vf in enumerate(state.verified_findings, 1):
                key = _artifact_key(vf)
                entry = state.artifact_state[key]
                if selected_indexes is not None:
                    if fi - 1 in selected_indexes:
                        targets.append((fi, vf, key, entry))
                elif entry.get("status") in ("pending", "failed"):
                    targets.append((fi, vf, key, entry))

            ph5 = None
            if targets:
                ph5 = _phase_open(ui, "artifacts", total=len(targets))
                if not ui.is_live:
                    fmt.info(
                        f"phase 5: generating artifacts for {len(targets)} "
                        f"of {len(state.verified_findings)} findings..."
                    )

            for target_i, (fi, vf, key, entry) in enumerate(targets, 1):
                patch_filename = entry["patch_filename"]
                report_filename = entry["report_filename"]
                if selected_indexes is None:
                    progress = f"[{fi}/{total}] generating patch"
                else:
                    progress = (
                        f"[{target_i}/{len(targets)}] regenerating finding {fi}/{total}"
                    )

                if ph5 is not None:
                    ph5.set_current(f"patch: {vf.finding.title}")
                if not ui.is_live:
                    fmt.info(f"  {progress}: {vf.finding.title}")
                patch_result = _phase5_patch(vf, ctx, state, patch_max_turns, ui=ui)
                entry["attempts"] = int(entry.get("attempts", 0)) + 1
                entry["last_patch_max_turns"] = patch_max_turns
                if patch_result.patch_text is None:
                    _mark_artifact_failed(
                        entry,
                        state,
                        fi,
                        total,
                        patch_result.error_code or "patch_failed",
                        patch_result.error or "patch generation failed",
                    )
                    if ph5 is not None:
                        ph5.advance()
                    continue

                if ph5 is not None:
                    ph5.set_current(f"report: {vf.finding.title}")
                if not ui.is_live:
                    fmt.info(f"  [{fi}/{total}] generating report...")
                try:
                    report_text = _phase5_report(
                        vf, patch_filename, patch_result.patch_text, state, ctx
                    )
                except Exception as e:
                    _mark_artifact_failed(
                        entry, state, fi, total, "report_generation_error", str(e)
                    )
                    if ph5 is not None:
                        ph5.advance()
                    continue

                try:
                    (artifact_dir / patch_filename).write_text(patch_result.patch_text)
                    (artifact_dir / report_filename).write_text(report_text)
                except OSError as e:
                    _mark_artifact_failed(
                        entry, state, fi, total, "write_artifact_error", str(e)
                    )
                    if ph5 is not None:
                        ph5.advance()
                    continue

                entry["status"] = "written"
                entry["last_error_code"] = None
                entry["last_error"] = None
                state.save()
                if not ui.is_live:
                    fmt.info(
                        f"  [{fi}/{total}] wrote {report_filename} + {patch_filename}"
                    )
                if ph5 is not None:
                    ph5.advance()

            if ph5 is not None:
                ph5.complete(f"{len(targets)} artifact(s) written")

        readme_written = _write_findings_readme(state, base_dir)

        # Safety-net rewinds.
        unreviewed = [
            f for f in state.scope.mandatory_files if f not in state.reviewed_files
        ]
        if unreviewed:
            state.phase = "triage"
            state.save()
            msg = (
                f"Audit incomplete: {len(unreviewed)} files were not reviewed. "
                f"Use /audit --resume to continue."
            )
            ui.incomplete(msg)
            return msg
        undeep_reviewed = [
            f for f in state.candidate_files if f not in state.deep_reviewed_files
        ]
        if undeep_reviewed:
            state.phase = "deep_review"
            state.save()
            msg = (
                f"Audit incomplete: {len(undeep_reviewed)} escalated files failed "
                f"deep review. Use /audit --resume to continue."
            )
            ui.incomplete(msg)
            return msg

        failed, pending_n, written = _artifact_summary(state)
        if failed or pending_n:
            state.phase = "artifacts"
            state.save()
            msg = (
                "Audit incomplete: artifact generation has "
                f"{failed} failed and {pending_n} pending out of "
                f"{len(state.verified_findings)} verified finding(s). "
                "Use /audit --resume --patch-max-turns 75 to retry incomplete "
                "artifacts, or /audit --regen --finding 1 --patch-max-turns 75 "
                "to retry a specific finding."
            )
            ui.incomplete(msg)
            return msg

        if state.measure_triage:
            _emit_measure_triage_recall(state)

        state.phase = "done"
        state.save()
    else:
        _failed, _pending, written = _artifact_summary(state)
        readme_written = False

    # Show the artifact row whenever there is a README to point at, including
    # the all-dropped path where no finding survived but the README records why.
    show_artifact_dir = bool(state.verified_findings) or readme_written
    artifact_dir_label = str(state.artifact_dir) if show_artifact_dir else None
    ui.summary(
        artifact_dir=artifact_dir_label,
        written=written,
        readme_written=readme_written,
    )

    if written == 0:
        if readme_written and state.adjudication_discarded:
            n = len(state.adjudication_discarded)
            return (
                "No findings survived adjudication. "
                f"{n} reproduced finding(s) were dropped as false positives or "
                f"out of a realistic threat model; see {state.artifact_dir}/README.md "
                "for the reasons."
            )
        return (
            "No provable security bugs or security-control failures found "
            "in Git-tracked files."
        )

    if readme_written:
        return (
            f"Audit complete. {written} finding(s) written to "
            f"{state.artifact_dir}/. Open {state.artifact_dir}/README.md "
            "to review."
        )
    return (
        f"Audit complete. {written} finding(s) written to {state.artifact_dir}/. "
        f"Run `ls {state.artifact_dir}/` to review."
    )


def _run_audit_phases(
    cmd_arg: str,
    ctx: InputContext,
    base_dir: str,
    state_dir: Path,
    workers: int,
    resume: bool,
    regen: bool,
    focus: list[str] | None,
    select_all: bool = False,
    *,
    force_review: list[str] | None = None,
    force_review_sources: dict[str, str] | None = None,
    measure_triage: bool = False,
    patch_max_turns: int = _DEFAULT_PATCH_MAX_TURNS,
    finding_selector: str | None = None,
) -> str:
    force_review = list(force_review or [])
    force_review_sources = dict(force_review_sources or {})
    selected_indexes: set[int] | None = None
    if resume or regen:
        try:
            commit = _git(["rev-parse", "HEAD"], base_dir)
        except RuntimeError as e:
            return f"error: cannot resolve git state: {e}"

        state = AuditRunState.find_resumable(
            state_dir, commit, focus, include_done=regen
        )
        if state is None:
            label = "regenerable" if regen else "resumable"
            return f"error: no {label} audit found for current commit and scope."
        if measure_triage != state.measure_triage:
            return (
                f"error: --measure-triage mismatch. saved run was "
                f"{'measure-triage' if state.measure_triage else 'normal'}; "
                f"this invocation is "
                f"{'measure-triage' if measure_triage else 'normal'}. "
                f"Start a fresh run instead of resuming."
            )
        _ensure_symbol_spans_index(state, base_dir)
        if regen:
            if not state.verified_findings:
                return "error: no verified findings to regenerate artifacts for."
            if finding_selector is not None:
                try:
                    selected_indexes = _parse_finding_selector(
                        finding_selector, len(state.verified_findings)
                    )
                except ValueError as e:
                    return f"error: {e}"
            state.phase = "artifacts"
            _reset_artifact_targets_for_regen(state, selected_indexes)
            state.save()
            label = (
                f"{len(selected_indexes)} selected"
                if selected_indexes is not None
                else f"{len(state.verified_findings)} verified"
            )
            fmt.info(
                f"regenerating artifacts for audit run {state.run_id} "
                f"({label} findings)"
            )
        else:
            fmt.info(f"resuming audit run {state.run_id} from phase {state.phase}")
    else:
        try:
            scope = _resolve_scope(base_dir, focus or [])
        except RuntimeError as e:
            return f"error: cannot resolve git scope: {e}"

        if not scope.mandatory_files:
            return "No auditable files found in scope."

        dirty_msg = _dirty_worktree_warning(base_dir, scope)
        if dirty_msg:
            fmt.warning(dirty_msg)

        state = AuditRunState(
            run_id=str(uuid.uuid4())[:8],
            scope=scope,
            queued_files=list(scope.mandatory_files),
            state_dir=state_dir,
            select_all=select_all,
            measure_triage=measure_triage,
        )
        all_marker = " --all" if state.select_all else ""
        measure_marker = " --measure-triage" if state.measure_triage else ""
        fmt.info(
            f"audit {state.run_id}: {len(scope.mandatory_files)} files, "
            f"branch={scope.branch}, commit={scope.commit[:8]}"
            f"{all_marker}{measure_marker}"
        )
        if len(scope.mandatory_files) > _LARGE_SCOPE_THRESHOLD:
            n = len(scope.mandatory_files)
            if state.select_all:
                preamble = f"{n} files in scope with --all (triage selection skipped)"
                detail = (
                    "phase 3 will deep-review every file in scope, "
                    "issuing at least one LLM call per file"
                )
                hint = "/audit --all <subdir>"
            else:
                preamble = f"{n} files in scope"
                detail = f"phase 2 may issue up to {n} LLM calls"
                hint = "/audit <subdir>"
            fmt.warning(
                f"{preamble}. {detail}. "
                f"consider narrowing with `{hint}` (one or more paths/globs)."
            )

    ui = AuditUI(
        run_id=state.run_id,
        branch=state.scope.branch,
        commit=state.scope.commit,
        workers=workers,
        total_files=len(state.scope.mandatory_files),
    )
    with ui:
        return _run_pipeline(
            state,
            ui,
            ctx,
            base_dir,
            workers,
            resume=resume,
            force_review=force_review,
            force_review_sources=force_review_sources,
            patch_max_turns=patch_max_turns,
            selected_indexes=selected_indexes,
        )
