/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-28-_RECEIPT_REASON = "handled"
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-29-_RECOVERY_STAGE = "recovery_receipt"
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-30-_RECOVERY_REASON = "business_commit_reconciled"
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-31-_FAILURE_STAGE = "blocked_receipt"
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-32-_VALID_REASONS = frozenset(
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-33-    {
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-34-        "business_commit_reconciled",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-35-        "duplicate_replay",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py:36:        "handler_exception",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-37-        "receipt_store_corrupt",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-38-        "receipt_lock_failed",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-39-        "receipt_write_failed",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-40-        "recovery_authority_mismatch",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-41-    }
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-42-)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-43-
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-44-
--
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-113-                    frozenset(item for item in state.pending if item != update_id),
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-114-                    recovered,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-115-                    state.failed,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-116-                ),
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-117-            )
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-118-
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-119-    def record_failed(self, update_id: int, reason_code: str) -> None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-120-        """Persist a handler failure so restart recovery cannot overwrite it."""
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py:121:        if type(update_id) is not int or update_id < 0 or reason_code != "handler_exception":
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-122-            return
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-123-        with self._locked() as directory_fd:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-124-            state = self._read_unlocked(directory_fd)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-125-            if update_id in state.recovered:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-126-                raise TelegramIngressReceiptError("receipt_store_corrupt")
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-127-            existing = state.failed.get(update_id)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-128-            if existing is not None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-129-                if existing != reason_code:
--
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-380-        raw_failed = raw.get("terminal_failures", {})
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-381-        if not isinstance(raw_failed, dict):
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-382-            raise TelegramIngressReceiptError("receipt_store_corrupt")
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-383-        for raw_update_id, record in raw_failed.items():
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-384-            if (
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-385-                not isinstance(raw_update_id, str)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-386-                or not raw_update_id.isdecimal()
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-387-                or raw_update_id != str(int(raw_update_id))
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py:388:                or record != {"reason_code": "handler_exception", "stage": _FAILURE_STAGE}
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-389-            ):
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-390-                raise TelegramIngressReceiptError("receipt_store_corrupt")
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-391-            update_id = int(raw_update_id)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-392-            if update_id < 0 or update_id in receipts or update_id in recovered:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-393-                raise TelegramIngressReceiptError("receipt_store_corrupt")
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py:394:            failed[update_id] = "handler_exception"
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-395-        return _ReceiptState(frozenset(receipts), recovered, failed)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-396-
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-397-    def _write_unlocked(
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-398-        self,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-399-        directory_fd: int,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-400-        state: _ReceiptState,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-401-    ) -> None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-402-        payload: dict[str, object] = {
--
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-617-        self,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-618-        *,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-619-        update_id: int,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-620-        provenance_digest: str,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-621-    ) -> None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-622-        """Receipt an authenticated business commit after UI recovery completed."""
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-623-        if self._outcome.get(update_id) is False:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-624-            raise TelegramIngressReceiptError(
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py:625:                self._reason.get(update_id, "handler_exception")
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-626-            )
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-627-        self._store.record_recovered(update_id, provenance_digest)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-628-        self._processing.discard(update_id)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-629-        self._resolve(update_id, True)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-630-        log_telegram_ingress(
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-631-            _RECOVERY_STAGE,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-632-            update_id,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-633-            reason_code=_RECOVERY_REASON,
--
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-641-        )
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-642-        if pending and not running:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-643-            return False
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-644-        for update_id in pending:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-645-            await self._ready[update_id].wait()
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-646-            if self._outcome.get(update_id) is not True:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-647-                self._on_blocked(
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-648-                    update_id,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py:649:                    self._reason.get(update_id, "handler_exception"),
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-650-                )
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-651-                return False
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-652-        return True
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-653-
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-654-    def acknowledged_through(self, offset: int) -> None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-655-        try:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-656-            self._store.forget_before(offset)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_polling_receipts.py-657-        except TelegramIngressReceiptError:
--
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11238-            )
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11239-        await self._send_adaptive_operator_result(message, result)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11240-
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11241-    async def _handle_ptb_error(self, update: object, context: object) -> None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11242-        """Record a content-free terminal reason without acknowledging the update."""
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11243-        gate = getattr(self, "_telegram_polling_receipt_gate", None)
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11244-        if gate is not None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11245-            try:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py:11246:                gate.failed(update, "handler_exception")
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11247-            except TelegramIngressReceiptError:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11248-                pass
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11249-        log_ingress_stage(
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11250-            "receipt",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11251-            getattr(update, "update_id", None),
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py:11252:            reason_code="handler_exception",
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11253-            logger=logger,
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11254-        )
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11255-
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11256-    async def _handle_callback_query(
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11257-        self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11258-    ) -> None:
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11259-        """Handle inline keyboard button clicks."""
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py-11260-        query = getattr(update, "callback_query", None)


**Planning text rendering approach**

bash

