# NousResearch/hermes-agent · Feishu document comment intelligent reply feature

## Open now

- Nothing open on GitHub.

These members all relate to adding intelligent (AI-driven) reply functionality for Feishu/Lark document comments, incorporating a 3-tier access control system. The items appear to be a feature request and associated implementation pull requests for the same capability.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #11898. Verify it addresses your case.
- **Recommended:** read PR #11898 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-11898`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `1.00`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #11465: \[Feature\]: Support Feishu/Lark document comment intelligent reply

## Prior & competing PRs

- **#11023: feat: Feishu document comment intelligent reply with 3-tier access control**. closed without merge · fix tier `suggested`
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#11465`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#11898: feat(feishu): intelligent reply on document comments with 3-tier access control**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/feishu.py`, `gateway/platforms/feishu_comment.py`, `gateway/platforms/feishu_comment_rules.py`, `scripts/release.py`, `tests/gateway/test_feishu.py`, `tests/gateway/test_feishu_comment.py`, `tests/gateway/test_feishu_comment_rules.py`, `tests/tools/test_feishu_tools.py`, `tools/feishu_doc_tool.py`, `tools/feishu_drive_tool.py`, `toolsets.py`
  - fix hunks:
  - `gateway/platforms/feishu.py`

    ```diff
    @@ -1228,6 +1228,10 @@ def _build_event_handler(self) -> Any:
                 .register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat)
                 .register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_p2p_chat_entered)
                 .register_p2_im_message_recalled_v1(self._on_message_recalled)
    +            .register_p2_customized_event(
    +                "drive.notice.comment_add_v1",
    +                self._on_drive_comment_event,
    +            )
                 .build()
             )
     
    ```

    ```diff
    @@ -1965,6 +1969,25 @@ def _on_p2p_chat_entered(self, data: Any) -> None:
         def _on_message_recalled(self, data: Any) -> None:
             logger.debug("[Feishu] Message recalled by user")
     
    +    def _on_drive_comment_event(self, data: Any) -> None:
    +        """Handle drive document comment notification (drive.notice.comment_add_v1).
    +
    +        Delegates to :mod:`gateway.platforms.feishu_comment` for parsing,
    +        logging, and reaction.  Scheduling follows the same
    +        ``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.
    +        """
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +
    +        loop = self._loop
    +        if not self._loop_accepts_callbacks(loop):
    +            logger.warning("[Feishu] Dropping drive comment event before adapter loop is ready")
    +            return
    +        future = asyncio.run_coroutine_threadsafe(
    +            handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),
    +            loop,
    +        )
    +        future.add_done_callback(self._log_background_failure)
    +
         def _on_reaction_event(self, event_type: str, data: Any) -> None:
             """Route user reactions on bot messages as synthetic text events."""
             event = getattr(data, "event", None)
    ```

    ```diff
    @@ -2590,6 +2613,8 @@ async def _handle_webhook_request(self, request: Any) -> Any:
                 self._on_reaction_event(event_type, data)
             elif event_type == "card.action.trigger":
                 self._on_card_action_trigger(data)
    +        elif event_type == "drive.notice.comment_add_v1":
    +            self._on_drive_comment_event(data)
             else:
                 logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
             return web.json_response({"code": 0, "msg": "ok"})
    ```
  - `gateway/platforms/feishu_comment.py`

    ```diff
    @@ -0,0 +1,1383 @@
    +"""
    +Feishu/Lark drive document comment handling.
    +
    +Processes ``drive.notice.comment_add_v1`` events and interacts with the
    +Drive v2 comment reaction API.  Kept in a separate module so that the
    +main ``feishu.py`` adapter does not grow further and comment-related
    +logic can evolve independently.
    +
    +Flow:
    +  1. Parse event -> extract file_token, comment_id, reply_id, etc.
    +  2. Add OK reaction
    +  3. Parallel fetch: doc meta + comment details (batch_query)
    +  4. Branch on is_whole:
    +       Whole -> list whole comments timeline
    +       Local -> list comment thread replies
    +  5. Build prompt (local or whole)
    +  6. Create AIAgent with feishu_doc + feishu_drive tools -> agent generates reply
    +  7. Route reply:
    +       Whole -> add_whole_comment
    +       Local -> reply_to_comment (fallback to add_whole_comment on 1069302)
    +"""
    +
    +from __future__ import annotations
    +
    +import asyncio
    +import json
    +import logging
    +from typing import Any, Dict, List, Optional, Tuple
    +
    +logger = logging.getLogger(__name__)
    +
    +# ---------------------------------------------------------------------------
    +# Lark SDK helpers (lazy-imported)
    +# ---------------------------------------------------------------------------
    +
    +
    +def _build_request(method: str, uri: str, paths=None, queries=None, body=None):
    +    """Build a lark_oapi BaseRequest."""
    +    from lark_oapi import AccessTokenType
    +    from lark_oapi.core.enum import HttpMethod
    +    from lark_oapi.core.model.base_request import BaseRequest
    +
    +    http_method = HttpMethod.GET if method == "GET" else HttpMethod.POST
    +
    +    builder = (
    +        BaseRequest.builder()
    +        .http_method(http_method)
    +        .uri(uri)
    +        .token_types({AccessTokenType.TENANT})
    +    )
    +    if paths:
    +        builder = builder.paths(paths)
    +    if queries:
    +        builder = builder.queries(queries)
    +    if body is not None:
    +        builder = builder.body(body)
    +    return builder.build()
    +
    +
    +async def _exec_request(client, method, uri, paths=None, queries=None, body=None):
    +    """Execute a lark API request and return (code, msg, data_dict)."""
    +    logger.info("[Feishu-Comment] API >>> %s %s paths=%s queries=%s body=%s",
    +                 method, uri, paths, queries,
    +                 json.dumps(body, ensure_ascii=False)[:500] if body else None)
    +    request = _build_request(method, uri, paths, queries, body)
    +    response = await asyncio.to_thread(client.request, request)
    +
    +    code = getattr(response, "code", None)
    +    msg = getattr(response, "msg", "")
    +
    +    data: dict = {}
    +    raw = getattr(response, "raw", None)
    +    if raw and hasattr(raw, "content"):
    +        try:
    +            body_json = json.loads(raw.content)
    +            data = body_json.get("data", {})
    +        except (json.JSONDecodeError, AttributeError):
    +            pass
    +    if not data:
    +        resp_data = getattr(response, "data", None)
    +        if isinstance(resp_data, dict):
    +            data = resp_data
    +        elif resp_data and hasattr(resp_data, "__dict__"):
    +            data = vars(resp_data)
    +
    +    logger.info("[Feishu-Comment] API <<< %s %s code=%s msg=%s data_keys=%s",
    +                 method, uri, code, msg, list(data.keys()) if data else "empty")
    +    if code != 0:
    +        # Log raw response for debugging failed API calls
    +        raw = getattr(response, "raw", None)
    +        raw_content = ""
    +        if raw and hasattr(raw, "content"):
    +            raw_content = raw.content[:500] if isinstance(raw.content, (str, bytes)) else str(raw.content)[:500]
    +        logger.warning("[Feishu-Comment] API FAIL raw response: %s", raw_content)
    +    return code, msg, data
    +
    +
    +# ---------------------------------------------------------------------------
    +# Event parsing
    +# ---------------------------------------------------------------------------
    +
    +
    +def parse_drive_comment_event(data: Any) -> Optional[Dict[str, Any]]:
    +    """Extract structured fields from a ``drive.notice.comment_add_v1`` payload.
    +
    +    *data* may be a ``CustomizedEvent`` (WebSocket) whose ``.event`` is a dict,
    +    or a ``SimpleNamespace`` (Webhook) built from the full JSON body.
    +
    +    Returns a flat dict with the relevant fields, or ``None`` when the
    +    payload is malformed.
    +    """
    +    logger.debug("[Feishu-Comment] parse_drive_comment_event: data type=%s", type(data).__name__)
    +    event = getattr(data, "event", None)
    +    if event is None:
    +        logger.debug("[Feishu-Comment] parse_drive_comment_event: no .event attribute, returning None")
    +        return None
    +
    +    evt: dict = event if isinstance(event, dict) else (
    +        vars(event) if hasattr(event, "__dict__") else {}
    +    )
    +    logger.debug("[Feishu-Comment] parse_drive_comment_event: evt keys=%s", list(evt.keys()))
    +
    +    notice_meta = evt.get("notice_meta") or {}
    +    if not isinstance(notice_meta, dict):
    +        notice_meta = vars(notice_meta) if hasattr(notice_meta, "__dict__") else {}
    +
    +    from_user = notice_meta.get("from_user_id") or {}
    +    if not isinstance(from_user, dict):
    +        from_user = vars(from_user) if hasattr(from_user, "__dict__") else {}
    +
    +    to_user = notice_meta.get("to_user_id") or {}
    +    if not isinstance(to_user, dict):
    +        to_user = vars(to_user) if hasattr(to_user, "__dict__") else {}
    +
    +    return {
    +        "event_id": str(evt.get("event_id") or ""),
    +        "comment_id": str(evt.get("comment_id") or ""),
    +        "reply_id": str(evt.get("reply_id") or ""),
    +        "is_mentioned": bool(evt.get("is_mentioned")),
    +        "timestamp": str(evt.get("timestamp") or ""),
    +        "file_token": [redacted:secret-assignment]"file_token") or ""),
    +        "file_type": str(notice_meta.get("file_type") or ""),
    +        "notice_type": str(notice_meta.get("notice_type") or ""),
    +        "from_open_id": str(from_user.get("open_id") or ""),
    +        "to_open_id": str(to_user.get("open_id") or ""),
    +    }
    +
    +
    +# ---------------------------------------------------------------------------
    +# Comment reaction API
    +# ---------------------------------------------------------------------------
    +
    +_REACTION_URI = "/open-apis/drive/v2/files/:file_token/comments/reaction"
    +
    +
    +async def add_comment_reaction(
    +    client: Any,
    +    *,
    +    file_token: str,
    +    file_type: str,
    +    reply_id: str,
    +    reaction_type: str = "OK",
    +) -> bool:
    +    """Add an emoji reaction to a document comment reply.
    +
    +    Uses the Drive v2 ``update_reaction`` endpoint::
    +
    +        POST /open-apis/drive/v2/files/{file_token}/comments/reaction?file_type=...
    +
    +    Returns ``True`` on success, ``False`` on failure (errors are logged).
    +    """
    +    try:
    +        from lark_oapi import AccessTokenType  # noqa: F401
    +    except ImportError:
    +        logger.error("[Feishu-Comment] lark_oapi not available")
    +        return False
    +
    +    body = {
    +        "action": "add",
    +        "reply_id": reply_id,
    +        "reaction_type": reaction_type,
    +    }
    +
    +    code, msg, _ = await _exec_request(
    +        client, "POST", _REACTION_URI,
    +        paths={"file_token": [redacted:secret-assignment]
    +        queries=[("file_type", file_type)],
    +        body=body,
    +    )
    +
    +    succeeded = code == 0
    +    if succeeded:
    +        logger.info(
    +            "[Feishu-Comment] Reaction '%s' added: file=%s:%s reply=%s",
    +            reaction_type, file_type, file_token, reply_id,
    +        )
    +    else:
    +        logger.warning(
    +            "[Feishu-Comment] Reaction API failed: code=%s msg=%s "
    +            "file=%s:%s reply=%s",
    +            code, msg, file_type, file_token, reply_id,
    +        )
    +    return succeeded
    +
    +
    +async def delete_comment_reaction(
    +    client: Any,
    +    *,
    +    file_token: str,
    +    file_type: str,
    +    reply_id: str,
    +    reaction_type: str = "OK",
    +) -> bool:
    +    """Remove an emoji reaction from a document comment reply.
    +
    +    Best-effort — errors are logged but not raised.
    +    """
    +    body = {
    +        "action": "delete",
    +        "reply_id": reply_id,
    +        "reaction_type": reaction_type,
    +    }
    +
    +    code, msg, _ = await _exec_request(
    +        client, "POST", _REACTION_URI,
    +        paths={"file_token": [redacted:secret-assignment]
    +        queries=[("file_type", file_type)],
    +        body=body,
    +    )
    +
    +    succeeded = code == 0
    +    if succeeded:
    +        logger.info(
    +            "[Feishu-Comment] Reaction '%s' deleted: file=%s:%s reply=%s",
    +            reaction_type, file_type, file_token, reply_id,
    +        )
    +    else:
    +        logger.warning(
    +            "[Feishu-Comment] Reaction API failed: code=%s msg=%s "
    +            "file=%s:%s reply=%s",
    +            code, msg, file_type, file_token, reply_id,
    +        )
    +    return succeeded
    +
    +
    +# ---------------------------------------------------------------------------
    +# API call layer
    +# ---------------------------------------------------------------------------
    +
    +_BATCH_QUERY_META_URI = "/open-apis/drive/v1/metas/batch_query"
    +_BATCH_QUERY_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/comments/batch_query"
    +_LIST_COMMENTS_URI = "/open-apis/drive/v1/files/:file_token/comments"
    +_LIST_REPLIES_URI = "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies"
    +_REPLY_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies"
    +_ADD_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/new_comments"
    +
    +
    +async def query_document_meta(
    +    client: Any, file_token: str, file_type: str,
    +) -> Dict[str, Any]:
    +    """Fetch document title and URL via batch_query meta API.
    +
    +    Returns ``{"title": "...", "url": "...", "doc_type": "..."}`` or empty dict.
    +    """
    +    body = {
    +        "request_docs": [{"doc_token": [redacted:secret-assignment] "doc_type": file_type}],
    +        "with_url": True,
    +    }
    +    logger.debug("[Feishu-Comment] query_document_meta: file_token=%s file_type=%s", file_token, file_type)
    +    code, msg, data = await _exec_request(
    +        client, "POST", _BATCH_QUERY_META_URI, body=body,
    +    )
    +    if code != 0:
    +        logger.warning("[Feishu-Comment] Meta batch_query failed: code=%s msg=%s", code, msg)
    +        return {}
    +
    +    metas = data.get("metas", [])
    +    logger.debug("[Feishu-Comment] query_document_meta: raw metas type=%s value=%s",
    +                 type(metas).__name__, str(metas)[:300])
    +    if not metas:
    +        # Try alternate response shape: metas may be a dict keyed by token
    +        if isinstance(data.get("metas"), dict):
    +            meta = data["metas"].get(file_token, {})
    +        else:
    +            logger.debug("[Feishu-Comment] query_document_meta: no metas found")
    +            return {}
    +    else:
    +        meta = metas[0] if isinstance(metas, list) else {}
    +
    +    result = {
    +        "title": meta.get("title", ""),
    +        "url": meta.get("url", ""),
    +        "doc_type": meta.get("doc_type", file_type),
    +    }
    +    logger.info("[Feishu-Comment] query_document_meta: title=%s url=%s",
    +                result["title"], result["url"][:80] if result["url"] else "")
    +    return result
    +
    +
    +_COMMENT_RETRY_LIMIT = 6
    +_COMMENT_RETRY_DELAY_S = 1.0
    +
    +
    +async def batch_query_comment(
    +    client: Any, file_token: str, file_type: str, comment_id: str,
    +) -> Dict[str, Any]:
    +    """Fetch comment details via batch_query comment API.
    +
    +    Retries up to 6 times on failure (handles eventual consistency).
    +
    +    Returns the comment dict with fields like ``is_whole``, ``quote``,
    +    ``reply_list``, etc.  Empty dict on failure.
    +    """
    +    logger.debug("[Feishu-Comment] batch_query_comment: file_token=%s comment_id=%s", file_token, comment_id)
    +
    +    for attempt in range(_COMMENT_RETRY_LIMIT):
    +        code, msg, data = await _exec_request(
    +            client, "POST", _BATCH_QUERY_COMMENT_URI,
    +            paths={"file_token": [redacted:secret-assignment]
    +            queries=[
    +                ("file_type", file_type),
    +                ("user_id_type", "open_id"),
    +            ],
    +            body={"comment_ids": [comment_id]},
    +        )
    +        if code == 0:
    +            break
    +        if attempt < _COMMENT_RETRY_LIMIT - 1:
    +            logger.info(
    +                "[Feishu-Comment] batch_query_comment retry %d/%d: code=%s msg=%s",
    +                attempt + 1, _COMMENT_RETRY_LIMIT, code, msg,
    +            )
    +            await asyncio.sleep(_COMMENT_RETRY_DELAY_S)
    +        else:
    +            logger.warning(
    +                "[Feishu-Comment] batch_query_comment failed after %d attempts: code=%s msg=%s",
    +                _COMMENT_RETRY_LIMIT, code, msg,
    +            )
    +            return {}
    +
    +    # Response: {"items": [{"comment_id": "...", ...}]}
    +    items = data.get("items", [])
    +    logger.debug("[Feishu-Comment] batch_query_comment: got %d items", len(items) if isinstance(items, list) else 0)
    +    if items and isinstance(items, list):
    +        item = items[0]
    +        logger.info("[Feishu-Comment] batch_query_comment: is_whole=%s quote=%s reply_count=%s",
    +                    item.get("is_whole"),
    +                    (item.get("quote", "") or "")[:60],
    +                    len(item.get("reply_list", {}).get("replies", [])) if isinstance(item.get("reply_list"), dict) else "?")
    +        return item
    +    logger.warning("[Feishu-Comment] batch_query_comment: empty items, raw data keys=%s", list(data.keys()))
    +    return {}
    +
    +
    +async def list_whole_comments(
    +    client: Any, file_token: str, file_type: str,
    +) -> List[Dict[str, Any]]:
    +    """List all whole-document comments (paginated, up to 500)."""
    +    logger.debug("[Feishu-Comment] list_whole_comments: file_token=%s", file_token)
    +    all_comments: List[Dict[str, Any]] = []
    +    page_token = ""
    +
    +    for _ in range(5):  # max 5 pages
    +        queries = [
    +            ("file_type", file_type),
    +            ("is_whole", "true"),
    +            ("page_size", "100"),
    +            ("user_id_type", "open_id"),
    +        ]
    +        if page_token:
    +            queries.append(("page_token", page_token))
    +
    +        code, msg, data = await _exec_request(
    +            client, "GET", _LIST_COMMENTS_URI,
    +            paths={"file_token": [redacted:secret-assignment]
    +            queries=queries,
    +        )
    +        if code != 0:
    +            logger.warning("[Feishu-Comment] List whole comments failed: code=%s msg=%s", code, msg)
    +            break
    +
    +        items = data.get("items", [])
    +        if isinstance(items, list):
    +            all_comments.extend(items)
    +            logger.debug("[Feishu-Comment] list_whole_comments: page got %d items, total=%d",
    +                         len(items), len(all_comments))
    +
    +        if not data.get("has_more"):
    +            break
    +        page_token = data.get("page_token", "")
    +        if not page_token:
    +            break
    +
    +    logger.info("[Feishu-Comment] list_whole_comments: total %d whole comments fetched", len(all_comments))
    +    return all_comments
    +
    +
    +async def list_comment_replies(
    +    client: Any, file_token: str, file_type: str, comment_id: str,
    +    *, expect_reply_id: str = "",
    +) -> List[Dict[str, Any]]:
    +    """List all replies in a comment thread (paginated, up to 500).
    +
    +    If *expect_reply_id* is set and not found in the first fetch,
    +    retries up to 6 times (handles eventual consistency).
    +    """
    +    logger.debug("[Feishu-Comment] list_comment_replies: file_token=%s comment_id=%s", file_token, comment_id)
    +
    +    for attempt in range(_COMMENT_RETRY_LIMIT):
    +        all_replies: List[Dict[str, Any]] = []
    +        page_token = ""
    +        fetch_ok = True
    +
    +        for _ in range(5):  # max 5 pages
    +            queries = [
    +                ("file_type", file_type),
    +                ("page_size", "100"),
    +                ("user_id_type", "open_id"),
    +            ]
    +            if page_token:
    +                queries.append(("page_token", page_token))
    +
    +            code, msg, data = await _exec_request(
    +                client, "GET", _LIST_REPLIES_URI,
    +                paths={"file_token": [redacted:secret-assignment] "comment_id": comment_id},
    +                queries=queries,
    +            )
    +            if code != 0:
    +                logger.warning("[Feishu-Comment] List replies failed: code=%s msg=%s", code, msg)
    +                fetch_ok = False
    +                break
    +
    +            items = data.get("items", [])
    +            if isinstance(items, list):
    +                all_replies.extend(items)
    +
    +            if not data.get("has_more"):
    +                break
    +            page_token = data.get("page_token", "")
    +            if not page_token:
    +                break
    +
    +        # If we don't need a specific reply, or we found it, return
    +        if not expect_reply_id or not fetch_ok:
    +            break
    +        found = any(r.get("reply_id") == expect_reply_id for r in all_replies)
    +        if found:
    +            break
    +        if attempt < _COMMENT_RETRY_LIMIT - 1:
    +            logger.info(
    +                "[Feishu-Comment] list_comment_replies: reply_id=%s not found, retry %d/%d",
    +                expect_reply_id, attempt + 1, _COMMENT_RETRY_LIMIT,
    +            )
    +            await asyncio.sleep(_COMMENT_RETRY_DELAY_S)
    +        else:
    +            logger.warning(
    +                "[Feishu-Comment] list_comment_replies: reply_id=%s not found after %d attempts",
    +                expect_reply_id, _COMMENT_RETRY_LIMIT,
    +            )
    +
    +    logger.info("[Feishu-Comment] list_comment_replies: total %d replies fetched", len(all_replies))
    +    return all_replies
    +
    +
    +def _sanitize_comment_text(text: str) -> str:
    +    """Escape characters not allowed in Feishu comment text_run content."""
    +    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    +
    +
    +async def reply_to_comment(
    +    client: Any, file_token: str, file_type: str, comment_id: str, text: str,
    +) -> Tuple[bool, int]:
    +    """Post a reply to a local comment thread.
    +
    +    Returns ``(success, code)``.
    +    """
    +    text = _sanitize_comment_text(text)
    +    logger.info("[Feishu-Comment] reply_to_comment: comment_id=%s text=%s",
    +                comment_id, text[:100])
    +    body = {
    +        "content": {
    +            "elements": [
    +                {"type": "text_run", "text_run": {"text": text}},
    +            ]
    +        }
    +    }
    +
    +    code, msg, _ = await _exec_request(
    +        client, "POST", _REPLY_COMMENT_URI,
    +        paths={"file_token": [redacted:secret-assignment] "comment_id": comment_id},
    +        queries=[("file_type", file_type)],
    +        body=body,
    +    )
    +    if code != 0:
    +        logger.warning(
    +            "[Feishu-Comment] reply_to_comment FAILED: code=%s msg=%s comment_id=%s",
    +            code, msg, comment_id,
    +        )
    +    else:
    +        logger.info("[Feishu-Comment] reply_to_comment OK: comment_id=%s", comment_id)
    +    return code == 0, code
    +
    +
    +async def add_whole_comment(
    +    client: Any, file_token: str, file_type: str, text: str,
    +) -> bool:
    +    """Add a new whole-document comment.
    +
    +    Returns ``True`` on success.
    +    """
    +    text = _sanitize_comment_text(text)
    +    logger.info("[Feishu-Comment] add_whole_comment: file_token=%s text=%s",
    +                file_token, text[:100])
    +    body = {
    +        "file_type": file_type,
    +        "reply_elements": [
    +            {"type": "text", "text": text},
    +        ],
    +    }
    +
    +    code, msg, _ = await _exec_request(
    +        client, "POST", _ADD_COMMENT_URI,
    +        paths={"file_token": [redacted:secret-assignment]
    +        body=body,
    +    )
    +    if code != 0:
    +        logger.warning("[Feishu-Comment] add_whole_comment FAILED: code=%s msg=%s", code, msg)
    +    else:
    +        logger.info("[Feishu-Comment] add_whole_comment OK")
    +    return code == 0
    +
    +
    +_REPLY_CHUNK_SIZE = 4000
    +
    +
    +def _chunk_text(text: str, limit: int = _REPLY_CHUNK_SIZE) -> List[str]:
    +    """Split text into chunks for delivery, preferring line breaks."""
    +    if len(text) <= limit:
    +        return [text]
    +    chunks = []
    +    while text:
    +        if len(text) <= limit:
    +            chunks.append(text)
    +            break
    +        # Find last newline within limit
    +        cut = text.rfind("\n", 0, limit)
    +        if cut <= 0:
    +            cut = limit
    +        chunks.append(text[:cut])
    +        text = text[cut:].lstrip("\n")
    +    return chunks
    +
    +
    +async def deliver_comment_reply(
    +    client: Any,
    +    file_token: str,
    +    file_type: str,
    +    comment_id: str,
    +    text: str,
    +    is_whole: bool,
    +) -> bool:
    +    """Route agent reply to the correct API, chunking long text.
    +
    +    - Whole comment -> add_whole_comment
    +    - Local comment -> reply_to_comment, fallback to add_whole_comment on 1069302
    +    """
    +    chunks = _chunk_text(text)
    +    logger.info("[Feishu-Comment] deliver_comment_reply: is_whole=%s comment_id=%s text_len=%d chunks=%d",
    +                is_whole, comment_id, len(text), len(chunks))
    +
    +    all_ok = True
    +    for i, chunk in enumerate(chunks):
    +        if len(chunks) > 1:
    +            logger.info("[Feishu-Comment] deliver_comment_reply: sending chunk %d/%d (%d chars)",
    +                        i + 1, len(chunks), len(chunk))
    +
    +        if is_whole:
    +            ok = await add_whole_comment(client, file_token, file_type, chunk)
    +        else:
    +            success, code = await reply_to_comment(client, file_token, file_type, comment_id, chunk)
    +            if success:
    +                ok = True
    +            elif code == 1069302:
    +                logger.info("[Feishu-Comment] Reply not allowed (1069302), falling back to add_whole_comment")
    +                ok = await add_whole_comment(client, file_token, file_type, chunk)
    +                is_whole = True  # subsequent chunks also use add_comment
    +            else:
    +                ok = False
    +
    +        if not ok:
    +            all_ok = False
    +            break
    +
    +    return all_ok
    +
    +
    +# ---------------------------------------------------------------------------
    +# Comment content extraction helpers
    +# ---------------------------------------------------------------------------
    +
    +
    +def _extract_reply_text(reply: Dict[str, Any]) -> str:
    +    """Extract plain text from a comment reply's content structure."""
    +    content = reply.get("content", {})
    +    if isinstance(content, str):
    +        try:
    +            content = json.loads(content)
    +        except (json.JSONDecodeError, TypeError):
    +            return content
    +
    +    elements = content.get("elements", [])
    +    parts = []
    +    for elem in elements:
    +        if elem.get("type") == "text_run":
    +            text_run = elem.get("text_run", {})
    +            parts.append(text_run.get("text", ""))
    +        elif elem.get("type") == "docs_link":
    +            docs_link = elem.get("docs_link", {})
    +            parts.append(docs_link.get("url", ""))
    +        elif elem.get("type") == "person":
    +            person = elem.get("person", {})
    +            parts.append(f"@{person.get('user_id', 'unknown')}")
    +    return "".join(parts)
    +
    +
    +def _get_reply_user_id(reply: Dict[str, Any]) -> str:
    +    """Extract user_id from a reply dict."""
    +    user_id = reply.get("user_id", "")
    +    if isinstance(user_id, dict):
    +        return user_id.get("open_id", "") or user_id.get("user_id", "")
    +    return str(user_id)
    +
    +
    +def _extract_semantic_text(reply: Dict[str, Any], self_open_id: str = "") -> str:
    +    """Extract semantic text from a reply, stripping self @mentions and extra whitespace."""
    +    content = reply.get("content", {})
    +    if isinstance(content, str):
    +        try:
    +            content = json.loads(content)
    +        except (json.JSONDecodeError, TypeError):
    +            return content
    +
    +    elements = content.get("elements", [])
    +    parts = []
    +    for elem in elements:
    +        if elem.get("type") == "person":
    +            person = elem.get("person", {})
    +            uid = person.get("user_id", "")
    +            # Skip self @mention (it's routing, not content)
    +            if self_open_id and uid == self_open_id:
    +                continue
    +            parts.append(f"@{uid}")
    +        elif elem.get("type") == "text_run":
    +            text_run = elem.get("text_run", {})
    +            parts.append(text_run.get("text", ""))
    +        elif elem.get("type") == "docs_link":
    +            docs_link = elem.get("docs_link", {})
    +            parts.append(docs_link.get("url", ""))
    +    return " ".join("".join(parts).split()).strip()
    +
    +
    +# ---------------------------------------------------------------------------
    +# Document link parsing and wiki resolution
    +# ---------------------------------------------------------------------------
    +
    +import re as _re
    +
    +# Matches feishu/lark document URLs and extracts doc_type + token
    +_FEISHU_DOC_URL_RE = _re.compile(
    +    r"(?:feishu\.cn|larkoffice\.com|larksuite\.com|lark\.suite\.com)"
    +    r"/(?P<doc_type>wiki|doc|docx|sheet|sheets|slides|mindnote|bitable|base|file)"
    +    r"/(?P<token>[A-Za-z0-9_-]{10,40})"
    +)
    +
    +_WIKI_GET_NODE_URI = "/open-apis/wiki/v2/spaces/get_node"
    +
    +
    +def _extract_docs_links(replies: List[Dict[str, Any]]) -> List[Dict[str, str]]:
    +    """Extract unique document links from a list of comment replies.
    +
    +    Returns list of ``{"url": "...", "doc_type": "...", "token": "..."}`` dicts.
    +    """
    +    seen_tokens = set()
    +    links = []
    +    for reply in replies:
    +        content = reply.get("content", {})
    +        if isinstance(content, str):
    +            try:
    +                content = json.loads(content)
    +            except (json.JSONDecodeError, TypeError):
    +                continue
    +        for elem in content.get("elements", []):
    +            if elem.get("type") not in ("docs_link", "link"):
    +                continue
    +            link_data = elem.get("docs_link") or elem.get("link") or {}
    +            url = link_data.get("url", "")
    +            if not url:
    +                continue
    +            m = _FEISHU_DOC_URL_RE.search(url)
    +            if not m:
    +                continue
    +            doc_type = m.group("doc_type")
    +            token = m.group("token")
    +            if token in seen_tokens:
    +                continue
    +            seen_tokens.add(token)
    +            links.append({"url": url, "doc_type": doc_type, "token": [redacted:secret-assignment]
    +    return links
    +
    +
    +async def _reverse_lookup_wiki_token(
    +    client: Any, obj_type: str, obj_token: str,
    +) -> Optional[str]:
    +    """Reverse-lookup: given an obj_token, find its wiki node_token.
    +
    +    Returns the wiki_token if the document belongs to a wiki space,
    +    or None if it doesn't or the API call fails.
    +    """
    +    code, msg, data = await _exec_request(
    +        client, "GET", _WIKI_GET_NODE_URI,
    +        queries=[("token", obj_token), ("obj_type", obj_type)],
    +    )
    +    if code == 0:
    +        node = data.get("node", {})
    +        wiki_token = node.get("node_token", "")
    +        return wiki_token if wiki_token else None
    +    # code != 0: either not a wiki doc or service error — log and return None
    +    logger.warning("[Feishu-Comment] Wiki reverse lookup failed: code=%s msg=%s obj=%s:%s", code, msg, obj_type, obj_token)
    +    return None
    +
    +
    +async def _resolve_wiki_nodes(
    +    client: Any,
    +    links: List[Dict[str, str]],
    +) -> List[Dict[str, str]]:
    +    """Resolve wiki links to their underlying document type and token.
    +
    +    Mutates entries in *links* in-place: replaces ``doc_type`` and ``token``
    +    with the resolved values for wiki links.  Non-wiki links are unchanged.
    +    """
    +    wiki_links = [l for l in links if l["doc_type"] == "wiki"]
    +    if not wiki_links:
    +        return links
    +
    +    for link in wiki_links:
    +        wiki_token = link["token"]
    +        code, msg, data = await _exec_request(
    +            client, "GET", _WIKI_GET_NODE_URI,
    +            queries=[("token", wiki_token)],
    +        )
    +        if code == 0:
    +            node = data.get("node", {})
    +            resolved_type = node.get("obj_type", "")
    +            resolved_token = node.get("obj_token", "")
    +            if resolved_type and resolved_token:
    +                logger.info(
    +                    "[Feishu-Comment] Wiki resolved: %s -> %s:%s",
    +                    wiki_token, resolved_type, resolved_token,
    +                )
    +                link["resolved_type"] = resolved_type
    +                link["resolved_token"] = resolved_token
    +            else:
    +                logger.warning("[Feishu-Comment] Wiki resolve returned empty: %s", wiki_token)
    +        else:
    +            logger.warning("[Feishu-Comment] Wiki resolve failed: code=%s msg=%s token=%s", code, msg, wiki_token)
    +
    +    return links
    +
    +
    +def _format_referenced_docs(
    +    links: List[Dict[str, str]], current_file_token: str = "",
    +) -> str:
    +    """Format resolved document links for prompt embedding."""
    +    if not links:
    +        return ""
    +    lines = ["", "Referenced documents in comments:"]
    +    for link in links:
    +        rtype = link.get("resolved_type", link["doc_type"])
    +        rtoken = link.get("resolved_token", link["token"])
    +        is_current = rtoken == current_file_token
    +        suffix = " (same as current document)" if is_current else ""
    +        lines.append(f"- {rtype}:{rtoken}{suffix} ({link['url'][:80]})")
    +    return "\n".join(lines)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Prompt construction
    +# ---------------------------------------------------------------------------
    +
    +_PROMPT_TEXT_LIMIT = 220
    +_LOCAL_TIMELINE_LIMIT = 20
    +_WHOLE_TIMELINE_LIMIT = 12
    +
    +
    +def _truncate(text: str, limit: int = _PROMPT_TEXT_LIMIT) -> str:
    +    """Truncate text for prompt embedding."""
    +    if len(text) <= limit:
    +        return text
    +    return text[:limit] + "..."
    +
    +
    +def _select_local_timeline(
    +    timeline: List[Tuple[str, str, bool]],
    +    target_index: int,
    +) -> List[Tuple[str, str, bool]]:
    +    """Select up to _LOCAL_TIMELINE_LIMIT entries centered on target_index.
    +
    +    Always keeps first, target, and last entries.
    +    """
    +    if len(timeline) <= _LOCAL_TIMELINE_LIMIT:
    +        return timeline
    +    n = len(timeline)
    +    selected = set()
    +    selected.add(0)                            # first
    +    selected.add(n - 1)                        # last
    +    if 0 <= target_index < n:
    +        selected.add(target_index)             # current
    +    # Expand outward from target
    +    budget = _LOCAL_TIMELINE_LIMIT - len(selected)
    +    lo, hi = target_index - 1, target_index + 1
    +    while budget > 0 and (lo >= 0 or hi < n):
    +        if lo >= 0 and lo not in selected:
    +            selected.add(lo)
    +            budget -= 1
    +        lo -= 1
    +        if budget > 0 and hi < n and hi not in selected:
    +            selected.add(hi)
    +            budget -= 1
    +        hi += 1
    +    return [timeline[i] for i in sorted(selected)]
    +
    +
    +def _select_whole_timeline(
    +    timeline: List[Tuple[str, str, bool]],
    +    current_index: int,
    +    nearest_self_index: int,
    +) -> List[Tuple[str, str, bool]]:
    +    """Select up to _WHOLE_TIMELINE_LIMIT entries for whole-doc comments.
    +
    +    Prioritizes current entry and nearest self reply.
    +    """
    +    if len(timeline) <= _WHOLE_TIMELINE_LIMIT:
    +        return timeline
    +    n = len(timeline)
    +    selected = set()
    +    if 0 <= current_index < n:
    +        selected.add(current_index)
    +    if 0 <= nearest_self_index < n:
    +        selected.add(nearest_self_index)
    +    # Expand outward from current
    +    budget = _WHOLE_TIMELINE_LIMIT - len(selected)
    +    lo, hi = current_index - 1, current_index + 1
    +    while budget > 0 and (lo >= 0 or hi < n):
    +        if lo >= 0 and lo not in selected:
    +            selected.add(lo)
    +            budget -= 1
    +        lo -= 1
    +        if budget > 0 and hi < n and hi not in selected:
    +            selected.add(hi)
    +            budget -= 1
    +        hi += 1
    +    if not selected:
    +        # Fallback: take last N entries
    +        return timeline[-_WHOLE_TIMELINE_LIMIT:]
    +    return [timeline[i] for i in sorted(selected)]
    +
    +
    +_COMMON_INSTRUCTIONS = """
    +This is a Feishu document comment thread, not an IM chat.
    +Do NOT call feishu_drive_add_comment or feishu_drive_reply_comment yourself.
    +Your reply will be posted automatically. Just output the reply text.
    +Use the thread timeline above as the main context.
    +If the quoted content is not enough, use feishu_doc_read to read nearby context.
    +The quoted content is your primary anchor — insert/summarize/explain requests are about it.
    +Do not guess document content you haven't read.
    +Reply in the same language as the user's comment unless they request otherwise.
    +Use plain text only. Do not use Markdown, headings, bullet lists, tables, or code blocks.
    +Do not show your reasoning process. Do not start with "I will", "Let me", or "I'll first".
    +Output only the final user-facing reply.
    +If no reply is needed, output exactly NO_REPLY.
    +""".strip()
    +
    +
    +def build_local_comment_prompt(
    +    *,
    +    doc_title: str,
    +    doc_url: str,
    +    file_token: str,
    +    file_type: str,
    +    comment_id: str,
    +    quote_text: str,
    +    root_comment_text: str,
    +    target_reply_text: str,
    +    timeline: List[Tuple[str, str, bool]],  # [(user_id, text, is_self)]
    +    self_open_id: str,
    +    target_index: int = -1,
    +    referenced_docs: str = "",
    +) -> str:
    +    """Build the prompt for a local (quoted-text) comment."""
    +    selected = _select_local_timeline(timeline, target_index)
    +
    +    lines = [
    +        f'The user added a reply in "{doc_title}".',
    +        f'Current user comment text: "{_truncate(target_reply_text)}"',
    +        f'Original comment text: "{_truncate(root_comment_text)}"',
    +        f'Quoted content: "{_truncate(quote_text, 500)}"',
    +        "This comment mentioned you (@mention is for routing, not task content).",
    +        f"Document link: {doc_url}",
    +        "Current commented document:",
    +        f"- file_type={file_type}",
    +        f"- file_token={file_token}",
    +        f"- comment_id={comment_id}",
    +        "",
    +        f"Current comment card timeline ({len(selected)}/{len(timeline)} entries):",
    +    ]
    +
    +    for user_id, text, is_self in selected:
    +        marker = " <-- YOU" if is_self else ""
    +        lines.append(f"[{user_id}] {_truncate(text)}{marker}")
    +
    +    if referenced_docs:
    +        lines.append(referenced_docs)
    +
    +    lines.append("")
    +    lines.append(_COMMON_INSTRUCTIONS)
    +    return "\n".join(lines)
    +
    +
    +def build_whole_comment_prompt(
    +    *,
    +    doc_title: str,
    +    doc_url: str,
    +    file_token: str,
    +    file_type: str,
    +    comment_text: str,
    +    timeline: List[Tuple[str, str, bool]],  # [(user_id, text, is_self)]
    +    self_open_id: str,
    +    current_index: int = -1,
    +    nearest_self_index: int = -1,
    +    referenced_docs: str = "",
    +) -> str:
    +    """Build the prompt for a whole-document comment."""
    +    selected = _select_whole_timeline(timeline, current_index, nearest_self_index)
    +
    +    lines = [
    +        f'The user added a comment in "{doc_title}".',
    +        f'Current user comment text: "{_truncate(comment_text)}"',
    +        "This is a whole-document comment.",
    +        "This comment mentioned you (@mention is for routing, not task content).",
    +        f"Document link: {doc_url}",
    +        "Current commented document:",
    +        f"- file_type={file_type}",
    +        f"- file_token={file_token}",
    +        "",
    +        f"Whole-document comment timeline ({len(selected)}/{len(timeline)} entries):",
    +    ]
    +
    +    for user_id, text, is_self in selected:
    +        marker = " <-- YOU" if is_self else ""
    +        lines.append(f"[{user_id}] {_truncate(text)}{marker}")
    +
    +    if referenced_docs:
    +        lines.append(referenced_docs)
    +
    +    lines.append("")
    +    lines.append(_COMMON_INSTRUCTIONS)
    +    return "\n".join(lines)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Agent execution
    +# ---------------------------------------------------------------------------
    +
    +
    +def _resolve_model_and_runtime() -> Tuple[str, dict]:
    +    """Resolve model and provider credentials, same as gateway message handling."""
    +    import os
    +    from gateway.run import _load_gateway_config, _resolve_gateway_model
    +
    +    user_config = _load_gateway_config()
    +    model = _resolve_gateway_model(user_config)
    +
    +    from gateway.run import _resolve_runtime_agent_kwargs
    +    runtime_kwargs = _resolve_runtime_agent_kwargs()
    +
    +    # Fall back to provider's default model if none configured
    +    if not model and runtime_kwargs.get("provider"):
    +        try:
    +            from hermes_cli.models import get_default_model_for_provider
    +            model = get_default_model_for_provider(runtime_kwargs["provider"])
    +        except Exception:
    +            pass
    +
    +    return model, runtime_kwargs
    +
    +
    +# ---------------------------------------------------------------------------
    +# Session cache for cross-card memory within the same document
    +# ---------------------------------------------------------------------------
    +
    +import threading
    +import time as _time
    +
    +_SESSION_MAX_MESSAGES = 50  # keep last N messages per document session
    +_SESSION_TTL_S = 3600       # expire sessions after 1 hour of inactivity
    +
    +_session_cache_lock = threading.Lock()
    +_session_cache: Dict[str, Dict] = {}  # key -> {"messages": [...], "last_access": float}
    +
    +
    +def _session_key(file_type: str, file_token: str) -> str:
    +    return f"comment-doc:{file_type}:{file_token}"
    +
    +
    +def _load_session_history(key: str) -> List[Dict[str, Any]]:
    +    """Load conversation history for a document session."""
    +    with _session_cache_lock:
    +        entry = _session_cache.get(key)
    +        if entry is None:
    +            return []
    +        # Check TTL
    +        if _time.time() - entry["last_access"] > _SESSION_TTL_S:
    +            del _session_cache[key]
    +            logger.info("[Feishu-Comment] Session expired: %s", key)
    +            return []
    +        entry["last_access"] = _time.time()
    +        return list(entry["messages"])
    +
    +
    +def _save_session_history(key: str, messages: List[Dict[str, Any]]) -> None:
    +    """Save conversation history for a document session (keeps last N messages)."""
    +    # Only keep user/assistant messages (strip system messages and tool internals)
    +    cleaned = [
    +        m for m in messages
    +        if m.get("role") in ("user", "assistant") and m.get("content")
    +    ]
    +    # Keep last N
    +    if len(cleaned) > _SESSION_MAX_MESSAGES:
    +        cleaned = cleaned[-_SESSION_MAX_MESSAGES:]
    +    with _session_cache_lock:
    +        _session_cache[key] = {
    +            "messages": cleaned,
    +            "last_access": _time.time(),
    +        }
    +        logger.info("[Feishu-Comment] Session saved: %s (%d messages)", key, len(cleaned))
    +
    +
    +def _run_comment_agent(prompt: str, client: Any, session_key: str = "") -> str:
    +    """Create an AIAgent with feishu tools and run the prompt.
    +
    +    If *session_key* is provided, loads/saves conversation history for
    +    cross-card memory within the same document.
    +
    +    Returns the agent's final response text, or empty string on failure.
    +    """
    +    from run_agent import AIAgent
    +
    +    logger.info("[Feishu-Comment] _run_comment_agent: injecting lark client into tool thread-locals")
    +    from tools.feishu_doc_tool import set_client as set_doc_client
    +    from tools.feishu_drive_tool import set_client as set_drive_client
    +    set_doc_client(client)
    +    set_drive_client(client)
    +
    +    try:
    +        model, runtime_kwargs = _resolve_model_and_runtime()
    +        logger.info("[Feishu-Comment] _run_comment_agent: model=%s provider=%s base_url=%s",
    +                    model, runtime_kwargs.get("provider"), (runtime_kwargs.get("base_url") or "")[:50])
    +
    +        # Load session history for cross-card memory
    +        history = _load_session_history(session_key) if session_key else []
    +        if history:
    +            logger.info("[Feishu-Comment] _run_comment_agent: loaded %d history messages from session %s",
    +                        len(history), session_key)
    +
    +        agent = AIAgent(
    +            model=model,
    +            base_url=runtime_kwargs.get("base_url"),
    +            api_key=runtime_kwargs.get("api_key"),
    +            provider=runtime_kwargs.get("provider"),
    +            api_mode=runtime_kwargs.get("api_mode"),
    +            credential_pool=runtime_kwargs.get("credential_pool"),
    +            quiet_mode=True,
    +            skip_context_files=True,
    +            skip_memory=True,
    +            max_iterations=15,
    +            enabled_toolsets=["feishu_doc", "feishu_drive"],
    +        )
    +        logger.info("[Feishu-Comment] _run_comment_agent: calling run_conversation (prompt=%d chars, history=%d)",
    +                    len(prompt), len(history))
    +        result = agent.run_conversation(prompt, conversation_history=history or None)
    +        response = (result.get("final_response") or "").strip()
    +        api_calls = result.get("api_calls", 0)
    +        logger.info("[Feishu-Comment] _run_comment_agent: done api_calls=%d response_len=%d response=%s",
    +                    api_calls, len(response), response[:200])
    +
    +        # Save updated history
    +        if session_key:
    +            new_messages = result.get("messages", [])
    +            if new_messages:
    +                _save_session_history(session_key, new_messages)
    +
    +        return response
    +    except Exception as e:
    +        logger.exception("[Feishu-Comment] _run_comment_agent: agent failed: %s", e)
    +        return ""
    +    finally:
    +        set_doc_client(None)
    +        set_drive_client(None)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Event handler entry point
    +# ---------------------------------------------------------------------------
    +
    +_NO_REPLY_SENTINEL = "NO_REPLY"
    +
    +
    +_ALLOWED_NOTICE_TYPES = {"add_comment", "add_reply"}
    +
    +
    +async def handle_drive_comment_event(
    +    client: Any, data: Any, *, self_open_id: str = "",
    +) -> None:
    +    """Full orchestration for a drive comment event.
    +
    +    1. Parse event + filter (self-reply, notice_type)
    +    2. Add OK reaction
    +    3. Fetch doc meta + comment details in parallel
    +    4. Branch on is_whole: build timeline
    +    5. Build prompt, run agent
    +    6. Deliver reply
    +    """
    +    logger.info("[Feishu-Comment] ========== handle_drive_comment_event START ==========")
    +    parsed = parse_drive_comment_event(data)
    +    if parsed is None:
    +        logger.warning("[Feishu-Comment] Dropping malformed drive comment event")
    +        return
    +    logger.info("[Feishu-Comment] [Step 0/5] Event parsed successfully")
    +
    +    file_token = [redacted:secret-assignment]"file_token"]
    +    file_type = parsed["file_type"]
    +    comment_id = parsed["comment_id"]
    +    reply_id = parsed["reply_id"]
    +    from_open_id = parsed["from_open_id"]
    +    to_open_id = parsed["to_open_id"]
    +    notice_type = parsed["notice_type"]
    +
    +    # Filter: self-reply, receiver check, notice_type
    +    if from_open_id and self_open_id and from_open_id == self_open_id:
    +        logger.debug("[Feishu-Comment] Skipping self-authored event: from=%s", from_open_id)
    +        return
    +    if not to_open_id or (self_open_id and to_open_id != self_open_id):
    +        logger.debug("[Feishu-Comment] Skipping event not addressed to self: to=%s", to_open_id or "(empty)")
    +        return
    +    if notice_type and notice_type not in _ALLOWED_NOTICE_TYPES:
    +        logger.debug("[Feishu-Comment] Skipping notice_type=%s", notice_type)
    +        return
    +    if not file_token or not file_type or not comment_id:
    +        logger.warning("[Feishu-Comment] Missing required fields, skipping")
    +        return
    +
    +    logger.info(
    +        "[Feishu-Comment] Event: notice=%s file=%s:%s comment=%s from=%s",
    +        notice_type, file_type, file_token, comment_id, from_open_id,
    +    )
    +
    +    # Access control
    +    from gateway.platforms.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys
    +
    +    comments_cfg = load_config()
    +    rule = resolve_rule(comments_cfg, file_type, file_token)
    +
    +    # If no exact match and config has wiki keys, try reverse-lookup
    +    if rule.match_source in ("wildcard", "top") and has_wiki_keys(comments_cfg):
    +        wiki_token = await _reverse_lookup_wiki_token(client, file_type, file_token)
    +        if wiki_token:
    +            rule = resolve_rule(comments_cfg, file_type, file_token, wiki_token=[redacted:secret-assignment]
    +
    +    if not rule.enabled:
    +        logger.info("[Feishu-Comment] Comments disabled for %s:%s, skipping", file_type, file_token)
    +        return
    +    if not is_user_allowed(rule, from_open_id):
    +        logger.info("[Feishu-Comment] User %s denied (policy=%s, rule=%s)", from_open_id, rule.policy, rule.match_source)
    +        return
    +
    +    logger.info("[Feishu-Comment] Access granted: user=%s policy=%s rule=%s", from_open_id, rule.policy, rule.match_source)
    +    if reply_id:
    +        asyncio.ensure_future(
    +            add_comment_reaction(
    +                client,
    +                file_token=[redacted:secret-assignment]
    +                file_type=file_type,
    +                reply_id=reply_id,
    +                reaction_type="OK",
    +            )
    +        )
    +
    +    # Step 2: Parallel fetch -- doc meta + comment details
    +    logger.info("[Feishu-Comment] [Step 2/5] Parallel fetch: doc meta + comment batch_query")
    +    meta_task = asyncio.ensure_future(
    +        query_document_meta(client, file_token, file_type)
    +    )
    +    comment_task = asyncio.ensure_future(
    +        batch_query_comment(client, file_token, file_type, comment_id)
    +    )
    +    doc_meta, comment_detail = await asyncio.gather(meta_task, comment_task)
    +
    +    doc_title = doc_meta.get("title", "Untitled")
    +    doc_url = doc_meta.get("url", "")
    +    is_whole = bool(comment_detail.get("is_whole"))
    +
    +    logger.info(
    +        "[Feishu-Comment] Comment context: title=%s is_whole=%s",
    +        doc_title, is_whole,
    +    )
    +
    +    # Step 3: Build timeline based on comment type
    +    logger.info("[Feishu-Comment] [Step 3/5] Building timeline (is_whole=%s)", is_whole)
    +    if is_whole:
    +        # Whole-document comment: fetch all whole comments as timeline
    +        logger.info("[Feishu-Comment] Fetching whole-document comments for timeline...")
    +        whole_comments = await list_whole_comments(client, file_token, file_type)
    +
    +        timeline: List[Tuple[str, str, bool]] = []
    +        current_text = ""
    +        current_index = -1
    +        nearest_self_index = -1
    +        for wc in whole_comments:
    +            reply_list = wc.get("reply_list", {})
    +            if isinstance(reply_list, str):
    +                try:
    +                    reply_list = json.loads(reply_list)
    +                except (json.JSONDecodeError, TypeError):
    +                    reply_list = {}
    +            replies = reply_list.get("replies", [])
    +            for r in replies:
    +                uid = _get_reply_user_id(r)
    +                text = _extract_reply_text(r)
    +                is_self = (uid == self_open_id) if self_open_id else False
    +                idx = len(timeline)
    +                timeline.append((uid, text, is_self))
    +                if uid == from_open_id:
    +                    current_text = _extract_semantic_text(r, self_open_id)
    +                    current_index = idx
    +                if is_self:
    +                    nearest_self_index = idx
    +
    +        if not current_text:
    +            for i, (uid, text, is_self) in reversed(list(enumerate(timeline))):
    +                if not is_self:
    +                    current_text = text
    +                    current_index = i
    +                    break
    +
    +        logger.info("[Feishu-Comment] Whole timeline: %d entries, current_idx=%d, self_idx=%d, text=%s",
    +                    len(timeline), current_index, nearest_self_index,
    +                    current_text[:80] if current_text else "(empty)")
    +
    +        # Extract and resolve document links from all replies
    +        all_raw_replies = []
    +        for wc in whole_comments:
    +            rl = wc.get("reply_list", {})
    +            if isinstance(rl, str):
    +                try:
    +                    rl = json.loads(rl)
    +                except (json.JSONDecodeError, TypeError):
    +                    rl = {}
    +            all_raw_replies.extend(rl.get("replies", []))
    +        doc_links = _extract_docs_links(all_raw_replies)
    +        if doc_links:
    +            doc_links = await _resolve_wiki_nodes(client, doc_links)
    +        ref_docs_text = _format_referenced_docs(doc_links, file_token)
    +
    +        prompt = build_whole_comment_prompt(
    +            doc_title=doc_title,
    +            doc_url=doc_url,
    +            file_token=[redacted:secret-assignment]
    +            file_type=file_type,
    +            comment_text=current_text,
    +            timeline=timeline,
    +            self_open_id=self_open_id,
    +            current_index=current_index,
    +            nearest_self_index=nearest_self_index,
    +            referenced_docs=ref_docs_text,
    +        )
    +
    +    else:
    +        # Local comment: fetch the comment thread replies
    +        logger.info("[Feishu-Comment] Fetching comment thread replies...")
    +        replies = await list_comment_replies(
    +            client, file_token, file_type, comment_id,
    +            expect_reply_id=reply_id,
    +        )
    +
    +        quote_text = comment_detail.get("quote", "")
    +
    +        timeline = []
    +        root_text = ""
    +        target_text = ""
    +        target_index = -1
    +        for i, r in enumerate(replies):
    +            uid = _get_reply_user_id(r)
    +            text = _extract_reply_text(r)
    +            is_self = (uid == self_open_id) if self_open_id else False
    +            timeline.append((uid, text, is_self))
    +            if i == 0:
    +                root_text = _extract_semantic_text(r, self_open_id)
    +            rid = r.get("reply_id", "")
    +            if rid and rid == reply_id:
    +                target_text = _extract_semantic_text(r, self_open_id)
    +                target_index = i
    +
    +        if not target_text and timeline:
    +            for i, (uid, text, is_self) in reversed(list(enumerate(timeline))):
    +                if uid == from_open_id:
    +                    target_text = text
    +                    target_index = i
    +                    break
    +
    +        logger.info("[Feishu-Comment] Local timeline: %d entries, target_idx=%d, quote=%s root=%s target=%s",
    +                    len(timeline), target_index,
    +                    quote_text[:60] if quote_text else "(empty)",
    +                    root_text[:60] if root_text else "(empty)",
    +                    target_text[:60] if target_text else "(empty)")
    +
    +        # Extract and resolve document links from replies
    +        doc_links = _extract_docs_links(replies)
    +        if doc_links:
    +            doc_links = await _resolve_wiki_nodes(client, doc_links)
    +        ref_docs_text = _format_referenced_docs(doc_links, file_token)
    +
    +        prompt = build_local_comment_prompt(
    +            doc_title=doc_title,
    +            doc_url=doc_url,
    +            file_token=[redacted:secret-assignment]
    +            file_type=file_type,
    +            comment_id=comment_id,
    +            quote_text=quote_text,
    +            root_comment_text=root_text,
    +            target_reply_text=target_text,
    +            timeline=timeline,
    +            self_open_id=self_open_id,
    +            target_index=target_index,
    +            referenced_docs=ref_docs_text,
    +        )
    +
    +    logger.info("[Feishu-Comment] [Step 4/5] Prompt built (%d chars), running agent...", len(prompt))
    +    logger.debug("[Feishu-Comment] Full prompt:\n%s", prompt)
    +
    +    # Step 4: Run agent in a thread (run_conversation is synchronous)
    +    # Session key groups all comment cards on the same document
    +    sess_key = _session_key(file_type, file_token)
    +    loop = asyncio.get_running_loop()
    +    response = await loop.run_in_executor(
    +        None, _run_comment_agent, prompt, client, sess_key,
    +    )
    +
    +    if not response or _NO_REPLY_SENTINEL in response:
    +        logger.info("[Feishu-Comment] Agent returned NO_REPLY, skipping delivery")
    +    else:
    +        logger.info("[Feishu-Comment] Agent response (%d chars): %s", len(response), response[:200])
    +
    +        # Step 5: Deliver reply
    +        logger.info("[Feishu-Comment] [Step 5/5] Delivering reply (is_whole=%s, comment_id=%s)", is_whole, comment_id)
    +        success = await deliver_comment_reply(
    +            client, file_token, file_type, comment_id, response, is_whole,
    +        )
    +        if success:
    +            logger.info("[Feishu-Comment] Reply delivered successfully")
    +        else:
    +            logger.error("[Feishu-Comment] Failed to deliver reply")
    +
    +    # Cleanup: remove OK reaction (best-effort, non-blocking)
    +    if reply_id:
    +        await delete_comment_reaction(
    +            client,
    +            file_token=[redacted:secret-assignment]
    +            file_type=file_type,
    +            reply_id=reply_id,
    +            reaction_type="OK",
    +        )
    +
    +    logger.info("[Feishu-Comment] ========== handle_drive_comment_event END ==========")
    ```
  - `gateway/platforms/feishu_comment_rules.py`

    ```diff
    @@ -0,0 +1,429 @@
    +"""
    +Feishu document comment access-control rules.
    +
    +3-tier rule resolution: exact doc > wildcard "*" > top-level > code defaults.
    +Each field (enabled/policy/allow_from) falls back independently.
    +Config: ~/.hermes/feishu_comment_rules.json (mtime-cached, hot-reload).
    +Pairing store: ~/.hermes/feishu_comment_pairing.json.
    +"""
    +
    +from __future__ import annotations
    +
    +import json
    +import logging
    +import time
    +from dataclasses import dataclass, field
    +from pathlib import Path
    +from typing import Any, Dict, Optional
    +
    +from hermes_constants import get_hermes_home
    +
    +logger = logging.getLogger(__name__)
    +
    +# ---------------------------------------------------------------------------
    +# Paths
    +# ---------------------------------------------------------------------------
    +#
    +# Uses the canonical ``get_hermes_home()`` helper (HERMES_HOME-aware and
    +# profile-safe). Resolved at import time; this module is lazy-imported by
    +# the Feishu comment event handler, which runs long after profile overrides
    +# have been applied, so freezing paths here is safe.
    +
    +RULES_FILE = get_hermes_home() / "feishu_comment_rules.json"
    +PAIRING_FILE = get_hermes_home() / "feishu_comment_pairing.json"
    +
    +# ---------------------------------------------------------------------------
    +# Data models
    +# ---------------------------------------------------------------------------
    +
    +_VALID_POLICIES = ("allowlist", "pairing")
    +
    +
    +@dataclass(frozen=True)
    +class CommentDocumentRule:
    +    """Per-document rule.  ``None`` means 'inherit from lower tier'."""
    +    enabled: Optional[bool] = None
    +    policy: Optional[str] = None
    +    allow_from: Optional[frozenset] = None
    +
    +
    +@dataclass(frozen=True)
    +class CommentsConfig:
    +    """Top-level comment access config."""
    +    enabled: bool = True
    +    policy: str = "pairing"
    +    allow_from: frozenset = field(default_factory=frozenset)
    +    documents: Dict[str, CommentDocumentRule] = field(default_factory=dict)
    +
    +
    +@dataclass(frozen=True)
    +class ResolvedCommentRule:
    +    """Fully resolved rule after field-by-field fallback."""
    +    enabled: bool
    +    policy: str
    +    allow_from: frozenset
    +    match_source: str  # e.g. "exact:docx:xxx" | "wildcard" | "top" | "default"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Mtime-cached file loading
    +# ---------------------------------------------------------------------------
    +
    +class _MtimeCache:
    +    """Generic mtime-based file cache.  ``stat()`` per access, re-read only on change."""
    +
    +    def __init__(self, path: Path):
    +        self._path = path
    +        self._mtime: float = 0.0
    +        self._data: Optional[dict] = None
    +
    +    def load(self) -> dict:
    +        try:
    +            st = self._path.stat()
    +            mtime = st.st_mtime
    +        except FileNotFoundError:
    +            self._mtime = 0.0
    +            self._data = {}
    +            return {}
    +
    +        if mtime == self._mtime and self._data is not None:
    +            return self._data
    +
    +        try:
    +            with open(self._path, "r", encoding="utf-8") as f:
    +                data = json.load(f)
    +            if not isinstance(data, dict):
    +                data = {}
    +        except (json.JSONDecodeError, OSError):
    +            logger.warning("[Feishu-Rules] Failed to read %s, using empty config", self._path)
    +            data = {}
    +
    +        self._mtime = mtime
    +        self._data = data
    +        return data
    +
    +
    +_rules_cache = _MtimeCache(RULES_FILE)
    +_pairing_cache = _MtimeCache(PAIRING_FILE)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Config parsing
    +# ---------------------------------------------------------------------------
    +
    +def _parse_frozenset(raw: Any) -> Optional[frozenset]:
    +    """Parse a list of strings into a frozenset; return None if key absent."""
    +    if raw is None:
    +        return None
    +    if isinstance(raw, (list, tuple)):
    +        return frozenset(str(u).strip() for u in raw if str(u).strip())
    +    return None
    +
    +
    +def _parse_document_rule(raw: dict) -> CommentDocumentRule:
    +    enabled = raw.get("enabled")
    +    if enabled is not None:
    +        enabled = bool(enabled)
    +    policy = raw.get("policy")
    +    if policy is not None:
    +        policy = str(policy).strip().lower()
    +        if policy not in _VALID_POLICIES:
    +            policy = None
    +    allow_from = _parse_frozenset(raw.get("allow_from"))
    +    return CommentDocumentRule(enabled=enabled, policy=policy, allow_from=allow_from)
    +
    +
    +def load_config() -> CommentsConfig:
    +    """Load comment rules from disk (mtime-cached)."""
    +    raw = _rules_cache.load()
    +    if not raw:
    +        return CommentsConfig()
    +
    +    documents: Dict[str, CommentDocumentRule] = {}
    +    raw_docs = raw.get("documents", {})
    +    if isinstance(raw_docs, dict):
    +        for key, rule_raw in raw_docs.items():
    +            if isinstance(rule_raw, dict):
    +                documents[str(key)] = _parse_document_rule(rule_raw)
    +
    +    policy = str(raw.get("policy", "pairing")).strip().lower()
    +    if policy not in _VALID_POLICIES:
    +        policy = "pairing"
    +
    +    return CommentsConfig(
    +        enabled=raw.get("enabled", True),
    +        policy=policy,
    +        allow_from=_parse_frozenset(raw.get("allow_from")) or frozenset(),
    +        documents=documents,
    +    )
    +
    +
    +# ---------------------------------------------------------------------------
    +# Rule resolution  (§8.4 field-by-field fallback)
    +# ---------------------------------------------------------------------------
    +
    +def has_wiki_keys(cfg: CommentsConfig) -> bool:
    +    """Check if any document rule key starts with 'wiki:'."""
    +    return any(k.startswith("wiki:") for k in cfg.documents)
    +
    +
    +def resolve_rule(
    +    cfg: CommentsConfig,
    +    file_type: str,
    +    file_token: str,
    +    wiki_token: str = "",
    +) -> ResolvedCommentRule:
    +    """Resolve effective rule: exact doc → wiki key → wildcard → top-level → defaults."""
    +    exact_key = f"{file_type}:{file_token}"
    +
    +    exact = cfg.documents.get(exact_key)
    +    exact_src = f"exact:{exact_key}"
    +    if exact is None and wiki_token:
    +        wiki_key = f"wiki:{wiki_token}"
    +        exact = cfg.documents.get(wiki_key)
    +        exact_src = f"exact:{wiki_key}"
    +
    +    wildcard = cfg.documents.get("*")
    +
    +    layers = []
    +    if exact is not None:
    +        layers.append((exact, exact_src))
    +    if wildcard is not None:
    +        layers.append((wildcard, "wildcard"))
    +
    +    def _pick(field_name: str):
    +        for layer, source in layers:
    +            val = getattr(layer, field_name)
    +            if val is not None:
    +                return val, source
    +        return getattr(cfg, field_name), "top"
    +
    +    enabled, en_src = _pick("enabled")
    +    policy, pol_src = _pick("policy")
    +    allow_from, _ = _pick("allow_from")
    +
    +    # match_source = highest-priority tier that contributed any field
    +    priority_order = {"exact": 0, "wildcard": 1, "top": 2}
    +    best_src = min(
    +        [en_src, pol_src],
    +        key=lambda s: priority_order.get(s.split(":")[0], 3),
    +    )
    +
    +    return ResolvedCommentRule(
    +        enabled=enabled,
    +        policy=policy,
    +        allow_from=allow_from,
    +        match_source=best_src,
    +    )
    +
    +
    +# ---------------------------------------------------------------------------
    +# Pairing store
    +# ---------------------------------------------------------------------------
    +
    +def _load_pairing_approved() -> set:
    +    """Return set of approved user open_ids (mtime-cached)."""
    +    data = _pairing_cache.load()
    +    approved = data.get("approved", {})
    +    if isinstance(approved, dict):
    +        return set(approved.keys())
    +    if isinstance(approved, list):
    +        return set(str(u) for u in approved if u)
    +    return set()
    +
    +
    +def _save_pairing(data: dict) -> None:
    +    PAIRING_FILE.parent.mkdir(parents=True, exist_ok=True)
    +    tmp = PAIRING_FILE.with_suffix(".tmp")
    +    with open(tmp, "w", encoding="utf-8") as f:
    +        json.dump(data, f, indent=2, ensure_ascii=False)
    +    tmp.replace(PAIRING_FILE)
    +    # Invalidate cache so next load picks up change
    +    _pairing_cache._mtime = 0.0
    +    _pairing_cache._data = None
    +
    +
    +def pairing_add(user_open_id: str) -> bool:
    +    """Add a user to the pairing-approved list. Returns True if newly added."""
    +    data = _pairing_cache.load()
    +    approved = data.get("approved", {})
    +    if not isinstance(approved, dict):
    +        approved = {}
    +    if user_open_id in approved:
    +        return False
    +    approved[user_open_id] = {"approved_at": time.time()}
    +    data["approved"] = approved
    +    _save_pairing(data)
    +    return True
    +
    +
    +def pairing_remove(user_open_id: str) -> bool:
    +    """Remove a user from the pairing-approved list. Returns True if removed."""
    +    data = _pairing_cache.load()
    +    approved = data.get("approved", {})
    +    if not isinstance(approved, dict):
    +        return False
    +    if user_open_id not in approved:
    +        return False
    +    del approved[user_open_id]
    +    data["approved"] = approved
    +    _save_pairing(data)
    +    return True
    +
    +
    +def pairing_list() -> Dict[str, Any]:
    +    """Return the approved dict  {user_open_id: {approved_at: ...}}."""
    +    data = _pairing_cache.load()
    +    approved = data.get("approved", {})
    +    return dict(approved) if isinstance(approved, dict) else {}
    +
    +
    +# ---------------------------------------------------------------------------
    +# Access check  (public API for feishu_comment.py)
    +# ---------------------------------------------------------------------------
    +
    +def is_user_allowed(rule: ResolvedCommentRule, user_open_id: str) -> bool:
    +    """Check if user passes the resolved rule's policy gate."""
    +    if user_open_id in rule.allow_from:
    +        return True
    +    if rule.policy == "pairing":
    +        return user_open_id in _load_pairing_approved()
    +    return False
    +
    +
    +# ---------------------------------------------------------------------------
    +# CLI
    +# ---------------------------------------------------------------------------
    +
    +def _print_status() -> None:
    +    cfg = load_config()
    +    print(f"Rules file: {RULES_FILE}")
    +    print(f"  exists: {RULES_FILE.exists()}")
    +    print(f"Pairing file: {PAIRING_FILE}")
    +    print(f"  exists: {PAIRING_FILE.exists()}")
    +    print()
    +    print(f"Top-level:")
    +    print(f"  enabled:    {cfg.enabled}")
    +    print(f"  policy:     {cfg.policy}")
    +    print(f"  allow_from: {sorted(cfg.allow_from) if cfg.allow_from else '[]'}")
    +    print()
    +    if cfg.documents:
    +        print(f"Document rules ({len(cfg.documents)}):")
    +        for key, rule in sorted(cfg.documents.items()):
    +            parts = []
    +            if rule.enabled is not None:
    +                parts.append(f"enabled={rule.enabled}")
    +            if rule.policy is not None:
    +                parts.append(f"policy={rule.policy}")
    +            if rule.allow_from is not None:
    +                parts.append(f"allow_from={sorted(rule.allow_from)}")
    +            print(f"  [{key}] {', '.join(parts) if parts else '(empty — inherits all)'}")
    +    else:
    +        print("Document rules: (none)")
    +    print()
    +    approved = pairing_list()
    +    print(f"Pairing approved ({len(approved)}):")
    +    for uid, meta in sorted(approved.items()):
    +        ts = meta.get("approved_at", 0)
    +        print(f"  {uid}  (approved_at={ts})")
    +
    +
    +def _do_check(doc_key: str, user_open_id: str) -> None:
    +    cfg = load_config()
    +    parts = doc_key.split(":", 1)
    +    if len(parts) != 2:
    +        print(f"Error: doc_key must be 'fileType:fileToken', got '{doc_key}'")
    +        return
    +    file_type, file_token = parts
    +    rule = resolve_rule(cfg, file_type, file_token)
    +    allowed = is_user_allowed(rule, user_open_id)
    +    print(f"Document:     {doc_key}")
    +    print(f"User:         {user_open_id}")
    +    print(f"Resolved rule:")
    +    print(f"  enabled:      {rule.enabled}")
    +    print(f"  policy:       {rule.policy}")
    +    print(f"  allow_from:   {sorted(rule.allow_from) if rule.allow_from else '[]'}")
    +    print(f"  match_source: {rule.match_source}")
    +    print(f"Result:       {'ALLOWED' if allowed else 'DENIED'}")
    +
    +
    +def _main() -> int:
    +    import sys
    +
    +    try:
    +        from hermes_cli.env_loader import load_hermes_dotenv
    +        load_hermes_dotenv()
    +    except Exception:
    +        pass
    +
    +    usage = (
    +        "Usage: python -m gateway.platforms.feishu_comment_rules <command> [args]\n"
    +        "\n"
    +        "Commands:\n"
    +        "  status                              Show rules config and pairing state\n"
    +        "  check <fileType:token> <user>        Simulate access check\n"
    +        "  pairing add <user_open_id>           Add user to pairing-approved list\n"
    +        "  pairing remove <user_open_id>        Remove user from pairing-approved list\n"
    +        "  pairing list                         List pairing-approved users\n"
    +        "\n"
    +        f"Rules config file: {RULES_FILE}\n"
    +        "  Edit this JSON file directly to configure policies and document rules.\n"
    +        "  Changes take effect on the next comment event (no restart needed).\n"
    +    )
    +
    +    args = sys.argv[1:]
    +    if not args:
    +        print(usage)
    +        return 1
    +
    +    cmd = args[0]
    +
    +    if cmd == "status":
    +        _print_status()
    +
    +    elif cmd == "check":
    +        if len(args) < 3:
    +            print("Usage: check <fileType:fileToken> <user_open_id>")
    +            return 1
    +        _do_check(args[1], args[2])
    +
    +    elif cmd == "pairing":
    +        if len(args) < 2:
    +            print("Usage: pairing <add|remove|list> [args]")
    +            return 1
    +        sub = args[1]
    +        if sub == "add":
    +            if len(args) < 3:
    +                print("Usage: pairing add <user_open_id>")
    +                return 1
    +            if pairing_add(args[2]):
    +                print(f"Added: {args[2]}")
    +            else:
    +                print(f"Already approved: {args[2]}")
    +        elif sub == "remove":
    +            if len(args) < 3:
    +                print("Usage: pairing remove <user_open_id>")
    +                return 1
    +            if pairing_remove(args[2]):
    +                print(f"Removed: {args[2]}")
    +            else:
    +                print(f"Not in approved list: {args[2]}")
    +        elif sub == "list":
    +            approved = pairing_list()
    +            if not approved:
    +                print("(no approved users)")
    +            for uid, meta in sorted(approved.items()):
    +                print(f"  {uid}  approved_at={meta.get('approved_at', '?')}")
    +        else:
    +            print(f"Unknown pairing subcommand: {sub}")
    +            return 1
    +    else:
    +        print(f"Unknown command: {cmd}\n")
    +        print(usage)
    +        return 1
    +    return 0
    +
    +
    +if __name__ == "__main__":
    +    import sys
    +    sys.exit(_main())
    ```
  - `scripts/release.py`

    ```diff
    @@ -75,6 +75,7 @@
         "Asunfly@users.noreply.github.com": "Asunfly",
         # contributors (manual mapping from git names)
         "[redacted:email]": "asheriif",
    +    "[redacted:email]": "liujinkun2025",
         "[redacted:email]": "dmahan93",
         "[redacted:email]": "samherring99",
         "[redacted:email]": "Aum08Desai",
    ```
  - `tests/gateway/test_feishu.py`

    ```diff
    @@ -601,6 +601,10 @@ def register_p2_im_message_recalled_v1(self, _handler):
                     calls.append("message_recalled")
                     return self
     
    +            def register_p2_customized_event(self, event_key, _handler):
    +                calls.append(f"customized:{event_key}")
    +                return self
    +
                 def build(self):
                     calls.append("build")
                     return "handler"
    ```

    ```diff
    @@ -628,6 +632,7 @@ def builder(_encrypt_key, _verification_token):
                     "bot_deleted",
                     "p2p_chat_entered",
                     "message_recalled",
    +                "customized:drive.notice.comment_add_v1",
                     "build",
                 ],
             )
    ```
  - `tests/gateway/test_feishu_comment.py`

    ```diff
    @@ -0,0 +1,261 @@
    +"""Tests for feishu_comment — event filtering, access control integration, wiki reverse lookup."""
    +
    +import asyncio
    +import json
    +import unittest
    +from types import SimpleNamespace
    +from unittest.mock import AsyncMock, Mock, patch
    +
    +from gateway.platforms.feishu_comment import (
    +    parse_drive_comment_event,
    +    _ALLOWED_NOTICE_TYPES,
    +    _sanitize_comment_text,
    +)
    +
    +
    +def _make_event(
    +    comment_id="c1",
    +    reply_id="r1",
    +    notice_type="add_reply",
    +    file_token="[redacted:secret-assignment]",
    +    file_type="docx",
    +    from_open_id="ou_user",
    +    to_open_id="ou_bot",
    +    is_mentioned=True,
    +):
    +    """Build a minimal drive comment event SimpleNamespace."""
    +    return SimpleNamespace(event={
    +        "event_id": "evt_1",
    +        "comment_id": comment_id,
    +        "reply_id": reply_id,
    +        "is_mentioned": is_mentioned,
    +        "timestamp": "1713200000",
    +        "notice_meta": {
    +            "file_token": [redacted:secret-assignment]
    +            "file_type": file_type,
    +            "notice_type": notice_type,
    +            "from_user_id": {"open_id": from_open_id},
    +            "to_user_id": {"open_id": to_open_id},
    +        },
    +    })
    +
    +
    +class TestParseEvent(unittest.TestCase):
    +    def test_parse_valid_event(self):
    +        evt = _make_event()
    +        parsed = parse_drive_comment_event(evt)
    +        self.assertIsNotNone(parsed)
    +        self.assertEqual(parsed["comment_id"], "c1")
    +        self.assertEqual(parsed["file_type"], "docx")
    +        self.assertEqual(parsed["from_open_id"], "ou_user")
    +        self.assertEqual(parsed["to_open_id"], "ou_bot")
    +
    +    def test_parse_missing_event_attr(self):
    +        self.assertIsNone(parse_drive_comment_event(object()))
    +
    +    def test_parse_none_event(self):
    +        self.assertIsNone(parse_drive_comment_event(SimpleNamespace()))
    +
    +
    +class TestEventFiltering(unittest.TestCase):
    +    """Test the filtering logic in handle_drive_comment_event."""
    +
    +    def _run(self, coro):
    +        return asyncio.get_event_loop().run_until_complete(coro)
    +
    +    @patch("gateway.platforms.feishu_comment_rules.load_config")
    +    @patch("gateway.platforms.feishu_comment_rules.resolve_rule")
    +    @patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
    +    def test_self_reply_filtered(self, mock_allowed, mock_resolve, mock_load):
    +        """Events where from_open_id == self_open_id should be dropped."""
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +
    +        evt = _make_event(from_open_id="ou_bot", to_open_id="ou_bot")
    +        self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
    +        mock_load.assert_not_called()
    +
    +    @patch("gateway.platforms.feishu_comment_rules.load_config")
    +    @patch("gateway.platforms.feishu_comment_rules.resolve_rule")
    +    @patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
    +    def test_wrong_receiver_filtered(self, mock_allowed, mock_resolve, mock_load):
    +        """Events where to_open_id != self_open_id should be dropped."""
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +
    +        evt = _make_event(to_open_id="ou_other_bot")
    +        self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
    +        mock_load.assert_not_called()
    +
    +    @patch("gateway.platforms.feishu_comment_rules.load_config")
    +    @patch("gateway.platforms.feishu_comment_rules.resolve_rule")
    +    @patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
    +    def test_empty_to_open_id_filtered(self, mock_allowed, mock_resolve, mock_load):
    +        """Events with empty to_open_id should be dropped."""
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +
    +        evt = _make_event(to_open_id="")
    +        self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
    +        mock_load.assert_not_called()
    +
    +    @patch("gateway.platforms.feishu_comment_rules.load_config")
    +    @patch("gateway.platforms.feishu_comment_rules.resolve_rule")
    +    @patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
    +    def test_invalid_notice_type_filtered(self, mock_allowed, mock_resolve, mock_load):
    +        """Events with unsupported notice_type should be dropped."""
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +
    +        evt = _make_event(notice_type="resolve_comment")
    +        self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
    +        mock_load.assert_not_called()
    +
    +    def test_allowed_notice_types(self):
    +        self.assertIn("add_comment", _ALLOWED_NOTICE_TYPES)
    +        self.assertIn("add_reply", _ALLOWED_NOTICE_TYPES)
    +        self.assertNotIn("resolve_comment", _ALLOWED_NOTICE_TYPES)
    +
    +
    +class TestAccessControlIntegration(unittest.TestCase):
    +    def _run(self, coro):
    +        return asyncio.get_event_loop().run_until_complete(coro)
    +
    +    @patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
    +    @patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
    +    @patch("gateway.platforms.feishu_comment_rules.resolve_rule")
    +    @patch("gateway.platforms.feishu_comment_rules.load_config")
    +    def test_denied_user_no_side_effects(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
    +        """Denied user should not trigger typing reaction or agent."""
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +        from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
    +
    +        mock_resolve.return_value = ResolvedCommentRule(True, "allowlist", frozenset(), "top")
    +        mock_load.return_value = Mock()
    +
    +        client = Mock()
    +        evt = _make_event()
    +        self._run(handle_drive_comment_event(client, evt, self_open_id="ou_bot"))
    +
    +        # No API calls should be made for denied users
    +        client.request.assert_not_called()
    +
    +    @patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
    +    @patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
    +    @patch("gateway.platforms.feishu_comment_rules.resolve_rule")
    +    @patch("gateway.platforms.feishu_comment_rules.load_config")
    +    def test_disabled_comment_skipped(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
    +        """Disabled comments should return immediately."""
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +        from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
    +
    +        mock_resolve.return_value = ResolvedCommentRule(False, "allowlist", frozenset(), "top")
    +        mock_load.return_value = Mock()
    +
    +        evt = _make_event()
    +        self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
    +        mock_allowed.assert_not_called()
    +
    +
    +class TestSanitizeCommentText(unittest.TestCase):
    +    def test_angle_brackets_escaped(self):
    +        self.assertEqual(_sanitize_comment_text("List<String>"), "List&lt;String&gt;")
    +
    +    def test_ampersand_escaped_first(self):
    +        self.assertEqual(_sanitize_comment_text("a & b"), "a &amp; b")
    +
    +    def test_ampersand_not_double_escaped(self):
    +        result = _sanitize_comment_text("a < b & c > d")
    +        self.assertEqual(result, "a &lt; b &amp; c &gt; d")
    +        self.assertNotIn("&amp;lt;", result)
    +        self.assertNotIn("&amp;gt;", result)
    +
    +    def test_plain_text_unchanged(self):
    +        self.assertEqual(_sanitize_comment_text("hello world"), "hello world")
    +
    +    def test_empty_string(self):
    +        self.assertEqual(_sanitize_comment_text(""), "")
    +
    +    def test_code_snippet(self):
    +        text = 'if (a < b && c > 0) { return "ok"; }'
    +        result = _sanitize_comment_text(text)
    +        self.assertNotIn("<", result)
    +        self.assertNotIn(">", result)
    +        self.assertIn("&lt;", result)
    +        self.assertIn("&gt;", result)
    +
    +
    +class TestWikiReverseLookup(unittest.TestCase):
    +    def _run(self, coro):
    +        return asyncio.get_event_loop().run_until_complete(coro)
    +
    +    @patch("gateway.platforms.feishu_comment._exec_request")
    +    def test_reverse_lookup_success(self, mock_exec):
    +        from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
    +
    +        mock_exec.return_value = (0, "Success", {
    +            "node": {"node_token": "[redacted:secret-assignment]", "obj_token": "[redacted:secret-assignment]"},
    +        })
    +        result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
    +        self.assertEqual(result, "WIKI_TOKEN_123")
    +        # Verify correct API params
    +        call_args = mock_exec.call_args
    +        queries = call_args[1].get("queries") or call_args[0][3]
    +        query_dict = dict(queries)
    +        self.assertEqual(query_dict["token"], "docx_abc")
    +        self.assertEqual(query_dict["obj_type"], "docx")
    +
    +    @patch("gateway.platforms.feishu_comment._exec_request")
    +    def test_reverse_lookup_not_wiki(self, mock_exec):
    +        from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
    +
    +        mock_exec.return_value = (131001, "not found", {})
    +        result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
    +        self.assertIsNone(result)
    +
    +    @patch("gateway.platforms.feishu_comment._exec_request")
    +    def test_reverse_lookup_service_error(self, mock_exec):
    +        from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
    +
    +        mock_exec.return_value = (500, "internal error", {})
    +        result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
    +        self.assertIsNone(result)
    +
    +    @patch("gateway.platforms.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock)
    +    @patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=True)
    +    @patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=True)
    +    @patch("gateway.platforms.feishu_comment_rules.resolve_rule")
    +    @patch("gateway.platforms.feishu_comment_rules.load_config")
    +    @patch("gateway.platforms.feishu_comment.add_comment_reaction", new_callable=AsyncMock)
    +    @patch("gateway.platforms.feishu_comment.batch_query_comment", new_callable=AsyncMock)
    +    @patch("gateway.platforms.feishu_comment.query_document_meta", new_callable=AsyncMock)
    +    def test_wiki_lookup_triggered_when_no_exact_match(
    +        self, mock_meta, mock_batch, mock_reaction,
    +        mock_load, mock_resolve, mock_allowed, mock_wiki_keys, mock_lookup,
    +    ):
    +        """Wiki reverse lookup should fire when rule falls to wildcard/top and wiki keys exist."""
    +        from gateway.platforms.feishu_comment import handle_drive_comment_event
    +        from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
    +
    +        # First resolve returns wildcard (no exact match), second returns exact wiki match
    +        mock_resolve.side_effect = [
    +            ResolvedCommentRule(True, "allowlist", frozenset(), "wildcard"),
    +            ResolvedCommentRule(True, "allowlist", frozenset(), "exact:wiki:WIKI123"),
    +        ]
    +        mock_load.return_value = Mock()
    +        mock_lookup.return_value = "WIKI123"
    +        mock_meta.return_value = {"title": "Test", "url": ""}
    +        mock_batch.return_value = {"is_whole": False, "quote": ""}
    +
    +        evt = _make_event()
    +        # Will proceed past access control but fail later — that's OK, we just test the lookup
    +        try:
    +            self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
    +        except Exception:
    +            pass
    +
    +        mock_lookup.assert_called_once_with(unittest.mock.ANY, "docx", "docx_token")
    +        self.assertEqual(mock_resolve.call_count, 2)
    +        # Second call should include wiki_token
    +        second_call_kwargs = mock_resolve.call_args_list[1]
    +        self.assertEqual(second_call_kwargs[1].get("wiki_token") or second_call_kwargs[0][3], "WIKI123")
    +
    +
    +if __name__ == "__main__":
    +    unittest.main()
    ```
  - `tests/gateway/test_feishu_comment_rules.py`

    ```diff
    @@ -0,0 +1,320 @@
    +"""Tests for feishu_comment_rules — 3-tier access control rule engine."""
    +
    +import json
    +import os
    +import tempfile
    +import time
    +import unittest
    +from pathlib import Path
    +from unittest.mock import patch
    +
    +from gateway.platforms.feishu_comment_rules import (
    +    CommentsConfig,
    +    CommentDocumentRule,
    +    ResolvedCommentRule,
    +    _MtimeCache,
    +    _parse_document_rule,
    +    has_wiki_keys,
    +    is_user_allowed,
    +    load_config,
    +    pairing_add,
    +    pairing_list,
    +    pairing_remove,
    +    resolve_rule,
    +)
    +
    +
    +class TestCommentDocumentRuleParsing(unittest.TestCase):
    +    def test_parse_full_rule(self):
    +        rule = _parse_document_rule({
    +            "enabled": False,
    +            "policy": "allowlist",
    +            "allow_from": ["ou_a", "ou_b"],
    +        })
    +        self.assertFalse(rule.enabled)
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertEqual(rule.allow_from, frozenset(["ou_a", "ou_b"]))
    +
    +    def test_parse_partial_rule(self):
    +        rule = _parse_document_rule({"policy": "allowlist"})
    +        self.assertIsNone(rule.enabled)
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertIsNone(rule.allow_from)
    +
    +    def test_parse_empty_rule(self):
    +        rule = _parse_document_rule({})
    +        self.assertIsNone(rule.enabled)
    +        self.assertIsNone(rule.policy)
    +        self.assertIsNone(rule.allow_from)
    +
    +    def test_invalid_policy_ignored(self):
    +        rule = _parse_document_rule({"policy": "invalid_value"})
    +        self.assertIsNone(rule.policy)
    +
    +
    +class TestResolveRule(unittest.TestCase):
    +    def test_exact_match(self):
    +        cfg = CommentsConfig(
    +            policy="pairing",
    +            allow_from=frozenset(["ou_top"]),
    +            documents={
    +                "docx:abc": CommentDocumentRule(policy="allowlist"),
    +            },
    +        )
    +        rule = resolve_rule(cfg, "docx", "abc")
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertTrue(rule.match_source.startswith("exact:"))
    +
    +    def test_wildcard_match(self):
    +        cfg = CommentsConfig(
    +            policy="pairing",
    +            documents={
    +                "*": CommentDocumentRule(policy="allowlist"),
    +            },
    +        )
    +        rule = resolve_rule(cfg, "docx", "unknown")
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertEqual(rule.match_source, "wildcard")
    +
    +    def test_top_level_fallback(self):
    +        cfg = CommentsConfig(policy="pairing", allow_from=frozenset(["ou_top"]))
    +        rule = resolve_rule(cfg, "docx", "whatever")
    +        self.assertEqual(rule.policy, "pairing")
    +        self.assertEqual(rule.allow_from, frozenset(["ou_top"]))
    +        self.assertEqual(rule.match_source, "top")
    +
    +    def test_exact_overrides_wildcard(self):
    +        cfg = CommentsConfig(
    +            policy="pairing",
    +            documents={
    +                "*": CommentDocumentRule(policy="pairing"),
    +                "docx:abc": CommentDocumentRule(policy="allowlist"),
    +            },
    +        )
    +        rule = resolve_rule(cfg, "docx", "abc")
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertTrue(rule.match_source.startswith("exact:"))
    +
    +    def test_field_by_field_fallback(self):
    +        """Exact sets policy, wildcard sets allow_from, enabled from top."""
    +        cfg = CommentsConfig(
    +            enabled=True,
    +            policy="pairing",
    +            allow_from=frozenset(["ou_top"]),
    +            documents={
    +                "*": CommentDocumentRule(allow_from=frozenset(["ou_wildcard"])),
    +                "docx:abc": CommentDocumentRule(policy="allowlist"),
    +            },
    +        )
    +        rule = resolve_rule(cfg, "docx", "abc")
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertEqual(rule.allow_from, frozenset(["ou_wildcard"]))
    +        self.assertTrue(rule.enabled)
    +
    +    def test_explicit_empty_allow_from_does_not_fall_through(self):
    +        """allow_from=[] on exact should NOT inherit from wildcard or top."""
    +        cfg = CommentsConfig(
    +            allow_from=frozenset(["ou_top"]),
    +            documents={
    +                "*": CommentDocumentRule(allow_from=frozenset(["ou_wildcard"])),
    +                "docx:abc": CommentDocumentRule(
    +                    policy="allowlist",
    +                    allow_from=frozenset(),
    +                ),
    +            },
    +        )
    +        rule = resolve_rule(cfg, "docx", "abc")
    +        self.assertEqual(rule.allow_from, frozenset())
    +
    +    def test_wiki_token_match(self):
    +        cfg = CommentsConfig(
    +            policy="pairing",
    +            documents={
    +                "wiki:WIKI123": CommentDocumentRule(policy="allowlist"),
    +            },
    +        )
    +        rule = resolve_rule(cfg, "docx", "obj_token", wiki_token="[redacted:secret-assignment]")
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertTrue(rule.match_source.startswith("exact:wiki:"))
    +
    +    def test_exact_takes_priority_over_wiki(self):
    +        cfg = CommentsConfig(
    +            documents={
    +                "docx:abc": CommentDocumentRule(policy="allowlist"),
    +                "wiki:WIKI123": CommentDocumentRule(policy="pairing"),
    +            },
    +        )
    +        rule = resolve_rule(cfg, "docx", "abc", wiki_token="[redacted:secret-assignment]")
    +        self.assertEqual(rule.policy, "allowlist")
    +        self.assertTrue(rule.match_source.startswith("exact:docx:"))
    +
    +    def test_default_config(self):
    +        cfg = CommentsConfig()
    +        rule = resolve_rule(cfg, "docx", "anything")
    +        self.assertTrue(rule.enabled)
    +        self.assertEqual(rule.policy, "pairing")
    +        self.assertEqual(rule.allow_from, frozenset())
    +
    +
    +class TestHasWikiKeys(unittest.TestCase):
    +    def test_no_wiki_keys(self):
    +        cfg = CommentsConfig(documents={
    +            "docx:abc": CommentDocumentRule(policy="allowlist"),
    +            "*": CommentDocumentRule(policy="pairing"),
    +        })
    +        self.assertFalse(has_wiki_keys(cfg))
    +
    +    def test_has_wiki_keys(self):
    +        cfg = CommentsConfig(documents={
    +            "wiki:WIKI123": CommentDocumentRule(policy="allowlist"),
    +        })
    +        self.assertTrue(has_wiki_keys(cfg))
    +
    +    def test_empty_documents(self):
    +        cfg = CommentsConfig()
    +        self.assertFalse(has_wiki_keys(cfg))
    +
    +
    +class TestIsUserAllowed(unittest.TestCase):
    +    def test_allowlist_allows_listed(self):
    +        rule = ResolvedCommentRule(True, "allowlist", frozenset(["ou_a"]), "top")
    +        self.assertTrue(is_user_allowed(rule, "ou_a"))
    +
    +    def test_allowlist_denies_unlisted(self):
    +        rule = ResolvedCommentRule(True, "allowlist", frozenset(["ou_a"]), "top")
    +        self.assertFalse(is_user_allowed(rule, "ou_b"))
    +
    +    def test_allowlist_empty_denies_all(self):
    +        rule = ResolvedCommentRule(True, "allowlist", frozenset(), "top")
    +        self.assertFalse(is_user_allowed(rule, "ou_anyone"))
    +
    +    def test_pairing_allows_in_allow_from(self):
    +        rule = ResolvedCommentRule(True, "pairing", frozenset(["ou_a"]), "top")
    +        self.assertTrue(is_user_allowed(rule, "ou_a"))
    +
    +    def test_pairing_checks_store(self):
    +        rule = ResolvedCommentRule(True, "pairing", frozenset(), "top")
    +        with patch(
    +            "gateway.platforms.feishu_comment_rules._load_pairing_approved",
    +            return_value={"ou_approved"},
    +        ):
    +            self.assertTrue(is_user_allowed(rule, "ou_approved"))
    +            self.assertFalse(is_user_allowed(rule, "ou_unknown"))
    +
    +
    +class TestMtimeCache(unittest.TestCase):
    +    def test_returns_empty_dict_for_missing_file(self):
    +        cache = _MtimeCache(Path("/nonexistent/path.json"))
    +        self.assertEqual(cache.load(), {})
    +
    +    def test_reads_file_and_caches(self):
    +        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
    +            json.dump({"key": "value"}, f)
    +            f.flush()
    +            path = Path(f.name)
    +        try:
    +            cache = _MtimeCache(path)
    +            data = cache.load()
    +            self.assertEqual(data, {"key": "value"})
    +            # Second load should use cache (same mtime)
    +            data2 = cache.load()
    +            self.assertEqual(data2, {"key": "value"})
    +        finally:
    +            path.unlink()
    +
    +    def test_reloads_on_mtime_change(self):
    +        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
    +            json.dump({"v": 1}, f)
    +            f.flush()
    +            path = Path(f.name)
    +        try:
    +            cache = _MtimeCache(path)
    +            self.assertEqual(cache.load(), {"v": 1})
    +            # Modify file
    +            time.sleep(0.05)
    +            with open(path, "w") as f2:
    +                json.dump({"v": 2}, f2)
    +            # Force mtime change detection
    +            os.utime(path, (time.time() + 1, time.time() + 1))
    +            self.assertEqual(cache.load(), {"v": 2})
    +        finally:
    +            path.unlink()
    +
    +
    +class TestLoadConfig(unittest.TestCase):
    +    def test_load_with_documents(self):
    +        raw = {
    +            "enabled": True,
    +            "policy": "allowlist",
    +            "allow_from": ["ou_a"],
    +            "documents": {
    +                "*": {"policy": "pairing"},
    +                "docx:abc": {"policy": "allowlist", "allow_from": ["ou_b"]},
    +            },
    +        }
    +        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
    +            json.dump(raw, f)
    +            path = Path(f.name)
    +        try:
    +            with patch("gateway.platforms.feishu_comment_rules.RULES_FILE", path):
    +                with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(path)):
    +                    cfg = load_config()
    +            self.assertTrue(cfg.enabled)
    +            self.assertEqual(cfg.policy, "allowlist")
    +            self.assertEqual(cfg.allow_from, frozenset(["ou_a"]))
    +            self.assertIn("*", cfg.documents)
    +            self.assertIn("docx:abc", cfg.documents)
    +            self.assertEqual(cfg.documents["docx:abc"].policy, "allowlist")
    +        finally:
    +            path.unlink()
    +
    +    def test_load_missing_file_returns_defaults(self):
    +        with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))):
    +            cfg = load_config()
    +        self.assertTrue(cfg.enabled)
    +        self.assertEqual(cfg.policy, "pairing")
    +        self.assertEqual(cfg.allow_from, frozenset())
    +        self.assertEqual(cfg.documents, {})
    +
    +
    +class TestPairingStore(unittest.TestCase):
    +    def setUp(self):
    +        self._tmpdir = tempfile.mkdtemp()
    +        self._pairing_file = Path(self._tmpdir) / "pairing.json"
    +        with open(self._pairing_file, "w") as f:
    +            json.dump({"approved": {}}, f)
    +        self._patcher_file = patch("gateway.platforms.feishu_comment_rules.PAIRING_FILE", self._pairing_file)
    +        self._patcher_cache = patch(
    +            "gateway.platforms.feishu_comment_rules._pairing_cache",
    +            _MtimeCache(self._pairing_file),
    +        )
    +        self._patcher_file.start()
    +        self._patcher_cache.start()
    +
    +    def tearDown(self):
    +        self._patcher_cache.stop()
    +        self._patcher_file.stop()
    +        if self._pairing_file.exists():
    +            self._pairing_file.unlink()
    +        os.rmdir(self._tmpdir)
    +
    +    def test_add_and_list(self):
    +        self.assertTrue(pairing_add("ou_new"))
    +        approved = pairing_list()
    +        self.assertIn("ou_new", approved)
    +
    +    def test_add_duplicate(self):
    +        pairing_add("ou_a")
    +        self.assertFalse(pairing_add("ou_a"))
    +
    +    def test_remove(self):
    +        pairing_add("ou_a")
    +        self.assertTrue(pairing_remove("ou_a"))
    +        self.assertNotIn("ou_a", pairing_list())
    +
    +    def test_remove_nonexistent(self):
    +        self.assertFalse(pairing_remove("ou_nobody"))
    +
    +
    +if __name__ == "__main__":
    +    unittest.main()
    ```
  - `tests/tools/test_feishu_tools.py`

    ```diff
    @@ -0,0 +1,62 @@
    +"""Tests for feishu_doc_tool and feishu_drive_tool — registration and schema validation."""
    +
    +import importlib
    +import unittest
    +
    +from tools.registry import registry
    +
    +# Trigger tool discovery so feishu tools get registered
    +importlib.import_module("tools.feishu_doc_tool")
    +importlib.import_module("tools.feishu_drive_tool")
    +
    +
    +class TestFeishuToolRegistration(unittest.TestCase):
    +    """Verify feishu tools are registered and have valid schemas."""
    +
    +    EXPECTED_TOOLS = {
    +        "feishu_doc_read": "feishu_doc",
    +        "feishu_drive_list_comments": "feishu_drive",
    +        "feishu_drive_list_comment_replies": "feishu_drive",
    +        "feishu_drive_reply_comment": "feishu_drive",
    +        "feishu_drive_add_comment": "feishu_drive",
    +    }
    +
    +    def test_all_tools_registered(self):
    +        for tool_name, toolset in self.EXPECTED_TOOLS.items():
    +            entry = registry.get_entry(tool_name)
    +            self.assertIsNotNone(entry, f"{tool_name} not registered")
    +            self.assertEqual(entry.toolset, toolset)
    +
    +    def test_schemas_have_required_fields(self):
    +        for tool_name in self.EXPECTED_TOOLS:
    +            entry = registry.get_entry(tool_name)
    +            schema = entry.schema
    +            self.assertIn("name", schema)
    +            self.assertEqual(schema["name"], tool_name)
    +            self.assertIn("description", schema)
    +            self.assertIn("parameters", schema)
    +            self.assertIn("type", schema["parameters"])
    +            self.assertEqual(schema["parameters"]["type"], "object")
    +
    +    def test_handlers_are_callable(self):
    +        for tool_name in self.EXPECTED_TOOLS:
    +            entry = registry.get_entry(tool_name)
    +            self.assertTrue(callable(entry.handler))
    +
    +    def test_doc_read_schema_params(self):
    +        entry = registry.get_entry("feishu_doc_read")
    +        props = entry.schema["parameters"].get("properties", {})
    +        self.assertIn("doc_token", props)
    +
    +    def test_drive_tools_require_file_token(self):
    +        for tool_name in self.EXPECTED_TOOLS:
    +            if tool_name == "feishu_doc_read":
    +                continue
    +            entry = registry.get_entry(tool_name)
    +            props = entry.schema["parameters"].get("properties", {})
    +            self.assertIn("file_token", props, f"{tool_name} missing file_token param")
    +            self.assertIn("file_type", props, f"{tool_name} missing file_type param")
    +
    +
    +if __name__ == "__main__":
    +    unittest.main()
    ```
  - `tools/feishu_doc_tool.py`

    ```diff
    @@ -0,0 +1,131 @@
    +"""Feishu Document Tool -- read document content via Feishu/Lark API.
    +
    +Provides ``feishu_doc_read`` for reading document content as plain text.
    +Uses the same lazy-import + BaseRequest pattern as feishu_comment.py.
    +"""
    +
    +import json
    +import logging
    +import threading
    +
    +from tools.registry import registry, tool_error, tool_result
    +
    +logger = logging.getLogger(__name__)
    +
    +# Thread-local storage for the lark client injected by feishu_comment handler.
    +_local = threading.local()
    +
    +
    +def set_client(client):
    +    """Store a lark client for the current thread (called by feishu_comment)."""
    +    _local.client = client
    +
    +
    +def get_client():
    +    """Return the lark client for the current thread, or None."""
    +    return getattr(_local, "client", None)
    +
    +
    +# ---------------------------------------------------------------------------
    +# feishu_doc_read
    +# ---------------------------------------------------------------------------
    +
    +_RAW_CONTENT_URI = "/open-apis/docx/v1/documents/:document_id/raw_content"
    +
    +FEISHU_DOC_READ_SCHEMA = {
    +    "name": "feishu_doc_read",
    +    "description": (
    +        "Read the full content of a Feishu/Lark document as plain text. "
    +        "Useful when you need more context beyond the quoted text in a comment."
    +    ),
    +    "parameters": {
    +        "type": "object",
    +        "properties": {
    +            "doc_token": {
    +                "type": "string",
    +                "description": "The document token (from the document URL or comment context).",
    +            },
    +        },
    +        "required": ["doc_token"],
    +    },
    +}
    +
    +
    +def _check_feishu():
    +    try:
    +        import lark_oapi  # noqa: F401
    +        return True
    +    except ImportError:
    +        return False
    +
    +
    +def _handle_feishu_doc_read(args: dict, **kwargs) -> str:
    +    doc_token = args.get("doc_token", "").strip()
    +    if not doc_token:
    +        return tool_error("doc_token is required")
    +
    +    client = get_client()
    +    if client is None:
    +        return tool_error("Feishu client not available (not in a Feishu comment context)")
    +
    +    try:
    +        from lark_oapi import AccessTokenType
    +        from lark_oapi.core.enum import HttpMethod
    +        from lark_oapi.core.model.base_request import BaseRequest
    +    except ImportError:
    +        return tool_error("lark_oapi not installed")
    +
    +    request = (
    +        BaseRequest.builder()
    +        .http_method(HttpMethod.GET)
    +        .uri(_RAW_CONTENT_URI)
    +        .token_types({AccessTokenType.TENANT})
    +        .paths({"document_id": doc_token})
    +        .build()
    +    )
    +
    +    # Tool handlers run synchronously in a worker thread (no running event
    +    # loop), so call the blocking lark client directly.
    +    response = client.request(request)
    +
    +    code = getattr(response, "code", None)
    +    if code != 0:
    +        msg = getattr(response, "msg", "unknown error")
    +        return tool_error(f"Failed to read document: code={code} msg={msg}")
    +
    +    raw = getattr(response, "raw", None)
    +    if raw and hasattr(raw, "content"):
    +        try:
    +            body = json.loads(raw.content)
    +            content = body.get("data", {}).get("content", "")
    +            return tool_result(success=True, content=content)
    +        except (json.JSONDecodeError, AttributeError):
    +            pass
    +
    +    # Fallback: try response.data
    +    data = getattr(response, "data", None)
    +    if data:
    +        if isinstance(data, dict):
    +            content = data.get("content", "")
    +        else:
    +            content = getattr(data, "content", str(data))
    +        return tool_result(success=True, content=content)
    +
    +    return tool_error("No content returned from document API")
    +
    +
    +# ---------------------------------------------------------------------------
    +# Registration
    +# ---------------------------------------------------------------------------
    +
    +registry.register(
    +    name="feishu_doc_read",
    +    toolset="feishu_doc",
    +    schema=FEISHU_DOC_READ_SCHEMA,
    +    handler=_handle_feishu_doc_read,
    +    check_fn=_check_feishu,
    +    requires_env=[],
    +    is_async=False,
    +    description="Read Feishu document content",
    +    emoji="\U0001f4c4",
    +)
    ```
  - `tools/feishu_drive_tool.py`

    ```diff
    @@ -0,0 +1,429 @@
    +"""Feishu Drive Tools -- document comment operations via Feishu/Lark API.
    +
    +Provides tools for listing, replying to, and adding document comments.
    +Uses the same lazy-import + BaseRequest pattern as feishu_comment.py.
    +The lark client is injected per-thread by the comment event handler.
    +"""
    +
    +import json
    +import logging
    +import threading
    +
    +from tools.registry import registry, tool_error, tool_result
    +
    +logger = logging.getLogger(__name__)
    +
    +# Thread-local storage for the lark client injected by feishu_comment handler.
    +_local = threading.local()
    +
    +
    +def set_client(client):
    +    """Store a lark client for the current thread (called by feishu_comment)."""
    +    _local.client = client
    +
    +
    +def get_client():
    +    """Return the lark client for the current thread, or None."""
    +    return getattr(_local, "client", None)
    +
    +
    +def _check_feishu():
    +    try:
    +        import lark_oapi  # noqa: F401
    +        return True
    +    except ImportError:
    +        return False
    +
    +
    +def _do_request(client, method, uri, paths=None, queries=None, body=None):
    +    """Build and execute a BaseRequest, return (code, msg, data_dict)."""
    +    from lark_oapi import AccessTokenType
    +    from lark_oapi.core.enum import HttpMethod
    +    from lark_oapi.core.model.base_request import BaseRequest
    +
    +    http_method = HttpMethod.GET if method == "GET" else HttpMethod.POST
    +
    +    builder = (
    +        BaseRequest.builder()
    +        .http_method(http_method)
    +        .uri(uri)
    +        .token_types({AccessTokenType.TENANT})
    +    )
    +    if paths:
    +        builder = builder.paths(paths)
    +    if queries:
    +        builder = builder.queries(queries)
    +    if body is not None:
    +        builder = builder.body(body)
    +
    +    request = builder.build()
    +
    +    # Tool handlers run synchronously in a worker thread (no running event
    +    # loop), so call the blocking lark client directly.
    +    response = client.request(request)
    +
    +    code = getattr(response, "code", None)
    +    msg = getattr(response, "msg", "")
    +
    +    # Parse response data
    +    data = {}
    +    raw = getattr(response, "raw", None)
    +    if raw and hasattr(raw, "content"):
    +        try:
    +            body_json = json.loads(raw.content)
    +            data = body_json.get("data", {})
    +        except (json.JSONDecodeError, AttributeError):
    +            pass
    +    if not data:
    +        resp_data = getattr(response, "data", None)
    +        if isinstance(resp_data, dict):
    +            data = resp_data
    +        elif resp_data and hasattr(resp_data, "__dict__"):
    +            data = vars(resp_data)
    +
    +    return code, msg, data
    +
    +
    +# ---------------------------------------------------------------------------
    +# feishu_drive_list_comments
    +# ---------------------------------------------------------------------------
    +
    +_LIST_COMMENTS_URI = "/open-apis/drive/v1/files/:file_token/comments"
    +
    +FEISHU_DRIVE_LIST_COMMENTS_SCHEMA = {
    +    "name": "feishu_drive_list_comments",
    +    "description": (
    +        "List comments on a Feishu document. "
    +        "Use is_whole=true to list whole-document comments only."
    +    ),
    +    "parameters": {
    +        "type": "object",
    +        "properties": {
    +            "file_token": {
    +                "type": "string",
    +                "description": "The document file token.",
    +            },
    +            "file_type": {
    +                "type": "string",
    +                "description": "File type (default: docx).",
    +                "default": "docx",
    +            },
    +            "is_whole": {
    +                "type": "boolean",
    +                "description": "If true, only return whole-document comments.",
    +                "default": False,
    +            },
    +            "page_size": {
    +                "type": "integer",
    +                "description": "Number of comments per page (max 100).",
    +                "default": 100,
    +            },
    +            "page_token": {
    +                "type": "string",
    +                "description": "Pagination token for next page.",
    +            },
    +        },
    +        "required": ["file_token"],
    +    },
    +}
    +
    +
    +def _handle_list_comments(args: dict, **kwargs) -> str:
    +    client = get_client()
    +    if client is None:
    +        return tool_error("Feishu client not available")
    +
    +    file_token = args.get("file_token", "").strip()
    +    if not file_token:
    +        return tool_error("file_token is required")
    +
    +    file_type = args.get("file_type", "docx") or "docx"
    +    is_whole = args.get("is_whole", False)
    +    page_size = args.get("page_size", 100)
    +    page_token = args.get("page_token", "")
    +
    +    queries = [
    +        ("file_type", file_type),
    +        ("user_id_type", "open_id"),
    +        ("page_size", str(page_size)),
    +    ]
    +    if is_whole:
    +        queries.append(("is_whole", "true"))
    +    if page_token:
    +        queries.append(("page_token", page_token))
    +
    +    code, msg, data = _do_request(
    +        client, "GET", _LIST_COMMENTS_URI,
    +        paths={"file_token": [redacted:secret-assignment]
    +        queries=queries,
    +    )
    +    if code != 0:
    +        return tool_error(f"List comments failed: code={code} msg={msg}")
    +
    +    return tool_result(data)
    +
    +
    +# ---------------------------------------------------------------------------
    +# feishu_drive_list_comment_replies
    +# ---------------------------------------------------------------------------
    +
    +_LIST_REPLIES_URI = "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies"
    +
    +FEISHU_DRIVE_LIST_REPLIES_SCHEMA = {
    +    "name": "feishu_drive_list_comment_replies",
    +    "description": "List all replies in a comment thread on a Feishu document.",
    +    "parameters": {
    +        "type": "object",
    +        "properties": {
    +            "file_token": {
    +                "type": "string",
    +                "description": "The document file token.",
    +            },
    +            "comment_id": {
    +                "type": "string",
    +                "description": "The comment ID to list replies for.",
    +            },
    +            "file_type": {
    +                "type": "string",
    +                "description": "File type (default: docx).",
    +                "default": "docx",
    +            },
    +            "page_size": {
    +                "type": "integer",
    +                "description": "Number of replies per page (max 100).",
    +                "default": 100,
    +            },
    +            "page_token": {
    +                "type": "string",
    +                "description": "Pagination token for next page.",
    +            },
    +        },
    +        "required": ["file_token", "comment_id"],
    +    },
    +}
    +
    +
    +def _handle_list_replies(args: dict, **kwargs) -> str:
    +    client = get_client()
    +    if client is None:
    +        return tool_error("Feishu client not available")
    +
    +    file_token = args.get("file_token", "").strip()
    +    comment_id = args.get("comment_id", "").strip()
    +    if not file_token or not comment_id:
    +        return tool_error("file_token and comment_id are required")
    +
    +    file_type = args.get("file_type", "docx") or "docx"
    +    page_size = args.get("page_size", 100)
    +    page_token = args.get("page_token", "")
    +
    +    queries = [
    +        ("file_type", file_type),
    +        ("user_id_type", "open_id"),
    +        ("page_size", str(page_size)),
    +    ]
    +    if page_token:
    +        queries.append(("page_token", page_token))
    +
    +    code, msg, data = _do_request(
    +        client, "GET", _LIST_REPLIES_URI,
    +        paths={"file_token": [redacted:secret-assignment] "comment_id": comment_id},
    +        queries=queries,
    +    )
    +    if code != 0:
    +        return tool_error(f"List replies failed: code={code} msg={msg}")
    +
    +    return tool_result(data)
    +
    +
    +# ---------------------------------------------------------------------------
    +# feishu_drive_reply_comment
    +# ---------------------------------------------------------------------------
    +
    +_REPLY_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies"
    +
    +FEISHU_DRIVE_REPLY_SCHEMA = {
    +    "name": "feishu_drive_reply_comment",
    +    "description": (
    +        "Reply to a local comment thread on a Feishu document. "
    +        "Use this for local (quoted-text) comments. "
    +        "For whole-document comments, use feishu_drive_add_comment instead."
    +    ),
    +    "parameters": {
    +        "type": "object",
    +        "properties": {
    +            "file_token": {
    +                "type": "string",
    +                "description": "The document file token.",
    +            },
    +            "comment_id": {
    +                "type": "string",
    +                "description": "The comment ID to reply to.",
    +            },
    +            "content": {
    +                "type": "string",
    +                "description": "The reply text content (plain text only, no markdown).",
    +            },
    +            "file_type": {
    +                "type": "string",
    +                "description": "File type (default: docx).",
    +                "default": "docx",
    +            },
    +        },
    +        "required": ["file_token", "comment_id", "content"],
    +    },
    +}
    +
    +
    +def _handle_reply_comment(args: dict, **kwargs) -> str:
    +    client = get_client()
    +    if client is None:
    +        return tool_error("Feishu client not available")
    +
    +    file_token = args.get("file_token", "").strip()
    +    comment_id = args.get("comment_id", "").strip()
    +    content = args.get("content", "").strip()
    +    if not file_token or not comment_id or not content:
    +        return tool_error("file_token, comment_id, and content are required")
    +
    +    file_type = args.get("file_type", "docx") or "docx"
    +
    +    body = {
    +        "content": {
    +            "elements": [
    +                {
    +                    "type": "text_run",
    +                    "text_run": {"text": content},
    +                }
    +            ]
    +        }
    +    }
    +
    +    code, msg, data = _do_request(
    +        client, "POST", _REPLY_COMMENT_URI,
    +        paths={"file_token": [redacted:secret-assignment] "comment_id": comment_id},
    +        queries=[("file_type", file_type)],
    +        body=body,
    +    )
    +    if code != 0:
    +        return tool_error(f"Reply comment failed: code={code} msg={msg}")
    +
    +    return tool_result(success=True, data=data)
    +
    +
    +# ---------------------------------------------------------------------------
    +# feishu_drive_add_comment
    +# ---------------------------------------------------------------------------
    +
    +_ADD_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/new_comments"
    +
    +FEISHU_DRIVE_ADD_COMMENT_SCHEMA = {
    +    "name": "feishu_drive_add_comment",
    +    "description": (
    +        "Add a new whole-document comment on a Feishu document. "
    +        "Use this for whole-document comments or as a fallback when "
    +        "reply_comment fails with code 1069302."
    +    ),
    +    "parameters": {
    +        "type": "object",
    +        "properties": {
    +            "file_token": {
    +                "type": "string",
    +                "description": "The document file token.",
    +            },
    +            "content": {
    +                "type": "string",
    +                "description": "The comment text content (plain text only, no markdown).",
    +            },
    +            "file_type": {
    +                "type": "string",
    +                "description": "File type (default: docx).",
    +                "default": "docx",
    +            },
    +        },
    +        "required": ["file_token", "content"],
    +    },
    +}
    +
    +
    +def _handle_add_comment(args: dict, **kwargs) -> str:
    +    client = get_client()
    +    if client is None:
    +        return tool_error("Feishu client not available")
    +
    +    file_token = args.get("file_token", "").strip()
    +    content = args.get("content", "").strip()
    +    if not file_token or not content:
    +        return tool_error("file_token and content are required")
    +
    +    file_type = args.get("file_type", "docx") or "docx"
    +
    +    body = {
    +        "file_type": file_type,
    +        "reply_elements": [
    +            {"type": "text", "text": content},
    +        ],
    +    }
    +
    +    code, msg, data = _do_request(
    +        client, "POST", _ADD_COMMENT_URI,
    +        paths={"file_token": [redacted:secret-assignment]
    +        body=body,
    +    )
    +    if code != 0:
    +        return tool_error(f"Add comment failed: code={code} msg={msg}")
    +
    +    return tool_result(success=True, data=data)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Registration
    +# ---------------------------------------------------------------------------
    +
    +registry.register(
    +    name="feishu_drive_list_comments",
    +    toolset="feishu_drive",
    +    schema=FEISHU_DRIVE_LIST_COMMENTS_SCHEMA,
    +    handler=_handle_list_comments,
    +    check_fn=_check_feishu,
    +    requires_env=[],
    +    is_async=False,
    +    description="List document comments",
    +    emoji="\U0001f4ac",
    +)
    +
    +registry.register(
    +    name="feishu_drive_list_comment_replies",
    +    toolset="feishu_drive",
    +    schema=FEISHU_DRIVE_LIST_REPLIES_SCHEMA,
    +    handler=_handle_list_replies,
    +    check_fn=_check_feishu,
    +    requires_env=[],
    +    is_async=False,
    +    description="List comment replies",
    +    emoji="\U0001f4ac",
    +)
    +
    +registry.register(
    +    name="feishu_drive_reply_comment",
    +    toolset="feishu_drive",
    +    schema=FEISHU_DRIVE_REPLY_SCHEMA,
    +    handler=_handle_reply_comment,
    +    check_fn=_check_feishu,
    +    requires_env=[],
    +    is_async=False,
    +    description="Reply to a document comment",
    +    emoji="\u2709\ufe0f",
    +)
    +
    +registry.register(
    +    name="feishu_drive_add_comment",
    +    toolset="feishu_drive",
    +    schema=FEISHU_DRIVE_ADD_COMMENT_SCHEMA,
    +    handler=_handle_add_comment,
    +    check_fn=_check_feishu,
    +    requires_env=[],
    +    is_async=False,
    +    description="Add a whole-document comment",
    +    emoji="\u2709\ufe0f",
    +)
    ```
  - `toolsets.py`

    ```diff
    @@ -201,6 +201,21 @@
             "includes": []
         },
     
    +    "feishu_doc": {
    +        "description": "Read Feishu/Lark document content",
    +        "tools": ["feishu_doc_read"],
    +        "includes": []
    +    },
    +
    +    "feishu_drive": {
    +        "description": "Feishu/Lark document comment operations (list, reply, add)",
    +        "tools": [
    +            "feishu_drive_list_comments", "feishu_drive_list_comment_replies",
    +            "feishu_drive_reply_comment", "feishu_drive_add_comment",
    +        ],
    +        "includes": []
    +    },
    +
     
         # Scenario-specific toolsets
         
    ```

## Fix status

- Merged to main in PR #11898 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-18`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
