---
phase: 01-matrix-qa-polish
plan: 03
type: execute
wave: 2
depends_on: ["01-01", "01-02"]
files_modified:
  - adapter/matrix/bot.py
  - adapter/matrix/reactions.py
  - adapter/matrix/handlers/confirm.py
  - adapter/matrix/handlers/settings.py
autonomous: true
requirements: []

must_haves:
  truths:
    - "OutgoingUI renders as text + '!yes / !no' hint, no m.reaction events sent"
    - "_button_action_to_reaction function is removed from bot.py"
    - "on_reaction callback is removed from bot.py"
    - "ReactionEvent import is removed from bot.py"
    - "build_skills_text no longer mentions reactions 1-9"
    - "build_confirmation_text uses !yes/!no instead of reaction emojis"
    - "!yes reads pending_confirm from store and returns action description"
    - "!no clears pending_confirm and returns cancellation message"
    - "!settings returns a read-only dashboard with skills/soul/safety/chats status"
  artifacts:
    - path: "adapter/matrix/bot.py"
      provides: "Clean send_outgoing without reactions, pending_confirm storage on OutgoingUI"
      contains: "!yes"
    - path: "adapter/matrix/reactions.py"
      provides: "Updated text builders without reaction references"
    - path: "adapter/matrix/handlers/confirm.py"
      provides: "!yes/!no handlers reading pending_confirm"
      contains: "get_pending_confirm"
    - path: "adapter/matrix/handlers/settings.py"
      provides: "Read-only dashboard for !settings"
  key_links:
    - from: "adapter/matrix/bot.py"
      to: "adapter/matrix/store.py"
      via: "set_pending_confirm on OutgoingUI send"
      pattern: "set_pending_confirm"
    - from: "adapter/matrix/handlers/confirm.py"
      to: "adapter/matrix/store.py"
      via: "get_pending_confirm / clear_pending_confirm"
      pattern: "get_pending_confirm"
---

<objective>
Remove all reaction-based UX from the Matrix adapter and replace with text-based !yes/!no confirmation. Update settings dashboard to read-only format.

Purpose: Per D-06/D-07/D-08, reactions are removed entirely. OutgoingUI renders as plain text with !yes/!no hint. Per D-12, !settings becomes a read-only dashboard.

Output: Clean bot.py without reactions, working !yes/!no confirmation flow, updated text builders, read-only settings dashboard.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md
@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md

@adapter/matrix/bot.py
@adapter/matrix/reactions.py
@adapter/matrix/handlers/confirm.py
@adapter/matrix/handlers/settings.py
@adapter/matrix/store.py
@adapter/matrix/converter.py
@core/protocol.py

<interfaces>
<!-- From adapter/matrix/store.py (after Plan 01 adds pending_confirm helpers): -->

```python
PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"

async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None
async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None
async def clear_pending_confirm(store: StateStore, room_id: str) -> None
```

<!-- From core/protocol.py — OutgoingUI and UIButton: -->

```python
@dataclass
class UIButton:
    label: str
    action: str
    payload: dict = field(default_factory=dict)
    style: str = "secondary"

@dataclass
class OutgoingUI:
    chat_id: str
    text: str
    buttons: list[UIButton] = field(default_factory=list)

@dataclass
class IncomingCallback:
    user_id: str
    platform: str
    chat_id: str
    action: str
    payload: dict = field(default_factory=dict)
```

<!-- From adapter/matrix/converter.py — how !yes/!no become IncomingCallback: -->

```python
# In from_command():
if command in {"yes", "no"}:
    action = "confirm" if command == "yes" else "cancel"
    return IncomingCallback(
        user_id=sender,
        platform=PLATFORM,
        chat_id=chat_id,
        action=action,
        payload={"source": "command", "command": command},
    )
```

<!-- From adapter/matrix/handlers/__init__.py — confirm/cancel registration: -->

```python
dispatcher.register(IncomingCallback, "confirm", handle_confirm)
dispatcher.register(IncomingCallback, "cancel", handle_cancel)
```

<!-- From sdk.interface.UserSettings — used by settings dashboard: -->

```python
@dataclass
class UserSettings:
    skills: dict
    connectors: dict
    soul: dict
    safety: dict
    plan: dict
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Remove reactions from bot.py, update send_outgoing for !yes/!no (per D-06, D-07)</name>
  <files>adapter/matrix/bot.py</files>
  <read_first>adapter/matrix/bot.py, adapter/matrix/store.py, core/protocol.py</read_first>
  <action>
Modify `adapter/matrix/bot.py` with these specific changes:

**1. Remove ReactionEvent import (line 14):**
Change the nio import block from:
```python
from nio import (
    AsyncClient,
    AsyncClientConfig,
    InviteMemberEvent,
    MatrixRoom,
    ReactionEvent,
    RoomMemberEvent,
    RoomMessageText,
)
```
to:
```python
from nio import (
    AsyncClient,
    AsyncClientConfig,
    InviteMemberEvent,
    MatrixRoom,
    RoomMemberEvent,
    RoomMessageText,
)
```

**2. Remove `from_reaction` import (line 20):**
Change:
```python
from adapter.matrix.converter import from_reaction, from_room_event
```
to:
```python
from adapter.matrix.converter import from_room_event
```

**3. Add store import for pending_confirm:**
Add this import:
```python
from adapter.matrix.store import set_pending_confirm
```

**4. Delete the entire `on_reaction` method from MatrixBot class (lines 106-114).**

**5. Delete the entire `_button_action_to_reaction` function (lines 135-140).**

**6. Rewrite the OutgoingUI block in `send_outgoing` function.**
Replace the existing `if isinstance(event, OutgoingUI):` block (lines 154-180) with:

```python
    if isinstance(event, OutgoingUI):
        lines = [event.text]
        if event.buttons:
            lines.append("")
            for btn in event.buttons:
                lines.append(f"  {btn.label}")
            lines.append("")
            lines.append("Ответьте !yes для подтверждения или !no для отмены.")
        body = "\n".join(lines)
        await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
        # Store pending confirmation for !yes/!no handler
        if event.buttons:
            action_id = event.buttons[0].action if event.buttons else "unknown"
            payload = event.buttons[0].payload if event.buttons else {}
            await set_pending_confirm(store, room_id, {
                "action_id": action_id,
                "description": event.text,
                "payload": payload,
            })
        return
```

**PROBLEM:** `send_outgoing` is a module-level function with signature `async def send_outgoing(client, room_id, event)`. It doesn't receive `store`. We need to pass `store` to it.

**Solution:** Change `send_outgoing` signature to include `store`:
```python
async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent, store: StateStore | None = None) -> None:
```

And update `MatrixBot._send_all` to pass store:
```python
    async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
        for event in outgoing:
            await send_outgoing(self.client, room_id, event, store=self.runtime.store)
```

**7. In `main()`, remove the on_reaction callback registration.**
Delete this line:
```python
    client.add_event_callback(bot.on_reaction, ReactionEvent)
```

**8. Add StateStore import at top:**
```python
from core.store import InMemoryStore, SQLiteStore, StateStore
```
(StateStore is already imported on line 37 — verify it's there.)

The `set_pending_confirm` call in the OutgoingUI handler should guard against store being None:
```python
        if event.buttons and store is not None:
            action_id = event.buttons[0].action
            payload = event.buttons[0].payload
            await set_pending_confirm(store, room_id, {
                "action_id": action_id,
                "description": event.text,
                "payload": payload,
            })
```
  </action>
  <verify>
    <automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.bot import send_outgoing, MatrixBot, build_runtime; print('OK')" && python -c "import ast; tree = ast.parse(open('adapter/matrix/bot.py').read()); names = [n.name for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]; assert '_button_action_to_reaction' not in names, 'reaction helper still exists'; assert 'on_reaction' not in names, 'on_reaction still exists'; print('REACTION CODE REMOVED')"</automated>
  </verify>
  <acceptance_criteria>
- `adapter/matrix/bot.py` does NOT contain the string `_button_action_to_reaction`
- `adapter/matrix/bot.py` does NOT contain the string `on_reaction`
- `adapter/matrix/bot.py` does NOT contain `ReactionEvent`
- `adapter/matrix/bot.py` does NOT contain `from_reaction`
- `adapter/matrix/bot.py` does NOT contain `m.reaction`
- `adapter/matrix/bot.py` contains `Ответьте !yes для подтверждения или !no для отмены.`
- `adapter/matrix/bot.py` contains `set_pending_confirm`
- `send_outgoing` function signature includes `store` parameter
  </acceptance_criteria>
  <done>bot.py has no reaction code; OutgoingUI renders text + !yes/!no; pending_confirm stored on OutgoingUI send</done>
</task>

<task type="auto">
  <name>Task 2: Update reactions.py text builders + confirm.py handlers + settings.py dashboard (per D-06, D-07, D-08, D-12)</name>
  <files>adapter/matrix/reactions.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py</files>
  <read_first>adapter/matrix/reactions.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py, adapter/matrix/store.py</read_first>
  <action>
**Part A: Update adapter/matrix/reactions.py**

1. Update `build_skills_text` — replace the last line "Реакции 1-9 переключают навыки." with instruction for text commands:

Replace:
```python
    lines.append("Реакции 1️⃣-9️⃣ переключают навыки.")
