#!/usr/bin/env python3
"""
attnroute — Attentional Context Router
=======================================
Working-memory-inspired context injection for Claude Code.

Tiers:
  HOT  (>0.8)   Full file injection — active development focus
  WARM (0.25-0.8) Compressed TOC — background awareness
  COLD (<0.25)   Evicted from context window

Features:
  - Decay: Unmentioned files fade at configurable rates
  - Co-activation: Related files boost each other via keywords.json graph
  - Pinned files: Always at least WARM (config-driven)
  - Cache stability: HOT files sorted by streak length for API prompt cache hits
  - Self-tuning: Telemetry-driven parameter optimization
  - Domain-agnostic: All keywords loaded from keywords.json, zero hardcoded domains

Hook: UserPromptSubmit
Input: JSON from stdin {"prompt": "..."}
Output: Tiered context to stdout
"""

import copy
import io
import json
import os
import sys
from pathlib import Path

# Fix Windows encoding (cp1252 can't handle Unicode box-drawing/emoji chars)
# Guard against double-wrapping if module is imported multiple times
if sys.stdout.encoding != 'utf-8' and not isinstance(sys.stdout, io.TextIOWrapper):
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
if sys.stderr.encoding != 'utf-8' and not isinstance(sys.stderr, io.TextIOWrapper):
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
import re
from datetime import datetime

# Import utilities for cleaner import handling
from attnroute.compat import LazyLoader, safe_read_stdin, try_import

# Import telemetry lib
_telemetry_imports, TELEMETRY_LIB_AVAILABLE = try_import(
    "attnroute.telemetry_lib", "telemetry_lib",
    ["estimate_tokens_from_chars", "get_session_id", "get_project", "load_project_overrides", "rotate_jsonl"]
)
if TELEMETRY_LIB_AVAILABLE:
    estimate_tokens_from_chars = _telemetry_imports["estimate_tokens_from_chars"]
    get_session_id = _telemetry_imports["get_session_id"]
    get_project = _telemetry_imports["get_project"]
    load_project_overrides = _telemetry_imports["load_project_overrides"]
    rotate_jsonl = _telemetry_imports["rotate_jsonl"]

# Import plugin system
_plugin_imports, PLUGINS_AVAILABLE = try_import(
    "attnroute.plugins", "plugins", ["get_plugins"]
)
if PLUGINS_AVAILABLE:
    get_plugins = _plugin_imports["get_plugins"]

# Import learner with lazy initialization (avoids side effects at import time)
_learner_imports, _LEARNER_IMPORTABLE = try_import(
    "attnroute.learner", "learner", ["Learner", "auto_extract_keywords"]
)
if _LEARNER_IMPORTABLE:
    Learner = _learner_imports["Learner"]
    auto_extract_keywords = _learner_imports["auto_extract_keywords"]
    _learner_loader = LazyLoader(lambda: Learner())
    LEARNER_AVAILABLE = True
else:
    _learner_loader = None
    LEARNER_AVAILABLE = False

def get_learner():
    """Get the lazily-initialized Learner instance."""
    if _learner_loader is None:
        return None
    return _learner_loader.get()

# Import search index with lazy initialization
_indexer_imports, _INDEXER_IMPORTABLE = try_import(
    "attnroute.indexer", "indexer", ["SearchIndex", "BM25_AVAILABLE"]
)
if _INDEXER_IMPORTABLE:
    SearchIndex = _indexer_imports["SearchIndex"]
    BM25_AVAILABLE = _indexer_imports["BM25_AVAILABLE"]
    _search_loader = LazyLoader(lambda: SearchIndex())
    SEARCH_AVAILABLE = BM25_AVAILABLE
else:
    _search_loader = None
    SEARCH_AVAILABLE = False
    BM25_AVAILABLE = False

def get_search_index():
    """Get the lazily-initialized SearchIndex instance."""
    if _search_loader is None:
        return None
    return _search_loader.get()

def ensure_search_index_built():
    """Lazily build or update the search index if needed."""
    if not SEARCH_AVAILABLE:
        return

    idx = get_search_index()
    if idx is None:
        return

    try:
        status = idx.status()
        if status.get("indexed_documents", 0) == 0:
            # Index is empty - build it
            docs_root = resolve_docs_root()
            # Include source code if enabled
            code_roots = [Path.cwd()] if SOURCE_INDEXING_ENABLED else []
            idx.build(docs_root, code_roots=code_roots)
            doc_types = status.get("document_types", {})
            print(f"[attnroute] Built search index with {idx.status()['indexed_documents']} docs", file=sys.stderr)
            if SOURCE_INDEXING_ENABLED:
                code_count = doc_types.get("code", 0)
                print(f"[attnroute] Source code indexing: {code_count} files indexed", file=sys.stderr)
    except Exception as e:
        print(f"[attnroute] Search index build failed: {e}", file=sys.stderr)

# Try to import file predictor with lazy initialization (optional prefetching)
_predictor_imports, _PREDICTOR_IMPORTABLE = try_import(
    "attnroute.predictor", "predictor", ["FilePredictor"]
)
if _PREDICTOR_IMPORTABLE:
    FilePredictor = _predictor_imports["FilePredictor"]
    _predictor_loader = LazyLoader(lambda: FilePredictor())
    PREDICTOR_AVAILABLE = True
else:
    _predictor_loader = None
    PREDICTOR_AVAILABLE = False

def get_predictor():
    """Get the lazily-initialized FilePredictor instance."""
    if _predictor_loader is None:
        return None
    return _predictor_loader.get()

# Try to import repo mapper for symbol-level context (Aider-style)
try:
    from attnroute.repo_map import RepoMapper
    REPO_MAP_AVAILABLE = True
except ImportError:
    try:
        from repo_map import RepoMapper
        REPO_MAP_AVAILABLE = True
    except ImportError:
        RepoMapper = None
        REPO_MAP_AVAILABLE = False

# Try to import outliner for source code outline extraction
try:
    from attnroute.outliner import extract_outline
    OUTLINER_AVAILABLE = True
except ImportError:
    try:
        from outliner import extract_outline
        OUTLINER_AVAILABLE = True
    except ImportError:
        extract_outline = None
        OUTLINER_AVAILABLE = False

# Global repo mapper (lazily initialized)
_repo_mapper = None
_repo_mapper_project = None

def get_repo_mapper(project_root: str = None):
    """Get or create repo mapper for current project."""
    global _repo_mapper, _repo_mapper_project
    if not REPO_MAP_AVAILABLE:
        return None

    # Detect project root from CWD if not specified
    if project_root is None:
        project_root = str(Path.cwd())

    # Reuse if same project
    if _repo_mapper and _repo_mapper_project == project_root:
        return _repo_mapper

    try:
        _repo_mapper = RepoMapper(project_root, max_files=REPO_MAP_MAX_FILES)
        _repo_mapper.index(verbose=False)
        _repo_mapper_project = project_root
        return _repo_mapper
    except Exception as e:
        print(f"[attnroute] Repo map init failed: {e}", file=sys.stderr)
        return None

# Try to import networkx for graph-based co-activation
try:
    import networkx as nx
    NETWORKX_AVAILABLE = True
except ImportError:
    nx = None
    NETWORKX_AVAILABLE = False

# Co-activation graph (built from CO_ACTIVATION dict using networkx)
_coactivation_graph = None

# ============================================================================
# DOCS ROOT RESOLUTION
# ============================================================================

# Cache for resolve_docs_root() to avoid repeated glob operations
_docs_root_cache: Path | None = None
_docs_root_checked: bool = False

def resolve_docs_root() -> Path:
    """
    Resolve documentation root with correct priority order.

    Priority:
    1. CONTEXT_DOCS_ROOT environment variable (explicit override)
    2. Project-local .claude/ directory (if exists with .md files)
    3. Global ~/.claude/ directory (fallback)

    Returns: Path to docs root
    Raises: FileNotFoundError if no valid docs directory found

    Note: Results are cached at module level to avoid repeated glob operations.
    """
    global _docs_root_cache, _docs_root_checked

    # Return cached result if already resolved
    if _docs_root_checked:
        if _docs_root_cache is None:
            raise FileNotFoundError("No .claude/ directory with documentation found (cached)")
        return _docs_root_cache

    result = None

    # Priority 1: Explicit environment variable
    if env_root := os.getenv('CONTEXT_DOCS_ROOT'):
        env_path = Path(env_root).expanduser().resolve()
        if env_path.is_dir():
            print(f"[attnroute] Using CONTEXT_DOCS_ROOT: {env_path}", file=sys.stderr)
            result = env_path

    # Priority 2: Project-local .claude/ (more explicit check)
    if result is None:
        project_claude = Path.cwd() / ".claude"
        if project_claude.is_dir():
            # Check if it has any .md files (not just exists)
            md_files = list(project_claude.glob("**/*.md"))
            if md_files:
                print(f"[attnroute] Using project-local .claude: {project_claude}", file=sys.stderr)
                print(f"  Found {len(md_files)} .md files", file=sys.stderr)
                result = project_claude
            else:
                print(f"[attnroute] WARN:Project .claude/ exists but has no .md files: {project_claude}", file=sys.stderr)

    # Priority 3: Global ~/.claude/ (last resort)
    if result is None:
        global_claude = Path.home() / ".claude"
        if global_claude.is_dir():
            md_files = list(global_claude.glob("**/*.md"))
            if md_files:
                print(f"[attnroute] Using global ~/.claude: {global_claude}", file=sys.stderr)
                print(f"  Found {len(md_files)} .md files", file=sys.stderr)
                result = global_claude
            else:
                print("[attnroute] WARN:Global ~/.claude/ exists but has no .md files", file=sys.stderr)

    # Cache the result (even if None)
    _docs_root_checked = True
    _docs_root_cache = result

    # Priority 4: Fail with helpful error
    if result is None:
        raise FileNotFoundError(
            "\n"
            "─────────────────────────────────────────────────────────\n"
            "  No .claude/ directory with documentation found.\n"
            "\n"
            "  Create .claude/ in your project root and add .md files:\n"
            "    mkdir -p .claude/\n"
            "    echo '# My Project' > .claude/README.md\n"
            "\n"
            "  Or set explicit path:\n"
            "    export CONTEXT_DOCS_ROOT=/path/to/docs\n"
            "\n"
            "  Priority order:\n"
            "    1. CONTEXT_DOCS_ROOT environment variable\n"
            "    2. Project-local .claude/ (current directory)\n"
            "    3. Global ~/.claude/ (home directory)\n"
            "─────────────────────────────────────────────────────────\n"
        )

    return result

