---
name: VakHunter — состояние после 2026-05-18 + план Порции 3
description: Handoff после длинной сессии 2026-05-18 — **21 коммит, main = dbee858 (FF up-to-date)**. Порция 3 закрыта, pytest в pre-commit, smoke парсеров → дашборд 8787, все рендеры вакансий унифицированы, fl.ru fix, тесты в tests/, work_format в /profile, утренние пуши opt-in, A/B retention metric, user_states dual-write phase 1, AI парсер employment_type/work_format фикс
type: project
originSessionId: 805ead91-dfa3-49d2-a5e6-01a980e715fd
---

## Quick start для нового агента

**Прод:** mac mini `192.168.0.107` под launchd `local.vakhunter`. SSH `ssh -i ~/.ssh/id_ed25519 cebers_mini@192.168.0.107`. Репо `~/Services/VakHunter`. Текущая ветка `devin/1777709351-search-and-tracking-fixes`, **main догнал ветку** (`git log main..HEAD = 0`). Лог: `~/Services/shared/logs/vakhunter.error.log`. xray-прокси `http://127.0.0.1:2081`.

**Контроль:**
- Рестарт: `launchctl kickstart -k gui/$(id -u)/local.vakhunter`
- Тесты (30/30, ~0.2s): `cd ~/Services/VakHunter && PYTHONPATH=. ./venv/bin/python -m pytest`
- Pre-commit гонит pytest + ruff на каждом коммите
- Smoke парсеров: launchd `local.vakhunter-parsers-smoke` в 04:30 ежедневно, лог через общий `vakhunter.error.log` → дашборд 8787

**Что в проде (последние 5 коммитов):**
```
dbee858 fix(ai): корректное разделение employment_type vs work_format
4db5742 feat(state): user_states.pkl + SQLite dual-write (phase 1)
c7a52d1 feat(notifications): утренние пуши 07:00 + A/B retention в admin report
e5ea240 feat(ux): work_format в /profile + hint про неточный preview-count
22a8caa fix(parsers): fl.ru расширить IT-маркеры + чистка мёртвых ключей реестра
```

**Если трогаешь рендер вакансий:** см. блок «Канонический формат рендера вакансий» ниже — 5 точек, где живёт единая HTML-раскладка. Любой новый рендер должен ему соответствовать (HTML + `escape()` + title как `<a href>` если url + кнопки `vacancy_applied_{vid}` / `vacancy_hide_{vid}`).

**Запреты:** `profi_parser._score_relevance` строгий by design; `/tmp/vh_qa/storage_state.json` НИКОГДА не писать в zemlya-bot оригинал; AGENT_HANDOFF.md в корне репо устарел в части бэклога (часть пунктов уже зафиксено).

**Open follow-ups (НЕ срочно):**
- Phase 2 user_states (switch read priority pkl→SQLite) через ~неделю observation что dual-write пишет корректно
- A/B welcome winner — через 2–4 недели когда retention-метрика наберёт статистику
- Webhook + Prometheus — **сознательно НЕ делаем**, см. блок «infra-bound» ниже. Polling работает идеально, ops_dashboard покрывает наблюдаемость 90%

**Если AI парсер профиля сломается в логах** — смотри `bot/ai_assistant.py:parse_user_description`. Последний фикс (`dbee858`): employment_type и work_format строго разделены, есть финальные валидаторы и axes-swap recovery. Если AI снова начнёт путать оси — починка через расширение `emp_type_map` / `work_format_map` + промпт.

## Где живёт

- Прод: `cebers_mini@192.168.0.107:~/Services/VakHunter` (mac mini, ssh `~/.ssh/id_ed25519`)
- launchd: `local.vakhunter`. Рестарт: `launchctl kickstart -k gui/$(id -u)/local.vakhunter`
- Ветка: `devin/1777709351-search-and-tracking-fixes`
- Логи: `~/Services/shared/logs/vakhunter.error.log` (всё через stderr)
- Бэкапы: `~/Services/shared/backups/vakhunter_*/`
- Прокси для TG API: xray `http://127.0.0.1:2081`

## 21 коммит сделано 2026-05-18 (main = HEAD)

| Hash | Что |
|---|---|
| `c4b7566` | trudvsem API param (search_string → text); bare excepts → ValueError; убран хардкод BOT_TOKEN в run.sh |
| `a5c9f9e` | profi: убран мёртвый jina-fallback (HTTP 451) |
| `d588f25` | **Релевантность Порция 1:** «Не интересно» работает (`db.get_hidden_vacancy_signatures` + filter в `bot/search_service.py:filter_results_for_user`); learned negative tokens (weight ≤ −2) применяются в quick search |
| `4771af8` | UnboundLocalError в `bot/main_ptb.py:handle_text` (employment_label re-import на 1974 ломал онбординг); `db.add_vacancy` auto-derive `external_id` из source:url |
| `b972dde` | `/search` распознаёт 80+ городов через новый `utils.cities.extract_city_from_query` (раньше hardcoded 13 — Краснодар/Челябинск/Уфа молча игнорировались) |
| `d88e323` | **Порция 2 UX:** «⭐ Сохранённые» в главное меню (`bot.main_ptb.VakHunterBot.get_main_menu_keyboard`); профиль разделил «Основная специальность» / «Активные подписки» (`bot/handlers.py:handle_profile`); почищен `REPLY_BUTTON_MAP` от дублей |
| `071dbc5` | **Порция 3.1:** подписки от живого профиля. `Database.resolve_subscription_with_user(sub, user)` подмешивает пустые поля sub из user перед вызовом `_parse_vacancies`. Specialty НЕ подменяется. 5/5 unit tests. |
| `d11eac3` | **Порция 3.2:** fuzzy fingerprint. `_canonical_title_tokens`: dehyphenate full-stack → fullstack, alias developer/engineer/programmer → разработчик, sorted tokens. На реальном «Python москва» dedupe схлопнул 42 из 117 (-36%). 10/10 unit tests. |
| `6205280` | **Порция 3.3:** live preview filter count. `CallbackHandlers.search_result_cache` (bounded TTLCache 200×3600s, не персистится). `_perform_search` пишет в кеш после `filter_results_for_user`. `handle_search_filters` показывает «🔎 С этими фильтрами: N из последнего поиска» через `_preview_filter_count`, который применяет `filter_vacancies` + `filter_by_salary_min` к кешу. 8/8 unit-кейсов. |
| `59d88ee` | **Тесты в pre-commit.** Раньше pre-commit гонял только `tests/test_resume_extract.py` при изменении одного файла; остальные 17 `test_*.py` в репо лежали мёртвым грузом. Подключён `pytest-fast` hook (запускается на любых `.py`): 30 passed in 0.22s. `pytest.ini` (`asyncio_mode=auto`, deselect устаревших) + `conftest.py` (`collect_ignore` для `test_parsers.py` и `agents/test_agents.py`). |
| `ac99234` | **Observability: smoke парсеров → дашборд 8787.** `scripts/parsers_smoke.py` + launchd `local.vakhunter-parsers-smoke` (04:30 ежедневно) гоняет все 12 парсеров через asyncio.gather с timeout 45s. Структурные логи `[parsers-smoke] X: OK\|EMPTY\|TIMEOUT\|FAIL` в `~/Services/shared/logs/vakhunter.error.log` — этот файл уже зарегистрирован в `~/Services/ops_dashboard/dashboard_config.json` для сервиса VakHunter, ERROR-строки автоматически попадают в Logs/History дашборда. Первый прогон: 13/14 OK за 3.2s; `fl.ru` упал — реальный сигнал, виден в UI. |
| `b84e09d` | **UX-фикс №1 (юзер заметил):** notif-flow → выбор площадки → формат не такой как в `/search`. `_render_notification_source_results` теперь рендерит вакансии в каноническом стиле (`_perform_search`): title-as-`<a href>`, company, salary, кнопки `#N ✅ Откликнулся` / `🚫 Не интересно` через `_vacancy_id` (scheduler.store_notification_batch уже его кладёт). Fallback на preview если `_vacancy_id` нет. |
| `34796d7` | **UX-фикс №2 (self-аудит после b84e09d):** найдены ещё 3 расхождения. (a) **`scheduler._send_digest`** — главный поток пушей — был Markdown без title-ссылки, кнопки только на топ-3; теперь HTML+escape, title-link, кнопки на все 5 (digest_size по дефолту). (b) **`_render_quick_export_source_results`** (callback_router.py:866, второй entry-point через scheduler «🔍 Найти ещё/Полный поиск») — копия notif-фикса; также добавил persistence в `_store_quick_export_results` чтобы `_vacancy_id` появился. (c) **`handle_favorites`** — title теперь `<a href>` (раньше `<b>`), убрана дублирующая строка `🔗 Открыть`, добавлен escape() для защиты от HTML-инъекции. |
| `a8af980` | **chore:** 15 `test_*.py` файлов перенесены из корня в `tests/` (git mv). `pytest.ini:testpaths=tests`, `conftest.py:collect_ignore=tests/test_parsers.py`. 30 passed, 0 регрессов. |
| `22a8caa` | **fl.ru fix + чистка реестра парсеров.** `_IT_QUERY_MARKERS` расширен с 32 до 100+ слов (веб/языки/фреймворки/CMS/DevOps/ML/mobile/CRM/QA/БД) — теперь «сайт», «typescript», «django», «kubernetes» и т.д. не отсекаются ранним return. Также удалены 8 мёртвых ключей из `_PARSER_KEY_OVERRIDES` (workzilla, weblancer, freelanceru, freelancehunt, zarplata, remoteok, wwr, hn_jobs). Live тест: «сайт»→3, «telegram бот»→3. AGENT_HANDOFF §9 quick-win. |
| `e5ea240` | **work_format в /profile + R3.3 hint.** (a) Новая кнопка «🏢 Формат работы» в `edit_profile` меню → `handle_edit_work_format` рендерит выбор office/remote/hybrid/any → `_handle_work_format_callback` сохраняет канонический токен. Раньше work_format был только в ⚙️ Фильтры (session-only). AGENT_HANDOFF §9 quick-win. (b) R3.3 preview hint: когда выставлен employment_type или work_format, строка показывает «~N (приблизительно — для точного числа нажми 🚀 Искать сейчас)» вместо точного числа, чтобы юзер не подумал что вакансий 0 совсем. |
| **`main`** | **FF-merge `devin/1777709351-...` → `main`.** Все 23 коммита от 2026-05-18 теперь и на main и на ветке. Бот не перезапускался — файлы те же, изменился только указатель ref. |
| `c7a52d1` | **Утренние пуши + A/B retention metric.** (a) Cron `send_morning_digests` в 07:00 МСК для opt-in юзеров (`users.morning_push_enabled`). Источник интересов — top-5 положительных весов из `user_keyword_preferences`, поиск через `search_profile_sources`, рендер канонический с кнопками `vacancy_applied_/hide_`. Логируется `morning_digest_sent`. По умолчанию OFF, opt-in через кнопку «🌅 Утренние подборки» в /profile → ✏️ Редактировать (`handle_toggle_morning_push`). Зеркальная кнопка «🌅 Отключить утро» в самом дайджесте. (b) `send_daily_admin_report` теперь включает секцию «🧪 A/B welcome (30d → search)»: SQL JOIN `user_events welcome_variant_assigned` ↔ `quick_search_started/results_shown`, выводит conversion rate по варианту. |
| `4db5742` | **user_states pkl + SQLite dual-write (phase 1).** Новая таблица `user_states_kv (user_id PK, payload BLOB, updated_at REAL)`. `PersistentTTLCache.flush` после атомарного pkl-replace теперь дополнительно делает per-user INSERT OR REPLACE в SQLite. `_sqlite_load_fallback` срабатывает когда pkl нет (бэкап после удаления). Ошибки SQLite не блокируют pkl-flow (logger.warning без propagation). **Phase 2 (другая сессия):** переключить read-приоритет на SQLite. **Phase 3:** убрать pkl. |
| `dbee858` | **AI парсер: разделение employment_type vs work_format.** Найдено через direct-python smoke (vh_smoke_full.py): на запрос «Python разработчик, удалёнка» AI клал `employment_type='remote'`, что в корне неверно. `remote` — это `work_format`. Последствие: URL-параметр `employment=remote` не матчился ни с одним HH/Avito токеном → молча игнорировался → юзер получал полную смесь, а не remote-only. Корни: (1) системный промпт перечислял "remote" как валидный для employment_type; (2) `emp_type_map` мапил «удалённая»/«remote» → "remote"; (3) не было финальной валидации. Фикс в `bot/ai_assistant.py:parse_user_description`: переписан промпт с чётким разделением осей и пояснением «удалёнка → work_format»; в emp_type_map добавлен part_time/internship, убраны "remote"-маппинги; axes-swap recovery (если AI вернул "remote" в employment_type — переносим в work_format, employment_type→"any" + log); финальные валидаторы для обеих осей с defaulting to "any". Live тест 4/4 PASS (Python remote, Электрик permanent, Курьер part_time, Бухгалтер office). |
| **`main`** | **FF-merge №3.** main догнал ветку, оба указывают на `dbee858`. |