rg -n 'checkin' /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py | head -100; rg -n 'checkin' /home/cube/projects/richard/hermes-agent/gateway/platforms/nutrition_coaching.py | head -100


        snapshot_getter = getattr(bridge, "finalized_coaching_snapshot", None)
        try:
            snapshot = snapshot_getter(callback.session_id) if callable(snapshot_getter) else None
        except Exception:
            snapshot = None
        if isinstance(snapshot, Mapping):
            notice = "오늘 체크인은 이미 완료되었습니다."
            return WizardReply(True, True, notice, WizardPrompt(f"✅ {notice}"), None)
        return opening

    def open_launcher(self, customer_key: str) -> WizardReply:
        if not self._ensure_live_registry():
            return WizardReply(True, False, "고객 경로를 사용할 수 없습니다.", None, None)
        resolved = self._by_key.get(customer_key)
        if resolved is None:
            return WizardReply(True, False, "고객 경로를 사용할 수 없습니다.", None, None)
        opening = resolved.bridge.open_launcher("nutrition_daily")
        if not opening.accepted:
            return opening
        return self._terminal_launcher_reply(resolved, opening)

    def bind_launcher(self, customer_key: str, callback_data: str, message_id: str) -> bool:
        if not self._ensure_live_registry():
            return False
        callback = CallbackData.parse(callback_data)
        if callback is None:
            return False
        resolved = self._by_key.get(customer_key)
        return resolved is not None and resolved.bridge.bind_launcher_message(
            callback.session_id,
            message_id,
        )

    def handle_text(self, address: IncomingAddress, text: str) -> CustomerTransition:
        resolved = self.resolve(address)
        if resolved is None:
            return CustomerTransition(WizardReply(True, False, "이 공간에서는 체크인을 제출할 수 없습니다.", None, None))
        compact = " ".join(text.split())
        if compact == "코칭 일시중지":
            return self._set_customer_paused(address, paused=True)
        if compact == "코칭 다시 시작":
            return self._set_customer_paused(address, paused=False)
        if " ".join(text.split()) == "서비스 중단 및 동의 철회 요청":
            return self.withdraw_customer(address)
        if " ".join(text.split()) in {"오늘 체크인 수정", "체크인 수정"}:
            return CustomerTransition(resolved.bridge.open_nutrition_correction())
        reply = resolved.bridge.handle_text(text, *address.key)
        if reply is None:
            reply = WizardReply(True, False, "이 공간에서는 체크인 제출만 사용할 수 있습니다.", None, None)
        return CustomerTransition(reply)
    def handle_callback(self, incoming: CallbackInput) -> CustomerTransition:
        resolved = self.resolve(incoming.address)
        if resolved is None or resolved.customer is None:
            return CustomerTransition(WizardReply(True, False, "이 버튼을 사용할 수 없습니다.", None, None))
        customer = resolved.customer
        callback = CallbackData.parse(incoming.data)
        finalizing = False
        token: str | None = None
        if (
            callback is not None
            and callback.step == "summary"
            and callback.action == "a0"
        ):
            finalizing = True
            try:
                token = self._prepare_checkin_finalization(
                    customer.spec.customer_key,
                    callback.session_id,
                )
            except DraftLedgerError as exc:
                logger.warning("customer nutrition finalization prepare failed: %s", exc.detail)
                return CustomerTransition(
                    WizardReply(
                        True,
                        False,
                        "현재 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해 주세요.",
                        None,
                        None,
                    )
                )
        reply = resolved.bridge.handle_callback(
            incoming.data,
            *incoming.address.key,
            incoming.message_id,
        )
        if callback is None:
            return CustomerTransition(reply)
        safety = resolved.bridge.finalized_safety_snapshot(callback.session_id)
        if safety is not None:
            return CustomerTransition(reply, self._completion_from_safety(resolved, safety))
        snapshot = resolved.bridge.finalized_coaching_snapshot(callback.session_id)
        if finalizing and token is not None and snapshot is not None:
            try:
                self._commit_checkin_finalization(
                    token,
                    customer.spec.customer_key,
                    callback.session_id,
                )
                self._finalization_recovery_error = None
            except DraftLedgerError as exc:
                logger.warning("customer nutrition finalization commit failed: %s", exc.detail)
                return CustomerTransition(
                    WizardReply(
                        True,
                        False,
                        "현재 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해 주세요.",
                        None,
                        None,
                    )
                )
        if not reply.accepted:
            return CustomerTransition(reply)
        if reply.notice not in {"체크인을 저장했습니다.", "안전 기록을 저장했습니다."}:
            return CustomerTransition(reply)
        if snapshot is None or token is None:
            return CustomerTransition(reply)
        return CustomerTransition(
            reply,
            CompletionNotice(
                customer.spec.customer_key,
                customer.spec.display_name,
                str(snapshot.get("kst_day", "")),
                token,
            ),
        )

    @staticmethod
    def _completion_from_safety(
        resolved: ResolvedCustomer,
        snapshot: dict[str, object],
        *,
        role: str = "customer",
    ) -> CompletionNotice:
        event = snapshot.get("event")
        reasons: tuple[str, ...] = ()
        referral = ""
        safety = getattr(event, "safety", None) if event is not None else None
        typed_reasons = tuple(getattr(safety, "reasons", ()) or ())
        if typed_reasons:
            reasons = tuple(

[17799 more lines in file. Use offset=3300 to continue.]


            logger.warning("nutrition operator card route is not explicitly configured")
            return
        action = self._nutrition_generation_card_action(
            coordinator,
            token,
            coordinator.owner,
        )
        if action is None:
            logger.warning(
                "nutrition operator card token=%s state=unavailable",
                token,
            )
            return
        await self._publish_nutrition_generation_card(coordinator, owner, action)
        self._schedule_nutrition_generation(token, coordinator, coordinator.owner)

    @staticmethod
    def _nutrition_safety_details(completion: object) -> str:
        reasons = getattr(completion, "hold_reasons", ()) or ()
        lines: list[str] = []
        for reason in tuple(reasons)[:6]:
            compact = " ".join(str(reason).split())[:240]
            if compact:
                lines.append(f"• {compact}")
        if not lines:
            lines.append("• 안전 사유가 기록되었습니다.")
        referral = " ".join(str(getattr(completion, "referral_guidance", "")).split())[:500]
        if not referral:
            referral = "증상이 있거나 악화되면 운동·식단 조절보다 의료기관 상담을 우선해 주세요."
        return "안전 사유:\n" + "\n".join(lines) + f"\n진료 안내: {referral}"

    async def _render_nutrition_text(
        self,
        message: object,
        transition: object,
        bridge: object,
    ) -> None:
        reply = getattr(transition, "reply", None)
        prompt = getattr(reply, "prompt", None)
        callback_data = str(getattr(reply, "callback_data", "") or "")
        if prompt is None and callback_data:
            prompt = WizardPrompt(
                str(getattr(reply, "notice", "") or "오늘 체크인을 시작하거나 이어갈 수 있습니다."),
                (("오늘 기록 시작", callback_data),),
            )
        text = prompt.text if prompt is not None else str(
            getattr(reply, "notice", "체크인 제출만 사용할 수 있습니다.")
        )
        message_id: object = None
        active_message_id = getattr(bridge, "active_prompt_message_id", lambda: None)()
        if active_message_id and self._bot is not None and prompt is not None:
            try:
                await self._bot.edit_message_text(
                    chat_id=int(self._physique_chat_id(message)),
                    message_id=int(active_message_id),
                    text=text,
                    reply_markup=self._physique_markup(prompt),
                )
                message_id = active_message_id
            except Exception:
                sent = await self._send_nutrition_topic(
                    chat_id=int(self._physique_chat_id(message)),
                    topic_id=self._physique_thread_id(message),
                    text=text,
                    reply_markup=self._physique_markup(prompt),
                )
                message_id = getattr(sent, "message_id", None)
        else:
            sent = await self._send_nutrition_topic(
                chat_id=int(self._physique_chat_id(message)),
                topic_id=self._physique_thread_id(message),
                text=text,
                reply_markup=self._physique_markup(prompt) if prompt is not None else None,
            )
            message_id = getattr(sent, "message_id", None)
        if prompt is not None and message_id:
            if callback_data:
                try:
                    callback = CallbackData.parse(callback_data)
                except (TypeError, ValueError):
                    callback = None
                bind_launcher = getattr(bridge, "bind_launcher_message", None)
                if callback is not None and callable(bind_launcher):
                    bind_launcher(callback.session_id, str(message_id))
            else:
                bind_active = getattr(bridge, "bind_active_prompt_message", None)
                if callable(bind_active):
                    bind_active(str(message_id))
        completion = getattr(transition, "completion", None)
        if completion is not None:
            await self._render_nutrition_completion(completion)

    async def _render_nutrition_draft(
        self,
        query: "CallbackQuery",

[8541 more lines in file. Use offset=6155 to continue.]


        notice = (
            "코칭을 일시중지했습니다. 새 체크인 처리와 코칭 전달이 중단됩니다."
            if paused
            else "코칭을 다시 시작했습니다. 기존 체크인 기록에서 이어갈 수 있습니다."
        )
        return CustomerTransition(WizardReply(True, True, notice, None, None))

    @staticmethod
    def _customer_consent_callback(
        customer_key: object,
        notice_version: object,
        action: str,
    ) -> str:
        if action not in {"g", "d"}:
            raise ValueError("unsupported customer consent action")
        key = str(customer_key or "").strip()
        version = str(notice_version or "").strip()
        if not key or not version:
            raise ValueError("customer consent callback inputs are required")
        token = hashlib.sha256(
            f"customer-consent\0{key}\0{version}".encode("utf-8")
        ).hexdigest()[:24]
        callback = f"{_CUSTOMER_CONSENT_CALLBACK_PREFIX}:{token}:{action}"
        if len(callback.encode("utf-8")) > 64:
            raise ValueError("customer consent callback exceeds Telegram limit")
        return callback

    def _registered_customer(self, address: IncomingAddress) -> object | None:
        if not self._ensure_live_registry():
            return None
        return self._registered_routes.get(address.key)

    def is_registered_customer_address(
        self,
        address: IncomingAddress,
    ) -> bool:
        """Identify customer rooms without exposing registry internals."""
        return self._registered_customer(address) is not None

    def open_customer_onboarding(
        self,
        address: IncomingAddress,
    ) -> CustomerOnboardingCard | None:
        customer = self._registered_customer(address)
        spec = getattr(customer, "spec", None)
        if spec is None or getattr(spec, "enabled", False) is True:
            return None
        from checkin_cli.customer_coaching import CONSENT_VERSION

        consent = getattr(spec, "ai_processing_consent", None)
        if (
            getattr(consent, "granted", False) is True
            and getattr(consent, "notice_version", None) == CONSENT_VERSION
        ):
            return CustomerOnboardingCard(

[18184 more lines in file. Use offset=2915 to continue.]


126:from gateway.platforms.physique_checkin import (
245:    expected_checkin_revision: str
616:        self._physique_checkin_config = PhysiqueCheckinConfig.from_extra(config.extra)
617:        self._physique_checkin: Optional[PhysiqueCheckinBridge] = None
618:        self._physique_checkin_error: Optional[str] = None
619:        raw_physique = config.extra.get("physique_checkin") if isinstance(config.extra, dict) else None
620:        if isinstance(raw_physique, dict) and raw_physique.get("enabled") is True and self._physique_checkin_config is None:
621:            self._physique_checkin_error = (
622:                "physique_checkin initialization failed: invalid exact owner/chat/topic or expiry configuration."
637:        self._customer_checkin_keyboard_topics: set[tuple[str, str]] = set()
4403:    def _get_physique_checkin(self) -> Optional[PhysiqueCheckinBridge]:
4405:        config = getattr(self, "_physique_checkin_config", None)
4408:        if getattr(self, "_physique_checkin_error", None):
4410:        existing = getattr(self, "_physique_checkin", None)
4413:                self._physique_checkin = PhysiqueCheckinBridge(config)
4416:                    "physique_checkin initialization failed: "
4418:                    "Check the profile workspace/checkin_cli package and exact owner/chat/topic settings."
4420:                self._physique_checkin_error = diagnostic[:500]
4421:                logger.error("[%s] %s", getattr(self, "name", "telegram"), self._physique_checkin_error)
4423:        return self._physique_checkin
4508:        raw_physique = extra.get("physique_checkin")
4511:        if self._physique_checkin_config is not None:
4514:                    "chat_id": self._physique_checkin_config.chat_id,
4515:                    "topic_id": self._physique_checkin_config.topic_id,
4682:            package_root = profile_root / "workspace" / "checkin_cli"
4770:                f"Check registry_path={config.registry_path!s}, the profile workspace/checkin_cli package, "
5698:    def _customer_checkin_reply_markup():
5705:    async def _ensure_customer_checkin_keyboard(self, address: object) -> None:
5709:        initialized = getattr(self, "_customer_checkin_keyboard_topics", None)
5712:            self._customer_checkin_keyboard_topics = initialized
5719:            reply_markup=self._customer_checkin_reply_markup(),
5817:        await self._ensure_customer_checkin_keyboard(address)
6251:        review_summary = TelegramAdapter._nutrition_checkin_review_summary(action)
6343:    def _nutrition_checkin_review_summary(action: object) -> str:
6479:        checkin_revision = getattr(action, "generation_checkin_revision", None)
6485:            or not isinstance(checkin_revision, str)
6486:            or len(checkin_revision) != 64
6499:            "expected_checkin_revision": checkin_revision,
6521:        checkin_revision = getattr(action, "generation_checkin_revision", None)
6530:            or not isinstance(checkin_revision, str)
6531:            or not re.fullmatch(r"[a-f0-9]{64}", checkin_revision)
6546:                "checkin_revision": checkin_revision,
6586:            generation_checkin_revision=getattr(generation, "checkin_revision", None),
6897:            generation_checkin_revision=getattr(generation, "checkin_revision", None),
7080:                expected_checkin_revision=generation_pins["expected_checkin_revision"],
7451:        if self._physique_checkin_config is None:
7453:        if not self._physique_checkin_config.allow_anonymous_sender_chat:
7457:        if sender_chat_id != self._physique_checkin_config.chat_id:
7459:        if self._physique_chat_id(message) != self._physique_checkin_config.chat_id:
7461:        if self._physique_thread_id(message) != self._physique_checkin_config.topic_id:
7463:        return self._physique_checkin_config.owner_id
7510:            bridge = self._get_physique_checkin()
7792:        config = self._physique_checkin_config
7800:        bridge = self._get_physique_checkin()
7879:        bridge = self._get_physique_checkin()
7905:        bridge = self._get_physique_checkin()
7966:        bridge = self._get_physique_checkin()
8103:            config = getattr(self, "_physique_checkin_config", None)
8262:                "checkin_rate_percent",
8560:            "daily": ("daily-checkin",),
8706:            package_root = profile_root / "workspace" / "checkin_cli"
8712:            from checkin_cli.coaching_grounding import export_coaching_grounding
8741:            config = getattr(self, "_physique_checkin_config", None)
8826:            package_root = profile_root / "workspace" / "checkin_cli"
8832:            from checkin_cli.coaching_grounding import build_grounded_feedback_context
8848:            package_root = profile_root / "workspace" / "checkin_cli"
8854:            from checkin_cli.coaching_grounding import build_grounded_conversation_context
9182:        bridge = self._get_physique_checkin()
9208:        if card in {"physique-checkin-morning", "physique-source-review"}:
9209:            return self._get_physique_checkin() is not None and self._bot is not None
9214:        if card == "physique-checkin-morning":
9215:            return await self.send_physique_checkin_launcher()
9350:    def _missing_checkin_reminder_settings(config: object) -> tuple[str, datetime] | None:
9355:            nutrition.get("missing_checkin_reminder")
9373:    def _terminal_morning_checkin_received_at(
9387:    async def _send_missing_checkin_reminder(
9402:        settings = self._missing_checkin_reminder_settings(getattr(self, "config", None))
9429:            from checkin_cli.customer_coaching import RegisteredCustomerDualCoachCoordinator
9438:                prepared = dual_coach.reserve_missing_checkin_reminder(
9464:                    checkin_received_at=self._terminal_morning_checkin_received_at(
9498:                self._terminal_morning_checkin_received_at(
9690:            from checkin_cli import (
9700:            from checkin_cli.customer_schedule import (
9701:                abandon_missing_checkin_reminder_for_terminal_morning_response,
9704:            from checkin_cli.customer_reporting import (
9708:            from checkin_cli.adaptive_nutrition import load_verified_dual_coach_risk_policy
9709:            from checkin_cli.customer_coaching import RegisteredCustomerDualCoachCoordinator
9710:            from checkin_cli.customer_schedule import ApprovedReminderScheduleEvidence
9746:                if self._terminal_morning_checkin_received_at(dual_coach, local_now.date()) is not None:
9756:                    missing_morning_checkins={customer_key: local_now.date()},
9822:                failure = await self._send_missing_checkin_reminder(
9833:                        abandon_missing_checkin_reminder_for_terminal_morning_response
9906:                        from checkin_cli.adaptive_nutrition import AdaptiveEventStore
9907:                        from checkin_cli.wizard import WizardService
9908:                        from checkin_cli.customer_reporting import (
10209:            "checkin_rate_percent",
10212:            "checkin_rate",
10562:        if self._physique_checkin_config is None or not self._bot:
10590:                chat_id=int(self._physique_checkin_config.chat_id),
10594:                    self._physique_checkin_config.chat_id,
27:from gateway.platforms.physique_checkin import CallbackData, PhysiqueCheckinBridge, WizardPrompt, WizardReply
28:from gateway.platforms.physique_checkin_config import PhysiqueCheckinConfig
45:    from checkin_cli.operator_console import (
57:    from checkin_cli.customer_coaching import CustomerRuntime, RegisteredCustomerBinding
58:    from checkin_cli.store import CanonicalEventTransaction
59:    from checkin_cli.adaptive_nutrition import (
121:    from checkin_cli.adaptive_nutrition import feature_config_digest
125:    from checkin_cli.adaptive_nutrition import (
139:    from checkin_cli.customer_admin import (
147:    from checkin_cli.customer_coaching import (
164:    from checkin_cli.customer_coaching import CustomerRegistry, CustomerRuntime
165:    from checkin_cli.customer_grounding import CustomerSnapshot
569:    generation_checkin_revision: str | None = None
607:_CHECKIN_FINALIZATION_JOURNAL_SCHEMA = "checkin-finalization-journal-v1"
621:        "checkin_event_id",
622:        "checkin_revision",
1393:    checkin_revision: str
1405:    checkin_revision: str
1421:    checkin_event_id: str
1422:    checkin_revision: str
1458:        checkin_event_id: str,
1459:        checkin_revision: str,
1474:            checkin_event_id=checkin_event_id,
1475:            checkin_revision=checkin_revision,
1501:        checkin_event_id: str,
1502:        checkin_revision: str,
1523:            checkin_event_id,
1524:            checkin_revision,
1532:            "checkin_event_id": checkin_event_id,
1533:            "checkin_revision": checkin_revision,
1557:            checkin_event_id=checkin_event_id,
1558:            checkin_revision=checkin_revision,
1583:        checkin_event_id: str,
1584:        checkin_revision: str,
1590:            "checkin_event_id": checkin_event_id,
1591:            "checkin_revision": checkin_revision,
1610:            or type(self.checkin_event_id) is not str
1611:            or not 1 <= len(self.checkin_event_id) <= 128
1612:            or _DRAFT_GENERATION_DIGEST.fullmatch(self.checkin_revision) is None
1738:            self.checkin_event_id,
1739:            self.checkin_revision,
1757:        expected_checkin_event_id: str,
1758:        expected_checkin_revision: str,
1777:        if expected_checkin_event_id != self.checkin_event_id:
1779:        if expected_checkin_revision != self.checkin_revision:
1884:            checkin_event_id=self.checkin_event_id,
1885:            checkin_revision=self.checkin_revision,
1914:            "checkin_event_id": self.checkin_event_id,
1915:            "checkin_revision": self.checkin_revision,
1962:            checkin_event_id=_draft_generation_required_string(
1963:                payload, "checkin_event_id"
1965:            checkin_revision=_draft_generation_required_string(
1966:                payload, "checkin_revision"
2041:            or successor.checkin_event_id != predecessor.checkin_event_id
2042:            or successor.checkin_revision != predecessor.checkin_revision
2108:    from checkin_cli.customer_admin import _resolve_profile_root, _resolve_registry_path
2116:    from checkin_cli.customer_admin import load_runtime_customer_registry
2163:            owner_actions / "checkin-finalization-journal.json"
2176:            self._recover_checkin_finalizations()
2195:        from checkin_cli.wizard import WizardService
2662:                or delivery.get("checkin_revision") != generation.checkin_revision
2669:                    generation.checkin_revision,
2907:        from checkin_cli.customer_coaching import CONSENT_VERSION
2967:        from checkin_cli.customer_admin import set_customer_ai_consent
2968:        from checkin_cli.customer_coaching import AiProcessingConsent, CONSENT_VERSION
3037:        from checkin_cli.customer_admin import (
3225:                token = self._prepare_checkin_finalization(
3253:                self._commit_checkin_finalization(
3307:                from checkin_cli.customer_grounding import safe_hold_reasons
3314:                from checkin_cli.customer_grounding import referral_guidance
3350:        from checkin_cli.customer_grounding import CustomerSnapshot
3395:            from checkin_cli.customer_reporting import CustomerWeeklyReviewSource
3472:        checkin_event_id: str,
3473:        checkin_revision: str,
3479:                "checkin_event_id": checkin_event_id,
3480:                "checkin_revision": checkin_revision,
3514:            generation_checkin_revision=generation.checkin_revision,
3532:        checkin_event_id = self._canonical_finalized_event_id(selection)
3533:        checkin_revision = self.current_judgment_revision_binding(selection)
3535:        if customer is None or checkin_event_id is None:
3568:                or current.checkin_event_id != checkin_event_id
3569:                or current.checkin_revision != checkin_revision
3582:                    expected_checkin_event_id=checkin_event_id,
3583:                    expected_checkin_revision=checkin_revision,
3595:                    expected_checkin_event_id=checkin_event_id,
3596:                    expected_checkin_revision=checkin_revision,
3622:            checkin_event_id,
3623:            checkin_revision,
3627:            checkin_event_id=checkin_event_id,
3628:            checkin_revision=checkin_revision,
3641:            expected_checkin_event_id=checkin_event_id,
3642:            expected_checkin_revision=checkin_revision,
3652:            expected_checkin_event_id=checkin_event_id,
3653:            expected_checkin_revision=checkin_revision,
3735:        expected_checkin_revision: str | None = None,
3751:            current.checkin_event_id,
3752:            current.checkin_revision,
3766:            expected_checkin_event_id=current.checkin_event_id,
3767:            expected_checkin_revision=(
3768:                current.checkin_revision


**Investigating _physique_thread_id exception cause**

rg -n 'def _physique_thread_id|def _physique_chat_id|def _physique_owner_id' /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py

cd /home/cube/.hermes/profiles/dualcoachtest && find data/customers/task26_live_2e_r2_20260815_8527916639 -maxdepth 4 -type f -printf '%p %s bytes\n' | sort; find data/customers/task26_live_2e_r2_20260815_8527916639/wizard -maxdepth 3 -type f -not -name '*.lock' -print -exec python -m json.tool {} \; 2>&1 | head -500

rg -n 'class TelegramAdapter|def __init__' /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py | head -30; rg -n 'class PlatformConfig' /home/cube/projects/richard/hermes-agent/gateway --glob '*.py'


7439:    def _physique_thread_id(message: object) -> str:
7444:    def _physique_owner_id(message_or_query: object) -> str:
7466:    def _physique_chat_id(message: object) -> str:


data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/.lock 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/adjustment-policy-v1.json 1104 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/baseline-candidate.json 2352 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/baseline-v1.json 1219 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/initial-plan-v1.json 1795 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/readiness-current.json 414 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/readiness-receipt-v1.json 1124 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/ready.json 834 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/restriction-reconciliation-v1.json 480 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-onboarding/session.json 270 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/.adaptive.lock 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/.authority-transition.lock 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/adaptive-overlays.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/authority-mirror-intents.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/canonical-sequence.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/config-epoch-journal.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/events.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/feature-epoch.json 207 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/food-catalog.json 55 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/meal-constraints.json 42 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/policy-audit.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/policy-revisions.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/policy.json 1300 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/source-day-intents.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/nutrition-plans/source-days.jsonl 0 bytes
data/customers/task26_live_2e_r2_20260815_8527916639/wizard/telegram-bindings.json.lock 0 bytes


                **generation_pins,
            )
        )
        if action.accepted:
            editing.pop(actual_address.key, None)
            route = self._nutrition_generation_card_route(coordinator)
            if route is not None:
                await self._publish_nutrition_generation_card(
                    coordinator,
                    route,
                    action,
                )
                return True
        sent = await self._send_nutrition_topic(
            chat_id=(
                getattr(actual_address, "chat_id", None)
                or self._physique_chat_id(message)
            ),
            topic_id=(
                getattr(actual_address, "topic_id", None)
                or self._physique_thread_id(message)
            ),
            text=self._nutrition_draft_text(action),
        )
        if action.accepted:
            await self._activate_nutrition_generation_card(
                actor,
                action,
                getattr(sent, "message_id", None),
            )
        return True

    @staticmethod
    def _parse_nutrition_draft_edit(
        value: str,
    ) -> tuple[str, object | None] | None:
        from .nutrition_coaching_proposal import (
            NutritionTargets,
            valid_nutrition_targets,
        )

        normalized = value.strip()
        if not normalized:
            return None
        first, separator, remainder = normalized.partition("\n")
        if not first.startswith("목표"):
            return normalized, None
        parts = first.split()
        if len(parts) != 5 or not separator or not remainder.strip():
            return None
        try:
            targets = NutritionTargets(*(int(item) for item in parts[1:]))
        except ValueError:
            return None
        if not valid_nutrition_targets(targets):
            return None
        return remainder.strip(), targets

    @staticmethod
    def _physique_thread_id(message: object) -> str:
        thread_id = getattr(message, "message_thread_id", None)
        return str(thread_id) if thread_id is not None else TelegramAdapter._GENERAL_TOPIC_THREAD_ID

    @staticmethod
    def _physique_owner_id(message_or_query: object) -> str:
        user = getattr(message_or_query, "from_user", None)
        return str(getattr(user, "id", ""))

    def _physique_text_owner_id(self, message: object) -> str:
        """Resolve an opted-in anonymous group sender only at the wizard boundary."""
        owner_id = self._physique_owner_id(message)
        if self._physique_checkin_config is None:
            return owner_id
        if not self._physique_checkin_config.allow_anonymous_sender_chat:
            return owner_id
        sender_chat = getattr(message, "sender_chat", None)
        sender_chat_id = str(getattr(sender_chat, "id", ""))
        if sender_chat_id != self._physique_checkin_config.chat_id:
            return owner_id
        if self._physique_chat_id(message) != self._physique_checkin_config.chat_id:
            return owner_id
        if self._physique_thread_id(message) != self._physique_checkin_config.topic_id:
            return owner_id
        return self._physique_checkin_config.owner_id

    @staticmethod
    def _physique_chat_id(message: object) -> str:
        return str(getattr(getattr(message, "chat", None), "id", getattr(message, "chat_id", "")))

    @staticmethod
    def _physique_markup(prompt: WizardPrompt):
        """Render only opaque button addresses; prompt text holds all labels."""
        if not prompt.buttons and not prompt.button_rows:
            return None
        if not TELEGRAM_AVAILABLE:
            return None
        from telegram import InlineKeyboardButton as Button
        from telegram import InlineKeyboardMarkup as Markup

        rows = prompt.button_rows or tuple((button,) for button in prompt.buttons)

[7216 more lines in file. Use offset=7480 to continue.]


184:    def __init__(self, reason: str) -> None:
526:class TelegramAdapter(BasePlatformAdapter):
603:    def __init__(self, config: PlatformConfig):
/home/cube/projects/richard/hermes-agent/gateway/config.py:319:class PlatformConfig:


**Inspecting process imports and source modifications**

stat -c '%y %n' /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py /home/cube/projects/richard/hermes-agent/gateway/platforms/nutrition_coaching.py /home/cube/.hermes/profiles/dualcoachtest/config.yaml; rg -n 'PlatformConfig\(' /home/cube/projects/richard/hermes-agent/tests/gateway/conftest.py /home/cube/projects/richard/hermes-agent/tests/gateway/test_telegram_physique_checkin.py | head -30

rg -n 'def _recover_checkin_finalizations|def _read_finalization|finalization_journal' /home/cube/projects/richard/hermes-agent/gateway/platforms/nutrition_coaching.py | head -40; rg -n 'class WizardStorage|def .*binding|telegram-bindings' /home/cube/.hermes/profiles/dualcoachtest/workspace/checkin_cli/checkin_cli/wizard_storage.py /home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py | head -100


        out.append(line)
        i += 1

    return '\n'.join(out)

class TelegramAdapter(BasePlatformAdapter):
    """
    Telegram bot adapter.

    Handles:
    - Receiving messages from users and groups
    - Sending responses with Telegram markdown
    - Forum topics (thread_id support)
    - Media messages
    """

    # Telegram message limits
    MAX_MESSAGE_LENGTH = 4096
    supports_code_blocks = True  # Telegram MarkdownV2 renders fenced code blocks
    # Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
    # UTF-8 characters. Content above this is sent via the legacy chunking path.
    RICH_MESSAGE_MAX_CHARS = 32768
    # Backwards-compatible alias for tests/external callers that referenced the
    # initial implementation name. The API limit is character-based, not bytes.
    RICH_MESSAGE_MAX_BYTES = RICH_MESSAGE_MAX_CHARS
    # Threshold for detecting Telegram client-side message splits.
    # When a chunk is near this limit, a continuation is almost certain.
    _SPLIT_THRESHOLD = 4000
    MEDIA_GROUP_WAIT_SECONDS = 0.8
    _GENERAL_TOPIC_THREAD_ID = "1"

    # Telegram's edit_message applies MarkdownV2 formatting only on the
    # finalize=True path.  Without this flag, stream_consumer._send_or_edit
    # short-circuits when the raw text is unchanged between the last streamed
    # edit and the final edit, skipping the plain-text → MarkdownV2 conversion.
    # Fixes #25710.
    REQUIRES_EDIT_FINALIZE: bool = True

    # Adaptive text-batch ingress: short messages need a tighter delay so the
    # first token reaches the agent fast.  Numbers tuned for "feels instant":
    # ≤320 codepoints (one short paragraph) settles in ~180ms; ≤1024
    # (a normal paragraph) in ~240ms; longer waits the configured cap.
    # Always clamped to ``_text_batch_delay_seconds`` so an operator can lower
    # the cap further via env var.
    _TEXT_BATCH_FAST_LEN = 320
    _TEXT_BATCH_FAST_DELAY_S = 0.18
    _TEXT_BATCH_SHORT_LEN = 1024
    _TEXT_BATCH_SHORT_DELAY_S = 0.24

    @staticmethod
    def _env_float_clamped(
        name: str,
        default: float,
        *,
        min_value: Optional[float] = None,
        max_value: Optional[float] = None,
    ) -> float:
        """Read a float env var, reject non-finite values, and clamp to bounds.

        Guarantees the returned value is a finite number usable directly in
        ``asyncio.sleep()`` and similar APIs that reject NaN / Inf.
        """
        import math

        raw = os.getenv(name)
        try:
            value = float(raw) if raw is not None else float(default)
        except (TypeError, ValueError):
            value = float(default)
        if not math.isfinite(value):
            value = float(default)
        if min_value is not None:
            value = max(value, min_value)
        if max_value is not None:
            value = min(value, max_value)
        return value

    @property
    def message_len_fn(self):
        """Telegram measures message length in UTF-16 code units."""
        return utf16_len

    def __init__(self, config: PlatformConfig):
        super().__init__(config, Platform.TELEGRAM)
        self._app: Optional[Application] = None
        self._bot: Optional[Bot] = None
        # Diagnostic identity is pinned only after the authenticated Bot has
        # completed startup.  None is intentionally untrusted.
        self._diagnostic_production_adapter_token = None
        self._diagnostic_production_bot_identity = None
        self._diagnostic_production_bot_digest = None
        # This stays inert for every normal profile: no profile-local module is
        # imported unless the explicit nested flag and all exact constraints are
        # present.  The actual bridge is lazy so a malformed/non-profile home
        # cannot prevent the ordinary Telegram adapter from starting.
        self._physique_checkin_config = PhysiqueCheckinConfig.from_extra(config.extra)
        self._physique_checkin: Optional[PhysiqueCheckinBridge] = None
        self._physique_checkin_error: Optional[str] = None
        raw_physique = config.extra.get("physique_checkin") if isinstance(config.extra, dict) else None
        if isinstance(raw_physique, dict) and raw_physique.get("enabled") is True and self._physique_checkin_config is None:
            self._physique_checkin_error = (
                "physique_checkin initialization failed: invalid exact owner/chat/topic or expiry configuration."
            )
        self._nutrition_coaching_config = NutritionCoachingConfig.from_extra(config.extra)
        self._adaptive_nutrition_config = AdaptiveNutritionConfig.from_extra(config.extra)
        self._adaptive_nutrition_error: Optional[str] = None
        if self._adaptive_nutrition_config is not None:
            try:
                self._validate_adaptive_review_space_config()
            except Exception as exc:
                self._adaptive_nutrition_error = (
                    "adaptive review-space validation failed: "
                    f"{type(exc).__name__}"
                )
        self._adaptive_operator_service = None
        self._adaptive_operator_keyboard_chats: set[str] = set()
        self._customer_checkin_keyboard_topics: set[tuple[str, str]] = set()
        self._nutrition_coaching = None
        self._room_bootstrap_transport = None
        self._nutrition_onboarding_runtime = None
        self._nutrition_onboarding_preview = None
        self._nutrition_coaching_error: Optional[str] = None
        self._nutrition_live_state_error: Optional[str] = None
        raw_nutrition = config.extra.get("nutrition_coaching") if isinstance(config.extra, dict) else None
        if isinstance(raw_nutrition, dict) and raw_nutrition.get("enabled") is True and self._nutrition_coaching_config is None:
            self._nutrition_coaching_error = (
                "nutrition_coaching initialization failed: invalid registry_path; use a profile-relative path."
            )
        self._nutrition_draft_editing: Dict[
            tuple[str, str, str], tuple[str, _NutritionGenerationPins]
        ] = {}
        self._webhook_mode: bool = False
        self._mention_patterns = self._compile_mention_patterns()
        self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
        self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
        # Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
        # path degrades (tables → bullet lists, task lists, <details>, block
        # math) via sendRichMessage / editMessageText's rich_message param using
        # the raw agent markdown. Enabled by default; users can opt out for
        # clients that accept but render rich messages poorly via
        # platforms.telegram.extra.rich_messages: false.
        self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
        # Latched off after a capability failure on sendRichMessage /
        # sendRichMessageDraft (e.g. older python-telegram-bot without the
        # endpoint) so later sends skip the doomed rich attempt entirely.
        self._rich_send_disabled: bool = False
        self._rich_draft_disabled: bool = False
        # Buffer rapid/album photo updates so Telegram image bursts are handled
        # as a single MessageEvent instead of self-interrupting multiple turns.
        self._media_batch_delay_seconds = env_float("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", 0.8)
        self._pending_photo_batches: Dict[str, MessageEvent] = {}
        self._pending_photo_batch_tasks: Dict[str, asyncio.Task] = {}
        self._media_group_events: Dict[str, MessageEvent] = {}
        self._media_group_tasks: Dict[str, asyncio.Task] = {}
        # Buffer rapid text messages so Telegram client-side splits of long
        # messages are aggregated into a single MessageEvent.  Lower defaults
        # (0.3s / 1.0s instead of 0.6s / 2.0s) let short replies stream
        # without a noticeable wait — combined with the adaptive fast-path
        # in ``_calc_text_batch_delay`` below, ≤320-codepoint replies settle
        # in ~180ms.  All bounds are conservative for Telegram's
        # ~1 edit/s flood envelope.
        self._text_batch_delay_seconds = self._env_float_clamped(
            "HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS",
            0.3,
            min_value=0.08,
            max_value=2.0,
        )
        self._text_batch_split_delay_seconds = self._env_float_clamped(
            "HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS",

[14006 more lines in file. Use offset=690 to continue.]


    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
        # Handle both missing keys and explicit null values (YAML null → None)
        mode = data.get("mode")
        at_hour = data.get("at_hour")
        idle_minutes = data.get("idle_minutes")
        notify = data.get("notify")
        exclude = data.get("notify_exclude_platforms")
        return cls(
            mode=mode if mode is not None else "both",
            at_hour=at_hour if at_hour is not None else 4,
            idle_minutes=idle_minutes if idle_minutes is not None else 1440,
            notify=_coerce_bool(notify, True),
            notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"),
        )

@dataclass
class PlatformConfig:
    """Configuration for a single messaging platform."""
    enabled: bool = False
    token: Optional[str] = None  # Bot token (Telegram, Discord)
    api_key: Optional[str] = None  # API key if different from token
    home_channel: Optional[HomeChannel] = None

    # Reply threading mode (Telegram/Slack)
    # - "off": Never thread replies to original message
    # - "first": Only first chunk threads to user's message (default)
    # - "all": All chunks in multi-part replies thread to user's message
    reply_to_mode: str = "first"

    # Whether the gateway is allowed to send "♻ Gateway online" /
    # "♻ Gateway restarted" lifecycle notifications on this platform.
    # Default True preserves prior behavior. Set False on platforms used
    # by end users (e.g. Slack) where operator-flavored restart pings are
    # noise; keep True for back-channels where the operator wants them.
    gateway_restart_notification: bool = True

    # Platform-specific settings
    extra: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        result = {
            "enabled": self.enabled,
            "extra": self.extra,
            "reply_to_mode": self.reply_to_mode,
            "gateway_restart_notification": self.gateway_restart_notification,
        }
        if self.token:
            result["token"] = self.token
        if self.api_key:
            result["api_key"] = self.api_key
        if self.home_channel:
            result["home_channel"] = self.home_channel.to_dict()
        return result

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
        home_channel = None
        if "home_channel" in data:
            home_channel = HomeChannel.from_dict(data["home_channel"])

        # gateway_restart_notification may be bridged into extra via the
        # shared-key loop in load_gateway_config(); check both top-level
        # and extra so YAML ``discord: gateway_restart_notification: false``
        # works without needing a separate platforms: block.
        _grn = data.get("gateway_restart_notification")
        if _grn is None:
            _grn = data.get("extra", {}).get("gateway_restart_notification")

        return cls(
            enabled=_coerce_bool(data.get("enabled"), False),
            token=data.get("token"),
            api_key=data.get("api_key"),
            home_channel=home_channel,
            reply_to_mode=data.get("reply_to_mode", "first"),
            gateway_restart_notification=_coerce_bool(_grn, True),
            extra=data.get("extra", {}),
        )

# Streaming defaults — single source of truth so both StreamingConfig and
# StreamConsumerConfig agree on the out-of-the-box edit rhythm.  Tuned for
# Telegram's ~1 edit/s flood envelope: a touch under 1s lets the cadence
# breathe without bumping into rate limits, and a smaller buffer threshold
# makes short replies feel near-instant in DMs.
DEFAULT_STREAMING_EDIT_INTERVAL: float = 0.8
DEFAULT_STREAMING_BUFFER_THRESHOLD: int = 24
DEFAULT_STREAMING_CURSOR: str = " ▉"

@dataclass
class StreamingConfig:
    """Configuration for real-time token streaming to messaging platforms."""
    enabled: bool = False
    # Transport selection:
    #   "auto"  — prefer native streaming-draft updates when the platform
    #             supports them (Telegram sendMessageDraft, Bot API 9.5+);
    #             fall back to edit-based when not.

[1797 more lines in file. Use offset=400 to continue.]


2026-08-14 17:36:25.078056035 +0900 /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py
2026-08-14 16:53:13.395665799 +0900 /home/cube/projects/richard/hermes-agent/gateway/platforms/nutrition_coaching.py
2026-08-14 15:46:48.718543843 +0900 /home/cube/.hermes/profiles/dualcoachtest/config.yaml
/home/cube/projects/richard/hermes-agent/tests/gateway/test_telegram_physique_checkin.py:1026:        adapter = TelegramAdapter(PlatformConfig(enabled=True, token="test", extra=extra))
/home/cube/projects/richard/hermes-agent/tests/gateway/test_telegram_physique_checkin.py:2997:    return PlatformConfig(


2162:        self._finalization_journal_path = (
8005:    def _finalization_journal_ledger_path(self) -> Path:
8008:            "_finalization_journal_path",
8015:    def _read_checkin_finalization_journal(
8018:        path = self._finalization_journal_ledger_path()
8055:    def _write_checkin_finalization_journal(
8060:            self._finalization_journal_ledger_path(),
8080:            entries = self._read_checkin_finalization_journal()
8096:                self._write_checkin_finalization_journal(entries)
8161:            entries = self._read_checkin_finalization_journal()
8187:                    self._write_checkin_finalization_journal(entries)
8193:    def _recover_checkin_finalizations(self) -> None:
8194:        entries = self._read_checkin_finalization_journal()
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:246:    def has_binding(self, session_id: object) -> bool:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:253:    def has_active_binding(self) -> bool:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:273:    def binding_path(self) -> Path:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:589:    def _advance(self, binding: WizardBinding, action: str, value: str | None) -> WizardReply:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:634:    def _prompt(self, binding: WizardBinding) -> WizardPrompt:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:746:    def _nutrition_summary_prompt(self, binding: WizardBinding) -> WizardPrompt:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:783:    def _registered_morning_prompt(self, binding: WizardBinding) -> WizardPrompt:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:803:    def _adaptive_prompt(self, binding: WizardBinding) -> WizardPrompt:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:815:    def _schedule_reference_prompt(self, binding: WizardBinding) -> WizardPrompt:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:846:    def _invalid_text_prompt(binding: WizardBinding) -> WizardPrompt:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:1001:    def _default_binding_path() -> Path:
/home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py:1003:        return Path(get_hermes_home()) / "data" / "wizard" / "telegram-bindings.json"
/home/cube/.hermes/profiles/dualcoachtest/workspace/checkin_cli/checkin_cli/wizard_storage.py:17:class WizardStorage:
/home/cube/.hermes/profiles/dualcoachtest/workspace/checkin_cli/checkin_cli/wizard_storage.py:122:class WizardStorage(WizardStorage):


**Planning no-write monitoring with strace**

**Designing snapshot-based read-only driver**

The incident route is now narrowed to the registered-customer branch: plain `checkin` is not a launcher alias, so it reaches `NutritionCoachingCoordinator.handle_text(...)` and then `_render_nutrition_text(...)`. I’m moving to an instrumented reproduction with live-state mutation guards and a fake Telegram send surface.

rg -n 'def get_hermes_home|HERMES_HOME' /home/cube/projects/richard/hermes-agent/hermes_constants.py /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py | head -60; cd '/home/cube/projects/richard/traning coach' && find .omo/evidence -maxdepth 2 -type d 2>/dev/null | sort | tail -50


            raise ValueError("configured registry path is missing")
        raw_path = _Path(str(raw_value))
        if not raw_path.parts or raw_path == _Path(".") or ".." in raw_path.parts:
            raise ValueError("configured registry path is invalid")

        if raw_path.is_absolute():
            registry_candidate = raw_path
            root_hint = None
        else:
            root_hint = getattr(adapter_config, "profile_root", None)
            if root_hint is None:
                extra = getattr(adapter_config, "extra", None)
                raw_nutrition = extra.get("nutrition_coaching") if isinstance(extra, dict) else None
                if isinstance(raw_nutrition, dict):
                    root_hint = raw_nutrition.get("profile_root")
            if root_hint is None:
                try:
                    from hermes_cli.config import get_hermes_home
                    root_hint = get_hermes_home()
                except (ImportError, OSError, RuntimeError):
                    root_hint = os.environ.get("HERMES_HOME", "").strip()
            if not root_hint:
                raise ValueError("configured profile root is missing")
            root_path = _Path(str(root_hint))
            registry_candidate = root_path / raw_path
            if raw_path == _Path("registry.json"):
                canonical_candidate = root_path / "customers" / "registry.json"
                if canonical_candidate.is_file():
                    registry_candidate = canonical_candidate

        try:
            if raw_path.is_absolute():
                root_candidate = (
                    registry_candidate.parent.parent
                    if registry_candidate.parent.name == "customers"
                    else registry_candidate.parent
                )
            else:
                root_candidate = _Path(str(root_hint))
            if root_candidate.is_symlink() or not root_candidate.is_dir():
                raise ValueError("configured profile root is invalid")
            profile_root = root_candidate.resolve()
            if profile_root.is_symlink():
                raise ValueError("configured profile root is invalid")
            if registry_candidate.is_symlink():
                raise ValueError("configured registry path is a symlink")
            registry_path = registry_candidate.resolve()
        except (OSError, RuntimeError, ValueError) as exc:
            if isinstance(exc, ValueError):
                raise
            raise ValueError("configured registry path is unavailable") from exc

        if (
            not registry_path.is_file()
            or not registry_path.is_relative_to(profile_root)
            or registry_path
            not in {
                (profile_root / "customers" / "registry.json").resolve(),
                (profile_root / "registry.json").resolve(),
            }
        ):
            raise ValueError("configured registry path is outside the profile authority")
        return profile_root, registry_path

    def _adaptive_reserved_route_categories(self) -> tuple[tuple[object, ...], tuple[object, ...]]:
        """Return owner-scheduled and generic Telegram routes reserved by config."""
        extra = self.config.extra if isinstance(self.config.extra, dict) else {}
        owner_scheduled: list[object] = []
        generic: list[object] = []

        home = getattr(self.config, "home_channel", None)
        if home is not None:
            home_chat = getattr(home, "chat_id", None)
            home_topic = getattr(home, "thread_id", None) or "1"
            owner_scheduled.append(
                {"chat_id": home_chat, "topic_id": home_topic}
            )

        raw_physique = extra.get("physique_checkin")
        if raw_physique is not None:
            generic.append(raw_physique)
        if self._physique_checkin_config is not None:
            generic.append(
                {
                    "chat_id": self._physique_checkin_config.chat_id,
                    "topic_id": self._physique_checkin_config.topic_id,
                }
            )

        dm_topics = extra.get("dm_topics")
        if dm_topics is not None:
            if not isinstance(dm_topics, list):
                generic.append(dm_topics)
            else:
                for chat_entry in dm_topics:
                    if not isinstance(chat_entry, Mapping):
                        generic.append(chat_entry)
                        continue
                    chat_id = chat_entry.get("chat_id")
                    topics = chat_entry.get("topics")
                    if not chat_id or not isinstance(topics, list):
                        generic.append(chat_entry)
                        continue
                    for topic in topics:
                        if not isinstance(topic, Mapping) or not topic.get("name"):
                            generic.append(topic)
                            continue
                        thread_id = topic.get("thread_id")
                        if thread_id is not None:
                            generic.append(
                                {
                                    "chat_id": chat_id,
                                    "topic_id": thread_id,
                                }
                            )

        owner_names = (
            "owner_scheduled_routes",
            "scheduled_routes",
            "owner_schedule",
            "scheduled_delivery_routes",
            "cron_routes",
            "owner_deliveries",
        )
        generic_names = (
            "generic_reserved_routes",
            "generic_routes",
            "reserved_routes",
            "reserved_spaces",
            "operator_routes",
            "platform_reserved_routes",
        )
        for name in owner_names:
            if name in extra:
                owner_scheduled.append(extra[name])
        for name in generic_names:
            if name in extra:
                generic.append(extra[name])
        return tuple(owner_scheduled), tuple(generic)

    def _validate_adaptive_review_space_config(self) -> None:
        """Validate review ingress before Telegram startup or customer loading."""
        adaptive = getattr(self, "_adaptive_nutrition_config", None)
        review = getattr(adaptive, "review_operator", None)
        if review is None:

[10121 more lines in file. Use offset=4575 to continue.]


        if flow == "morning":
            result = self._service.start_morning(context, day)
        elif flow == "nutrition_daily":
            result = self._service.start_nutrition(context, day)
        elif flow == "schedule_reference":
            result = self._service.start_schedule_reference(context, day)
        else:
            result = self._service.start_workout(context, day)
        session_id, version = self._result_identity(result)
        self._bindings[session_id] = WizardBinding(
            session_id, self.config.owner_id, self.config.chat_id, self.config.topic_id,
            "launch", version, str(message_id), now + self.config.expires_seconds,
        )
        self._persist()
        callback = CallbackData(session_id, "launch", version, "start").encode()
        return WizardReply(True, True, "", None, callback)

    def open_schedule_reference_launcher(
        self, *, message_id: str = "", now_epoch: int | None = None
    ) -> WizardReply:
        """Open the owner-scoped schedule source-fact entry card."""
        return self.open_launcher("schedule_reference", message_id=message_id, now_epoch=now_epoch)

    def open_nutrition_correction(self, *, now_epoch: int | None = None) -> WizardReply:
        now = self._now(now_epoch)
        day = datetime.now(_KST).date().isoformat()
        result = self._service.start_nutrition_correction(self._context(), day)
        session_id, version = self._result_identity(result)
        if _SESSION_ID.fullmatch(session_id) is None:
            return WizardReply(True, False, "수정할 오늘 체크인이 없습니다.", None, None)
        step = str(getattr(result, "step", "bodyweight"))
        binding = WizardBinding(
            session_id, self.config.owner_id, self.config.chat_id, self.config.topic_id,
            step, version, "", now + self.config.expires_seconds,
            awaiting_text=self._expects_text(step),
        )
        self._bindings[session_id] = binding
        self._active_session_id = session_id
        self._persist()
        return WizardReply(True, True, "기존 기록은 보존하고 수정 기록을 새로 시작합니다.", self._prompt(binding), None)

    def active_is_finalized(self) -> bool:
        binding = self._bindings.get(self._active_session_id or "")
        storage = getattr(self._service, "_storage", None)
        session = storage.load(binding.session_id) if binding is not None and storage is not None else None
        return session is not None and getattr(session, "finalized_event_id", None) is not None

    def bind_launcher_message(self, session_id: str, message_id: str) -> bool:
        binding = self._bindings.get(session_id)
        if binding is None or not message_id:
            return False
        binding.message_id = str(message_id)
        self._persist()
        return True

    def bind_prompt_message(self, session_id: str, message_id: str) -> bool:
        return self.bind_launcher_message(session_id, message_id)

    def bind_active_prompt_message(self, message_id: str) -> bool:
        """Bind a follow-up typed-answer prompt to the same private session."""
        binding = self._bindings.get(self._active_session_id or "")
        if binding is None or not message_id:
            return False
        binding.message_id = str(message_id)
        self._persist()
        return True
    def has_binding(self, session_id: object) -> bool:
        """Return whether this bridge owns a live persisted wizard session."""
        if not isinstance(session_id, str):
            return False
        binding = self._bindings.get(session_id)
        return binding is not None and self._now(None) < binding.expires_at

    def has_active_binding(self) -> bool:
        """Return whether this bridge has a live session for typed continuation."""
        return self.has_binding(self._active_session_id)

    def active_prompt(self) -> WizardPrompt | None:
        """Return the current prompt for private-DM recovery without advancing state."""
        binding = self._bindings.get(self._active_session_id or "")
        if binding is None or self._now(None) >= binding.expires_at:
            return None
        return self._prompt(binding)

    def bind_active_message(self, message_id: str) -> None:
        """Move the active callback card to a newly rendered recovery message."""
        binding = self._bindings.get(self._active_session_id or "")
        if binding is None or not str(message_id).strip():
            return
        binding.message_id = str(message_id)
        self._persist()

    @property
    def binding_path(self) -> Path:
        """Expose the durable binding location for diagnostics and tests."""
        return self._binding_store._path

    def handle_callback(self, data: str, owner_id: str, chat_id: str, topic_id: str, message_id: str, *, now_epoch: int | None = None) -> WizardReply:
        parsed = CallbackData.parse(data)
        if parsed is None:
            return WizardReply(True, False, "체크인 버튼 형식이 올바르지 않습니다.", None, None)
        binding = self._bindings.get(parsed.session_id)
        if not self._matches(owner_id, chat_id, topic_id) or binding is None:
            return WizardReply(True, False, "이 체크인은 이 토픽의 소유자만 사용할 수 있습니다.", None, None)
        if self._now(now_epoch) >= binding.expires_at:
            return WizardReply(True, False, "체크인이 만료됐습니다. 새로 시작해 주세요.", None, None)
        navigation_actions = {"p", "k", "l"}
        if (
            str(message_id) != binding.message_id
            or parsed.step != binding.step
            or parsed.version != binding.version
            or (binding.awaiting_text and parsed.action not in navigation_actions)
        ):
            return WizardReply(True, False, "이전 버튼입니다. 현재 질문에서 이어가 주세요.", None, None)
        if parsed.step == "launch":
            if parsed.action != "start":
                return WizardReply(True, False, "시작 버튼만 사용할 수 있습니다.", None, None)
            binding.step = self._session_step(parsed.session_id)
            binding.awaiting_text = self._expects_text(binding.step)
            self._active_session_id = binding.session_id

[705 more lines in file. Use offset=300 to continue.]


    @staticmethod
    def _result_identity(result: object) -> tuple[str, int]:
        return str(getattr(result, "session_id")), int(getattr(result, "version"))

    @staticmethod
    def _load_profile_service() -> _WizardService:
        from hermes_cli.config import get_hermes_home
        package_root = Path(get_hermes_home()) / "workspace" / "checkin_cli"
        if not package_root.is_dir():
            raise RuntimeError("physique check-in package is unavailable")
        package_text = str(package_root)
        if package_text not in sys.path:
            sys.path.insert(0, package_text)
        from checkin_cli.wizard import WizardService
        return cast(
            _WizardService,
            WizardService.for_standalone(Path(get_hermes_home()) / "data" / "wizard"),
        )

    @staticmethod
    def _default_binding_path() -> Path:
        from hermes_cli.config import get_hermes_home
        return Path(get_hermes_home()) / "data" / "wizard" / "telegram-bindings.json"


            self,
            "_generations_path",
            self._drafts_path.with_name("draft-generations.json"),
        )

    def _generation_card_ledger_path(self) -> Path:
        return getattr(
            self,
            "_generation_cards_path",
            self._drafts_path.with_name("draft-generation-cards.json"),
        )

    @staticmethod
    def _write_json_private(path: Path, payload: object) -> None:
        path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        path.parent.chmod(0o700)
        temporary = path.with_suffix(".tmp")
        with temporary.open("w", encoding="utf-8") as handle:
            temporary.chmod(0o600)
            json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(temporary, path)
        path.chmod(0o600)

    def _finalization_journal_ledger_path(self) -> Path:
        return getattr(
            self,
            "_finalization_journal_path",
            self._profile_root
            / "data"
            / "owner-actions"
            / "checkin-finalization-journal.json",
        )

    def _read_checkin_finalization_journal(
        self,
    ) -> dict[str, _CheckinFinalizationEntry]:
        path = self._finalization_journal_ledger_path()
        if not path.exists():
            return {}
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise DraftLedgerError(
                f"check-in finalization journal is unreadable ({type(exc).__name__})"
            ) from exc
        if (
            type(payload) is not dict
            or set(payload) != {"schema_version", "entries"}
            or payload.get("schema_version") != _CHECKIN_FINALIZATION_JOURNAL_SCHEMA
            or type(payload.get("entries")) is not dict
        ):
            raise DraftLedgerError("check-in finalization journal is malformed")
        entries: dict[str, _CheckinFinalizationEntry] = {}
        for token, value in payload["entries"].items():
            if (
                type(token) is not str
                or _DRAFT_GENERATION_IDENTIFIER.fullmatch(token) is None
                or type(value) is not dict
                or set(value) != {"customer_key", "session_id", "state"}
                or type(value.get("customer_key")) is not str
                or not value["customer_key"]
                or type(value.get("session_id")) is not str
                or _CHECKIN_SESSION_ID.fullmatch(value["session_id"]) is None
                or value.get("state") not in {"prepared", "committed"}
            ):
                raise DraftLedgerError("check-in finalization journal contains a malformed entry")
            entries[token] = _CheckinFinalizationEntry(
                customer_key=value["customer_key"],
                session_id=value["session_id"],
                state=value["state"],
            )
        return entries

    def _write_checkin_finalization_journal(
        self,
        entries: Mapping[str, _CheckinFinalizationEntry],
    ) -> None:
        self._write_json_private(
            self._finalization_journal_ledger_path(),
            {
                "schema_version": _CHECKIN_FINALIZATION_JOURNAL_SCHEMA,
                "entries": {token: asdict(value) for token, value in entries.items()},
            },
        )

    @staticmethod
    def _checkin_finalization_token(customer_key: str, session_id: str) -> str:
        return hashlib.sha256(f"{customer_key}:{session_id}".encode()).hexdigest()[:16]

    def _prepare_checkin_finalization(
        self,
        customer_key: str,
        session_id: str,
    ) -> str:
        if self._finalization_recovery_error is not None:
            raise DraftLedgerError(self._finalization_recovery_error)
        token = self._checkin_finalization_token(customer_key, session_id)
        with self._delivery_lock():
            entries = self._read_checkin_finalization_journal()
            expected = _CheckinFinalizationEntry(
                customer_key=customer_key,
                session_id=session_id,
                state="prepared",
            )
            existing = entries.get(token)
            if existing is not None:
                if (
                    existing.customer_key != customer_key
                    or existing.session_id != session_id
                ):
                    raise DraftLedgerError("check-in finalization token is already bound")
                return token
            entries[token] = expected
            try:
                self._write_checkin_finalization_journal(entries)
            except (OSError, ValueError) as exc:
                raise DraftLedgerError(
                    f"check-in finalization prepare could not be persisted ({type(exc).__name__})"
                ) from exc
        return token

    def _ensure_pending_generation_locked(
        self,
        token: str,
        owner: IncomingAddress,
        selection: DraftSelection,
    ) -> DraftGenerationRecord:
        generations = self._read_draft_generations()
        history = generations.get(token)
        customer = selection.customer
        checkin_event_id = self._canonical_finalized_event_id(selection)
        checkin_revision = self.current_judgment_revision_binding(selection)
        if customer is None:
            raise DraftLedgerError("finalized check-in customer is unavailable")
        if history is not None:
            current = history[-1]
            if (
                current.customer_key != customer.spec.customer_key
                or current.checkin_event_id != checkin_event_id
                or current.checkin_revision != checkin_revision
            ):
                raise DraftLedgerError("generation job does not match finalized check-in")
            return current
        if (
            checkin_event_id is None
            or _DRAFT_GENERATION_DIGEST.fullmatch(checkin_revision) is None
        ):
            raise DraftLedgerError("finalized check-in authority is unavailable")
        pending = DraftGenerationRecord.create_pending(
            customer_key=customer.spec.customer_key,
            checkin_event_id=checkin_event_id,
            checkin_revision=checkin_revision,
            model_contract_version=_DRAFT_GENERATION_MODEL_CONTRACT,
            provider_contract_version=_DRAFT_GENERATION_PROVIDER_CONTRACT,
            actor=self._draft_generation_actor(owner),
            authority_digest=self._draft_generation_authority_digest(
                owner,
                customer.spec.customer_key,
                checkin_event_id,
                checkin_revision,
            ),
            occurred_at=self._generation_now(),
        )
        generations[token] = (pending,)
        try:
            self._write_draft_generations(generations)
        except (OSError, ValueError) as exc:
            raise DraftLedgerError(
                f"generation job could not be persisted ({type(exc).__name__})"
            ) from exc
        return pending

    def _commit_checkin_finalization(
        self,
        token: str,
        customer_key: str,
        session_id: str,
    ) -> None:
        with self._delivery_lock():
            entries = self._read_checkin_finalization_journal()
            entry = entries.get(token)
            if entry is None or (
                entry.customer_key != customer_key
                or entry.session_id != session_id
            ):
                raise DraftLedgerError("check-in finalization intent is unavailable")
            requests = self._read_requests()
            existing = requests.get(token)
            if existing is None:
                requests[token] = (customer_key, session_id)
                try:
                    self._write_json_private(self._requests_path, requests)
                except (OSError, ValueError) as exc:
                    raise DraftLedgerError(
                        f"draft request projection could not be persisted ({type(exc).__name__})"
                    ) from exc
            elif existing != (customer_key, session_id):
                raise DraftLedgerError("draft request token is already bound")
            selection = self.resolve_draft(token, self.owner)
            if selection is None:
                raise DraftLedgerError("finalized check-in cannot be resolved")
            self._ensure_pending_generation_locked(token, self.owner, selection)
            if entry.state != "committed":
                entries[token] = replace(entry, state="committed")
                try:
                    self._write_checkin_finalization_journal(entries)
                except (OSError, ValueError) as exc:
                    raise DraftLedgerError(
                        f"check-in finalization commit could not be persisted ({type(exc).__name__})"
                    ) from exc

    def _recover_checkin_finalizations(self) -> None:
        entries = self._read_checkin_finalization_journal()
        for token, entry in entries.items():
            if entry.state == "committed":
                continue
            resolved = self._by_key.get(entry.customer_key)
            snapshot = (
                resolved.bridge.finalized_coaching_snapshot(entry.session_id)
                if resolved is not None
                else None
            )
            if snapshot is None:
                continue
            self._commit_checkin_finalization(
                token,
                entry.customer_key,
                entry.session_id,
            )

    def _save_request(
        self,
        token: str,
        customer_key: str,
        session_id: str,
    ) -> tuple[str, str]:
        with self._delivery_lock():
            requests = self._read_requests()
            self._read_drafts()
            self._read_deliveries()
            existing = requests.get(token)
            if existing is not None:
                if existing[0] != customer_key:

[12874 more lines in file. Use offset=8225 to continue.]


/home/cube/projects/richard/hermes-agent/hermes_constants.py:17:_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar(
/home/cube/projects/richard/hermes-agent/hermes_constants.py:18:    "_HERMES_HOME_OVERRIDE", default=_UNSET
/home/cube/projects/richard/hermes-agent/hermes_constants.py:29:    return _HERMES_HOME_OVERRIDE.set(value)
/home/cube/projects/richard/hermes-agent/hermes_constants.py:34:    _HERMES_HOME_OVERRIDE.reset(token)
/home/cube/projects/richard/hermes-agent/hermes_constants.py:37:def get_hermes_home_override() -> str | None:
/home/cube/projects/richard/hermes-agent/hermes_constants.py:39:    override = _HERMES_HOME_OVERRIDE.get()
/home/cube/projects/richard/hermes-agent/hermes_constants.py:54:def get_hermes_home() -> Path:
/home/cube/projects/richard/hermes-agent/hermes_constants.py:57:    Reads HERMES_HOME env var, falls back to the platform-native default.
/home/cube/projects/richard/hermes-agent/hermes_constants.py:60:    When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates
/home/cube/projects/richard/hermes-agent/hermes_constants.py:66:    expected to propagate ``HERMES_HOME`` explicitly (see the systemd
/home/cube/projects/richard/hermes-agent/hermes_constants.py:74:    val = os.environ.get("HERMES_HOME", "").strip()
/home/cube/projects/richard/hermes-agent/hermes_constants.py:96:                f"[HERMES_HOME fallback] HERMES_HOME is unset but active "
/home/cube/projects/richard/hermes-agent/hermes_constants.py:100:                f"subprocess spawner should pass HERMES_HOME explicitly "
/home/cube/projects/richard/hermes-agent/hermes_constants.py:118:    In Docker or custom deployments where ``HERMES_HOME`` points outside
/home/cube/projects/richard/hermes-agent/hermes_constants.py:119:    ``~/.hermes`` (e.g. ``/opt/data``), returns ``HERMES_HOME`` directly
/home/cube/projects/richard/hermes-agent/hermes_constants.py:122:    In profile mode where ``HERMES_HOME`` is ``<root>/profiles/<name>``,
/home/cube/projects/richard/hermes-agent/hermes_constants.py:130:    env_home = os.environ.get("HERMES_HOME", "")
/home/cube/projects/richard/hermes-agent/hermes_constants.py:136:        # HERMES_HOME is under ~/.hermes (normal or profile mode)
/home/cube/projects/richard/hermes-agent/hermes_constants.py:148:    # Not a profile path — HERMES_HOME itself is the root
/home/cube/projects/richard/hermes-agent/hermes_constants.py:212:        4. ``<HERMES_HOME>/skills`` last-resort
/home/cube/projects/richard/hermes-agent/hermes_constants.py:233:        new_subpath: Preferred path relative to HERMES_HOME (e.g. ``"cache/images"``).
/home/cube/projects/richard/hermes-agent/hermes_constants.py:234:        old_name: Legacy path relative to HERMES_HOME (e.g. ``"image_cache"``).
/home/cube/projects/richard/hermes-agent/hermes_constants.py:251:    ``$HERMES_HOME/node/bin``. Include both shapes on every platform so mixed
/home/cube/projects/richard/hermes-agent/hermes_constants.py:316:    """Return a user-friendly display string for the current HERMES_HOME.
/home/cube/projects/richard/hermes-agent/hermes_constants.py:340:    prevent catastrophic host bricking when ``HERMES_HOME`` or other path
/home/cube/projects/richard/hermes-agent/hermes_constants.py:367:    """Return ``{HERMES_HOME}/home`` when the profile-home directory exists."""
/home/cube/projects/richard/hermes-agent/hermes_constants.py:368:    hermes_home = get_hermes_home_override() or (env or {}).get("HERMES_HOME") or os.getenv("HERMES_HOME")
/home/cube/projects/richard/hermes-agent/hermes_constants.py:415:    ``HERMES_HOME`` scopes Hermes state. ``HOME`` is reserved for the OS/user
/home/cube/projects/richard/hermes-agent/hermes_constants.py:417:    If a parent process is already running with ``HOME={HERMES_HOME}/home``,
/home/cube/projects/richard/hermes-agent/hermes_constants.py:439:      ``{HERMES_HOME}/home`` for persistent state. If a host parent already has
/home/cube/projects/richard/hermes-agent/hermes_constants.py:442:    * ``profile``: use ``{HERMES_HOME}/home`` when it exists, preserving the
/home/cube/projects/richard/hermes-agent/hermes_constants.py:591:    """Return the path to ``config.yaml`` under HERMES_HOME.
/home/cube/projects/richard/hermes-agent/hermes_constants.py:600:    """Return the path to the skills directory under HERMES_HOME."""
/home/cube/projects/richard/hermes-agent/hermes_constants.py:606:    """Return the path to the ``.env`` file under HERMES_HOME."""
/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram.py:4450:                    root_hint = os.environ.get("HERMES_HOME", "").strip()
.omo/evidence/task26/task26-clarification-normalized-successor-4e9962be49b72951c3b9ed7e1a4fe36d0ca03e757872ae03364c1ab7a790f32b
.omo/evidence/task26/task26-consent-observer-st_01a007da
.omo/evidence/task26/task26-continuous-lifecycle-observer-st_01a007da-v1
.omo/evidence/task26/task26-continuous-lifecycle-observer-st_01a007da-v2-e788f5d5
.omo/evidence/task26/task26-continuous-lifecycle-observer-v3-e2f1699e
.omo/evidence/task26/task26-continuous-lifecycle-observer-v3-final-4e9962be
.omo/evidence/task26/task26-continuous-lifecycle-observer-v3-reconciliation-e788f5d5
.omo/evidence/task26/task26-continuous-lifecycle-observer-v4-4e9962be
.omo/evidence/task26/task26-continuous-lifecycle-observer-v5-4e9962be
.omo/evidence/task26/task26-continuous-lifecycle-observer-v6-6aaef77d
.omo/evidence/task26/task26-continuous-lifecycle-observer-v6.1-6aaef77d
.omo/evidence/task26/task26-expired-human-window-cleanup-5e6f2f2a01321ac89c2950e511e7346d23070c51146c14c22c04db54034b28f1
.omo/evidence/task26/task26-fresh-canonical-invite-20260815T040558Z
.omo/evidence/task26/task26-gate20-3b42723d0e8256351defdf31958f9ade795f80a7be456529740949c87875d527-artifacts
.omo/evidence/task26/task26-gate20-3b42723d0e8256351defdf31958f9ade795f80a7be456529740949c87875d527-direct-retry-artifacts
.omo/evidence/task26/task26-gate20-3b42723d0e8256351defdf31958f9ade795f80a7be456529740949c87875d527-retry-artifacts
.omo/evidence/task26/task26-gate20-5e6f2f2a01321ac89c2950e511e7346d23070c51146c14c22c04db54034b28f1-artifacts
.omo/evidence/task26/task26-gate20-653cc2f0b47873e95f985ea3eb32b8f2c044d6de64cd9e8fe37ebcec67c7a723-artifacts
.omo/evidence/task26/task26-gate20-corrected-harness-4e9962be49b72951c3b9ed7e1a4fe36d0ca03e757872ae03364c1ab7a790f32b-st_01a0080c
.omo/evidence/task26/task26-gate20-test-isolated-e788f5d56aef04da3097007e2ab79614f2060607d4331162169733b1208d9377-st_01a0080c
.omo/evidence/task26/task26-gates18-20-activation-successor-30bcd6633875050aa4f56f49a8bb26cd616ffa2409fc04caef523a8a62d5d1cc-st_01a0080c
.omo/evidence/task26/task26-gates18-20-clarification-normalized-4e9962be49b72951c3b9ed7e1a4fe36d0ca03e757872ae03364c1ab7a790f32b-st_01a0080c
.omo/evidence/task26/task26-gates18-20-summary-production-e2f1699e8586ed3f835eaa2a3eaeaecaf5e52a4ad82aa931caade0ed377077ce-st_01a0080c
.omo/evidence/task26/task26-inode-test-fix-successor-1bec19bee4e4cdde1eb5fb1352d3228900ad9e1d8200e307d904a05c8756651c
.omo/evidence/task26/task26-inode-test-fix-successor-6aaef77de87489c3be493978645781b24b55ab07513ff1f880164741d0bd3c73
.omo/evidence/task26/task26-invite-harness-st_01a0056a
.omo/evidence/task26/task26-invite-harness-st_01a0056a-v2
.omo/evidence/task26/task26-invite-harness-st_01a0056a-v3
.omo/evidence/task26/task26-lifecycle-observer-st_01a005b4
.omo/evidence/task26/task26-owner-customer-v1-candidate
.omo/evidence/task26/task26-owner-customer-v1-candidate-v2
.omo/evidence/task26/task26-owner-customer-v1-candidate-v3
.omo/evidence/task26/task26-post-claim-adjudicator-st_01a007d9
.omo/evidence/task26/task26-post-claim-adjudicator-v2-st_01a007d9
.omo/evidence/task26/task26-post-launch-owner-review-repair-5e6f2f2a01321ac89c2950e511e7346d23070c51146c14c22c04db54034b28f1
.omo/evidence/task26/task26-repaired-archive-successor-2e0894eac92bc396cc4723bf1f18ebc653b95018dd41574df435941c235da925
.omo/evidence/task26/task26-repaired-archive-successor-3b42723d0e8256351defdf31958f9ade795f80a7be456529740949c87875d527
.omo/evidence/task26/task26-repaired-archive-successor-653cc2f0b47873e95f985ea3eb32b8f2c044d6de64cd9e8fe37ebcec67c7a723
.omo/evidence/task26/task26-runbook-rebind-2e0894ea-st_01a0054e
.omo/evidence/task26/task26-runtime-rebind-50685ac2c063c5b18fa895cc1991e3473368d177c7309bf54af0374ea67ba1d1
.omo/evidence/task26/task26-same-actor-preflight-st_01a00547
.omo/evidence/task26/task26-summary-production-successor-e2f1699e8586ed3f835eaa2a3eaeaecaf5e52a4ad82aa931caade0ed377077ce
.omo/evidence/task26/task26-telegram-network-probe-v3-e788f5d5
.omo/evidence/task26/task26-test-isolated-successor-e788f5d56aef04da3097007e2ab79614f2060607d4331162169733b1208d9377
.omo/evidence/task26/task26-token-rotation-successor-4a8627af8f9b931d537e17dbd3e357b7a0f2b2abffdbf28f6ad19a8d32893ec0
.omo/evidence/task26/task26-trainer-free-successor-seal-input-653cc2f0b47873e95f985ea3eb32b8f2c044d6de64cd9e8fe37ebcec67c7a723
.omo/evidence/task26/task26-v1-successor-archive
.omo/evidence/task26/task26-workspace-quarantine-candidate-b486732266b39a04ca8e851bed5253ecee99d85cb164f5dbe963ea9eeb4b08ca
.omo/evidence/task26/task26-workspace-quarantine-candidate-d6d3e26aaa3d72403ea1ee7f7d44de9f8523399c2ef5329d0d90d37f556aa4f2
.omo/evidence/telegram-button-checkin


**Configuring driver with dynamic profile root**

rg -n 'def load_runtime_customer_registry|def _resolve_profile_root|def _resolve_registry_path' /home/cube/.hermes/profiles/dualcoachtest/workspace/checkin_cli/checkin_cli/customer_admin.py

rg -n 'class TelegramBindingStore|def _persist|_binding_store' /home/cube/projects/richard/hermes-agent/gateway/platforms/physique_checkin.py | head -80


1144:def _resolve_profile_root(profile_root: Path) -> Path:
1154:def _resolve_registry_path(profile_root: Path) -> Path:
1982:def load_runtime_customer_registry(profile_root: Path) -> CustomerRegistry: