"""Hook 事件处理器 — Claude Code 认知集成

4 个事件处理器，每个有不同的延迟预算：
- awaken  (SessionStart):      非阻塞，可做重活（backfill embedding）
- orient  (UserPromptSubmit):  阻塞！只做 BM25 搜索（~10ms）
- observe (PostToolUse):       async 后台，延迟无所谓
- reflect (SessionEnd):        非阻塞，启动 maintenance 子进程

注意：reflect 绑定 SessionEnd（每 session 一次），不是 Stop（每 turn 一次）。
Stop 是 turn-end 质量门禁，SessionEnd 才是会话清理。
SessionEnd 有已知 bug 可能不触发，awaken 中有兜底机制。

安全原则：顶层永远 exit 0，绝不阻塞 Claude Code 主流程。
"""

from __future__ import annotations

import json
import logging
import os
import sys
from pathlib import Path
from typing import NamedTuple

from ..config import INDEX1_HOME, load_config
from ..resilience import CircuitBreaker, FileStateCircuitBreaker
from .db_cog import CogDatabase
from .realtime import RealtimeUpdater
from .signals import IMPORTANCE_THRESHOLD, parse_diff_signals, score_importance


class ObserveResult(NamedTuple):
    """LLM Observer 结果（#242 — _observe_with_llm 返回类型）

    __bool__ 兼容 `if result:` 判断；失败时 error 写入 raw_events 做诊断。
    """

    ok: bool
    provider: str | None = None
    fact_count: int = 0
    error: str | None = None

    def __bool__(self) -> bool:  # type: ignore[override]
        return self.ok

logger = logging.getLogger(__name__)

# 只读/查询工具 — 无副作用，tool_response 通常很大但价值低，只存元数据（#145 Phase B）
# 标准：工具不修改文件系统/代码，只返回查询结果
_METADATA_ONLY_TOOLS = frozenset({"Read", "Grep", "Glob", "WebSearch", "WebFetch"})

# ── Orient 注入常量 (#266) ──
# 分层截断：搜索排名越靠前给越多空间
_ORIENT_FACTS_LIMIT = 10          # facts 最大注入条数
_ORIENT_CODE_LIMIT = 3            # code 最大注入条数
_ORIENT_TACTICS_LIMIT = 3         # tactics 最大注入条数
_ORIENT_TIER1_CHARS = 250         # 第一梯队 (1-3) 字符上限
_ORIENT_TIER2_CHARS = 150         # 第二梯队 (4-6) 字符上限
_ORIENT_TIER3_CHARS = 100         # 第三梯队 (7+) 字符上限
_ORIENT_MAX_CHARS = 2000          # 总输出字符预算（~500 tokens）


def _tiered_truncate(text: str, rank: int) -> str:
    """按排名分层截断文本 (#266)"""
    if rank <= 3:
        limit = _ORIENT_TIER1_CHARS
    elif rank <= 6:
        limit = _ORIENT_TIER2_CHARS
    else:
        limit = _ORIENT_TIER3_CHARS
    if len(text) <= limit:
        return text
    return text[:limit - 1] + "…"


def _format_fact_line(fact: "dict | FactItem", rank: int) -> str:
    """格式化单条 fact 注入行 (#266)

    第一梯队 (1-3) 额外注入 context_json 的 file/tags 关键字段。
    """
    # 兼容 dict (BM25 降级) 和 FactItem 对象 (统一搜索)
    if isinstance(fact, dict):
        assertion = fact.get("assertion", "")
        conf = fact.get("confidence", 0) or 0
        ctx_raw = fact.get("context_json", "")
    else:
        assertion = getattr(fact, "assertion", "")
        conf = getattr(fact, "confidence", 0) or 0
        ctx_raw = getattr(fact, "context_json", "")

    text = _tiered_truncate(assertion, rank)
    line = f"  - {text} ({conf:.0%})"

    # 第一梯队补充 context_json 关键字段
    if rank <= 3 and ctx_raw:
        try:
            ctx = json.loads(ctx_raw) if isinstance(ctx_raw, str) else ctx_raw
            if isinstance(ctx, dict):
                extras = []
                if ctx.get("file"):
                    extras.append(f"file:{ctx['file']}")
                if ctx.get("tags"):
                    tags = ctx["tags"] if isinstance(ctx["tags"], str) else ",".join(ctx["tags"])
                    extras.append(f"tags:{tags}")
                if extras:
                    line += f" [{', '.join(extras)}]"
        except (json.JSONDecodeError, TypeError) as e:
            logger.debug("context_json parse failed (rank=%d): %s", rank, e)
    return line