# ============================================================================
# CONFIGURATION
# ============================================================================

# State file location
PROJECT_STATE = Path(".claude/attn_state.json")
GLOBAL_STATE = Path.home() / ".claude" / "attn_state.json"
HISTORY_FILE = Path.home() / ".claude" / "attention_history.jsonl"

# History retention
MAX_HISTORY_DAYS = 30  # Archive entries older than 30 days

# Decay rates per category (how fast files fade when not mentioned)
# Higher = slower decay (more persistent)
DECAY_RATES = {
    "systems/": 0.85,       # Hardware is stable, decay slow
    "modules/": 0.70,       # Code changes more frequently
    "integrations/": 0.80,  # APIs semi-stable
    "docs/": 0.75,          # Documentation medium decay
    "default": 0.70
}

# Attention thresholds
HOT_THRESHOLD = 0.8         # Full file injection
WARM_THRESHOLD = 0.25       # Header-only injection
# Below WARM = COLD (evicted)

# Boost amounts
KEYWORD_BOOST = 1.0         # Direct mention → score = 1.0
COACTIVATION_BOOST = 0.35   # Related file boost
TRANSITIVE_COACT_BOOST = 0.15  # 2-hop co-activation boost (A→B→C)
SEMANTIC_BOOST_WEIGHT = 0.8    # Weight for semantic search results

# Limits (prevent context explosion) - TIGHTENED for lower waste
MAX_HOT_FILES = 3           # Max full-file injection slots
MAX_WARM_FILES = 8          # WARM headers are cheap (~50 tokens each), allow more
# Adaptive injection: only HOT when truly confident, otherwise downgrade to WARM
# This saves ~450 tokens per demoted file (500 full → 50 TOC)
CONFIDENT_HOT_THRESHOLD = 0.90   # Score must be this high for HOT injection
CONFIDENT_HOT_MIN_STREAK = 2     # OR: file must be warm for this many consecutive turns
WARM_HEADER_LINES = 25      # Lines to extract for warm context
MAX_TOTAL_CHARS = 22000     # Balance between coverage and cost

# Confidence thresholds for smarter selection
MIN_SEMANTIC_SCORE = 0.15   # Minimum semantic relevance to consider
MIN_KEYWORD_MATCHES = 1     # Minimum keyword matches to activate
SELECTION_CONFIDENCE = 0.6  # Files below this confidence are demoted

# Keyword matching thresholds
SHORT_KEYWORD_LENGTH = 4    # Keywords <= this length require word boundaries to avoid false positives
SEMANTIC_SEARCH_TOP_K = 6   # Number of semantic search results to retrieve

# Score adjustment factors
PINNED_FILE_FLOOR_BOOST = 0.1      # Extra boost above WARM_THRESHOLD for pinned files
DEMOTED_FILE_PENALTY = 0.5         # Multiplier for demoted files (0.5 = 50% reduction)
PREDICTIVE_PREWARM_TOP_K = 3       # Number of predicted files to pre-warm
PREDICTIVE_PREWARM_MAX_BOOST = 0.2 # Maximum boost from prediction
PREDICTIVE_PREWARM_CONFIDENCE_SCALE = 0.3  # Scale prediction probability by this
PREDICTIVE_PREWARM_MARGIN = 0.05   # Stay this far below WARM_THRESHOLD to avoid false activation

# Content compression
WARM_COMPRESSION_MAX_CHARS = 2000  # Maximum characters for warm TOC compression

# Repo map integration
REPO_MAP_MAX_FILES = 200           # Maximum files to index in repo mapper
REPO_MAP_TOKEN_BUDGET = 500        # Token budget for symbol-level context

# Notification handling
NOTIFICATION_SHORT_PROMPT_THRESHOLD = 200  # Prompts shorter than this after notification strip get clamped
NOTIFICATION_MAX_HOT_FILES = 1     # Limit hot files for notification-mixed prompts
NOTIFICATION_MAX_WARM_FILES = 2    # Limit warm files for notification-mixed prompts
NOTIFICATION_MAX_CHARS = 5000      # Limit total chars for notification-mixed prompts

# Log file rotation
LOG_MAX_SIZE_BYTES = 50_000        # Rotate log when it exceeds this size
LOG_KEEP_SIZE_BYTES = 25_000       # Keep this many bytes after rotation

# Pinned files (always at least WARM) — loaded from keywords.json "pinned" field
# Empty by default; config-driven only.
PINNED_FILES = []

# ============================================================================
# SOURCE CODE ROUTING CONFIGURATION
# ============================================================================

# Feature flag - enables source code indexing and routing
SOURCE_INDEXING_ENABLED = True

# Source file limits (separate from doc limits to prevent crowding out docs)
SOURCE_MAX_HOT_FILES = 2           # Max source files to inject as HOT
SOURCE_MAX_WARM_FILES = 3          # Max source files to inject as WARM
SOURCE_MAX_CHARS = 8000            # Max total chars for source context

# HINT tier: path-only file suggestions (~5 tokens each, very cheap)
# Includes overflow WARM files that didn't fit in core selection
HINT_MAX_FILES = 20                # Max HINT files to inject
HINT_THRESHOLD = 0.15              # Score threshold for HINT tier (below WARM)

# Source file size limit (skip large generated/minified files)
SOURCE_MAX_FILE_SIZE = 100_000     # 100KB - skip files larger than this

# Maximum tracked source files in state (prevent unbounded growth)
MAX_TRACKED_SOURCE_FILES = 50

# Supported source file extensions
SOURCE_EXTENSIONS = {
    ".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs",
    ".java", ".c", ".cpp", ".h", ".rb", ".php", ".swift", ".kt"
}

# Directories to exclude from source indexing
SOURCE_EXCLUDED_DIRS = {
    "node_modules", ".git", "__pycache__", "venv", ".venv",
    "dist", "build", "target", ".next", ".nuxt", "coverage",
    ".pytest_cache", ".mypy_cache", ".tox", "eggs", "*.egg-info"
}


def _is_source_file(path: str) -> bool:
    """Check if a path is a source code file based on extension."""
    return Path(path).suffix.lower() in SOURCE_EXTENSIONS


def _is_doc_file(path: str, docs_root: Path = None) -> bool:
    """Check if a path is a documentation file (under .claude/)."""
    if path.endswith(".md"):
        return True
    # Check if .claude is actually a directory component, not just a substring
    # Handles both Unix (/) and Windows (\) path separators
    normalized = path.replace("\\", "/")
    return "/.claude/" in normalized or normalized.startswith(".claude/")


def _evict_lowest_source(state: dict, new_path: str, new_score: float) -> None:
    """
    Evict the lowest-scoring source file to make room for a new one.

    Only evicts if the new file's score is higher than the lowest.
    """
    source_files = [(p, s) for p, s in state["scores"].items() if _is_source_file(p)]
    if not source_files:
        return

    # Find lowest scoring source file
    lowest_path, lowest_score = min(source_files, key=lambda x: x[1])

    # Only evict if new score is higher
    if new_score > lowest_score:
        del state["scores"][lowest_path]
        if lowest_path in state.get("consecutive_turns", {}):
            del state["consecutive_turns"][lowest_path]
        # Add the new file
        state["scores"][new_path] = new_score
        state["consecutive_turns"][new_path] = 0

# ============================================================================
# CONFIG LOADING
# Load keywords and co-activation from external config if available
# ============================================================================

def load_keyword_config() -> tuple[dict[str, list[str]], dict[str, list[str]], list[str]]:
    """
    Load keywords, co-activation graph, and pinned files from keywords.json.
    Falls back to hardcoded defaults if config doesn't exist or fails to parse.

    Returns: (keywords_dict, co_activation_dict, pinned_list)
    """
    # Try project-local config first, then global
    config_paths = [
        Path(".claude/keywords.json"),
        Path.home() / ".claude" / "keywords.json"
    ]

    for config_path in config_paths:
        if config_path.exists():
            try:
                config = json.loads(config_path.read_text(encoding='utf-8'))
                keywords = config.get("keywords", {})
                co_activation = config.get("co_activation", {})
                pinned = config.get("pinned", [])

                # Validate structure
                if keywords and isinstance(keywords, dict):
                    print(f"[attnroute] Loaded keywords from {config_path}", file=sys.stderr)
                    return keywords, co_activation, pinned
            except (json.JSONDecodeError, Exception) as e:
                print(f"[attnroute] WARN:Failed to load {config_path}: {e}", file=sys.stderr)
                continue

    # Fallback: auto-extract keywords from .md file content (zero-config mode)
    if LEARNER_AVAILABLE:
        try:
            docs_root = Path(".claude") if Path(".claude").is_dir() else Path.home() / ".claude"
            auto_kw = auto_extract_keywords(docs_root)
            if auto_kw:
                pass  # Auto-extracted keywords silently
                return auto_kw, {}, []
        except Exception:
            pass

    print("[attnroute] WARN: No keywords.json found — attnroute has no routing rules.", file=sys.stderr)
    print("  Run 'attnroute init' to generate a keywords.json template.", file=sys.stderr)
    return _DEFAULT_KEYWORDS, _DEFAULT_CO_ACTIVATION, []

