"""Hikaflow-specific tools for the Pydantic AI agent.

This module defines tools that the agent can use to interact with
the Hikaflow API and perform debugging operations.
"""

from __future__ import annotations

import asyncio
import logging
import os
import re
import shutil
import subprocess
import sys
import json
import time
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Literal, Optional, TypedDict

import httpx

try:
    from pydantic_ai import RunContext
except ImportError:
    RunContext = None  # type: ignore[assignment,misc]

from autodebug.agent.core import HikaflowDeps, ORCHESTRATOR_MODEL

logger = logging.getLogger(__name__)


# --- Progress tracking for long-running tools ---
# Thread-safe dict updated by tool progress callbacks, read by CLI spinner.
# Keys: "completed", "total", "current" (test name).  Reset between tool calls.
import threading

_tool_progress: dict[str, Any] = {}
_tool_progress_lock = threading.Lock()


def get_tool_progress() -> dict[str, Any]:
    """Get current tool progress state (thread-safe)."""
    with _tool_progress_lock:
        return dict(_tool_progress)


def _reset_tool_progress() -> None:
    """Clear tool progress state."""
    with _tool_progress_lock:
        _tool_progress.clear()


def _update_tool_progress(completed: int, total: int, current: str = "") -> None:
    """Update tool progress state (called from executor threads)."""
    with _tool_progress_lock:
        _tool_progress["completed"] = completed
        _tool_progress["total"] = total
        _tool_progress["current"] = current


# --- Tool tracking for one-shot tools (prepare_tools filter) ---
# Tracks which tools have been called this run so the prepare_tools filter
# can remove one-shot tools (like scan_codebase) after first use.
_tools_called_this_run: set[str] = set()

_ONE_SHOT_TOOLS: set[str] = set()  # scan_codebase cache handles dedup; no need to remove the tool
_HIDDEN_TOOLS = {"list_directory", "scan_flaky_tests"}  # local fallback


def _get_tool_config() -> tuple[set[str], set[str]]:
    """Get tool filtering config from server (or local fallback).

    Returns (hidden_tools, one_shot_tools) sets.
    """
    try:
        from autodebug.agent.core import get_agent_config
        cfg = get_agent_config()
        tools = cfg.get("tools", {})
        return (
            set(tools.get("hidden_tools", list(_HIDDEN_TOOLS))),
            set(tools.get("one_shot_tools", list(_ONE_SHOT_TOOLS))),
        )
    except Exception:
        return _HIDDEN_TOOLS, _ONE_SHOT_TOOLS


def reset_tool_tracking() -> None:
    """Reset per-run tool tracking. Call at the start of each agent turn."""
    _tools_called_this_run.clear()


# --- Scan state reset (called by /clear) ---
# These module-level refs are set by register_hikaflow_tools() closures.
# reset_scan_state() is called from cli_v2.py /clear handler to flush stale data.
_registered_scan_caches: list[dict] = []
_registered_finding_locations: list[set] = []
_registered_review_refs: list[list] = []  # [[instance_ref, path_ref]]
_registered_read_caches: list[dict] = []


def reset_scan_state() -> None:
    """Reset all scan caches and finding state.

    Call this from /clear to ensure stale scan data doesn't pollute new scans.
    """
    _tools_called_this_run.clear()
    for cache in _registered_scan_caches:
        cache.clear()
    for loc_set in _registered_finding_locations:
        loc_set.clear()
    for refs in _registered_review_refs:
        refs[0] = None  # _review_agent_instance
        refs[1] = None  # _review_agent_project_path
    reset_read_cache()


def reset_read_cache() -> None:
    """Clear read_file caches so files can be re-read after compaction.

    Called from _auto_compact_history() when old messages (including
    file contents) are dropped from context. Without this, the agent
    gets "Already read" stubs for files whose content is no longer
    in the conversation.
    """
    for cache in _registered_read_caches:
        cache.clear()


# --- Semgrep rule exclusion for known-noisy rules ---
# Only exclude rules that are genuinely too noisy or no longer exist in .semgrep.yaml.
# Rules like dict-key-access, catch-and-pass, catch-and-return-none are re-enabled
# because they catch real bugs (KeyErrors, swallowed exceptions).
_SEMGREP_EXCLUDED_RULES: frozenset[str] = frozenset({
    # Performance nits — too noisy for non-hot-path code
    "string-concat-in-loop",
    "slow-string-building",
    "inefficient-string-concatenation",
    # Style / naming / formatting — not bugs
    "function-too-long",
    "too-many-arguments",
    "too-many-return-statements",
    "too-many-branches",
    "missing-docstring",
    "line-too-long",
    "naming-convention-violation",
    "import-order",
    "unused-import",
    "wildcard-import",
    # Generic best-practice suggestions — low signal
    "no-print-statements",
    "consider-using-with",
    "unnecessary-pass",
    "unnecessary-comprehension",
    "consider-using-enumerate",
    "consider-using-generator",
    "simplifiable-if-statement",
    "useless-else-on-loop",
})

_SEVERITY_ORDER = {"ERROR": 0, "WARNING": 1, "INFO": 2}

# --- Finding confidence scoring ---
# Quantitative score = source_weight × severity_weight × confidence_weight × rule_precision
# Higher = more trustworthy, range ~0.1 to 1.0
_SOURCE_WEIGHTS = {"semgrep": 1.0, "security": 0.7, "async": 0.7, "quality": 0.6, "worker": 0.6}
_SEVERITY_WEIGHTS = {"ERROR": 1.0, "HIGH": 1.0, "WARNING": 0.7, "MEDIUM": 0.7, "INFO": 0.4, "LOW": 0.4}
_CONFIDENCE_WEIGHTS = {"DEFINITE": 1.0, "LIKELY": 0.7}

# Per-rule precision overrides: rules known to have high/low false-positive rates.
# 1.0 = virtually no false positives; 0.5 = many false positives.
_RULE_PRECISION: dict[str, float] = {
    # High precision (deterministic patterns, almost always real bugs)
    "sql-string-formatting": 1.0,
    "dangerous-eval": 1.0,
    "pickle-untrusted-data": 1.0,
    "subprocess-shell-true": 0.95,
    "mutable-default-argument": 0.95,
    "yaml-unsafe-load": 0.95,
    "postgrest-unencoded-filter": 0.95,
    "blocking-call-in-async-time-sleep": 0.9,
    "react-useeffect-setinterval-no-cleanup": 0.9,
    "react-useeffect-listener-no-cleanup": 0.9,
    "open-file-no-context-manager": 0.85,
    "tempfile-no-cleanup": 0.85,
    # Medium precision (context-dependent, sometimes intentional)
    "hardcoded-password": 0.8,
    "hardcoded-token-or-key": 0.7,
    "bare-except-clause": 0.7,
    "catch-and-pass": 0.7,
    "catch-and-return-none": 0.7,
    "blocking-call-in-async-requests": 0.7,
    "asyncio-gather-no-return-exceptions": 0.7,
    "create-task-result-ignored": 0.7,
    "redis-no-timeout": 0.7,
    "react-missing-key-prop": 0.75,
    # Lower precision (high false-positive rate, need context)
    "ssrf-user-controlled-url": 0.5,
    "dict-key-access-without-check": 0.5,
    "global-mutable-in-async": 0.6,
    "string-concat-in-loop": 0.5,
    "unvalidated-redirect": 0.5,
    "insecure-hash-algorithm": 0.5,
}


def _load_feedback_precision(project_path: str) -> dict[str, float]:
    """Load per-rule precision adjustments from user feedback data.

    Reads `.hikaflow/feedback.json` which tracks per-rule TP/FP/dismiss counts.
    Returns a dict of rule_id -> adjusted precision (0.1 to 1.0).

    The adjustment formula blends the static _RULE_PRECISION with the observed
    true-positive rate:  adjusted = base * 0.6 + observed_tp_rate * 0.4
    Requires at least 3 feedback entries per rule before adjusting.
    """
    feedback_path = os.path.join(project_path, ".hikaflow", "feedback.json")
    if not os.path.isfile(feedback_path):
        return {}
    try:
        with open(feedback_path) as f:
            data = json.load(f)
    except Exception:
        return {}

    adjustments: dict[str, float] = {}
    rules = data.get("rules", {})
    for rule_id, counts in rules.items():
        if not isinstance(counts, dict):
            continue
        tp = counts.get("fixed", 0) + counts.get("true_positive", 0)
        fp = counts.get("false_positive", 0)
        dismissed = counts.get("dismissed", 0)
        total = tp + fp + dismissed
        if total < 3:
            continue  # Not enough data to adjust
        observed_rate = tp / total if total > 0 else 0.5
        base = _RULE_PRECISION.get(rule_id, 0.75)
        # Blend: 60% static + 40% observed, clamped to [0.1, 1.0]
        adjusted = base * 0.6 + observed_rate * 0.4
        adjustments[rule_id] = max(0.1, min(1.0, round(adjusted, 2)))
    return adjustments


# Cached feedback adjustments (refreshed per scan)
_feedback_precision_cache: dict[str, float] = {}
_feedback_precision_project: str = ""


def _score_finding(
    source: str,
    severity: str,
    confidence: str = "DEFINITE",
    rule_id: str = "",
) -> float:
    """Compute a quantitative confidence score (0.0-1.0) for a finding.

    Score = source_weight × severity_weight × confidence_weight × rule_precision.
    Semgrep rules use per-rule precision. Worker findings use categorical confidence.
    Rule precision is blended with user feedback data when available.
    """
    sw = _SOURCE_WEIGHTS.get(source, 0.5)
    sev = _SEVERITY_WEIGHTS.get(severity.upper(), 0.5)
    cw = _CONFIDENCE_WEIGHTS.get(confidence.upper(), 0.7)
    # Per-rule precision: strip prefix (e.g. "user-rules.catch-and-pass" -> "catch-and-pass")
    short_rule = rule_id.rsplit(".", 1)[-1] if rule_id else ""
    # Use feedback-adjusted precision if available, else static
    rp = _feedback_precision_cache.get(short_rule) or _RULE_PRECISION.get(short_rule, 0.75)
    return round(sw * sev * cw * rp, 2)


def _confidence_indicator(score: float) -> str:
    """Return a text indicator for a confidence score."""
    if score >= 0.7:
        return "high"
    if score >= 0.4:
        return "med"
    return "low"


async def smart_tool_filter(
    ctx: "RunContext[HikaflowDeps]",
    tool_defs: list,
) -> list:
    """Dynamically filter tools at each agent step.

    - Remove hidden tools (server-configured)
    - Remove one-shot tools after they've been called
    - Config fetched from /v1/cli/agent-config with local fallback

    Registered as `prepare_tools` on the Agent constructor.
    """
    hidden, one_shot = _get_tool_config()
    filtered = [
        td for td in tool_defs
        if td.name not in hidden
        and (td.name not in one_shot or td.name not in _tools_called_this_run)
    ]
    if not filtered and tool_defs:
        logger.warning(
            "smart_tool_filter returned 0 tools (input had %d). "
            "hidden=%s one_shot=%s called=%s",
            len(tool_defs),
            hidden,
            one_shot,
            _tools_called_this_run,
        )
    return filtered


# --- Timeout helpers ---

async def _run_in_executor_with_timeout(
    func,
    timeout: float,
    timeout_msg: str,
) -> Any:
    """Run a sync function in executor with an asyncio timeout.

    Args:
        func: Callable to run (no arguments — use lambda to bind args).
        timeout: Seconds before TimeoutError.
        timeout_msg: Human-readable message returned on timeout.

    Returns:
        Result of func(), or timeout_msg string on timeout.
    """
    loop = asyncio.get_running_loop()
    try:
        return await asyncio.wait_for(
            loop.run_in_executor(None, func),
            timeout=timeout,
        )
    except asyncio.TimeoutError:
        return timeout_msg


# --- Tool output sanitization ---

# Per-tool output limits. Higher-value analysis tools get more context budget
# so the LLM can reason about richer data. Simple/API tools stay compact.
_TOOL_OUTPUT_LIMITS: dict[str, int] = {
    "read_file": 50_000,
    "list_directory": 8_000,
    "detect_flaky_patterns": 16_000,
    "scan_codebase": 60_000,
    "scan_flaky_tests": 15_000,
    "heal_flaky_test": 16_000,
    "generate_fix_hypothesis": 12_000,
    "generate_tests": 12_000,
    "run_tests": 10_000,
    "fetch_production_errors": 6_000,
    "get_error_details": 8_000,
    "view_io_transcript": 8_000,
    "validate_fix": 8_000,
    "dashboard_stats": 8_000,
    "self_heal": 8_000,
    "project_settings": 6_000,
    "dora_metrics": 6_000,
    "notify": 4_000,
    "contract_drift": 8_000,
    "performance_baselines": 8_000,
    "mutation_testing": 10_000,
    "scan_feedback": 6_000,
    "search_codebase": 20_000,
    "_default": 6_000,
}

# Steering messages appended when output is truncated, telling the LLM
# exactly how to get the missing data (per Anthropic's "truncation with
# steering" pattern from their tool design guide).
_TRUNCATION_STEERING: dict[str, str] = {
    "heal_flaky_test": "To see the full diff, call read_file on the specific file shown above.",
    "detect_flaky_patterns": "To see all matches, call detect_flaky_patterns on a specific file instead of a directory.",
    "scan_codebase": "To investigate a specific finding, call review_files or read_file on the file.",
    "scan_flaky_tests": "To see details for a specific flaky test, call detect_flaky_patterns on that test file.",
    "run_tests": "To see the full test output, re-run with a more specific path.",
    "generate_tests": "To see all generated tests, call read_file on the output file.",
    "dashboard_stats": "Use dashboard_stats with a more specific action or reduce the limit parameter.",
    "self_heal": "Use self_heal with action='events' and a smaller limit to see specific events.",
    "contract_drift": "Focus on specific endpoints by filtering your API test coverage.",
    "performance_baselines": "Reduce the limit parameter or focus on degrading endpoints only.",
    "mutation_testing": "Use mutation_testing with action='results' and a smaller limit, or run on fewer paths.",
}

# Patterns that indicate prompt injection attempts in tool output
_INJECTION_PATTERNS = [
    re.compile(r"ignore\s+(all\s+)?previous\s+instructions", re.IGNORECASE),
    re.compile(r"ignore\s+(all\s+)?prior\s+instructions", re.IGNORECASE),
    re.compile(r"disregard\s+(all\s+)?(previous|prior|above)\s+instructions", re.IGNORECASE),
    re.compile(r"override\s+system\s+prompt", re.IGNORECASE),
    re.compile(r"new\s+system\s+prompt", re.IGNORECASE),
    re.compile(r"you\s+are\s+now\s+(?:a|an)\s+(?:different|new)", re.IGNORECASE),
    re.compile(r"forget\s+(all\s+)?(previous|prior|your)\s+instructions", re.IGNORECASE),
    re.compile(r"system\s*:\s*you\s+are", re.IGNORECASE),
]

# Tools that read local project source code. Their output is NOT an injection
# vector (the user controls their own repo), so injection sanitization is
# skipped to avoid censoring the agent's own source code / config files.
_LOCAL_SOURCE_TOOLS: frozenset[str] = frozenset({
    "read_file", "search_codebase", "list_directory", "git_status",
    "detect_flaky_patterns", "scan_codebase",
})


def _sanitize_tool_output(result: str, tool_name: str = "") -> str:
    """Sanitize tool output: strip injection attempts + smart truncation.

    Uses per-tool output limits and head+tail truncation (keeps the
    beginning and end of the output, which typically contain the summary
    and recommendations respectively). Appends a steering message telling
    the LLM how to retrieve the truncated data.

    Injection pattern sanitization is skipped for local source tools
    (read_file, search_codebase, etc.) to avoid censoring the project's
    own code — e.g. regex patterns that match injection strings.

    Args:
        result: Raw tool output string.
        tool_name: Name of the tool that produced this output.

    Returns:
        Sanitized string with injection attempts neutralized and smart truncation.
    """
    sanitized = result
    if tool_name not in _LOCAL_SOURCE_TOOLS:
        for pattern in _INJECTION_PATTERNS:
            if pattern.search(sanitized):
                logger.warning("Sanitized potential prompt injection from tool output")
                sanitized = pattern.sub("[REDACTED-INJECTION-ATTEMPT]", sanitized)

    limit = _TOOL_OUTPUT_LIMITS.get(tool_name, _TOOL_OUTPUT_LIMITS["_default"])
    if len(sanitized) > limit:
        # Head+tail truncation: keep the start (summary/headers) and end
        # (recommendations/diffs) instead of chopping only at the end.
        head_budget = int(limit * 0.65)
        tail_budget = int(limit * 0.25)
        head = sanitized[:head_budget]
        tail = sanitized[-tail_budget:]
        steering = _TRUNCATION_STEERING.get(tool_name, "")
        if steering:
            steering = f"\nHint: {steering}"
        sanitized = (
            head
            + f"\n\n... [{len(result):,} chars total, middle omitted]{steering}\n\n"
            + tail
        )
    return sanitized


def _extract_json_object(text: str) -> Optional[dict[str, Any]]:
    """Best-effort JSON object extraction from noisy command output."""
    if not text:
        return None

    decoder = json.JSONDecoder()
    candidates: list[str] = []
    raw = text.strip()
    if raw:
        candidates.append(raw)
    # If logs precede JSON, semgrep payload is typically emitted as a JSON
    # object at the beginning of a line.
    match = re.search(r"(?:^|\n)\s*\{", text)
    if match:
        candidate = text[match.start():].strip()
        if candidate and candidate not in candidates:
            candidates.append(candidate)

    for candidate in candidates:
        try:
            parsed, _ = decoder.raw_decode(candidate)
            if isinstance(parsed, dict):
                return parsed
        except Exception:
            continue

    return None


def _load_last_scan(project_path: str) -> set[tuple[str, int, str]]:
    """Load previous scan results for delta comparison.

    Returns a set of (relative_path, line, check_id) tuples from the last scan.
    """
    scan_path = os.path.join(project_path, ".hikaflow", "last-scan.json")
    if not os.path.isfile(scan_path):
        return set()
    try:
        import json as _json
        with open(scan_path) as f:
            data = _json.load(f)
        return {(d["path"], d["line"], d["check_id"]) for d in data if isinstance(d, dict)}
    except Exception:
        return set()


def _save_last_scan(project_path: str, findings: list[dict]) -> None:
    """Save current scan results for future delta comparison.

    Uses atomic write (temp file + os.replace) to prevent corrupt state
    if the process is interrupted mid-write.
    """
    import tempfile
    scan_dir = os.path.join(project_path, ".hikaflow")
    target = os.path.join(scan_dir, "last-scan.json")
    try:
        os.makedirs(scan_dir, exist_ok=True)
        import json as _json
        entries = []
        for f in findings:
            entries.append({
                "path": os.path.relpath(f.get("path", ""), project_path),
                "line": f.get("start", {}).get("line", 0),
                "check_id": f.get("check_id", ""),
            })
        # Write to temp file first, then atomic rename
        fd, tmp_path = tempfile.mkstemp(dir=scan_dir, suffix=".tmp")
        try:
            with os.fdopen(fd, "w") as fh:
                _json.dump(entries, fh)
            os.replace(tmp_path, target)
        except Exception:
            # Clean up temp file on failure
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise
    except Exception:
        pass


def _load_scan_ignore(project_path: str) -> dict:
    """Load .hikaflow/scan-ignore.yaml suppression rules with expiry enforcement.

    Returns dict with 'semgrep', 'review', and '_warnings' keys.
    Expired suppressions are skipped. Missing-reason suppressions are warned.
    """
    ignore_path = os.path.join(project_path, ".hikaflow", "scan-ignore.yaml")
    if not os.path.isfile(ignore_path):
        return {}
    try:
        import yaml
        from datetime import date
        with open(ignore_path) as f:
            data = yaml.safe_load(f)
        if not isinstance(data, dict):
            return {}

        warnings: list[str] = []
        today = date.today()

        def _filter_entries(entries: list) -> list:
            active = []
            for entry in entries:
                if not isinstance(entry, dict):
                    continue
                # Warn if no reason
                if not entry.get("reason"):
                    rule_id = entry.get("rule") or entry.get("pattern", "?")
                    warnings.append(f"Suppression '{rule_id}' has no reason — add justification")
                # Check expiry
                expires_str = entry.get("expires")
                if expires_str:
                    try:
                        expires_date = date.fromisoformat(str(expires_str))
                        if expires_date < today:
                            rule_id = entry.get("rule") or entry.get("pattern", "?")
                            warnings.append(f"Suppression '{rule_id}' expired on {expires_str} — re-verify or remove")
                            continue  # Skip expired
                        # Warn if expiring within 14 days
                        days_left = (expires_date - today).days
                        if days_left <= 14:
                            rule_id = entry.get("rule") or entry.get("pattern", "?")
                            warnings.append(f"Suppression '{rule_id}' expires in {days_left} day(s)")
                    except (ValueError, TypeError):
                        pass  # Invalid date format — keep entry but don't enforce
                active.append(entry)
            return active

        result: dict = {}
        for key in ("semgrep", "review"):
            entries = data.get(key, [])
            if isinstance(entries, list):
                result[key] = _filter_entries(entries)
            else:
                result[key] = []
        result["_warnings"] = warnings
        return result
    except Exception:
        return {}


def _build_review_context(project_path: str) -> str:
    """Build project-specific context for review workers.

    Reads .hikaflow/scan-context.md (user-provided) and auto-detects
    project patterns that workers should know about.  Also reads
    .hikaflow/review-rules.md for project-specific review rules that
    get appended to every worker prompt.
    """
    lines: list[str] = []

    # User-provided context file
    ctx_file = os.path.join(project_path, ".hikaflow", "scan-context.md")
    if os.path.isfile(ctx_file):
        try:
            with open(ctx_file) as f:
                content = f.read()[:1000].strip()
            if content:
                lines.append(content)
        except Exception:
            pass

    # Project-specific review rules (V-10)
    rules_file = os.path.join(project_path, ".hikaflow", "review-rules.md")
    if os.path.isfile(rules_file):
        try:
            with open(rules_file) as f:
                rules = f.read()[:3000].strip()
            if rules:
                lines.append(f"\n## Project Review Rules\n{rules}")
        except Exception:
            pass

    # Auto-detected patterns
    db_pkg = os.path.join(project_path, "api", "db")
    db_mod = os.path.join(project_path, "api", "db.py")
    if os.path.isdir(db_pkg) and os.path.isfile(os.path.join(db_pkg, "__init__.py")):
        if os.path.isfile(db_mod):
            lines.append("- api/db.py is dead code (shadowed by api/db/ package)")

    logging_cfg = os.path.join(project_path, "api", "logging_config.py")
    if os.path.isfile(logging_cfg):
        try:
            with open(logging_cfg) as f:
                head = f.read(3000)
            if "StructuredLogger" in head and "**kwargs" in head:
                lines.append("- Logger uses StructuredLogger that accepts **kwargs — do not flag keyword args in logger calls")
        except Exception:
            pass

    return "\n".join(lines) if lines else "None detected."


def _parse_worker_output(raw: str, path: str) -> list[dict]:
    """Parse structured JSON output from review workers.

    Workers are instructed to return a JSON array of findings.
    Falls back to raw text extraction if JSON parsing fails.
    """
    raw = raw.strip()
    # Try to extract JSON array from the output
    # Workers sometimes wrap JSON in markdown code blocks
    import re as _pre
    json_match = _pre.search(r'\[[\s\S]*\]', raw)
    if json_match:
        try:
            findings = json.loads(json_match.group())
            if isinstance(findings, list):
                valid = []
                for f in findings:
                    if not isinstance(f, dict):
                        continue
                    # Validate required keys
                    if "line" not in f or "message" not in f:
                        continue
                    # Normalize severity
                    sev = str(f.get("severity", "LOW")).upper()
                    if sev not in ("HIGH", "MEDIUM", "LOW"):
                        sev = "LOW"
                    conf = str(f.get("confidence", "LIKELY")).upper()
                    if conf not in ("DEFINITE", "LIKELY"):
                        conf = "LIKELY"
                    valid.append({
                        "line": int(f["line"]),
                        "message": str(f["message"])[:200],
                        "severity": sev,
                        "confidence": conf,
                        "category": str(f.get("category", "code-quality"))[:30],
                        "path": path,
                    })
                return valid
        except (json.JSONDecodeError, ValueError):
            pass

    # Fallback: extract line-number references from raw text
    findings = []
    for m in _pre.finditer(r'[Ll]ine\s+(\d+)[:\s]+(.+?)(?:\n|$)', raw):
        findings.append({
            "line": int(m.group(1)),
            "message": m.group(2).strip()[:200],
            "severity": "LOW",
            "confidence": "LIKELY",
            "category": "code-quality",
            "path": path,
        })
    return findings


# Regex for scan report finding lines produced by _taint_analysis and semgrep formatters.
# Matches: - `path/file.py:42` [SEVERITY|CONF|0.85] check-name: Description text
# Also handles optional [NEW] tag after the severity/conf/score bracket.
import re as _scan_re
_SCAN_FINDING_RE = _scan_re.compile(
    r"^\s*-\s+`([^`]+):(\d+)`\s+\[([A-Z]+)\|([^|]+)\|([0-9.]+)\]"
    r"(?:\s*\[NEW\])?\s*(\S+):\s*(.+)$"
)


def _parse_scan_findings(raw_report: str, max_findings: int = 100) -> tuple[list[dict], dict[str, int]]:
    """Extract structured findings from raw scan report markdown.

    Parses lines matching the format produced by semgrep/review workers:
        - `path/file.py:LINE` [SEV|CONF|SCORE] check: message

    Returns:
        Tuple of (findings_list, severity_counts).
    """
    findings: list[dict] = []
    severity_counts: dict[str, int] = {}

    for line in raw_report.splitlines():
        m = _SCAN_FINDING_RE.match(line)
        if not m:
            continue
        severity = m.group(3)
        severity_counts[severity] = severity_counts.get(severity, 0) + 1

        if len(findings) < max_findings:
            try:
                score = float(m.group(5))
            except ValueError:
                score = 0.0
            findings.append({
                "file": m.group(1),
                "line": int(m.group(2)),
                "severity": severity,
                "message": m.group(7).strip(),
                "check": m.group(6),
                "confidence": m.group(4),
                "score": score,
            })

    return findings, severity_counts


def _post_dedup_worker_findings(
    worker_findings: list[dict],
    semgrep_locations: set[tuple[str, int]],
    line_tolerance: int = 3,
) -> tuple[list[dict], int]:
    """Merge worker findings that overlap Semgrep findings instead of dropping them.

    Returns (deduped_findings, merged_count) where merged findings are tagged
    with _semgrep_enriched=True so the display can indicate the source.
    """
    if not semgrep_locations:
        return worker_findings, 0
    merged_count = 0
    result = []
    for f in worker_findings:
        path = f.get("path", "")
        line = f.get("line", 0)
        overlaps_semgrep = any(
            fp == path and abs(ln - line) <= line_tolerance
            for (fp, ln) in semgrep_locations
        )
        if overlaps_semgrep:
            # Keep the finding but tag it as enriching a Semgrep result
            f["_semgrep_enriched"] = True
            merged_count += 1
        result.append(f)
    return result, merged_count


class ProjectScanMeta(TypedDict):
    configured: bool
    repo_slug: str | None
    reason: str | None


class ProductionErrorsScanMeta(TypedDict):
    ok: bool
    total: int
    count: int
    error: str | None


class IssueGroupsScanMeta(TypedDict):
    ok: bool
    count: int
    error: str | None


class FlakyPatternsScanMeta(TypedDict):
    ok: bool
    matches: int
    top_file: str | None
    risk_level: str | None
    reason: str | None
    error: str | None


class SessionsScanMeta(TypedDict):
    ok: bool
    count: int
    error: str | None


class RepoHygieneScanMeta(TypedDict):
    ok: bool
    changes: int
    has_tsbuildinfo: bool
    untracked: int
    error: str | None


class SemgrepScanMeta(TypedDict, total=False):
    ok: bool
    status: str
    findings: int
    findings_by_severity: dict[str, int]
    filtered: int
    files: list[str]
    error: str | None


class QuarantineScanMeta(TypedDict):
    ok: bool
    count: int
    error: str | None


class ScanMeta(TypedDict, total=False):
    project: ProjectScanMeta
    production_errors: ProductionErrorsScanMeta
    issue_groups: IssueGroupsScanMeta
    flaky_patterns: FlakyPatternsScanMeta
    sessions: SessionsScanMeta
    repo_hygiene: RepoHygieneScanMeta
    semgrep: SemgrepScanMeta
    quarantine: QuarantineScanMeta


@dataclass
class ScanClassification:
    root_causes: list[str]
    symptoms: list[str]
    notes: list[str]


def _classify_scan_findings(scan_meta: ScanMeta) -> ScanClassification:
    root_causes: list[str] = []
    symptoms: list[str] = []
    notes: list[str] = []

    project_meta = scan_meta.get("project")
    if project_meta and not project_meta.get("configured", True):
        symptoms.append("Project is not configured in local credentials.")

    prod_meta = scan_meta.get("production_errors")
    if prod_meta and not prod_meta.get("ok", True):
        if prod_meta.get("error") == "NO_PROJECT_LINKED":
            notes.append("Production errors lookup skipped until project is linked.")
        else:
            root_causes.append("Production errors API request failed.")

    issue_meta = scan_meta.get("issue_groups")
    if issue_meta and not issue_meta.get("ok", True):
        if issue_meta.get("error") == "NO_PROJECT_LINKED":
            notes.append("Issue groups lookup skipped until project is linked.")
        else:
            root_causes.append("Issue groups API request failed.")

    sessions_meta = scan_meta.get("sessions")
    if sessions_meta and not sessions_meta.get("ok", True):
        if sessions_meta.get("error") == "NO_PROJECT_LINKED":
            notes.append("Session lookup skipped until project is linked.")
        elif (prod_meta and not prod_meta.get("ok", True)) or (issue_meta and not issue_meta.get("ok", True)):
            symptoms.append("Session listing failed (likely due to same API/backend issue).")
        else:
            root_causes.append("Sessions API request failed.")

    semgrep_meta = scan_meta.get("semgrep")
    if semgrep_meta:
        semgrep_status = semgrep_meta.get("status")
        if semgrep_status in {"error", "exception"}:
            root_causes.append("Semgrep scan failed.")
        elif semgrep_status == "timeout":
            notes.append("Semgrep timed out on one or more scan targets.")
        elif semgrep_status == "findings_with_partial_errors":
            notes.append("Semgrep returned partial results with recoverable parser/execution issues.")

    return ScanClassification(root_causes=root_causes, symptoms=symptoms, notes=notes)


def _scan_recommendations(scan_meta: ScanMeta) -> list[str]:
    next_steps: list[str] = []

    semgrep_meta = scan_meta.get("semgrep")
    semgrep_files = semgrep_meta.get("files", []) if semgrep_meta else []
    if semgrep_meta and semgrep_meta.get("status") in {"findings", "findings_with_partial_errors"} and semgrep_files:
        files_str = ", ".join(f"'{f}'" for f in semgrep_files[:5])
        next_steps.append(f"- Deep review Semgrep findings: `review_files([{files_str}])`")

    flaky_meta = scan_meta.get("flaky_patterns")
    top_file = flaky_meta.get("top_file") if flaky_meta else None
    if top_file:
        next_steps.append(
            f"- Investigate top flaky finding: `read_file('{top_file}')` then `detect_flaky_patterns('{top_file}')`"
        )

    prod_meta = scan_meta.get("production_errors")
    issue_meta = scan_meta.get("issue_groups")
    sessions_meta = scan_meta.get("sessions")
    no_project_linked = any(
        meta and meta.get("error") == "NO_PROJECT_LINKED"
        for meta in (prod_meta, issue_meta, sessions_meta)
    )
    if (
        (prod_meta and not prod_meta.get("ok", True))
        or (issue_meta and not issue_meta.get("ok", True))
        or (sessions_meta and not sessions_meta.get("ok", True))
    ):
        if no_project_linked:
            next_steps.append(
                "- Link project explicitly first: `hikaflow login` then `hikaflow init --project-id <id>`, then re-run `scan_codebase()`."
            )
        else:
            next_steps.append(
                "- Restore API signal first: run `hikaflow status` and `hikaflow doctor`, then re-run `scan_codebase()`."
            )
            next_steps.append("- If API errors persist, capture diagnostics: `hikaflow doctor --json` and share request_id values.")
    elif prod_meta and prod_meta.get("ok", False) and prod_meta.get("count", 0) > 0:
        next_steps.append("- Drill into production errors: `fetch_production_errors()` then `get_error_details(error_id)`")

    quarantine_meta = scan_meta.get("quarantine")
    if quarantine_meta and quarantine_meta.get("count", 0) > 0:
        next_steps.append("- Review quarantined tests: `quarantine(action='list')`")

    repo_meta = scan_meta.get("repo_hygiene")
    if repo_meta and repo_meta.get("has_tsbuildinfo"):
        next_steps.append("- Clean TS build artifact tracking: add `*.tsbuildinfo` to `.gitignore` and run `git rm --cached web/tsconfig.tsbuildinfo`")

    project_meta = scan_meta.get("project")
    if project_meta and not project_meta.get("configured", True):
        next_steps.append("- Link project explicitly: `hikaflow init --project-id <id>` (scan no longer auto-links by design)")

    if semgrep_meta and semgrep_meta.get("status") in {"error", "exception", "timeout", "missing"}:
        next_steps.append("- Restore static analysis signal: run `semgrep --version`, then `semgrep scan --config p/ci .` and retry `scan_codebase()`.")

    return next_steps


def _scan_fidelity(scan_meta: ScanMeta) -> tuple[str, list[str]]:
    """Compute scan fidelity status from typed section outcomes."""
    degraded_reasons: list[str] = []

    project_meta = scan_meta.get("project")
    if project_meta and not project_meta.get("configured", True):
        degraded_reasons.append("Project not linked; project-scoped context is limited.")

    prod_meta = scan_meta.get("production_errors")
    if prod_meta and not prod_meta.get("ok", True) and prod_meta.get("error") != "NO_PROJECT_LINKED":
        degraded_reasons.append("Production errors API unavailable.")

    issue_meta = scan_meta.get("issue_groups")
    if issue_meta and not issue_meta.get("ok", True) and issue_meta.get("error") != "NO_PROJECT_LINKED":
        degraded_reasons.append("Issue groups API unavailable.")

    sessions_meta = scan_meta.get("sessions")
    if sessions_meta and not sessions_meta.get("ok", True) and sessions_meta.get("error") != "NO_PROJECT_LINKED":
        degraded_reasons.append("Sessions API unavailable.")

    semgrep_meta = scan_meta.get("semgrep")
    if semgrep_meta:
        status = semgrep_meta.get("status")
        if status in {"error", "exception", "missing", "timeout"}:
            degraded_reasons.append(f"Semgrep unavailable ({status}).")
        elif status == "findings_with_partial_errors":
            degraded_reasons.append("Semgrep returned partial results.")

    scan_status = "full" if not degraded_reasons else "degraded"
    return scan_status, degraded_reasons


def _scan_evidence_lines(scan_meta: ScanMeta) -> list[str]:
    """Build deterministic evidence lines for operational findings."""
    lines: list[str] = []

    prod_meta = scan_meta.get("production_errors")
    if prod_meta and not prod_meta.get("ok", True) and prod_meta.get("error") != "NO_PROJECT_LINKED":
        lines.append(
            f"- Production errors API failure | source=`Production Errors` | artifact={prod_meta.get('error') or 'unknown'}"
        )

    issue_meta = scan_meta.get("issue_groups")
    if issue_meta and not issue_meta.get("ok", True) and issue_meta.get("error") != "NO_PROJECT_LINKED":
        lines.append(
            f"- Issue groups API failure | source=`Issue Groups` | artifact={issue_meta.get('error') or 'unknown'}"
        )

    sessions_meta = scan_meta.get("sessions")
    if sessions_meta and not sessions_meta.get("ok", True) and sessions_meta.get("error") != "NO_PROJECT_LINKED":
        lines.append(
            f"- Sessions API failure | source=`Sessions` | artifact={sessions_meta.get('error') or 'unknown'}"
        )

    semgrep_meta = scan_meta.get("semgrep")
    if semgrep_meta and semgrep_meta.get("status") in {
        "error",
        "exception",
        "timeout",
        "missing",
        "findings_with_partial_errors",
    }:
        lines.append(
            f"- Semgrep reliability issue | source=`Semgrep` | status={semgrep_meta.get('status')} | artifact={semgrep_meta.get('error') or 'none'}"
        )

    project_meta = scan_meta.get("project")
    if project_meta and not project_meta.get("configured", True):
        lines.append(
            f"- Project linkage missing | source=`Project` | artifact={project_meta.get('reason') or 'not configured'}"
        )

    return lines


def _env_flag(name: str, default: bool = False) -> bool:
    raw = os.environ.get(name)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}


def _apply_evidence_guard(
    classification: ScanClassification,
    evidence_lines: list[str],
    strict_evidence_mode: bool,
) -> tuple[ScanClassification, bool]:
    """Demote ungrounded root-cause claims when strict evidence mode is enabled."""
    if not strict_evidence_mode:
        return classification, False
    if classification.root_causes and not evidence_lines:
        return (
            ScanClassification(
                root_causes=[],
                symptoms=classification.symptoms,
                notes=classification.notes + [
                    "Evidence guard: root-cause claims were demoted due to missing evidence artifacts."
                ],
            ),
            True,
        )
    return classification, False


def _apply_patch_simple(patch: str, project_root: str, dry_run: bool) -> str:
    """Simple fallback patch applier for when patch-ng is not installed.

    Supports the '*** Begin Patch / *** Update File' format and basic
    unified diff (--- / +++ / @@ hunks).
    """
    import re as _re

    # Parse "*** Update File: <path>" blocks
    file_blocks = _re.split(r"\*\*\*\s+Update File:\s*", patch)
    if len(file_blocks) > 1:
        results = []
        for block in file_blocks[1:]:
            lines = block.strip().split("\n")
            if not lines:
                continue
            filepath = lines[0].strip()

            # Validate path: reject absolute paths, ".." traversal, and symlink escapes
            if os.path.isabs(filepath) or ".." in filepath.split(os.sep):
                results.append(f"  - {filepath}: REJECTED (path traversal)")
                continue
            full_path = os.path.realpath(os.path.join(project_root, filepath))
            real_root = os.path.realpath(project_root)
            if not full_path.startswith(real_root + os.sep) and full_path != real_root:
                results.append(f"  - {filepath}: REJECTED (escapes project root)")
                continue

            # Parse - and + lines
            old_lines = []
            new_lines = []
            for line in lines[1:]:
                if line.startswith("*** End Patch"):
                    break
                if line.startswith("@@"):
                    continue
                if line.startswith("-"):
                    old_lines.append(line[1:])
                elif line.startswith("+"):
                    new_lines.append(line[1:])

            if dry_run:
                results.append(f"  - {filepath}: -{len(old_lines)} +{len(new_lines)} lines")
                continue

            if not os.path.isfile(full_path):
                results.append(f"  - {filepath}: file not found")
                continue

            # Simple find-and-replace with proper resource management
            try:
                with open(full_path, "r", encoding="utf-8") as fh:
                    content = fh.read()
                old_text = "\n".join(old_lines)
                new_text = "\n".join(new_lines)
                if old_text and old_text in content:
                    content = content.replace(old_text, new_text, 1)
                    with open(full_path, "w", encoding="utf-8") as fh:
                        fh.write(content)
                    results.append(f"  - {filepath}: patched")
                else:
                    results.append(f"  - {filepath}: old text not found (patch may not match)")
            except Exception as e:
                results.append(f"  - {filepath}: error: {e}")

        if dry_run:
            return (
                f"**Dry run — patch NOT applied.**\n"
                f"Would modify {len(results)} file(s):\n" + "\n".join(results) + "\n\n"
                f"Patch content:\n```diff\n{patch[:3000]}\n```\n\n"
                f"Run again with dry_run=False to apply."
            )
        return "Patch results:\n" + "\n".join(results)

    return "Error: Unsupported patch format. Use '*** Begin Patch / *** Update File' or unified diff format."


# --- Compact profile wrapper tools (Claude SDK optimized) ---

def _tool_envelope(
    status: str,
    summary: str,
    data: Optional[dict[str, Any]] = None,
    next_actions: Optional[list[str]] = None,
    confidence: float = 1.0,
    tool_name: str = "",
) -> str:
    """Return a compact JSON envelope string for structured tool output."""
    status = status if status in {"ok", "error", "partial"} else "error"
    summary = (summary or "").strip()[:240]
    if not summary:
        summary = "No summary."
    conf = max(0.0, min(1.0, float(confidence)))
    payload: dict[str, Any] = {
        "status": status,
        "summary": summary,
        "data": data or {},
        "confidence": round(conf, 2),
    }
    if next_actions:
        payload["next_actions"] = [str(a)[:120] for a in next_actions[:8]]
    return _sanitize_tool_output(
        json.dumps(payload, ensure_ascii=True),
        tool_name=tool_name,
    )

def _looks_like_error(text: str) -> bool:
    t = (text or "").strip().lower()
    return t.startswith("error") or t.startswith("confirmation required")

def _extract_first_http_url(text: str) -> str:
    m = re.search(r"https?://[^\s)]+", text or "")
    return m.group(0) if m else ""


def _no_project_envelope(tool_name: str, local_alternatives: Optional[list[str]] = None) -> str:
    """Return a structured error envelope when no project is linked.

    Cloud tools call this early to give the agent a clear signal with
    actionable next_actions instead of a raw error string.
    """
    actions = ["Run 'hikaflow init --project-name <name>' to connect a project."]
    if local_alternatives:
        actions.append(f"Use local tools instead: {', '.join(local_alternatives)}")
    return _tool_envelope(
        "error",
        "No project linked. Connect a project to use production features.",
        {"missing": "project_id"},
        actions,
        0.0,
        tool_name,
    )


def register_hikaflow_tools(agent: Any) -> None:
    """Register all Hikaflow-specific tools with the agent.

    Args:
        agent: Pydantic AI Agent instance.
    """
    # Cache for scan_codebase to avoid redundant 30s+ scans in the same session
    _scan_cache: dict[str, str] = {}
    _registered_scan_caches.append(_scan_cache)

    @agent.tool
    async def fetch_production_errors(
        ctx: RunContext[HikaflowDeps],
        limit: int = 10,
        environment: Optional[str] = None,
        error_type: Optional[str] = None,
    ) -> str:
        """Fetch recent production errors from Hikaflow.

        Args:
            limit: Maximum number of errors to return.
            environment: Filter by environment (production/staging/development).
            error_type: Filter by exception type.

        Returns:
            Formatted table of errors with ID, type, message, count, and last seen.
        """
        if not _get_project_id(ctx):
            return "Error fetching errors: No project linked. Run 'hikaflow init' first."

        data, error = await _agent_api_request(
            ctx,
            "GET",
            "/v1/prod-errors",
            params={
                "per_page": limit,
                "environment": environment or ctx.deps.environment,
            },
            timeout=30.0,
            attempts=3,
        )
        if error or data is None:
            return f"Error fetching errors: {error or 'unknown API error'}"

        result = data if isinstance(data, dict) else {"errors": data}
        errors = [
            {
                "id": e.get("error_id"),
                "exception_type": e.get("exception_type", "Unknown"),
                "message": e.get("exception_message", ""),
                "count": e.get("breadcrumb_count", 1),
                "last_seen": e.get("created_at"),
            }
            for e in result.get("errors", [])
            if isinstance(e, dict)
        ]
        if error_type:
            errors = [e for e in errors if e.get("exception_type") == error_type]
        return _format_errors_table(errors)

    @agent.tool
    async def get_error_details(
        ctx: RunContext[HikaflowDeps],
        error_id: str,
    ) -> str:
        """Get comprehensive details for a specific error.

        Args:
            error_id: The error ID to fetch details for.

        Returns:
            Error details including stack trace, location, count, and I/O summary.
        """
        data, err = await _agent_api_request(
            ctx, "GET", f"/v1/prod-errors/{error_id}", timeout=30.0,
        )
        if err or data is None:
            return f"Error fetching error details: {err or 'unknown'}"

        # Transform raw error_data into the expected format
        exc = data.get("exception") or {}
        frames = data.get("stack_trace") or data.get("stack") or []
        last_frame = frames[-1] if isinstance(frames, list) and frames and isinstance(frames[-1], dict) else {}
        stack_lines = []
        for f in (frames if isinstance(frames, list) else []):
            if isinstance(f, dict):
                fn = f.get("filename") or f.get("file") or "unknown"
                ln = f.get("lineno") or f.get("line")
                func = f.get("function") or ""
                stack_lines.append(f"{fn}:{ln} {func}".strip() if ln else f"{fn} {func}".strip())

        error = {
            "exception_type": exc.get("type") or data.get("exception_type") or "Unknown",
            "message": exc.get("message") or data.get("exception_message") or "",
            "file": last_frame.get("filename") or last_frame.get("file") or "unknown",
            "line": last_frame.get("lineno") or last_frame.get("line"),
            "first_seen": data.get("timestamp") or data.get("created_at"),
            "count": data.get("count", 1),
            "environment": data.get("environment"),
            "stack_trace": "\n".join(stack_lines) if stack_lines else None,
        }

        # Extract transcript from the same payload
        source = data.get("transcript") or data.get("io") or data
        transcript = {
            "http": source.get("http") or source.get("http_calls") or [],
            "sql": source.get("sql") or source.get("sql_queries") or [],
            "time": source.get("time") or source.get("time_probes") or [],
        }

        return _format_error_details(error, transcript)

    @agent.tool
    async def replay_error(
        ctx: RunContext[HikaflowDeps],
        error_id: str,
        runs: int = 3,
    ) -> str:
        """Replay an error deterministically using captured I/O.

        This is Hikaflow's unique capability - it replays the exact
        conditions that caused the error, including HTTP responses,
        database results, and time values.

        Args:
            error_id: The error to replay.
            runs: Number of replay runs to perform.

        Returns:
            Replay results showing whether the error was reproduced.
        """
        try:
            from autodebug_replay import replay_run
        except ImportError:
            return "Error: autodebug_replay module not available. Cannot perform replay."

        results = []
        for i in range(runs):
            try:
                result = await replay_run(error_id)
                results.append(result)
            except Exception as e:
                results.append({"reproduced": False, "error": str(e)})

        return _format_replay_results(results)

    @agent.tool
    async def view_io_transcript(
        ctx: RunContext[HikaflowDeps],
        error_id: str,
        io_type: str = "all",
    ) -> str:
        """View the captured I/O transcript for an error.

        The transcript shows all external I/O that occurred during
        the request that caused the error.

        Args:
            error_id: The error to view transcript for.
            io_type: Type of I/O to show: "http", "sql", "time", or "all".

        Returns:
            Formatted I/O transcript.
        """
        data, err = await _agent_api_request(
            ctx, "GET", f"/v1/prod-errors/{error_id}", timeout=30.0,
        )
        if err or data is None:
            return f"Error fetching transcript: {err or 'unknown'}"

        # Extract transcript from the error payload
        source = data.get("transcript") or data.get("io") or data
        transcript = {
            "http": source.get("http") or source.get("http_calls") or [],
            "sql": source.get("sql") or source.get("sql_queries") or [],
            "time": source.get("time") or source.get("time_probes") or [],
            "task_graph": source.get("task_graph") or source.get("async_task_graph") or [],
        }
        return _format_transcript(transcript, io_type)

    @agent.tool
    async def generate_fix_hypothesis(
        ctx: RunContext[HikaflowDeps],
        error_id: str,
    ) -> str:
        """Generate AI-powered fix hypotheses for an error.

        Analyzes the error and proposes potential fixes with
        confidence scores.

        Args:
            error_id: The error to generate fixes for.

        Returns:
            List of fix hypotheses with diffs and confidence scores.
        """
        try:
            from autodebug.autonomous.analyzer import analyze_failure
            from autodebug.autonomous.hypothesis import generate_hypotheses
        except ImportError:
            return "Error: Autonomous debugging modules not available."

        error_data, err = await _agent_api_request(
            ctx, "GET", f"/v1/prod-errors/{error_id}", timeout=30.0,
        )
        if err or error_data is None:
            return f"Error fetching error {error_id}: {err or 'unknown'}"

        try:
            analysis = analyze_failure(error_data)
            hypotheses = await generate_hypotheses(analysis)
            return _format_hypotheses(hypotheses)
        except Exception as e:
            return f"Error generating hypotheses: {e}"

    @agent.tool
    async def validate_fix(
        ctx: RunContext[HikaflowDeps],
        error_id: str,
        patch: str,
        runs: int = 10,
    ) -> str:
        """Validate a fix by applying it and running deterministic replay.

        This provides proof that a fix resolves the error by running
        multiple replay iterations.

        Args:
            error_id: The error being fixed.
            patch: Unified diff of the fix to validate.
            runs: Number of validation runs to perform.

        Returns:
            Validation result with verdict and proof certificate ID.
        """
        try:
            from autodebug.autonomous.validator import validate_patch
        except ImportError:
            return "Error: Validation module not available."

        try:
            result = await validate_patch(
                error_id=error_id,
                patch=patch,
                num_runs=runs,
            )
            return _format_validation_result(result)
        except Exception as e:
            return f"Error validating fix: {e}"

    @agent.tool
    async def search_similar_fixes(
        ctx: RunContext[HikaflowDeps],
        error_id: Optional[str] = None,
        exception_type: Optional[str] = None,
        limit: int = 5,
    ) -> str:
        """Search for similar errors that were previously fixed.

        Uses pattern matching to find historical fixes that may apply.

        Args:
            error_id: Current error to find similar fixes for.
            exception_type: Exception type to search for.
            limit: Maximum number of matches to return.

        Returns:
            List of similar past fixes with success rates.
        """
        try:
            from autodebug.learning.search import find_similar
        except ImportError:
            return "Error: Learning module not available."

        try:
            matches = await find_similar(
                error_id=error_id,
                exception_type=exception_type,
                limit=limit,
            )
            return _format_similar_fixes(matches)
        except Exception as e:
            return f"Error searching similar fixes: {e}"

    @agent.tool
    async def apply_patch(
        ctx: RunContext[HikaflowDeps],
        patch: str,
        dry_run: bool = False,
    ) -> str:
        """Apply a unified diff patch to files.

        Args:
            patch: Unified diff content to apply.
            dry_run: If True, preview what would change without applying.

        Returns:
            Result message indicating success or failure, or preview if dry_run.
        """
        # Try patch-ng first, fall back to simple line-based patcher
        try:
            import patch_ng
            patchset = patch_ng.fromstring(patch.encode())
            if patchset:  # fromstring returns False (not None) on parse failure
                if dry_run:
                    files_affected = [str(p.target) for p in patchset.items]
                    return (
                        f"**Dry run — patch NOT applied.**\n"
                        f"Would modify {len(files_affected)} file(s): {', '.join(files_affected)}\n\n"
                        f"Patch content:\n```diff\n{patch[:3000]}\n```\n\n"
                        f"Run again with dry_run=False to apply."
                    )
                success = patchset.apply(strip=1, root=ctx.deps.project_path)
                if success:
                    files_changed = [str(p.target) for p in patchset.items]
                    return f"Patch applied successfully to {len(files_changed)} file(s): {', '.join(files_changed)}"
                else:
                    return "Error: Failed to apply patch. Files may have been modified or patch may not match."
        except ImportError:
            pass  # Fall through to simple patcher
        except Exception as e:
            return f"Error applying patch: {e}"

        # Simple fallback: parse "*** Begin Patch" format or unified diff
        return _apply_patch_simple(patch, ctx.deps.project_path, dry_run)

    # --- Destructive git operation confirmation ---

    # Set of git operations considered destructive that require confirmation.
    # When require_confirmation is True (default), these operations will be
    # blocked unless the caller explicitly sets require_confirmation=False.
    _DESTRUCTIVE_GIT_OPS = {"push", "push --force", "push -f", "branch -D", "branch -d", "reset --hard"}

    def _is_destructive_git_op(cmd_args: list[str]) -> bool:
        """Check if a git command is destructive.

        Args:
            cmd_args: Git command arguments (excluding 'git' itself).

        Returns:
            True if the operation is considered destructive.
        """
        if not cmd_args:
            return False
        joined = " ".join(cmd_args[:3])
        for op in _DESTRUCTIVE_GIT_OPS:
            if joined.startswith(op):
                return True
        # Special case: push with --force anywhere in args
        if cmd_args[0] == "push" and ("--force" in cmd_args or "-f" in cmd_args):
            return True
        return False

    @agent.tool
    async def git_status(
        ctx: RunContext[HikaflowDeps],
    ) -> str:
        """Show git repository status: modified, staged, and untracked files.

        Returns:
            Git status output showing changed files, or error message.
        """
        try:
            # Short status for overview
            status = subprocess.run(
                ["git", "status", "--short"],
                capture_output=True,
                text=True,
                cwd=ctx.deps.project_path,
                timeout=15,
            )
            if status.returncode != 0:
                return f"Error: {status.stderr.strip()}"

            output_parts = []
            status_text = status.stdout.strip()
            if status_text:
                # Filter noise files
                _NOISE_PREFIXES = (".hypothesis/", "__pycache__/", ".pytest_cache/", "node_modules/")
                lines = status_text.split("\n")
                filtered = [l for l in lines if not any(n in l for n in _NOISE_PREFIXES)]
                skipped = len(lines) - len(filtered)
                status_text = "\n".join(filtered[:50])
                if skipped:
                    status_text += f"\n... ({skipped} noise files hidden)"
                output_parts.append("**Changed files:**\n```\n" + status_text + "\n```")
            else:
                output_parts.append("Working tree clean — no changes.")

            # Branch info
            branch = subprocess.run(
                ["git", "branch", "--show-current"],
                capture_output=True,
                text=True,
                cwd=ctx.deps.project_path,
                timeout=10,
            )
            if branch.returncode == 0 and branch.stdout.strip():
                output_parts.insert(0, f"**Branch:** `{branch.stdout.strip()}`\n")

            # Diff stat for changed files
            if status_text:
                diff = subprocess.run(
                    ["git", "diff", "--stat"],
                    capture_output=True,
                    text=True,
                    cwd=ctx.deps.project_path,
                    timeout=15,
                )
                if diff.returncode == 0 and diff.stdout.strip():
                    output_parts.append("\n**Diff summary:**\n```\n" + diff.stdout.strip() + "\n```")

            return _sanitize_tool_output("\n".join(output_parts), tool_name="git_status")
        except subprocess.TimeoutExpired:
            return "Error: git status timed out. The repository may be very large or on a slow filesystem."
        except Exception as e:
            return f"Error running git status: {type(e).__name__}"

    @agent.tool
    async def git_commit(
        ctx: RunContext[HikaflowDeps],
        message: str,
        files: Optional[list[str]] = None,
    ) -> str:
        """Preview a git commit. This tool NEVER actually commits — it only shows
        what would be committed and gives the user the command to run manually.

        Args:
            message: Proposed commit message.
            files: Specific files to include (default: all changed files).

        Returns:
            Preview of what would be committed, with the git command for the user to run.
        """
        try:
            result = subprocess.run(
                ["git", "status", "--short"],
                capture_output=True, text=True, cwd=ctx.deps.project_path,
                timeout=15,
            )
            files_arg = " ".join(files) if files else "-u"
            return (
                f"**Commit preview (not committed — you must run this yourself):**\n"
                f"Message: {message}\n"
                f"Files: {', '.join(files) if files else 'all tracked changes'}\n\n"
                f"Changes:\n```\n{result.stdout[:2000]}\n```\n\n"
                f"Run in your terminal:\n```\n"
                f"git add {files_arg} && git commit -m \"{message}\"\n```"
            )
        except Exception as e:
            return f"Error checking git status: {e}"

    @agent.tool
    async def create_pull_request(
        ctx: RunContext[HikaflowDeps],
        title: str,
        body: str,
        branch: str,
        require_confirmation: bool = True,
    ) -> str:
        """Create a GitHub pull request with the fix.

        Args:
            title: PR title.
            body: PR description (markdown).
            branch: Branch name to create.
            require_confirmation: If True, block destructive git ops (push)
                unless explicitly overridden. Defaults to True.

        Returns:
            PR URL or error message.
        """
        if require_confirmation:
            return (
                "Confirmation required: This operation will push a branch to the "
                "remote repository and create a pull request. Re-call with "
                "require_confirmation=False to proceed."
            )

        try:
            from ghapi.all import GhApi
        except ImportError:
            return "Error: ghapi not installed. Install with: pip install ghapi"

        # Get repo info from git remote
        try:
            result = subprocess.run(
                ["git", "remote", "get-url", "origin"],
                capture_output=True,
                text=True,
                cwd=ctx.deps.project_path,
                timeout=10,
            )
        except subprocess.TimeoutExpired:
            return "Error: git remote lookup timed out (10s)"
        remote_url = result.stdout.strip()

        # Parse owner/repo from URL
        if "github.com" not in remote_url:
            return "Error: Remote is not a GitHub repository"

        # Handle both HTTPS and SSH formats
        parts = remote_url.replace(".git", "").split("github.com")[-1]
        parts = parts.lstrip("/").lstrip(":")
        try:
            owner, repo = parts.split("/")
        except ValueError:
            return f"Error: Could not parse GitHub repository from: {remote_url}"

        # Create branch and push
        try:
            subprocess.run(
                ["git", "checkout", "-b", branch],
                cwd=ctx.deps.project_path,
                capture_output=True,
                timeout=15,
            )
        except subprocess.TimeoutExpired:
            return "Error: git checkout timed out (15s)"
        try:
            push_result = subprocess.run(
                ["git", "push", "-u", "origin", branch],
                cwd=ctx.deps.project_path,
                capture_output=True,
                text=True,
                timeout=60,
            )
        except subprocess.TimeoutExpired:
            return "Error: git push timed out (60s)"
        if push_result.returncode != 0:
            return f"Error pushing branch: {push_result.stderr}"

        # Create PR via ghapi
        github_token = ctx.deps.github_token or os.getenv("GITHUB_TOKEN")
        if not github_token:
            return "Error: GITHUB_TOKEN not set. Cannot create PR."

        try:
            api = GhApi(token=github_token)
            pr = api.pulls.create(
                owner=owner,
                repo=repo,
                title=title,
                body=body,
                head=branch,
                base="main",
            )
            return f"Pull request created: {pr.html_url}"
        except Exception as e:
            return f"Error creating PR: {e}"


    # --- Local project tools ---

    def _check_path_security(ctx: Any, path: str) -> tuple[str, str | None]:
        """Validate path is within project directory. Returns (full_path, error_or_None)."""
        full_path = os.path.join(ctx.deps.project_path, path)
        real_path = os.path.realpath(full_path)
        real_project = os.path.realpath(ctx.deps.project_path)
        if real_path != real_project and not real_path.startswith(real_project + os.sep):
            return full_path, "Error: Path is outside project directory"
        return full_path, None

    @agent.tool
    async def run_tests(
        ctx: RunContext[HikaflowDeps],
        path: str = "tests/",
        verbose: bool = False,
        pattern: Optional[str] = None,
        maxfail: int = 10,
        quick: bool = False,
    ) -> str:
        """Run pytest on the project and return results.

        WARNING: path="tests/" runs ALL tests (3000+) and can take 5+ minutes.
        For broad issue discovery, use scan_codebase instead.
        Only use this with a SPECIFIC file (e.g., "tests/test_auth.py") or pattern.

        Args:
            path: Path to test file or directory (relative to project root).
                  Prefer specific files over "tests/" to avoid long waits.
            verbose: If True, show verbose output instead of quiet.
            pattern: pytest -k pattern to filter tests by name.
            maxfail: Stop after this many failures (default: 10). Set to 0 for unlimited.
            quick: If True, excludes slow/integration/e2e markers and sets maxfail=5.

        Returns:
            Test results with pass/fail summary and failure details.
        """
        full_path, err = _check_path_security(ctx, path)
        if err:
            return err

        cmd = [sys.executable, "-m", "pytest", full_path, "--tb=short"]
        if verbose:
            cmd.append("-v")
        else:
            cmd.append("-q")
        if pattern:
            cmd.extend(["-k", pattern])
        if quick:
            cmd.extend(["-m", "not slow and not integration and not e2e"])
            maxfail = maxfail if maxfail != 10 else 5
        if maxfail > 0:
            cmd.extend(["--maxfail", str(maxfail)])

        # Strip sensitive env vars from subprocess environment
        clean_env = {
            k: v for k, v in os.environ.items()
            if k not in {
                "HIKAFLOW_TOKEN", "AUTODEBUG_TOKEN", "ANTHROPIC_API_KEY",
                "OPENAI_API_KEY", "STRIPE_SECRET_KEY", "GITHUB_HIKAFLOW_TOKEN",
                "SUPABASE_SERVICE_ROLE_KEY", "CLERK_SECRET_KEY",
            }
        }

        loop = asyncio.get_event_loop()
        try:
            result = await loop.run_in_executor(None, lambda: subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=90,
                cwd=ctx.deps.project_path,
                env=clean_env,
            ))

            output = result.stdout + result.stderr
            if len(output) > 10000:
                # Keep the end (pytest summary) which is most useful
                output = "... [truncated, showing last 10000 chars]\n" + output[-10000:]

            status = "PASSED" if result.returncode == 0 else "FAILED"
            return _sanitize_tool_output(f"**Test Result: {status}**\n\n```\n{output}\n```", tool_name="run_tests")
        except subprocess.TimeoutExpired:
            return "Error: Tests timed out after 90 seconds. Try a specific file or pattern."
        except Exception as e:
            return f"Error running tests: {e}"

    # Removed from agent tools (absorbed by scan_codebase / run_tests)
    async def collect_tests(
        ctx: RunContext[HikaflowDeps],
        path: str = "tests/",
    ) -> str:
        """Discover all test nodeids in a directory using pytest --collect-only.

        Args:
            path: Path to test file or directory (relative to project root).

        Returns:
            Numbered list of test nodeids and total count.
        """
        full_path, err = _check_path_security(ctx, path)
        if err:
            return err

        from autodebug.flaky_quarantine import run_pytest_collect

        try:
            nodeids = await _run_in_executor_with_timeout(
                lambda: run_pytest_collect(full_path),
                timeout=60.0,
                timeout_msg=f"Error: Test collection timed out after 60s for {path}. Try a more specific path.",
            )
            if isinstance(nodeids, str):  # timeout message
                return nodeids
        except Exception as e:
            return f"Error collecting tests: {e}"

        if not nodeids:
            return f"No tests found in {path}"

        lines = [f"**Found {len(nodeids)} tests in {path}:**\n"]
        for i, nodeid in enumerate(nodeids, 1):
            lines.append(f"{i}. `{nodeid}`")
            if i >= 200:
                lines.append(f"\n... and {len(nodeids) - 200} more")
                break
        return "\n".join(lines)

    @agent.tool
    async def scan_flaky_tests(
        ctx: RunContext[HikaflowDeps],
        path: str = "tests/",
        runs: int = 3,
        parallel: int = 2,
    ) -> str:
        """Run tests multiple times to empirically detect flaky tests.

        This runs each test N times and identifies tests that sometimes
        pass and sometimes fail — the hallmark of flakiness.

        IMPORTANT: Only use this when the user specifically asks to find flaky tests
        (e.g. "find flaky tests", "scan for flakiness"). Do NOT use for general
        "find issues" or "code review" requests — use scan_codebase + review_files instead.

        Best practice: pass a SPECIFIC test file path (e.g. "tests/test_auth.py"),
        not "tests/" which scans everything and can take 10+ minutes.

        Args:
            path: Path to test file or directory (relative to project root).
                  Prefer specific files over broad directories.
            runs: Number of times to run each test (2-10).
            parallel: Number of parallel test workers (1-4).

        Returns:
            Flaky scan report with flaky tests, pass rates, and scores.
        """
        _tools_called_this_run.add("scan_flaky_tests")

        full_path, err = _check_path_security(ctx, path)
        if err:
            return err

        from autodebug.flaky_quarantine import (
            format_flaky_scan_report,
            scan_for_flaky_tests as _scan,
        )

        # Safety caps
        runs = max(2, min(runs, 10))
        parallel = max(1, min(parallel, 4))

        # If scanning a broad directory, cap timeout more aggressively
        scan_timeout = 60 if os.path.isfile(full_path) else 120

        _reset_tool_progress()
        loop = asyncio.get_event_loop()
        try:
            result = await loop.run_in_executor(
                None,
                lambda: _scan(
                    full_path,
                    runs=runs,
                    parallel=parallel,
                    timeout=scan_timeout,
                    progress_callback=_update_tool_progress,
                ),
            )
        except Exception as e:
            return f"Error scanning for flaky tests: {e}"
        finally:
            _reset_tool_progress()

        return _sanitize_tool_output(format_flaky_scan_report(result), tool_name="scan_flaky_tests")

    # Cache for detect_flaky_patterns to avoid redundant scans in the same session
    _detect_cache: dict[str, str] = {}

    @agent.tool
    async def detect_flaky_patterns(
        ctx: RunContext[HikaflowDeps],
        path: str,
    ) -> str:
        """Static analysis to detect common flaky test patterns.

        IMPORTANT: Pass a specific test FILE path, not a directory.
        Example: detect_flaky_patterns('tests/test_metrics.py')

        Scans code for patterns like time.sleep, random usage,
        race conditions, network calls, and other flakiness indicators
        WITHOUT running the tests.

        Args:
            path: Path to a specific test file (relative to project root).

        Returns:
            Risk assessment with matched patterns, categories, and recommendations.
        """
        full_path, err = _check_path_security(ctx, path)
        if err:
            return err

        # Guard: reject directory scans — require specific files
        if os.path.isdir(full_path):
            return (
                f"Error: '{path}' is a directory. Pass a specific test file instead.\n"
                "Example: detect_flaky_patterns('tests/test_metrics.py')\n\n"
                "Tip: check your scan_codebase results for specific file:line references to target."
            )

        # Return cached result if same path was already scanned this session
        if path in _detect_cache:
            return _detect_cache[path] + "\n\n*[cached — same path was already scanned this session]*"

        from autodebug.healing.detector import (
            analyze_flakiness_risk,
            detect_flaky_patterns as _detect,
        )

        try:
            result = await _run_in_executor_with_timeout(
                lambda: _detect(Path(full_path)),
                timeout=60.0,
                timeout_msg=f"Error: Pattern detection timed out after 60s for {path}. Try a more specific file path.",
            )
            if isinstance(result, str):  # timeout message
                return result
            risk = analyze_flakiness_risk(result)
        except Exception as e:
            return f"Error detecting flaky patterns: {e}"

        # Filter out likely false positives with semantic awareness
        _FP_FILE_PATTERNS = {"replay", "recorder", "deterministic", "rng_replay", "time_replay"}
        _FP_LOCAL_VAR_NAMES = {"acquired", "results", "items", "collected", "output", "data", "buf"}

        def _is_likely_false_positive(match) -> bool:
            """Filter matches that are almost certainly false positives."""
            fname = os.path.basename(match.file_path).lower()
            # Skip files that test deterministic replay behavior
            for fp_pat in _FP_FILE_PATTERNS:
                if fp_pat in fname:
                    return True
            # Skip low-confidence matches
            if match.confidence < 0.70:
                return True
            # Skip matches where the matched code looks like a URL or import
            code = match.matched_code.strip() if hasattr(match, 'matched_code') else ""
            if code and ("http://" in code or "https://" in code):
                return True
            return False

        filtered_matches = [m for m in result.matches if not _is_likely_false_positive(m)]
        fp_count = len(result.matches) - len(filtered_matches)

        lines = [
            f"**Flaky Pattern Analysis for {path}**\n",
            f"- Risk Level: **{risk['risk_level'].upper()}** (score: {risk['risk_score']})",
            f"- Summary: {risk['summary']}",
        ]
        if fp_count > 0:
            lines.append(f"- Filtered: {fp_count} likely false positives (replay test files, low confidence)")

        if risk.get("by_category"):
            lines.append("\n**Patterns by Category:**")
            for cat, count in risk["by_category"].items():
                lines.append(f"- {cat}: {count} matches")

        if filtered_matches:
            project_root = ctx.deps.project_path or os.getcwd()
            lines.append(f"\n**Top Matches ({min(len(filtered_matches), 20)} of {len(filtered_matches)}, {fp_count} filtered):**")
            for m in filtered_matches[:20]:
                rel = os.path.relpath(m.file_path, project_root)
                lines.append(
                    f"- `{rel}:{m.line_number}` — {m.pattern.name} "
                    f"(confidence: {m.confidence:.0%})"
                )
        elif result.matches:
            lines.append(f"\n*All {len(result.matches)} matches were filtered as likely false positives (replay tests, low confidence).*")

        if risk.get("recommendations"):
            lines.append("\n**Recommendations:**")
            # Deduplicate recommendations
            seen = set()
            for rec in risk["recommendations"]:
                if rec not in seen:
                    seen.add(rec)
                    lines.append(f"- {rec}")

        _output = _sanitize_tool_output("\n".join(lines), tool_name="detect_flaky_patterns")
        _detect_cache[path] = _output
        return _output

    @agent.tool
    async def heal_flaky_test(
        ctx: RunContext[HikaflowDeps],
        path: str,
        dry_run: bool = True,
    ) -> str:
        """Auto-fix detected flaky test patterns using templates and LLM.

        IMPORTANT: Always call with dry_run=True first to preview fixes.
        Only set dry_run=False after the user has reviewed the preview
        and explicitly asked to apply the changes.

        Args:
            path: Path to file or directory to heal (relative to project root).
            dry_run: If True (default), preview fixes without modifying files.
                     Must preview first before applying.

        Returns:
            Healing report with detected patterns, fixes, diffs, and success rate.
        """
        full_path, err = _check_path_security(ctx, path)
        if err:
            return err

        from autodebug.healing.healer import (
            HealingStrategy,
            generate_fix_report,
            heal_test as _heal,
        )

        # Pass the user's model choice to the healer
        _agent_model = os.getenv("HIKAFLOW_MODEL", "")
        _llm_provider = None
        _llm_model = None
        if ":" in _agent_model:
            _llm_provider, _llm_model = _agent_model.split(":", 1)

        # Fail-fast: check API key exists before running 30+ LLM calls
        _key_map = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY"}
        _required_key = _key_map.get(_llm_provider or "anthropic")
        if _required_key and not os.environ.get(_required_key):
            # No API key — downgrade to template-only to avoid 30 error messages
            logger.info(f"No {_required_key} found, using template-only healing strategy")
            strategy = HealingStrategy.TEMPLATE_ONLY
        else:
            strategy = HealingStrategy.TEMPLATE_THEN_LLM

        # Suppress healer logger to prevent internal warnings from leaking to terminal
        import logging as _logging
        _healer_logger = _logging.getLogger("autodebug.healing.healer")
        _prev_level = _healer_logger.level
        _healer_logger.setLevel(_logging.CRITICAL)

        try:
            session = await _run_in_executor_with_timeout(
                lambda: _heal(
                    Path(full_path),
                    strategy=strategy,
                    dry_run=dry_run,
                    validate=False,
                    llm_model=_llm_model,
                    llm_provider=_llm_provider,
                ),
                timeout=120.0,
                timeout_msg=f"Error: Healing timed out after 120s for {path}. LLM-based fixes may be slow — try template-only strategy.",
            )
            if isinstance(session, str):  # timeout message
                return session
        except Exception as e:
            err_type = type(e).__name__
            err_msg = str(e).lower()
            if any(k in err_msg for k in ("authentication", "api key", "api_key", "credit balance", "billing")):
                return "Error: LLM API unavailable (auth or billing issue). Template-based healing still works — try setting OPENAI_API_KEY or use the template-only strategy."
            return f"Error healing tests: {err_type}: {str(e)[:200]}"
        finally:
            _healer_logger.setLevel(_prev_level)

        mode = "DRY RUN (preview only)" if dry_run else "APPLIED"
        report = generate_fix_report(session, project_root=ctx.deps.project_path)
        # Catch auth/billing errors that the healer swallowed into HealingResult
        if any(k in report.lower() for k in ("authentication", "api_key", "auth_token", "credit balance", "billing")):
            # Strip raw API error JSON from report
            import re as _re
            report = _re.sub(
                r"Error code: \d+ - \{.*?\}", "[LLM API error]", report
            )
            report = (
                "Note: LLM-based healing requires a valid API key with credits. "
                "Template-based fixes were still attempted.\n\n" + report
            )

        # Validate proposed fixes with AST parsing to catch syntax errors
        import ast as _ast
        if dry_run and hasattr(session, "results"):
            invalid_fixes = []
            for r in session.results:
                if hasattr(r, "fixed_code") and r.fixed_code:
                    try:
                        _ast.parse(r.fixed_code)
                    except SyntaxError as syn_err:
                        fname = getattr(r, "file_path", "unknown")
                        invalid_fixes.append(f"  - `{fname}`: {syn_err.msg} (line {syn_err.lineno})")
            if invalid_fixes:
                report = (
                    f"**WARNING: {len(invalid_fixes)} proposed fix(es) have syntax errors and should NOT be applied:**\n"
                    + "\n".join(invalid_fixes) + "\n\n" + report
                )

        return _sanitize_tool_output(f"**Healing Mode: {mode}**\n\n{report}", tool_name="heal_flaky_test")

    # Removed from agent tools (absorbed by scan_codebase)
    async def check_environment(
        ctx: RunContext[HikaflowDeps],
    ) -> str:
        """Run environment diagnostics (Python, Docker, deps, disk, CI, conflicts).

        Checks everything needed for Hikaflow to work properly.

        Returns:
            Diagnostic report with pass/fail checks and recommendations.
        """
        from autodebug.doctor import format_report, run_doctor

        try:
            results = await _run_in_executor_with_timeout(
                run_doctor,
                timeout=30.0,
                timeout_msg="Error: Environment diagnostics timed out after 30s. Network or credential checks may be slow. Try running 'hikaflow doctor' directly.",
            )
            if isinstance(results, str):  # timeout message
                return results
        except Exception as e:
            return f"Error running diagnostics: {e}"

        return _sanitize_tool_output(format_report(results), tool_name="check_environment")

    async def manage_quarantine(
        ctx: RunContext[HikaflowDeps],
        action: str = "list",
        nodeids: Optional[list[str]] = None,
    ) -> str:
        """Manage the .flaky-quarantine file for skipping flaky tests in CI.

        Args:
            action: One of "list", "add", "remove", or "deselect_args".
            nodeids: Test nodeids for add/remove actions.

        Returns:
            Result of the quarantine operation.
        """
        from autodebug.flaky_quarantine import (
            QuarantinedTest,
            add_to_quarantine,
            generate_pytest_deselect_args,
            list_quarantined_tests,
            remove_from_quarantine,
        )
        from datetime import datetime, timezone

        quarantine_path = Path(ctx.deps.project_path) / ".flaky-quarantine"

        if action == "list":
            tests = list_quarantined_tests(quarantine_path)
            if not tests:
                return "No tests are currently quarantined."
            lines = [f"**Quarantined Tests ({len(tests)}):**\n"]
            lines.append("| Test | Pass Rate | Flakiness | Quarantined |")
            lines.append("|------|-----------|-----------|-------------|")
            for t in tests:
                lines.append(
                    f"| `{t.nodeid}` | {t.pass_rate*100:.0f}% | "
                    f"{t.flakiness_score:.0f} | {t.quarantined_at[:10]} |"
                )
            return "\n".join(lines)

        elif action == "add":
            if not nodeids:
                return "Error: 'nodeids' required for add action."
            tests_to_add = [
                QuarantinedTest(
                    nodeid=nid,
                    flakiness_score=0,
                    pass_rate=0,
                    quarantined_at=datetime.now(timezone.utc).isoformat(),
                    reason="Manually quarantined via agent",
                    auto_quarantined=False,
                )
                for nid in nodeids
            ]
            added = add_to_quarantine(tests_to_add, quarantine_path)
            return f"Added {added} test(s) to quarantine."

        elif action == "remove":
            if not nodeids:
                return "Error: 'nodeids' required for remove action."
            removed = remove_from_quarantine(nodeids, quarantine_path)
            return f"Removed {removed} test(s) from quarantine."

        elif action == "deselect_args":
            args = generate_pytest_deselect_args(quarantine_path)
            if not args:
                return "No quarantined tests — no deselect args needed."
            return f"Pytest args to skip quarantined tests:\n```\n{' '.join(args)}\n```"

        else:
            return f"Error: Unknown action '{action}'. Use: list, add, remove, deselect_args"

    async def compare_transcripts(
        ctx: RunContext[HikaflowDeps],
        left_path: str,
        right_path: str,
    ) -> str:
        """Compare two captured I/O transcripts to find divergences.

        Useful for understanding why two test runs produced different results.

        Args:
            left_path: Path to first transcript file (relative to project root).
            right_path: Path to second transcript file (relative to project root).

        Returns:
            Diff report showing where and how the transcripts diverge.
        """
        left_full, err = _check_path_security(ctx, left_path)
        if err:
            return err
        right_full, err = _check_path_security(ctx, right_path)
        if err:
            return err

        from autodebug.diff import (
            diff_transcripts as _diff,
            format_diff_report,
            load_transcript,
        )

        loop = asyncio.get_event_loop()
        try:
            left_data = await loop.run_in_executor(
                None, load_transcript, Path(left_full)
            )
            right_data = await loop.run_in_executor(
                None, load_transcript, Path(right_full)
            )
            result = _diff(left_data, right_data)
        except FileNotFoundError:
            return f"Error: Transcript file not found: {left_path} or {right_path}"
        except Exception as e:
            return f"Error comparing transcripts: {type(e).__name__}: {getattr(e, 'args', [''])[0] if e.args else ''}"

        return _sanitize_tool_output(format_diff_report(result), tool_name="compare_transcripts")

    # --- Helper: resolve project_id from config ---

    def _get_project_id(ctx: Any) -> Optional[str]:
        """Get project_id from local credentials config."""
        try:
            from autodebug.config import load_credentials
            creds = load_credentials()
            return creds.get("project_id")
        except Exception:
            return None

    # --- Circuit breaker: session-scoped per-host failure tracking ---
    _circuit_failures: dict[str, int] = {}  # host -> consecutive failure count
    _circuit_open: set[str] = set()  # hosts marked unreachable
    _CIRCUIT_THRESHOLD = 2  # consecutive failures before opening circuit

    def _circuit_host(api_base: str) -> str:
        """Extract host from api_base for circuit breaker tracking."""
        try:
            from urllib.parse import urlparse
            return urlparse(api_base).netloc or api_base
        except Exception:
            return api_base

    async def _agent_api_request(
        ctx: RunContext[HikaflowDeps],
        method: str,
        path: str,
        *,
        params: Optional[Dict[str, Any]] = None,
        json_body: Optional[Dict[str, Any]] = None,
        timeout: float = 30.0,
        attempts: int = 3,
        extra_headers: Optional[Dict[str, str]] = None,
    ) -> tuple[Optional[Any], Optional[str]]:
        """Shared API helper with lightweight retry for transient failures.

        Includes a session-scoped circuit breaker: after 2 consecutive failures
        to the same host, the circuit opens and all subsequent requests return
        immediately with an error.  A successful request resets the counter.

        GET requests are deduplicated per-session: identical method+path+params
        return a cached result instead of hitting the API again.
        """
        host = _circuit_host(ctx.deps.api_base)

        # Circuit breaker: fast-fail if host is marked unreachable
        if host in _circuit_open:
            return None, (
                f"API unreachable (circuit open for {host}). "
                "Use local tools: run_tests, codebase_scan, review_files."
            )

        delay = 0.25
        last_error: Optional[str] = None
        method = method.upper()

        # Dedup GET requests (read-only, safe to cache within a session)
        if method == "GET" and hasattr(ctx.deps, "_tool_cache"):
            cache_params = {"path": path, **(params or {})}
            cached = ctx.deps.get_cached_tool_result("_api_get", cache_params)
            if cached is not None:
                return cached
        for attempt in range(1, max(1, attempts) + 1):
            async with httpx.AsyncClient() as client:
                try:
                    headers = {"Authorization": f"Bearer {ctx.deps.token}"}
                    if extra_headers:
                        headers.update(extra_headers)
                    resp = await client.request(
                        method,
                        f"{ctx.deps.api_base}{path}",
                        headers=headers,
                        params=params,
                        json=json_body,
                        timeout=timeout,
                    )
                    resp.raise_for_status()
                    try:
                        result = (resp.json(), None)
                    except Exception:
                        result = ({"status_code": resp.status_code, "text": resp.text}, None)
                    # Cache successful GET responses
                    if method == "GET" and hasattr(ctx.deps, "_tool_cache"):
                        cache_params = {"path": path, **(params or {})}
                        ctx.deps.cache_tool_result("_api_get", cache_params, result)
                    # Success: reset circuit breaker for this host
                    _circuit_failures.pop(host, None)
                    return result
                except httpx.HTTPStatusError as e:
                    status = e.response.status_code if e.response is not None else None
                    body = ""
                    request_id = None
                    try:
                        if e.response is not None:
                            body = (e.response.text or "")[:180]
                            request_id = (
                                e.response.headers.get("x-request-id")
                                or e.response.headers.get("x-correlation-id")
                            )
                    except Exception:
                        pass
                    last_error = f"{status} {body}".strip()
                    if request_id:
                        last_error = f"{last_error} [request_id={request_id}]"
                    if status is None or status < 500 or status > 599 or attempt == attempts:
                        break
                except httpx.RequestError as e:
                    last_error = str(e)
                    if attempt == attempts:
                        break
            await asyncio.sleep(delay)
            delay = min(delay * 2, 2.0)

        # Track consecutive failures for circuit breaker
        _circuit_failures[host] = _circuit_failures.get(host, 0) + 1
        if _circuit_failures[host] >= _CIRCUIT_THRESHOLD:
            _circuit_open.add(host)
            logger.warning("Circuit breaker opened for %s after %d consecutive failures", host, _circuit_failures[host])

        return None, (last_error or "unknown API error")

    # =================================================================
    # Session Management Tools (5)
    # =================================================================

    async def list_sessions(
        ctx: RunContext[HikaflowDeps],
        limit: int = 20,
        status: Optional[str] = None,
    ) -> str:
        """List recent session replays.

        Args:
            limit: Maximum number of sessions to return (1-100).
            status: Filter by status: active, completed, or failed.

        Returns:
            Table of sessions with ID, status, created date, duration, and chunk count.
        """
        project_id = _get_project_id(ctx)
        if not project_id:
            return "Error: No project linked. Run 'hikaflow init' first."

        params: dict[str, Any] = {"project_id": project_id, "limit": min(max(limit, 1), 100)}
        if status:
            params["status"] = status

        data, error = await _agent_api_request(
            ctx,
            "GET",
            "/v1/sessions",
            params=params,
            timeout=15.0,
            attempts=3,
        )
        if error or data is None:
            return f"Error: {error or 'unable to list sessions'}"
        sessions = data if isinstance(data, list) else data.get("sessions", data.get("items", []))

        if not sessions:
            return "No sessions found."

        lines = [f"**Sessions ({len(sessions)}):**\n"]
        lines.append("| ID | Status | Created | Duration | Chunks |")
        lines.append("|-----|--------|---------|----------|--------|")
        for s in sessions:
            sid = str(s.get("id", ""))[:12]
            s_status = s.get("status", "unknown")
            raw_created = s.get("created_at", "")
            created = str(raw_created)[:10] if raw_created else "N/A"
            duration = s.get("duration_ms")
            dur_str = f"{duration/1000:.1f}s" if duration else "N/A"
            chunks = s.get("chunk_count", s.get("chunks", "N/A"))
            lines.append(f"| `{sid}` | {s_status} | {created} | {dur_str} | {chunks} |")
        return _sanitize_tool_output("\n".join(lines), tool_name="sessions")

    async def create_session(
        ctx: RunContext[HikaflowDeps],
        metadata: Optional[str] = None,
        trace_id: Optional[str] = None,
    ) -> str:
        """Create a new session replay.

        Args:
            metadata: Optional JSON string of metadata to attach.
            trace_id: Optional trace ID to link this session to a run.

        Returns:
            Session ID and upload URL for adding chunks.
        """
        project_id = _get_project_id(ctx)
        if not project_id:
            return "Error: No project linked. Run 'hikaflow init' first."

        meta: dict[str, Any] = {}
        if metadata:
            import json
            try:
                meta = json.loads(metadata)
            except json.JSONDecodeError as e:
                return f"Error: Invalid metadata JSON: {e}"

        payload: dict[str, Any] = {"project_id": project_id, "metadata": meta}
        if trace_id:
            payload["trace_id"] = trace_id

        data, err = await _agent_api_request(
            ctx, "POST", "/v1/sessions", json_body=payload, timeout=30.0, attempts=1,
        )
        if err or data is None:
            return f"Error creating session: {err or 'unknown'}"
        session_id = data.get("session_id", data.get("id", "unknown"))
        return f"Session created: `{session_id}`\nUpload URL: {data.get('upload_url', 'N/A')}"

    async def upload_session(
        ctx: RunContext[HikaflowDeps],
        file_path: str,
        stream: str = "dom",
        metadata: Optional[str] = None,
        trace_id: Optional[str] = None,
    ) -> str:
        """One-shot session upload: create session, upload file, finalize.

        Args:
            file_path: Path to JSONL or .jsonl.zst file (relative to project root).
            stream: Stream type: dom, console, network, errors, or perf.
            metadata: Optional JSON string of metadata.
            trace_id: Optional trace ID to link to a run.

        Returns:
            Session ID and upload status.
        """
        full_path, err = _check_path_security(ctx, file_path)
        if err:
            return err
        if not os.path.isfile(full_path):
            return f"Error: File not found: {file_path}"

        project_id = _get_project_id(ctx)
        if not project_id:
            return "Error: No project linked. Run 'hikaflow init' first."

        meta: dict[str, Any] = {}
        if metadata:
            import json
            try:
                meta = json.loads(metadata)
            except json.JSONDecodeError as e:
                return f"Error: Invalid metadata JSON: {e}"

        payload: dict[str, Any] = {"project_id": project_id, "metadata": meta}
        if trace_id:
            payload["trace_id"] = trace_id

        try:
            # Step 1: Create session
            data, err = await _agent_api_request(
                ctx, "POST", "/v1/sessions", json_body=payload, timeout=30.0, attempts=1,
            )
            if err or data is None:
                return f"Error creating session: {err or 'unknown'}"
            session_id = data.get("session_id") or data.get("id")
            if not session_id:
                return f"Error: API did not return a session_id. Response: {str(data)[:200]}"

            # Step 2: Get upload URL
            if stream == "dom":
                upload_url = data.get("upload_url")
                if not upload_url:
                    return f"Error: API did not return an upload_url for session {session_id}."
            else:
                import uuid
                chunk_id = str(uuid.uuid4())
                chunk_data, chunk_err = await _agent_api_request(
                    ctx, "POST", f"/v1/sessions/{session_id}/chunks",
                    json_body={"stream": stream, "chunk_id": chunk_id},
                    timeout=30.0, attempts=1,
                )
                if chunk_err or chunk_data is None:
                    return f"Error creating chunk: {chunk_err or 'unknown'}"
                upload_url = chunk_data.get("upload_url")
                if not upload_url:
                    return f"Error: API did not return upload_url for chunk {chunk_id}."

            # Step 3: Upload file to object storage (external URL, not API)
            file_bytes = await asyncio.to_thread(Path(full_path).read_bytes)
            async with httpx.AsyncClient() as client:
                upload_resp = await client.put(
                    upload_url,
                    content=file_bytes,
                    headers={"Content-Type": "application/octet-stream"},
                    timeout=120.0,
                )
                upload_resp.raise_for_status()

            # Step 4: Finalize
            _fin_data, fin_err = await _agent_api_request(
                ctx, "POST", f"/v1/sessions/{session_id}/finalize", timeout=30.0, attempts=1,
            )
            if fin_err:
                return f"Session uploaded but finalization failed: {fin_err}. Session ID: `{session_id}`"

            return f"Session uploaded and finalized: `{session_id}`"
        except Exception as e:
            return f"Error during upload: {e}"

    async def share_session(
        ctx: RunContext[HikaflowDeps],
        session_id: str,
        expires_days: int = 7,
    ) -> str:
        """Create a shareable link for a session replay.

        Args:
            session_id: The session ID to share.
            expires_days: Number of days before the link expires (default: 7).

        Returns:
            Shareable URL for the session.
        """
        data, err = await _agent_api_request(
            ctx, "POST", f"/v1/sessions/{session_id}/share",
            json_body={"expires_in_days": expires_days}, timeout=30.0, attempts=1,
        )
        if err or data is None:
            return f"Error sharing session: {err or 'unknown'}"
        share_url = data.get("share_url", "N/A")
        return f"Share link created: {share_url}\nExpires in {expires_days} days."

    async def download_session(
        ctx: RunContext[HikaflowDeps],
        session_id: str,
        output_dir: str = ".hikaflow-sessions",
    ) -> str:
        """Download session chunks to a local directory.

        Args:
            session_id: The session ID to download.
            output_dir: Directory to save chunks (relative to project root).

        Returns:
            Download summary with number of chunks saved.
        """
        full_path, err = _check_path_security(ctx, output_dir)
        if err:
            return err

        data, err = await _agent_api_request(
            ctx, "GET", f"/v1/sessions/{session_id}/chunks", timeout=30.0,
        )
        if err or data is None:
            return f"Error fetching chunks: {err or 'unknown'}"
        chunks = data.get("chunks", [])

        if not chunks:
            return f"No chunks found for session {session_id}."

        out_dir = Path(full_path)
        out_dir.mkdir(parents=True, exist_ok=True)
        downloaded = 0

        # Download from external storage URLs (not API)
        async with httpx.AsyncClient() as client:
            for chunk in chunks:
                url = chunk.get("download_url")
                if not url:
                    continue
                stream_name = chunk.get("stream", "unknown")
                chunk_id = chunk.get("chunk_id", "chunk")
                path = out_dir / f"{stream_name}-{chunk_id}.jsonl.zst"
                try:
                    dl_resp = await client.get(url, timeout=60.0)
                    dl_resp.raise_for_status()
                    path.write_bytes(dl_resp.content)
                    downloaded += 1
                except Exception:
                    continue  # skip failed chunk downloads

        return f"Downloaded {downloaded} chunks to `{output_dir}/`"

    # =================================================================
    # Issue Management Tools (4)
    # =================================================================

    async def list_issue_groups(
        ctx: RunContext[HikaflowDeps],
    ) -> str:
        """List issue groups with member counts.

        Returns:
            Table of issue groups with ID, title, member count, and creation date.
        """
        project_id = _get_project_id(ctx)
        if not project_id:
            return "Error: No project linked. Run 'hikaflow init' first."

        data, error = await _agent_api_request(
            ctx,
            "GET",
            "/v1/issue-groups",
            params={"project_id": project_id},
            timeout=30.0,
            attempts=3,
        )
        if error or data is None:
            return f"Error: {error or 'unable to list issue groups'}"
        groups = data.get("groups", data) if isinstance(data, dict) else data

        if not groups:
            return "No issue groups found."

        lines = [f"**Issue Groups ({len(groups)}):**\n"]
        lines.append("| ID | Title | Members | Created |")
        lines.append("|-----|-------|---------|---------|")
        for g in groups:
            gid = str(g.get("id", ""))[:8]
            title = g.get("title", g.get("exception_type", "Unknown"))
            members = g.get("member_count", g.get("error_count", "?"))
            created = str(g.get("created_at", ""))[:10]
            lines.append(f"| `{gid}` | {title} | {members} | {created} |")
        return _sanitize_tool_output("\n".join(lines), tool_name="issue_groups")

    async def group_error(
        ctx: RunContext[HikaflowDeps],
        error_id: str,
    ) -> str:
        """Create or find an issue group for an error.

        Automatically groups the error with similar errors based on
        exception type and stack trace similarity.

        Args:
            error_id: The error ID to group.

        Returns:
            Group ID and grouping result.
        """
        data, err = await _agent_api_request(
            ctx, "POST", f"/v1/prod-errors/{error_id}/group", timeout=30.0, attempts=1,
        )
        if err or data is None:
            return f"Error grouping: {err or 'unknown'}"
        group_id = data.get("group_id", data.get("id", "unknown"))
        action = data.get("action", "grouped")
        return f"Error `{error_id}` {action} into group `{group_id}`"

    async def delete_issue_group(
        ctx: RunContext[HikaflowDeps],
        group_id: str,
    ) -> str:
        """Delete an issue group.

        Args:
            group_id: The group ID to delete.

        Returns:
            Confirmation of deletion.
        """
        data, err = await _agent_api_request(
            ctx, "DELETE", f"/v1/issue-groups/{group_id}", timeout=30.0, attempts=1,
        )
        if err:
            return f"Error deleting group: {err}"
        return f"Issue group `{group_id}` deleted."

    async def merge_issue_groups(
        ctx: RunContext[HikaflowDeps],
        source_group_id: str,
        target_group_id: str,
    ) -> str:
        """Merge one issue group into another.

        Args:
            source_group_id: The group to merge FROM (will be deleted).
            target_group_id: The group to merge INTO (will absorb members).

        Returns:
            Merge result with member count.
        """
        data, err = await _agent_api_request(
            ctx, "POST", f"/v1/issue-groups/{source_group_id}/merge",
            json_body={"target_group_id": target_group_id}, timeout=30.0, attempts=1,
        )
        if err or data is None:
            return f"Error merging groups: {err or 'unknown'}"
        merged = data.get("merged_count", "?")
        return f"Merged group `{source_group_id}` into `{target_group_id}` ({merged} members moved)."

    # =================================================================
    # Pattern Database Tools (5)
    # =================================================================

    async def list_patterns(
        ctx: RunContext[HikaflowDeps],
        limit: int = 20,
        order: str = "success",
        fix_type: Optional[str] = None,
    ) -> str:
        """List stored fix patterns from the local pattern database.

        Args:
            limit: Maximum number of patterns to show.
            order: Sort order: success (by success rate), uses (by usage count), recent.
            fix_type: Filter by fix type (e.g., add_await, add_retry).

        Returns:
            Table of patterns with ID, fix type, exception, success rate, and usage count.
        """
        from autodebug.patterns import get_pattern_store

        loop = asyncio.get_event_loop()
        try:
            store = await loop.run_in_executor(None, get_pattern_store)
            order_map = {"success": "success_count", "uses": "total_uses", "recent": "updated_at"}
            if fix_type:
                patterns = await loop.run_in_executor(
                    None, lambda: store.find_by_fix_type(fix_type, limit=limit)
                )
            else:
                patterns = await loop.run_in_executor(
                    None, lambda: store.list_patterns(limit=limit, order_by=order_map.get(order, "success_count"))
                )
        except Exception as e:
            return f"Error loading patterns: {e}"

        if not patterns:
            return "No patterns stored yet. Patterns are created when fixes are validated."

        lines = [f"**Patterns ({len(patterns)}):**\n"]
        lines.append("| Fix Type | Exception | Success Rate | Uses |")
        lines.append("|----------|-----------|-------------|------|")
        for p in patterns:
            total = p.success_count + p.failure_count
            rate = f"{p.success_rate*100:.0f}%" if total > 0 else "N/A"
            exc = p.exception_signature.exception_type
            lines.append(f"| {p.fix_type} | {exc} | {rate} | {p.total_uses} |")
        return _sanitize_tool_output("\n".join(lines), tool_name="patterns")

    async def get_pattern_stats(
        ctx: RunContext[HikaflowDeps],
    ) -> str:
        """Show pattern database statistics.

        Returns:
            Summary of total patterns, success rates, top exception types, and fix types.
        """
        from autodebug.patterns import get_pattern_store

        loop = asyncio.get_event_loop()
        try:
            store = await loop.run_in_executor(None, get_pattern_store)
            stats = await loop.run_in_executor(None, store.get_stats)
        except Exception as e:
            return f"Error loading pattern stats: {e}"

        lines = [
            "**Pattern Database Statistics:**\n",
            f"- Total patterns: {stats.get('total_patterns')}",
            f"- Total uses: {stats.get('total_uses')}",
            f"- Overall success rate: {stats.get('overall_success_rate'):.1%}",
        ]

        if stats.get("top_exception_types"):
            lines.append("\n**Top Exception Types:**")
            for item in stats.get("top_exception_types")[:5]:
                lines.append(f"- {item.get('type')}: {item.get('count')}")

        if stats.get("top_fix_types"):
            lines.append("\n**Top Fix Types:**")
            for item in stats.get("top_fix_types")[:5]:
                lines.append(f"- {item.get('type')}: {item.get('count')}")

        if stats.get("by_source"):
            lines.append("\n**By Source:**")
            for source, count in stats.get("by_source").items():
                lines.append(f"- {source}: {count}")

        return _sanitize_tool_output("\n".join(lines), tool_name="patterns")

    async def match_patterns(
        ctx: RunContext[HikaflowDeps],
        run_path: str,
        min_similarity: float = 0.5,
        max_results: int = 5,
    ) -> str:
        """Find matching fix patterns for a failure run.

        Args:
            run_path: Path to the run directory (relative to project root).
            min_similarity: Minimum similarity threshold (0.0-1.0).
            max_results: Maximum number of matches to return.

        Returns:
            Matching patterns with similarity scores and suggested fixes.
        """
        full_path, err = _check_path_security(ctx, run_path)
        if err:
            return err

        import json as json_mod
        from autodebug.patterns import find_similar_patterns, format_match_report

        run_dir = Path(full_path)
        if not run_dir.is_dir():
            return f"Error: {run_path} is not a directory."

        # Find exception in the run
        exception = None
        events_path = run_dir / "events.jsonl"
        if events_path.exists():
            for line in events_path.read_text().splitlines():
                try:
                    record = json_mod.loads(line)
                    if record.get("type") == "EXCEPTION":
                        exception = record
                        break
                except json_mod.JSONDecodeError:
                    continue

        result_path = run_dir / "result.json"
        if result_path.exists() and not exception:
            try:
                result_data = json_mod.loads(result_path.read_text())
                if result_data.get("exception"):
                    exception = result_data["exception"]
            except (json_mod.JSONDecodeError, OSError):
                pass

        if not exception:
            return "Error: No exception found in run directory."

        loop = asyncio.get_event_loop()
        try:
            matches = await loop.run_in_executor(
                None,
                lambda: find_similar_patterns(
                    exception_data=exception,
                    min_similarity=min_similarity,
                    max_results=max_results,
                ),
            )
        except Exception as e:
            return f"Error matching patterns: {e}"

        if not matches:
            return "No matching patterns found."

        try:
            report = format_match_report(matches)
            return _sanitize_tool_output(report, tool_name="patterns")
        except Exception:
            lines = [f"**Found {len(matches)} matching patterns:**\n"]
            for i, m in enumerate(matches, 1):
                lines.append(f"{i}. Similarity: {getattr(m, 'similarity', 0):.0%} — {getattr(m, 'fix_type', 'unknown')}")
            return "\n".join(lines)

    async def add_pattern(
        ctx: RunContext[HikaflowDeps],
        run_path: str,
        fix_diff_path: str,
        fix_description: str,
        fix_type: Optional[str] = None,
        tags: Optional[list[str]] = None,
    ) -> str:
        """Add a pattern from a proven fix to the pattern database.

        Args:
            run_path: Path to the run directory containing the exception.
            fix_diff_path: Path to the fix diff file.
            fix_description: Description of what the fix does.
            fix_type: Type of fix (auto-detected if not provided).
            tags: Optional tags for categorizing the pattern.

        Returns:
            Pattern ID and summary of the added pattern.
        """
        run_full, err = _check_path_security(ctx, run_path)
        if err:
            return err
        diff_full, err = _check_path_security(ctx, fix_diff_path)
        if err:
            return err

        import json as json_mod
        from autodebug.patterns import extract_pattern_from_fix, get_pattern_store

        # Find exception
        run_dir = Path(run_full)
        exception = None
        events_path = run_dir / "events.jsonl"
        if events_path.exists():
            for line in events_path.read_text().splitlines():
                try:
                    record = json_mod.loads(line)
                    if record.get("type") == "EXCEPTION":
                        exception = record
                        break
                except json_mod.JSONDecodeError:
                    continue

        result_path = run_dir / "result.json"
        if result_path.exists() and not exception:
            try:
                result_data = json_mod.loads(result_path.read_text())
                if result_data.get("exception"):
                    exception = result_data["exception"]
            except (json_mod.JSONDecodeError, OSError):
                pass

        if not exception:
            return "Error: No exception found in run directory."

        diff_path = Path(diff_full)
        if not diff_path.is_file():
            return f"Error: Diff file not found: {fix_diff_path}"
        diff_content = diff_path.read_text()

        loop = asyncio.get_event_loop()
        try:
            pattern = await loop.run_in_executor(
                None,
                lambda: extract_pattern_from_fix(
                    exception_data=exception,
                    fix_diff=diff_content,
                    fix_description=fix_description,
                    fix_type=fix_type,
                    tags=tags,
                ),
            )
            store = await loop.run_in_executor(None, get_pattern_store)
            pattern_id = await loop.run_in_executor(None, store.add_pattern, pattern)
        except Exception as e:
            return f"Error adding pattern: {e}"

        return (
            f"Pattern added: `{pattern_id}`\n"
            f"- Fix type: {pattern.fix_type}\n"
            f"- Exception: {pattern.exception_signature.exception_type}"
        )

    async def export_patterns(
        ctx: RunContext[HikaflowDeps],
        output_path: str = "patterns.json",
    ) -> str:
        """Export all patterns from the local database to a JSON file.

        Args:
            output_path: Path for the output file (relative to project root).

        Returns:
            Number of patterns exported and file path.
        """
        full_path, err = _check_path_security(ctx, output_path)
        if err:
            return err

        from autodebug.patterns import get_pattern_store

        loop = asyncio.get_event_loop()
        try:
            store = await loop.run_in_executor(None, get_pattern_store)
            count = await loop.run_in_executor(None, store.export_patterns, Path(full_path))
        except Exception as e:
            return f"Error exporting patterns: {e}"

        return f"Exported {count} patterns to `{output_path}`"

    # =================================================================
    # Replay & Export Tools (3)
    # =================================================================

    async def export_transcript(
        ctx: RunContext[HikaflowDeps],
        transcript_path: str,
        fmt: str = "har",
        output_path: Optional[str] = None,
    ) -> str:
        """Export an I/O transcript to HAR, curl, or Postman format.

        Args:
            transcript_path: Path to transcript file (relative to project root).
            fmt: Export format: har, curl, or postman.
            output_path: Optional output file path (relative to project root).

        Returns:
            Export result or the exported content if no output path given.
        """
        full_path, err = _check_path_security(ctx, transcript_path)
        if err:
            return err
        if not os.path.isfile(full_path):
            return f"Error: Transcript file not found: {transcript_path}"

        from autodebug.diff import load_transcript
        from autodebug.export import export_to_curl, export_to_har, export_to_postman

        import json as json_mod

        loop = asyncio.get_event_loop()
        try:
            records = await loop.run_in_executor(None, load_transcript, Path(full_path))
        except Exception as e:
            return f"Error loading transcript: {e}"

        http_records = [r for r in records if r.get("type") == "HTTP"]
        if not http_records:
            return "No HTTP records found in transcript."

        try:
            if fmt == "har":
                result = export_to_har(http_records)
                content = json_mod.dumps(result, indent=2)
            elif fmt == "curl":
                content = export_to_curl(http_records)
            elif fmt == "postman":
                name = Path(full_path).stem
                result = export_to_postman(http_records, collection_name=name)
                content = json_mod.dumps(result, indent=2)
            else:
                return f"Error: Unknown format '{fmt}'. Use: har, curl, postman"
        except Exception as e:
            return f"Error exporting: {e}"

        if output_path:
            out_full, err = _check_path_security(ctx, output_path)
            if err:
                return err
            Path(out_full).write_text(content, encoding="utf-8")
            return f"Exported {len(http_records)} HTTP records to `{output_path}` ({fmt} format)"

        # Truncate for display if very large
        if len(content) > 5000:
            content = content[:5000] + "\n... [truncated]"
        return f"**{fmt.upper()} Export ({len(http_records)} records):**\n```\n{content}\n```"

    async def minimize_transcript(
        ctx: RunContext[HikaflowDeps],
        harness_path: str,
        transcript_path: str,
        method: str = "hierarchical",
        output_path: Optional[str] = None,
    ) -> str:
        """Delta-debug a transcript to find the smallest failing case.

        Uses delta debugging to systematically remove records while
        preserving the failure, finding the minimal reproduction.

        Args:
            harness_path: Path to the repro harness (relative to project root).
            transcript_path: Path to transcript file (relative to project root).
            method: Minimization method: ddmin, hierarchical, or causal.
            output_path: Optional path to save the minimal transcript.

        Returns:
            Minimization report with original/minimal record counts and reduction percentage.
        """
        harness_full, err = _check_path_security(ctx, harness_path)
        if err:
            return err
        transcript_full, err = _check_path_security(ctx, transcript_path)
        if err:
            return err

        from autodebug.diff import load_transcript
        from autodebug.minimize import format_minimization_report
        from autodebug.minimize import minimize_transcript as _minimize

        try:
            transcript = await _run_in_executor_with_timeout(
                lambda: load_transcript(Path(transcript_full)),
                timeout=30.0,
                timeout_msg="__TIMEOUT__",
            )
            if transcript == "__TIMEOUT__":
                return "Error: loading transcript timed out (30s)"
        except Exception as e:
            return f"Error loading transcript: {e}"

        try:
            result = await _run_in_executor_with_timeout(
                lambda: _minimize(transcript, Path(harness_full), method=method),
                timeout=300.0,
                timeout_msg="__TIMEOUT__",
            )
            if result == "__TIMEOUT__":
                return "Error: minimization timed out (5 min). Try a smaller transcript or 'ddmin' method."
        except Exception as e:
            return f"Error during minimization: {e}"

        report = format_minimization_report(result)

        if output_path and "minimal_transcript" in result:
            import json as json_mod
            out_full, err = _check_path_security(ctx, output_path)
            if err:
                return err
            def _write_minimized_transcript() -> None:
                with open(out_full, "w", encoding="utf-8") as f:
                    for record in result.get("minimal_transcript"):
                        f.write(json_mod.dumps(record, sort_keys=True) + "\n")

            await asyncio.to_thread(_write_minimized_transcript)
            report += f"\nMinimal transcript saved to `{output_path}`"

        return _sanitize_tool_output(report, tool_name="transcript")

    async def optimize_bundle(
        ctx: RunContext[HikaflowDeps],
        bundle_path: str,
        output_path: Optional[str] = None,
    ) -> str:
        """Compress and deduplicate a replay bundle.

        Args:
            bundle_path: Path to the bundle directory (relative to project root).
            output_path: Optional output path for optimized bundle.

        Returns:
            Optimization report with size reduction.
        """
        full_path, err = _check_path_security(ctx, bundle_path)
        if err:
            return err

        try:
            from autodebug.optimize import optimize_bundle as _optimize
        except ImportError:
            return "Error: optimize module not available."

        loop = asyncio.get_event_loop()
        try:
            out_path = Path(output_path) if output_path else None
            if out_path:
                out_full, err = _check_path_security(ctx, output_path)
                if err:
                    return err
                out_path = Path(out_full)

            result = await _run_in_executor_with_timeout(
                lambda: _optimize(Path(full_path), output=out_path),
                timeout=120.0,
                timeout_msg="__TIMEOUT__",
            )
            if result == "__TIMEOUT__":
                return "Error: bundle optimization timed out (120s). Try a smaller bundle."
        except Exception as e:
            return f"Error optimizing bundle: {e}"

        original = result.get("original_size", 0)
        optimized = result.get("optimized_size", 0)
        reduction = (1 - optimized / original) * 100 if original > 0 else 0
        return (
            f"**Bundle Optimized:**\n"
            f"- Original: {original:,} bytes\n"
            f"- Optimized: {optimized:,} bytes\n"
            f"- Reduction: {reduction:.1f}%"
        )

    # =================================================================
    # AI & Monitoring Tools (4)
    # =================================================================

    @agent.tool
    async def generate_tests(
        ctx: RunContext[HikaflowDeps],
        source_path: str,
        max_tests: int = 5,
    ) -> str:
        """AI-powered test generation for Python files.

        Analyzes the source code and generates pytest-compatible tests
        using an LLM (requires ANTHROPIC_API_KEY or OPENAI_API_KEY).

        Args:
            source_path: Path to the Python file to generate tests for.
            max_tests: Maximum number of tests per function to generate (1-20).

        Returns:
            Generated test code ready to save or review.
        """
        full_path, err = _check_path_security(ctx, source_path)
        if err:
            return err
        if os.path.isdir(full_path):
            return f"Error: '{source_path}' is a directory. Provide a specific Python file path."
        if not os.path.isfile(full_path):
            return f"Error: File not found: {source_path}"

        try:
            from autodebug.autonomous.test_generator import generate_tests as _gen_tests
        except ImportError:
            return "Error: test_generator module not available. Install with: pip install autodebug[ai]"

        max_tests = max(1, min(max_tests, 20))

        try:
            # Use single strategy ("happy_path") from agent to avoid N+1 LLM
            # calls per function × 3 strategies which causes timeouts.
            result = await _run_in_executor_with_timeout(
                lambda: _gen_tests(
                    Path(full_path),
                    strategies=["happy_path"],
                    max_tests_per_function=max_tests,
                ),
                timeout=90.0,
                timeout_msg=f"Error: Test generation timed out after 90s for {source_path}. The LLM API may be slow or unreachable.",
            )
            if isinstance(result, str) and result.startswith("Error:"):
                return result
        except Exception as e:
            return f"Error generating tests: {e}"

        # TestSuite has .to_file_content() that renders the full test file
        if hasattr(result, "to_file_content"):
            code = result.to_file_content()
        elif isinstance(result, str):
            code = result
        else:
            code = str(result)
        return _sanitize_tool_output(f"**Generated Tests for `{source_path}`:**\n\n```python\n{code}\n```", tool_name="generate_tests")

    # Removed from agent tools (use CLI 'hikaflow watch' command instead)
    async def watch_errors(
        ctx: RunContext[HikaflowDeps],
        since_minutes: int = 60,
        limit: int = 20,
    ) -> str:
        """Check for new production errors since a given time.

        Args:
            since_minutes: Look back this many minutes (default: 60).
            limit: Maximum errors to return.

        Returns:
            New errors since the cutoff time, or confirmation that none occurred.
        """
        from datetime import datetime, timedelta, timezone
        cutoff = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
        cutoff_iso = cutoff.isoformat()

        result, err = await _agent_api_request(
            ctx, "GET", "/v1/prod-errors",
            params={"per_page": limit, "since": cutoff_iso, "environment": ctx.deps.environment},
            timeout=30.0,
        )
        if err or result is None:
            return f"Error fetching errors: {err or 'unknown'}"
        errors = result.get("errors", [])

        if not errors:
            return f"No new errors in the last {since_minutes} minutes."

        lines = [f"**{len(errors)} new error(s) in the last {since_minutes} minutes:**\n"]
        lines.append("| ID | Error | Time |")
        lines.append("|-----|-------|------|")
        for e in errors:
            eid = e.get("error_id", "?")
            exc = e.get("exception_type", "Unknown")
            msg = (e.get("exception_message", "") or "")[:40]
            time_str = str(e.get("created_at", ""))[:19]
            lines.append(f"| `{eid}` | {exc}: {msg} | {time_str} |")
        return _sanitize_tool_output("\n".join(lines), tool_name="fetch_production_errors")

    # Removed from agent tools (absorbed by scan_codebase)
    async def get_project_status(
        ctx: RunContext[HikaflowDeps],
    ) -> str:
        """Show project status including config, recent errors summary, and health.

        Returns:
            Project overview with configuration, error counts, and status.
        """
        try:
            from autodebug.auth import get_credentials, load_config
            config = load_config()
            creds = get_credentials()
        except Exception:
            config = {}
            creds = {}

        project_id = creds.get("project_id", "Not configured")
        user = creds.get("name") or creds.get("email") or creds.get("user_id")
        # Try to extract email from JWT token if not in creds
        if not user:
            try:
                import base64, json as _json
                token = creds.get("access_token") or creds.get("token") or ""
                if token and "." in token:
                    payload = token.split(".")[1]
                    # Add padding
                    payload += "=" * (4 - len(payload) % 4)
                    data = _json.loads(base64.urlsafe_b64decode(payload))
                    user = data.get("email") or data.get("sub") or data.get("name")
            except Exception:
                pass
        user = user or "Authenticated (name unavailable)"
        api_base = config.get("api_base", ctx.deps.api_base)
        model = os.getenv("HIKAFLOW_MODEL", ORCHESTRATOR_MODEL)

        # Show ~/relative path instead of absolute
        project_path = ctx.deps.project_path
        try:
            project_path = f"~/{Path(project_path).relative_to(Path.home())}"
        except (ValueError, TypeError):
            pass

        lines = [
            "**Project Status:**\n",
            f"- User: {user}",
            f"- Project ID: `{project_id}`",
            f"- API: {api_base}",
            f"- Model: {model}",
            f"- Project path: `{project_path}`",
        ]

        # Try to get recent error count
        data, err = await _agent_api_request(
            ctx, "GET", "/v1/prod-errors", params={"per_page": 1}, timeout=10.0,
        )
        if err or data is None:
            lines.append("- API: Unreachable")
        else:
            total = data.get("total", len(data.get("errors", [])))
            lines.append(f"- Total errors: {total}")
            lines.append("- API: Connected")

        return "\n".join(lines)

    @agent.tool
    async def scan_codebase(
        ctx: RunContext[HikaflowDeps],
        force: bool = False,
        mode: str = "full",
    ) -> str:
        """Comprehensive codebase scan — runs Semgrep + diagnostics in parallel.

        Runs parallel checks based on mode selection and returns a consolidated report.

        Args:
            force: If True, bypass the scan cache and run a fresh scan.
            mode: Scan mode selector. Options:
                - "full": All 12 checks (default)
                - "semgrep-only": Semgrep static analysis only
                - "security-only": Security-focused checks (semgrep ERROR, taint)
                - "taint-only": Taint flow analysis only
                - "hygiene-only": Repo hygiene, dependencies, environment
                - "api-only": API-connected checks (production errors, issue groups, sessions)

        Returns:
            Combined scan report with findings from selected checks.
        """
        import asyncio as _aio

        from autodebug.debug_log import debug as _dbg
        try:
            from api.metrics import inc as _metrics_inc
        except Exception:
            _metrics_inc = None  # type: ignore[assignment]

        def _inc(metric: str, value: int = 1) -> None:
            if _metrics_inc is None:
                return
            try:
                _metrics_inc(metric, value)
            except Exception:
                pass

        _inc("scan.codebase.runs")

        # Validate mode parameter — supports both canonical names and spec aliases
        _mode_aliases = {
            "quick": "semgrep-only",
            "security": "security-only",
            "deep": "full",
            "async": "api-only",
        }
        valid_modes = {"full", "semgrep-only", "security-only", "taint-only", "hygiene-only", "api-only"}
        normalized_mode = (mode or "full").strip().lower()
        normalized_mode = _mode_aliases.get(normalized_mode, normalized_mode)
        if normalized_mode not in valid_modes:
            return f"Error: Invalid mode '{mode}'. Valid modes: {', '.join(sorted(valid_modes | set(_mode_aliases.keys())))}"

        # Mode-based check selection
        mode_check_map = {
            "full": ["Project", "Production Errors", "Issue Groups", "Flaky Patterns",
                     "Environment", "Pattern Database", "Quarantine", "Sessions",
                     "Repo Hygiene", "Semgrep", "Taint Analysis", "Dependency Audit"],
            "semgrep-only": ["Project", "Semgrep"],
            "security-only": ["Project", "Semgrep", "Taint Analysis"],
            "taint-only": ["Project", "Taint Analysis"],
            "hygiene-only": ["Project", "Repo Hygiene", "Dependency Audit", "Environment"],
            "api-only": ["Project", "Production Errors", "Issue Groups", "Sessions"],
        }
        allowed_checks = set(mode_check_map.get(normalized_mode, mode_check_map["full"]))

        # Load feedback-adjusted rule precision for this project
        global _feedback_precision_cache, _feedback_precision_project
        if _feedback_precision_project != ctx.deps.project_path:
            _feedback_precision_cache = _load_feedback_precision(ctx.deps.project_path)
            _feedback_precision_project = ctx.deps.project_path

        # Return cached results if already scanned this session (unless force=True)
        cache_key = ctx.deps.project_path
        cached = _scan_cache.get(cache_key) if not force else None
        if force:
            _scan_cache.pop(cache_key, None)
        if cached is not None:
            if isinstance(cached, dict):
                _dbg("CACHE_HIT | scan_codebase (full report)")
                return cached["report"]
            else:
                _dbg("CACHE_HIT | scan_codebase → already scanned")
                return "Already scanned. Use review_files or read_file to investigate specific findings."

        # Pre-flight context check
        _preflight_warnings: list[str] = []
        project_path = ctx.deps.project_path
        if not os.path.isdir(project_path):
            return f"Error: Project path does not exist: {project_path}"
        # Check for minimum viable project structure
        _has_python = any(
            f.endswith(".py") for f in os.listdir(project_path) if os.path.isfile(os.path.join(project_path, f))
        ) or any(
            os.path.isdir(os.path.join(project_path, d)) for d in ("api", "autodebug", "worker", "src")
        )
        _has_node = os.path.isfile(os.path.join(project_path, "package.json")) or os.path.isdir(os.path.join(project_path, "web"))
        if not _has_python and not _has_node:
            _preflight_warnings.append("No Python or Node.js project structure detected — scan may find nothing.")
        # Validate .semgrep.yaml if present
        _semgrep_yaml = os.path.join(project_path, ".semgrep.yaml")
        if os.path.isfile(_semgrep_yaml):
            try:
                _validate_result = await _run_in_executor_with_timeout(
                    lambda: subprocess.run(
                        ["semgrep", "--validate", "--config", _semgrep_yaml],
                        capture_output=True, text=True, timeout=10,
                    ),
                    timeout=12.0,
                    timeout_msg=None,
                )
                if _validate_result is not None and _validate_result.returncode != 0:
                    _preflight_warnings.append(
                        f".semgrep.yaml validation failed: {(_validate_result.stderr or _validate_result.stdout or '')[:200]}"
                    )
            except Exception:
                pass

        # Structured scan metadata used for deterministic recommendation logic.
        scan_meta: ScanMeta = {}

        async def _project_status():
            from autodebug.repo_utils import _normalize_repo_slug

            def _detect_repo_slug() -> str | None:
                try:
                    for name in ("origin", "upstream"):
                        result = subprocess.run(
                            ["git", "remote", "get-url", name],
                            capture_output=True, text=True, check=False,
                            cwd=ctx.deps.project_path, timeout=3,
                        )
                        if result.returncode == 0:
                            slug = _normalize_repo_slug(result.stdout.strip())
                            if slug:
                                return slug
                except Exception:
                    pass
                return None

            try:
                from autodebug.auth import get_credentials
                creds = get_credentials() or {}
            except Exception:
                creds = {}
            project_id = creds.get("project_id")
            repo_slug = creds.get("repo_slug") or _detect_repo_slug()
            project_reason = None if project_id else "No project configured (scan is read-only; run setup/link explicitly)"
            if not project_id:
                project_id = "Not configured"
            user = creds.get("name") or creds.get("email") or creds.get("user_id")
            if not user:
                try:
                    import base64, json as _json
                    token = creds.get("access_token") or creds.get("token") or ""
                    if token and "." in token:
                        payload = token.split(".")[1]
                        payload += "=" * (4 - len(payload) % 4)
                        data = _json.loads(base64.urlsafe_b64decode(payload))
                        user = data.get("email") or data.get("sub") or data.get("name")
                except Exception:
                    pass
            user = user or "Unknown"
            if project_reason and project_id == "Not configured":
                if repo_slug:
                    scan_meta["project"] = {
                        "configured": False,
                        "repo_slug": repo_slug,
                        "reason": project_reason,
                    }
                    return (
                        f"User: {user} | Project: `{project_id}` ({project_reason}) | "
                        f"Repo: `{repo_slug}` | Path: `{ctx.deps.project_path}`"
                    )
                scan_meta["project"] = {
                    "configured": False,
                    "repo_slug": None,
                    "reason": project_reason,
                }
                return f"User: {user} | Project: `{project_id}` ({project_reason}) | Path: `{ctx.deps.project_path}`"
            scan_meta["project"] = {
                "configured": project_id != "Not configured",
                "repo_slug": repo_slug,
                "reason": None,
            }
            return f"User: {user} | Project: `{project_id}` | Path: `{ctx.deps.project_path}`"

        async def _api_get(
            path: str,
            *,
            params: Optional[Dict[str, Any]] = None,
            timeout: float = 15.0,
            attempts: int = 3,
        ) -> tuple[Optional[dict], Optional[str]]:
            """Thin wrapper over _agent_api_request for scan API calls."""
            data, err = await _agent_api_request(
                ctx, "GET", path, params=params, timeout=timeout, attempts=attempts,
            )
            if err or data is None:
                _inc("scan.api.error")
                _dbg(f"SCAN_API_ERROR | {path} error={err}")
                return None, err or "unknown error"
            if isinstance(data, dict):
                return data, None
            return {"items": data}, None

        async def _production_errors():
            if not _get_project_id(ctx):
                scan_meta["production_errors"] = {
                    "ok": False,
                    "total": 0,
                    "count": 0,
                    "error": "NO_PROJECT_LINKED",
                }
                return "Skipped: No project linked. Run `hikaflow init --project-id <id>` first."
            data, error = await _api_get("/v1/prod-errors", params={"per_page": 20}, timeout=15.0, attempts=3)
            if error or data is None:
                _inc("scan.api.prod_errors.failed")
                scan_meta["production_errors"] = {
                    "ok": False,
                    "total": 0,
                    "count": 0,
                    "error": error or "unknown error",
                }
                return f"Could not fetch: {error or 'unknown error'}"
            errors = data.get("errors", [])
            total = data.get("total", len(errors))
            scan_meta["production_errors"] = {
                "ok": True,
                "total": total,
                "count": len(errors),
                "error": None,
            }
            if not errors:
                return "No production errors found."
            lines = [f"**{total} total error(s)** — showing {len(errors)} most recent:"]
            for e in errors[:10]:
                exc = e.get("exception_type", "Unknown")
                msg = (e.get("exception_message", "") or "")[:60]
                count = e.get("breadcrumb_count", 1)
                lines.append(f"  - {exc}: {msg} (x{count})")
            if len(errors) > 10:
                lines.append(f"  ... and {len(errors) - 10} more")
            return "\n".join(lines)

        async def _issue_groups():
            if not _get_project_id(ctx):
                scan_meta["issue_groups"] = {
                    "ok": False,
                    "count": 0,
                    "error": "NO_PROJECT_LINKED",
                }
                return "Skipped: No project linked. Run `hikaflow init --project-id <id>` first."
            data, error = await _api_get("/v1/issue-groups", timeout=15.0, attempts=3)
            if error or data is None:
                _inc("scan.api.issue_groups.failed")
                scan_meta["issue_groups"] = {
                    "ok": False,
                    "count": 0,
                    "error": error or "unknown error",
                }
                return f"Could not fetch: {error or 'unknown error'}"
            groups = data.get("groups", data.get("items", []))
            scan_meta["issue_groups"] = {
                "ok": True,
                "count": len(groups),
                "error": None,
            }
            if not groups:
                return "No issue groups."
            lines = [f"{len(groups)} group(s):"]
            for g in groups[:10]:
                title = g.get("title", g.get("exception_type", "Unknown"))
                members = g.get("member_count", g.get("error_count", "?"))
                lines.append(f"  - {title} ({members} errors)")
            return "\n".join(lines)

        async def _flaky_patterns():
            from autodebug.healing.detector import (
                analyze_flakiness_risk,
                detect_flaky_patterns as _detect,
                detect_in_changed_files as _detect_changed,
            )
            try:
                # Scan only files changed since main (fast, <10s)
                # Never scan the whole tests/ directory — it always times out on large codebases
                project_path = Path(ctx.deps.project_path)
                result = await _run_in_executor_with_timeout(
                    lambda: _detect_changed(project_path),
                    timeout=10.0,
                    timeout_msg=None,
                )
                if result is None or (hasattr(result, 'files_scanned') and result.files_scanned == 0):
                    scan_meta["flaky_patterns"] = {
                        "ok": True,
                        "matches": 0,
                        "top_file": None,
                        "risk_level": None,
                        "reason": "no_changed_tests",
                        "error": None,
                    }
                    return "No recently changed test files. Use `detect_flaky_patterns('tests/<specific_file>')` for targeted analysis."
                if isinstance(result, str):  # timeout message
                    scan_meta["flaky_patterns"] = {
                        "ok": False,
                        "matches": 0,
                        "top_file": None,
                        "risk_level": None,
                        "reason": "timeout",
                        "error": None,
                    }
                    return result
                risk = analyze_flakiness_risk(result)
                # Filter out likely false positives (replay test files, low confidence)
                _fp_keywords = {"replay", "recorder", "deterministic", "rng_replay", "time_replay"}
                filtered = [
                    m for m in result.matches
                    if m.confidence >= 0.70 and not any(kw in os.path.basename(m.file_path).lower() for kw in _fp_keywords)
                ]
                fp_count = len(result.matches) - len(filtered)
                summary = f"Risk: **{risk['risk_level'].upper()}** (score: {risk['risk_score']})"
                if filtered:
                    summary += f" — {len(filtered)} pattern match(es)"
                    if fp_count:
                        summary += f" ({fp_count} false positives filtered)"
                    project_root = ctx.deps.project_path
                    for m in filtered[:15]:
                        rel = os.path.relpath(m.file_path, project_root)
                        summary += f"\n  - `{rel}:{m.line_number}` — {m.pattern.name}"
                elif result.matches:
                    summary += f" — {len(result.matches)} matches (all filtered as likely false positives)"
                if risk.get("recommendations"):
                    summary += "\n  Recommendations:"
                    for rec in risk["recommendations"][:3]:
                        summary += f"\n  - {rec}"
                top_file = None
                if filtered:
                    top_file = os.path.relpath(filtered[0].file_path, project_root)
                scan_meta["flaky_patterns"] = {
                    "ok": True,
                    "matches": len(filtered),
                    "top_file": top_file,
                    "risk_level": risk.get("risk_level"),
                    "reason": None,
                    "error": None,
                }
                return summary
            except Exception as ex:
                scan_meta["flaky_patterns"] = {
                    "ok": False,
                    "matches": 0,
                    "top_file": None,
                    "risk_level": None,
                    "reason": "exception",
                    "error": str(ex),
                }
                return f"Could not analyze: {ex}"

        async def _environment():
            from autodebug.doctor import run_doctor

            try:
                results = await _run_in_executor_with_timeout(
                    run_doctor,
                    timeout=15.0,
                    timeout_msg="Timed out (15s)",
                )
                if isinstance(results, str):  # timeout message
                    return results

                checks = results.get("checks", [])
                failed = [c for c in checks if not c.passed and not c.warning]
                warnings = [c for c in checks if c.warning]
                passed = [c for c in checks if c.passed and not c.warning]
                parts = [f"{len(passed)} passed"]
                if failed:
                    parts.append(f"**{len(failed)} failed**: " + ", ".join(c.message for c in failed))
                if warnings:
                    parts.append(f"{len(warnings)} warning(s)")
                return " | ".join(parts)
            except Exception as ex:
                return f"Could not run: {ex}"

        async def _pattern_stats():
            from autodebug.patterns import get_pattern_store
            try:
                store = await _run_in_executor_with_timeout(
                    get_pattern_store,
                    timeout=10.0,
                    timeout_msg="Timed out loading pattern store",
                )
                if isinstance(store, str):  # timeout message
                    return store
                loop = _aio.get_event_loop()
                stats = await loop.run_in_executor(None, store.get_stats)
                total = stats.get("total_patterns", 0)
                if total == 0:
                    return "No patterns stored yet."
                rate = stats.get("overall_success_rate", 0)
                uses = stats.get("total_uses", 0)
                return f"{total} patterns | {rate:.0%} success rate | {uses} total uses"
            except Exception:
                return "No pattern database found."

        async def _quarantine():
            from autodebug.flaky_quarantine import list_quarantined_tests
            try:
                qpath = Path(ctx.deps.project_path) / ".flaky-quarantine"
                tests = await _run_in_executor_with_timeout(
                    lambda: list_quarantined_tests(qpath),
                    timeout=10.0,
                    timeout_msg="Timed out reading quarantine file",
                )
                if isinstance(tests, str):  # timeout message
                    return tests
                if not tests:
                    scan_meta["quarantine"] = {"ok": True, "count": 0, "error": None}
                    return "No quarantined tests."
                scan_meta["quarantine"] = {"ok": True, "count": len(tests), "error": None}
                return f"{len(tests)} test(s) quarantined: " + ", ".join(
                    f"`{t.nodeid}`" for t in tests[:5]
                )
            except Exception:
                scan_meta["quarantine"] = {"ok": False, "count": 0, "error": "No quarantine file found."}
                return "No quarantine file found."

        async def _sessions():
            if not _get_project_id(ctx):
                scan_meta["sessions"] = {"ok": False, "count": 0, "error": "NO_PROJECT_LINKED"}
                return "Skipped: No project linked. Run `hikaflow init --project-id <id>` first."
            data, error = await _api_get("/v1/sessions", params={"limit": 5}, timeout=15.0, attempts=3)
            if error or data is None:
                _inc("scan.api.sessions.failed")
                scan_meta["sessions"] = {"ok": False, "count": 0, "error": error or "unknown error"}
                return f"Could not fetch sessions: {error or 'unknown error'}"
            sessions = data.get("sessions", data.get("items", []))
            scan_meta["sessions"] = {"ok": True, "count": len(sessions), "error": None}
            if not sessions:
                return "No sessions recorded."
            return f"{len(sessions)} recent session(s)."

        async def _repo_hygiene():
            """Check for untracked artifacts and migrations that should be reviewed."""
            try:
                project_path = ctx.deps.project_path
                result = await _run_in_executor_with_timeout(
                    lambda: subprocess.run(
                        ["git", "status", "--porcelain"],
                        capture_output=True, text=True, cwd=project_path, timeout=10,
                    ),
                    timeout=12.0,
                    timeout_msg=None,
                )
                if result is None or result.returncode != 0:
                    return "Not a git repo or unable to read status."
                lines = [l.strip() for l in result.stdout.splitlines() if l.strip()]
                if not lines:
                    return "Working tree clean."

                untracked = [l[3:] for l in lines if l.startswith("?? ")]
                tracked = [l[3:] for l in lines if not l.startswith("?? ")]
                notes = []
                if any(p.startswith("migrations/") and p.endswith(".sql") for p in untracked):
                    notes.append("Untracked migration(s) found — review and commit or remove.")
                if any(p.startswith("screenshots/") for p in untracked):
                    notes.append("Untracked screenshots — consider ignoring artifacts.")
                if any(p.startswith("dogfood-results/") for p in untracked):
                    notes.append("Untracked dogfood results — consider ignoring artifacts.")
                if any(p.startswith(".agents/") for p in untracked):
                    notes.append("Untracked .agents/ — consider ignoring editor artifacts.")
                if any(p.endswith(".tsbuildinfo") for p in tracked + untracked):
                    notes.append("TypeScript build artifacts detected (.tsbuildinfo) — ignore and untrack from git.")
                scan_meta["repo_hygiene"] = {
                    "ok": True,
                    "changes": len(lines),
                    "has_tsbuildinfo": any(p.endswith(".tsbuildinfo") for p in tracked + untracked),
                    "untracked": len(untracked),
                    "error": None,
                }

                summary = f"{len(lines)} change(s) in working tree."
                if notes:
                    summary += " " + " ".join(notes)
                return summary
            except Exception as ex:
                scan_meta["repo_hygiene"] = {
                    "ok": False,
                    "changes": 0,
                    "has_tsbuildinfo": False,
                    "untracked": 0,
                    "error": str(ex),
                }
                return f"Unable to read repo status: {ex}"

        async def _semgrep_scan():
            """Run Semgrep security/quality scan. Language-agnostic, deterministic."""
            project_path = ctx.deps.project_path

            # Read Semgrep settings from server config (or local fallback)
            try:
                from autodebug.agent.core import get_agent_config
                _sem_cfg = get_agent_config().get("semgrep", {})
            except Exception:
                _sem_cfg = {}
            _sg_fast_config = _sem_cfg.get("fast_config", ["p/ci"])
            _sg_deep_config = _sem_cfg.get("deep_config", ["p/default", "p/typescript", "p/react"])
            _sg_max_bytes = _sem_cfg.get("max_target_bytes", 300000)
            _sg_timeout = _sem_cfg.get("timeout_seconds", 120)
            _sg_exec_timeout = _sem_cfg.get("executor_timeout_seconds", 180)
            _sg_file_timeout = _sem_cfg.get("file_timeout_seconds", 20)
            _sg_timeout_threshold = _sem_cfg.get("timeout_threshold", 5)
            _sg_exclude = _sem_cfg.get("exclude", [
                "node_modules", ".git", ".next", "__pycache__",
                "*.min.js", "*.lock", "*.map", ".venv", "venv",
                "dist", "build", "coverage", "htmlcov",
                ".mypy_cache", ".ruff_cache", ".pytest_cache",
                ".turbo", ".autodebug", ".hikaflow",
            ])
            _sg_max_files = _sem_cfg.get("max_files", 15000)
            _sg_max_changed_files = _sem_cfg.get("max_changed_files", 300)
            _sg_target_roots = _sem_cfg.get("target_roots", [
                "api", "worker", "autodebug", "web", "mcp_server", "hikaflow",
                "scripts", "ide",
            ])
            _sg_jobs = _sem_cfg.get("jobs", 4)
            _sg_cache_enabled = _sem_cfg.get("cache", True)
            _sg_cache_dir = _sem_cfg.get("cache_dir", "~/.cache/semgrep/hikaflow")
            _sg_include_exts = _sem_cfg.get("include_exts", [
                ".py", ".pyi",
                ".js", ".jsx", ".ts", ".tsx",
                ".go", ".rs", ".java", ".kt",
                ".c", ".cc", ".cpp", ".h", ".hpp",
                ".rb", ".php",
            ])

            def _semgrep_supports_cache() -> bool:
                try:
                    help_text = subprocess.run(
                        ["semgrep", "scan", "--help"],
                        capture_output=True, text=True, timeout=5,
                        env=semgrep_env,
                    )
                    if help_text.returncode != 0:
                        return False
                    return "--cache" in help_text.stdout
                except Exception:
                    return False

            def _merge_configs(configs: list[str], custom_only: bool = False) -> list[str]:
                semgrep_yaml = os.path.join(project_path, ".semgrep.yaml")
                if custom_only and os.path.isfile(semgrep_yaml):
                    # Use only project-local rules to avoid timeout from
                    # loading multiple large rule packs simultaneously.
                    return [semgrep_yaml]
                merged = []
                for c in configs:
                    if c and c not in merged:
                        merged.append(c)
                if os.path.isfile(semgrep_yaml) and semgrep_yaml not in merged:
                    merged.append(semgrep_yaml)
                return merged

            def _filter_files(files: list[str]) -> list[str]:
                filtered = []
                for f in files:
                    if not f:
                        continue
                    if any(f.endswith(ext) for ext in _sg_include_exts):
                        filtered.append(f)
                return filtered

            def _changed_files() -> list[str]:
                # Best-effort diff-aware targeting
                try:
                    base = None
                    mb = subprocess.run(
                        ["git", "merge-base", "HEAD", "origin/main"],
                        capture_output=True, text=True, cwd=project_path, timeout=3,
                    )
                    if mb.returncode == 0:
                        base = mb.stdout.strip()
                    if not base:
                        mb = subprocess.run(
                            ["git", "merge-base", "HEAD", "origin/master"],
                            capture_output=True, text=True, cwd=project_path, timeout=3,
                        )
                        if mb.returncode == 0:
                            base = mb.stdout.strip()
                    if base:
                        diff = subprocess.run(
                            ["git", "diff", "--name-only", "--diff-filter=ACMRTUXB", base, "HEAD"],
                            capture_output=True, text=True, cwd=project_path, timeout=5,
                        )
                        if diff.returncode == 0:
                            files = [f for f in diff.stdout.splitlines() if f]
                            return _filter_files(files)
                except Exception:
                    pass
                try:
                    diff = subprocess.run(
                        ["git", "diff", "--name-only", "--diff-filter=ACMRTUXB", "HEAD~1", "HEAD"],
                        capture_output=True, text=True, cwd=project_path, timeout=5,
                    )
                    if diff.returncode == 0:
                        files = [f for f in diff.stdout.splitlines() if f]
                        return _filter_files(files)
                except Exception:
                    pass
                try:
                    status = subprocess.run(
                        ["git", "status", "--porcelain"],
                        capture_output=True, text=True, cwd=project_path, timeout=5,
                    )
                    if status.returncode == 0:
                        files = []
                        for line in status.stdout.splitlines():
                            path = line[3:].strip()
                            if path:
                                files.append(path)
                        return _filter_files(files)
                except Exception:
                    pass
                return []

            def _is_large_repo(root: str, max_files: int) -> bool:
                count = 0
                for base, dirs, files in os.walk(root):
                    # prune excluded dirs
                    dirs[:] = [d for d in dirs if d not in _sg_exclude]
                    count += len(files)
                    if count > max_files:
                        return True
                return False

            try:
                # If repo is large, limit scan scope to key roots
                targets = [project_path]
                if _is_large_repo(project_path, _sg_max_files):
                    targets = []
                    for name in _sg_target_roots:
                        p = os.path.join(project_path, name)
                        if os.path.isdir(p):
                            targets.append(p)
                    if not targets:
                        targets = [project_path]
                    # Per-shard timeouts for large repos — each target dir gets
                    # its own subprocess, so individual timeouts can be tighter
                    # while still completing the full scan.
                    _sg_timeout = min(_sg_timeout, 90)
                    _sg_exec_timeout = min(_sg_exec_timeout, 120)

                findings = []
                timeouts = []
                errors = []

                def _parse_semgrep_output(stdout: str, stderr: str) -> tuple[list[dict], Optional[str]]:
                    data = _extract_json_object(stdout)
                    if data is not None:
                        return data.get("results", []), None
                    # Some semgrep builds emit JSON to stderr or mix logs into stdout.
                    data = _extract_json_object(stderr)
                    if data is not None:
                        return data.get("results", []), None
                    return [], "Failed to parse Semgrep output"

                # Fast tier: diff-aware scan (PR-like)
                fast_configs = _merge_configs(_sg_fast_config)
                changed = _changed_files()
                semgrep_env = os.environ.copy()
                semgrep_env.setdefault("PYDANTIC_DISABLE_PLUGINS", "1")
                semgrep_env.setdefault("LOGFIRE_PYDANTIC_PLUGIN_DISABLED", "1")
                cache_supported = _semgrep_supports_cache()
                if 0 < len(changed) <= _sg_max_changed_files:
                    _sg_cmd_fast = [
                        "semgrep", "scan", "--json", "--quiet",
                        f"--max-target-bytes={_sg_max_bytes}",
                        f"--jobs={_sg_jobs}",
                        f"--timeout={_sg_file_timeout}",
                        f"--timeout-threshold={_sg_timeout_threshold}",
                    ]
                    if _sg_cache_enabled and cache_supported:
                        _sg_cmd_fast.extend(["--cache", f"--cache-dir={_sg_cache_dir}"])
                    for c in fast_configs:
                        _sg_cmd_fast.extend(["--config", c])
                    for pat in _sg_exclude:
                        _sg_cmd_fast.extend(["--exclude", pat])
                    _sg_cmd_fast.extend(changed)

                    result = await _run_in_executor_with_timeout(
                        lambda: subprocess.run(
                            _sg_cmd_fast,
                            capture_output=True, text=True, timeout=_sg_timeout,
                            env=semgrep_env,
                        ),
                        timeout=float(_sg_exec_timeout),
                        timeout_msg=None,
                    )
                    if result is None:
                        timeouts.append("changed-files")
                    elif result.returncode not in (0, 1):
                        parsed, perr = _parse_semgrep_output(result.stdout, result.stderr)
                        if parsed:
                            findings.extend(parsed)
                        else:
                            msg = (result.stderr or result.stdout or "").strip()
                            if not msg:
                                msg = f"exit code {result.returncode}"
                            if perr:
                                msg = f"{msg} | {perr}" if msg else perr
                            errors.append(msg[:200])
                    else:
                        parsed, perr = _parse_semgrep_output(result.stdout, result.stderr)
                        if parsed:
                            findings.extend(parsed)
                        else:
                            errors.append(f"{perr} (fast tier)")

                # Deep tier: sharded scan
                # If we have changed files, focus deep scan on their parent directories
                # to avoid scanning the entire repo on every run.
                if changed and len(targets) > 1:
                    changed_dirs = set()
                    for cf in changed:
                        parts = cf.split(os.sep)
                        if parts:
                            changed_dirs.add(parts[0])
                    focused_targets = [
                        t for t in targets
                        if os.path.basename(t) in changed_dirs
                    ]
                    if focused_targets:
                        targets = focused_targets

                # Default: custom rules only (fast, no timeout).
                # deep_config packs (p/default, p/typescript, p/react) are loaded
                # only when explicitly requested via server config or targeted scans.
                deep_configs = _merge_configs(_sg_deep_config, custom_only=True)
                _sg_cmd_base = [
                    "semgrep", "scan", "--json", "--quiet",
                    f"--max-target-bytes={_sg_max_bytes}",
                    f"--jobs={_sg_jobs}",
                    f"--timeout={_sg_file_timeout}",
                    f"--timeout-threshold={_sg_timeout_threshold}",
                ]
                if _sg_cache_enabled and cache_supported:
                    _sg_cmd_base.extend(["--cache", f"--cache-dir={_sg_cache_dir}"])
                for c in deep_configs:
                    _sg_cmd_base.extend(["--config", c])
                for pat in _sg_exclude:
                    _sg_cmd_base.extend(["--exclude", pat])

                # Run shards in parallel with asyncio.gather for speed
                async def _scan_shard(shard_target: str):
                    """Run Semgrep on a single shard directory."""
                    cmd = list(_sg_cmd_base) + [shard_target]
                    return await _run_in_executor_with_timeout(
                        lambda cmd=cmd: subprocess.run(
                            cmd,
                            capture_output=True, text=True, timeout=_sg_timeout,
                            env=semgrep_env,
                        ),
                        timeout=float(_sg_exec_timeout),
                        timeout_msg=None,
                    )

                shard_results = await _aio.gather(
                    *[_scan_shard(t) for t in targets],
                    return_exceptions=True,
                )
                for target, result in zip(targets, shard_results):
                    if isinstance(result, Exception):
                        errors.append(f"{os.path.relpath(target, project_path)}: {result}"[:200])
                        continue
                    if result is None:
                        timeouts.append(os.path.relpath(target, project_path))
                        continue
                    if result.returncode not in (0, 1):  # 1 = findings found (not an error)
                        parsed, perr = _parse_semgrep_output(result.stdout, result.stderr)
                        if parsed:
                            findings.extend(parsed)
                            continue
                        msg = (result.stderr or result.stdout or "").strip()
                        if not msg:
                            msg = f"exit code {result.returncode}"
                        if perr:
                            msg = f"{msg} | {perr}" if msg else perr
                        errors.append(msg[:200])
                        continue
                    parsed, perr = _parse_semgrep_output(result.stdout, result.stderr)
                    if parsed:
                        findings.extend(parsed)
                    else:
                        errors.append(f"{perr} (deep tier)")

                # Fallback: if ALL shards timed out and we scanned the full repo,
                # retry on individual key directories
                if timeouts and not findings and len(targets) == 1 and targets[0] == project_path:
                    _dbg("SEMGREP | full-repo scan timed out, retrying per-directory")
                    fallback_targets = []
                    for name in _sg_target_roots:
                        p = os.path.join(project_path, name)
                        if os.path.isdir(p):
                            fallback_targets.append(p)
                    if fallback_targets:
                        timeouts.clear()
                        for target in fallback_targets:
                            _sg_cmd = list(_sg_cmd_base) + [target]
                            result = await _run_in_executor_with_timeout(
                                lambda: subprocess.run(
                                    _sg_cmd,
                                    capture_output=True, text=True, timeout=90,
                                    env=semgrep_env,
                                ),
                                timeout=120.0,
                                timeout_msg=None,
                            )
                            if result is None:
                                timeouts.append(os.path.relpath(target, project_path))
                                continue
                            if result.returncode in (0, 1):
                                parsed, perr = _parse_semgrep_output(result.stdout, result.stderr)
                                if parsed:
                                    findings.extend(parsed)

                # Deduplicate findings by (path, line, rule)
                if findings:
                    deduped = []
                    seen = set()
                    for f in findings:
                        key = (f.get("path"), f.get("start", {}).get("line"), f.get("check_id"))
                        if key not in seen:
                            seen.add(key)
                            deduped.append(f)
                    findings = deduped

                # Filter out known-noisy rules
                _excluded = _SEMGREP_EXCLUDED_RULES
                try:
                    _excluded = _excluded | frozenset(_sem_cfg.get("excluded_rules", []))
                except Exception:
                    pass

                # Apply .hikaflow/scan-ignore.yaml suppressions
                _ignore = _load_scan_ignore(project_path)
                _sg_suppressions = _ignore.get("semgrep", [])
                if isinstance(_sg_suppressions, list) and _sg_suppressions:
                    def _is_suppressed(finding):
                        check_id = finding.get("check_id", "")
                        fpath = os.path.relpath(finding.get("path", ""), project_path)
                        for sup in _sg_suppressions:
                            if not isinstance(sup, dict):
                                continue
                            if sup.get("rule", "") in check_id:
                                for pat in sup.get("paths", []):
                                    if fpath.startswith(pat) or fpath == pat:
                                        return True
                        return False
                    pre_suppress = len(findings)
                    findings = [f for f in findings if not _is_suppressed(f)]
                    suppress_count = pre_suppress - len(findings)
                else:
                    suppress_count = 0

                # Auto-suppress SSRF findings ONLY when the matched code line
                # references env vars or known config accessors (not user input).
                # No blanket path-based suppression — every SSRF finding must
                # show env-var evidence in the matched code to be suppressed.
                _ENV_VAR_PATTERNS = (
                    "os.environ", "os.getenv", "settings.", "config.",
                    "SUPABASE_URL", "API_BASE", "REDIS_URL", "UPSTASH_",
                    "_supabase_url", "_headers()", "_get_supabase_config",
                    ".supabase_url", "api.github.com",
                )
                def _is_ssrf_env_var(finding):
                    check_id = finding.get("check_id", "").lower()
                    if "ssrf" not in check_id:
                        return False
                    # Only suppress if the matched code line actually references env vars
                    lines = finding.get("extra", {}).get("lines", "")
                    return any(pat in lines for pat in _ENV_VAR_PATTERNS)

                pre_ssrf = len(findings)
                findings = [f for f in findings if not _is_ssrf_env_var(f)]
                ssrf_env_count = pre_ssrf - len(findings)

                pre_filter = len(findings)
                findings = [
                    f for f in findings
                    if not any(excl in f.get("check_id", "") for excl in _excluded)
                ]
                filtered_count = (pre_filter - len(findings)) + suppress_count + ssrf_env_count

                if not findings:
                    if timeouts:
                        _inc("scan.semgrep.timeout")
                        scan_meta["semgrep"] = {
                            "ok": False,
                            "status": "timeout",
                            "findings": 0,
                            "filtered": filtered_count,
                            "files": [],
                            "error": None,
                        }
                        return (
                            "Semgrep timed out on: "
                            + ", ".join(timeouts[:5])
                            + (f" (+{len(timeouts) - 5} more)" if len(timeouts) > 5 else "")
                        )
                    if errors:
                        _inc("scan.semgrep.error")
                        scan_meta["semgrep"] = {
                            "ok": False,
                            "status": "error",
                            "findings": 0,
                            "filtered": filtered_count,
                            "files": [],
                            "error": errors[0],
                        }
                        return f"Semgrep error: {errors[0]}"
                    _inc("scan.semgrep.no_findings")
                    scan_meta["semgrep"] = {
                        "ok": True,
                        "status": "no_findings",
                        "findings": 0,
                        "filtered": filtered_count,
                        "files": [],
                        "error": None,
                    }
                    msg = "No security or quality issues found by Semgrep."
                    if filtered_count:
                        msg += f"\n*{filtered_count} low-confidence finding(s) suppressed by rule exclusions.*"
                    return msg

                # Index Semgrep findings for cross-source dedup with worker results
                nonlocal _semgrep_finding_locations
                new_locs: set[tuple[str, int]] = set()
                for f in findings:
                    fpath = os.path.relpath(f.get("path", ""), project_path)
                    start_line = f.get("start", {}).get("line", 0)
                    end_line = f.get("end", {}).get("line", start_line)
                    for ln in range(start_line, end_line + 1):
                        new_locs.add((fpath, ln))
                with _semgrep_lock:
                    _semgrep_finding_locations = new_locs

                # Sort by severity (ERROR first), then path + line
                findings.sort(
                    key=lambda f: (
                        _SEVERITY_ORDER.get(
                            f.get("extra", {}).get("severity", "WARNING").upper(), 9
                        ),
                        f.get("path", ""),
                        f.get("start", {}).get("line", 0),
                    )
                )

                # Compute severity breakdown
                sev_counts: dict[str, int] = {}
                for f in findings:
                    sev = f.get("extra", {}).get("severity", "WARNING").upper()
                    sev_counts[sev] = sev_counts.get(sev, 0) + 1

                # Delta mode: compare with previous scan and carry forward
                # unchanged-file findings from cache (incremental scanning)
                prev_findings = _load_last_scan(project_path)
                current_keys = set()
                scanned_files = set()
                for f in findings:
                    fpath = os.path.relpath(f.get("path", ""), project_path)
                    fline = f.get("start", {}).get("line", 0)
                    current_keys.add((fpath, fline, f.get("check_id", "")))
                    scanned_files.add(fpath)

                # Carry forward findings for files NOT scanned this round
                # (they weren't in the changed set, so they keep previous results)
                carried_forward = 0
                if prev_findings and changed:
                    for prev_key in prev_findings:
                        prev_path = prev_key[0] if isinstance(prev_key, tuple) else ""
                        if prev_path and prev_path not in scanned_files:
                            current_keys.add(prev_key)
                            carried_forward += 1

                new_count = len(current_keys - prev_findings) if prev_findings else 0
                fixed_count = len(prev_findings - current_keys) if prev_findings else 0

                # Save merged findings (scanned + carried forward) for next delta
                _save_last_scan(project_path, findings)

                # Build finding lines — show top findings by severity, no pagination
                _MAX_DISPLAYED = 20
                delta_header = ""
                if prev_findings:
                    delta_parts = [f"{new_count} new", f"{fixed_count} fixed"]
                    if carried_forward:
                        delta_parts.append(f"{carried_forward} carried from cache")
                    delta_header = f" ({', '.join(delta_parts)})"
                scan_mode = ""
                if changed:
                    scan_mode = f" [incremental: {len(changed)} changed file(s)]"

                # Collect all findings with scores for ranking
                scored_findings: list[tuple[float, str, dict]] = []
                severity_counts: dict[str, int] = {}
                for f in findings:
                    sev = f.get("extra", {}).get("severity", "WARNING").upper()
                    severity_counts[sev] = severity_counts.get(sev, 0) + 1
                    fpath = os.path.relpath(f["path"], project_path)
                    fline = f["start"]["line"]
                    rule = f["check_id"].split(".")[-1]
                    msg = f.get("extra", {}).get("message", "")[:80]
                    delta_tag = ""
                    if prev_findings and (fpath, fline, f.get("check_id", "")) not in prev_findings:
                        delta_tag = " [NEW]"
                    score = _score_finding("semgrep", sev, rule_id=f.get("check_id", ""))
                    conf_tag = _confidence_indicator(score)
                    line_str = f"  - `{fpath}:{fline}` [{sev}|{conf_tag}|{score:.2f}]{delta_tag} {rule}: {msg}"
                    scored_findings.append((score, sev, {"line": line_str, "path": fpath}))

                # Sort by score descending (highest severity/confidence first)
                scored_findings.sort(key=lambda x: (-x[0], x[1]))

                # Severity summary
                sev_summary = ", ".join(
                    f"{cnt} {sev}" for sev, cnt in
                    sorted(severity_counts.items(), key=lambda x: _SEVERITY_ORDER.get(x[0], 9))
                    if cnt > 0
                )

                lines = [f"**{len(findings)} finding(s){delta_header}{scan_mode}** ({sev_summary}):"]

                # Show top N findings
                displayed = scored_findings[:_MAX_DISPLAYED]
                for _, _, info in displayed:
                    lines.append(info["line"])

                if len(scored_findings) > _MAX_DISPLAYED:
                    lines.append(
                        f"\n  *Showing top {_MAX_DISPLAYED} of {len(scored_findings)} findings by severity. "
                        f"Use `review_files` or `read_file` on hotspot files below for detailed analysis.*"
                    )
                if filtered_count:
                    lines.append(f"\n  *{filtered_count} low-confidence finding(s) suppressed by rule exclusions.*")
                # Surface scan-ignore warnings (expired, expiring, missing reason)
                _ignore_warnings = _ignore.get("_warnings", [])
                if _ignore_warnings:
                    lines.append("\n  **Suppression warnings:**")
                    for w in _ignore_warnings[:5]:
                        lines.append(f"  - {w}")
                if timeouts:
                    lines.append(
                        "  Note: timed out on "
                        + ", ".join(timeouts[:5])
                        + (f" (+{len(timeouts) - 5} more)" if len(timeouts) > 5 else "")
                    )
                if errors:
                    _inc("scan.semgrep.partial_error")
                    lines.append(f"  Warning: partial Semgrep errors encountered: {errors[0]}")
                _inc("scan.semgrep.findings")
                semgrep_files = []
                for f in findings[:200]:
                    p = f.get("path")
                    if isinstance(p, str):
                        rel = os.path.relpath(p, project_path)
                        if rel not in semgrep_files:
                            semgrep_files.append(rel)
                # Collect ERROR-severity findings for cross-module chain detection
                security_findings = []
                for f in findings:
                    if f.get("extra", {}).get("severity", "").upper() == "ERROR":
                        sf_path = os.path.relpath(f.get("path", ""), project_path)
                        security_findings.append({
                            "path": sf_path,
                            "line": f.get("start", {}).get("line", 0),
                            "rule": f["check_id"].split(".")[-1],
                            "message": f.get("extra", {}).get("message", "")[:60],
                        })

                scan_meta["semgrep"] = {
                    "ok": True,
                    "status": "findings_with_partial_errors" if errors else "findings",
                    "findings": len(findings),
                    "findings_by_severity": sev_counts,
                    "filtered": filtered_count,
                    "files": semgrep_files,
                    "security_findings": security_findings[:50],
                    "error": errors[0] if errors else None,
                }
                return "\n".join(lines)
            except FileNotFoundError:
                _inc("scan.semgrep.missing")
                scan_meta["semgrep"] = {
                    "ok": False,
                    "status": "missing",
                    "findings": 0,
                    "files": [],
                    "error": None,
                }
                return (
                    "Semgrep not installed. Install with:\n"
                    "  pip install semgrep      # Python\n"
                    "  brew install semgrep     # macOS (Homebrew)\n"
                    "  npm install -g semgrep   # Node.js\n"
                    "Semgrep enables static analysis for security and code quality scanning."
                )
            except Exception as ex:
                _inc("scan.semgrep.exception")
                scan_meta["semgrep"] = {
                    "ok": False,
                    "status": "exception",
                    "findings": 0,
                    "files": [],
                    "error": str(ex),
                }
                return f"Semgrep error: {ex}"

        async def _dependency_audit():
            """Check for known vulnerabilities in Python/Node dependencies."""
            project_path = ctx.deps.project_path
            results = []

            # Python: pip-audit
            if os.path.isfile(os.path.join(project_path, "pyproject.toml")) or os.path.isfile(os.path.join(project_path, "requirements.txt")):
                try:
                    proc = await _run_in_executor_with_timeout(
                        lambda: subprocess.run(
                            ["pip-audit", "--format=json", "--progress-spinner=off"],
                            capture_output=True, text=True, timeout=30,
                            cwd=project_path,
                        ),
                        timeout=35.0,
                        timeout_msg=None,
                    )
                    if proc is not None and proc.returncode in (0, 1):
                        data = _extract_json_object(proc.stdout)
                        if data:
                            vulns = data.get("dependencies", [])
                            vuln_list = [d for d in vulns if d.get("vulns")]
                            if vuln_list:
                                results.append(f"**{len(vuln_list)} Python package(s) with known vulnerabilities:**")
                                for pkg in vuln_list[:5]:
                                    name = pkg.get("name", "?")
                                    ver = pkg.get("version", "?")
                                    ids = [v.get("id", "?") for v in pkg.get("vulns", [])[:3]]
                                    results.append(f"  - {name}=={ver}: {', '.join(ids)}")
                                if len(vuln_list) > 5:
                                    results.append(f"  ... and {len(vuln_list) - 5} more")
                            else:
                                results.append("Python: No known vulnerabilities.")
                        else:
                            results.append("Python: pip-audit ran but output not parseable.")
                    elif proc is None:
                        results.append("Python: pip-audit timed out.")
                except FileNotFoundError:
                    results.append("Python: pip-audit not installed (install with `pip install pip-audit`).")
                except Exception as ex:
                    results.append(f"Python: pip-audit error: {ex}")

            # Node: npm audit
            if os.path.isfile(os.path.join(project_path, "package.json")) or os.path.isdir(os.path.join(project_path, "web")):
                npm_dir = project_path
                if os.path.isfile(os.path.join(project_path, "web", "package.json")):
                    npm_dir = os.path.join(project_path, "web")
                try:
                    proc = await _run_in_executor_with_timeout(
                        lambda: subprocess.run(
                            ["npm", "audit", "--json"],
                            capture_output=True, text=True, timeout=30,
                            cwd=npm_dir,
                        ),
                        timeout=35.0,
                        timeout_msg=None,
                    )
                    if proc is not None:
                        data = _extract_json_object(proc.stdout)
                        if data:
                            meta = data.get("metadata", {}).get("vulnerabilities", {})
                            total = sum(meta.get(s, 0) for s in ("critical", "high", "moderate", "low"))
                            if total:
                                parts = []
                                for sev in ("critical", "high", "moderate", "low"):
                                    c = meta.get(sev, 0)
                                    if c:
                                        parts.append(f"{c} {sev}")
                                results.append(f"Node: {total} vulnerability(ies): {', '.join(parts)}")
                            else:
                                results.append("Node: No known vulnerabilities.")
                        else:
                            results.append("Node: npm audit ran but output not parseable.")
                    else:
                        results.append("Node: npm audit timed out.")
                except FileNotFoundError:
                    results.append("Node: npm not installed.")
                except Exception as ex:
                    results.append(f"Node: npm audit error: {ex}")

            return "\n".join(results) if results else "No dependency manifests found."

        async def _taint_analysis():
            """Run Semgrep taint mode for data-flow tracking (source→sink)."""
            project_path = ctx.deps.project_path
            try:
                from autodebug.analysis.data_flow import TAINT_RULES_YAML
            except ImportError:
                scan_meta["taint"] = {"ok": False, "flows": 0, "status": "module_unavailable", "error": "Taint analysis module not available"}
                return "Taint analysis module not available."

            import tempfile as _tf

            # Check semgrep availability
            try:
                ver = await _run_in_executor_with_timeout(
                    lambda: subprocess.run(
                        ["semgrep", "--version"],
                        capture_output=True, timeout=5,
                    ),
                    timeout=8.0,
                    timeout_msg=None,
                )
                if ver is None or ver.returncode != 0:
                    scan_meta["taint"] = {"ok": False, "flows": 0, "status": "semgrep_unavailable", "error": "Semgrep not available for taint analysis"}
                    return "Semgrep not available for taint analysis."
            except Exception:
                scan_meta["taint"] = {"ok": False, "flows": 0, "status": "semgrep_unavailable", "error": "Semgrep not available for taint analysis"}
                return "Semgrep not available for taint analysis."

            # Write taint rules to temp file
            with _tf.TemporaryDirectory() as tmpdir:
                rules_file = os.path.join(tmpdir, "taint_rules.yaml")
                with open(rules_file, "w") as fh:
                    fh.write(TAINT_RULES_YAML)

                # Target key Python directories
                _taint_targets = []
                for name in ("api", "worker", "autodebug"):
                    p = os.path.join(project_path, name)
                    if os.path.isdir(p):
                        _taint_targets.append(p)
                if not _taint_targets:
                    _taint_targets = [project_path]

                semgrep_env = os.environ.copy()
                semgrep_env.setdefault("PYDANTIC_DISABLE_PLUGINS", "1")

                all_results: list[dict] = []
                _taint_failures: list[tuple[str, str]] = []  # (target_relpath, failure_type)
                for target in _taint_targets:
                    cmd = [
                        "semgrep", "scan", "--json", "--quiet",
                        "--config", rules_file,
                        "--dataflow-traces",
                        "--max-target-bytes=300000",
                        "--jobs=4",
                        "--timeout=15",
                        "--timeout-threshold=3",
                        "--exclude", "node_modules",
                        "--exclude", ".git",
                        "--exclude", "__pycache__",
                        "--exclude", ".venv",
                        "--exclude", "tests",
                        target,
                    ]
                    proc = await _run_in_executor_with_timeout(
                        lambda: subprocess.run(
                            cmd,
                            capture_output=True, text=True, timeout=90,
                            env=semgrep_env,
                        ),
                        timeout=100.0,
                        timeout_msg=None,
                    )
                    _target_rel = os.path.relpath(target, project_path)
                    if proc is not None and proc.returncode in (0, 1):
                        try:
                            data = _extract_json_object(proc.stdout)
                        except Exception:
                            data = None
                        if data is not None:
                            all_results.extend(data.get("results", []))
                        elif proc.stdout and proc.stdout.strip():
                            _taint_failures.append((_target_rel, "parse_failed"))
                    elif proc is not None:
                        _taint_failures.append((_target_rel, "execution_failed"))
                    else:
                        # proc is None → timeout
                        _taint_failures.append((_target_rel, "execution_failed"))

                if not all_results and _taint_failures:
                    _fail_statuses = {s for _, s in _taint_failures}
                    _dominant = "execution_failed" if "execution_failed" in _fail_statuses else "parse_failed"
                    _targets_str = ", ".join(t for t, _ in _taint_failures[:3])
                    scan_meta["taint"] = {
                        "ok": False, "flows": 0, "status": _dominant,
                        "error": f"Taint analysis failed for: {_targets_str}",
                    }
                    return f"Taint analysis {_dominant.replace('_', ' ')}: failed for {_targets_str}."

                if not all_results:
                    scan_meta["taint"] = {"ok": True, "flows": 0, "status": "ok_no_flows"}
                    return "No taint flows detected (data-flow analysis found no source→sink paths)."

                # Deduplicate by (path, line, rule)
                seen: set[tuple] = set()
                deduped: list[dict] = []
                for r in all_results:
                    key = (r.get("path"), r.get("start", {}).get("line"), r.get("check_id"))
                    if key not in seen:
                        seen.add(key)
                        deduped.append(r)

                # Group by rule type
                lines = [f"**{len(deduped)} taint flow(s) detected (source → sink):**"]
                by_rule: dict[str, list[dict]] = {}
                for r in deduped:
                    rule = r.get("check_id", "unknown").split(".")[-1]
                    by_rule.setdefault(rule, []).append(r)

                for rule, items in sorted(by_rule.items(), key=lambda x: -len(x[1])):
                    lines.append(f"\n  **{rule}** ({len(items)}):")
                    for item in items[:15]:
                        fpath = os.path.relpath(item.get("path", ""), project_path)
                        fline = item.get("start", {}).get("line", 0)
                        msg = item.get("extra", {}).get("message", "")[:60]
                        # Extract taint trace if available
                        trace = item.get("extra", {}).get("dataflow_trace", {})
                        source_info = ""
                        if trace and trace.get("taint_source"):
                            src = trace["taint_source"]
                            if isinstance(src, list) and src:
                                src_loc = src[0].get("location", {})
                                src_line = src_loc.get("start", {}).get("line", "?")
                                source_info = f" (source: line {src_line})"
                        lines.append(f"  - `{fpath}:{fline}`{source_info} {msg}")
                    if len(items) > 15:
                        lines.append(f"    +{len(items) - 15} more")

                scan_meta["taint"] = {"ok": True, "flows": len(deduped), "status": "ok_with_flows"}
                return "\n".join(lines)

        # Fire selected checks in parallel with per-scan timing
        import time as _tm

        all_labels = [
            "Project", "Production Errors", "Issue Groups",
            "Flaky Patterns", "Environment",
            "Pattern Database", "Quarantine", "Sessions",
            "Repo Hygiene", "Semgrep", "Taint Analysis", "Dependency Audit",
        ]
        all_fns = [
            _project_status, _production_errors, _issue_groups,
            _flaky_patterns, _environment, _pattern_stats,
            _quarantine, _sessions, _repo_hygiene, _semgrep_scan,
            _taint_analysis, _dependency_audit,
        ]

        # Filter based on mode
        labels = [lbl for lbl, fn in zip(all_labels, all_fns) if lbl in allowed_checks]
        fns = [fn for lbl, fn in zip(all_labels, all_fns) if lbl in allowed_checks]

        async def _timed(label, fn):
            _dbg(f"  {label} | started")
            t0 = _tm.monotonic()
            try:
                result = await fn()
            except Exception as exc:
                _dbg(f"  {label} | error ({_tm.monotonic() - t0:.1f}s) → {type(exc).__name__}: {exc}")
                raise
            elapsed = _tm.monotonic() - t0
            preview = str(result)[:120].replace("\n", " ")
            _dbg(f"  {label} | done ({elapsed:.1f}s) → {preview}")
            return result

        results = await _aio.gather(
            *[_timed(lbl, fn) for lbl, fn in zip(labels, fns)],
            return_exceptions=True,
        )

        report = ["# Codebase Scan Report\n"]
        if _preflight_warnings:
            report.append("### Pre-flight Warnings")
            for w in _preflight_warnings:
                report.append(f"- {w}")
            report.append("")

        # ── Hotspot files (TOP of report for agent focus) ──
        # A "hotspot" is a file with 3+ findings — likely needs focused review.
        semgrep_meta = scan_meta.get("semgrep", {})
        hotspot_files: list[str] = []
        if semgrep_meta.get("ok") and semgrep_meta.get("findings", 0) > 0:
            file_counts: dict[str, int] = {}
            for sf in semgrep_meta.get("files", []):
                file_counts[sf] = file_counts.get(sf, 0) + 1
            for f_key in _load_last_scan(project_path):
                fp = f_key[0] if isinstance(f_key, tuple) else ""
                if fp:
                    file_counts[fp] = file_counts.get(fp, 0) + 1
            hotspot_files = sorted(
                [fp for fp, cnt in file_counts.items() if cnt >= 3],
                key=lambda fp: -file_counts.get(fp, 0),
            )[:8]

        if hotspot_files:
            report.append("## Hotspot Files (most findings — investigate these first)")
            for hf in hotspot_files:
                report.append(f"- `{hf}` ({file_counts.get(hf, 0)} findings)")
            report.append(
                f"\n*Use `review_files` or `read_file` on these {len(hotspot_files)} "
                f"hotspot(s) for targeted deep-dive analysis.*\n"
            )

        for label, result in zip(labels, results):
            if isinstance(result, Exception):
                report.append(f"### {label}\nError: {result}\n")
            else:
                report.append(f"### {label}\n{result}\n")

        classification = _classify_scan_findings(scan_meta)
        fidelity, fidelity_reasons = _scan_fidelity(scan_meta)
        strict_evidence_mode = _env_flag("HIKAFLOW_STRICT_EVIDENCE_MODE", default=True)
        degraded_scan_guard = _env_flag("HIKAFLOW_DEGRADED_SCAN_GUARD", default=True)
        evidence_lines = _scan_evidence_lines(scan_meta)
        classification, evidence_guard_applied = _apply_evidence_guard(
            classification,
            evidence_lines=evidence_lines,
            strict_evidence_mode=strict_evidence_mode,
        )

        _inc(f"scan.fidelity.{fidelity}")
        _inc(f"scan.confidence.{'high' if fidelity == 'full' else 'low'}")
        if evidence_guard_applied:
            _inc("scan.guard.evidence_demoted")
        if degraded_scan_guard and fidelity != "full":
            _inc("scan.guard.degraded_applied")

        critical_blockers: list[str] = []
        if classification.root_causes:
            critical_blockers.extend(classification.root_causes)
        # Surface first high-impact symptom when no hard root cause exists.
        if not critical_blockers and classification.symptoms:
            critical_blockers.append(classification.symptoms[0])
        if critical_blockers:
            report.append("## Critical Blockers")
            for item in critical_blockers[:3]:
                report.append(f"- {item}")
            report.append("")

        report.append("## Scan Fidelity")
        report.append(f"- status: **{fidelity.upper()}**")
        report.append(f"- analysis confidence: **{'LOW' if fidelity != 'full' else 'HIGH'}**")
        report.append(f"- strict_evidence_mode: **{'ON' if strict_evidence_mode else 'OFF'}**")
        report.append(f"- degraded_scan_guard: **{'ON' if degraded_scan_guard else 'OFF'}**")
        if fidelity_reasons:
            report.append("- degraded reasons:")
            for reason in fidelity_reasons:
                report.append(f"  - {reason}")
        if degraded_scan_guard and fidelity != "full":
            report.append("- guard note: degraded fidelity guard is active; treat causal claims as tentative.")
        report.append("")
        if evidence_lines:
            report.append("## Evidence")
            report.extend(evidence_lines)
            report.append("")

        if classification.root_causes or classification.symptoms or classification.notes:
            report.append("## Finding Classification")
            if classification.root_causes:
                heading = "### Root Causes"
                if degraded_scan_guard and fidelity != "full":
                    heading = "### Tentative Root Causes (degraded fidelity)"
                report.append(f"\n{heading}")
                for item in classification.root_causes:
                    report.append(f"- {item}")
            if classification.symptoms:
                report.append("\n### Derived Symptoms")
                for item in classification.symptoms:
                    report.append(f"- {item}")
            if classification.notes:
                report.append("\n### Notes")
                for item in classification.notes:
                    report.append(f"- {item}")
            report.append("")

        # Build deterministic drill-down suggestions from typed metadata.
        next_steps = _scan_recommendations(scan_meta)

        if next_steps:
            report.append("---\n### Recommended Next Steps\n" + "\n".join(next_steps))
        else:
            report.append(
                "---\n*Next steps: inspect specific errors, run targeted tests, "
                "fix flaky patterns, or replay production issues.*"
            )
        report.append(
            "\n> **Tip:** Say 'false positive' about any finding to improve future scan accuracy."
        )

        # Hotspot files already shown at top of report (computed earlier)

        # ── Cross-module vulnerability chain detection ──
        # Traces ERROR-severity findings to see if they're reachable from
        # public-facing endpoints (route handlers, CLI commands, webhooks).
        security_findings = semgrep_meta.get("security_findings", [])
        if security_findings:
            import ast as _chain_ast

            _PUBLIC_PATTERNS = (
                "api/routers/", "api/sessions.py", "api/webhooks/",
                "api/main.py", "autodebug/cli", "worker/",
            )

            # Group findings by file
            findings_by_file: dict[str, list[dict]] = {}
            for sf in security_findings:
                findings_by_file.setdefault(sf["path"], []).append(sf)

            # For each file with findings, check which public modules import it
            chains: list[str] = []
            finding_modules = set()
            for fpath in findings_by_file:
                # Convert path to module-style name for import matching
                mod = fpath.replace("/", ".").replace(".py", "")
                # Also match bare module name (last component)
                parts = mod.split(".")
                finding_modules.add((fpath, mod, parts[-1] if parts else ""))

            # Scan Python files for imports of finding-containing modules
            py_files: list[str] = []
            for root, _dirs, fnames in os.walk(project_path):
                rel_root = os.path.relpath(root, project_path)
                if any(skip in rel_root for skip in ("node_modules", ".git", "__pycache__", ".venv", "venv")):
                    continue
                for fn in fnames:
                    if fn.endswith(".py"):
                        py_files.append(os.path.join(rel_root, fn))

            for fpath, mod, short_name in finding_modules:
                importers: list[str] = []
                for py in py_files[:500]:  # Cap to prevent slowdown
                    if py == fpath:
                        continue
                    full = os.path.join(project_path, py)
                    try:
                        with open(full, "r", errors="replace") as fh:
                            src = fh.read(8192)  # Only check top of file
                        # Check for import statements referencing this module
                        if f"from {mod}" in src or f"import {mod}" in src or f"from {short_name}" in src:
                            is_public = any(py.startswith(p) for p in _PUBLIC_PATTERNS)
                            if is_public:
                                importers.append(py)
                    except OSError:
                        continue

                if importers:
                    for sf in findings_by_file[fpath]:
                        for imp in importers[:3]:  # Cap importers per finding
                            chains.append(
                                f"- `{imp}` -> `{fpath}:{sf['line']}` "
                                f"[{sf['rule']}] {sf['message']}"
                            )

            # Also detect compound chains: two files with findings that import each other
            finding_paths = list(findings_by_file.keys())
            compound: list[str] = []
            for i, fp_a in enumerate(finding_paths):
                for fp_b in finding_paths[i + 1:]:
                    full_a = os.path.join(project_path, fp_a)
                    full_b = os.path.join(project_path, fp_b)
                    try:
                        with open(full_a, "r", errors="replace") as fh:
                            src_a = fh.read(8192)
                        with open(full_b, "r", errors="replace") as fh:
                            src_b = fh.read(8192)
                    except OSError:
                        continue
                    mod_a = fp_a.replace("/", ".").replace(".py", "")
                    mod_b = fp_b.replace("/", ".").replace(".py", "")
                    a_imports_b = f"from {mod_b}" in src_a or f"import {mod_b}" in src_a
                    b_imports_a = f"from {mod_a}" in src_b or f"import {mod_a}" in src_b
                    if a_imports_b or b_imports_a:
                        compound.append(f"- `{fp_a}` <-> `{fp_b}` (both have ERROR findings, linked by import)")

            if chains or compound:
                report.append("\n## Vulnerability Chains (cross-module reachability)")
                if chains:
                    report.append("Public endpoint -> vulnerable code:")
                    for c in chains[:10]:
                        report.append(c)
                    if len(chains) > 10:
                        report.append(f"  +{len(chains) - 10} more chain(s)")
                if compound:
                    report.append("\nCompound risk (multiple findings linked by imports):")
                    for c in compound[:5]:
                        report.append(c)
                report.append(
                    "\n*Chains show how vulnerabilities in internal modules are reachable "
                    "from public-facing code. Prioritize fixing these.*"
                )

        final_report = _sanitize_tool_output("\n".join(report), tool_name="scan_codebase")

        # Cache report (no pagination — full report returned in one call)
        _scan_cache[cache_key] = {"report": final_report}
        _dbg(f"SCAN_COMPLETE | {len(final_report):,} chars, cached")
        return final_report

    # Semgrep findings index for cross-source dedup in review_files
    _semgrep_finding_locations: set[tuple[str, int]] = set()
    _semgrep_lock = threading.Lock()
    _registered_finding_locations.append(_semgrep_finding_locations)

    # Lazily created specialized worker agents for review_files
    _worker_agents: dict[str, Any] = {}  # "security" | "async" | "quality" -> Agent
    _worker_agents_project_path: str | None = None
    _review_refs: list = [_worker_agents, _worker_agents_project_path]
    _registered_review_refs.append(_review_refs)

    # --- File classification for worker routing ---
    _ASYNC_INDICATORS = re.compile(
        r"async\s+def|asyncio\.|await\s|create_task|gather\(|aiohttp|httpx\.Async|"
        r"async\s+with|async\s+for|threading\.|Lock\(|Semaphore\(|Event\(\)",
        re.IGNORECASE,
    )
    _SECURITY_INDICATORS = re.compile(
        r"password|secret|token|auth|jwt|cookie|session|csrf|cors|redirect|"
        r"sql|query|execute|inject|sanitize|escape|hash|crypt|pickle|yaml\.load|"
        r"eval\(|exec\(|subprocess|shell|os\.system|requests\.\w+\(|httpx\.\w+\(",
        re.IGNORECASE,
    )

    def _classify_file(content: str) -> list[str]:
        """Determine which specialized workers should review this file.

        Returns 1-2 worker types based on file content.
        Always includes 'quality'. Adds 'security' and/or 'async' when relevant.
        """
        workers = ["quality"]
        head = content[:8000]  # Check first 8k chars for speed
        if _SECURITY_INDICATORS.search(head):
            workers.append("security")
        if _ASYNC_INDICATORS.search(head):
            workers.append("async")
        return workers

    @agent.tool
    async def review_files(
        ctx: RunContext[HikaflowDeps],
        paths: list[str],
        focus: str = "security vulnerabilities, bugs, code quality issues, deprecated APIs",
        max_files_per_turn: int = 15,
        max_workers_per_file: int = 3,
        max_total_worker_invocations: int = 30,
    ) -> str:
        """Dispatch specialized worker agents to review files in parallel.

        Each file is routed to 1-3 specialized workers (security, async, code-quality)
        based on file content. Workers run in parallel for maximum coverage.

        Args:
            paths: File paths to review (relative to project root, max 15).
            focus: What to look for in the review.
            max_files_per_turn: Maximum files to review this turn (1-15, default 15).
            max_workers_per_file: Maximum worker types per file (1-3, default 3).
            max_total_worker_invocations: Total worker budget across all files (1-45, default 30).

        Returns:
            Combined review findings from all workers.
        """
        nonlocal _worker_agents, _worker_agents_project_path
        from autodebug.debug_log import debug as _dbg

        # Invalidate cached agents if project path changed
        if _worker_agents_project_path != ctx.deps.project_path:
            _worker_agents = {}

        if not _worker_agents:
            try:
                from pydantic_ai import Agent as _Agent
                from autodebug.agent.system_prompt import (
                    SECURITY_WORKER_PROMPT,
                    ASYNC_WORKER_PROMPT,
                    CODE_QUALITY_WORKER_PROMPT,
                )
                from autodebug.agent.core import _build_model
                worker_model = _build_model(
                    ctx.deps.worker_model,
                    api_base=ctx.deps.api_base,
                    token=ctx.deps.token,
                )
                project_context = _build_review_context(ctx.deps.project_path)
                _worker_agents = {
                    "security": _Agent(
                        worker_model,
                        system_prompt=SECURITY_WORKER_PROMPT.format(project_context=project_context),
                    ),
                    "async": _Agent(
                        worker_model,
                        system_prompt=ASYNC_WORKER_PROMPT.format(project_context=project_context),
                    ),
                    "quality": _Agent(
                        worker_model,
                        system_prompt=CODE_QUALITY_WORKER_PROMPT.format(project_context=project_context),
                    ),
                }
                _worker_agents_project_path = ctx.deps.project_path
            except ImportError:
                return "Error: LLM backend not configured. Install pydantic-ai and set credentials to use review_files."
            except AttributeError:
                return "Error: LLM model not configured. Set WORKER_MODEL or configure credentials.json."
            except Exception as e:
                return f"Error creating review workers: {e}"

        # Clamp budget parameters to valid ranges
        max_files_per_turn = max(1, min(int(max_files_per_turn), 15))
        max_workers_per_file = max(1, min(int(max_workers_per_file), 3))
        max_total_worker_invocations = max(1, min(int(max_total_worker_invocations), 45))

        paths = paths[:max_files_per_turn]
        _dbg(f"REVIEW_FILES | dispatching {len(paths)} file(s): {paths} "
             f"(budget: files={max_files_per_turn}, workers/file={max_workers_per_file}, total={max_total_worker_invocations})")

        # Load review suppressions for per-file context
        _ignore = _load_scan_ignore(ctx.deps.project_path)
        _review_suppressions = _ignore.get("review", [])

        _FILE_CONTENT_CAP = 60_000  # chars per file sent to worker
        _WORKER_TIMEOUT = 90.0  # seconds per worker invocation (235b models need ~60s for large files)

        async def _review_one(path: str) -> tuple[str, list[dict]]:
            # Use cached content from budget pre-classification when available
            if path in _content_cache:
                raw_content = _content_cache[path]
            else:
                full = os.path.join(ctx.deps.project_path, path)
                try:
                    raw_content = await asyncio.to_thread(Path(full).read_text)
                except Exception as e:
                    return f"### {path}\nError: {e}", []

            total_len = len(raw_content)
            content = raw_content[:_FILE_CONTENT_CAP]
            truncation_note = ""
            if total_len > _FILE_CONTENT_CAP:
                truncation_note = (
                    f"\n\n[TRUNCATED: showing first {_FILE_CONTENT_CAP:,} of {total_len:,} chars "
                    "— findings may be incomplete for code beyond this point]"
                )

            # Cross-file context: extract imports to give worker awareness of related modules
            import re as _cre
            imports = _cre.findall(r'(?:from|import)\s+([\w.]+)', content[:5000])
            import_note = ""
            if imports:
                unique_mods = sorted(set(m.split('.')[0] for m in imports))[:10]
                import_note = f"\n\nThis file imports from: {', '.join(unique_mods)}."

            # Guard/validation functions in this file
            guard_fns = _cre.findall(r"def\s+(_?validate\w*|_?check\w*|_?verify\w*|_?sanitize\w*|_?is_\w+)\s*\(", content)
            crossfile_note = ""
            if guard_fns:
                crossfile_note = f"\n\nThis file defines guard/validation functions: {', '.join(guard_fns[:10])}. Check if flagged code is protected by these before reporting."

            # Per-file Semgrep context: tell worker what Semgrep already found
            # so it can add context rather than duplicate or skip
            semgrep_note = ""
            if _semgrep_finding_locations:
                file_semgrep_lines = sorted(
                    ln for (fp, ln) in _semgrep_finding_locations if fp == path
                )
                if file_semgrep_lines:
                    semgrep_note = (
                        f"\n\nSemgrep already flagged lines {file_semgrep_lines} in this file. "
                        "For those lines: only report if you have ADDITIONAL context "
                        "(e.g., exploitability analysis, data flow insight) that Semgrep can't provide. "
                        "Focus your effort on finding issues Semgrep missed."
                    )

            # Per-file suppression context
            suppression_note = ""
            if isinstance(_review_suppressions, list) and _review_suppressions:
                applicable = [
                    s for s in _review_suppressions
                    if isinstance(s, dict)
                    and any(
                        path.startswith(sp) or path == sp
                        for sp in s.get("paths", [])
                    )
                ]
                if applicable:
                    suppression_note = "\n\nKnown accepted patterns for this file (DO NOT report these):\n"
                    for s in applicable:
                        suppression_note += f"- {s.get('pattern', '')}: {s.get('reason', '')}\n"

            base_prompt = (
                f"FOCUS: {focus}\n\n"
                f"File: `{path}`"
                f"{import_note}{crossfile_note}{semgrep_note}{suppression_note}{truncation_note}"
                f"\n\n```\n{content}\n```"
            )

            # Classify file and dispatch to specialized workers (respect budget)
            worker_types = _classify_file(raw_content)[:max_workers_per_file]

            async def _run_worker(wtype: str) -> tuple[str, list[dict]]:
                agent_instance = _worker_agents.get(wtype)
                if not agent_instance:
                    return "", []
                try:
                    result = await asyncio.wait_for(
                        agent_instance.run(base_prompt, usage=ctx.usage),
                        timeout=_WORKER_TIMEOUT,
                    )
                    parsed = _parse_worker_output(result.output, path)
                    for pf in parsed:
                        pf["_worker_type"] = wtype
                    return result.output, parsed
                except asyncio.TimeoutError:
                    return f"[{wtype} TIMEOUT]", []
                except Exception as e:
                    return f"[{wtype} error: {e}]", []

            # Run all applicable workers in parallel
            worker_results = await asyncio.gather(
                *[_run_worker(wt) for wt in worker_types]
            )

            # Merge findings from all workers, dedup by (line, message)
            all_parsed: list[dict] = []
            seen_keys: set[tuple[int, str]] = set()
            for _, parsed_list in worker_results:
                for pf in parsed_list:
                    key = (pf["line"], pf["message"][:80])
                    if key not in seen_keys:
                        seen_keys.add(key)
                        all_parsed.append(pf)

            if all_parsed:
                # Score and sort by confidence (highest first)
                for pf in all_parsed:
                    wtype = pf.get("_worker_type", "worker")
                    pf["_score"] = _score_finding(wtype, pf["severity"], pf.get("confidence", "LIKELY"))
                all_parsed.sort(key=lambda x: (-x["_score"], x["line"]))

                worker_labels = ", ".join(worker_types)
                lines = [f"### {path} (reviewed by: {worker_labels})"]
                for pf in all_parsed:
                    wt_tag = f" [{pf.get('_worker_type', '?')}]" if len(worker_types) > 1 else ""
                    score = pf["_score"]
                    conf_tag = _confidence_indicator(score)
                    lines.append(
                        f"- Line {pf['line']} [{pf['severity']}|{conf_tag}|{score:.2f}]{wt_tag}: "
                        f"{pf['message']}"
                    )
                return "\n".join(lines), all_parsed
            # No findings from any worker
            worker_labels = ", ".join(worker_types)
            return f"### {path} (reviewed by: {worker_labels})\nNo issues found.", []

        # Budget pre-classification: read files once, estimate worker counts, enforce total budget.
        # Cached content is reused by _review_one to avoid a second disk read.
        pending_reviews: list[dict] = []
        budgeted_paths: list[str] = []
        _content_cache: dict[str, str] = {}
        _total_workers_planned = 0

        for p in paths:
            _full_path = os.path.join(ctx.deps.project_path, p)
            try:
                _full_content = await asyncio.to_thread(Path(_full_path).read_text)
                _content_cache[p] = _full_content
            except Exception:
                # Can't pre-read — still include (error will surface in _review_one)
                budgeted_paths.append(p)
                _total_workers_planned += 1
                continue
            _estimated_workers = len(_classify_file(_full_content[:8000])[:max_workers_per_file])
            if _total_workers_planned + _estimated_workers > max_total_worker_invocations:
                pending_reviews.append({
                    "path": p,
                    "reason": "worker_budget_exhausted",
                    "planned_workers": _estimated_workers,
                })
                continue
            budgeted_paths.append(p)
            _total_workers_planned += _estimated_workers

        paths = budgeted_paths

        review_tasks = [_review_one(p) for p in paths]
        results = await asyncio.gather(*review_tasks)

        # Separate display text and structured findings
        reviews = [r[0] for r in results]
        all_worker_findings: list[dict] = []
        for r in results:
            all_worker_findings.extend(r[1])

        # Post-processing: merge worker findings that overlap Semgrep (tag, don't drop)
        if all_worker_findings and _semgrep_finding_locations:
            all_worker_findings, merged_count = _post_dedup_worker_findings(
                all_worker_findings, _semgrep_finding_locations
            )
            if merged_count:
                reviews.append(
                    f"\n*{merged_count} finding(s) overlap Semgrep results (marked with [+Semgrep] below).*"
                )

        # Append pending_reviews section if files were deferred due to budget
        if pending_reviews:
            _deferred_paths = [pr["path"] for pr in pending_reviews[:10]]
            reviews.append(
                f"\n**Budget limit reached.** {len(pending_reviews)} file(s) deferred:"
            )
            for pr in pending_reviews[:10]:
                reviews.append(f"- `{pr['path']}` ({pr['reason']})")
            if len(pending_reviews) > 10:
                reviews.append(f"... and {len(pending_reviews) - 10} more")
            # V-15: Auto-continue hint so the agent knows to offer the next batch
            _paths_arg = str(_deferred_paths)
            reviews.append(
                f"\n*To review the remaining files, call:* "
                f"`review_files(paths={_paths_arg})`"
            )

        combined = "\n\n".join(reviews)
        _dbg(f"REVIEW_FILES | done, {len(combined):,} chars total, {len(all_worker_findings)} unique findings, "
             f"{len(pending_reviews)} deferred")
        return combined

    async def manage_server_quarantine(
        ctx: RunContext[HikaflowDeps],
        action: str = "list",
        test_id: Optional[str] = None,
        reason: Optional[str] = None,
        expires_days: int = 14,
    ) -> str:
        """Manage server-side test quarantine (no code changes required).

        Unlike manage_quarantine which uses a local .flaky-quarantine file,
        this manages quarantine on the server for the entire team.

        Args:
            action: One of "list", "add", or "remove".
            test_id: Test ID for add/remove (e.g., 'test_module.py::test_func').
            reason: Reason for quarantine (used with "add").
            expires_days: Days until quarantine expires (default: 14, used with "add").

        Returns:
            Quarantine list or operation result.
        """
        project_id = _get_project_id(ctx)
        if not project_id:
            return "Error: No project linked. Run 'hikaflow init' first."

        try:
            from autodebug.flakiness import QuarantineManager
            mgr = QuarantineManager(project_id=project_id, token=ctx.deps.token)
        except ImportError:
            return "Error: QuarantineManager not available."

        loop = asyncio.get_event_loop()

        if action == "list":
            try:
                tests = await loop.run_in_executor(None, mgr.get_quarantined_tests)
            except Exception as e:
                return f"Error listing quarantine: {e}"
            if not tests:
                return "No tests are currently quarantined on the server."
            lines = [f"**Server Quarantine ({len(tests)} tests):**\n"]
            for tid in sorted(tests):
                lines.append(f"- `{tid}`")
            return "\n".join(lines)

        elif action == "add":
            if not test_id:
                return "Error: 'test_id' is required for add action."
            try:
                success = await loop.run_in_executor(
                    None,
                    lambda: mgr.quarantine_test(test_id, reason=reason or "Quarantined via agent", expires_days=expires_days),
                )
            except Exception as e:
                return f"Error adding to quarantine: {e}"
            if success:
                return f"Test `{test_id}` quarantined on server (expires in {expires_days} days)."
            last_err = getattr(mgr, '_last_error', None)
            return f"Failed to quarantine test.{f' Error: {last_err}' if last_err else ''}"

        elif action == "remove":
            if not test_id:
                return "Error: 'test_id' is required for remove action."
            try:
                success = await loop.run_in_executor(
                    None,
                    lambda: mgr.unquarantine_test(test_id),
                )
            except Exception as e:
                return f"Error removing from quarantine: {e}"
            if success:
                return f"Test `{test_id}` removed from server quarantine."
            last_err = getattr(mgr, '_last_error', None)
            return f"Failed to unquarantine test.{f' Error: {last_err}' if last_err else ''}"

        else:
            return f"Error: Unknown action '{action}'. Use: list, add, remove"

    # =================================================================
    # Consolidated Dispatcher Tools (5)
    # =================================================================

    @agent.tool
    async def sessions(
        ctx: RunContext[HikaflowDeps],
        action: str = "list",
        session_id: Optional[str] = None,
        limit: int = 20,
        status: Optional[str] = None,
        file_path: Optional[str] = None,
        stream: str = "dom",
        metadata: Optional[str] = None,
        trace_id: Optional[str] = None,
        output_dir: str = ".hikaflow-sessions",
        expires_days: int = 7,
    ) -> str:
        """Manage session replays.

        Actions:
          - list: Browse recorded sessions. Params: limit, status.
          - create: Create a new session. Params: metadata, trace_id.
          - upload: One-shot create+upload+finalize. Params: file_path (required), stream, metadata, trace_id.
          - share: Generate shareable link. Params: session_id (required), expires_days.
          - download: Download session chunks locally. Params: session_id (required), output_dir.

        Args:
            action: One of: list, create, upload, share, download.
            session_id: Session ID (required for share, download).
            limit: Max sessions for list (1-100).
            status: Filter by status for list: active, completed, failed.
            file_path: Path to JSONL file for upload.
            stream: Stream type for upload: dom, console, network, errors, perf.
            metadata: JSON string of metadata for create/upload.
            trace_id: Trace ID for create/upload.
            output_dir: Download directory.
            expires_days: Days until share link expires.

        Returns:
            Result of the session operation.
        """
        if action == "list":
            return await list_sessions(ctx, limit=limit, status=status)
        elif action == "create":
            return await create_session(ctx, metadata=metadata, trace_id=trace_id)
        elif action == "upload":
            if not file_path:
                return "Error: 'file_path' required for upload action."
            return await upload_session(ctx, file_path=file_path, stream=stream, metadata=metadata, trace_id=trace_id)
        elif action == "share":
            if not session_id:
                return "Error: 'session_id' required for share action."
            return await share_session(ctx, session_id=session_id, expires_days=expires_days)
        elif action == "download":
            if not session_id:
                return "Error: 'session_id' required for download action."
            return await download_session(ctx, session_id=session_id, output_dir=output_dir)
        else:
            return f"Error: Unknown action '{action}'. Use: list, create, upload, share, download"

    @agent.tool
    async def issue_groups(
        ctx: RunContext[HikaflowDeps],
        action: str = "list",
        error_id: Optional[str] = None,
        group_id: Optional[str] = None,
        source_group_id: Optional[str] = None,
        target_group_id: Optional[str] = None,
    ) -> str:
        """Manage issue groups for production errors.

        Actions:
          - list: View all issue groups with member counts.
          - group: Auto-group an error. Params: error_id (required).
          - delete: Delete a group. Params: group_id (required).
          - merge: Merge source into target. Params: source_group_id, target_group_id (both required).

        Args:
            action: One of: list, group, delete, merge.
            error_id: Error ID for group action.
            group_id: Group ID for delete action.
            source_group_id: Source group for merge (will be deleted).
            target_group_id: Target group for merge (absorbs members).

        Returns:
            Result of the issue group operation.
        """
        if action == "list":
            return await list_issue_groups(ctx)
        elif action == "group":
            if not error_id:
                return "Error: 'error_id' required for group action."
            return await group_error(ctx, error_id=error_id)
        elif action == "delete":
            if not group_id:
                return "Error: 'group_id' required for delete action."
            return await delete_issue_group(ctx, group_id=group_id)
        elif action == "merge":
            if not source_group_id or not target_group_id:
                return "Error: 'source_group_id' and 'target_group_id' required for merge action."
            return await merge_issue_groups(ctx, source_group_id=source_group_id, target_group_id=target_group_id)
        else:
            return f"Error: Unknown action '{action}'. Use: list, group, delete, merge"

    @agent.tool
    async def patterns(
        ctx: RunContext[HikaflowDeps],
        action: str = "list",
        limit: int = 20,
        order: str = "success",
        fix_type: Optional[str] = None,
        run_path: Optional[str] = None,
        min_similarity: float = 0.5,
        max_results: int = 5,
        fix_diff_path: Optional[str] = None,
        fix_description: Optional[str] = None,
        tags: Optional[list[str]] = None,
        output_path: str = "patterns.json",
    ) -> str:
        """Manage the fix pattern database.

        Actions:
          - list: Browse patterns. Params: limit, order (success/uses/recent), fix_type.
          - stats: Show database statistics.
          - match: Find matching patterns for a failure. Params: run_path (required), min_similarity, max_results.
          - add: Add a proven fix. Params: run_path, fix_diff_path, fix_description (all required), fix_type, tags.
          - export: Export patterns to JSON. Params: output_path.

        Args:
            action: One of: list, stats, match, add, export.
            limit: Max patterns for list.
            order: Sort for list: success, uses, recent.
            fix_type: Filter by fix type for list, or type hint for add.
            run_path: Path to run directory for match/add.
            min_similarity: Similarity threshold for match (0.0-1.0).
            max_results: Max matches for match.
            fix_diff_path: Path to diff file for add.
            fix_description: Description for add.
            tags: Tags for add.
            output_path: File path for export.

        Returns:
            Result of the pattern operation.
        """
        if action == "list":
            return await list_patterns(ctx, limit=limit, order=order, fix_type=fix_type)
        elif action == "stats":
            return await get_pattern_stats(ctx)
        elif action == "match":
            if not run_path:
                return "Error: 'run_path' required for match action."
            return await match_patterns(ctx, run_path=run_path, min_similarity=min_similarity, max_results=max_results)
        elif action == "add":
            if not run_path or not fix_diff_path or not fix_description:
                return "Error: 'run_path', 'fix_diff_path', and 'fix_description' required for add action."
            return await add_pattern(ctx, run_path=run_path, fix_diff_path=fix_diff_path, fix_description=fix_description, fix_type=fix_type, tags=tags)
        elif action == "export":
            return await export_patterns(ctx, output_path=output_path)
        else:
            return f"Error: Unknown action '{action}'. Use: list, stats, match, add, export"

    @agent.tool
    async def transcript(
        ctx: RunContext[HikaflowDeps],
        action: str = "export",
        transcript_path: Optional[str] = None,
        fmt: str = "har",
        output_path: Optional[str] = None,
        harness_path: Optional[str] = None,
        method: str = "hierarchical",
        bundle_path: Optional[str] = None,
        left_path: Optional[str] = None,
        right_path: Optional[str] = None,
    ) -> str:
        """Manage I/O transcripts and replay bundles.

        Actions:
          - export: Export transcript to HAR/curl/Postman. Params: transcript_path (required), fmt, output_path.
          - minimize: Delta-debug to find smallest failing case. Params: harness_path, transcript_path (both required), method, output_path.
          - optimize: Compress/deduplicate a replay bundle. Params: bundle_path (required), output_path.
          - compare: Compare two transcripts. Params: left_path, right_path (both required).

        Args:
            action: One of: export, minimize, optimize, compare.
            transcript_path: Path to transcript file (for export/minimize).
            fmt: Export format: har, curl, postman.
            output_path: Output file path.
            harness_path: Path to repro harness (for minimize).
            method: Minimization method: ddmin, hierarchical, causal.
            bundle_path: Path to bundle directory (for optimize).
            left_path: First transcript for compare.
            right_path: Second transcript for compare.

        Returns:
            Result of the transcript operation.
        """
        if action == "export":
            if not transcript_path:
                return "Error: 'transcript_path' required for export action."
            return await export_transcript(ctx, transcript_path=transcript_path, fmt=fmt, output_path=output_path)
        elif action == "minimize":
            if not harness_path or not transcript_path:
                return "Error: 'harness_path' and 'transcript_path' required for minimize action."
            return await minimize_transcript(ctx, harness_path=harness_path, transcript_path=transcript_path, method=method, output_path=output_path)
        elif action == "optimize":
            if not bundle_path:
                return "Error: 'bundle_path' required for optimize action."
            return await optimize_bundle(ctx, bundle_path=bundle_path, output_path=output_path)
        elif action == "compare":
            if not left_path or not right_path:
                return "Error: 'left_path' and 'right_path' required for compare action."
            return await compare_transcripts(ctx, left_path=left_path, right_path=right_path)
        else:
            return f"Error: Unknown action '{action}'. Use: export, minimize, optimize, compare"

    @agent.tool
    async def quarantine(
        ctx: RunContext[HikaflowDeps],
        action: str = "list",
        scope: str = "local",
        nodeids: Optional[list[str]] = None,
        test_id: Optional[str] = None,
        reason: Optional[str] = None,
        expires_days: int = 14,
    ) -> str:
        """Manage test quarantine (local file or server-side).

        Actions:
          - list: Show quarantined tests.
          - add: Add test(s) to quarantine. Local: nodeids. Server: test_id.
          - remove: Remove test(s). Local: nodeids. Server: test_id.
          - deselect_args: Generate pytest deselect args (local only).

        Args:
            action: One of: list, add, remove, deselect_args.
            scope: "local" (.flaky-quarantine file) or "server" (team-wide server quarantine).
            nodeids: Test node IDs for local add/remove (list).
            test_id: Test ID for server add/remove (string).
            reason: Reason for quarantine (server add).
            expires_days: Days until expiry (server add, default 14).

        Returns:
            Quarantine list or operation result.
        """
        if scope == "local":
            return await manage_quarantine(ctx, action=action, nodeids=nodeids)
        elif scope == "server":
            return await manage_server_quarantine(ctx, action=action, test_id=test_id, reason=reason, expires_days=expires_days)
        else:
            return f"Error: Unknown scope '{scope}'. Use: local, server"

    # =================================================================
    # Scan Feedback Tool
    # =================================================================

    @agent.tool
    async def scan_feedback(
        ctx: RunContext[HikaflowDeps],
        action: str,
        rule: str,
        file_path: str = "",
        line: int = 0,
        reason: str = "",
    ) -> str:
        """Record feedback on scan findings to improve future scan accuracy.

        Feedback adjusts per-rule confidence scores over time, improving scan relevance.

        Args:
            action: One of: 'fixed' (user fixed the issue), 'false_positive' (not a real bug),
                    'dismissed' (acknowledged but won't fix), 'true_positive' (confirmed real),
                    'stats' (show feedback statistics).
            rule: The Semgrep rule ID (e.g. 'catch-and-pass', 'ssrf-user-controlled-url').
            file_path: File where finding was reported (optional, for tracking).
            line: Line number of finding (optional, for tracking).
            reason: Explanation for why this is FP/dismissed (optional but encouraged).

        Returns:
            Confirmation of recorded feedback or statistics.
        """
        project_path = ctx.deps.project_path
        feedback_dir = os.path.join(project_path, ".hikaflow")
        feedback_file = os.path.join(feedback_dir, "feedback.json")

        # Load existing feedback
        data: dict = {"rules": {}, "entries": []}
        if os.path.isfile(feedback_file):
            try:
                with open(feedback_file) as f:
                    data = json.load(f)
                if "rules" not in data:
                    data["rules"] = {}
                if "entries" not in data:
                    data["entries"] = []
            except Exception:
                data = {"rules": {}, "entries": []}

        if action == "stats":
            rules = data.get("rules", {})
            if not rules:
                return "No feedback recorded yet. Use scan_feedback to record finding outcomes."
            lines = ["## Scan Feedback Statistics\n"]
            lines.append("| Rule | Fixed | FP | Dismissed | Total | TP Rate |")
            lines.append("|------|-------|----|-----------|-------|---------|")
            for rule_id, counts in sorted(rules.items(), key=lambda x: -(
                x[1].get("fixed", 0) + x[1].get("true_positive", 0) +
                x[1].get("false_positive", 0) + x[1].get("dismissed", 0)
            )):
                tp = counts.get("fixed", 0) + counts.get("true_positive", 0)
                fp = counts.get("false_positive", 0)
                dismissed = counts.get("dismissed", 0)
                total = tp + fp + dismissed
                rate = f"{tp/total*100:.0f}%" if total > 0 else "N/A"
                lines.append(f"| {rule_id} | {tp} | {fp} | {dismissed} | {total} | {rate} |")

            # Show active adjustments
            adj = _load_feedback_precision(project_path)
            if adj:
                lines.append("\n### Active Precision Adjustments (3+ entries)")
                for r, v in sorted(adj.items()):
                    base = _RULE_PRECISION.get(r, 0.75)
                    delta = v - base
                    arrow = "+" if delta > 0 else ""
                    lines.append(f"- `{r}`: {base:.2f} -> {v:.2f} ({arrow}{delta:.2f})")
            return _sanitize_tool_output("\n".join(lines), tool_name="scan_feedback")

        # Validate action
        valid_actions = ("fixed", "false_positive", "dismissed", "true_positive")
        if action not in valid_actions:
            return f"Error: action must be one of: {', '.join(valid_actions)}, stats"

        if not rule:
            return "Error: rule is required (e.g. 'catch-and-pass')."

        # Strip rule prefix if full semgrep ID passed
        short_rule = rule.rsplit(".", 1)[-1]

        # Update per-rule counts
        if short_rule not in data["rules"]:
            data["rules"][short_rule] = {}
        data["rules"][short_rule][action] = data["rules"][short_rule].get(action, 0) + 1

        # Record individual entry for audit trail
        from datetime import datetime
        entry = {
            "rule": short_rule,
            "action": action,
            "file": file_path,
            "line": line,
            "reason": reason,
            "timestamp": datetime.utcnow().isoformat() + "Z",
        }
        # Keep last 500 entries to bound file size
        data["entries"].append(entry)
        if len(data["entries"]) > 500:
            data["entries"] = data["entries"][-500:]

        # Save atomically
        try:
            os.makedirs(feedback_dir, exist_ok=True)
            import tempfile as _tf
            fd, tmp = _tf.mkstemp(dir=feedback_dir, suffix=".tmp")
            with os.fdopen(fd, "w") as fh:
                json.dump(data, fh, indent=2)
            os.replace(tmp, feedback_file)
        except Exception as ex:
            return f"Error saving feedback: {ex}"

        # Refresh feedback cache
        global _feedback_precision_cache, _feedback_precision_project
        _feedback_precision_cache = _load_feedback_precision(project_path)
        _feedback_precision_project = project_path

        # Build confirmation
        counts = data["rules"][short_rule]
        tp = counts.get("fixed", 0) + counts.get("true_positive", 0)
        fp = counts.get("false_positive", 0)
        dismissed = counts.get("dismissed", 0)
        total = tp + fp + dismissed
        msg = f"Recorded `{action}` for rule `{short_rule}`"
        if file_path:
            msg += f" at `{file_path}:{line}`"
        if reason:
            msg += f" — {reason}"
        msg += f"\n\nRule stats: {tp} TP, {fp} FP, {dismissed} dismissed ({total} total)"
        if total >= 3:
            adj = _load_feedback_precision(project_path).get(short_rule)
            if adj:
                base = _RULE_PRECISION.get(short_rule, 0.75)
                msg += f"\nPrecision adjusted: {base:.2f} -> {adj:.2f}"
        return msg

    # =================================================================
    # Analytics, Settings & Integration Tools (8)
    # =================================================================

    @agent.tool
    async def dashboard_stats(
        ctx: RunContext[HikaflowDeps],
        action: str = "overview",
        project_id: Optional[str] = None,
        test_id: Optional[str] = None,
        days: int = 30,
        limit: int = 20,
        offset: int = 0,
        min_score: float = 0.3,
        include_quarantined: bool = False,
        status: Optional[str] = None,
    ) -> str:
        """View dashboard analytics, flaky tests, trends, DX metrics, and regressions.

        Actions:
          - overview: High-level run stats (pass rate, fixes, totals).
          - flaky: List flaky tests ranked by flakiness score. Params: project_id, min_score, limit, offset, include_quarantined.
          - trends: Flakiness trend for a specific test over time. Params: project_id (required), test_id (required), days.
          - dx: Developer experience metrics (CI stability, time-to-fix, productivity). Params: days.
          - regressions: Performance/behavior regressions across runs. Params: project_id, status, days, limit.

        Args:
            action: One of: overview, flaky, trends, dx, regressions.
            project_id: Filter by project (required for trends, optional for flaky/regressions).
            test_id: Test ID (required for trends).
            days: Time window in days (default 30).
            limit: Max items to return.
            offset: Pagination offset (for flaky).
            min_score: Minimum flakiness score threshold (for flaky, default 0.3).
            include_quarantined: Include quarantined tests (for flaky, default False).
            status: Filter regressions by status: new, acknowledged, resolved, false_positive.

        Returns:
            Formatted analytics report or table.
        """
        if action == "overview":
            data, error = await _agent_api_request(ctx, "GET", "/v1/dashboard/stats", timeout=15.0)
            if error or data is None:
                return f"Error fetching dashboard stats: {error or 'unknown'}"
            lines = [
                "**Dashboard Overview:**\n",
                "| Metric | Value |",
                "|--------|-------|",
                f"| Total Runs | {data.get('total_runs', 0):,} |",
                f"| Passed | {data.get('passed_runs', 0):,} |",
                f"| Failed | {data.get('failed_runs', 0):,} |",
                f"| Flaky | {data.get('flaky_runs', 0):,} |",
                f"| In Progress | {data.get('in_progress_runs', 0):,} |",
                f"| Pass Rate | {data.get('pass_rate', 0):.1%} |",
                f"| Fixes Generated | {data.get('fixes_generated', 0):,} |",
                f"| Fixes Applied | {data.get('fixes_applied', 0):,} |",
                f"| GitHub Connected | {data.get('has_github', False)} |",
            ]
            return _sanitize_tool_output("\n".join(lines), tool_name="dashboard_stats")

        elif action == "flaky":
            params: dict[str, Any] = {
                "min_score": min_score,
                "limit": min(max(limit, 1), 100),
                "offset": max(offset, 0),
                "include_quarantined": include_quarantined,
            }
            if project_id:
                params["project_id"] = project_id
            data, error = await _agent_api_request(ctx, "GET", "/v1/flaky-tests", params=params, timeout=15.0)
            if error or data is None:
                return f"Error fetching flaky tests: {error or 'unknown'}"
            tests = data.get("tests", [])
            if not tests:
                return "No flaky tests found above the minimum score threshold."
            total = data.get("total_count", len(tests))
            lines = [f"**Flaky Tests ({len(tests)} of {total}):**\n"]
            lines.append("| Test | Score | Pass | Fail | Last Failure |")
            lines.append("|------|-------|------|------|-------------|")
            for t in tests:
                name = t.get("test_name", t.get("test_id", "?"))
                if len(name) > 50:
                    name = name[:47] + "..."
                last = str(t.get("last_failure", "N/A"))[:19]
                lines.append(
                    f"| {name} | {t.get('flakiness_score', 0):.2f} "
                    f"| {t.get('pass_count', 0)} | {t.get('fail_count', 0)} "
                    f"| {last} |"
                )
            return _sanitize_tool_output("\n".join(lines), tool_name="dashboard_stats")

        elif action == "trends":
            if not project_id or not test_id:
                return "Error: 'project_id' and 'test_id' are required for trends action."
            data, error = await _agent_api_request(
                ctx, "GET", "/v1/flaky-tests/trends",
                params={"project_id": project_id, "test_id": test_id, "days": days},
                timeout=15.0,
            )
            if error or data is None:
                return f"Error fetching trends: {error or 'unknown'}"
            points = data.get("data_points", [])
            if not points:
                return f"No trend data for test `{test_id}` in the last {days} days."
            lines = [f"**Flakiness Trend for `{test_id}` ({days} days):**\n"]
            lines.append("| Date | Score | Label |")
            lines.append("|------|-------|-------|")
            for p in points:
                lines.append(
                    f"| {str(p.get('timestamp', ''))[:10]} "
                    f"| {p.get('value', 0):.2f} | {p.get('label', '')} |"
                )
            return _sanitize_tool_output("\n".join(lines), tool_name="dashboard_stats")

        elif action == "dx":
            data, error = await _agent_api_request(
                ctx, "GET", "/v1/dx-metrics", params={"days": days}, timeout=20.0,
            )
            if error or data is None:
                return f"Error fetching DX metrics: {error or 'unknown'}"
            ci = data.get("ci_stability", {})
            ttf = data.get("time_to_fix", {})
            prod = data.get("productivity", {})
            lines = [
                f"**DX Metrics ({data.get('time_range', f'{days} days')}):**\n",
                "**CI Stability:**",
                f"  Flake Rate: {ci.get('flake_rate', 0):.1%} ({ci.get('flake_rate_change', 0):+.1%})",
                f"  Stability Score: {ci.get('stability_score', 0):.1f}/100",
                f"  Fixed Flakes: {ci.get('fixed_flakes', 0)}",
                f"  Total Runs: {ci.get('total_runs', 0):,}\n",
                "**Time to Fix:**",
                f"  MTTF: {ttf.get('mttf_seconds', 0) / 3600:.1f}h ({ttf.get('mttf_change_pct', 0):+.0f}%)",
                f"  P50/P90: {ttf.get('p50_seconds', 0) / 3600:.1f}h / {ttf.get('p90_seconds', 0) / 3600:.1f}h",
                f"  Auto-fixed: {ttf.get('auto_fixed_count', 0)} | Manual: {ttf.get('manual_fix_count', 0)}",
                f"  Time Saved: {ttf.get('time_saved_hours', 0):.1f}h\n",
                "**Productivity:**",
                f"  Speed: {prod.get('speed_score', 0):.0f}/100 | Effectiveness: {prod.get('effectiveness_score', 0):.0f}/100",
                f"  Quality: {prod.get('quality_score', 0):.0f}/100 | Impact: {prod.get('impact_score', 0):.0f}/100",
                f"  Overall: {prod.get('overall_score', 0):.0f}/100",
            ]
            top_flaky = data.get("top_flaky_tests", [])
            if top_flaky:
                lines.append(f"\n**Top Flaky Tests ({len(top_flaky)}):**")
                for t in top_flaky[:5]:
                    lines.append(f"  - {t.get('test_name', '?')}: {t.get('flakiness_score', 0):.2f}")
            return _sanitize_tool_output("\n".join(lines), tool_name="dashboard_stats")

        elif action == "regressions":
            params = {"days": days, "limit": min(max(limit, 1), 200)}
            if project_id:
                params["project_id"] = project_id
            if status:
                params["status"] = status
            data, error = await _agent_api_request(ctx, "GET", "/v1/regressions", params=params, timeout=15.0)
            if error or data is None:
                return f"Error fetching regressions: {error or 'unknown'}"
            regs = data.get("regressions", [])
            stats = data.get("stats", {})
            if not regs:
                return "No regressions detected in the given time window."
            lines = [
                f"**Regressions ({stats.get('total', len(regs))}):** "
                f"Critical: {stats.get('critical', 0)} | Warning: {stats.get('warning', 0)} | "
                f"New: {stats.get('new_count', 0)} | Resolved: {stats.get('resolved', 0)}\n",
                "| Test | Metric | Change | Severity | Status |",
                "|------|--------|--------|----------|--------|",
            ]
            for r in regs:
                name = r.get("test_name", "?")
                if len(name) > 40:
                    name = name[:37] + "..."
                lines.append(
                    f"| {name} | {r.get('metric', '?')} "
                    f"| {r.get('change_percent', 0):+.1f}% "
                    f"| {r.get('severity', '?')} | {r.get('status', '?')} |"
                )
            return _sanitize_tool_output("\n".join(lines), tool_name="dashboard_stats")

        else:
            return f"Error: Unknown action '{action}'. Use: overview, flaky, trends, dx, regressions"

    @agent.tool
    async def self_heal(
        ctx: RunContext[HikaflowDeps],
        action: str = "config",
        run_id: Optional[str] = None,
        enabled: Optional[bool] = None,
        auto_pr: Optional[bool] = None,
        auto_merge: Optional[bool] = None,
        min_confidence: Optional[float] = None,
        require_approvals: Optional[int] = None,
        excluded_paths: Optional[list[str]] = None,
        included_branches: Optional[list[str]] = None,
        max_prs_per_day: Optional[int] = None,
        max_fixes_per_test: Optional[int] = None,
        limit: int = 50,
        event_action: Optional[str] = None,
        days: int = 30,
    ) -> str:
        """Manage self-healing: view/update config, trigger healing, view events and stats.

        Actions:
          - config: View current self-healing configuration.
          - update: Update config. Params: enabled, auto_pr, auto_merge, min_confidence, require_approvals, excluded_paths, included_branches, max_prs_per_day, max_fixes_per_test.
          - trigger: Trigger self-healing for a specific run. Params: run_id (required).
          - events: View recent self-healing events. Params: limit, event_action.
          - stats: View self-healing statistics. Params: days.

        Args:
            action: One of: config, update, trigger, events, stats.
            run_id: Run ID (required for trigger).
            enabled: Enable/disable self-healing (for update).
            auto_pr: Auto-create PRs (for update).
            auto_merge: Auto-merge PRs (for update).
            min_confidence: Minimum confidence threshold 0.0-1.0 (for update).
            require_approvals: Required approvals count (for update).
            excluded_paths: Paths to exclude from healing (for update).
            included_branches: Branches to include (for update).
            max_prs_per_day: Max PRs per day (for update).
            max_fixes_per_test: Max fix attempts per test (for update).
            limit: Max events to return (for events).
            event_action: Filter events by action type (for events).
            days: Time window in days (for stats).

        Returns:
            Configuration, event list, stats report, or trigger result.
        """
        if action == "config":
            data, error = await _agent_api_request(ctx, "GET", "/v1/self-heal/config", timeout=15.0)
            if error or data is None:
                return f"Error fetching self-heal config: {error or 'unknown'}"
            lines = [
                "**Self-Healing Configuration:**\n",
                f"  Enabled: {data.get('enabled', False)}",
                f"  Auto-PR: {data.get('auto_pr', False)}",
                f"  Auto-Merge: {data.get('auto_merge', False)}",
                f"  Min Confidence: {data.get('min_confidence', 0.9)}",
                f"  Required Approvals: {data.get('require_approvals', 1)}",
                f"  Max PRs/Day: {data.get('max_prs_per_day', 5)}",
                f"  Max Fixes/Test: {data.get('max_fixes_per_test', 3)}",
                f"  Included Branches: {', '.join(data.get('included_branches', []))}",
                f"  Excluded Paths: {', '.join(data.get('excluded_paths', [])) or 'none'}",
                f"  Slack Webhook: {'configured' if data.get('slack_webhook_url') else 'not set'}",
                f"  Linear Team: {data.get('linear_team_id') or 'not set'}",
            ]
            return _sanitize_tool_output("\n".join(lines), tool_name="self_heal")

        elif action == "update":
            body: dict[str, Any] = {}
            if enabled is not None:
                body["enabled"] = enabled
            if auto_pr is not None:
                body["auto_pr"] = auto_pr
            if auto_merge is not None:
                body["auto_merge"] = auto_merge
            if min_confidence is not None:
                body["min_confidence"] = min_confidence
            if require_approvals is not None:
                body["require_approvals"] = require_approvals
            if excluded_paths is not None:
                body["excluded_paths"] = excluded_paths
            if included_branches is not None:
                body["included_branches"] = included_branches
            if max_prs_per_day is not None:
                body["max_prs_per_day"] = max_prs_per_day
            if max_fixes_per_test is not None:
                body["max_fixes_per_test"] = max_fixes_per_test
            if not body:
                return "Error: Provide at least one field to update (e.g., enabled=True, auto_pr=True)."
            data, error = await _agent_api_request(
                ctx, "PUT", "/v1/self-heal/config", json_body=body, timeout=15.0,
            )
            if error or data is None:
                return f"Error updating self-heal config: {error or 'unknown'}"
            updated_fields = ", ".join(f"{k}={v}" for k, v in body.items())
            return _sanitize_tool_output(
                f"**Self-healing config updated:** {updated_fields}", tool_name="self_heal",
            )

        elif action == "trigger":
            if not run_id:
                return "Error: 'run_id' is required for trigger action."
            data, error = await _agent_api_request(
                ctx, "POST", f"/v1/runs/{run_id}/self-heal", timeout=60.0, attempts=1,
            )
            if error or data is None:
                return f"Error triggering self-heal: {error or 'unknown'}"
            lines = [
                "**Self-Heal Triggered:**\n",
                f"  Run: {data.get('run_id', run_id)}",
                f"  Action: {data.get('action', '?')}",
                f"  Reason: {data.get('reason', 'N/A')}",
            ]
            if data.get("fix_id"):
                lines.append(f"  Fix ID: {data['fix_id']}")
            if data.get("pr_url"):
                lines.append(f"  PR: {data['pr_url']}")
            if data.get("confidence") is not None:
                lines.append(f"  Confidence: {data['confidence']:.2f}")
            return _sanitize_tool_output("\n".join(lines), tool_name="self_heal")

        elif action == "events":
            params: dict[str, Any] = {"limit": min(max(limit, 1), 200)}
            if event_action:
                params["action"] = event_action
            data, error = await _agent_api_request(
                ctx, "GET", "/v1/self-heal/events", params=params, timeout=15.0,
            )
            if error or data is None:
                return f"Error fetching self-heal events: {error or 'unknown'}"
            events = data if isinstance(data, list) else data.get("events", [])
            if not events:
                return "No self-healing events found."
            lines = [
                f"**Self-Healing Events ({len(events)}):**\n",
                "| Test | Action | Confidence | PR | Created |",
                "|------|--------|------------|-----|---------|",
            ]
            for e in events:
                name = e.get("test_name", "?")
                if len(name) > 35:
                    name = name[:32] + "..."
                conf = f"{e.get('confidence', 0):.2f}" if e.get("confidence") is not None else "N/A"
                pr = e.get("pr_url", "N/A")
                if pr and len(pr) > 30:
                    pr = pr[-30:]
                created = str(e.get("created_at", ""))[:19]
                lines.append(f"| {name} | {e.get('action', '?')} | {conf} | {pr} | {created} |")
            return _sanitize_tool_output("\n".join(lines), tool_name="self_heal")

        elif action == "stats":
            data, error = await _agent_api_request(
                ctx, "GET", "/v1/self-heal/stats", params={"days": days}, timeout=15.0,
            )
            if error or data is None:
                return f"Error fetching self-heal stats: {error or 'unknown'}"
            lines = [
                f"**Self-Healing Stats ({days} days):**\n",
                f"  Total Events: {data.get('total_events', 0)}",
                f"  Fixes Generated: {data.get('fixes_generated', 0)}",
                f"  PRs Created: {data.get('prs_created', 0)}",
                f"  PRs Merged: {data.get('prs_merged', 0)}",
                f"  Fix Failures: {data.get('fix_failures', 0)}",
                f"  Skipped: {data.get('skipped', 0)}",
                f"  Success Rate: {data.get('success_rate', 0):.1%}",
                f"  Avg Confidence: {data.get('avg_confidence', 0):.2f}",
            ]
            return _sanitize_tool_output("\n".join(lines), tool_name="self_heal")

        else:
            return f"Error: Unknown action '{action}'. Use: config, update, trigger, events, stats"

    @agent.tool
    async def project_settings(
        ctx: RunContext[HikaflowDeps],
        action: str = "list",
        project_id: Optional[str] = None,
        description: Optional[str] = None,
        repo: Optional[str] = None,
        session_retention_days: Optional[int] = None,
        test_id: Optional[str] = None,
        window: int = 10,
        git_branch: Optional[str] = None,
        offset: int = 0,
        limit: int = 50,
    ) -> str:
        """Manage projects and view test health.

        Actions:
          - list: List all projects. Params: offset, limit.
          - get: Get project details. Params: project_id (uses linked project if omitted).
          - update: Update project settings. Params: project_id, description, repo, session_retention_days.
          - health: Get health for a specific test. Params: project_id, test_id (required), window, git_branch.

        Args:
            action: One of: list, get, update, health.
            project_id: Project ID (uses linked project if omitted for get/update/health).
            description: New project description (for update).
            repo: Repository slug e.g. owner/repo (for update).
            session_retention_days: Days to retain sessions (for update).
            test_id: Test ID for health check (required for health).
            window: Number of recent runs for health (default 10).
            git_branch: Filter health by git branch.
            offset: Pagination offset (for list).
            limit: Max items (for list).

        Returns:
            Project list, details, update confirmation, or test health report.
        """
        if action == "list":
            data, error = await _agent_api_request(
                ctx, "GET", "/v1/projects",
                params={"offset": max(offset, 0), "limit": min(max(limit, 1), 200)},
                timeout=15.0,
            )
            if error or data is None:
                return f"Error listing projects: {error or 'unknown'}"
            projects = data if isinstance(data, list) else data.get("projects", [])
            if not projects:
                return "No projects found. Link a project with 'hikaflow init'."
            lines = [
                f"**Projects ({len(projects)}):**\n",
                "| Name | ID | Runs | Pass Rate | Repo |",
                "|------|----|------|-----------|------|",
            ]
            for p in projects:
                name = p.get("name", "?")
                pid = str(p.get("id", "?"))[:8]
                runs = p.get("runs_count", 0)
                rate = p.get("pass_rate", 0)
                repo_val = p.get("repo", "N/A") or "N/A"
                lines.append(f"| {name} | {pid}... | {runs:,} | {rate:.1%} | {repo_val} |")
            return _sanitize_tool_output("\n".join(lines), tool_name="project_settings")

        elif action == "get":
            pid = project_id or _get_project_id(ctx)
            if not pid:
                return "Error: No project linked. Run 'hikaflow init' or pass project_id."
            data, error = await _agent_api_request(ctx, "GET", f"/v1/projects/{pid}", timeout=15.0)
            if error or data is None:
                return f"Error fetching project: {error or 'unknown'}"
            lines = [
                "**Project Details:**\n",
                f"  ID: {data.get('id', '?')}",
                f"  Name: {data.get('name', '?')}",
                f"  Description: {data.get('description') or 'none'}",
                f"  Repo: {data.get('repo') or 'not linked'}",
                f"  Runs: {data.get('runs_count', 0):,}",
                f"  Pass Rate: {data.get('pass_rate', 0):.1%}",
                f"  Session Retention: {data.get('session_retention_days', 'default')} days",
                f"  Last Run: {str(data.get('last_run', 'N/A'))[:19]}",
            ]
            return _sanitize_tool_output("\n".join(lines), tool_name="project_settings")

        elif action == "update":
            pid = project_id or _get_project_id(ctx)
            if not pid:
                return "Error: No project linked. Run 'hikaflow init' or pass project_id."
            body: dict[str, Any] = {}
            if description is not None:
                body["description"] = description
            if repo is not None:
                body["repo"] = repo
            if session_retention_days is not None:
                body["session_retention_days"] = session_retention_days
            if not body:
                return "Error: Provide at least one field to update (description, repo, session_retention_days)."
            data, error = await _agent_api_request(
                ctx, "PATCH", f"/v1/projects/{pid}", json_body=body, timeout=15.0,
            )
            if error or data is None:
                return f"Error updating project: {error or 'unknown'}"
            updated = ", ".join(f"{k}={v}" for k, v in body.items())
            return _sanitize_tool_output(
                f"**Project updated:** {updated}", tool_name="project_settings",
            )

        elif action == "health":
            pid = project_id or _get_project_id(ctx)
            if not pid:
                return "Error: No project linked. Run 'hikaflow init' or pass project_id."
            if not test_id:
                return "Error: 'test_id' is required for health action."
            params: dict[str, Any] = {"window": window}
            if git_branch:
                params["git_branch"] = git_branch
            data, error = await _agent_api_request(
                ctx, "GET", f"/v1/projects/{pid}/tests/{test_id}/health",
                params=params, timeout=15.0,
            )
            if error or data is None:
                return f"Error fetching test health: {error or 'unknown'}"
            lines = [
                f"**Test Health: `{data.get('test_id', test_id)}`**\n",
                f"  Total Runs: {data.get('total_runs', 0)}",
                f"  Passed: {data.get('pass_count', 0)}",
                f"  Failed: {data.get('fail_count', 0)}",
                f"  Transitions: {data.get('transitions', 0)}",
                f"  Flakiness Score: {data.get('flakiness_score', 0):.2f}",
                f"  Is Flaky: {data.get('is_flaky', False)}",
                f"  Last Run: {str(data.get('last_run_at', 'N/A'))[:19]}",
            ]
            return _sanitize_tool_output("\n".join(lines), tool_name="project_settings")

        else:
            return f"Error: Unknown action '{action}'. Use: list, get, update, health"

    @agent.tool
    async def dora_metrics(
        ctx: RunContext[HikaflowDeps],
        project_id: Optional[str] = None,
        days: int = 30,
    ) -> str:
        """Get DORA (DevOps Research and Assessment) metrics for a project.

        Measures software delivery performance across four key metrics:
        - Deployment Frequency: How often code is deployed to production.
        - Lead Time for Changes: Time from commit to production deployment.
        - Change Failure Rate: Percentage of deployments causing failures.
        - Mean Time to Recovery (MTTR): Time to restore service after failure.

        Each metric is rated Elite, High, Medium, or Low.

        Args:
            project_id: Project to analyze (uses linked project if omitted).
            days: Time window in days (default 30, max 365).

        Returns:
            DORA metrics report with performance levels and benchmarks.
        """
        pid = project_id or _get_project_id(ctx)
        if not pid:
            return "Error: No project linked. Run 'hikaflow init' or pass project_id."
        data, error = await _agent_api_request(
            ctx, "GET", f"/v1/projects/{pid}/dora-metrics",
            params={"days": min(max(days, 7), 365)}, timeout=20.0,
        )
        if error or data is None:
            return f"Error fetching DORA metrics: {error or 'unknown'}"
        level_icons = {"Elite": "****", "High": "*** ", "Medium": "**  ", "Low": "*   "}
        lines = [
            f"**DORA Metrics ({data.get('period_days', days)} days):**\n",
            "| Metric | Value | Level |",
            "|--------|-------|-------|",
            f"| Deployment Frequency | {data.get('deployment_frequency', 0):.1f}/{data.get('deployment_frequency_unit', 'day')} "
            f"| [{level_icons.get(data.get('deployment_frequency_level', ''), '?   ')}] {data.get('deployment_frequency_level', '?')} |",
            f"| Lead Time | {data.get('lead_time_hours', 0):.1f}h "
            f"| [{level_icons.get(data.get('lead_time_level', ''), '?   ')}] {data.get('lead_time_level', '?')} |",
            f"| Change Failure Rate | {data.get('change_failure_rate', 0):.1%} "
            f"| [{level_icons.get(data.get('change_failure_rate_level', ''), '?   ')}] {data.get('change_failure_rate_level', '?')} |",
            f"| MTTR | {data.get('mttr_hours', 0):.1f}h "
            f"| [{level_icons.get(data.get('mttr_level', ''), '?   ')}] {data.get('mttr_level', '?')} |",
            "",
            f"**Overall Level: {data.get('overall_level', '?')}**",
            f"Total Deployments: {data.get('total_deployments', 0)} | Failed: {data.get('failed_deployments', 0)}",
        ]
        benchmarks = data.get("benchmarks", {})
        if benchmarks:
            lines.append("\n**Benchmarks:**")
            for level, desc in benchmarks.items():
                if isinstance(desc, str):
                    lines.append(f"  {level}: {desc}")
        return _sanitize_tool_output("\n".join(lines), tool_name="dora_metrics")

    @agent.tool
    async def notify(
        ctx: RunContext[HikaflowDeps],
        action: str = "slack",
        webhook_url: Optional[str] = None,
        test_name: Optional[str] = None,
        project_name: Optional[str] = None,
        notification_type: str = "flaky_alert",
        error_message: Optional[str] = None,
        flake_rate: Optional[float] = None,
        recent_failures: Optional[int] = None,
        fix_description: Optional[str] = None,
        pr_url: Optional[str] = None,
        run_id: Optional[str] = None,
        risk_score: Optional[float] = None,
        category: Optional[str] = None,
        patterns: Optional[list[str]] = None,
        verification_passed: Optional[bool] = None,
        runs_passed: Optional[int] = None,
        runs_total: Optional[int] = None,
        team_id: Optional[str] = None,
        linear_api_key: Optional[str] = None,
        root_cause: Optional[str] = None,
    ) -> str:
        """Send notifications to Slack or create Linear issues.

        Actions:
          - slack: Send a Slack notification. Params: webhook_url (required), test_name (required), project_name (required), notification_type, plus type-specific fields.
          - linear: Create a Linear issue. Params: team_id (required), linear_api_key (required), test_name (required), project_name (required), flake_rate (required), recent_failures (required).

        Args:
            action: One of: slack, linear.
            webhook_url: Slack webhook URL (required for slack).
            test_name: Test name (required for both).
            project_name: Project name (required for both).
            notification_type: Slack notification type: test_failure, flaky_alert, auto_fix, prediction.
            error_message: Error message (for test_failure).
            flake_rate: Flake rate 0.0-1.0 (for flaky_alert / linear).
            recent_failures: Recent failure count (for flaky_alert / linear).
            fix_description: Fix description (for auto_fix).
            pr_url: PR URL (for auto_fix).
            run_id: Run ID for context.
            risk_score: Risk score (for prediction).
            category: Category (for prediction).
            patterns: Detected patterns (for prediction).
            verification_passed: Whether fix was verified (for auto_fix).
            runs_passed: Verification runs passed (for auto_fix).
            runs_total: Verification total runs (for auto_fix).
            team_id: Linear team ID (required for linear).
            linear_api_key: Linear API key (required for linear).
            root_cause: Root cause analysis (for linear).

        Returns:
            Send confirmation or created issue details.
        """
        if action == "slack":
            if not webhook_url or not test_name or not project_name:
                return "Error: 'webhook_url', 'test_name', and 'project_name' are required for slack."
            valid_types = {"test_failure", "flaky_alert", "auto_fix", "prediction"}
            if notification_type not in valid_types:
                return f"Error: notification_type must be one of: {', '.join(sorted(valid_types))}"
            body: dict[str, Any] = {
                "webhook_url": webhook_url,
                "test_name": test_name,
                "project_name": project_name,
                "notification_type": notification_type,
            }
            for key, val in [
                ("error_message", error_message), ("flake_rate", flake_rate),
                ("recent_failures", recent_failures), ("fix_description", fix_description),
                ("pr_url", pr_url), ("run_id", run_id), ("risk_score", risk_score),
                ("category", category), ("patterns", patterns),
                ("verification_passed", verification_passed),
                ("runs_passed", runs_passed), ("runs_total", runs_total),
            ]:
                if val is not None:
                    body[key] = val
            data, error = await _agent_api_request(
                ctx, "POST", "/v1/integrations/slack/send", json_body=body, timeout=15.0,
            )
            if error or data is None:
                return f"Error sending Slack notification: {error or 'unknown'}"
            success = data.get("success", False)
            return _sanitize_tool_output(
                f"**Slack notification {'sent' if success else 'failed'}:** {data.get('message', '')}",
                tool_name="notify",
            )

        elif action == "linear":
            if not all([team_id, linear_api_key, test_name, project_name]):
                return "Error: 'team_id', 'linear_api_key', 'test_name', and 'project_name' are required for linear."
            if flake_rate is None or recent_failures is None:
                return "Error: 'flake_rate' and 'recent_failures' are required for linear."
            body = {
                "team_id": team_id,
                "test_name": test_name,
                "project_name": project_name,
                "flake_rate": flake_rate,
                "recent_failures": recent_failures,
            }
            if root_cause:
                body["root_cause"] = root_cause
            if run_id:
                body["run_id"] = run_id
            issue, error = await _agent_api_request(
                ctx, "POST", "/v1/integrations/linear/issues",
                json_body=body, timeout=30.0,
                extra_headers={"X-Linear-Api-Key": linear_api_key},
            )
            if error or issue is None:
                return f"Error creating Linear issue: {error or 'unknown'}"
            return _sanitize_tool_output(
                f"**Linear Issue Created:**\n"
                f"  ID: {issue.get('identifier', '?')}\n"
                f"  Title: {issue.get('title', '?')}\n"
                f"  URL: {issue.get('url', 'N/A')}",
                tool_name="notify",
            )

        else:
            return f"Error: Unknown action '{action}'. Use: slack, linear"

    @agent.tool
    async def contract_drift(
        ctx: RunContext[HikaflowDeps],
        days: int = 30,
        limit: int = 100,
    ) -> str:
        """Get API contract drift summary showing schema changes across endpoints.

        Detects added fields, removed fields, and type changes in API responses
        compared to their expected contracts/schemas.

        Args:
            days: Time window in days (default 30).
            limit: Max endpoints to return (default 100).

        Returns:
            Contract drift report with endpoint details and stats.
        """
        data, error = await _agent_api_request(
            ctx, "GET", "/v1/contract-drift/summary",
            params={"days": days, "limit": min(max(limit, 1), 500)},
            timeout=15.0,
        )
        if error or data is None:
            return f"Error fetching contract drift: {error or 'unknown'}"
        endpoints = data.get("endpoints", [])
        stats = data.get("stats", {})
        if not endpoints:
            return f"No contract drift detected in the last {days} days."
        lines = [
            f"**Contract Drift ({days} days):**",
            f"Endpoints: {stats.get('total_endpoints', len(endpoints))} | "
            f"Drifts: {stats.get('total_drifts', 0)} | "
            f"Added: {stats.get('added_fields', 0)} | "
            f"Removed: {stats.get('removed_fields', 0)} | "
            f"Type Changes: {stats.get('type_changes', 0)}\n",
            "| Endpoint | Method | Drifts | Details |",
            "|----------|--------|--------|---------|",
        ]
        for ep in endpoints:
            details_parts = []
            for f in ep.get("fields", [])[:5]:
                dt = f.get("drift_type", "?")
                field_name = f.get("field", "?")
                if dt == "added":
                    details_parts.append(f"+{field_name}")
                elif dt == "removed":
                    details_parts.append(f"-{field_name}")
                elif dt == "type_changed":
                    details_parts.append(f"{field_name}: {f.get('expected_type', '?')}->{f.get('actual_type', '?')}")
                else:
                    details_parts.append(f"{field_name} ({dt})")
            extra = len(ep.get("fields", [])) - 5
            if extra > 0:
                details_parts.append(f"+{extra} more")
            details = ", ".join(details_parts) or "N/A"
            lines.append(
                f"| {ep.get('endpoint', '?')} | {ep.get('method', '?')} "
                f"| {ep.get('drift_count', 0)} | {details} |"
            )
        return _sanitize_tool_output("\n".join(lines), tool_name="contract_drift")

    @agent.tool
    async def performance_baselines(
        ctx: RunContext[HikaflowDeps],
        days: int = 30,
        limit: int = 100,
    ) -> str:
        """Get endpoint performance baselines with latency percentiles and trends.

        Shows p50/p95/p99 latency for each endpoint and whether performance
        is improving, stable, or degrading.

        Args:
            days: Time window in days (default 30).
            limit: Max endpoints to return (default 100).

        Returns:
            Performance baselines report with latency data and trends.
        """
        data, error = await _agent_api_request(
            ctx, "GET", "/v1/performance/baselines",
            params={"days": days, "limit": min(max(limit, 1), 500)},
            timeout=15.0,
        )
        if error or data is None:
            return f"Error fetching performance baselines: {error or 'unknown'}"
        baselines = data.get("baselines", [])
        stats = data.get("stats", {})
        if not baselines:
            return f"No performance baselines recorded in the last {days} days."
        lines = [
            f"**Performance Baselines ({days} days):**",
            f"Endpoints: {stats.get('total_endpoints', len(baselines))} | "
            f"Degrading: {stats.get('degrading', 0)} | "
            f"Improving: {stats.get('improving', 0)} | "
            f"Avg P50: {stats.get('avg_p50_ms', 0):.0f}ms | "
            f"Avg P95: {stats.get('avg_p95_ms', 0):.0f}ms\n",
            "| Endpoint | Method | P50 | P95 | P99 | Samples | Trend |",
            "|----------|--------|-----|-----|-----|---------|-------|",
        ]
        for b in baselines:
            trend = b.get("trend", "stable")
            trend_display = "DEGRADING" if trend == "degrading" else trend
            lines.append(
                f"| {b.get('endpoint', '?')} | {b.get('method', '?')} "
                f"| {b.get('p50_ms', 0):.0f}ms | {b.get('p95_ms', 0):.0f}ms "
                f"| {b.get('p99_ms', 0):.0f}ms | {b.get('sample_count', 0):,} "
                f"| {trend_display} |"
            )
        return _sanitize_tool_output("\n".join(lines), tool_name="performance_baselines")

    @agent.tool
    async def mutation_testing(
        ctx: RunContext[HikaflowDeps],
        action: str = "results",
        project_id: Optional[str] = None,
        paths: Optional[list[str]] = None,
        test_command: str = "pytest",
        max_mutants: int = 100,
        days: int = 30,
        limit: int = 200,
    ) -> str:
        """View mutation testing results or trigger a new mutation test run.

        Mutation testing injects small faults into code and checks if tests
        catch them. High mutation score = high quality tests.

        Actions:
          - results: View recent mutation testing results. Params: days, limit.
          - run: Trigger a new mutation test. Params: project_id, paths (required), test_command, max_mutants. NOTE: Rate limited to 2/hour. Can take several minutes.

        Args:
            action: One of: results, run.
            project_id: Project ID for run (uses linked project if omitted).
            paths: File paths to mutate (required for run).
            test_command: Test command: pytest, python -m pytest, tox, nox, make test.
            max_mutants: Max mutants to generate 1-1000 (default 100).
            days: Time window for results (default 30).
            limit: Max results to return (default 200).

        Returns:
            Mutation testing results or run confirmation.
        """
        if action == "results":
            data, error = await _agent_api_request(
                ctx, "GET", "/v1/mutation-testing/results",
                params={"days": days, "limit": min(max(limit, 1), 1000)},
                timeout=15.0,
            )
            if error or data is None:
                return f"Error fetching mutation testing results: {error or 'unknown'}"
            mutants = data.get("mutants", [])
            stats = data.get("stats", {})
            if not mutants and not stats:
                return f"No mutation testing results in the last {days} days."
            lines = [
                f"**Mutation Testing Results ({days} days):**",
                f"Total: {stats.get('total_mutants', 0)} | "
                f"Killed: {stats.get('killed', 0)} | "
                f"Survived: {stats.get('survived', 0)} | "
                f"Timeout: {stats.get('timeout', 0)} | "
                f"Errors: {stats.get('errors', 0)}",
                f"**Mutation Score: {stats.get('mutation_score', 0):.1f}%**\n",
            ]
            if mutants:
                lines.append("| File | Line | Operator | Status | Killing Test |")
                lines.append("|------|------|----------|--------|-------------|")
                for m in mutants[:50]:
                    file_name = m.get("file", "?")
                    if len(file_name) > 30:
                        file_name = "..." + file_name[-27:]
                    killing = m.get("killing_test", "N/A") or "N/A"
                    if len(killing) > 30:
                        killing = killing[:27] + "..."
                    lines.append(
                        f"| {file_name} | {m.get('line', '?')} "
                        f"| {m.get('operator', '?')} | {m.get('status', '?')} "
                        f"| {killing} |"
                    )
                if len(mutants) > 50:
                    lines.append(f"\n... and {len(mutants) - 50} more mutants")
            return _sanitize_tool_output("\n".join(lines), tool_name="mutation_testing")

        elif action == "run":
            pid = project_id or _get_project_id(ctx)
            if not pid:
                return "Error: No project linked. Run 'hikaflow init' or pass project_id."
            if not paths:
                return "Error: 'paths' is required for run action (list of file paths to mutate)."
            allowed_commands = {"pytest", "python -m pytest", "tox", "nox", "make test"}
            if test_command not in allowed_commands:
                return f"Error: test_command must be one of: {', '.join(sorted(allowed_commands))}"
            data, error = await _agent_api_request(
                ctx, "POST", f"/v1/projects/{pid}/mutation-test",
                json_body={
                    "paths": paths,
                    "test_command": test_command,
                    "max_mutants": min(max(max_mutants, 1), 1000),
                },
                timeout=120.0,
                attempts=1,
            )
            if error or data is None:
                return f"Error running mutation test: {error or 'unknown'}"
            lines = [
                "**Mutation Test Complete:**\n",
                f"  Total Mutants: {data.get('total_mutants', 0)}",
                f"  Killed: {data.get('killed', 0)}",
                f"  Survived: {data.get('survived', 0)}",
                f"  Timeout: {data.get('timeout', 0)}",
                f"  Errors: {data.get('error', 0)}",
                f"  **Mutation Score: {data.get('mutation_score', 0):.1f}%**",
                f"  Quality Rating: {data.get('quality_rating', 'N/A')}",
                f"  Execution Time: {data.get('execution_time_seconds', 0):.1f}s",
                "",
                "Note: Mutation testing is rate limited to 2 runs/hour.",
            ]
            return _sanitize_tool_output("\n".join(lines), tool_name="mutation_testing")

        else:
            return f"Error: Unknown action '{action}'. Use: results, run"

    # --- Persistent data tools (always available, regardless of filesystem sandbox) ---

    @agent.tool
    async def update_findings(
        ctx: RunContext[HikaflowDeps],
        action: Literal["list", "add", "update", "remove"] = "list",
        issue_id: str = "",
        file: str = "",
        line: int = 0,
        severity: Literal["CRITICAL", "HIGH", "MEDIUM", "LOW"] = "MEDIUM",
        description: str = "",
        status: Literal["draft", "open", "fixed", "wontfix"] = "draft",
    ) -> str:
        """Manage the persistent findings tracker (.hikaflow/findings.json).

        Use this to track issues you discover and mark them as fixed after patching.
        Findings persist across sessions and are loaded into your system prompt.

        New findings start as 'draft'. After verifying with code (bash, run_tests),
        promote to 'open' via update_findings(action='update', issue_id='X', status='open').
        Set to 'wontfix' for false alarms. Only 'open' findings appear in final reports.

        Args:
            action: 'list' (show all), 'add' (new finding), 'update' (change status), 'remove' (delete).
            issue_id: Unique ID like 'B-08' or 'D-01'. Required for add/update/remove.
            file: File path (relative). Required for add.
            line: Line number. Required for add.
            severity: CRITICAL, HIGH, MEDIUM, or LOW. Used for add.
            description: One-line description. Required for add.
            status: 'draft' (unverified), 'open' (verified), 'fixed', 'wontfix'. Default: draft.

        Returns:
            Current findings list or confirmation message.
        """
        import json as _json
        from datetime import date

        findings_path = os.path.join(ctx.deps.project_path, ".hikaflow", "findings.json")
        findings: list[dict] = []

        # Load existing
        if os.path.isfile(findings_path):
            try:
                with open(findings_path) as f:
                    data = _json.load(f)
                if isinstance(data, list):
                    findings = data
            except Exception:
                pass

        if action == "list":
            if not findings:
                return "No findings tracked yet. Use update_findings(action='add', ...) to start."
            lines = []
            for f in findings:
                st = f.get("status", "open")
                if st == "fixed":
                    marker = "FIXED"
                elif st == "wontfix":
                    marker = "WONTFIX"
                elif st == "draft":
                    marker = "DRAFT"
                else:
                    marker = f.get("severity", "?")
                lines.append(f"- [{marker}] {f.get('issue_id', '?')}: {f.get('file', '?')}:{f.get('line', '?')} — {f.get('description', '')[:120]}")
            draft_count = sum(1 for f in findings if f.get("status") == "draft")
            open_count = sum(1 for f in findings if f.get("status") == "open")
            fixed_count = sum(1 for f in findings if f.get("status") == "fixed")
            header = f"**{open_count} verified, {draft_count} draft, {fixed_count} fixed / {len(findings)} total findings:**"
            if draft_count:
                header += "\n⚠️ Draft findings are unverified. Run code to confirm, then update status to 'open' or 'wontfix'."
            return header + "\n" + "\n".join(lines)

        if action == "add":
            if not issue_id or not file or not description:
                return "Error: add requires issue_id, file, and description."
            if any(f.get("issue_id") == issue_id for f in findings):
                return f"Error: issue_id '{issue_id}' already exists. Use action='update' to change it."
            findings.append({
                "issue_id": issue_id,
                "file": file,
                "line": line,
                "severity": severity.upper(),
                "status": status,
                "description": description[:300],
                "found_at": str(date.today()),
                "fixed_at": None,
            })

        elif action == "update":
            if not issue_id:
                return "Error: update requires issue_id."
            found = False
            for f in findings:
                if f.get("issue_id") == issue_id:
                    if status:
                        f["status"] = status
                    if status == "fixed":
                        f["fixed_at"] = str(date.today())
                    if description:
                        f["description"] = description[:300]
                    if file:
                        f["file"] = file
                    if line:
                        f["line"] = line
                    found = True
                    break
            if not found:
                return f"Error: issue_id '{issue_id}' not found."

        elif action == "remove":
            if not issue_id:
                return "Error: remove requires issue_id."
            before = len(findings)
            findings = [f for f in findings if f.get("issue_id") != issue_id]
            if len(findings) == before:
                return f"Error: issue_id '{issue_id}' not found."

        else:
            return f"Error: unknown action '{action}'. Use: list, add, update, remove."

        # Save atomically
        try:
            import tempfile
            findings_dir = os.path.join(ctx.deps.project_path, ".hikaflow")
            os.makedirs(findings_dir, exist_ok=True)
            fd, tmp = tempfile.mkstemp(dir=findings_dir, suffix=".tmp")
            try:
                with os.fdopen(fd, "w") as fh:
                    _json.dump(findings, fh, indent=2)
                os.replace(tmp, findings_path)
            except Exception:
                try:
                    os.unlink(tmp)
                except OSError:
                    pass
                raise
        except Exception as e:
            return f"Error saving findings: {e}"

        open_count = sum(1 for f in findings if f.get("status") != "fixed")
        return f"OK. {open_count} open / {len(findings)} total findings. (action={action}, id={issue_id})"

    @agent.tool
    async def load_skill(
        ctx: RunContext[HikaflowDeps],
        name: str,
    ) -> str:
        """Load workflow instructions for a specific skill.

        Use this when you want to switch modes mid-conversation.
        Available skills: investigate, scan, fix_flaky, production_debug, deep_dive, self_analysis.

        Args:
            name: The skill name to load.

        Returns:
            The skill content, or an error message.
        """
        from autodebug.agent.skills import load_skill_content, get_skill_names

        available = get_skill_names()
        if name not in available:
            return f"Unknown skill '{name}'. Available: {', '.join(available)}"

        content = load_skill_content(name)
        if content is None:
            return f"Failed to load skill '{name}'."

        ctx.deps._active_skill_content = content
        return f"Loaded skill '{name}'. Instructions are now active."

    @agent.tool
    async def memory_insert(
        ctx: RunContext[HikaflowDeps],
        block: str,
        content: str,
    ) -> str:
        """Append content to a working context block.

        Blocks: project_context (2000 chars), session_state (1000 chars),
        user_preferences (500 chars). active_findings is auto-populated.

        Use this to add new information without replacing existing content.

        Args:
            block: Block name (project_context, session_state, user_preferences).
            content: Text to append.

        Returns:
            Status message with current block size.
        """
        if ctx.deps._memory_blocks is None:
            return "Error: memory blocks not initialized."
        return ctx.deps._memory_blocks.insert(block, content)

    @agent.tool
    async def memory_replace(
        ctx: RunContext[HikaflowDeps],
        block: str,
        old_content: str,
        new_content: str,
    ) -> str:
        """Replace specific text within a working context block.

        Use this for targeted edits — updating a hypothesis, correcting a note,
        or refining project context.

        Args:
            block: Block name (project_context, session_state, user_preferences).
            old_content: Exact text to find and replace (must match exactly).
            new_content: Replacement text.

        Returns:
            Status message with current block size.
        """
        if ctx.deps._memory_blocks is None:
            return "Error: memory blocks not initialized."
        return ctx.deps._memory_blocks.replace(block, old_content, new_content)

    @agent.tool
    async def memory_rethink(
        ctx: RunContext[HikaflowDeps],
        block: str,
        new_content: str,
    ) -> str:
        """Completely rewrite a working context block.

        Use this when the block content is outdated or needs a fresh start.
        WARNING: This replaces ALL existing content in the block.

        Args:
            block: Block name (project_context, session_state, user_preferences).
            new_content: Complete new content for the block.

        Returns:
            Status message with current block size.
        """
        if ctx.deps._memory_blocks is None:
            return "Error: memory blocks not initialized."
        return ctx.deps._memory_blocks.rethink(block, new_content)

    @agent.tool
    async def save_memory(
        ctx: RunContext[HikaflowDeps],
        content: str,
        append: bool = False,
    ) -> str:
        """Save to project_context memory block. Use memory_rethink/memory_insert instead.

        Args:
            content: Content to save.
            append: If True, appends instead of overwriting.

        Returns:
            Status message.
        """
        if ctx.deps._memory_blocks is not None:
            if append:
                return ctx.deps._memory_blocks.insert("project_context", content)
            return ctx.deps._memory_blocks.rethink("project_context", content)

        # Legacy fallback when memory blocks not initialized
        memory_dir = os.path.join(ctx.deps.project_path, ".hikaflow")
        memory_path = os.path.join(memory_dir, "memory.md")
        try:
            os.makedirs(memory_dir, exist_ok=True)
            if append and os.path.isfile(memory_path):
                with open(memory_path, "r", encoding="utf-8") as f:
                    existing = f.read()
                content = existing.rstrip() + "\n\n" + content
            with open(memory_path, "w", encoding="utf-8") as f:
                f.write(content)
            return f"Memory saved ({len(content)} chars)."
        except Exception as e:
            return f"Error saving memory: {e}"


    @agent.tool
    async def search_reflections(
        ctx: RunContext[HikaflowDeps],
        query: str,
        limit: int = 5,
    ) -> str:
        """Search past session reflections by keyword.

        Args:
            query: Keyword to search for in past tasks and reflections.
            limit: Maximum number of results (default 5).

        Returns:
            Matching reflections or 'no matches found'.
        """
        from autodebug.agent.reflections import search_reflections as _search
        matches = _search(ctx.deps.project_path, query, limit=limit)
        if not matches:
            return f"No past reflections matching '{query}'."
        lines = []
        for r in matches:
            ts = r.timestamp[:10] if len(r.timestamp) >= 10 else r.timestamp
            lines.append(f"[{ts}] {r.outcome}: {r.task}")
            lines.append(f"  {r.reflection[:200]}")
        return "\n".join(lines)

    @agent.tool
    async def save_reflection(
        ctx: RunContext[HikaflowDeps],
        reflection: str,
        task: str = "",
    ) -> str:
        """Save a reflection about an insight or lesson learned.

        Args:
            reflection: The lesson or insight to remember.
            task: Short description of what was being worked on.

        Returns:
            Status message.
        """
        from autodebug.agent.reflections import save_reflection as _save
        session_id = ""
        if ctx.deps._memory_blocks is not None:
            session_id = ctx.deps._memory_blocks.session_id
        try:
            _save(
                project_path=ctx.deps.project_path,
                session_id=session_id,
                task=task or "manual reflection",
                outcome="partial",
                reflection=reflection,
            )
            return "Reflection saved."
        except Exception as e:
            return f"Error saving reflection: {e}"

    # --- Dynamic skill library tools ---

    @agent.tool
    async def create_skill(
        ctx: RunContext[HikaflowDeps],
        description: str,
        procedure: list[str],
        tags: list[str] | None = None,
    ) -> str:
        """Save a reusable debugging procedure to the skill library.

        Args:
            description: Short description of what this skill does.
            procedure: List of step-by-step instructions.
            tags: Optional keywords for search (e.g. ["async", "race"]).

        Returns:
            Confirmation with skill_id.
        """
        from autodebug.agent.skill_library import save_skill
        session_id = ""
        if ctx.deps._memory_blocks is not None:
            session_id = ctx.deps._memory_blocks.session_id
        try:
            skill_id = save_skill(
                project_path=ctx.deps.project_path,
                description=description,
                procedure=procedure,
                tags=tags,
                session_id=session_id,
            )
            return f"Skill saved: {skill_id}"
        except Exception as e:
            return f"Error saving skill: {e}"

    @agent.tool
    async def search_skills(
        ctx: RunContext[HikaflowDeps],
        query: str,
        limit: int = 3,
    ) -> str:
        """Search the dynamic skill library for relevant procedures.

        Args:
            query: Keywords to search (e.g. "async race condition").
            limit: Max results to return (default 3).

        Returns:
            Matching skills with descriptions and success rates.
        """
        from autodebug.agent.skill_library import search_skills as _search
        try:
            results = _search(ctx.deps.project_path, query, limit=limit)
            if not results:
                return "No matching skills found."
            lines = []
            for s in results:
                badge = " [verified]" if s.verified else ""
                rate = f" ({s.success_rate:.0%} success)" if s.times_used > 0 else ""
                lines.append(f"- **{s.skill_id}**{badge}{rate}: {s.description}")
            return "\n".join(lines)
        except Exception as e:
            return f"Error searching skills: {e}"

    @agent.tool
    async def use_skill(
        ctx: RunContext[HikaflowDeps],
        skill_id: str,
    ) -> str:
        """Load a skill's full procedure into context for use.

        Args:
            skill_id: The skill ID to load (e.g. "debug-async-race-condition").

        Returns:
            The full procedure steps.
        """
        from autodebug.agent.skill_library import get_skill
        try:
            skill = get_skill(ctx.deps.project_path, skill_id)
            if not skill:
                return f"Skill not found: {skill_id}"
            lines = [f"## Skill: {skill.description}"]
            if skill.tags:
                lines.append(f"Tags: {', '.join(skill.tags)}")
            lines.append("")
            for i, step in enumerate(skill.procedure, 1):
                lines.append(f"{i}. {step}")
            lines.append("")
            lines.append(
                f"After using this skill, call record_skill_outcome('{skill_id}', success=True/False) "
                "to track whether it helped."
            )
            return "\n".join(lines)
        except Exception as e:
            return f"Error loading skill: {e}"

    @agent.tool
    async def record_skill_outcome(
        ctx: RunContext[HikaflowDeps],
        skill_id: str,
        success: bool,
    ) -> str:
        """Record whether a skill helped solve the problem.

        Args:
            skill_id: The skill that was used.
            success: True if the skill helped, False if not.

        Returns:
            Status message.
        """
        from autodebug.agent.skill_library import record_skill_outcome as _record
        try:
            found = _record(ctx.deps.project_path, skill_id, success)
            if not found:
                return f"Skill not found: {skill_id}"
            return f"Recorded {'success' if success else 'failure'} for skill {skill_id}."
        except Exception as e:
            return f"Error recording outcome: {e}"



    @agent.tool
    async def incident_overview(
        ctx: RunContext[HikaflowDeps],
        incident_id: Optional[str] = None,
        environment: Literal["production", "staging", "canary"] = "production",
        lookback_minutes: int = 60,
        include_transcript: bool = False,
        similarity_focus: Literal["stack", "transcript", "regression"] = "stack",
    ) -> str:
        """Return a structured incident overview for compact profile workflows."""
        if not _get_project_id(ctx):
            return _no_project_envelope("incident_overview", ["codebase_scan", "run_tests", "review_files"])
        allowed_envs = {"production", "staging", "canary"}
        allowed_focus = {"stack", "transcript", "regression"}
        env = (environment or ctx.deps.environment or "production").strip().lower()
        if env not in allowed_envs:
            return _tool_envelope(
                "error",
                "Invalid environment. Use production|staging|canary.",
                {
                    "incident_snapshot": {"incident_id": incident_id, "environment": env, "similarity_focus": similarity_focus},
                    "context": {"lookback_minutes": 60},
                    "related_failures": [],
                    "replay_status": None,
                    "suggested_next_steps": [],
                },
                ["Re-call with a valid environment value."],
                0.0,
                "incident_overview",
            )
        focus = (similarity_focus or "stack").strip().lower()
        if focus not in allowed_focus:
            return _tool_envelope(
                "error",
                "Invalid similarity_focus. Use stack|transcript|regression.",
                {
                    "incident_snapshot": {"incident_id": incident_id, "environment": env, "similarity_focus": focus},
                    "context": {"lookback_minutes": 60},
                    "related_failures": [],
                    "replay_status": None,
                    "suggested_next_steps": [],
                },
                ["Re-call with a valid similarity_focus value."],
                0.0,
                "incident_overview",
            )
        clamped_lookback = max(5, min(int(lookback_minutes), 1440))
        clamped = clamped_lookback != int(lookback_minutes)
        errors_raw = await fetch_production_errors(ctx, limit=10, environment=env)
        details_raw = ""
        transcript_raw = ""
        similar_raw = ""

        if incident_id:
            details_raw = await get_error_details(ctx, error_id=incident_id)
            if include_transcript:
                transcript_raw = await view_io_transcript(ctx, error_id=incident_id, io_type="all")
            similar_raw = await search_similar_fixes(ctx, error_id=incident_id, limit=5)

        status = "ok"
        if _looks_like_error(errors_raw):
            status = "error"
        elif any(_looks_like_error(x) for x in [details_raw, transcript_raw, similar_raw] if x):
            status = "partial"
        if clamped and status == "ok":
            status = "partial"

        related_failures: list[dict[str, str]] = []
        if similar_raw:
            related_failures.append({"source": "search_similar_fixes", "snippet": similar_raw[:4000]})
        suggested_next_steps = []
        if incident_id:
            suggested_next_steps.append(
                f"Call failure_similarity_search(error_id='{incident_id}') to find similar past failures."
            )
        else:
            suggested_next_steps.append(
                "Pick an error_id from the errors above, then call failure_similarity_search(error_id=<id>)."
            )
        suggested_next_steps.append("Use generate_fix_candidates once root cause is clear.")
        if clamped:
            suggested_next_steps.append("lookback_minutes was clamped to allowed range 5..1440.")
        data = {
            "incident_snapshot": {"incident_id": incident_id, "environment": env, "similarity_focus": focus},
            "context": {
                "lookback_minutes": clamped_lookback,
                "errors_raw": errors_raw[:6000],
                "details_raw": details_raw[:6000] if details_raw else "",
                "transcript_raw": transcript_raw[:6000] if transcript_raw else "",
            },
            "related_failures": related_failures,
            "replay_status": None,
            "suggested_next_steps": suggested_next_steps,
        }
        summary = "Incident overview generated." if status != "error" else "Failed to build incident overview."
        return _tool_envelope(status, summary, data, data["suggested_next_steps"], 0.8 if status != "error" else 0.2, "incident_overview")

    @agent.tool
    async def failure_similarity_search(
        ctx: RunContext[HikaflowDeps],
        error_id: Optional[str] = None,
        error_type: Optional[str] = None,
        error_message: Optional[str] = None,
        category: Literal["crash", "flake", "performance", "security"] = "crash",
        run_path: Optional[str] = None,
        limit: int = 10,
        include_patterns: bool = True,
    ) -> str:
        """Find similar failures with structured output.

        Requires error_id OR both error_type and error_message.
        """
        if not _get_project_id(ctx):
            return _no_project_envelope("failure_similarity_search", ["codebase_scan", "run_tests"])
        allowed_categories = {"crash", "flake", "performance", "security"}
        cat = (category or "crash").strip().lower()
        if cat not in allowed_categories:
            return _tool_envelope(
                "error",
                "Invalid category. Use crash|flake|performance|security.",
                {"matches": [], "source_mix": {}, "context": {"category": cat}},
                ["Re-call with a valid category."],
                0.0,
                "failure_similarity_search",
            )
        if run_path:
            _, path_err = _check_path_security(ctx, run_path)
            if path_err:
                return _tool_envelope(
                    "error",
                    "run_path must be repo-scoped.",
                    {"matches": [], "source_mix": {}, "context": {"run_path": run_path}},
                    ["Provide a repo-relative run_path."],
                    0.0,
                    "failure_similarity_search",
                )
        limit = max(1, min(int(limit), 50))
        if not error_id and not (error_type and error_message):
            return _tool_envelope(
                "error",
                "error_id or (error_type + error_message) is required.",
                {"matches": []},
                ["Provide error_id, or both error_type and error_message."],
                0.0,
                "failure_similarity_search",
            )
        similar_raw = await search_similar_fixes(
            ctx,
            error_id=error_id,
            exception_type=error_type,
            limit=limit,
        )
        patterns_raw = ""
        if include_patterns and run_path:
            patterns_raw = await patterns(ctx, action="match", run_path=run_path, max_results=min(limit, 10))
        status = "ok"
        if _looks_like_error(similar_raw) and (not patterns_raw or _looks_like_error(patterns_raw)):
            status = "error"
        elif _looks_like_error(similar_raw) or (patterns_raw and _looks_like_error(patterns_raw)):
            status = "partial"
        matches: list[dict[str, Any]] = []
        if similar_raw and not _looks_like_error(similar_raw):
            matches.append({"source": "similar_fixes", "rank": 1, "score": 0.6, "snippet": similar_raw[:2000]})
        if patterns_raw and not _looks_like_error(patterns_raw):
            matches.append({"source": "patterns", "rank": len(matches) + 1, "score": 0.5, "snippet": patterns_raw[:2000]})
        data = {
            "matches": matches,
            "source_mix": {
                "similar_fixes": similar_raw[:6000],
                "patterns": patterns_raw[:6000] if patterns_raw else "",
            },
            "context": {
                "error_id": error_id,
                "error_type": error_type,
                "category": cat,
            },
            "fallback_notes": [] if status == "ok" else ["One or more match sources failed or were unavailable."],
        }
        _eid = error_id or ""
        _next = []
        if _eid and status != "error":
            _next.append(f"Call generate_fix_candidates(error_id='{_eid}') to propose fixes.")
        elif status == "error":
            _next.append("Call incident_overview to get a valid error_id first.")
        else:
            _next.append("Call generate_fix_candidates with the error_id and root_cause from matches above.")
        return _tool_envelope(
            status,
            "Similarity search completed." if status != "error" else "Similarity search failed.",
            data,
            _next,
            0.75 if status == "ok" else 0.4 if status == "partial" else 0.1,
            "failure_similarity_search",
        )

    @agent.tool
    async def generate_fix_candidates(
        ctx: RunContext[HikaflowDeps],
        run_id: Optional[str] = None,
        error_id: Optional[str] = None,
        root_cause: str = "",
        category: Literal["logic", "config", "infra", "performance", "security"] = "logic",
        code_context: str = "",
        similar_fix_ids: Optional[list[str]] = None,
        max_candidates: int = 5,
    ) -> str:
        """Generate structured fix candidates."""
        if not _get_project_id(ctx):
            return _no_project_envelope("generate_fix_candidates", ["codebase_scan", "review_files"])
        allowed_categories = {"logic", "config", "infra", "performance", "security"}
        cat = (category or "logic").strip().lower()
        if cat not in allowed_categories:
            return _tool_envelope(
                "error",
                "Invalid category. Use logic|config|infra|performance|security.",
                {"candidates": [], "ranking_reason": "", "safety_notes": [], "no_fix_reason": "invalid_category"},
                ["Re-call with a valid category."],
                0.0,
                "generate_fix_candidates",
            )
        max_candidates = max(1, min(int(max_candidates), 12))
        chosen_error_id = (error_id or run_id or "").strip()
        if not chosen_error_id:
            return _tool_envelope(
                "error",
                "error_id (or run_id) is required for candidate generation.",
                {"candidates": [], "no_fix_reason": "missing_error_or_run_id"},
                ["Provide error_id from incident_overview."],
                0.0,
                "generate_fix_candidates",
            )
        hypo_raw = await generate_fix_hypothesis(ctx, error_id=chosen_error_id)
        similar_raw = await search_similar_fixes(ctx, error_id=chosen_error_id, limit=max_candidates)
        status = "ok" if not _looks_like_error(hypo_raw) else "error"
        if status == "ok" and _looks_like_error(similar_raw):
            status = "partial"
        candidates: list[dict[str, Any]] = []
        if hypo_raw and not _looks_like_error(hypo_raw):
            candidates.append(
                {
                    "id": "cand-1",
                    "strategy": cat,
                    "confidence": 0.65 if status == "ok" else 0.45,
                    "proposal": hypo_raw[:2500],
                }
            )
        if similar_raw and not _looks_like_error(similar_raw) and len(candidates) < max_candidates:
            candidates.append(
                {
                    "id": "cand-similar",
                    "strategy": "historical",
                    "confidence": 0.55,
                    "proposal": similar_raw[:2000],
                }
            )
        if not candidates and status != "error":
            status = "partial"
        data = {
            "candidates": candidates,
            "ranking_reason": "Prioritize candidates with stronger confidence and replay viability.",
            "safety_notes": ["Review patch scope before apply.", "Run validate_fix_candidate before PR."],
            "no_fix_reason": "" if candidates else ("Hypothesis generation failed." if status == "error" else "No candidate could be materialized from current context."),
            "raw_hypotheses": hypo_raw[:9000],
            "raw_similar": similar_raw[:6000],
            "context": {
                "error_id": chosen_error_id,
                "root_cause": root_cause[:2000],
                "category": cat,
                "code_context": code_context[:12000],
                "similar_fix_ids": (similar_fix_ids or [])[:20],
            },
        }
        _gen_next = []
        if candidates and chosen_error_id:
            _gen_next.append(
                f"Call validate_fix_candidate(error_id='{chosen_error_id}', patch=<candidate.proposal>) to test the top fix."
            )
        elif status == "error":
            _gen_next.append("Call failure_similarity_search to gather more context before retrying.")
        else:
            _gen_next.append("No candidates generated. Try providing more code_context or root_cause detail.")
        return _tool_envelope(
            status,
            "Fix candidates generated." if status != "error" else "Fix candidate generation failed.",
            data,
            _gen_next,
            0.7 if status == "ok" else 0.4 if status == "partial" else 0.1,
            "generate_fix_candidates",
        )

    @agent.tool
    async def validate_fix_candidate(
        ctx: RunContext[HikaflowDeps],
        error_id: str,
        patch: str,
        test_targets: Optional[list[str]] = None,
        test_quick: bool = True,
        test_maxfail: int = 1,
        contract_scope: Literal["unit", "integration", "full"] = "unit",
        perf_scope: Literal["baseline", "regression"] = "baseline",
        mutate: bool = False,
    ) -> str:
        """Validate a candidate fix with replay/tests and optional gates."""
        if not _get_project_id(ctx):
            return _no_project_envelope("validate_fix_candidate", ["run_tests"])
        contract_scope = (contract_scope or "unit").strip().lower()
        perf_scope = (perf_scope or "baseline").strip().lower()
        validation_raw = await validate_fix(ctx, error_id=error_id, patch=patch, runs=10)
        gate_notes: list[str] = []
        test_results: list[str] = []
        for p in (test_targets or [])[:5]:
            test_results.append(
                await run_tests(
                    ctx,
                    path=p,
                    quick=test_quick,
                    maxfail=max(1, min(int(test_maxfail), 50)),
                )
            )
        contract_raw = ""
        perf_raw = ""
        mutation_raw = ""
        if contract_scope in {"unit", "integration", "full"}:
            contract_raw = await contract_drift(ctx, days=30, limit=100)
        else:
            gate_notes.append("contract_scope invalid; contract gate skipped.")
        if perf_scope in {"baseline", "regression"}:
            perf_raw = await performance_baselines(ctx, days=30)
        else:
            gate_notes.append("perf_scope invalid; perf gate skipped.")
        if mutate:
            mutation_raw = await mutation_testing(ctx, action="results", days=30)
        else:
            gate_notes.append("Mutation gate skipped (mutate=false).")

        parts = [validation_raw, *test_results, contract_raw, perf_raw, mutation_raw]
        status = "ok"
        if _looks_like_error(validation_raw):
            status = "error"
        elif any(_looks_like_error(p) for p in parts[1:] if p):
            status = "partial"
        if gate_notes and status == "ok":
            status = "partial"
        data = {
            "validation_verdict": validation_raw[:6000],
            "replay_summary": validation_raw[:3000],
            "test_summary": test_results[:5],
            "contract_summary": contract_raw[:3000] if contract_raw else None,
            "performance_summary": perf_raw[:3000] if perf_raw else None,
            "mutation_summary": mutation_raw[:3000] if mutation_raw else None,
            "gate_notes": gate_notes,
        }
        _val_next = []
        if status == "ok":
            _val_next.append("Validation passed. Call create_fix_pr(fix_id=<id>, confirm=true) to create a PR.")
        elif status == "partial":
            _val_next.append("Validation partially passed. Review gate_notes before calling create_fix_pr.")
        else:
            _val_next.append("Validation failed. Revise the patch or call generate_fix_candidates for alternatives.")
        return _tool_envelope(
            status,
            "Candidate validation completed." if status != "error" else "Candidate validation failed.",
            data,
            _val_next,
            0.8 if status == "ok" else 0.5 if status == "partial" else 0.15,
            "validate_fix_candidate",
        )

    @agent.tool
    async def create_fix_pr(
        ctx: RunContext[HikaflowDeps],
        run_id: Optional[str] = None,
        fix_id: Optional[str] = None,
        repo_owner: str = "",
        repo_name: str = "",
        base_branch: str = "main",
        title: str = "",
        body: str = "",
        draft: bool = False,
        confirm: bool = False,
    ) -> str:
        """Create a PR from a fix with structured output."""
        if not _get_project_id(ctx):
            return _no_project_envelope("create_fix_pr", ["run_tests", "review_files"])
        if not (run_id or fix_id):
            return _tool_envelope(
                "error",
                "run_id or fix_id is required.",
                {},
                ["Provide run_id or fix_id from previous steps."],
                0.0,
                "create_fix_pr",
            )
        if not confirm:
            return _tool_envelope(
                "error",
                "Confirmation required to create/push PR branch.",
                {"requires_confirmation": True},
                ["Re-call with confirm=true after review."],
                0.0,
                "create_fix_pr",
            )
        if not repo_owner.strip() or not repo_name.strip():
            return _tool_envelope(
                "error",
                "repo_owner and repo_name are required.",
                {},
                ["Provide repo_owner and repo_name for server PR creation."],
                0.0,
                "create_fix_pr",
            )

        pr_url = ""
        pr_number: Optional[int] = None
        branch = f"fix-{(fix_id or run_id or 'change')[:32]}".replace("/", "-")
        deduped = False
        raw_result = ""
        status = "ok"
        next_actions = ["Check remote permissions and repository installation if failed."]

        # Preferred path: server-side PR creation (credentials remain server-side).
        if fix_id:
            data, err = await _agent_api_request(
                ctx,
                "POST",
                f"/v1/fixes/{fix_id}/create-pr",
                json_body={
                    "owner": repo_owner.strip(),
                    "repo": repo_name.strip(),
                    "base_branch": (base_branch or "main").strip(),
                    "title": (title or "").strip() or None,
                    "body": (body or "").strip() or None,
                },
                timeout=120.0,
                attempts=1,
            )
        else:
            data, err = await _agent_api_request(
                ctx,
                "POST",
                f"/v1/runs/{run_id}/create-fix-pr",
                json_body={
                    "repo_owner": repo_owner.strip(),
                    "repo_name": repo_name.strip(),
                    "base_branch": (base_branch or "main").strip(),
                },
                timeout=120.0,
                attempts=1,
            )

        if err:
            # Admin fallback path for local repos when server route is unavailable.
            fallback_notes = [f"Server PR flow failed: {err[:180]}"]
            pr_title = title or f"Fix {(fix_id or run_id)}"
            pr_body = body or "Automated fix PR generated by compact tool."
            pr_raw = await create_pull_request(
                ctx,
                title=pr_title,
                body=pr_body,
                branch=branch,
                require_confirmation=False,
            )
            raw_result = pr_raw[:3000]
            if _looks_like_error(pr_raw):
                status = "error"
                next_actions = fallback_notes + next_actions
            else:
                status = "partial"
                pr_url = _extract_first_http_url(pr_raw)
                next_actions = ["PR created via local fallback; verify branch and PR metadata."]
        else:
            payload = data or {}
            if isinstance(payload, dict):
                pr_url = str(payload.get("pr_url") or payload.get("url") or "")
                maybe_number = payload.get("pr_number") or payload.get("number")
                try:
                    pr_number = int(maybe_number) if maybe_number is not None else None
                except Exception:
                    pr_number = None
                branch = str(payload.get("branch") or payload.get("branch_name") or branch)
                deduped = bool(payload.get("deduped", False))
                raw_result = json.dumps(payload, ensure_ascii=True)[:3000]
            else:
                raw_result = str(payload)[:3000]

        if status != "error" and not pr_url:
            status = "partial"

        data = {
            "pr_url": pr_url,
            "pr_number": pr_number,
            "branch_name": branch,
            "deduped": deduped,
            "proof_reference": {"run_id": run_id, "fix_id": fix_id, "base_branch": base_branch, "draft": draft},
            "repo": {"owner": repo_owner, "name": repo_name},
            "raw_result": raw_result,
        }
        return _tool_envelope(
            status,
            "PR creation completed." if status == "ok" else ("PR creation partially completed." if status == "partial" else "PR creation failed."),
            data,
            next_actions,
            0.85 if status == "ok" else 0.55 if status == "partial" else 0.2,
            "create_fix_pr",
        )

    @agent.tool
    async def quarantine_control(
        ctx: RunContext[HikaflowDeps],
        scope: Literal["local", "server"] = "local",
        action: Literal["list", "add", "remove", "deselect"] = "list",
        nodeids: Optional[list[str]] = None,
        test_id: Optional[str] = None,
        reason: Optional[str] = None,
        expires_days: int = 14,
        project_id: Optional[str] = None,
    ) -> str:
        """Unified quarantine control with structured output."""
        allowed_scopes = {"local", "server"}
        allowed_actions = {"list", "add", "remove", "deselect"}
        scope = (scope or "local").strip().lower()
        action = (action or "list").strip().lower()
        if scope not in allowed_scopes:
            return _tool_envelope(
                "error",
                "Invalid scope. Use local|server.",
                {"scope": scope, "action": action, "tests": []},
                ["Re-call with a valid scope."],
                0.0,
                "quarantine_control",
            )
        if action not in allowed_actions:
            return _tool_envelope(
                "error",
                "Invalid action. Use list|add|remove|deselect.",
                {"scope": scope, "action": action, "tests": []},
                ["Re-call with a valid action."],
                0.0,
                "quarantine_control",
            )
        if action == "add" and not test_id:
            return _tool_envelope(
                "error",
                "add action requires test_id.",
                {"scope": scope, "action": action, "tests": []},
                ["Provide test_id for add."],
                0.0,
                "quarantine_control",
            )
        if action == "remove" and not (test_id or nodeids):
            return _tool_envelope(
                "error",
                "remove action requires test_id or nodeids.",
                {"scope": scope, "action": action, "tests": []},
                ["Provide test_id or nodeids for remove."],
                0.0,
                "quarantine_control",
            )
        mapped_action = "deselect_args" if action == "deselect" else action
        raw = await quarantine(
            ctx,
            action=mapped_action,
            scope=scope,
            nodeids=nodeids,
            test_id=test_id,
            reason=reason,
            expires_days=max(1, min(int(expires_days), 90)),
        )
        status = "ok" if not _looks_like_error(raw) else "error"
        data = {
            "scope": scope,
            "action": action,
            "tests": [],
            "deselect_args": raw if mapped_action == "deselect_args" else None,
            "error_code": "invalid_action_or_scope" if status == "error" else None,
            "raw": raw[:6000],
            "project_id": project_id or _get_project_id(ctx),
        }
        _q_next = {
            "add": ["Call quarantine_control(action='list') to verify quarantine state."],
            "remove": ["Call quarantine_control(action='list') to verify removal."],
            "list": ["Call quarantine_control(action='add', test_id=<id>) to quarantine a flaky test."],
            "deselect": ["Call run_tests to verify deselected tests are excluded."],
        }
        return _tool_envelope(
            status,
            "Quarantine action completed." if status == "ok" else "Quarantine action failed.",
            data,
            _q_next.get(action, ["Call quarantine_control(action='list') to inspect current state."]),
            0.85 if status == "ok" else 0.2,
            "quarantine_control",
        )

    # NOTE: codebase_scan is superseded by scan_for_bugs (builtin) in compact profile.
    # Kept here for admin profile and pydantic-ai backend compatibility.
    @agent.tool
    async def codebase_scan(
        ctx: RunContext[HikaflowDeps],
        force: bool = False,
        scope: Literal["incremental", "full"] = "incremental",
        max_findings: int = 100,
        include_semgrep: bool = True,
        page: int = 1,
        continuation_token: Optional[str] = None,
        mode: Literal["full", "semgrep-only", "security-only", "taint-only", "hygiene-only", "api-only"] = "full",
    ) -> str:
        """Run structured codebase scan output for compact profile usage."""
        allowed_scope = {"incremental", "full"}
        normalized_scope = (scope or "incremental").strip().lower()

        # Decode continuation token if provided — overrides scope/page/max_findings
        if continuation_token:
            _token_str = str(continuation_token).strip()
            _token_parts = _token_str.split(":")
            if len(_token_parts) != 4 or _token_parts[0] != "scan":
                return _tool_envelope(
                    "error",
                    f"Invalid continuation_token format: '{_token_str}'. Expected scan:<scope>:<page>:<page_size>.",
                    {"findings": [], "severity_counts": {}, "scan_meta": {"continuation_token": _token_str}},
                    ["Re-call without continuation_token or use a token from a previous scan response."],
                    0.0,
                    "codebase_scan",
                )
            try:
                _token_scope = _token_parts[1]
                _token_page = int(_token_parts[2])
                _token_page_size = int(_token_parts[3])
            except (ValueError, IndexError):
                return _tool_envelope(
                    "error",
                    f"Malformed continuation_token: non-integer page or page_size in '{_token_str}'.",
                    {"findings": [], "severity_counts": {}, "scan_meta": {"continuation_token": _token_str}},
                    ["Re-call without continuation_token or use a valid token."],
                    0.0,
                    "codebase_scan",
                )
            if _token_scope in allowed_scope:
                normalized_scope = _token_scope
            page = _token_page
            max_findings = _token_page_size

        if normalized_scope not in allowed_scope:
            return _tool_envelope(
                "error",
                "Invalid scope. Use incremental|full.",
                {"findings": [], "severity_counts": {}, "scan_meta": {"scope": normalized_scope}},
                ["Re-call with a valid scope."],
                0.0,
                "codebase_scan",
            )
        test_mode = os.getenv("PYTEST_CURRENT_TEST") is not None or os.getenv("ENVIRONMENT") == "test"
        if test_mode:
            include_semgrep = False

        if include_semgrep:
            raw = await scan_codebase(ctx, force=force, mode=mode)
        else:
            raw = (
                "Semgrep-disabled compact scan mode is active. "
                "Run scan_codebase(force=true) for full static-analysis coverage."
            )
        status = "ok" if not _looks_like_error(raw) else "error"
        if not include_semgrep and status == "ok":
            status = "partial"
        if status == "ok" and ("semgrep not available" in (raw or "").lower() or "fallback" in (raw or "").lower()):
            status = "partial"

        # V-13: Extract per-section failures from raw report so compact envelope surfaces them
        import re as _scan_re
        _failed_sections: list[dict] = []
        for _sec_match in _scan_re.finditer(
            r"###\s+(.+?)\n(?:Error:|Could not fetch:)\s*(.+?)(?:\n|$)", raw or ""
        ):
            _failed_sections.append({
                "section": _sec_match.group(1).strip(),
                "error": _sec_match.group(2).strip()[:200],
            })
        if _failed_sections and status == "ok":
            status = "partial"
        requested_page = max(1, int(page))
        page_size = max(5, min(int(max_findings), 500))
        parsed_findings, parsed_severity_counts = _parse_scan_findings(raw or "", max_findings=page_size)
        findings_count_hint = len(parsed_findings)
        has_more = findings_count_hint >= page_size
        next_token = f"scan:{normalized_scope}:{requested_page + 1}:{page_size}" if has_more else ""
        data = {
            "findings": parsed_findings,
            "severity_counts": parsed_severity_counts,
            "scan_meta": {
                "scope": normalized_scope,
                "max_findings": page_size,
                "include_semgrep": include_semgrep,
                "scan_id": f"scan-{int(time.time())}",
                "mode": mode,
                "page": requested_page,
            },
            "hotspots": [],
            "classification": {},
            "evidence": [],
            "fidelity": {},
            "security_chains": [],
            "degraded_components": (
                (["semgrep"] if not include_semgrep else [])
                + [fs["section"] for fs in _failed_sections]
            ) if status == "partial" else [],
            "failed_sections": _failed_sections,
            "continuation_token": next_token,
            "pending_checks": [] if not has_more else ["additional_findings_pages"],
            "raw_report": raw[:12000],
        }
        _summary_parts = []
        if status == "ok":
            _summary_parts.append("Codebase scan completed.")
        elif _failed_sections:
            _section_names = ", ".join(fs["section"] for fs in _failed_sections[:3])
            _summary_parts.append(f"Codebase scan completed with failures in: {_section_names}.")
        else:
            _summary_parts.append("Codebase scan failed.")
        _scan_next = [
            "Call review_files(paths=[<high_risk_files>]) for targeted follow-up on findings.",
        ]
        if has_more:
            _scan_next.append(f"Call codebase_scan(continuation_token='{next_token}') for remaining findings.")
        _scan_next.append("Call run_tests(path=<test_file>) to verify specific concerns.")
        return _tool_envelope(
            status,
            " ".join(_summary_parts),
            data,
            _scan_next,
            0.75 if status == "ok" else 0.2,
            "codebase_scan",
        )

    @agent.tool
    async def knowledge_memory(
        ctx: RunContext[HikaflowDeps],
        action: Literal[
            "list_blocks", "read_block", "edit_block",
            "list_findings", "upsert_finding", "close_finding",
            "search_reflections", "save_reflection",
            "skill_query", "skill_create", "skill_details", "skill_outcome",
        ],
        payload: Any = "",
    ) -> str:
        """Unified knowledge/memory tool over memory, findings, reflections, and skills.

        Valid actions:
        - list_blocks: List all memory blocks.
        - read_block: Read a block. payload: {block: "<name>"}
        - edit_block: Edit a block. payload: {block: "<name>", mode: "insert"|"rethink"|"replace", content: "..."}
        - list_findings: List tracked findings.
        - upsert_finding: Add/update a finding. payload: {file, line, severity, description, status?, issue_id?}
        - close_finding: Close a finding. payload: {issue_id: "<id>"}
        - search_reflections: Search reflections. payload: {query: "..."}
        - save_reflection: Save a reflection. payload: {reflection: "...", task?: "..."}
        - skill_query: Search skills. payload: {query: "..."}
        - skill_create: Create a skill. payload: {description: "...", procedure: [...], tags?: [...]}
        - skill_details: Get skill details. payload: {skill_id: "<id>"}
        - skill_outcome: Record outcome. payload: {skill_id: "<id>", success: true|false}
        """
        action = (action or "").strip()
        allowed_actions = {
            "list_blocks",
            "read_block",
            "edit_block",
            "list_findings",
            "upsert_finding",
            "close_finding",
            "search_reflections",
            "save_reflection",
            "skill_query",
            "skill_create",
            "skill_details",
            "skill_outcome",
        }
        if action not in allowed_actions:
            return _tool_envelope(
                "error",
                f"Unknown action '{action}'. Valid actions: {', '.join(sorted(allowed_actions))}",
                {"action": action, "counts": {}, "result": ""},
                [
                    "Valid actions: list_blocks, read_block, edit_block, list_findings, "
                    "upsert_finding, close_finding, search_reflections, save_reflection, "
                    "skill_query, skill_create, skill_details, skill_outcome.",
                ],
                0.0,
                "knowledge_memory",
            )
        payload_obj: dict[str, Any]
        if isinstance(payload, dict):
            payload_obj = dict(payload)
        else:
            payload_obj = {"text": "" if payload is None else str(payload)}
        raw = ""
        status = "ok"
        try:
            if action == "list_blocks":
                if ctx.deps._memory_blocks is None:
                    raw = "Error: memory blocks not initialized."
                else:
                    raw = ctx.deps._memory_blocks.render_for_prompt()
            elif action == "read_block":
                block_name = (payload_obj.get("block") or payload_obj.get("text") or "").strip()
                if ctx.deps._memory_blocks is None:
                    raw = "Error: memory blocks not initialized."
                else:
                    raw = ctx.deps._memory_blocks.get(block_name)
            elif action == "search_reflections":
                raw = await search_reflections(ctx, query=(payload_obj.get("query") or payload_obj.get("text") or ""), limit=5)
            elif action == "save_reflection":
                raw = await save_reflection(
                    ctx,
                    reflection=(payload_obj.get("reflection") or payload_obj.get("text") or ""),
                    task=(payload_obj.get("task") or "knowledge_memory"),
                )
            elif action == "list_findings":
                raw = await update_findings(ctx, action="list")
            elif action == "upsert_finding":
                finding = payload_obj if isinstance(payload_obj, dict) else {}
                raw = await update_findings(
                    ctx,
                    action="add" if not finding.get("status") else "update",
                    issue_id=str(finding.get("issue_id") or ""),
                    file=finding.get("file"),
                    line=int(finding.get("line") or 0) if finding.get("line") is not None else None,
                    severity=finding.get("severity"),
                    status=finding.get("status"),
                    description=finding.get("description"),
                )
            elif action == "close_finding":
                issue_id = str(payload_obj.get("issue_id") or payload_obj.get("text") or "")
                raw = await update_findings(ctx, action="update", issue_id=issue_id, status="fixed")
            elif action == "skill_query":
                raw = await search_skills(ctx, query=(payload_obj.get("query") or payload_obj.get("text") or ""), limit=5)
            elif action == "skill_create":
                desc = str(payload_obj.get("description") or payload_obj.get("text") or "").strip()
                procedure = payload_obj.get("procedure") or []
                tags = payload_obj.get("tags") or None
                if not desc or not isinstance(procedure, list) or not procedure:
                    raw = "Error: skill_create requires description and non-empty procedure list."
                else:
                    raw = await create_skill(ctx, description=desc, procedure=[str(x) for x in procedure[:20]], tags=tags)
            elif action == "skill_details":
                raw = await use_skill(ctx, skill_id=str(payload_obj.get("skill_id") or payload_obj.get("text") or ""))
            elif action == "skill_outcome":
                skill_id = str(payload_obj.get("skill_id") or "")
                success = bool(payload_obj.get("success"))
                if not skill_id:
                    raw = "Error: skill_outcome requires skill_id."
                else:
                    raw = await record_skill_outcome(ctx, skill_id=skill_id, success=success)
            elif action == "edit_block":
                block = str(payload_obj.get("block") or "").strip()
                mode = str(payload_obj.get("mode") or "").strip()
                content = str(payload_obj.get("content") or "")
                # backward-compatible string payload format: block|mode|content
                if not block and "|" in str(payload_obj.get("text") or ""):
                    parts = (payload_obj.get("text") or "").split("|", 2)
                    if len(parts) == 3:
                        block, mode, content = parts
                if not block or not mode:
                    raw = "Error: edit_block requires payload.block and payload.mode."
                else:
                    if mode == "insert":
                        raw = await memory_insert(ctx, block=block, content=content)
                    elif mode == "rethink":
                        raw = await memory_rethink(ctx, block=block, new_content=content)
                    elif mode == "replace":
                        raw = await memory_rethink(ctx, block=block, new_content=content)
                    else:
                        raw = "Error: mode must be insert, rethink, or replace."
            else:
                raw = "Error: Unknown knowledge_memory action."
        except AttributeError:
            raw = "Error: memory not initialized. Run in the interactive REPL to enable memory features."
        except Exception as e:
            raw = f"Error: {e}"
        if _looks_like_error(raw):
            status = "error"
        data = {
            "action": action,
            "counts": {"result_chars": len(raw)},
            "result": raw[:12000],
        }
        _km_next_map = {
            "list_blocks": ["Call knowledge_memory(action='read_block', payload={'block': '<name>'}) to read a specific block."],
            "read_block": ["Call knowledge_memory(action='edit_block', payload={'block': '<name>', 'mode': 'insert', 'content': '...'}) to update."],
            "edit_block": ["Call knowledge_memory(action='list_blocks') to verify the update."],
            "list_findings": ["Call knowledge_memory(action='upsert_finding', payload={...}) to add/update findings."],
            "upsert_finding": ["Call knowledge_memory(action='list_findings') to verify."],
            "close_finding": ["Call knowledge_memory(action='list_findings') to verify closure."],
            "save_reflection": ["Call knowledge_memory(action='search_reflections', payload={'query': '...'}) to verify."],
            "search_reflections": ["Call knowledge_memory(action='save_reflection', payload={'reflection': '...', 'task': '...'}) to save a new reflection."],
            "skill_query": ["Call knowledge_memory(action='skill_details', payload={'skill_id': '<id>'}) to view a skill."],
            "skill_create": ["Call knowledge_memory(action='skill_query', payload={'query': '...'}) to verify creation."],
            "skill_details": ["Call knowledge_memory(action='skill_outcome', payload={'skill_id': '<id>', 'success': true}) to record outcome."],
            "skill_outcome": ["Call knowledge_memory(action='skill_query', payload={'query': '...'}) to verify."],
        }
        _km_next = _km_next_map.get(action, ["Call knowledge_memory(action='list_blocks') to inspect current state."])
        return _tool_envelope(
            status,
            "Knowledge memory action completed." if status == "ok" else "Knowledge memory action failed.",
            data,
            _km_next,
            0.9 if status == "ok" else 0.2,
            "knowledge_memory",
        )

    # ------------------------------------------------------------------
    # Split tools: memory_blocks, reflections, skills_manager
    # These provide focused interfaces for the actions previously in
    # knowledge_memory.  knowledge_memory still works as a dispatcher
    # for backward compatibility, but the compact profile can expose
    # these smaller tools instead.
    # ------------------------------------------------------------------

    @agent.tool
    async def memory_blocks(
        ctx: RunContext[HikaflowDeps],
        action: Literal["list", "read", "edit"],
        block: str = "",
        mode: Literal["insert", "rethink", "replace"] = "insert",
        content: str = "",
    ) -> str:
        """Manage working memory blocks (list, read, or edit).

        Args:
            action: list (show all blocks), read (get block content), edit (modify block).
            block: Block name. Required for read and edit.
            mode: Edit mode. Only used when action=edit. insert appends, rethink/replace overwrites.
            content: Content to insert or replace. Only used when action=edit.
        """
        raw = ""
        try:
            if action == "list":
                if ctx.deps._memory_blocks is None:
                    raw = "Error: memory blocks not initialized."
                else:
                    raw = ctx.deps._memory_blocks.render_for_prompt()
            elif action == "read":
                if not block:
                    raw = "Error: read requires block name."
                elif ctx.deps._memory_blocks is None:
                    raw = "Error: memory blocks not initialized."
                else:
                    raw = ctx.deps._memory_blocks.get(block)
            elif action == "edit":
                if not block or not mode:
                    raw = "Error: edit requires block and mode."
                elif mode == "insert":
                    raw = await memory_insert(ctx, block=block, content=content)
                elif mode in ("rethink", "replace"):
                    raw = await memory_rethink(ctx, block=block, new_content=content)
                else:
                    raw = "Error: mode must be insert, rethink, or replace."
        except Exception as e:
            raw = f"Error: {e}"
        status = "error" if _looks_like_error(raw) else "ok"
        _next_map = {
            "list": ["Call memory_blocks(action='read', block='<name>') to read a specific block."],
            "read": ["Call memory_blocks(action='edit', block='<name>', mode='insert', content='...') to update."],
            "edit": ["Call memory_blocks(action='list') to verify the update."],
        }
        return _tool_envelope(
            status,
            "Memory block action completed." if status == "ok" else "Memory block action failed.",
            {"action": action, "block": block, "result": (raw or "")[:12000]},
            _next_map.get(action, ["Call memory_blocks(action='list') to inspect state."]),
            0.9 if status == "ok" else 0.2,
            "memory_blocks",
        )

    @agent.tool
    async def reflections(
        ctx: RunContext[HikaflowDeps],
        action: Literal["search", "save"],
        query: str = "",
        reflection: str = "",
        task: str = "",
    ) -> str:
        """Search or save session reflections (learnings from debugging).

        Args:
            action: search (find past reflections) or save (record a new one).
            query: Search query. Required when action=search.
            reflection: The reflection text. Required when action=save.
            task: Task context for the reflection. Optional, used when action=save.
        """
        raw = ""
        try:
            if action == "search":
                raw = await search_reflections(ctx, query=query or "", limit=5)
            elif action == "save":
                if not reflection:
                    raw = "Error: save requires reflection text."
                else:
                    raw = await save_reflection(ctx, reflection=reflection, task=task or "reflections")
        except Exception as e:
            raw = f"Error: {e}"
        status = "error" if _looks_like_error(raw) else "ok"
        _next = {
            "search": ["Call reflections(action='save', reflection='...') to record a new learning."],
            "save": ["Call reflections(action='search', query='...') to verify it was saved."],
        }
        return _tool_envelope(
            status,
            "Reflection action completed." if status == "ok" else "Reflection action failed.",
            {"action": action, "result": (raw or "")[:12000]},
            _next.get(action, ["Call reflections(action='search', query='...') to browse reflections."]),
            0.9 if status == "ok" else 0.2,
            "reflections",
        )

    @agent.tool
    async def skills_manager(
        ctx: RunContext[HikaflowDeps],
        action: Literal["query", "create", "details", "outcome"],
        query: str = "",
        skill_id: str = "",
        description: str = "",
        procedure: Optional[list[str]] = None,
        tags: Optional[list[str]] = None,
        success: bool = False,
    ) -> str:
        """Manage reusable debugging skills (query, create, inspect, record outcome).

        Args:
            action: query (search skills), create (new skill), details (inspect skill), outcome (record result).
            query: Search query. Used when action=query.
            skill_id: Skill ID. Required for details and outcome.
            description: Skill description. Required for create.
            procedure: Step-by-step procedure list. Required for create.
            tags: Optional tags for create.
            success: Whether the skill succeeded. Used for outcome.
        """
        raw = ""
        try:
            if action == "query":
                raw = await search_skills(ctx, query=query or "", limit=5)
            elif action == "create":
                if not description or not procedure:
                    raw = "Error: create requires description and non-empty procedure list."
                else:
                    raw = await create_skill(ctx, description=description, procedure=[str(x) for x in procedure[:20]], tags=tags)
            elif action == "details":
                if not skill_id:
                    raw = "Error: details requires skill_id."
                else:
                    raw = await use_skill(ctx, skill_id=skill_id)
            elif action == "outcome":
                if not skill_id:
                    raw = "Error: outcome requires skill_id."
                else:
                    raw = await record_skill_outcome(ctx, skill_id=skill_id, success=success)
        except Exception as e:
            raw = f"Error: {e}"
        status = "error" if _looks_like_error(raw) else "ok"
        _next = {
            "query": ["Call skills_manager(action='details', skill_id='<id>') to inspect a skill."],
            "create": ["Call skills_manager(action='query', query='...') to verify creation."],
            "details": ["Call skills_manager(action='outcome', skill_id='<id>', success=true/false) after using the skill."],
            "outcome": ["Call skills_manager(action='query') to browse skills."],
        }
        return _tool_envelope(
            status,
            "Skills action completed." if status == "ok" else "Skills action failed.",
            {"action": action, "result": (raw or "")[:12000]},
            _next.get(action, ["Call skills_manager(action='query') to browse skills."]),
            0.9 if status == "ok" else 0.2,
            "skills_manager",
        )

    @agent.tool
    async def project_health_snapshot(
        ctx: RunContext[HikaflowDeps],
        project_id: Optional[str] = None,
        window_days: int = 30,
        include_dora: bool = True,
        include_feedback: bool = True,
        focus_test_id: Optional[str] = None,
        focus_path: Optional[str] = None,
        include_self_heal: bool = True,
        detail_level: Literal["summary", "detailed"] = "summary",
    ) -> str:
        """Return a consolidated project health snapshot."""
        if not (project_id or _get_project_id(ctx)):
            return _no_project_envelope("project_health_snapshot", ["codebase_scan", "run_tests"])
        allowed_detail = {"summary", "detailed"}
        detail_level = (detail_level or "summary").strip().lower()
        if detail_level not in allowed_detail:
            return _tool_envelope(
                "error",
                "Invalid detail_level. Use summary|detailed.",
                {"project_health": {"project_id": project_id or _get_project_id(ctx)}},
                ["Re-call with a valid detail_level."],
                0.0,
                "project_health_snapshot",
            )
        clamped_days = max(7, min(int(window_days), 90))
        source_notes: list[str] = []
        overview_raw = await dashboard_stats(ctx, action="overview", project_id=project_id)
        flaky_raw = await dashboard_stats(ctx, action="flaky", project_id=project_id, days=clamped_days)
        dora_raw = await dora_metrics(ctx, project_id=project_id, days=max(7, min(int(window_days), 90))) if include_dora else ""
        feedback_raw = await scan_feedback(ctx, action="stats", rule="all") if include_feedback else ""
        self_heal_raw = await self_heal(ctx, action="stats") if include_self_heal else ""
        focus_raw = ""
        if focus_path:
            focus_raw = await scan_flaky_tests(ctx, paths=[focus_path], runs=20, max_workers=2)
        status = "ok"
        raws = [overview_raw, flaky_raw, dora_raw, feedback_raw, self_heal_raw, focus_raw]
        if any(_looks_like_error(r) for r in raws if r):
            status = "partial"
        if _looks_like_error(overview_raw):
            source_notes.append("overview unavailable; retry dashboard_stats overview.")
        if _looks_like_error(flaky_raw):
            source_notes.append("flaky summary unavailable; retry dashboard_stats flaky.")
        if include_dora and _looks_like_error(dora_raw):
            source_notes.append("dora metrics unavailable; retry with include_dora=true later.")
        if include_feedback and _looks_like_error(feedback_raw):
            source_notes.append("feedback stats unavailable; retry scan_feedback action=stats.")
        if include_self_heal and _looks_like_error(self_heal_raw):
            source_notes.append("self-heal stats unavailable; retry self_heal action=stats.")
        data = {
            "overall_health": None if _looks_like_error(overview_raw) else overview_raw[:5000],
            "flaky_summary": None if _looks_like_error(flaky_raw) else flaky_raw[:5000],
            "feedback_trends": (None if _looks_like_error(feedback_raw) else feedback_raw[:5000]) if include_feedback else None,
            "self_heal_status": (None if _looks_like_error(self_heal_raw) else self_heal_raw[:5000]) if include_self_heal else None,
            "dora_report": (None if _looks_like_error(dora_raw) else dora_raw[:5000]) if include_dora else None,
            "project_health": {"project_id": project_id or _get_project_id(ctx), "window_days": clamped_days, "detail_level": detail_level},
            "actions_recommended": [
                "Call codebase_scan(scope='full') for deeper risk triage.",
                "Call incident_overview() to investigate specific production errors.",
            ],
            "source_notes": source_notes,
            "focus_test_id": focus_test_id,
            "focus_path": focus_path,
            "focus_raw": None if (focus_raw and _looks_like_error(focus_raw)) else (focus_raw[:5000] if focus_raw else None),
        }
        return _tool_envelope(
            status,
            "Project health snapshot generated.",
            data,
            data["actions_recommended"],
            0.75 if status == "ok" else 0.55,
            "project_health_snapshot",
        )


def register_file_tools(agent: Any) -> None:
    """Register basic file operation tools.

    Used as fallback when pydantic-ai-filesystem-sandbox is not available.
    """
    # Cache for read_file to avoid redundant reads in the same session.
    # Registered for clearing on compaction (so files can be re-read after
    # their content is dropped from conversation context).
    _read_file_cache: dict[str, str] = {}
    _registered_read_caches.append(_read_file_cache)

    @agent.tool
    async def read_file(
        ctx: RunContext[HikaflowDeps],
        path: str,
    ) -> str:
        """Read the contents of a file.

        Args:
            path: Path to the file (relative to project root).

        Returns:
            File contents or error message.
        """
        from autodebug.debug_log import debug as _dbg

        # Reject empty/whitespace paths (cache so repeated calls get stubs)
        path = path.strip()
        if not path:
            _read_file_cache[""] = "Error: No file path provided."
            return "Error: No file path provided. Pass a specific file path like 'src/main.py'."

        # Normalize absolute paths to relative (prevents cache key mismatch)
        project = ctx.deps.project_path
        if os.path.isabs(path) and path.startswith(project):
            rel = os.path.relpath(path, project)
            if rel != ".":
                path = rel

        # Reject directory paths early and cache them
        full_check = os.path.join(project, path)
        if os.path.isdir(full_check):
            error_msg = f"Error: '{path}' is a directory, not a file. Pass a specific file path."
            _read_file_cache[path] = error_msg
            return error_msg

        # Block secrets/sensitive files
        _SENSITIVE_PATTERNS = {
            ".env", ".env.local", ".env.production", ".env.staging",
            ".env.development", ".env.test",
            "credentials.json", "service-account.json",
            ".netrc", ".npmrc", ".pypirc",
            "id_rsa", "id_ed25519", "id_ecdsa",
            ".aws/credentials", ".docker/config.json",
        }
        basename = os.path.basename(path).lower()
        if basename in _SENSITIVE_PATTERNS or basename.startswith(".env"):
            _dbg(f'BLOCKED | read_file("{path}") → sensitive file')
            return (
                f"Error: Cannot read '{path}' — this file may contain secrets. "
                "Use environment variables or a secrets manager to access credentials."
            )

        # Return a short stub if same file was already read this session
        if path in _read_file_cache:
            cached = _read_file_cache[path]
            _dbg(f'CACHE_HIT | read_file("{path}")')
            if cached.startswith("Error:"):
                return f"STOP. Already tried. {cached}"
            return f"Already read ({len(cached):,} chars). Content is in your conversation above."

        full_path = os.path.join(ctx.deps.project_path, path)

        # Security: ensure path is within project (use os.sep to prevent prefix collisions)
        real_path = os.path.realpath(full_path)
        real_project = os.path.realpath(ctx.deps.project_path)
        if real_path != real_project and not real_path.startswith(real_project + os.sep):
            return "Error: Cannot read files outside project directory"

        try:
            # V-14: File size guard — refuse >10MB, warn >1MB
            _file_size = os.path.getsize(full_path)
            if _file_size > 10_000_000:
                error_msg = (
                    f"Error: '{path}' is {_file_size / 1_000_000:.1f} MB — too large to read (limit 10 MB). "
                    "Use Grep to search for specific patterns instead."
                )
                _read_file_cache[path] = error_msg
                return error_msg

            # Check for binary files by reading first 8KB for null bytes
            with open(full_path, "rb") as fb:
                sample = fb.read(8192)
            if b"\x00" in sample:
                ext = os.path.splitext(full_path)[1]
                return f"Error: '{path}' appears to be a binary file ({ext}). Use a specialized tool for non-text files."

            with open(full_path) as f:
                content = f.read()
            _MAX_FILE_SIZE = 100_000
            if _file_size > 1_000_000:
                content = content[:_MAX_FILE_SIZE] + (
                    f"\n... [WARNING: file is {_file_size / 1_000_000:.1f} MB — "
                    f"truncated at {_MAX_FILE_SIZE:,} of {len(content):,} chars. "
                    "Use Grep for targeted searches.]"
                )
            elif len(content) > _MAX_FILE_SIZE:
                content = content[:_MAX_FILE_SIZE] + f"\n... [truncated at {_MAX_FILE_SIZE:,} of {len(content):,} chars]"
            _read_file_cache[path] = content
            return content
        except FileNotFoundError:
            # Suggest similar files to prevent hallucinated-path loops
            import glob as _glob
            basename = os.path.basename(path)
            name_stem = os.path.splitext(basename)[0]
            project = ctx.deps.project_path
            candidates = _glob.glob(f"{project}/**/*{name_stem}*", recursive=True)
            candidates = [os.path.relpath(c, project) for c in candidates[:5]]
            if candidates:
                suggestions = "\n".join(f"  - {c}" for c in candidates)
                error_msg = f"Error: File not found: {path}\n\nDid you mean one of these?\n{suggestions}"
            else:
                error_msg = f"Error: File not found: {path}"
            # Cache the error so repeated lookups get a stub instead of retrying
            _read_file_cache[path] = error_msg
            return error_msg
        except UnicodeDecodeError:
            error_msg = f"Error: '{path}' is not a text file (encoding error)."
            _read_file_cache[path] = error_msg
            return error_msg
        except Exception as e:
            error_msg = f"Error reading file: {e}"
            _read_file_cache[path] = error_msg
            return error_msg

    @agent.tool
    async def list_directory(
        ctx: RunContext[HikaflowDeps],
        path: str = ".",
    ) -> str:
        """List contents of a directory.

        Args:
            path: Path to the directory (relative to project root).

        Returns:
            List of files and directories.
        """
        full_path = os.path.join(ctx.deps.project_path, path)

        # Security: ensure path is within project (use os.sep to prevent prefix collisions)
        real_path = os.path.realpath(full_path)
        real_project = os.path.realpath(ctx.deps.project_path)
        if real_path != real_project and not real_path.startswith(real_project + os.sep):
            return "Error: Cannot list directories outside project"

        try:
            entries = os.listdir(full_path)
            result = []
            for entry in sorted(entries):
                entry_path = os.path.join(full_path, entry)
                if os.path.isdir(entry_path):
                    result.append(f"[dir]  {entry}/")
                else:
                    result.append(f"[file] {entry}")
            return "\n".join(result) if result else "(empty directory)"
        except FileNotFoundError:
            return f"Error: Directory not found: {path}"
        except Exception as e:
            return f"Error listing directory: {e}"

    @agent.tool
    async def search_codebase(
        ctx: RunContext[HikaflowDeps],
        query: str,
        file_pattern: str = "",
        max_results: int = 50,
    ) -> str:
        """Search the codebase for files or content matching a query.

        Combines grep (content search) and glob (file name search) to find relevant files.

        Args:
            query: Text to search for (regex supported). Searches file contents.
            file_pattern: Optional glob pattern to filter files (e.g. '*.py', 'api/**/*.py').
                         If provided WITHOUT a query, lists matching files only.
            max_results: Maximum number of results to return (default 30).

        Returns:
            Matching file paths with line numbers and context, or matching file names.
        """
        import fnmatch
        import glob as _glob

        project_path = ctx.deps.project_path

        # Security: prevent path traversal in patterns
        if ".." in file_pattern or file_pattern.startswith("/"):
            return "Error: file_pattern must be relative to the project root."

        # Directories to skip
        _SKIP_DIRS = {
            "node_modules", ".git", ".next", "__pycache__", ".venv", "venv",
            "dist", "build", "coverage", "htmlcov", ".mypy_cache", ".ruff_cache",
            ".pytest_cache", ".turbo", ".autodebug",
        }
        # Binary extensions to skip
        _BINARY_EXTS = {
            ".pyc", ".pyo", ".so", ".dylib", ".dll", ".exe", ".bin",
            ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".woff",
            ".woff2", ".ttf", ".eot", ".mp3", ".mp4", ".zip", ".tar",
            ".gz", ".lock", ".map", ".min.js",
        }

        results: list[str] = []

        if not query and not file_pattern:
            return "Error: Provide either a query (content search) or file_pattern (file name search)."

        # File name search only (glob mode)
        if file_pattern and not query:
            pattern = os.path.join(project_path, file_pattern)
            matches = sorted(_glob.glob(pattern, recursive=True))
            filtered = []
            for m in matches:
                rel = os.path.relpath(m, project_path)
                parts = rel.split(os.sep)
                if any(p in _SKIP_DIRS for p in parts):
                    continue
                if os.path.isfile(m):
                    filtered.append(rel)
                elif os.path.isdir(m):
                    filtered.append(f"{rel}/")
            if not filtered:
                return f"No files matching '{file_pattern}'."
            total = len(filtered)
            shown = filtered[:max_results]
            out = "\n".join(shown)
            if total > max_results:
                out += f"\n\n... and {total - max_results} more files"
            return out

        # Content search (grep mode)
        try:
            pattern = re.compile(query, re.IGNORECASE)
        except re.error as e:
            return f"Error: Invalid regex pattern: {e}"

        match_count = 0
        files_searched = 0

        for root, dirs, files in os.walk(project_path):
            # Prune excluded dirs in-place
            dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
            for fname in sorted(files):
                ext = os.path.splitext(fname)[1].lower()
                if ext in _BINARY_EXTS:
                    continue
                rel_path = os.path.relpath(os.path.join(root, fname), project_path)

                # Apply file_pattern filter if specified
                if file_pattern and not fnmatch.fnmatch(rel_path, file_pattern):
                    continue

                full = os.path.join(root, fname)
                try:
                    # Skip large files (>500KB)
                    if os.path.getsize(full) > 500_000:
                        continue
                    with open(full, "r", errors="replace") as fh:
                        lines = fh.readlines()
                except (OSError, UnicodeDecodeError):
                    continue

                files_searched += 1
                file_matches: list[str] = []
                for i, line in enumerate(lines, 1):
                    if pattern.search(line):
                        file_matches.append(f"  {i}: {line.rstrip()[:150]}")
                        match_count += 1

                if file_matches:
                    results.append(f"**{rel_path}**")
                    # Show up to 10 matching lines per file
                    for m in file_matches[:10]:
                        results.append(m)
                    if len(file_matches) > 10:
                        results.append(f"  ... +{len(file_matches) - 10} more matches in this file")
                    results.append("")

                if match_count >= max_results:
                    break
            if match_count >= max_results:
                break

        if not results:
            return f"No matches for '{query}'" + (f" in '{file_pattern}'" if file_pattern else "") + f" ({files_searched} files searched)."

        header = f"Found {match_count} match(es) across {len([r for r in results if r.startswith('**')])} file(s)"
        if match_count >= max_results:
            header += f" (showing first {max_results}, use max_results to see more)"
        header += f":\n\n"
        return _sanitize_tool_output(header + "\n".join(results), tool_name="search_codebase")


# --- Formatting helpers ---

def _format_errors_table(errors: list[dict]) -> str:
    """Format errors as a markdown table."""
    if not errors:
        return "No errors found."

    lines = ["| ID | Error | Count | Last Seen |", "|-----|-------|-------|-----------|"]
    for e in errors:
        msg = e.get("message", "")[:30]
        if len(e.get("message", "")) > 30:
            msg += "..."
        lines.append(
            f"| {e.get('id', 'N/A')} | "
            f"{e.get('exception_type', 'Error')}: {msg} | "
            f"{e.get('count', 0)} | "
            f"{e.get('last_seen', 'N/A')} |"
        )
    return _sanitize_tool_output("\n".join(lines), tool_name="fetch_production_errors")


def _format_error_details(error: dict, transcript: dict) -> str:
    """Format detailed error information."""
    return _sanitize_tool_output(f"""**Error: {error.get('exception_type', 'Unknown')}**
- **Message:** {error.get('message', 'No message')}
- **Location:** `{error.get('file', 'unknown')}:{error.get('line', '?')}`
- **First seen:** {error.get('first_seen', 'N/A')}
- **Occurrences:** {error.get('count', 0)}
- **Environment:** {error.get('environment', 'N/A')}

**Stack Trace:**
```
{error.get('stack_trace', 'No stack trace available')}
```

**I/O Transcript Summary:**
- HTTP calls: {len(transcript.get('http', []))}
- SQL queries: {len(transcript.get('sql', []))}
- Time probes: {len(transcript.get('time', []))}
""", tool_name="get_error_details")


def _format_replay_results(results: list[dict]) -> str:
    """Format replay run results."""
    passed = sum(1 for r in results if r.get("reproduced"))
    total = len(results)
    status = "successfully reproduced" if passed == total else "inconsistent"
    return f"""**Replay Results:** {passed}/{total} runs {status}

{"The error was consistently reproduced - ready to debug." if passed == total else "Warning: Error did not reproduce consistently. This may indicate timing-dependent behavior."}
"""


def _format_transcript(transcript: dict, io_type: str) -> str:
    """Format I/O transcript."""
    lines = ["**I/O Transcript:**\n"]

    if io_type in ("http", "all") and transcript.get("http"):
        lines.append("**HTTP Requests:**")
        for item in transcript["http"]:
            lines.append(f"- {item.get('method', 'GET')} {item.get('url', 'unknown')} -> {item.get('status', '?')}")
        lines.append("")

    if io_type in ("sql", "all") and transcript.get("sql"):
        lines.append("**SQL Queries:**")
        for item in transcript["sql"]:
            query = item.get("query", "")[:50]
            if len(item.get("query", "")) > 50:
                query += "..."
            lines.append(f"- `{query}` -> {item.get('row_count', '?')} rows")
        lines.append("")

    if io_type in ("time", "all") and transcript.get("time"):
        lines.append("**Time Values:**")
        for item in transcript["time"]:
            lines.append(f"- {item.get('function', 'time')}: {item.get('value', 'N/A')}")
        lines.append("")

    if io_type in ("all",) and transcript.get("task_graph"):
        task_events = transcript["task_graph"]
        creates = [e for e in task_events if e.get("type") == "TASK_CREATE"]
        completes = [e for e in task_events if e.get("type") == "TASK_COMPLETE"]
        if creates or completes:
            lines.append("**Async Task Graph:**")
            lines.append(f"- Tasks created: {len(creates)}")
            lines.append(f"- Tasks completed: {len(completes)}")
            lines.append("")
            if creates:
                lines.append("**Task Creation Order:**")
                for e in creates:
                    parent = e.get("parent_task_id")
                    parent_str = f" (parent: task_{parent})" if parent else ""
                    lines.append(
                        f"- task_{e.get('task_id')}: "
                        f"`{e.get('coro_name', 'unknown')}`{parent_str}"
                    )
                lines.append("")
            if completes:
                lines.append("**Task Completion Order:**")
                for e in sorted(completes, key=lambda x: x.get("seq", 0)):
                    status = "cancelled" if e.get("cancelled") else (
                        "error" if e.get("has_exception") else "ok"
                    )
                    lines.append(f"- task_{e.get('task_id')}: {status}")
                lines.append("")

    if len(lines) == 1:
        lines.append("No I/O events recorded for this filter.")

    return _sanitize_tool_output("\n".join(lines), tool_name="view_io_transcript")


def _format_hypotheses(hypotheses: list) -> str:
    """Format fix hypotheses."""
    if not hypotheses:
        return "No fix hypotheses generated."

    lines = ["**Fix Hypotheses:**"]
    for i, h in enumerate(hypotheses, 1):
        confidence = h.get("confidence", 0)
        lines.append(f"\n### {i}. {h.get('strategy', 'Fix')} (confidence: {confidence:.0%})")
        lines.append(f"\n**Root cause:** {h.get('root_cause', 'Unknown')}")
        if h.get("diff"):
            lines.append(f"\n```diff\n{h['diff']}\n```")
    return "\n".join(lines)


def _format_validation_result(result: dict) -> str:
    """Format validation result."""
    verdict = result.get("verdict", "UNKNOWN")
    emoji_map = {
        "PROVEN_FIX": "[PROVEN]",
        "FLAKY_FIX": "[FLAKY]",
        "DETERMINISTIC_FIX": "[STILL_FAILS]",
        "REGRESSION": "[REGRESSION]",
    }
    return f"""**Validation Result:** {emoji_map.get(verdict, '[?]')} {verdict}

- **Runs passed:** {result.get('passed', 0)}/{result.get('total', 0)}
- **Certificate ID:** {result.get('certificate_id', 'N/A')}

{"Fix has been PROVEN to resolve the error!" if verdict == "PROVEN_FIX" else "Fix validation incomplete or failed."}
"""


def _format_similar_fixes(matches: list) -> str:
    """Format similar past fixes."""
    if not matches:
        return "No similar fixes found in the pattern database."

    lines = ["**Similar Past Fixes:**"]
    for m in matches:
        success_rate = m.get("success_rate", 0)
        lines.append(
            f"- **{m.get('exception_type', 'Error')}**: "
            f"{m.get('fix_type', 'fix')} (success rate: {success_rate:.0%})"
        )
        if m.get("description"):
            lines.append(f"  {m['description']}")
    return "\n".join(lines)