```
With:
```python
    lines.append("!skill on/off <название> — переключить навык.")
```

2. Update `build_confirmation_text` — remove reaction emojis, use only !yes/!no:

Replace the entire function with:
```python
def build_confirmation_text(description: str) -> str:
    return "\n".join(
        [
            "Lambda",
            description,
            "",
            "Ответьте !yes для подтверждения или !no для отмены.",
        ]
    )
```

3. Remove `add_reaction` and `remove_reaction` functions entirely (they send m.reaction events which are no longer used).

4. Keep `CONFIRM_REACTION`, `CANCEL_REACTION`, `SKILL_REACTIONS`, `REACTION_TO_INDEX`, `reaction_to_skill_index` — they are still imported by `converter.py` for `from_reaction`. Even though `from_reaction` is no longer called from bot.py, converter.py still exports it and removing would break imports. Keep for backwards compat.

Actually, check: `from_reaction` is imported in `converter.py` definition, not as an external import. And `bot.py` no longer imports `from_reaction`. But `converter.py` imports `CANCEL_REACTION, CONFIRM_REACTION, reaction_to_skill_index` from `reactions.py`. So those constants MUST stay.

Keep: `CONFIRM_REACTION`, `CANCEL_REACTION`, `SKILL_REACTIONS`, `REACTION_TO_INDEX`, `reaction_to_skill_index`, `build_skills_text`, `build_confirmation_text`.
Remove: `add_reaction`, `remove_reaction`.
Remove the `AsyncClient` import since add_reaction/remove_reaction used it and nothing else does.

Updated file should look like:
```python
from __future__ import annotations

from sdk.interface import UserSettings

CONFIRM_REACTION = "👍"
CANCEL_REACTION = "❌"
SKILL_REACTIONS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"]
REACTION_TO_INDEX = {emoji: idx + 1 for idx, emoji in enumerate(SKILL_REACTIONS)}


def build_skills_text(settings: UserSettings) -> str:
    lines: list[str] = ["Скиллы"]
    for idx, (name, enabled) in enumerate(settings.skills.items(), start=1):
        state = "on" if enabled else "off"
        emoji = SKILL_REACTIONS[idx - 1] if idx - 1 < len(SKILL_REACTIONS) else f"{idx}."
        lines.append(f"  {state}  {emoji} {name}")
    lines.append("")
    lines.append("!skill on/off <название> — переключить навык.")
    return "\n".join(lines)


def build_confirmation_text(description: str) -> str:
    return "\n".join(
        [
            "Lambda",
            description,
            "",
            "Ответьте !yes для подтверждения или !no для отмены.",
        ]
    )


def reaction_to_skill_index(key: str) -> int | None:
    return REACTION_TO_INDEX.get(key)
```

**Part B: Update adapter/matrix/handlers/confirm.py**

Rewrite to read pending_confirm from store. The handlers receive the standard signature `(event, auth_mgr, platform, chat_mgr, settings_mgr)` but need access to `store`. Since they're registered in `__init__.py` as plain functions (not closures), convert them to closure factories.

Replace entire file:

```python
from __future__ import annotations

from adapter.matrix.store import get_pending_confirm, clear_pending_confirm
from core.protocol import IncomingCallback, OutgoingMessage


def make_handle_confirm(store=None):
    async def handle_confirm(
        event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
    ) -> list:
        if store is None:
            return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]

        pending = await get_pending_confirm(store, event.chat_id)
        if not pending:
            return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]

        description = pending.get("description", "действие")
        action_id = pending.get("action_id", "unknown")
        await clear_pending_confirm(store, event.chat_id)

        return [
            OutgoingMessage(
                chat_id=event.chat_id,
                text=f"Подтверждено: {description}",
            )
        ]

    return handle_confirm


def make_handle_cancel(store=None):
    async def handle_cancel(
        event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
    ) -> list:
        if store is None:
            return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]

        pending = await get_pending_confirm(store, event.chat_id)
        if not pending:
            return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]

        await clear_pending_confirm(store, event.chat_id)

        return [
            OutgoingMessage(
                chat_id=event.chat_id,
                text="Действие отменено.",
            )
        ]

    return handle_cancel
```

**Part C: Update adapter/matrix/handlers/__init__.py for new confirm imports**

Change confirm imports from:
```python
from adapter.matrix.handlers.confirm import handle_cancel, handle_confirm
```
to:
```python
from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
```

Change registrations from:
```python
    dispatcher.register(IncomingCallback, "confirm", handle_confirm)
    dispatcher.register(IncomingCallback, "cancel", handle_cancel)