# ============================================================================
# KEYWORD MAPPINGS
# What words/phrases activate which files
# ============================================================================

# Domain-agnostic defaults — zero hardcoded domain keywords.
# attnroute is a generic keyword→file routing engine.
# All domain-specific keywords belong in keywords.json config files.
# These structural defaults only match common doc organization patterns.
_DEFAULT_KEYWORDS: dict[str, list[str]] = {}

# ============================================================================
# CO-ACTIVATION GRAPH
# When one file activates, these related files get a boost
# ============================================================================

# Domain-agnostic: empty by default. All co-activation belongs in keywords.json.
_DEFAULT_CO_ACTIVATION: dict[str, list[str]] = {}

# Load actual configuration (from keywords.json or fallback to defaults)
KEYWORDS, CO_ACTIVATION, _LOADED_PINNED = load_keyword_config()

# Override hardcoded PINNED_FILES with config-loaded ones if available
if _LOADED_PINNED:
    PINNED_FILES = _LOADED_PINNED


def build_bidirectional_coactivation(co_act: dict) -> dict:
    """Ensure A->B implies B->A (symmetric co-activation)."""
    bidirectional = {}
    for source, targets in co_act.items():
        if source not in bidirectional:
            bidirectional[source] = set()
        for t in targets:
            bidirectional[source].add(t)
            if t not in bidirectional:
                bidirectional[t] = set()
            bidirectional[t].add(source)
    return {k: list(v) for k, v in bidirectional.items()}


CO_ACTIVATION = build_bidirectional_coactivation(CO_ACTIVATION)

# Merge learned co-activation from the intelligence engine
if LEARNER_AVAILABLE:
    _learner_instance = get_learner()
    if _learner_instance is not None:  # C2: Guard against None if Learner init fails
        _learned_coact = _learner_instance.get_learned_coactivation()
        for source, targets in _learned_coact.items():
            if source not in CO_ACTIVATION:
                CO_ACTIVATION[source] = []
            for t in targets:
                if t not in CO_ACTIVATION[source]:
                    CO_ACTIVATION[source].append(t)

# Build networkx graph for transitive co-activation (2-hop relationships)
def build_coactivation_graph(co_act: dict):
    """Build networkx graph for transitive co-activation queries."""
    global _coactivation_graph
    if not NETWORKX_AVAILABLE:
        return None

    G = nx.Graph()
    for source, targets in co_act.items():
        G.add_node(source)
        for target in targets:
            G.add_edge(source, target, weight=1.0)

    _coactivation_graph = G
    return G

def get_transitive_coactivation(activated_files: set[str], max_hops: int = 2) -> dict[str, float]:
    """
    Get transitively co-activated files up to max_hops away.

    Returns: dict of {file_path: boost_score} where boost decays with distance
    """
    if not NETWORKX_AVAILABLE or _coactivation_graph is None:
        return {}

    transitive = {}
    for source in activated_files:
        if source not in _coactivation_graph:
            continue

        # BFS to find files within max_hops
        try:
            lengths = nx.single_source_shortest_path_length(_coactivation_graph, source, cutoff=max_hops)
            for target, distance in lengths.items():
                if target == source or target in activated_files:
                    continue
                # Decay boost by distance: hop 1 = COACTIVATION_BOOST, hop 2 = TRANSITIVE_COACT_BOOST
                if distance == 1:
                    boost = COACTIVATION_BOOST
                else:
                    boost = TRANSITIVE_COACT_BOOST / distance
                # Take max if file reachable from multiple sources
                transitive[target] = max(transitive.get(target, 0), boost)
        except nx.NetworkXError:
            continue

    return transitive

# Initialize the graph
_coactivation_graph = build_coactivation_graph(CO_ACTIVATION) if NETWORKX_AVAILABLE else None

# Keyword weights placeholder (learner.boost_scores() handles learned associations instead)
_KEYWORD_WEIGHTS: dict[str, float] = {}

# ============================================================================
# COMPILED KEYWORD REGEX (performance optimization)
# Pre-compile one regex per file path. Replaces ~200 regex ops/turn with ~24.
# ============================================================================

def _build_compiled_keywords(keywords: dict[str, list[str]]) -> dict[str, re.Pattern]:
    """Build compiled regex for each file's keywords."""
    compiled = {}
    for path, kw_list in keywords.items():
        if not kw_list:
            continue
        # Escape keywords and join with | for alternation
        # Add word boundaries for short keywords (<=4 chars)
        patterns = []
        for kw in kw_list:
            escaped = re.escape(kw)
            if len(kw) <= SHORT_KEYWORD_LENGTH:
                patterns.append(r'\b' + escaped + r'\b')
            else:
                patterns.append(escaped)
        try:
            compiled[path] = re.compile('|'.join(patterns), re.IGNORECASE)
        except re.error:
            # Fallback: skip this file's regex if pattern is invalid
            pass
    return compiled

_COMPILED_KEYWORDS: dict[str, re.Pattern] = _build_compiled_keywords(KEYWORDS)

# ============================================================================
# STATE MANAGEMENT
# ============================================================================

def get_state_file() -> Path:
    """Get appropriate state file (project-local preferred)."""
    # Use try/except instead of exists() to avoid TOCTOU race
    try:
        PROJECT_STATE.parent.mkdir(parents=True, exist_ok=True)
        return PROJECT_STATE
    except OSError:
        GLOBAL_STATE.parent.mkdir(parents=True, exist_ok=True)
        return GLOBAL_STATE


def load_state(state_file: Path) -> dict:
    """Load attention state from file (TOCTOU-safe)."""
    # Use try/except instead of exists() to avoid race conditions
    try:
        return json.loads(state_file.read_text(encoding='utf-8'))
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        pass

    # Initialize fresh state
    return {
        "scores": {path: 0.0 for path in KEYWORDS},
        "consecutive_turns": {path: 0 for path in KEYWORDS},  # Streak tracking for cache stability
        "turn_count": 0,
        "last_update": datetime.now().isoformat(),
    }


def save_state(state_file: Path, state: dict) -> None:
    """Save attention state to file using atomic write with fallback."""
    from attnroute.compat import safe_atomic_write
    state["last_update"] = datetime.now().isoformat()
    state_file.parent.mkdir(parents=True, exist_ok=True)
    content = json.dumps(state, indent=2)
    if not safe_atomic_write(state_file, content):
        print(f"[attnroute] Warning: Failed to save state to {state_file}", file=sys.stderr)


# ============================================================================
# ATTENTION DYNAMICS
# ============================================================================

def get_decay_rate(file_path: str) -> float:
    """Get decay rate for a file. Prefers learned rhythm, then category, then default."""
    # Priority 1: Learned per-file rhythm from the intelligence engine
    if LEARNER_AVAILABLE:
        learner = get_learner()
        if learner is not None:
            learned = learner.get_file_decay(file_path)
            if learned is not None:
                return learned

    # Priority 2: Category-based decay from config
    for prefix, rate in DECAY_RATES.items():
        if file_path.startswith(prefix):
            return rate
    return DECAY_RATES["default"]


def keyword_matches(keyword: str, text: str) -> bool:
    """Match keyword with word boundaries for short words to prevent false positives."""
    if len(keyword) <= SHORT_KEYWORD_LENGTH:
        return bool(re.search(r'\b' + re.escape(keyword) + r'\b', text))
    return keyword in text


def _keyword_activate(state: dict, prompt_lower: str, directly_activated: set[str]):
    """Keyword-based activation using compiled regex (fallback path)."""
    for path in KEYWORDS:
        if path in _COMPILED_KEYWORDS:
            # Fast path: single compiled regex search
            if _COMPILED_KEYWORDS[path].search(prompt_lower):
                state["scores"][path] = KEYWORD_BOOST
                directly_activated.add(path)
        else:
            # Fallback: individual keyword matching (rarely used)
            keywords = KEYWORDS[path]
            if any(keyword_matches(kw, prompt_lower) for kw in keywords):
                state["scores"][path] = KEYWORD_BOOST
                directly_activated.add(path)


def _extract_file_refs(prompt: str) -> list[str]:
    """Extract potential file references from a prompt.

    Finds explicit filenames (e.g., 'context_router.py', 'src/models.py')
    and snake_case identifiers that might match filenames.
    """
    refs = []
    # Explicit filenames with extensions
    refs += re.findall(
        r'[\w./\\-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|c|cpp|h|rb|php|swift|kt|md|json|yaml|yml|toml)',
        prompt
    )
    # snake_case identifiers (e.g., "context_router", "session_replay")
    refs += re.findall(r'\b[a-z][a-z0-9]*(?:_[a-z0-9]+){1,}\b', prompt)
    # CamelCase identifiers → convert to snake_case for matching
    # (e.g., "FilePredictor" → "file_predictor" matches predictor.py)
    camel_matches = re.findall(r'\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b', prompt)
    for cm in camel_matches:
        snake = re.sub(r'([A-Z])', r'_\1', cm).lower().lstrip('_')
        refs.append(snake)
    return refs


# Continuation prompt detection
_CONTINUATION_WORDS = {
    "yes", "yep", "yeah", "ok", "okay", "sure", "go", "do", "continue",
    "proceed", "agreed", "correct", "right", "exactly", "perfect",
    "awesome", "great", "good", "nice", "cool", "fine", "thanks",
    "dude", "love", "both", "all", "apply", "ship", "send", "run",
    "sorry", "please", "ahead", "hell", "fuck", "damn", "shit",
    "make", "it", "them", "that", "this", "the", "lgtm", "sweet",
    "keep", "going", "beautiful", "thorough", "just", "now",
}
_MAX_CONTINUATION_LENGTH = 60  # Prompts shorter than this may be continuations


def _is_continuation_prompt(prompt: str) -> bool:
    """Detect if a prompt is a short continuation/approval of prior context."""
    stripped = prompt.strip().rstrip(".!?,")
    if len(stripped) > _MAX_CONTINUATION_LENGTH:
        return False
    words = set(re.findall(r'[a-z]+', stripped.lower()))
    # If most words are continuation words, it's a follow-up
    if not words:
        return True
    continuation_ratio = len(words & _CONTINUATION_WORDS) / len(words)
    return continuation_ratio >= 0.5


def _build_effective_prompt(prompt: str, history: list[str]) -> str:
    """Build an enriched prompt by prepending context from prior turns.

    For continuation prompts ("yes go ahead", "fix them"), BM25 needs
    the context from what was previously discussed.
    """
    if not history:
        return prompt
    if not _is_continuation_prompt(prompt):
        return prompt
    # Prepend last 1-2 substantial prompts (skip other continuations)
    context_parts = []
    for prev in reversed(history):
        if not _is_continuation_prompt(prev) and len(prev) > 20:
            context_parts.append(prev)
            if len(context_parts) >= 2:
                break
    if not context_parts:
        return prompt
    context = " ".join(reversed(context_parts))
    return f"{context} {prompt}"


def _activate_by_filename(state: dict, prompt: str, directly_activated: set[str]):
    """Activate files that are directly referenced by name in the prompt."""
    file_refs = _extract_file_refs(prompt)
    if not file_refs:
        return

    idx = get_search_index()
    if idx is None:
        return

    try:
        all_paths = idx.get_all_paths()
    except Exception:
        return

    for ref in file_refs:
        ref_lower = ref.lower().replace("\\", "/")
        ref_basename = Path(ref_lower).name

        for path in all_paths:
            path_lower = path.lower()
            path_basename = Path(path_lower).name

            # Exact basename match (e.g., "context_router.py" → "context_router.py")
            if ref_basename and path_basename == ref_basename:
                state.setdefault("scores", {})[path] = max(state["scores"].get(path, 0), KEYWORD_BOOST)
                state.setdefault("consecutive_turns", {})[path] = state.get("consecutive_turns", {}).get(path, 0)
                directly_activated.add(path)
                continue

            # Name without extension match (e.g., "context_router" → "context_router.py")
            ref_stem = ref_lower.rsplit(".", 1)[0] if "." in ref_lower else ref_lower
            path_stem = path_lower.rsplit(".", 1)[0] if "." in path_lower else path_lower
            if ref_stem and (path_stem.endswith("/" + ref_stem) or path_stem == ref_stem):
                state.setdefault("scores", {})[path] = max(state["scores"].get(path, 0), KEYWORD_BOOST)
                state.setdefault("consecutive_turns", {})[path] = state.get("consecutive_turns", {}).get(path, 0)
                directly_activated.add(path)


def observe_tool_calls(state: dict, tool_calls: list[dict],
                       project_dir: str = None) -> dict:
    """Update attention state based on files Claude actually accessed.

    Called after Claude finishes responding. Boosts accessed files
    and learns co-access patterns for future predictions.

    Args:
        state: Current attention state
        tool_calls: List of tool call dicts with 'name' and 'input' keys
        project_dir: Project root for path normalization (optional)

    Returns:
        Updated state dict
    """
    accessed_files = set()
    cwd = project_dir or str(Path.cwd())
    cwd_normalized = cwd.replace("\\", "/").rstrip("/")

    for tc in tool_calls:
        tool_name = tc.get("name", "")
        tool_input = tc.get("input", {})

        # Extract file path and determine signal strength
        file_path = None
        boost = 0.7  # Default: strong signal (Read/Edit/Write)
        if tool_name in ("Read", "Edit", "Write", "MultiEdit"):
            file_path = tool_input.get("file_path")
            boost = 0.75  # Direct file access — strongest signal
        elif tool_name in ("Grep", "grep"):
            file_path = tool_input.get("path")
            boost = 0.4  # Directory search — moderate signal (searching, not reading)
        elif tool_name in ("Glob", "glob"):
            file_path = tool_input.get("path")
            boost = 0.3  # File pattern search — weaker signal

        if file_path:
            # Normalize to forward slashes and make relative to project
            rel = file_path.replace("\\", "/")
            if rel.lower().startswith(cwd_normalized.lower()):
                rel = rel[len(cwd_normalized):].lstrip("/")
            # Remove drive letter on Windows
            if len(rel) > 2 and rel[1] == ":":
                rel = rel[2:].lstrip("/")

            accessed_files.add(rel)
            state.setdefault("scores", {})[rel] = max(state["scores"].get(rel, 0), boost)
            if boost >= 0.7:  # Only count direct access for streak tracking
                state.setdefault("consecutive_turns", {})[rel] = (
                    state.get("consecutive_turns", {}).get(rel, 0) + 1
                )

    # Learn co-access: files used together get co-activation edges
    accessed_list = list(accessed_files)
    for i, a in enumerate(accessed_list):
        for b in accessed_list[i + 1:]:
            if a in CO_ACTIVATION:
                if b not in CO_ACTIVATION[a]:
                    CO_ACTIVATION[a].append(b)
            else:
                CO_ACTIVATION[a] = [b]
            if b in CO_ACTIVATION:
                if a not in CO_ACTIVATION[b]:
                    CO_ACTIVATION[b].append(a)
            else:
                CO_ACTIVATION[b] = [a]

    # Persist co-access edges through the learner (fixes silent data loss bug)
    if len(accessed_list) >= 2 and LEARNER_AVAILABLE:
        try:
            learner = get_learner()
            if learner is not None:
                learned_coact = learner.state.get("coactivation_learned", {})
                changed = False
                for i, a in enumerate(accessed_list):
                    for b in accessed_list[i + 1:]:
                        if a not in learned_coact:
                            learned_coact[a] = []
                        if b not in learned_coact[a]:
                            learned_coact[a].append(b)
                            changed = True
                        if b not in learned_coact:
                            learned_coact[b] = []
                        if a not in learned_coact[b]:
                            learned_coact[b].append(a)
                            changed = True
                if changed:
                    learner.state["coactivation_learned"] = learned_coact
                    learner._save()
        except Exception:
            pass  # Never crash the Stop hook

    return state


def update_attention(state: dict, prompt: str) -> tuple[dict, set[str]]:
    """
    Update attention scores based on prompt content.
    Returns updated state and set of directly activated files.

    Pipeline:
      1. Decay all scores
      1.5. Direct filename activation from prompt
      2. Semantic search (if available) OR keyword activation (fallback)
      3. Learned association boost
      4. Co-activation boost (direct + transitive via graph)
      5. Pinned file floor
      6. Demoted file penalty
      7. Update streak counters
    """
    # Ensure search index is built (lazy initialization)
    if state.get("turn_count", 0) == 0:
        ensure_search_index_built()

    # Rolling prompt context: enrich short/continuation prompts
    # with context from previous turns so BM25 has something to match
    prompt_history = state.get("prompt_history", [])
    effective_prompt = _build_effective_prompt(prompt, prompt_history)
    prompt_history.append(prompt[:200])  # Cap stored length
    state["prompt_history"] = prompt_history[-3:]  # Keep last 3

    prompt_lower = effective_prompt.lower()
    directly_activated: set[str] = set()

    # Ensure consecutive_turns dict exists (backwards compat with old state files)
    if "consecutive_turns" not in state:
        state["consecutive_turns"] = {path: 0 for path in state.get("scores", {})}

    # Phase 1: Decay all scores
    for path in state["scores"]:
        decay = get_decay_rate(path)
        state["scores"][path] *= decay

    # Phase 1.5: Direct filename activation from prompt
    _activate_by_filename(state, prompt, directly_activated)

    # Phase 2: Semantic search (if available) OR keyword activation
    # Use effective_prompt (enriched with history for continuations)
    # For continuation prompts, reduce BM25 influence so recency dominates
    is_continuation = _is_continuation_prompt(prompt)
    search_weight = SEMANTIC_BOOST_WEIGHT * (0.5 if is_continuation else 1.0)
    if SEARCH_AVAILABLE and get_search_index():
        try:
            results = get_search_index().query(effective_prompt, top_k=SEMANTIC_SEARCH_TOP_K)
            for path, relevance in results:
                # Only activate if above minimum semantic score
                if relevance >= MIN_SEMANTIC_SCORE:
                    boost = min(1.0, relevance * search_weight)
                    # If path is already in state, update if higher
                    if path in state["scores"]:
                        if boost > state["scores"][path]:
                            state["scores"][path] = boost
                            directly_activated.add(path)
                    else:
                        # Dynamically add source files found by search
                        # This allows state to grow organically as user works
                        if _is_source_file(path) and SOURCE_INDEXING_ENABLED:
                            # Check if we're at capacity for tracked source files
                            source_count = sum(1 for p in state["scores"] if _is_source_file(p))
                            if source_count < MAX_TRACKED_SOURCE_FILES:
                                state["scores"][path] = boost
                                state["consecutive_turns"][path] = 0
                                directly_activated.add(path)
                            else:
                                # Evict lowest-score source file to make room
                                _evict_lowest_source(state, path, boost)
                                if path in state["scores"]:
                                    directly_activated.add(path)
        except Exception as e:
            # Fallback to keyword activation if search fails
            print(f"[attnroute] Search query failed, using keyword fallback: {e}", file=sys.stderr)
            _keyword_activate(state, prompt_lower, directly_activated)
    else:
        # No search available - use keyword activation
        _keyword_activate(state, prompt_lower, directly_activated)

    # Phase 2.5: Learned association boost (from intelligence engine)
    if LEARNER_AVAILABLE:
        learner = get_learner()
        if learner is not None:
            state["scores"] = learner.boost_scores(effective_prompt, state["scores"])

    # Phase 2.7: Intent-based boost (semantic intent shortcuts)
    try:
        from attnroute.intent_map import boost_by_intent, detect_intent
        intents = detect_intent(effective_prompt)
        if intents:
            state["scores"] = boost_by_intent(intents, state["scores"], state)
    except ImportError:
        pass

    # Phase 3: Co-activation boost (direct neighbors)
    for activated_path in directly_activated:
        if activated_path in CO_ACTIVATION:
            for related_path in CO_ACTIVATION[activated_path]:
                if related_path in state["scores"]:
                    current = state["scores"][related_path]
                    state["scores"][related_path] = min(1.0, current + COACTIVATION_BOOST)

    # Phase 3.5: Transitive co-activation (2-hop neighbors via graph)
    if NETWORKX_AVAILABLE and _coactivation_graph is not None:
        transitive_boosts = get_transitive_coactivation(directly_activated, max_hops=2)
        for path, boost in transitive_boosts.items():
            if path in state["scores"]:
                current = state["scores"][path]
                # Only apply if file isn't already activated
                if current < WARM_THRESHOLD:
                    state["scores"][path] = min(1.0, current + boost)

    # Phase 3.6: Import graph boost
    # Files that import/are imported by activated files get a boost.
    # Built once on turn 0 and cached in state.
    import_graph = state.get("import_graph")
    if import_graph is None and state.get("turn_count", 0) == 0:
        try:
            from attnroute.warmup import _build_import_graph
            import_graph = _build_import_graph(Path.cwd())
            # Build reverse graph (importers of each file)
            reverse_graph: dict[str, set[str]] = {}
            for src, imports in import_graph.items():
                for imp in imports:
                    reverse_graph.setdefault(imp, set()).add(src)
            state["import_graph"] = import_graph
            state["import_graph_reverse"] = {k: list(v) for k, v in reverse_graph.items()}
        except Exception:
            state["import_graph"] = {}
            state["import_graph_reverse"] = {}
            import_graph = {}
    if import_graph:
        reverse = state.get("import_graph_reverse", {})
        for activated_path in directly_activated:
            # Boost files that the activated file imports
            for imported in import_graph.get(activated_path, set()):
                if imported in state["scores"]:
                    current = state["scores"][imported]
                    state["scores"][imported] = min(1.0, current + 0.15)
            # Boost files that import the activated file
            for importer in reverse.get(activated_path, []):
                if importer in state["scores"]:
                    current = state["scores"][importer]
                    state["scores"][importer] = min(1.0, current + 0.10)

    # Phase 3.7: Git co-change boost
    # Files that are historically changed together get mutual boost
    # Only boost partners that already have non-zero scores (evidence of relevance)
    cochange = state.get("cochange_graph")
    if cochange is None and state.get("turn_count", 0) == 0:
        try:
            from attnroute.warmup import build_cochange_graph
            cochange = build_cochange_graph(Path.cwd(), max_commits=150)
            state["cochange_graph"] = cochange
        except Exception:
            state["cochange_graph"] = {}
            cochange = {}
    if cochange:
        for activated_path in directly_activated:
            partners = cochange.get(activated_path, [])
            for partner, strength in partners:
                current = state["scores"].get(partner, 0)
                # Only boost files that already have some signal (non-zero score)
                if current > 0.01:
                    boost = min(0.2, strength * 0.25)
                    state["scores"][partner] = min(
                        1.0, current + boost
                    )

    # Phase 3.8: Test-source pairing
    # When a source file is activated, boost its test file and vice versa
    # Pre-build name→path lookup for O(1) matching instead of O(n) per file
    _name_to_path: dict[str, str] = {}
    for _p in state["scores"]:
        _n = Path(_p).name
        if _n not in _name_to_path:
            _name_to_path[_n] = _p

    for activated_path in directly_activated:
        basename = Path(activated_path).stem
        ext = Path(activated_path).suffix
        if not ext:
            continue
        if basename.startswith("test_"):
            target_name = basename[5:] + ext
        else:
            target_name = f"test_{basename}{ext}"

        target_path = _name_to_path.get(target_name)
        if target_path:
            current = state["scores"][target_path]
            state["scores"][target_path] = min(1.0, current + 0.20)

    # Phase 4: Pinned file floor
    for pinned in PINNED_FILES:
        if pinned in state["scores"]:
            state["scores"][pinned] = max(state["scores"][pinned], WARM_THRESHOLD + PINNED_FILE_FLOOR_BOOST)

    # Phase 5: Apply demoted file penalty from telemetry overrides
    if TELEMETRY_LIB_AVAILABLE:
        try:
            proj_overrides = load_project_overrides()
            demoted = proj_overrides.get("demoted_files", [])
            for path in demoted:
                if path in state["scores"] and path not in directly_activated:
                    state["scores"][path] *= DEMOTED_FILE_PENALTY
        except Exception:
            pass

    # Phase 5.5: Predictive pre-warming (boost predicted files if currently COLD)
    predictor = get_predictor() if PREDICTOR_AVAILABLE else None
    if predictor:
        try:
            active_files = [p for p, s in state["scores"].items() if s >= WARM_THRESHOLD]
            predictions = predictor.predict_with_prompt(
                prompt, active_files, top_k=PREDICTIVE_PREWARM_TOP_K
            )
            for path, prob in predictions:
                if path in state["scores"]:
                    current = state["scores"][path]
                    if current < WARM_THRESHOLD:  # Only pre-warm COLD files
                        boost = min(PREDICTIVE_PREWARM_MAX_BOOST, prob * PREDICTIVE_PREWARM_CONFIDENCE_SCALE)
                        state["scores"][path] = min(WARM_THRESHOLD - PREDICTIVE_PREWARM_MARGIN, current + boost)
        except Exception:
            pass

    # Phase 6: Update consecutive_turns counters for prompt cache stability
    # Files that are HOT or WARM this turn increment their streak.
    # Files that drop to COLD are removed to prevent unbounded growth.
    for path, score in state["scores"].items():
        tier = get_tier(score)
        if tier in ("HOT", "WARM"):
            state["consecutive_turns"][path] = state["consecutive_turns"].get(path, 0) + 1
        else:
            state["consecutive_turns"].pop(path, None)

    # Prune stale entries for files no longer in scores
    stale_keys = [k for k in state["consecutive_turns"] if k not in state["scores"]]
    for k in stale_keys:
        del state["consecutive_turns"][k]

    state["turn_count"] = state.get("turn_count", 0) + 1
    return state, directly_activated


# ============================================================================
# CONTENT EXTRACTION
# ============================================================================

def compress_warm(content: str, max_chars: int = WARM_COMPRESSION_MAX_CHARS) -> str:
    """
    Compress markdown content to TOC-style summary for WARM tier.

    Extracts:
    - All heading lines (# ## ### etc.)
    - First 2 non-empty lines after each heading (context lines)
    - Bullet points at top level (- or * prefixed)

    Target: ~50% size reduction compared to raw header extraction.
    """
    lines = content.split('\n')
    result = []
    chars = 0
    after_heading = 0  # Counter for lines after last heading

    for line in lines:
        stripped = line.strip()

        # Always include headings
        if stripped.startswith('#'):
            if chars + len(line) + 1 > max_chars:
                break
            result.append(line)
            chars += len(line) + 1
            after_heading = 0
            continue

        # Include first 2 non-empty lines after a heading
        if after_heading < 2 and stripped:
            if chars + len(line) + 1 > max_chars:
                break
            result.append(line)
            chars += len(line) + 1
            after_heading += 1
            continue

        # Include top-level bullet points
        if stripped.startswith(('-', '*')) and not stripped.startswith('---'):
            if chars + len(line) + 1 > max_chars:
                break
            result.append(line)
            chars += len(line) + 1
            continue

    compressed = '\n'.join(result)
    if len(compressed) < len(content):
        compressed += "\n\n... [WARM: Compressed TOC, mention to expand] ..."
    return compressed


def extract_warm_header(file_path: str, docs_root: Path, use_compression: bool = True) -> str | None:
    """
    Extract structured header for warm context.

    If use_compression=True (default), uses compress_warm() for TOC-style extraction.
    Otherwise falls back to first WARM_HEADER_LINES lines.
    Returns None if file missing.
    """
    full_path = docs_root / file_path
    if not full_path.exists():
        return None

    try:
        content = full_path.read_text(encoding='utf-8')

        if use_compression:
            return compress_warm(content)

        # Fallback: raw line-based extraction
        lines = content.split('\n')[:WARM_HEADER_LINES]
        header = '\n'.join(lines)

        # Add truncation marker if we cut content
        if len(content.split('\n')) > WARM_HEADER_LINES:
            header += "\n\n... [WARM: Content truncated, mention to expand] ..."

        return header
    except Exception as e:
        return f"[Error reading {file_path}: {e}]"


def get_full_content(file_path: str, docs_root: Path) -> str | None:
    """Get full file content for hot context."""
    full_path = docs_root / file_path
    if not full_path.exists():
        return None

    try:
        return full_path.read_text(encoding='utf-8')
    except Exception as e:
        return f"[Error reading {file_path}: {e}]"


# ============================================================================
# TIERED INJECTION
# ============================================================================

def get_tier(score: float) -> str:
    """Classify attention score into tier."""
    if score >= HOT_THRESHOLD:
        return "HOT"
    elif score >= WARM_THRESHOLD:
        return "WARM"
    elif score >= HINT_THRESHOLD:
        return "HINT"
    return "COLD"