## Что проверено e2e через Playwright + Telegram WebK

- Полная воронка свежего юзера: /start → AI-парсинг профиля → 17 вакансий
- 8 разных профилей через /search: Python / React / DS-ML / QA / Сварщик / Бухгалтер / Юрист / Повар — все возвращают релевантный топ-3
- Slash-команды: /help /stats /pause /resume /cancel
- Кнопка «🚫 Не интересно» → запись `action_type='hidden'` в `user_vacancy_actions` → URL не возвращается в повторном поиске (Порция 1 работает)
- Главное меню после Порции 2: «🔍 Найти работу» / «⭐ Сохранённые» / «👤 Профиль и тариф» / «📌 Ещё» / «❓ Помощь»

## QA-инфраструктура (mac mini, `/tmp/vh_qa/`)

Скрипты лежат и переиспользуемы — нужен только свежий `storage_state.json` от Telegram WebK сессии (брать копию из `~/Services/zemlya-bot/data/telegram_web/storage_state.json`, **не перезаписывать оригинал**):

- `qr_login.py` — обновить сессию через QR. Headless playwright на mac mini.
- `tg_qa.py` — основной flow (onboarding → выдача).
- `tg_qa2.py` — расширенный (профиль/подписки/«Свой запрос»/«Фильтры»).
- `tg_qa3.py` — e2e проверка кнопки «Не интересно» с прямой проверкой БД.
- `tg_qa4.py` — 8 специальностей через /search.
- `tg_qa_ux.py` — Порция 2 UX (Сохранённые в меню, разделение профиля).