```
to:
```python
    dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store))
    dispatcher.register(IncomingCallback, "cancel", make_handle_cancel(store))
```

**Part D: Update adapter/matrix/handlers/settings.py — handle_settings becomes read-only dashboard (per D-12)**

Replace the `handle_settings` function body. Keep ALL other functions unchanged.

```python
async def handle_settings(
    event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
    settings = await settings_mgr.get(event.user_id)
    chats = await chat_mgr.list_active(event.user_id)

    # Skills section
    skills_lines = []
    for name, enabled in settings.skills.items():
        state = "on" if enabled else "off"
        skills_lines.append(f"  {state}  {name}")
    skills_text = "\n".join(skills_lines) if skills_lines else "  нет навыков"

    # Soul section
    soul_lines = []
    for key, value in (settings.soul or {}).items():
        soul_lines.append(f"  {key}: {value}")
    soul_text = "\n".join(soul_lines) if soul_lines else "  по умолчанию"

    # Safety section
    safety_lines = []
    for key, value in (settings.safety or {}).items():
        state = "on" if value else "off"
        safety_lines.append(f"  {state}  {key}")
    safety_text = "\n".join(safety_lines) if safety_lines else "  по умолчанию"

    # Chats section
    chat_lines = [f"  {c.display_name} ({c.chat_id})" for c in chats]
    chats_text = "\n".join(chat_lines) if chat_lines else "  нет активных чатов"

    dashboard = "\n".join([
        "Настройки",
        "",
        "Скиллы:",
        skills_text,
        "",
        "Личность:",
        soul_text,
        "",
        "Безопасность:",
        safety_text,
        "",
        f"Активные чаты ({len(chats)}):",
        chats_text,
        "",
        "Изменить: !skills, !soul, !safety",
    ])

    return [OutgoingMessage(chat_id=event.chat_id, text=dashboard)]
```
  </action>
  <verify>
    <automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.reactions import build_skills_text, build_confirmation_text; from adapter.matrix.handlers.confirm import make_handle_confirm, make_handle_cancel; from adapter.matrix.handlers.settings import handle_settings; print('OK')" && python -c "from adapter.matrix.handlers import register_matrix_handlers; print('OK')"</automated>
  </verify>
  <acceptance_criteria>
- `adapter/matrix/reactions.py` does NOT contain `add_reaction`
- `adapter/matrix/reactions.py` does NOT contain `remove_reaction`
- `adapter/matrix/reactions.py` does NOT contain the string `Реакции 1`
- `adapter/matrix/reactions.py` contains `!skill on/off`
- `adapter/matrix/reactions.py` contains `!yes` in build_confirmation_text
- `adapter/matrix/handlers/confirm.py` contains `get_pending_confirm`
- `adapter/matrix/handlers/confirm.py` contains `clear_pending_confirm`
- `adapter/matrix/handlers/confirm.py` contains `def make_handle_confirm(`
- `adapter/matrix/handlers/confirm.py` contains `def make_handle_cancel(`
- `adapter/matrix/handlers/__init__.py` contains `make_handle_confirm(store)`
- `adapter/matrix/handlers/__init__.py` contains `make_handle_cancel(store)`
- `adapter/matrix/handlers/settings.py` `handle_settings` function contains the string `Настройки` and `Скиллы:` and `Изменить:`
- `adapter/matrix/handlers/settings.py` `handle_settings` does NOT contain `!connectors` or `!plan` or `!status` or `!whoami`
  </acceptance_criteria>
  <done>Reactions removed from text builders; !yes/!no handlers read pending_confirm; !settings is read-only dashboard</done>
</task>

</tasks>

<verification>
After both tasks:
- `python -c "from adapter.matrix.bot import send_outgoing, MatrixBot; from adapter.matrix.reactions import build_skills_text; from adapter.matrix.handlers.confirm import make_handle_confirm; from adapter.matrix.handlers import register_matrix_handlers; print('ALL OK')"`
- No string `m.reaction` in `adapter/matrix/bot.py`
- No string `_button_action_to_reaction` in `adapter/matrix/bot.py`
- No string `Реакции 1` in `adapter/matrix/reactions.py`
</verification>

<success_criteria>
- bot.py: no reaction code, OutgoingUI renders text + !yes/!no, stores pending_confirm
- reactions.py: build_skills_text says "!skill on/off", build_confirmation_text says "!yes/!no"
- confirm.py: !yes reads pending_confirm and confirms, !no clears and cancels
- settings.py: !settings returns read-only dashboard
- All imports resolve
</success_criteria>

<output>
After completion, create `.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md`
</output>