def _cache_sort_key(item: tuple[str, float], state: dict) -> tuple[int, int, float]:
    """
    Sort key for prompt cache stability.

    Priority (descending):
    1. Pinned files first (always present → always cached by API)
    2. Longest consecutive streak (most stable in context → highest cache hit)
    3. Highest attention score (tiebreaker)

    Returns tuple for descending sort (negate values since sorted() is ascending).
    """
    path, score = item
    is_pinned = 1 if path in PINNED_FILES else 0
    streak = state.get("consecutive_turns", {}).get(path, 0)
    return (-is_pinned, -streak, -score)


def _get_source_outline(file_path: str) -> str | None:
    """
    Get tree-sitter outline for a source file.

    Returns outline string or None if unavailable.
    Falls back to first 50 lines if outliner unavailable.
    """
    if not _is_source_file(file_path):
        return None

    full_path = Path.cwd() / file_path
    if not full_path.exists():
        # Try as absolute path
        full_path = Path(file_path)
        if not full_path.exists():
            return None

    # Check file size limit
    try:
        if full_path.stat().st_size > SOURCE_MAX_FILE_SIZE:
            return None
    except Exception:
        return None

    # Try tree-sitter outline first
    if OUTLINER_AVAILABLE and extract_outline:
        try:
            outline = extract_outline(full_path)
            if outline:
                return outline
        except Exception:
            pass

    # Fallback: first 50 lines
    try:
        content = full_path.read_text(encoding='utf-8', errors='replace')
        lines = content.split('\n')[:50]
        if len(content.split('\n')) > 50:
            lines.append("\n... [Source file truncated - use Read for full content] ...")
        return '\n'.join(lines)
    except Exception:
        return None


def _get_source_summary(file_path: str) -> str | None:
    """
    Get a one-line summary for WARM source files.

    Extracts first docstring or comment from the file.
    """
    full_path = Path.cwd() / file_path
    if not full_path.exists():
        full_path = Path(file_path)
        if not full_path.exists():
            return None

    try:
        content = full_path.read_text(encoding='utf-8', errors='replace')
        lines = content.split('\n')

        # Look for docstring or comment in first 10 lines
        for line in lines[:10]:
            stripped = line.strip()
            # Python docstring
            if stripped.startswith('"""') or stripped.startswith("'''"):
                doc = stripped.strip('"\'').strip()
                if doc:
                    return doc[:100]
            # Multi-line comment start
            if stripped.startswith('/*'):
                doc = stripped.lstrip('/*').strip()
                if doc:
                    return doc[:100]
            # Single-line comment
            if stripped.startswith('//') or stripped.startswith('#'):
                doc = stripped.lstrip('/#').strip()
                if doc and not doc.startswith('!'):  # Skip shebang
                    return doc[:100]

        return f"Source file: {Path(file_path).suffix} ({len(lines)} lines)"
    except Exception:
        return None


# Diversity-aware file selection constants
DIVERSITY_LAMBDA = 0.7          # Balance: 0.7 relevance, 0.3 diversity
MAX_PER_DIRECTORY = 3           # Hard cap: max files from same parent dir
DIVERSITY_PENALTY = 0.15        # Score penalty per already-selected sibling


def _directory_of(path: str) -> str:
    """Get the parent directory of a path."""
    parts = path.replace("\\", "/").rsplit("/", 1)
    return parts[0] if len(parts) > 1 else ""


def select_diverse_files(
    candidates: list[tuple[str, float]],
    max_files: int,
    state: dict,
) -> list[tuple[str, float]]:
    """Select files using MMR-style diversity-aware selection.

    Ensures spread across directories while respecting score ordering.
    Files from over-represented directories get penalized so coverage
    across the project is maximized.
    """
    if len(candidates) <= max_files:
        return candidates

    selected = []
    remaining = list(candidates)
    dir_counts: dict[str, int] = {}

    while len(selected) < max_files and remaining:
        best_idx = -1
        best_mmr = -float("inf")

        for i, (path, score) in enumerate(remaining):
            directory = _directory_of(path)

            # Hard cap: skip if directory already at max
            if dir_counts.get(directory, 0) >= MAX_PER_DIRECTORY:
                continue

            # MMR score: relevance minus diversity penalty
            siblings = dir_counts.get(directory, 0)
            penalty = siblings * DIVERSITY_PENALTY
            mmr_score = DIVERSITY_LAMBDA * score - (1 - DIVERSITY_LAMBDA) * penalty

            if mmr_score > best_mmr:
                best_mmr = mmr_score
                best_idx = i

        if best_idx < 0:
            # All remaining from maxed-out directories — take best by score
            best_idx = 0

        path, score = remaining.pop(best_idx)
        selected.append((path, score))
        directory = _directory_of(path)
        dir_counts[directory] = dir_counts.get(directory, 0) + 1

    # Re-sort for cache stability
    selected.sort(key=lambda item: _cache_sort_key(item, state))
    return selected


def build_context_output(state: dict, docs_root: Path) -> tuple[str, dict]:
    """
    Build tiered context output respecting limits.

    HOT files sorted for prompt cache stability:
    - Pinned files first (always present → always in cache prefix)
    - Then by consecutive_turns streak (longest streak = most stable = best cache hit)
    - Then by score (tiebreaker)

    Source files get tree-sitter outlines, not full content injection.
    Doc files get full content (HOT) or compressed TOC (WARM).

    Returns (output_string, stats_dict).
    """
    # Sort files by attention score (highest first) for tier assignment
    sorted_files = sorted(
        state["scores"].items(),
        key=lambda x: x[1],
        reverse=True
    )

    # Separate files into tiers AND by type (doc vs source)
    hot_docs = []
    hot_sources = []
    warm_docs = []
    warm_sources = []
    stats = {"hot": 0, "warm": 0, "cold": 0, "hot_src": 0, "warm_src": 0}
    total_chars = 0
    source_chars = 0

    for file_path, score in sorted_files:
        tier = get_tier(score)
        is_source = _is_source_file(file_path)

        # Adaptive injection: demote HOT → WARM unless truly confident
        # A file is "confident HOT" if:
        #   - Score >= CONFIDENT_HOT_THRESHOLD (very high), OR
        #   - Has been warm for CONFIDENT_HOT_MIN_STREAK consecutive turns
        # This saves ~450 tokens per demoted file (full content → TOC header)
        if tier == "HOT":
            streak = state.get("consecutive_turns", {}).get(file_path, 0)
            is_confident = (
                score >= CONFIDENT_HOT_THRESHOLD
                or streak >= CONFIDENT_HOT_MIN_STREAK
            )
            if is_confident:
                if is_source:
                    hot_sources.append((file_path, score))
                else:
                    hot_docs.append((file_path, score))
            else:
                # Demote to WARM — still useful (TOC), much cheaper
                if is_source:
                    warm_sources.append((file_path, score))
                else:
                    warm_docs.append((file_path, score))
        elif tier == "WARM":
            if is_source:
                warm_sources.append((file_path, score))
            else:
                warm_docs.append((file_path, score))
        else:
            stats["cold"] += 1

    # Diversity-aware selection: spread across directories
    all_doc_candidates = hot_docs + warm_docs
    all_doc_candidates.sort(key=lambda x: x[1], reverse=True)
    diverse_docs = select_diverse_files(
        all_doc_candidates, MAX_HOT_FILES + MAX_WARM_FILES, state
    )
    hot_docs = [(p, s) for p, s in diverse_docs if get_tier(s) == "HOT"][:MAX_HOT_FILES]
    warm_docs = [(p, s) for p, s in diverse_docs if get_tier(s) != "HOT"][:MAX_WARM_FILES]

    # Same for source files
    all_src_candidates = hot_sources + warm_sources
    all_src_candidates.sort(key=lambda x: x[1], reverse=True)
    diverse_sources = select_diverse_files(
        all_src_candidates, SOURCE_MAX_HOT_FILES + SOURCE_MAX_WARM_FILES, state
    )
    hot_sources = [(p, s) for p, s in diverse_sources if get_tier(s) == "HOT"][:SOURCE_MAX_HOT_FILES]
    warm_sources = [(p, s) for p, s in diverse_sources if get_tier(s) != "HOT"][:SOURCE_MAX_WARM_FILES]

    hot_blocks = []
    warm_blocks = []
    source_blocks = []
    repo_map_block = None

    # Try to get repo map for symbol-level context (Aider-style efficiency)
    mapper = get_repo_mapper() if REPO_MAP_AVAILABLE else None

    # === DOC FILES ===
    # SMART INJECTION STRATEGY for docs:
    # 1. First HOT doc: full content
    # 2. Additional HOT docs: repo map only (symbol-level)
    # 3. WARM docs: headers only

    first_hot = True
    hot_files_for_repomap = []

    for file_path, score in hot_docs:
        streak = state.get("consecutive_turns", {}).get(file_path, 0)

        if first_hot:
            content = get_full_content(file_path, docs_root)
            if content and total_chars + len(content) < MAX_TOTAL_CHARS:
                hot_blocks.append(f"─── [HOT] {file_path} (score: {score:.2f}, streak: {streak}) ───\n{content}")
                total_chars += len(content)
                stats["hot"] += 1
                first_hot = False
            elif content:
                # File pushed to repomap, not injected as hot - don't increment stats["hot"]
                hot_files_for_repomap.append(file_path)
        else:
            # File pushed to repomap, not injected as hot - don't increment stats["hot"]
            hot_files_for_repomap.append(file_path)

    # Generate repo map for additional hot doc files
    if mapper and hot_files_for_repomap:
        try:
            repo_map_content = mapper.get_context_for_files(
                hot_files_for_repomap,
                include_related=True,
                token_budget=REPO_MAP_TOKEN_BUDGET
            )
            if repo_map_content:
                repo_map_block = f"─── [REPO MAP] Symbol-level context ───\n{repo_map_content}"
                total_chars += len(repo_map_content)
        except Exception as e:
            print(f"[attnroute] Repo map generation failed: {e}", file=sys.stderr)

    for file_path, score in warm_docs:
        if stats["warm"] >= MAX_WARM_FILES:
            break
        header = extract_warm_header(file_path, docs_root)
        if header and total_chars + len(header) < MAX_TOTAL_CHARS:
            warm_blocks.append(f"─── [WARM] {file_path} (score: {score:.2f}) ───\n{header}")
            total_chars += len(header)
            stats["warm"] += 1

    # === SOURCE FILES ===
    # HOT sources: inject tree-sitter outline (not full content!)
    # WARM sources: inject file path + one-line summary

    for file_path, score in hot_sources:
        if source_chars >= SOURCE_MAX_CHARS:
            break
        outline = _get_source_outline(file_path)
        if outline and source_chars + len(outline) < SOURCE_MAX_CHARS:
            source_blocks.append(f"─── [HOT:SRC] {file_path} (score: {score:.2f}) ───\n{outline}")
            source_chars += len(outline)
            stats["hot_src"] += 1

    for file_path, score in warm_sources:
        if source_chars >= SOURCE_MAX_CHARS:
            break
        summary = _get_source_summary(file_path)
        if summary:
            line = f"─── [WARM:SRC] {file_path} (score: {score:.2f}) ───\n{summary}"
            if source_chars + len(line) < SOURCE_MAX_CHARS:
                source_blocks.append(line)
                source_chars += len(line)
                stats["warm_src"] += 1

    # Add source chars to total
    total_chars += source_chars

    # === HINT FILES ===
    # Path-only suggestions: overflow WARM files + borderline HINT files
    # Very cheap (~5 tokens each) but tells Claude "these files exist"
    # Overflow WARM files are high-scoring but didn't fit in core selection
    hint_blocks = []
    hint_files = []
    already = {p for p, _ in hot_docs + warm_docs + hot_sources + warm_sources}
    # First: overflow WARM files (highest value — scored WARM but not selected)
    for file_path, score in sorted_files:
        if len(hint_files) >= HINT_MAX_FILES:
            break
        if file_path in already:
            continue
        tier = get_tier(score)
        if tier in ("WARM", "HOT"):
            hint_files.append((file_path, score))
            already.add(file_path)
    # Then: traditional HINT tier files
    for file_path, score in sorted_files:
        if len(hint_files) >= HINT_MAX_FILES:
            break
        if file_path in already:
            continue
        tier = get_tier(score)
        if tier == "HINT":
            hint_files.append((file_path, score))

    if hint_files:
        hints_text = "─── [HINT] Related files (mention to load) ───\n"
        for file_path, score in hint_files:
            hints_text += f"  {file_path}\n"
        hint_blocks.append(hints_text)
        total_chars += len(hints_text)
    stats["hint"] = len(hint_files)

    # Combine output
    output_parts = []

    # Status header
    turn_label = f"Turn {state['turn_count']}"
    tier_line = f"Hot: {stats['hot']}  Warm: {stats['warm']}  Cold: {stats['cold']}"
    hint_info = f"  Hint: {stats['hint']}" if stats.get('hint') else ""
    src_line = f"Src: {stats['hot_src']}H/{stats['warm_src']}W{hint_info}" if (stats['hot_src'] + stats['warm_src'] + stats.get('hint', 0)) > 0 else ""
    chars_line = f"Chars: {total_chars:,} / {MAX_TOTAL_CHARS:,}"
    header_w = max(len(turn_label), len(tier_line), len(chars_line), len(src_line)) + 4
    header_lines = [
        f"┌─ {turn_label} {'─' * (header_w - len(turn_label) - 3)}┐",
        f"│ {tier_line:<{header_w - 2}} │",
    ]
    if src_line:
        header_lines.append(f"│ {src_line:<{header_w - 2}} │")
    header_lines.extend([
        f"│ {chars_line:<{header_w - 2}} │",
        f"└{'─' * header_w}┘",
    ])
    header = "\n".join(header_lines)
    output_parts.append(header)

    # Hot doc files first (stable order for cache)
    output_parts.extend(hot_blocks)

    # Repo map for additional hot doc files
    if repo_map_block:
        output_parts.append(repo_map_block)

    # Warm doc files
    output_parts.extend(warm_blocks)

    # Source files (with [HOT:SRC] / [WARM:SRC] labels)
    output_parts.extend(source_blocks)

    # Hint files (path-only, very cheap)
    output_parts.extend(hint_blocks)

    return "\n\n".join(output_parts), stats