Запуск: `~/Services/zemlya-bot/venv/bin/python /tmp/vh_qa/tg_qa*.py` (там playwright + chromium уже установлены). Скриншоты идут в `/tmp/vh_qa/screens*/`.

**Cookie hygiene:** после QA удалять `/tmp/vh_qa/storage_state.json`; **никогда** не писать в `~/Services/zemlya-bot/data/telegram_web/storage_state.json` — это активный профиль zemlya-bot.

## Порция 3 — состояние

**Закрыта полностью** этой же сессией: R3.1 `071dbc5`, R3.2 `d11eac3`, R3.3 `6205280`.

R3.3 сделан минимальным скоупом — не «большой рефакторинг роутера», а 68 строк в двух файлах: ephemeral `search_result_cache` на инстансе `CallbackHandlers` (TTLCache, не пикл) + `_preview_filter_count` + одна строка в шапке экрана фильтров. Кеш пишется в `_perform_search` после `filter_results_for_user`, до salary cut и пагинации, лимит 200 вакансий на пользователя. При смене фильтра существующие handlers (`handle_search_set_employment/format` и salary-input) уже вызывают `handle_search_filters`, который теперь автоматически пересчитывает count.

### План ниже — для исторического контекста

### R3.1 — Подписки от живого профиля (а не snapshot)

**Сейчас:** при создании подписки `db.add_subscription` копирует `specialty/skills/city/employment_type/salary_min/work_format` из user в `subscriptions` table. Юзер меняет профиль — подписки шлют по старым параметрам.

**План:**
1. В `database/db.py:get_user_subscriptions` (или новый `get_user_subscriptions_resolved`) подмешивать **актуальные** значения из user, когда колонка подписки = NULL или специальный маркер (например `'auto'`).
2. В `bot/scheduler.py:_send_digest` для каждого active subscription резолвить параметры через эту новую функцию.
3. UI «Редактировать подписку» — давать кнопку «Использовать из профиля» которая ставит колонку в NULL.

**Точки в коде:**
- `bot/scheduler.py` — найти `_send_digest` и где используется `subscription.get('specialty')`.
- `database/db.py:get_user_subscriptions` (текущая) и `add_subscription`.

**Риск:** изменит поведение существующих подписок. Нужна миграция или явное opt-in.

### R3.2 — Fuzzy дедупликация