class HookHandler:
    """Hook 事件处理器"""

    def __init__(self, db_cog: CogDatabase, config=None, db=None):
        self.db_cog = db_cog
        self.config = config
        self.db = db  # corpus Database（可选，orient 统一搜索用）
        self._observe_breaker = CircuitBreaker(
            marker_path=INDEX1_HOME / ".observe_circuit",
            max_failures=5,
            reset_after=300.0,
        )
        # LLM Observer 熔断器（独立于 DB 写熔断，防止 LLM 超时卡死 hook 进程）
        # #237 Phase 2: FileStateCircuitBreaker 替代 CircuitBreaker
        # - 文件态持久化（跨 hook 进程不丢失状态，解决 RC3 根因）
        # - 指数退避（3次→300s, 4次→600s, 5次→1200s, 上限 3600s）
        # - half-open 状态（冷却期过后单次探测恢复）
        # - fcntl.flock 并发安全（多 hook 进程同时访问不丢失计数）
        self._llm_breaker = FileStateCircuitBreaker(
            name="llm_observer",
            state_dir=INDEX1_HOME / "circuit_breakers",
            threshold=3,
            cooldown_seconds=300.0,
            max_cooldown_seconds=3600.0,
        )
        # 实时 L0 维护（observe hook 中执行）
        self._realtime: RealtimeUpdater | None = None
        if db_cog and config:
            try:
                self._realtime = RealtimeUpdater(db_cog, config)
            except Exception as e:
                logger.debug("RealtimeUpdater 初始化失败: %s", e)
        # orient 混合搜索复用资源（hook 进程是纯同步，需自建 event loop）
        self._orient_embedder = None
        self._orient_loop = None
        if self._can_orient_hybrid():
            import asyncio
            self._orient_loop = asyncio.new_event_loop()

    def _is_observer_available(self) -> bool:
        """#248: 磁盘态 Observer 可用性检测（替代内存标记）

        检测顺序（短路求值，总延迟 <2ms）：
        1. CB 熔断 → 不可用
        2. Worker 端口存活（socket connect_ex）→ 可用
        3. 任意 API key 存在 → 可用
        4. 三者都不满足 → 不可用
        """
        # 1. CB 熔断 — FileStateCircuitBreaker 读磁盘文件，<1ms
        if self._llm_breaker.is_open():
            return False
        # 2. Worker 端口 — socket connect_ex 非阻塞，<1ms
        import socket
        port = getattr(self.config, "observer_worker_port", 6889)
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            try:
                s.settimeout(0.1)
                result = s.connect_ex(("127.0.0.1", port))
                if result == 0:
                    return True
            finally:
                s.close()
        except OSError:
            pass
        # 3. 任意 API key — 与 _try_observe_l2 降级链匹配
        _api_key_envs = (
            "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY",
            "OPENCLAW_API_KEY",
        )
        if any(os.environ.get(k) for k in _api_key_envs):
            return True
        return False

    def handle(self, event: str, data: dict) -> dict:
        """分发事件到对应处理器"""
        handlers = {
            "SessionStart": self.awaken,
            "UserPromptSubmit": self.orient,
            "PostToolUse": self.observe,
            "SessionEnd": self.reflect,
        }
        handler = handlers.get(event)
        if not handler:
            return {}
        return handler(data)

    def awaken(self, data: dict) -> dict:
        """SessionStart — 非阻塞，生成苏醒上下文

        1. 兜底检查：上一个 session 的 maintenance 是否执行过
        2. 读 brief + top facts → 注入 <150 tokens
        """
        collection = _detect_collection(data)

        # Web UI 自动启动（非阻塞）
        if self.config and getattr(self.config, 'web_auto_start', False):
            try:
                from ..web import ensure_web_running
                ensure_web_running(self.config)
            except Exception as e:
                logger.debug("Web UI 自动启动失败: %s", e)

        # 兜底：SessionEnd 可能不触发（已知 Claude Code bug），
        # 检查上次 session 是否有 pending maintenance 标记
        self._fallback_maintenance(collection, data.get("session_id"))

        # Worldview 文件变更检测（<5ms，纯文件系统操作）
        # 独立 marker 文件，不干扰 maintenance pending marker
        try:
            from .rules_extractor import check_worldview_pending
            if check_worldview_pending(self.config, self.db_cog):
                wv_marker = INDEX1_HOME / ".worldview_pending"
                wv_marker.write_text(collection)
                logger.debug("worldview 文件有变更，写 .worldview_pending")
        except Exception:
            pass  # 宪法原则 4: Hook 崩溃静默 exit 0

        # Web UI 后台自动启动（<50ms，非阻塞）
        try:
            if self.config.web_auto_start:
                from ..web import ensure_web_running
                ensure_web_running(self.config)
        except Exception:
            pass  # 宪法原则 4: Hook 崩溃静默

        # #247: Observer Worker 后台自动启动（<50ms，非阻塞）
        try:
            if getattr(self.config, "observer_worker_enabled", True):
                from .observer_worker import ensure_observer_running
                ensure_observer_running(self.config)
        except Exception as e:
            logger.debug("Observer worker 启动跳过: %s", e)

        # #255: Corpus 过期检测（<5ms，纯 DB 查询）
        try:
            db_path = getattr(self.db, "_db_path", None) if self.db else None
            if db_path and isinstance(db_path, str):
                from ..watcher import check_corpus_stale
                stale_info = check_corpus_stale(
                    db_path,
                    getattr(self.config, "watcher_stale_threshold", 3600),
                )
                if stale_info.get("stale"):
                    stale_marker = INDEX1_HOME / ".corpus_stale"
                    stale_marker.write_text(json.dumps(stale_info))
                else:
                    # 索引新鲜 → 清除 marker
                    stale_marker = INDEX1_HOME / ".corpus_stale"
                    if stale_marker.exists():
                        stale_marker.unlink(missing_ok=True)
        except Exception as e:
            logger.debug("Corpus 过期检测跳过: %s", e)

        # #255: Watcher 自动启动（<100ms，非阻塞）
        try:
            if getattr(self.config, "watcher_auto_start", True):
                # watch_paths 为空时，从项目目录自动推断
                watch_paths = list(self.config.watch_paths)  # 拷贝，不修改原 config
                if not watch_paths:
                    project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd")
                    if project_dir and Path(project_dir).is_dir():
                        watch_paths = [project_dir]
                if watch_paths:
                    from ..watcher import ensure_watcher_running
                    if ensure_watcher_running(self.config, watch_paths=watch_paths):
                        stale_marker = INDEX1_HOME / ".corpus_stale"
                        if stale_marker.exists():
                            stale_marker.unlink(missing_ok=True)
        except Exception as e:
            logger.debug("Watcher 自动启动跳过: %s", e)

        session = self.db_cog.get_or_create_session(
            external_id=data.get("session_id"),
            collection=collection,
        )

        lines = [f"[index1] Project: {collection} | Session #{session['session_count']}"]

        # 上次 session 摘要（AI-native 结构化标签，#260）
        # 数据源优先级：session_record fact > 磁盘兜底文件
        _prev_record_found = False
        try:
            prev_record = self.db_cog.get_previous_session_record(
                collection, exclude_session_id=data.get("session_id"),
            )
            if prev_record:
                _prev_record_found = True
                sr = prev_record.get("session_record", prev_record)
                parts: list[str] = []
                # L1: 每个字段 isinstance 校验（防 JSON 解析出非预期类型）
                files = sr.get("files_changed", [])
                if isinstance(files, list) and files:
                    parts.append("files:" + ",".join(
                        Path(f).name for f in files[:5] if isinstance(f, str)
                    ))
                changes = sr.get("key_changes", [])
                if isinstance(changes, list) and changes:
                    change_strs: list[str] = []
                    for kc in changes[:3]:
                        if isinstance(kc, dict):
                            action = kc.get("action", "")
                            idents = kc.get("identifiers", [])
                            if isinstance(idents, list) and idents:
                                change_strs.append(
                                    f"{action}:{','.join(str(i) for i in idents[:2])}"
                                )
                    if change_strs:
                        parts.append("changes:" + ";".join(change_strs))
                patterns = sr.get("patterns", [])
                if isinstance(patterns, list) and patterns:
                    parts.append("patterns:" + ",".join(
                        str(p) for p in patterns[:3]
                    ))
                outcome = sr.get("outcome", "")
                if isinstance(outcome, str) and outcome:
                    parts.append(f"outcome:{outcome[:40]}")
                incomplete = sr.get("incomplete_work", [])
                if isinstance(incomplete, list) and incomplete:
                    parts.append("incomplete:" + ";".join(
                        str(i)[:40] for i in incomplete[:2]
                    ))
                if parts:
                    lines.append(
                        "[prev_session] " + " ".join(parts) + " [/prev_session]"
                    )
        except Exception as e:
            logger.debug("session_record 注入失败: %s", e)

        # 磁盘兜底 — session_record 不存在时从 .last_session_context 恢复
        if not _prev_record_found:
            try:
                context_file = INDEX1_HOME / ".last_session_context"
                if context_file.exists():
                    # L1: 文件大小校验（防空文件/超大文件）
                    file_size = context_file.stat().st_size
                    if 2 <= file_size <= 10240:
                        raw = context_file.read_text(encoding="utf-8")
                        disk_ctx = json.loads(raw)
                        # L1: 字段类型 + collection 匹配
                        if (isinstance(disk_ctx, dict)
                                and disk_ctx.get("collection") == collection):
                            dparts: list[str] = []
                            dfiles = disk_ctx.get("active_files", [])
                            if isinstance(dfiles, list) and dfiles:
                                dparts.append("files:" + ",".join(
                                    Path(f).name
                                    for f in dfiles[:5] if isinstance(f, str)
                                ))
                            derrors = disk_ctx.get("recent_errors", [])
                            if isinstance(derrors, list) and derrors:
                                dparts.append(
                                    f"last_error:{str(derrors[0])[:60]}"
                                )
                            if dparts:
                                lines.append(
                                    "[disk_recovery] "
                                    + " ".join(dparts)
                                    + " [/disk_recovery]"
                                )
                            logger.debug(
                                "awaken 从磁盘恢复上下文: %d bytes", file_size,
                            )
            except Exception as e:
                logger.debug("磁盘兜底读取失败: %s", e)

        # CLAUDE.md 兜底（#260 P0 — 终极兜底）
        # session_record 和 .last_session_context 都不存在时，
        # 从 .claude/CLAUDE.md 的 <index1-context> 标签读取
        if not _prev_record_found:
            try:
                project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd")
                if project_dir and isinstance(project_dir, str):
                    claude_md = Path(project_dir) / ".claude" / "CLAUDE.md"
                    if claude_md.exists():
                        from .context_writer import read_tagged_content
                        ctx_text = read_tagged_content(claude_md, "index1-context")
                        if ctx_text:
                            lines.append(
                                "[claude_md_recovery] "
                                + ctx_text.replace("\n", " | ")[:200]
                                + " [/claude_md_recovery]"
                            )
                            logger.debug("awaken 从 CLAUDE.md 恢复上下文")
            except Exception as e:
                logger.debug("CLAUDE.md 兜底读取失败: %s", e)

        # Top facts（含跨项目通用知识）
        top_facts = self.db_cog.get_top_facts(
            collection=collection, min_confidence=0.7, limit=5,
            include_universal=True,
        )
        if top_facts:
            lines.append("Key facts:")
            for f in top_facts[:3]:
                prefix = "[U] " if f.get("scope") == "universal" else ""
                conf = f" ({f['confidence']:.0%})" if f['confidence'] < 1.0 else ""
                lines.append(f"  - {prefix}{f['assertion'][:80]}{conf}")

        # 决策原则（constraint/preference — L3b 层，永不衰减）
        # 动态限制：与 top_facts 共享 ~150 token 预算，最多展示 3 条
        wv_limit = min(3, max(1, 5 - len(top_facts[:3])))
        worldview = self.db_cog.get_worldview_facts(collection, limit=wv_limit)
        if worldview:
            lines.append("Principles:")
            for wv in worldview:
                label = "[inferred]" if wv.get("source") == "hook" else "[explicit]"
                lines.append(f"  - {label} {wv['assertion'][:80]}")

        # Task context — 上次任务 + 待验证操作
        ctx = self.db_cog.get_session_context(
            external_id=data.get("session_id", ""),
        )
        if ctx:
            task = ctx.get("current_task", "")
            if task:
                lines.append(f"Last task: {task}")
            pending = ctx.get("pending_actions", {})
            if pending:
                lines.append("Pending:")
                for fp, status in list(pending.items())[:3]:
                    lines.append(f"  - {Path(fp).name}: {status}")

        # 异常中断检测 (#223) — session_record 不存在时触发
        # 复用 _prev_record_found 标记，避免重复 DB 查询
        try:
            if not _prev_record_found:
                prev_sid = self.db_cog.get_last_session_id(
                    collection, exclude_current=data.get("session_id"),
                )
                if prev_sid:
                    from ..session_context import SessionContext
                    checkpoint = SessionContext.aggregate_checkpoint(
                        self.db_cog, prev_sid,
                    )
                    if checkpoint.get("is_warm"):
                        lines.append("[interrupted] Previous session ended abnormally")
                        files = ",".join(
                            Path(f).name for f in checkpoint["active_files"][:5]
                        )
                        if files:
                            lines.append(f"  files:{files}")
                        if checkpoint["recent_errors"]:
                            lines.append(
                                f"  last_error:{checkpoint['recent_errors'][0][:80]}"
                            )
                        tools = "\u2192".join(checkpoint["tool_sequence"][:10])
                        if tools:
                            lines.append(f"  sequence:{tools}")
        except Exception as e:
            logger.debug("checkpoint detection failed: %s", e)

        # Observer 健康状态 (#237 Phase 3) — 非 healthy 时注入告警标签
        try:
            from .observer_health import check_observer_health, format_health_tag
            health = check_observer_health(self.db_cog)
            tag = format_health_tag(health)
            if tag:
                lines.append(tag)
        except Exception:
            pass  # 健康检查本身不能阻塞 awaken

        return {"output": "\n".join(lines)}

    def orient(self, data: dict) -> dict:
        """UserPromptSubmit — 阻塞！必须极快

        ONNX 后端可用时做 hybrid 搜索（BM25 + Vec, ~15ms），
        否则纯 BM25 搜索（~5ms）。
        提取 current_task 并更新 session context。
        """
        from .capture import extract_task_from_prompt

        user_message = data.get("prompt", "")
        collection = _detect_collection(data)
        session_id = data.get("session_id")
        if not user_message:
            return {}

        # 提取 current_task（L0 规则，不依赖 LLM）
        task = extract_task_from_prompt(user_message)
        if task and session_id:
            try:
                self.db_cog.update_session_context(
                    external_id=session_id,
                    current_task=task,
                )
            except Exception as e:
                logger.debug("update current_task 失败: %s", e)

        # 记录用户 prompt 到 raw_events（Web UI Events tab 可见）
        if session_id:
            try:
                self.db_cog.insert_raw_event(
                    session_id=session_id,
                    hook_event="UserPromptSubmit",
                    collection=collection,
                    payload={"prompt": user_message[:4096], "task": task or ""},
                    tool_name=None,
                    importance=0.5,
                    signals_json="{}",
                )
            except Exception as e:
                logger.debug("orient insert_raw_event 失败: %s", e)

        # L0 prompt 分析 — 从 user prompt 提取 constraint/preference/decision
        try:
            from .prompt_analyzer import extract_from_prompt
            from .dedup import check_duplicate
            prompt_facts = extract_from_prompt(user_message, collection, session_id)
            for pf in prompt_facts:
                try:
                    dup = check_duplicate(
                        assertion=pf["assertion"],
                        collection=pf.get("collection", ""),
                        session_id=pf.get("session_id"),
                        db_cog=self.db_cog,
                    )
                    if not dup.is_dup:
                        self.db_cog.insert_fact(pf)
                except Exception as e:
                    logger.debug("prompt fact dedup/写入失败: %s", e)
        except Exception as e:
            logger.debug("prompt_analyzer 失败: %s", e)

        # #248: Observer 状态通知（磁盘态检测，替代内存标记）
        _observer_warn = None
        if not self._is_observer_available():
            _observer_warn = (
                "[index1] Observer unavailable — "
                "run `index1 observer-worker` or set API key"
            )
            # P0-2: stderr 主动通知（用户在 Claude Code 终端可见）
            print(
                "[index1] \u26a0 Observer unavailable: "
                "no worker on port, no API key, or circuit breaker open. "
                "Run `index1 observer-worker` to start.",
                file=sys.stderr,
            )

        # #255: Corpus 过期告警（读 marker 文件，<1ms）
        _corpus_warn = None
        try:
            stale_marker = INDEX1_HOME / ".corpus_stale"
            if stale_marker.exists():
                _corpus_warn = (
                    "[index1] Corpus stale — "
                    "run `index1 index ./src` or set watch_paths in config"
                )
                print(
                    "[index1] \u26a0 Corpus index is stale. "
                    "Run `index1 index ./src` or configure watch_paths.",
                    file=sys.stderr,
                )
        except Exception as e:
            logger.debug("Corpus stale marker 读取失败: %s", e)

        # 统一搜索 — corpus + cognition 并行
        use_unified = self._can_orient_hybrid() and self.db is not None
        if use_unified:
            unified = self._orient_unified_search(user_message, collection)
        else:
            unified = None

        # 降级：统一搜索不可用时，纯 BM25 搜 cognition
        if unified is None:
            results = self.db_cog.fts_search_facts(
                query=user_message, collection=collection, limit=10,
                include_universal=False,
            )
            results = [r for r in results if r.get("confidence", 0) >= 0.5]
            if not results:
                _warns = [w for w in (_observer_warn, _corpus_warn) if w]
                if _warns:
                    return {"output": "\n".join(_warns)}
                return {}

            # Touch accessed facts
            for r in results[:_ORIENT_FACTS_LIMIT]:
                try:
                    self.db_cog.touch_fact(r["id"])
                except Exception as e:
                    logger.debug("touch_fact(%d) 失败: %s", r["id"], e)

            lines = ["[index1] Relevant knowledge:"]
            if _observer_warn:
                lines.insert(0, _observer_warn)
            if _corpus_warn:
                lines.insert(0, _corpus_warn)
            for i, f in enumerate(results[:_ORIENT_FACTS_LIMIT], 1):
                lines.append(_format_fact_line(f, i))
            # L2: token 预算硬限
            output = "\n".join(lines)
            if len(output) > _ORIENT_MAX_CHARS:
                output = output[:_ORIENT_MAX_CHARS] + "\n  …(truncated)"
            return {"output": output}

        # 统一搜索成功 — 格式化分组输出
        lines = [f"[index1] {unified.hint}"]
        if _observer_warn:
            lines.insert(0, _observer_warn)
        if _corpus_warn:
            lines.insert(0, _corpus_warn)

        # Touch accessed facts
        for f in unified.facts[:_ORIENT_FACTS_LIMIT]:
            try:
                self.db_cog.touch_fact(f.id)
            except Exception as e:
                logger.debug("touch_fact(%d) 失败: %s", f.id, e)

        if unified.facts:
            lines.append("Facts:")
            for i, f in enumerate(unified.facts[:_ORIENT_FACTS_LIMIT], 1):
                lines.append(_format_fact_line(f, i))

        if unified.code:
            lines.append("Code:")
            for i, c in enumerate(unified.code[:_ORIENT_CODE_LIMIT], 1):
                label = c.section or c.source or "?"
                content = _tiered_truncate(c.content, i)
                lines.append(f"  - [{label}] {content}")

        if unified.tactics:
            lines.append("Tactics:")
            for i, t in enumerate(unified.tactics[:_ORIENT_TACTICS_LIMIT], 1):
                pattern = _tiered_truncate(t.trigger_pattern, i)
                lines.append(f"  - {pattern} ({t.success_rate:.0%})")

        if unified.model:
            level = unified.model.get("understanding_level", 0)
            scope = unified.model.get("scope", "?")
            lines.append(f"Model: {scope} ({int(level * 100)}% understood)")

        _warn_count = sum(1 for w in (_observer_warn, _corpus_warn) if w)
        if len(lines) <= 1 + _warn_count:
            _warns = [w for w in (_observer_warn, _corpus_warn) if w]
            if _warns:
                return {"output": "\n".join(_warns)}
            return {}

        # L2: token 预算硬限
        output = "\n".join(lines)
        if len(output) > _ORIENT_MAX_CHARS:
            output = output[:_ORIENT_MAX_CHARS] + "\n  …(truncated)"
        return {"output": output}

    def observe(self, data: dict) -> dict:
        """PostToolUse — async 后台，纯写入（无跨调用状态）

        #242 简化路由（不自动降级，通知用户）：
        - LLM observer 可用 → L2（失败记录到 CB，orient 通知用户）
        - LLM observer 不可用 / CB open / 低 importance → L0 规则提取
        - L2 失败不 fallback L0，靠 CB 累积 → orient 告知用户

        Phase 1 (#124): 先 INSERT raw_event（~2ms），再路由 L0/L2。
        CircuitBreaker 保护：连续 5 次写失败后熔断，事实写入 buffer 文件。
        写 pending marker：标记此 session 有活动，需要 maintenance。

        注意: Hook 每次调用创建新进程新实例，不能依赖内存状态。
        所有跨调用逻辑（模式检测/摘要）移到 reflect() 中从 DB 查询。
        """
        from .capture import extract

        tool_name = data.get("tool_name", "")
        tool_input = data.get("tool_input", {})
        tool_output = str(data.get("tool_response", ""))
        collection = _detect_collection(data)
        session_id = data.get("session_id")
        ti = tool_input if isinstance(tool_input, dict) else {}

        # ── L0 信号提取 + 重要性评分 (#145 Phase B) ──
        signals = parse_diff_signals(tool_name, ti)
        importance = score_importance(tool_name, ti, tool_output, signals)

        # ── Event Buffer (#124) — 先持久化原始事件，再处理 ──
        # CB 打开时跳过（DB 不可写，避免无用写尝试）
        event_id: int | None = None
        if session_id and not self._observe_breaker.is_open():
            try:
                # 探索类工具只存元数据，不存完整 tool_response（节省空间）
                if tool_name in _METADATA_ONLY_TOOLS:
                    event_payload = {
                        "tool_name": tool_name,
                        "tool_input": ti,
                        "tool_response": "",
                        "_stripped": True,
                        "_size": len(tool_output),
                    }
                else:
                    event_payload = {
                        "tool_name": tool_name,
                        "tool_input": ti,
                        "tool_response": tool_output,
                    }
                signals_dict = signals.to_dict()
                event_id = self.db_cog.insert_raw_event(
                    session_id=session_id,
                    hook_event="PostToolUse",
                    collection=collection,
                    payload=event_payload,
                    tool_name=tool_name or None,
                    importance=importance,
                    signals_json=json.dumps(signals_dict) if signals_dict else "{}",
                )
            except Exception as e:
                logger.debug("insert_raw_event 失败（不影响主流程）: %s", e)

        # 写 pending marker（幂等，每次覆盖）
        self._write_pending_marker(collection, session_id)

        # 熔断检查 — CB 打开时写 buffer 文件而非 DB
        # #264: 低 importance 事件即使熔断也不产生 facts
        if self._observe_breaker.is_open():
            if importance >= IMPORTANCE_THRESHOLD:
                captured = extract(
                    tool_name=tool_name,
                    tool_input=ti,
                    tool_output=tool_output,
                    collection=collection,
                    session_id=session_id,
                )
                buffered = 0
                for fact in captured:
                    if len(fact.get("assertion", "")) >= 10:
                        _buffer_fact(fact)
                        buffered += 1
                logger.debug(
                    "observe 熔断中，%d/%d facts 写入 buffer",
                    buffered, len(captured),
                )
            return {}

        # ── #242: L2 或 L0，不自动降级 ──
        observer_enabled = getattr(self.config, "observer_enabled", True)
        use_l2 = (
            importance >= IMPORTANCE_THRESHOLD
            and observer_enabled
            and not self._llm_breaker.is_open()
        )
        if use_l2:
            # #247: 三级降级 Worker → API key 直调 → 通知用户
            observed = self._try_observe_l2(
                tool_name, tool_input, tool_output, collection, session_id,
            )
            if observed is None:
                # P0-2: Worker + API key 都不可用，stderr 通知用户
                print(
                    "[index1] \u26a0 Observer: no worker, no API key — "
                    "this tool call won't be observed",
                    file=sys.stderr,
                )
        elif importance >= IMPORTANCE_THRESHOLD:
            # L0 降级: L2 不可用但事件重要 → 规则提取兜底
            # (#264: importance < 阈值的事件只记 raw_events，不产生 facts)
            self._write_l0_facts(
                tool_name, ti, tool_output, collection, session_id, importance,
            )
        # else: importance < IMPORTANCE_THRESHOLD → 只记 raw_events，不产生 facts

        # mark processed
        if event_id:
            try:
                self.db_cog.mark_event_processed(event_id)
            except Exception as e:
                logger.debug("mark_event_processed 失败（不影响主流程）: %s", e)

        # 周期性 L0 维护（时间间隔控制，5 分钟内不重复）
        if self._realtime:
            try:
                self._realtime.periodic_l0(collection, session_id)
            except Exception as e:
                logger.debug("realtime periodic_l0 失败: %s", e)

        return {}  # async hook 的返回值被忽略

    def _write_l0_facts(
        self,
        tool_name: str,
        tool_input: dict,
        tool_output: str,
        collection: str,
        session_id: str | None,
        importance: float = 0.0,
    ) -> None:
        """L0 规则提取 + 去重 + 写入 facts（#242 — 从 observe 提取的公共方法）

        capture.extract() → scope_modules 填充 → 去重 → insert_fact → realtime
        piggyback 恢复和 observe() L0 路径共用此方法。
        """
        from .capture import extract

        captured = extract(
            tool_name=tool_name,
            tool_input=tool_input,
            tool_output=tool_output,
            collection=collection,
            session_id=session_id,
        )

        # Auto-fill scope_modules from scope_files（纯计算，无状态依赖）
        for fact in captured:
            try:
                scope_files = json.loads(fact.get("scope_files", "[]"))
                if scope_files and fact.get("scope_modules", "[]") == "[]":
                    modules = list({
                        Path(f).parent.name
                        for f in scope_files
                        if Path(f).parent.name and Path(f).parent.name != "."
                    })
                    if modules:
                        fact["scope_modules"] = json.dumps(modules[:5])
            except (json.JSONDecodeError, TypeError):
                pass

        for fact in captured:
            assertion = fact.get("assertion", "")
            if len(assertion) < 10:
                continue

            # 去重：精确匹配 + Jaccard 相似度（跳过向量层以保持快速）
            try:
                from .dedup import check_duplicate

                dedup_result = check_duplicate(
                    assertion=assertion,
                    collection=collection,
                    session_id=session_id,
                    db_cog=self.db_cog,
                    embedder=None,  # observe hook 不做向量去重
                )
                if dedup_result.is_dup:
                    logger.debug(
                        "observe 去重: %s (method=%s, sim=%.2f)",
                        assertion[:60], dedup_result.method, dedup_result.similarity,
                    )
                    continue
                result = self.db_cog.insert_fact(fact)
                if result is None:
                    self._observe_breaker.record_failure()
                else:
                    self._observe_breaker.record_success()
                    fact["id"] = result  # P3-6: 传递 fact_id 给 realtime
                    # 实时 L0 维护 — fact 写入后立即执行
                    if self._realtime:
                        try:
                            self._realtime.on_fact_saved(
                                fact, collection, session_id,
                                importance=importance,
                            )
                        except Exception as e:
                            logger.debug("realtime on_fact_saved 失败: %s", e)
            except Exception as e:
                logger.debug("observe insert_fact 失败: %s", e)
                self._observe_breaker.record_failure()

    def reflect(self, data: dict) -> dict:
        """SessionEnd — 从 DB 查本 session 事实 + 启动 maintenance

        Hook 每次创建新实例，无法依赖内存状态。
        所有跨调用分析（摘要/模式检测）从 DB 查 session facts 实现。
        """
        collection = _detect_collection(data)
        session_id = data.get("session_id")

        # 从 DB 查本 session 的事实
        session_facts: list[dict] = []
        if session_id:
            try:
                session_facts = self.db_cog.get_session_facts(session_id, collection)
            except Exception as e:
                logger.debug("reflect get_session_facts 失败: %s", e)

        # 持久化 session 事实摘要（从 DB 事实构建，非内存事件）
        self._persist_fact_summary(session_facts, collection, session_id)

        # 磁盘兜底：写 .last_session_context（#260）
        # awaken 的 session_record 依赖 maintenance 完成，
        # 如果 maintenance 被杀或未执行，awaken 从磁盘文件恢复
        self._persist_session_context_to_disk(session_id, collection)

        # CLAUDE.md 持久化兜底（#260 P0）
        # 终极兜底：即使所有 hook 失败，CLAUDE.md 仍在磁盘被 Claude Code 自动读取
        self._persist_to_claude_md(session_facts, session_id, collection, data)

        # 清除 pending 标记（maintenance 即将启动）
        marker = INDEX1_HOME / f".pending_maintenance_{collection}"
        try:
            marker.unlink(missing_ok=True)
            logger.debug("已清除 pending marker: %s", collection)
        except OSError as e:
            logger.warning("清除 marker 失败: %s", e)

        # 清除 realtime + periodic markers（maintenance 会重新执行全量 L0）
        try:
            RealtimeUpdater.clear_marker(collection)
        except Exception as e:
            logger.debug("清除 realtime marker 失败: %s", e)
        try:
            RealtimeUpdater.clear_periodic_marker(collection)
        except Exception as e:
            logger.debug("清除 periodic marker 失败: %s", e)

        # 异常速率监控 — 检测单 session 异常 fact 产出
        self._check_fact_anomaly(session_id, collection)

        # 启动 maintenance 子进程（observer 在子进程内先行执行）
        self._launch_maintenance(collection, session_id)
        return {}

    def _persist_session_context_to_disk(
        self, session_id: str | None, collection: str,
    ) -> None:
        """写 .last_session_context 磁盘兜底文件（#260）

        从 raw_events 聚合当前 session 的关键状态，JSON 写入磁盘。
        awaken 在 session_record 不存在时读取此文件恢复上下文。

        三层防御:
        - L1: session_id/collection 类型校验，INDEX1_HOME 存在性
        - L2: atomic rename (tmp → target)，写入后字节数校验
        - L3: logger.debug 记录写入/失败
        """
        # L1: 参数校验
        if not session_id or not isinstance(session_id, str):
            return
        if not isinstance(collection, str) or not collection:
            return
        try:
            INDEX1_HOME.mkdir(parents=True, exist_ok=True)
        except OSError:
            return

        try:
            from ..session_context import SessionContext
            checkpoint = SessionContext.aggregate_checkpoint(
                self.db_cog, session_id,
            )
            if not checkpoint.get("is_warm"):
                return  # 无实质活动，不写兜底

            disk_data = {
                "collection": collection,
                "session_id": session_id,
                "active_files": checkpoint.get("active_files", [])[:5],
                "recent_errors": checkpoint.get("recent_errors", [])[:3],
                "event_count": checkpoint.get("event_count", 0),
            }

            target = INDEX1_HOME / ".last_session_context"
            tmp = target.with_suffix(".tmp")

            raw = json.dumps(disk_data, ensure_ascii=False)
            tmp.write_text(raw, encoding="utf-8")

            # L2: 写入后字节数校验
            written = tmp.stat().st_size
            if written < 2:
                tmp.unlink(missing_ok=True)
                logger.debug("磁盘兜底写入异常: 文件太小 %d bytes", written)
                return

            # atomic rename
            tmp.rename(target)
            logger.debug("磁盘兜底已写入: %d bytes, collection=%s", written, collection)
        except Exception as e:
            logger.debug("磁盘兜底写入失败: %s", e)
            # 清理 tmp
            try:
                (INDEX1_HOME / ".last_session_context.tmp").unlink(missing_ok=True)
            except OSError:
                pass

    def _persist_to_claude_md(
        self,
        session_facts: list[dict],
        session_id: str | None,
        collection: str,
        data: dict,
    ) -> None:
        """写入 <index1-context> 到项目 .claude/CLAUDE.md（#260 P0）

        终极磁盘兜底：即使所有 hook 失败（进程崩溃、DB 被锁、超时），
        CLAUDE.md 仍在磁盘，Claude Code 启动时自动读取。

        三层防御:
        - L1: 项目路径/session_id 类型校验，facts 非空检查
        - L2: 委托 context_writer 原子写入(tmp+rename)
        - L3: logger.debug 记录写入/跳过/失败
        """
        # L1: 参数校验
        if not session_id or not isinstance(session_id, str):
            return
        if not isinstance(collection, str) or not collection:
            return
        if not session_facts:
            logger.debug("CLAUDE.md 跳过: 无 session facts")
            return

        # L1: 确定项目 .claude/CLAUDE.md 路径
        project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd")
        if not project_dir or not isinstance(project_dir, str):
            return
        claude_md = Path(project_dir) / ".claude" / "CLAUDE.md"
        if not claude_md.parent.exists():
            return  # 没有 .claude 目录的项目跳过

        # 构建 AI-native 摘要（结构化标签，不写散文）
        lines: list[str] = []
        lines.append(f"# Last Session ({collection})")
        lines.append("")

        # 文件变更汇总
        files_touched: set[str] = set()
        categories: dict[str, int] = {}
        errors: list[str] = []
        for fact in session_facts:
            cat = fact.get("category", "")
            if cat:
                categories[cat] = categories.get(cat, 0) + 1
            try:
                scope_files = json.loads(fact.get("scope_files", "[]"))
                if isinstance(scope_files, list):
                    files_touched.update(
                        str(f) for f in scope_files if isinstance(f, str)
                    )
            except (json.JSONDecodeError, TypeError):
                pass
            if cat in ("root_cause", "error"):
                assertion = fact.get("assertion", "")
                if assertion:
                    errors.append(assertion[:100])

        if files_touched:
            short_files = [Path(f).name for f in sorted(files_touched)[:8]]
            lines.append(f"files: {', '.join(short_files)}")
        if categories:
            cat_parts = [f"{k}:{v}" for k, v in sorted(
                categories.items(), key=lambda x: -x[1],
            )[:5]]
            lines.append(f"categories: {' '.join(cat_parts)}")
        if errors:
            lines.append(f"errors: {errors[0]}")

        lines.append(f"facts: {len(session_facts)}")
        lines.append("")
        lines.append(
            "Use `recall` MCP tool to search full context. "
            "This summary is a disk-level fallback."
        )

        content = "\n".join(lines)

        # L2: 委托 context_writer 原子写入
        try:
            from .context_writer import write_tagged_content
            ok = write_tagged_content(claude_md, "index1-context", content)
            if ok:
                logger.debug(
                    "CLAUDE.md 已更新: %s (%d facts, %d files)",
                    claude_md, len(session_facts), len(files_touched),
                )
            else:
                logger.debug("CLAUDE.md 写入返回 False")
        except Exception as e:
            # L3: 写入失败不阻塞 reflect
            logger.debug("CLAUDE.md 写入异常: %s", e)

    def _check_fact_anomaly(
        self, session_id: str | None, collection: str,
    ) -> None:
        """检测单 session 异常 fact 产出 — 放 reflect(SessionEnd)

        阈值 15: 正常 session 约 5-10 条 facts，超过 15 说明可能有
        自投毒循环或其他 bulk 异常。仅 WARNING 不阻断。
        """
        if not session_id:
            return
        anomalies = self.db_cog.count_facts_by_session(session_id, collection)
        for category, count in anomalies:
            logger.warning(
                "ANOMALY: session %s produced %d '%s' facts (threshold: 15)",
                session_id[:8] if session_id else "?", count, category,
            )

    def _launch_maintenance(
        self, collection: str, session_id: str | None,
    ) -> None:
        """启动 maintenance 子进程

        使用 sys.executable -m（非 entry point）：hook 进程本身就在 pipx venv 中，
        sys.executable 指向 venv python，-m 解析内部模块是正确路径。
        """
        import subprocess

        try:
            # #237 Phase 4: 清除 IDE 环境变量，防止 maintenance 子进程继承
            from ..env_utils import make_clean_env
            env = make_clean_env()

            log_file = INDEX1_HOME / "maintenance.log"
            with open(log_file, "a") as fh:
                subprocess.Popen(
                    [
                        sys.executable, "-m", "index1.cognitive.scheduler",
                        "--collection", collection,
                        "--session-id", session_id or "",
                        "--db-path", self.db_cog._db_path,
                    ],
                    env=env,
                    start_new_session=True,
                    stdout=subprocess.DEVNULL,
                    stderr=fh,
                )
        except Exception as e:
            logger.warning("启动维护进程失败: %s", e)

    def _fallback_maintenance(
        self, collection: str, current_session_id: str | None,
    ) -> None:
        """兜底：SessionEnd 未触发时，在 awaken 中补跑 maintenance

        检查 pending marker 文件。如果存在且不是当前 session，
        说明上一个 session 的 SessionEnd 没有触发，需要补跑。
        """
        import time

        marker = INDEX1_HOME / f".pending_maintenance_{collection}"
        if not marker.exists():
            return

        try:
            marker_data = json.loads(marker.read_text())
        except (OSError, json.JSONDecodeError):
            # 标记文件损坏，清除
            try:
                marker.unlink(missing_ok=True)
            except OSError:
                pass
            return

        prev_session = marker_data.get("session_id")
        marker_time = marker_data.get("timestamp", 0)

        # 跳过当前 session 的标记（不应该出现，但防御性检查）
        if prev_session == current_session_id:
            return

        # 标记必须至少 60 秒前（避免 SessionEnd 稍后到达时重复）
        marker_age = time.time() - marker_time
        if marker_age < 60:
            return

        # 超过 24 小时的标记视为过期，清除不补跑
        if marker_age > 86400:
            try:
                marker.unlink(missing_ok=True)
            except OSError:
                pass
            return

        logger.info(
            "兜底维护：Session %s 的 SessionEnd 未触发，补跑 maintenance",
            prev_session,
        )
        try:
            marker.unlink(missing_ok=True)
        except OSError:
            pass

        self._launch_maintenance(collection, prev_session)

    # ── #247: 三级降级 observe 路径 ──────────────────────────────────

    _WORKER_POST_TIMEOUT = 2.0  # POST /observe 超时（fire-and-forget）
    _API_DIRECT_TIMEOUT = 25.0  # httpx 直调 API 超时

    def _try_observe_l2(
        self,
        tool_name: str,
        tool_input: dict | str,
        tool_output: str,
        collection: str,
        session_id: str | None,
    ) -> ObserveResult | None:
        """#247 三级降级：Worker → API key 直调 → None

        Returns:
            ObserveResult — Worker 或 API 成功处理
            None — 两者都不可用（调用方 stderr 通知用户）
        """
        ti = tool_input if isinstance(tool_input, dict) else {}

        # ── 优先级 1: Observer Worker（fire-and-forget）──
        if getattr(self.config, "observer_worker_enabled", True):
            sent = self._send_to_worker(
                tool_name, ti, tool_output, collection, session_id,
            )
            if sent:
                return ObserveResult(ok=True, provider="worker")

        # ── 优先级 2: API key 直调（~2s，完整 LLM 观察）──
        api_key = os.environ.get("ANTHROPIC_API_KEY") or getattr(
            self.config, "anthropic_api_key", "",
        )
        if api_key:
            return self._observe_via_api(
                api_key, tool_name, ti, tool_output, collection, session_id,
            )

        # ── 优先级 3: 都不可用 → 返回 None ──
        return None

    _WORKER_HEALTH_TIMEOUT = 1.0  # GET /health 超时（P1-1 预检查）

    def _send_to_worker(
        self,
        tool_name: str,
        tool_input: dict,
        tool_output: str,
        collection: str,
        session_id: str | None,
    ) -> bool:
        """向 Observer Worker 发送 fire-and-forget POST，~10ms

        P1-1(#248): 先 GET /health 预检查，Worker 已死时快速跳到 API key 直调。
        Returns True if queued, False if worker not available.
        """
        import httpx

        port = getattr(self.config, "observer_worker_port", 6889)
        base = f"http://127.0.0.1:{port}"
        # P1-1: 预健康检查 — 避免 POST 超时等待 2s
        try:
            health_resp = httpx.get(
                f"{base}/health", timeout=self._WORKER_HEALTH_TIMEOUT,
            )
            if health_resp.status_code != 200:
                logger.debug("Worker /health 返回 %d", health_resp.status_code)
                return False
            health_data = health_resp.json()
            qd = health_data.get("queue_depth", 0)
            if qd > 100:
                logger.warning("Worker queue_depth=%d, 仍然发送", qd)
        except Exception as e:
            logger.debug("Worker /health 不可达: %s", e)
            return False
        # POST 观察事件
        payload = {
            "tool_name": tool_name,
            "tool_input": tool_input,
            "tool_output": tool_output,
            "collection": collection,
            "session_id": session_id,
        }
        try:
            resp = httpx.post(
                f"{base}/observe", json=payload,
                timeout=self._WORKER_POST_TIMEOUT,
            )
            if resp.status_code in (200, 202):
                return True
            logger.debug("Worker POST 返回 %d", resp.status_code)
            return False
        except Exception as e:
            logger.debug("Worker POST 失败: %s", e)
            return False

    def _observe_via_api(
        self,
        api_key: str,
        tool_name: str,
        tool_input: dict,
        tool_output: str,
        collection: str,
        session_id: str | None,
    ) -> ObserveResult:
        """httpx 直调 Anthropic API（~2s），完整 LLM 观察

        复用 observer/__init__.py 的 prompt 构建 + parse + DB 写入逻辑。
        """
        import asyncio

        from .observer import build_tool_prompt, process_raw_observation

        if self._llm_breaker.is_open():
            return ObserveResult(ok=False, error="circuit_breaker_open")

        try:
            prompt = build_tool_prompt(tool_name, tool_input, tool_output)

            import httpx

            from ..config import OBSERVER_MODELS

            model = OBSERVER_MODELS.get("claude_api", "claude-sonnet-4-5")
            api_url = "https://api.anthropic.com/v1/messages"

            loop = asyncio.new_event_loop()
            try:
                async def _call_api():
                    # httpx 默认 client — 走系统代理（代理合规）
                    async with httpx.AsyncClient() as client:
                        resp = await client.post(
                            api_url,
                            headers={
                                "x-api-key": api_key,
                                "anthropic-version": "2023-06-01",
                                "content-type": "application/json",
                            },
                            json={
                                "model": model,
                                "max_tokens": 1024,
                                "messages": [{"role": "user", "content": prompt}],
                            },
                            timeout=self._API_DIRECT_TIMEOUT,
                        )
                        resp.raise_for_status()
                        data = resp.json()
                        raw = ""
                        for block in data.get("content", []):
                            if block.get("type") == "text":
                                raw += block.get("text", "")
                        return raw

                raw = loop.run_until_complete(_call_api())
            finally:
                loop.close()

            if not raw:
                self._record_observer_failure("api_empty_response", collection, session_id)
                return ObserveResult(ok=False, error="api_empty_response")

            # parse + DB 写入（不需要 create_observer 重建 provider 连接）
            facts = process_raw_observation(
                db_cog=self.db_cog,
                raw=raw,
                collection=collection,
                session_id=session_id,
                provider_name="claude_api",
                tool_input=tool_input,
                model_name=model,
            )
            self._llm_breaker.record_success()
            return ObserveResult(ok=True, provider="claude_api", fact_count=len(facts))

        except Exception as e:
            self._record_observer_failure(str(e)[:200], collection, session_id)
            logger.warning("API 直调失败: %s", e)
            return ObserveResult(ok=False, error=str(e)[:200])

    # ── 旧 subprocess 模式（保留给 worker 内部复用）────────────────

    # hook timeout 30s，LLM 观察预算 25s（capture+realtime <1s，留 ~4s buffer）
    # #247: 20s 对 Claude CLI 不够（简单 prompt ~13s，8KB observer prompt >20s）
    _LLM_OBSERVE_TIMEOUT = 25.0
    _PROVIDER_CLOSE_TIMEOUT = 2.0

    def _observe_with_llm(
        self,
        tool_name: str,
        tool_input: dict | str,
        tool_output: str,
        collection: str,
        session_id: str | None,
    ) -> ObserveResult:
        """Per-tool-call LLM observation — 返回 ObserveResult

        创建临时 event loop 跑 async observer。
        每次 hook 调用是独立进程，无状态复用。

        保护机制：
        - asyncio.wait_for _LLM_OBSERVE_TIMEOUT 超时
        - CircuitBreaker: 连续 3 次超时/失败后熔断 10 分钟
        - provider close 也有 _PROVIDER_CLOSE_TIMEOUT 保护
        - 超时/熔断时 logger.warning 留日志

        #242: 返回 ObserveResult 替代 None，调用方据此决定是否 fallback L0。
        """
        import asyncio

        from .observer import create_observer

        # 熔断检查 — LLM 连续失败时跳过，避免反复卡死
        if self._llm_breaker.is_open():
            logger.debug("LLM observer 熔断中，跳过")
            return ObserveResult(ok=False, error="circuit_breaker_open")

        try:
            observer = create_observer(self.db_cog, self.config)
            if observer is None:
                return ObserveResult(ok=False, error="no_observer_created")

            ti = tool_input if isinstance(tool_input, dict) else {}
            loop = asyncio.new_event_loop()
            try:
                result = loop.run_until_complete(
                    asyncio.wait_for(
                        observer.observe_tool_call(
                            tool_name=tool_name,
                            tool_input=ti,
                            tool_output=tool_output,
                            collection=collection,
                            session_id=session_id,
                        ),
                        timeout=self._LLM_OBSERVE_TIMEOUT,
                    )
                )
                # #237 Phase 1: None = 全部 provider 失败，[] = 无可用 provider
                if result is None:
                    self._record_observer_failure(
                        "all_providers_failed", collection, session_id,
                    )
                    return ObserveResult(ok=False, error="all_providers_failed")
                else:
                    self._llm_breaker.record_success()
                    return ObserveResult(ok=True, fact_count=len(result))
            except asyncio.TimeoutError:
                self._record_observer_failure(
                    f"timeout_{self._LLM_OBSERVE_TIMEOUT}s",
                    collection, session_id,
                )
                logger.warning(
                    "LLM observer 超时 (>%ss)，tool=%s — 连续失败将触发熔断",
                    self._LLM_OBSERVE_TIMEOUT, tool_name,
                )
                return ObserveResult(
                    ok=False, error=f"timeout_{self._LLM_OBSERVE_TIMEOUT}s",
                )
            finally:
                # 关闭 provider clients（也加 timeout，防止 close 本身挂起）
                for p in observer.providers:
                    if hasattr(p, "close"):
                        try:
                            loop.run_until_complete(
                                asyncio.wait_for(p.close(), timeout=self._PROVIDER_CLOSE_TIMEOUT)
                            )
                        except Exception:
                            pass  # close 失败不重要，进程即将退出
                loop.close()
        except Exception as e:
            self._record_observer_failure(
                str(e)[:200], collection, session_id,
            )
            logger.warning("LLM observer 失败: %s", e)
            return ObserveResult(ok=False, error=str(e)[:200])

    def _record_observer_failure(
        self,
        reason: str,
        collection: str,
        session_id: str | None,
    ) -> None:
        """记录 observer 失败 + 检测 CB 状态转换

        #237 Phase 2: 当 CB 从 closed→open 时，向 raw_events 写入
        高重要性事件 (importance=0.9)，Web UI Events tab 自动展示。

        #247: 同时写 observer_failures.log（之前 timeout 路径漏写）
        """
        # #247: 写 observer_failures.log — timeout/exception 都要记录
        try:
            from .observer import _log_provider_failure
            _log_provider_failure("hooks", reason)
        except Exception:
            pass

        was_open = self._llm_breaker.is_open()
        self._llm_breaker.record_failure()
        now_open = self._llm_breaker.is_open()

        # CB 状态转换：closed→open，写 raw_event（Web UI 可见）
        if not was_open and now_open and session_id:
            try:
                self.db_cog.insert_raw_event(
                    session_id=session_id,
                    hook_event="PostToolUse",
                    collection=collection,
                    payload={
                        "event": "observer_circuit_open",
                        "reason": reason,
                        "stats": self._llm_breaker.stats,
                    },
                    tool_name="__observer__",
                    importance=0.9,
                    signals_json="{}",
                )
            except Exception:
                pass  # DB 写失败不影响主流程

    def _write_pending_marker(
        self, collection: str, session_id: str | None,
    ) -> None:
        """写 pending maintenance 标记文件（原子写入，节流 10s）"""
        import tempfile
        import time

        marker = INDEX1_HOME / f".pending_maintenance_{collection}"

        # 节流：marker 存在且 < 10s 就跳过（减少 I/O）
        try:
            if marker.exists() and time.time() - marker.stat().st_mtime < 10:
                return
        except OSError:
            pass

        payload = json.dumps({
            "session_id": session_id,
            "collection": collection,
            "timestamp": time.time(),
        })

        try:
            INDEX1_HOME.mkdir(parents=True, exist_ok=True)
            # 原子写入：先写临时文件，再 os.replace
            fd, tmp_name = tempfile.mkstemp(
                dir=INDEX1_HOME, prefix=".tmp_marker_",
            )
            os.write(fd, payload.encode())
            os.close(fd)
            os.replace(tmp_name, marker)
        except OSError:
            pass  # 写失败不影响正常功能


    def _persist_fact_summary(
        self,
        session_facts: list[dict],
        collection: str,
        session_id: str | None,
    ) -> None:
        """将 session 事实摘要持久化到 sessions 表（从 DB facts 构建）"""
        if not session_id or not session_facts:
            return

        categories: dict[str, int] = {}
        files_touched: set[str] = set()

        for fact in session_facts:
            cat = fact.get("category", "?")
            categories[cat] = categories.get(cat, 0) + 1
            try:
                scope_files = json.loads(fact.get("scope_files", "[]"))
                files_touched.update(scope_files)
            except (json.JSONDecodeError, TypeError):
                pass

        summary = {
            "fact_count": len(session_facts),
            "category_counts": categories,
            "files_touched": list(files_touched)[:20],
        }

        try:
            self.db_cog.update_session_context(
                external_id=session_id,
                event_summary=json.dumps(summary, ensure_ascii=False),
            )
        except Exception as e:
            logger.debug("persist fact_summary 失败: %s", e)

    def _can_orient_hybrid(self) -> bool:
        """判断 orient 是否可以使用混合搜索（仅 ONNX 后端足够快）"""
        if not self.config:
            return False
        backend = getattr(self.config, "embed_backend", "ollama")
        return backend == "onnx"

    def _orient_unified_search(
        self, query: str, collection: str,
    ):
        """orient 统一搜索 — corpus + cognition 并行（<180ms）

        复用 embedder 和 event loop，避免每次 orient 的创建/销毁开销。
        返回 UnifiedResult（code + facts + tactics + model + hint）。
        """
        try:
            import asyncio
            from ..unified_search import unified_recall

            if self._orient_embedder is None:
                from ..embed import create_embedder
                self._orient_embedder = create_embedder(self.config)
            if self._orient_loop is None or self._orient_loop.is_closed():
                self._orient_loop = asyncio.new_event_loop()

            result = self._orient_loop.run_until_complete(
                asyncio.wait_for(
                    unified_recall(
                        query=query,
                        config=self.config,
                        db=self.db,
                        embedder=self._orient_embedder,
                        db_cog=self.db_cog,
                        collection=collection,
                        top_k=5,
                    ),
                    timeout=0.18,  # orient 预算 ~210ms，留 30ms 余量
                )
            )
            return result
        except Exception as e:
            logger.debug("orient unified search 失败，降级到 BM25: %s", e)
            return None