# ============================================================================
# ATTENTION HISTORY TRACKING
# ============================================================================

def compute_transitions(prev_state: dict, curr_state: dict) -> dict:
    """Compute what moved between tiers."""
    transitions = {"to_hot": [], "to_warm": [], "to_cold": []}

    for path, score in curr_state["scores"].items():
        prev_score = prev_state.get("scores", {}).get(path, 0.0)
        prev_tier = get_tier(prev_score)
        curr_tier = get_tier(score)

        if curr_tier != prev_tier:
            if curr_tier == "HOT":
                transitions["to_hot"].append(path)
            elif curr_tier == "WARM":
                transitions["to_warm"].append(path)
            else:
                transitions["to_cold"].append(path)

    return transitions


def append_history(state: dict, prev_state: dict, activated: set[str], prompt: str, stats: dict):
    """Append structured entry to history log."""

    # Extract keywords from prompt (simple: first 8 significant words)
    stop_words = {"the", "a", "an", "is", "are", "to", "for", "and", "or", "in", "on", "it", "this", "that", "with", "of"}
    words = [w.lower() for w in prompt.split() if len(w) > 2 and w.lower() not in stop_words][:8]

    entry = {
        "turn": state["turn_count"],
        "timestamp": datetime.now().isoformat(),
        "instance_id": os.environ.get("CLAUDE_INSTANCE", "default"),
        "prompt_keywords": words,
        "activated": sorted(list(activated)),
        "hot": sorted([p for p, s in state["scores"].items() if get_tier(s) == "HOT"]),
        "warm": sorted([p for p, s in state["scores"].items() if get_tier(s) == "WARM"]),
        "cold_count": stats["cold"],
        "transitions": compute_transitions(prev_state, state),
        "total_chars": stats.get("total_chars", 0)
    }

    try:
        # Ensure history file exists
        if not HISTORY_FILE.exists():
            HISTORY_FILE.parent.mkdir(exist_ok=True)
            HISTORY_FILE.touch()

        with open(HISTORY_FILE, "a", encoding="utf-8") as f:
            f.write(json.dumps(entry) + "\n")
    except Exception:
        pass  # Don't fail hook on history write error


# ============================================================================
# MAIN ENTRY POINT
# ============================================================================

def strip_notifications(prompt: str) -> tuple:
    """
    Strip <task-notification> and <system-reminder> XML from prompt.
    Returns (cleaned_prompt, was_notification).
    """
    original_len = len(prompt)
    cleaned = re.sub(r'<task-notification>.*?</task-notification>', '', prompt, flags=re.DOTALL)
    cleaned = re.sub(r'<system-reminder>.*?</system-reminder>', '', cleaned, flags=re.DOTALL)
    cleaned = cleaned.strip()
    was_notification = len(cleaned) < original_len
    return cleaned, was_notification


# Cache for router overrides (avoid disk read every turn)
_OVERRIDES_CACHE: dict = {"params": {}, "mtime": 0.0, "path": None}

def load_telemetry_overrides():
    """Load auto-tuned parameter overrides from telemetry system (per-project aware).

    Uses mtime-based caching to avoid disk reads on every turn.
    """
    global MAX_HOT_FILES, MAX_WARM_FILES, MAX_TOTAL_CHARS, COACTIVATION_BOOST, DECAY_RATES

    overrides_file = Path.home() / ".claude" / "telemetry" / "router_overrides.json"

    # Check cache validity
    try:
        if overrides_file.exists():
            current_mtime = overrides_file.stat().st_mtime
            if (str(overrides_file) == _OVERRIDES_CACHE["path"] and
                current_mtime == _OVERRIDES_CACHE["mtime"]):
                params = _OVERRIDES_CACHE["params"]
            else:
                # Cache miss or stale - reload
                if TELEMETRY_LIB_AVAILABLE:
                    proj = load_project_overrides()
                    params = proj.get("overrides", {})
                else:
                    data = json.loads(overrides_file.read_text(encoding='utf-8'))
                    params = data.get("overrides", {})
                # Update cache
                _OVERRIDES_CACHE["params"] = params
                _OVERRIDES_CACHE["mtime"] = current_mtime
                _OVERRIDES_CACHE["path"] = str(overrides_file)
                # H4: Only log on cache miss (when we actually load new values)
                if params:
                    print(f"[attnroute] Loaded overrides: HOT={params.get('MAX_HOT_FILES', 'default')} WARM={params.get('MAX_WARM_FILES', 'default')} CHARS={params.get('MAX_TOTAL_CHARS', 'default')}", file=sys.stderr)
        else:
            return
    except Exception:
        return

    if "MAX_HOT_FILES" in params:
        MAX_HOT_FILES = int(params["MAX_HOT_FILES"])
    if "MAX_WARM_FILES" in params:
        MAX_WARM_FILES = int(params["MAX_WARM_FILES"])
    if "MAX_TOTAL_CHARS" in params:
        MAX_TOTAL_CHARS = int(params["MAX_TOTAL_CHARS"])
    if "COACTIVATION_BOOST" in params:
        COACTIVATION_BOOST = float(params["COACTIVATION_BOOST"])
    if "DECAY_RATES.default" in params:
        DECAY_RATES["default"] = float(params["DECAY_RATES.default"])