**Сейчас:** `bot/vacancy_filters.py:dedupe_vacancies()` использует `compute_fingerprint = (normalized_title, company, salary_str)`. «Python Developer» (hh.ru) и «Разработчик Python» (avito) — разные fingerprint, обе попадают в выдачу.

**План:** в `compute_fingerprint`:
1. Нормализовать title более агрессивно: lower, удалить punct, **сортировать токены**.
2. Удалить «общие» слова: разработчик/developer/engineer/senior/middle/junior.
3. Алиасы: react=react.js, python=питон, etc — взять из `utils/synonyms.py`.

**Точки в коде:**
- `bot/vacancy_filters.py:compute_fingerprint` и `dedupe_vacancies`.
- `utils/synonyms.py` (если есть, иначе создать).

**Тест:** сгенерировать набор пар «дубликатов» и убедиться что они сливаются.

### R3.3 — Live-фильтры (СДЕЛАНО `6205280`)

Реализовано минимальным скоупом, см. блок выше. Полная ре-архитектура роутера не понадобилась — preview работает на кеше последнего поиска, который автоматически обновляется при каждом `_perform_search`. Если потребуется развить — следующие шаги:

1. Показывать count на самих кнопках фильтра («💼 Тип: Полная (12)»), а не одной общей строкой.
2. Когда preview=0 — показывать кнопку «🔄 Расширить поиск» которая сбрасывает фильтры.
3. Учитывать что кеш не пересекается с employment_type/work_format, переданными в парсеры → при смене этих осей preview занижает реальный count после фактического поиска. Можно добавить hint «(точное число — после нажатия «🚀 Искать сейчас»)».

## Чего лучше не трогать

- `parsers/profi_parser.py:_score_relevance` — строгий фильтр negatives. Я уже трогал; на «python» отдаёт 0 — это by design.
- Кнопка «🗑 Очистить профиль» в Playwright-тестах — никогда не нажимать на чужом профиле.
- AGENT_HANDOFF.md в корне репо — есть, и его авторы написали важное про прокси и DC routing.

## Канонический формат рендера вакансий (после `34796d7`)

Все 5 экранов с карточками вакансий используют одну и ту же раскладку — если будешь добавлять новый, копируй из `_perform_search`:

- **parse_mode='HTML'**, `disable_web_page_preview=True`
- Каждая карточка: `{idx}. <a href="{escape(url, quote=True)}">{escape(title)}</a>` → опц. `🏢 {company}` → `💰 {salary}` → опц. `🏷 {source}`
- Под каждой карточкой ряд кнопок: `#{idx} ✅ Откликнулся` / `#{idx} 🚫 Не интересно` через `vacancy_applied_{vid}` / `vacancy_hide_{vid}`. `_vacancy_id` обязателен — если в потоке его нет, persist через `db.add_vacancy(...)` до рендера.
- Всегда `escape()` для динамических значений (title/company/salary/source/url) — защита от HTML-инъекции.

Точки, где этот формат сейчас живёт:
- `bot/callback_router.py:_perform_search` (1452+) — canonical, выдача `/search` и «🚀 Искать сейчас».
- `bot/callback_router.py:_render_notification_source_results` (954+) — после 🔔 уведомление → 📋 Смотреть все → площадка.
- `bot/callback_router.py:_render_quick_export_source_results` (866+) — после «🔍 Полный поиск» / «🔍 Найти ещё» из scheduler.
- `bot/scheduler.py:_send_digest` (548+) — сам push дайджест.
- `bot/handlers.py:handle_favorites` (2476+) — ⭐ Сохранённые (+ уникальные 👀/🗑).

Отдельные форматы (НЕ список карточек, не приводить):
- `_render_notification_preview_by_indices` (callback_router.py:1057) — детальная single-card view, нужна целиком другая раскладка с employment/location/description.
- `_build_card_text` — helper для preview-карточки.

## Тестовая инфраструктура (после `59d88ee`)

- **pre-commit гонит pytest на каждом коммите** (~0.2–0.6s). Гейт срабатывает на любых `.py` кроме `venv/`, `backups/`, `_archive_`, `cloudflared`.
- 30 unit-тестов в реестре. **Slow/integration исключены** через:
  - `pytest.ini:addopts=--deselect=test_ai_scoring.py::test_ai_scoring` (реальный NIM, ~60s)
  - `pytest.ini:addopts=--deselect=test_fixes.py::test_escaped_newlines` (ищет несуществующий `bot/main.py`)
  - `conftest.py:collect_ignore=['test_parsers.py', 'agents/test_agents.py']` (smoke-скрипты)