def _buffer_fact(fact_data: dict) -> bool:
    """CB 触发时写 buffer 文件（500 行上限），periodic_l0 自动 replay"""
    buffer_path = INDEX1_HOME / ".circuit_buffer.jsonl"
    try:
        INDEX1_HOME.mkdir(parents=True, exist_ok=True)
        # 检查行数上限
        existing_lines = 0
        if buffer_path.exists():
            existing_lines = sum(1 for _ in buffer_path.open())
            if existing_lines >= 500:
                logger.debug("circuit buffer 已满 (%d 行)，丢弃", existing_lines)
                return False
        with buffer_path.open("a") as f:
            f.write(json.dumps(fact_data, ensure_ascii=False) + "\n")
        return True
    except OSError as e:
        logger.debug("buffer_fact 写入失败: %s", e)
        return False


def _detect_collection(data: dict) -> str:
    """从 cwd 或环境变量推断项目名（永远返回有效值）

    委托给统一的 detect_collection()（collection.py, Issue #136）
    hooks 在 orient 阻塞路径，max_latency_ms=5 跳过 subprocess
    """
    from ..collection import detect_collection
    try:
        config = load_config()
    except Exception:
        config = None
    return detect_collection(cwd=data.get("cwd"), config=config, max_latency_ms=5)