def measure_claude_md() -> int:
    """Measure total CLAUDE.md injection size (global + project-local)."""
    total = 0
    # Global CLAUDE.md
    global_md = Path.home() / ".claude" / "CLAUDE.md"
    if global_md.exists():
        try:
            total += global_md.stat().st_size
        except Exception:
            pass
    # Project-local CLAUDE.md
    project_md = Path.cwd() / "CLAUDE.md"
    if project_md.exists():
        try:
            total += project_md.stat().st_size
        except Exception:
            pass
    # Also check .claude/CLAUDE.md in project
    project_claude_md = Path.cwd() / ".claude" / "CLAUDE.md"
    if project_claude_md.exists() and project_claude_md != project_md:
        try:
            total += project_claude_md.stat().st_size
        except Exception:
            pass
    return total


def measure_pool_size() -> int:
    """Estimate pool injection size from instance_state.jsonl."""
    pool_files = [
        Path.cwd() / ".claude" / "pool" / "instance_state.jsonl",
        Path.home() / ".claude" / "pool" / "instance_state.jsonl",
    ]
    for pool_file in pool_files:
        if pool_file.exists():
            try:
                # Pool loader takes last 20 entries within 1 hour
                lines = pool_file.read_text(encoding="utf-8", errors="replace").strip().split("\n")
                recent = lines[-20:] if len(lines) > 20 else lines
                return sum(len(line) for line in recent)
            except Exception:
                pass
    return 0


def record_turn_telemetry(prompt: str, was_notification: bool, stats: dict,
                          activated: set, state: dict, injection_chars: int):
    """Record turn data to telemetry/turns.jsonl for analysis."""
    try:
        telemetry_dir = Path.home() / ".claude" / "telemetry"
        telemetry_dir.mkdir(parents=True, exist_ok=True)
        turns_file = telemetry_dir / "turns.jsonl"

        hot_files = sorted([p for p, s in state["scores"].items() if s >= HOT_THRESHOLD])
        warm_files = sorted([p for p, s in state["scores"].items() if WARM_THRESHOLD <= s < HOT_THRESHOLD])

        # Measure external context sources
        claude_md_chars = measure_claude_md()
        pool_chars = measure_pool_size()

        # Token estimates (markdown content → ~3.0 chars/token)
        est_fn = estimate_tokens_from_chars if TELEMETRY_LIB_AVAILABLE else lambda c, t="markdown": max(0, int(c / 3.3))
        injection_tokens = est_fn(injection_chars, "markdown")
        claude_md_tokens = est_fn(claude_md_chars, "markdown")
        pool_tokens = est_fn(pool_chars, "mixed")
        total_tokens = injection_tokens + claude_md_tokens + pool_tokens

        record = {
            "turn_id": os.urandom(6).hex(),
            "timestamp": datetime.now().isoformat(),
            "project": get_project() if TELEMETRY_LIB_AVAILABLE else str(Path.cwd()).lower().replace("\\", "/"),
            "session_id": get_session_id() if TELEMETRY_LIB_AVAILABLE else "unknown",
            "prompt_length": len(prompt),
            "was_notification": was_notification,
            "injection_chars": injection_chars,
            "claude_md_chars": claude_md_chars,
            "pool_chars": pool_chars,
            "total_context_chars": injection_chars + claude_md_chars + pool_chars,
            "injection_tokens_est": injection_tokens,
            "claude_md_tokens_est": claude_md_tokens,
            "pool_tokens_est": pool_tokens,
            "total_context_tokens_est": total_tokens,
            "hot_count": stats.get("hot", 0),
            "warm_count": stats.get("warm", 0),
            "files_injected": hot_files[:MAX_HOT_FILES] + warm_files[:MAX_WARM_FILES],
            "files_used": [],  # Populated later by telemetry-record.py Stop hook
            "waste_ratio": -1,  # Populated later
            "tool_calls": 0,  # Populated later
        }
        with open(turns_file, "a", encoding="utf-8") as f:
            f.write(json.dumps(record) + "\n")
    except Exception:
        pass  # Never fail the hook


def main():
    """
    Main entry point for Claude Code hook.
    Reads JSON from stdin, outputs tiered context to stdout.
    """
    global MAX_HOT_FILES, MAX_WARM_FILES, MAX_TOTAL_CHARS  # C1: Allow modification in notification clamping

    # Parse input - use safe_read_stdin to prevent memory exhaustion DoS
    raw_input = safe_read_stdin()
    try:
        input_data = json.loads(raw_input)
        if not isinstance(input_data, dict):
            # Valid JSON but not a dict - treat as prompt
            prompt = raw_input
        else:
            prompt = input_data.get("prompt", "")
    except (json.JSONDecodeError, ValueError):
        # Fallback: treat entire stdin as prompt
        prompt = raw_input

    if not prompt.strip():
        return

    # === TELEMETRY: Strip notifications before keyword matching ===
    prompt, was_notification = strip_notifications(prompt)
    if not prompt.strip():
        # Pure notification with no user text — skip routing entirely
        record_turn_telemetry("", True, {"hot": 0, "warm": 0, "cold": 0}, set(), {"scores": {}, "turn_count": 0}, 0)
        return

    # === TELEMETRY: Load auto-tuned parameter overrides ===
    load_telemetry_overrides()

    # === FIX: Clamp injection for notification-mixed prompts ===
    # Short residual text after notification strip = likely system text, not a real query
    if was_notification and len(prompt) < NOTIFICATION_SHORT_PROMPT_THRESHOLD:
        MAX_HOT_FILES = min(MAX_HOT_FILES, NOTIFICATION_MAX_HOT_FILES)
        MAX_WARM_FILES = min(MAX_WARM_FILES, NOTIFICATION_MAX_WARM_FILES)
        MAX_TOTAL_CHARS = min(MAX_TOTAL_CHARS, NOTIFICATION_MAX_CHARS)

    # Determine docs root with proper priority order
    # Priority 1: Explicit CONTEXT_DOCS_ROOT environment variable
    # Priority 2: Project-local .claude/ (if exists with .md files)
    # Priority 3: Global ~/.claude/
    docs_root = resolve_docs_root()

    # Load state
    state_file = get_state_file()
    prev_state = load_state(state_file)  # Keep copy before mutation
    state = copy.deepcopy(prev_state)  # Deep copy for modification

    # Auto-warmup on first turn of session
    if state.get("turn_count", 0) == 0 and not state.get("warmup_applied"):
        try:
            from attnroute.warmup import build_warmup_state
            warmup = build_warmup_state(str(Path.cwd()), quick=True)
            warmup_scores = warmup.get("scores", {})
            for path, warmup_score in warmup_scores.items():
                current = state.get("scores", {}).get(path, 0)
                if warmup_score > current:
                    state.setdefault("scores", {})[path] = warmup_score
                    state.setdefault("consecutive_turns", {})[path] = 0
            state["warmup_applied"] = True
            print(f"[attnroute] Auto-warmup: {len(warmup_scores)} files pre-warmed", file=sys.stderr)
        except Exception as e:
            print(f"[attnroute] Auto-warmup failed: {e}", file=sys.stderr)

    # Update attention based on prompt
    state, activated = update_attention(state, prompt)

    # === PLUGIN: on_prompt_pre ===
    if PLUGINS_AVAILABLE:
        for plugin in get_plugins():
            try:
                prompt, should_continue = plugin.on_prompt_pre(prompt, state)
                if not should_continue:
                    return
            except Exception as e:
                print(f"[attnroute] Plugin {getattr(plugin, 'name', 'unknown')}.on_prompt_pre failed: {e}", file=sys.stderr)

    # Build output
    output, stats = build_context_output(state, docs_root)
    stats["total_chars"] = len(output)  # Add total chars to stats

    # === PLUGIN: on_prompt_post ===
    if PLUGINS_AVAILABLE:
        for plugin in get_plugins():
            try:
                additional = plugin.on_prompt_post(prompt, output, state)
                if additional:
                    output = output + additional
            except Exception as e:
                print(f"[attnroute] Plugin {getattr(plugin, 'name', 'unknown')}.on_prompt_post failed: {e}", file=sys.stderr)

    # Append to history log (before save, so turn_count is correct)
    append_history(state, prev_state, activated, prompt, stats)

    # Save state for next turn
    save_state(state_file, state)

    # Compact debug log (one-liner per turn, with size rotation)
    log_file = Path.home() / ".claude" / "context_injection.log"
    try:
        # Rotate log when it exceeds max size (using seek for efficiency)
        if log_file.exists() and log_file.stat().st_size > LOG_MAX_SIZE_BYTES:
            with open(log_file, 'rb') as f:
                f.seek(-LOG_KEEP_SIZE_BYTES, 2)  # Seek from end
                content = f.read()
            log_file.write_bytes(content)

        with open(log_file, "a", encoding='utf-8') as f:
            f.write(f"[{datetime.now().isoformat()[:19]}] T{state['turn_count']} "
                    f"H={stats['hot']} W={stats['warm']} C={stats['cold']} "
                    f"chars={len(output)} notif={was_notification} "
                    f"activated={','.join(activated) or 'none'}\n")
    except Exception:
        pass

    # Rotate attention_history.jsonl every 100 turns
    if TELEMETRY_LIB_AVAILABLE and state.get("turn_count", 0) % 100 == 0:
        try:
            rotate_jsonl(HISTORY_FILE, 1000)
        except Exception:
            pass

    # Output to Claude Code
    if stats["hot"] > 0 or stats["warm"] > 0:
        print(output)

    # === TELEMETRY: Record turn data ===
    record_turn_telemetry(prompt, was_notification, stats, activated, state, len(output))
    # Note: Usage tracking (log_injection, track_turn_usage) moved to telemetry_record.py Stop hook


if __name__ == "__main__":
    main()