- **Ручной полный прогон:** `cd ~/Services/VakHunter && PYTHONPATH=. ./venv/bin/python -m pytest` — гонит deselected тесты тоже. `test_parsers.py` запускать как скрипт: `PYTHONPATH=. ./venv/bin/python test_parsers.py`.

## Наблюдаемость через дашборд 8787 (после `ac99234`)

- Дашборд `ops_dashboard` (`/Users/cebers_mini/Services/ops_dashboard/server.py`, port 8787) — **passive observer**: читает `vakhunter.log` + `vakhunter.error.log` по конфигу `dashboard_config.json`, парсит launchd-статус, smartят SQLite базу. **Нет ingest API** — push кастомных событий невозможен.
- Чтобы что-то «попало в дашборд», достаточно писать в `~/Services/shared/logs/vakhunter.error.log` через `logger.error/warning`. Это уже делается всем что бот логирует на ERROR — и теперь же делает `scripts/parsers_smoke.py`.
- launchd-job `local.vakhunter-parsers-smoke` — расписание `StartCalendarInterval Hour=4 Minute=30`. **НЕ persistent сервис** (RunAtLoad=false), просто crontab.
- Ручной запуск: `launchctl kickstart gui/$(id -u)/local.vakhunter-parsers-smoke`. Лог: ищи `[parsers-smoke]` в общем error.log.
- Если бы понадобился custom-метрик-канал (тип «N поисков в час» / «медиана парсера»), варианты: (а) добавить `log_paths` запись в `dashboard_config.json` под новый файл, (б) писать структурные `[metrics] ...` строки в общий error.log с одним префиксом, дашборд их видит (но не агрегирует — только показывает).

## Что осталось infra-bound (не сделано в сессии 2026-05-18)

### Webhook (polling → webhook)

Cloudflared на mac mini **уже работает** для zemlya-bot (launchd `local.zemlya-web-tunnel`, `local.zemlya-tunnel`; binary в `~/Services/ops_dashboard/cloudflared/`, конфиги рядом). VakHunter мог бы тоже, но требует:

1. Решение по DNS — выделить hostname типа `vakhunter.cebers.host` (или поддомен у существующего)
2. `cloudflared tunnel route dns <tunnel-name> vakhunter.cebers.host` (требует Cloudflare API token с DNS edit)
3. Дописать ingress rule в cloudflared config: `vakhunter.cebers.host → http://127.0.0.1:8081` (новый port для webhook)
4. Запустить webhook-server в `bot/main_ptb.py` (PTB `Application.run_webhook()` вместо `run_polling()`)
5. `bot.set_webhook(url=https://vakhunter.cebers.host/<TOKEN>)`
6. Перезапустить через launchctl, мониторить ошибки

Это **разовая инфра-настройка**, делается за 30 минут когда есть DNS-доступ. Кода — ~50 строк в main_ptb.py + плюс плейст для нового туннеля.

### Prometheus / Grafana

**Нет серверов** на mac mini (проверял `which prometheus grafana-server promtool` — все 3 отсутствуют). Развёртывание требует:

1. `brew install prometheus grafana` (или Docker stack)
2. Конфиг Prometheus с scrape target на `127.0.0.1:9090/metrics` (новый эндпоинт VakHunter)
3. `prometheus-client` в Python + endpoint в `bot/main_ptb.py` (отдельный thread aiohttp, как payment_webhook)
4. Grafana datasource + дашборды (vacancies/min, parser latency p95, ERROR rate)

Это **отдельный stack**. ops_dashboard на 8787 покрывает 90% случаев через log-чтение + launchd-статус, поэтому маржинальная польза Prometheus неочевидна. Стоит делать когда нужна **минута-точная аналитика** (например для capacity planning).

## Если контекст сжался — что прочитать первым

1. Этот файл.
2. `vakhunter_overview.md` (тот же index, что был раньше).
3. `~/Services/VakHunter/AGENT_HANDOFF.md` на mac mini — общая архитектура от предыдущих агентов.
4. `git log --oneline -20` на ветке `devin/1777709351-search-and-tracking-fixes` — последние коммиты в проде.