def hook_main(event: str | None = None) -> None:
    """Hook CLI 入口 — 读 stdin JSON → 分发 → stdout 输出

    event 可选：
    - 显式传入：index1 hook PostToolUse（向后兼容）
    - 自动检测：index1 hook（从 stdin JSON 的 hook_event_name 读取）

    安全原则：顶层 try-except 兜住一切，永远 exit 0。
    认知功能是锦上添花，绝不阻塞 Claude Code。
    """
    # 递归守卫：observer spawn 的 claude 子进程不再触发 hook，
    # 防止 PostToolUse → observe → claude -p → PostToolUse 无限循环
    if os.environ.get("INDEX1_HOOK_CHILD"):
        return

    try:
        # 读 stdin JSON
        raw = sys.stdin.read()
        data = json.loads(raw) if raw.strip() else {}

        # 自动检测事件名（插件模式：从 stdin 读取）
        if not event:
            event = data.get("hook_event_name", "")
        if not event:
            return  # 无法确定事件类型

        config = load_config()
        if not config.cognitive_enabled:
            return

        # 连接认知数据库
        db_cog = CogDatabase(
            config.cognitive_db_path_resolved,
            config.effective_embedding_dim,
        )
        # corpus 数据库（orient 统一搜索用，可选）
        db = None
        try:
            db_cog.connect()

            # orient 需要 corpus DB 做统一搜索
            if event == "UserPromptSubmit":
                try:
                    from ..db import Database
                    db = Database(config.db_path_resolved, config.effective_embedding_dim)
                    db.connect()
                except Exception as e:
                    logger.debug("corpus DB 连接失败（orient 降级为 cognition-only）: %s", e)
                    db = None

            handler = HookHandler(db_cog, config, db=db)
            result = handler.handle(event, data)

            # 输出结果
            if result:
                # #260 P2: SessionStart 使用 hookSpecificOutput 原生格式
                # Claude Code 通过 additionalContext 注入上下文，比自定义 key 更可靠
                if event == "SessionStart" and "output" in result:
                    context_text = result["output"]
                    # L1: 内容类型 + 大小校验
                    if isinstance(context_text, str) and context_text:
                        if len(context_text) > 100_000:
                            context_text = context_text[:100_000]
                        result = {
                            "hookSpecificOutput": {
                                "hookEventName": "SessionStart",
                                "additionalContext": context_text,
                            }
                        }
                    else:
                        # 非 string/空内容 — 不输出无效格式
                        result = {}
                try:
                    json.dump(result, sys.stdout, ensure_ascii=False)
                    sys.stdout.flush()
                except (TypeError, ValueError, OSError) as e:
                    logger.error("hook 输出序列化失败: %s", e)
        finally:
            if db:
                try:
                    db.close()
                except Exception:
                    pass
            try:
                db_cog.close()
            except Exception:
                pass

    except Exception as e:
        # 永远 exit 0 — 不阻塞 Claude Code
        logger.debug("Hook %s 异常（已静默）: %s", event, e)
