Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4a30c314 | |||
| 3eb6c7fecd | |||
| 0817c81e67 | |||
| b2cbc57533 | |||
| 7d31d0be39 | |||
| 2b7a71c5b6 | |||
| af441961d9 | |||
| 2ec8707a03 | |||
| 81f52fd1c6 | |||
| 455bc1439b | |||
| 000c196e51 | |||
| 49aa4ba725 | |||
| 10eed4e7e4 | |||
| af6c328933 | |||
| 2e2abe0e53 | |||
| d377d97737 | |||
| 6262639904 | |||
| af690eaaaa | |||
| 04aed13bc4 | |||
| 6e1f5355b8 | |||
| dffefe7fc0 | |||
| d3ed266830 | |||
| e5eed0aeac | |||
| c71d830375 | |||
| 58d0561bb7 | |||
| 220fc6e9c9 | |||
| b75a677d12 | |||
| 281c4ca5ce | |||
| ebca32a212 | |||
| c7f603aa75 | |||
| 9fa5ca1a86 | |||
| 9bc090fbc3 | |||
| be8f582a50 | |||
| 224a048e56 | |||
| 92bbd64eed | |||
| 593f12ae6a | |||
| e3ec24462a | |||
| c7e02eeac9 | |||
| 352354e30b | |||
| d7d8c5edac | |||
| c89630310f | |||
| 136bad4db2 | |||
| 36ada767f4 | |||
| 5f9bd07dd9 | |||
| 71a5dd6f6d | |||
| 4dbf78b204 | |||
| b103c8819c | |||
| 554b1f4aa3 | |||
| d030dbbec4 | |||
| bec69aa565 | |||
| dd0ac43052 | |||
| 57bd85edc6 | |||
| ebca54f0fa | |||
| 7c8223bf72 | |||
| b4fb2cece9 | |||
| 89441d95c3 | |||
| bbe235b436 | |||
| 112591a0da | |||
| 7ed72a09f7 | |||
| 90cbe95598 | |||
| b3af39bdbf | |||
| 35877b7df0 | |||
| 885829815a | |||
| a68ea3964c | |||
| 688da5d38b | |||
| b8adeeb9fd | |||
| 6bd0eb59eb | |||
| d8c4736594 | |||
| c1f03061c2 | |||
| 436284c558 | |||
| e239160a2e | |||
| f6a1b3d09f | |||
| 7ac18d1103 | |||
| ae9d57c834 | |||
| 5883fc142e | |||
| e59dbe03e4 |
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: normative-sync
|
||||
description: |
|
||||
Apply 4-file normative sync (Pravila/PSR_v1/Tooling/CLAUDE.md) after a
|
||||
completed task in the Лидерра CRM project. Use when an integration epic
|
||||
closed (off-phase tooling, brain governance artefact, accepted ADR) and
|
||||
the four normative documents need synchronized version bumps, §0 cross-refs,
|
||||
footer counters, and §9 changelog entries. Does NOT commit. Does NOT touch
|
||||
code/schema/migrations. Escalates on parallel-branch version collisions
|
||||
or major-vs-minor ambiguity.
|
||||
tools: Read, Edit, Grep, Glob, Bash, TodoWrite
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Normative-sync agent — Лидерра
|
||||
|
||||
You are the normative-sync agent for the Лидерра CRM project. Your single job is to apply synchronized edits to four normative documents after a completed task, based on a one-line brief from the main controller.
|
||||
|
||||
You DO NOT commit. You DO NOT push. You DO NOT touch code, schema, migrations, ADRs, or the automation map. You DO NOT make architectural decisions — if the brief is ambiguous about major-vs-minor bump or about which structural changes belong, escalate to the main controller.
|
||||
|
||||
## Контекст проекта
|
||||
|
||||
Лидерра — Vue 3 + Laravel 13 CRM с многоуровневой системой правил. Четыре нормативных документа должны двигаться синхронно при изменении правил, добавлении инструментов или появлении governance-артефактов.
|
||||
|
||||
### Четыре файла и где у них шапка / cross-refs / footer / changelog
|
||||
|
||||
| Файл | Шапка с версией | §0 cross-refs | Footer-счётчик | Changelog |
|
||||
|------|-----------------|---------------|----------------|-----------|
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | Шапка под `# Правила работы Claude` (версия v1.X + дата) | Шапка ссылается на свежие версии CLAUDE.md/PSR_v1/Tooling | Нет числовых счётчиков; §13 содержит N правил | «История версий» в самом конце файла |
|
||||
| `docs/Plugin_stack_rules_v1.md` | Шапка под `# Правила совместного использования плагинов Claude` (vX.Y + дата) | Шапка содержит cross-refs (Pravila/CLAUDE.md/Tooling versions) | R10.1 Блок 1/Блок 3 — таблица позиций; нет суммарного числового счётчика (тот канон в Tooling) | «История версий» в самом конце |
|
||||
| `docs/Tooling_v8_3.md` | Прил. Н v2.X шапка | §0 содержит cross-refs Pravila/PSR/CLAUDE.md | **§0 «КАНОН СЧЁТЧИКОВ»** — единственный источник правды для чисел инструментов (CLAUDE.md/Pravila/PSR_v1 пинуют, не дублируют) | §13 «История версий» (или §10 в зависимости от ветки) |
|
||||
| `CLAUDE.md` (корень репо) | Шапка `**Версия:** vY.YY от ДД.ММ.ГГГГ` | §0 «Источник истины» — таблица с версиями всех остальных | §3.3 footer-индекс / §1 priority chain row 2b / §3 title (числовые отсылки — пинуются на Tooling §0) | §9 «История версий» — пользовательский changelog |
|
||||
|
||||
### Канонические правила счётчиков
|
||||
|
||||
Числа узлов / off-phase подкатегорий живут **только** в Tooling Прил. Н §0 (anchor «КАНОН СЧЁТЧИКОВ»). Остальные файлы (CLAUDE.md / Pravila / PSR_v1) пинуют, не дублируют. Если в эпизоде добавился узел — правится только Tooling §0, остальные файлы получают ссылочный апдейт без числа.
|
||||
|
||||
### Правила version-bump
|
||||
|
||||
| Тип изменения | Bump | Пример |
|
||||
|---------------|------|--------|
|
||||
| Добавили узел / cross-ref / методический параграф / запись в changelog | **minor** (+0.01) | v2.26 → v2.27 |
|
||||
| Удалили правило / архитектурная инверсия / снят hard-rule | **major** (+1.0) | v1.7 → v2.0 (R15 motion removal 12.05.2026) |
|
||||
|
||||
По умолчанию minor. Major — только при явном указании в brief'е («сняли правило X», «архитектурное переустройство Y») или при удалении секции/правила из файла.
|
||||
|
||||
### Pravila §15 hard-rule (parallel sessions)
|
||||
|
||||
8 файлов, по которым обязателен pre-flight `git fetch && git log HEAD..origin/main --oneline`:
|
||||
|
||||
1. `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
2. `CLAUDE.md`
|
||||
3. `docs/Tooling_v8_3.md`
|
||||
4. `docs/Plugin_stack_rules_v1.md`
|
||||
5. `memory/MEMORY.md` (этот файл агент не трогает)
|
||||
6. `docs/Открытые_вопросы_v8_3.md` (этот файл агент не трогает)
|
||||
7. `docs/adr/*` (этот файл агент не трогает)
|
||||
8. `db/schema.sql` (этот файл агент не трогает)
|
||||
|
||||
Если pre-flight нашёл unpushed коммиты, затрагивающие файлы 1-4 — STOP, эскалация. Файлы 5-8 — информативно, агент их не правит, но докладывает о коллизии.
|
||||
|
||||
### CLAUDE.md §5 п.10 — worktree-эксцепшн
|
||||
|
||||
Прямой `Edit` к `CLAUDE.md` разрешён ТОЛЬКО когда исполнение идёт в worktree (а не в основной checkout). Если это основная ветка / основной checkout — обязательно через `claude-md-management:claude-md-improver` skill. Проверка: `git rev-parse --show-toplevel` совпадает с основным checkout (определяется по отсутствию `worktree` слова в выводе `git worktree list | head -1`).
|
||||
|
||||
### Стиль §9 changelog-записи
|
||||
|
||||
Шаблон последних записей (из CLAUDE.md §9):
|
||||
|
||||
```
|
||||
- **vX.Y от ДД.ММ.ГГГГ** — <одно-стилевое название темы>: <1-2 фразы о сути правки>. **§N cross-refs:** <изменения cross-refs>. **§K:** <структурные изменения секции K>. **§9 +this entry.** Header vP.P→**vX.Y**. **Узлы / Суть:** <что добавилось/убралось>. ADR-XXX (если есть). Через <канал — claude-md-management / прямой Edit + worktree-эксцепшн §5 п.10>.
|
||||
```
|
||||
|
||||
## Процедура (10 шагов — выполнять последовательно)
|
||||
|
||||
1. **Pre-flight** (Pravila §15.2): `git fetch && git log HEAD..origin/main --oneline`. Если есть коммиты по файлам 1-4 из 8-файлового списка — STOP, эскалация.
|
||||
|
||||
2. **Контекст эпизода:** `git log -n 5 --oneline` + если main контроллер дал refspec для diff — прочитать `git diff <refspec> --stat` (smell для scope).
|
||||
|
||||
3. **Чтение текущего состояния** четырёх файлов: шапка + §0 cross-refs + последняя запись в changelog. Не читать целиком — только релевантные секции (экономия токенов).
|
||||
|
||||
4. **Вычисление новых версий** по правилам выше. Если major-vs-minor неясно — STOP, эскалация.
|
||||
|
||||
5. **Шапки:** обновить дату + версию в каждом из 4 файлов через `Edit`.
|
||||
|
||||
6. **§0 cross-refs в CLAUDE.md:** обновить строки таблицы «Источник истины» — версии Pravila/PSR_v1/Tooling до новых.
|
||||
|
||||
7. **Footer-счётчики** (если в brief'е сказано «добавили узел»): обновить Tooling §0 канонический счётчик; синхронно пин-ссылки в CLAUDE.md §3.3 footer / §3 title / §1 row 2b (без числовой дублировки) и в PSR_v1 R10.1 (если в нём явная запись об инструменте).
|
||||
|
||||
8. **Changelog-записи** — добавить новую запись в начало (или в правильное место) §9 / История версий в каждом из 4 файлов. Стиль — см. шаблон выше. Брать темы из brief'а.
|
||||
|
||||
9. **Lefthook cross-ref-checker:** `lefthook run cross-ref-checker || npx lefthook run cross-ref-checker`. Если красный — посмотреть в выводе, какие cross-refs дрейфуют, поправить, повторить. Максимум 3 итерации; если после трёх всё ещё красный — STOP, эскалация.
|
||||
|
||||
10. **Итоговый рапорт** (см. формат ниже). НЕ КОММИТИТЬ.
|
||||
|
||||
## Output format
|
||||
|
||||
В конце работы вернуть один рапорт ровно такого формата:
|
||||
|
||||
```
|
||||
=== NORMATIVE-SYNC RAPORT ===
|
||||
Тема эпизода: <из brief'а>
|
||||
Версии:
|
||||
- Pravila: vX.Y → vX.Z
|
||||
- PSR_v1: vX.Y → vX.Z
|
||||
- Tooling: vX.Y → vX.Z (Прил. Н)
|
||||
- CLAUDE.md: vX.YY → vX.ZZ
|
||||
Cross-refs verified: <yes | no>
|
||||
Lefthook cross-ref-checker (C2): <green | red after N iterations>
|
||||
§9-changelog: добавлены в N/4 файлов
|
||||
Footer-счётчики: <не менялись | Tooling §0 N → M>
|
||||
Файлы в рабочем дереве (uncommitted):
|
||||
- docs/Pravila_raboty_Claude_v1_1.md
|
||||
- docs/Plugin_stack_rules_v1.md
|
||||
- docs/Tooling_v8_3.md
|
||||
- CLAUDE.md
|
||||
Эскалации: <нет | <список>>
|
||||
=== END RAPORT ===
|
||||
```
|
||||
|
||||
## Boundaries (что НЕ делать)
|
||||
|
||||
- НЕ коммитить, НЕ пушить (только готовить diff в рабочем дереве)
|
||||
- НЕ править код, миграции, схему БД, конфиги Laravel/Vue
|
||||
- НЕ писать новые ADR (только цитировать уже принятые)
|
||||
- НЕ править `docs/automation-graph.html` (карта инструментов — отдельная задача)
|
||||
- НЕ править `MEMORY.md`, `Открытые_вопросы_v8_3.md`, `db/schema.sql`
|
||||
- НЕ принимать решения о major bump без явного указания в brief'е
|
||||
- НЕ добавлять «improvements» в несвязанные секции — только указанные шапки, §0, footer, changelog
|
||||
|
||||
## Escalation triggers
|
||||
|
||||
Остановиться и вернуть рапорт «требуется человек» если:
|
||||
|
||||
- Pre-flight нашёл unpushed коммиты с правкой одного из 4 файлов от параллельной сессии
|
||||
- Brief неясен: minor или major bump
|
||||
- Cross-ref-checker красный после 3 итераций
|
||||
- Brief упоминает изменения вне scope (новый ADR, правка схемы, правка карты) — отдельная задача
|
||||
- Обнаружен дрейф в счётчиках Tooling §0, который не объясняется brief'ом (значит, кто-то ещё правил)
|
||||
|
||||
## Известные эпизоды-прецеденты (для понимания стиля)
|
||||
|
||||
- CLAUDE.md v2.26 → v2.27 (22.05.2026, C1 marketing): добавили 10 узлов #74-#83, 18-я off-phase подкатегория marketing-tooling, ADR-015. Все 4 файла bumped + §9-записи. Cross-refs обновлены.
|
||||
- CLAUDE.md v2.24 → v2.25 (21.05.2026, ZAP+Ward install): сняли PENDING INSTALL на 2 узлах #68/#70. Tooling §4.43/§4.45 dormant→false. Чисто статусная правка без новых счётчиков.
|
||||
- CLAUDE.md v1.87 → v1.88 (12.05.2026, R15 motion removal): **major bump** в PSR_v1 (v1.7 → v2.0), потому что удалили целое правило R15. Пример редкого major.
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
name: prod-deploy-validator
|
||||
description: |
|
||||
Pre-flight 8-check validator before deploying to liderra.ru production.
|
||||
Use BEFORE every prod deploy — main controller asks "проверь готовность боевого"
|
||||
or "ready to deploy?". Returns GO / NO-GO verdict with concrete reason and
|
||||
pointer to the relevant quirk (104-108). Does NOT deploy. Does NOT modify
|
||||
prod state. READ-ONLY by design. Driven by 24.05.2026 03:46 UTC live incident
|
||||
(portal down 18 min due to config:cache running as root, quirk 107).
|
||||
tools: Bash, Read, Grep
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Prod-deploy-validator agent — Лидерра liderra.ru
|
||||
|
||||
You are the pre-flight validator before any deploy to the Лидерра CRM production server (`liderra.ru`). You run a fixed checklist of 8 read-only SSH checks and return a single verdict: **GO** or **NO-GO**.
|
||||
|
||||
You DO NOT deploy. You DO NOT modify production. You DO NOT execute migrations or restart services. You are READ-ONLY by design.
|
||||
|
||||
If any check returns unexpected output (not matching the documented patterns), the verdict is **NO-GO with escalation** — never guess.
|
||||
|
||||
## Контекст: 24.05.2026 03:46 UTC live-incident
|
||||
|
||||
В ночь на 24.05.2026 портал лёг на 18 минут. Корень — `php artisan config:cache` был запущен из-под пользователя `root`, а не `www-data`. Cache-файл `bootstrap/cache/config.php` получил владельца `root`, и веб-процесс под `www-data` не смог его перечитать → Laravel выпал на defaults (APP_KEY=NULL, DB=sqlite) → HTTP 500 на всех маршрутах.
|
||||
|
||||
Этот checklist — прямая защита от повторения. **П1 — самая важная проверка.**
|
||||
|
||||
## Квирки производственного окружения liderra.ru (память агента)
|
||||
|
||||
### Квирк 104 — stale `bootstrap/cache/config.php` переживает .env-фикс
|
||||
|
||||
Symptom: правишь `.env`, перезапускаешь PHP-FPM, портал всё равно ведёт себя как со старым `.env`. Cause: `bootstrap/cache/config.php` старше `.env`, Laravel читает из cache. Фикс: `php artisan config:clear && sudo -u www-data php artisan config:cache`.
|
||||
|
||||
### Квирк 105 — scp Windows→Linux кладёт CRLF в `.env`
|
||||
|
||||
Symptom: после `scp` файла с Windows на Linux появляются `\r\n` line endings в `.env`. Laravel парсит первую строку с `\r` хвостом → значение содержит `\r` → DB-имя или ключ не валиден → sqlite-fallback → 500. Фикс: `dos2unix /var/www/liderra/app/.env`.
|
||||
|
||||
### Квирк 106 — `queue:work --timeout` default 60s убивает worker сам себя
|
||||
|
||||
Symptom: `queue:work` стартует, через ~60 секунд процесс умирает с `SIGKILL`. Cause: default `--timeout=60` означает «убить если задача занимает >60 сек», но parent-loop тоже под этим контролем. Фикс: `--timeout=600` или `--max-jobs=100`.
|
||||
|
||||
### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент)
|
||||
|
||||
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: владелец `bootstrap/cache/config.php` ≠ `www-data` → PHP-FPM под `www-data` не может прочитать кэш → fallback на defaults → APP_KEY=NULL и DB=sqlite. Фикс: `sudo -u www-data php artisan config:cache`.
|
||||
|
||||
### Квирк 108 — NTFS junction для worktree node_modules
|
||||
|
||||
Не релевантен боевому серверу, относится к dev-окружению Windows.
|
||||
|
||||
## 8 pre-flight проверок
|
||||
|
||||
Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация.
|
||||
|
||||
### П1 — `bootstrap/cache/config.php` владелец и свежесть (Квирк 107, самый важный)
|
||||
|
||||
```bash
|
||||
ssh -o ConnectTimeout=10 liderra "stat -c '%U %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
|
||||
```
|
||||
|
||||
Ожидаемый формат — 2 строки:
|
||||
|
||||
```
|
||||
www-data 1234567890
|
||||
1234567880
|
||||
```
|
||||
|
||||
Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env.
|
||||
|
||||
Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason.
|
||||
|
||||
### П2 — `.env` line endings (квирк 105)
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo file /var/www/liderra/app/.env"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка — обычно `ASCII text` или `Unicode text, UTF-8 text` (UTF-8 нормально, если `.env` содержит кириллические комментарии или значения).
|
||||
|
||||
Зелёный = вывод НЕ содержит подстроку `CRLF line terminators`.
|
||||
|
||||
Красный = вывод содержит `CRLF`. Цитировать квирк 105.
|
||||
|
||||
NB: `ubuntu`-юзер не имеет read-прав на `.env` напрямую — `sudo` обязательно (sudo без пароля).
|
||||
|
||||
### П3 — Свободное место на диске
|
||||
|
||||
```bash
|
||||
ssh liderra "df -h / | tail -1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка `/dev/... размер используется доступно %% маунт`.
|
||||
|
||||
Зелёный = использовано ≤ 85%.
|
||||
|
||||
Красный = > 85%. Reason: «диск %% занят, выкат может не уместиться».
|
||||
|
||||
### П4 — Свежесть последнего бэкапа БД
|
||||
|
||||
```bash
|
||||
ssh liderra "ls -lt /home/ubuntu/backups/ 2>/dev/null | head -2 | tail -1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка `ls -l` (или пустая если каталог пуст).
|
||||
|
||||
Зелёный = mtime файла ≤ 24 часов назад. Распарсить дату из вывода и сравнить с текущим временем UTC.
|
||||
|
||||
Красный = бэкап старше 24 часов или каталог пуст. Reason: «бэкап несвежий, выкат с миграциями опасен».
|
||||
|
||||
### П5 — Health очереди
|
||||
|
||||
```bash
|
||||
ssh liderra "pgrep -fa queue:work; tail -50 /var/www/liderra/app/storage/logs/laravel.log | grep -ic -e failed -e error"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка процесса (от `pgrep`) + одна цифра (от `grep -c`).
|
||||
|
||||
Зелёный = есть `queue:work` процесс И цифра ≤ 5.
|
||||
|
||||
Красный = нет процесса ИЛИ цифра > 5. Reason соответственно.
|
||||
|
||||
### П6 — Nginx config syntax
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo nginx -t 2>&1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: 2 строки — `nginx: the configuration file ... syntax is ok` + `nginx: configuration file ... test is successful`.
|
||||
|
||||
Зелёный = обе строки присутствуют.
|
||||
|
||||
Красный = любое иное. Reason: «nginx config сломан».
|
||||
|
||||
### П7 — fail2ban активен
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo systemctl is-active fail2ban"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка — `active` ИЛИ `inactive` ИЛИ `failed`.
|
||||
|
||||
Зелёный = `active`.
|
||||
|
||||
Красный = иначе. Reason: «fail2ban не работает, выкат расширяет attack surface».
|
||||
|
||||
### П8 — Pending миграции
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && php artisan migrate:status 2>&1 | grep -c Pending"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна цифра.
|
||||
|
||||
Зелёный = `0` ИЛИ количество совпадает с тем, что заявлено в brief'е (главный исполнитель сказал «к выкату пойдут N миграций»).
|
||||
|
||||
Красный = есть pending, не заявленные в brief'е. Reason: «N необъявленных миграций — какие?».
|
||||
|
||||
## Процедура (5 шагов)
|
||||
|
||||
1. Принять brief от главного исполнителя («готовлю выкат X — что в нём: миграции / только code / scp-патч»). Если brief не упомянул миграции — П8 ожидает 0.
|
||||
2. Прогнать 8 проверок последовательно (sequential, не parallel — упрощает отладку при сбоях SSH).
|
||||
3. Собрать результаты в таблицу из 8 строк (см. Output format).
|
||||
4. Применить решающее правило:
|
||||
- Все 8 зелёных → **GO** + список smoke-команд для пост-выкатной проверки
|
||||
- Хоть одна красная → **NO-GO** + причина + ссылка на квирк (если есть) + что нужно сделать
|
||||
- Любая «не смог проверить» (SSH timeout, неожиданный формат) → **NO-GO с эскалацией**
|
||||
5. Опционально (если в brief'е `--post-smoke`): после ответа главному исполнителю «выкат прошёл, запускай post-smoke» — повторить проверки + добавить HTTP 200 на главной (`curl -fsSL -o /dev/null -w '%{http_code}' https://liderra.ru/`).
|
||||
|
||||
## Output format
|
||||
|
||||
В конце работы вернуть один рапорт:
|
||||
|
||||
```
|
||||
=== PROD-DEPLOY-VALIDATOR RAPORT ===
|
||||
Brief: <из входных данных>
|
||||
Проверки:
|
||||
П1 config:cache владелец [GREEN / RED] — <вывод | причина>
|
||||
П2 .env line endings [GREEN / RED] — <вывод | причина>
|
||||
П3 свободное место [GREEN / RED] — <вывод | причина>
|
||||
П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина>
|
||||
П5 health очереди [GREEN / RED] — <вывод | причина>
|
||||
П6 nginx syntax [GREEN / RED] — <вывод | причина>
|
||||
П7 fail2ban active [GREEN / RED] — <вывод | причина>
|
||||
П8 pending миграции [GREEN / RED] — <вывод | причина>
|
||||
|
||||
Вердикт: GO / NO-GO
|
||||
|
||||
Если NO-GO — что делать:
|
||||
<конкретные команды для починки>
|
||||
<ссылка на квирк memory если применимо>
|
||||
|
||||
Если GO — smoke-команды для пост-выкатной проверки:
|
||||
- curl -fsSL -o /dev/null -w '%{http_code}\n' https://liderra.ru/
|
||||
- ssh liderra "cd /var/www/liderra/app && php artisan migrate:status | tail -20"
|
||||
- ssh liderra "tail -20 /var/www/liderra/app/storage/logs/laravel.log"
|
||||
=== END RAPORT ===
|
||||
```
|
||||
|
||||
## Boundaries (что НЕ делать)
|
||||
|
||||
- НЕ выкатывать (выкат — главный исполнитель)
|
||||
- НЕ менять конфиги на боевом
|
||||
- НЕ запускать миграции, не рестартить очереди, не править .env
|
||||
- НЕ угадывать: неожиданный output = NO-GO с эскалацией
|
||||
- НЕ цитировать пароли / ключи / токены если они случайно появились в выводе
|
||||
|
||||
## Escalation triggers
|
||||
|
||||
Вернуть NO-GO с пометкой «нужен человек» если:
|
||||
|
||||
- SSH-таймаут больше 30 сек (сеть лежит или сервер не отвечает)
|
||||
- 2+ проверки вернули неожиданный формат (не вписывается в документированный шаблон выше) — что-то системно изменилось, агент не должен угадывать
|
||||
- Brief сослался на проверку, которой нет в этом checklist'е (расширение checklist'а — отдельная задача)
|
||||
- Обнаружены файлы / процессы с подозрительными именами (возможный компромет) — критическая эскалация
|
||||
|
||||
## Прецеденты в проекте
|
||||
|
||||
- 24.05.2026 03:46 UTC — портал лежал 18 мин из-за квирка 107. Эта проверка (П1) — прямая защита.
|
||||
- 23.05.2026 — partition+RLS+log fix на боевом (push `7e0c8dde`). Сейчас бэкап-крон активен (П4).
|
||||
- 22.05.2026 — HTTPS + fail2ban + ModSecurity WAF активированы (см. memory `project_server_hardening.md`). П7 проверяет fail2ban.
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: reviewer-agent
|
||||
description: |
|
||||
Independent reviewer of routing decisions for Лидерра brain governance.
|
||||
Reads an episode (JSON) + optional context (max 10 neighboring episodes
|
||||
of same task_id from docs/observer/episodes-*.jsonl), evaluates classifier
|
||||
choice quality, chain quality, agent self-assessment accuracy. Returns
|
||||
structured JSON review.
|
||||
|
||||
USED inside /brain-retro skill via Task() spawn — one Task per unreviewed
|
||||
episode in the period. NEVER edits files. NEVER commits. NEVER touches
|
||||
nodes.yaml / episodes / нормативку.
|
||||
|
||||
Escalates to controller if episode is malformed or schema unknown.
|
||||
|
||||
Reviewer-agent is part of LLM-first router overhaul (see spec
|
||||
docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md
|
||||
§4.6 v2.1). Replaces direct Opus API call (v2.0) with full Claude Code
|
||||
subagent for cross-episode reading and skill invocations.
|
||||
tools: Read, Grep, Glob, Skill
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Reviewer agent — Лидерра brain governance
|
||||
|
||||
You are the independent reviewer of routing decisions for the Лидерра CRM brain-governance experiment. Your single job is to evaluate one episode at a time and return a structured JSON review.
|
||||
|
||||
You DO NOT edit files. You DO NOT commit. You DO NOT modify the episode you are reviewing. You DO NOT make architectural decisions. If the episode is malformed or contradicts itself irreparably, escalate to the controller with `{"reviewer_error": "<reason>"}` and return.
|
||||
|
||||
## Context
|
||||
|
||||
You are spawned from inside `/brain-retro` skill via `Task(subagent_type='reviewer-agent', prompt=<episode JSON + period sanity answers>)`. Your output goes back to the controller which writes it into the episode's `review.*` fields.
|
||||
|
||||
Spec reference: `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` §4.6.
|
||||
|
||||
## What you receive
|
||||
|
||||
The controller passes you a prompt containing:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{full episode JSON, schema v2/v3/v4.x}
|
||||
|
||||
Period sanity-check answers (опционально):
|
||||
{sanity_answers JSON or "none"}
|
||||
|
||||
Reviewer instructions:
|
||||
Оцени по 8 параметрам ниже.
|
||||
Return ONLY JSON, no prose.
|
||||
```
|
||||
|
||||
## What you can read additionally (context)
|
||||
|
||||
Use `Read`, `Grep`, `Glob` to fetch:
|
||||
|
||||
1. **Up to 10 neighboring episodes** of the same `task_id` from `docs/observer/episodes-YYYY-MM.jsonl`. Use Grep to find them by `task_id`. **HARD LIMIT: 10**. If more exist, take the 10 closest in time.
|
||||
2. **`docs/registry/nodes.yaml`** if you need to understand capabilities of nodes mentioned in the episode.
|
||||
3. **NO other files** — no reading `tools/`, no reading source code, no reading other specs. Stay focused.
|
||||
|
||||
## What skills you can invoke
|
||||
|
||||
When needed for analysis (NOT for editing):
|
||||
|
||||
- **`superpowers:systematic-debugging`** — if `outcome_reviewed='rework'` OR there are `error` events. Apply 3-hypothesis methodology to identify `error_root_cause`.
|
||||
- **`superpowers:requesting-code-review`** — if you need a structured checklist for evaluating execution quality.
|
||||
- **`superpowers:brainstorming`** — if you need to consider alternatives more deeply than what classifier provided.
|
||||
|
||||
Skills are tools for YOUR thinking. They don't change anything. After invocation, return back to evaluating the episode.
|
||||
|
||||
## What you evaluate (8 dimensions)
|
||||
|
||||
Return JSON with these exact keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct | wrong_node | overkill | underkill | disputable",
|
||||
"chain_quality": "correct | missing_step | extra_step | wrong_order | n/a",
|
||||
"gap_assessment": "acceptable | mistake_should_complete | mistake_should_not_start | n/a",
|
||||
"agent_self_assessment_accuracy": "accurate | over_confident | under_confident | no_self_assessment",
|
||||
"error_root_cause": "wrong_skill | wrong_tool | wrong_chain_order | external_failure | n/a",
|
||||
"alternative_better": "<node_id from alternatives_considered or null>",
|
||||
"outcome_reviewed": "success | soft_success | rework | blocked",
|
||||
"reasoning": "1-3 предложения объяснения. Конкретно, не общо."
|
||||
}
|
||||
```
|
||||
|
||||
### Detail per dimension
|
||||
|
||||
**`node_quality`:**
|
||||
|
||||
- `correct` — selected node matches prompt intent and capability.
|
||||
- `wrong_node` — selected node does not match; better alternative existed (put it in `alternative_better`).
|
||||
- `overkill` — node is more heavy than needed (e.g., systematic-debugging for typo fix).
|
||||
- `underkill` — node is too light (e.g., direct edit for security-sensitive area).
|
||||
- `disputable` — reasonable but not obviously best.
|
||||
|
||||
**`chain_quality`:**
|
||||
|
||||
- `correct` — chain matches the recommended chain or is a reasonable alternative.
|
||||
- `missing_step` — important step skipped (e.g., writing-plans skipped before executing-plans for non-trivial feature).
|
||||
- `extra_step` — unnecessary step added.
|
||||
- `wrong_order` — steps executed in wrong order.
|
||||
- `n/a` — single-node task, no chain.
|
||||
|
||||
**`gap_assessment`** (only if `chain_gaps[].length > 0`):
|
||||
|
||||
- `acceptable` — gap is expected (approval gate, user-initiated pause).
|
||||
- `mistake_should_complete` — chain should have continued, agent stopped prematurely.
|
||||
- `mistake_should_not_start` — chain should not have begun (classifier picked wrong chain).
|
||||
|
||||
**`agent_self_assessment_accuracy`:**
|
||||
|
||||
- Сравни `self_assessment.confidence_in_choice` с реальным `outcome_inferred`/`outcome_reviewed`.
|
||||
- `confidence ≥ 0.7 + outcome=rework` → `over_confident`.
|
||||
- `confidence ≤ 0.4 + outcome=success` → `under_confident`.
|
||||
- Соответствие → `accurate`.
|
||||
- `self_assessment_pending: true` → `no_self_assessment`.
|
||||
|
||||
**`error_root_cause`** (only if `events.error.length > 0` AND `outcome ≠ success`):
|
||||
|
||||
- `wrong_skill` — error because classifier picked wrong skill.
|
||||
- `wrong_tool` — error from tool within correct skill (e.g., Edit instead of MultiEdit on multi-occurrence).
|
||||
- `wrong_chain_order` — error from misordered chain steps.
|
||||
- `external_failure` — network/lock/race/API-down (not agent's fault).
|
||||
- `n/a` — no error or success outcome.
|
||||
|
||||
**`alternative_better`:**
|
||||
|
||||
- Если `node_quality = wrong_node` → выбери лучший узел из `classifier_output.alternatives_considered[].node`.
|
||||
- Если ни один из alternatives не лучше — предложи свой (могут быть узлы вне alternatives_considered, см. `docs/registry/nodes.yaml`).
|
||||
- Иначе → `null`.
|
||||
|
||||
**`outcome_reviewed`** (proxy — закрывает 19.E в spec):
|
||||
|
||||
- Combine: `outcome_inferred` (from next-prompt sentiment) + sanity answers (period context) + `self_assessment.confidence` vs actual.
|
||||
- `success` — task completed and user moved on positively.
|
||||
- `soft_success` — task completed but with caveats (corrections, partial).
|
||||
- `rework` — task had to be redone (next prompt contained correction/refusal/sanity says «переделывал»).
|
||||
- `blocked` — task could not complete (external blocker, escape-hatch invoked).
|
||||
|
||||
**`reasoning`:**
|
||||
|
||||
- 1-3 предложения объяснения твоего решения.
|
||||
- Конкретно: ссылайся на episode fields, not general principles.
|
||||
- Если использовал cross-episode context — упомяни.
|
||||
|
||||
## Adaptive review by schema version
|
||||
|
||||
- **v4 episodes** — full eval all 8 dimensions.
|
||||
- **v3 episodes** — no `alternatives_considered`, оцени `node_quality` на основе `triggers_matched` и `outcome`. `alternative_better` ставь null.
|
||||
- **v2 episodes** — no `self_assessment`, ставь `agent_self_assessment_accuracy='no_self_assessment'`. Остальное как обычно.
|
||||
- **v1 episodes** — НЕ обрабатываются, return `{"reviewer_error": "v1 schema not supported"}`.
|
||||
|
||||
## What you DON'T do
|
||||
|
||||
- Не редактируешь episode (controller сам пишет review.* поля по твоему JSON output).
|
||||
- Не правишь nodes.yaml.
|
||||
- Не правишь spec.
|
||||
- Не делаешь коммиты.
|
||||
- Не общаешься с пользователем — твой output идёт controller'у.
|
||||
- Не читаешь больше 10 соседних эпизодов (cost cap).
|
||||
- Не читаешь tools/* / source code — это вне scope review.
|
||||
|
||||
## Output format
|
||||
|
||||
ONLY valid JSON, no markdown, no code fences, no explanation text. Controller парсит твой output напрямую как JSON.
|
||||
|
||||
Если решил escalate — return:
|
||||
|
||||
```json
|
||||
{"reviewer_error": "<concrete reason>"}
|
||||
```
|
||||
|
||||
И ничего больше.
|
||||
|
||||
## Example
|
||||
|
||||
Input от controller:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{
|
||||
"schema_version": 4,
|
||||
"task_id": "abc-123",
|
||||
"classifier_output": {
|
||||
"task_type": "feature",
|
||||
"recommended_node": "superpowers:brainstorming",
|
||||
"recommended_chain": ["superpowers:brainstorming", "superpowers:writing-plans"],
|
||||
"alternatives_considered": [
|
||||
{"node": "superpowers:writing-plans", "match_score": 0.5, "rejected_because": "design не утверждён"}
|
||||
],
|
||||
"reason_for_choice": "design discussion needed before plan"
|
||||
},
|
||||
"execution_trace": {
|
||||
"actual_node_invoked_first": "superpowers:brainstorming",
|
||||
"actual_chain_executed": [
|
||||
{"step": 1, "skill": "superpowers:brainstorming", "completed": true, "duration_sec": 1840}
|
||||
],
|
||||
"chain_gaps": [
|
||||
{"type": "incomplete_chain", "gap_after_step": 1, "gap_reason": "design approval gate", "gap_severity": "expected"}
|
||||
]
|
||||
},
|
||||
"self_assessment": {
|
||||
"summary": "Brainstorming done, awaiting approval to write plan",
|
||||
"confidence_in_choice": 0.85
|
||||
},
|
||||
"outcome_inferred": "soft_success",
|
||||
"events": []
|
||||
}
|
||||
```
|
||||
|
||||
Output (что ты возвращаешь):
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct",
|
||||
"chain_quality": "n/a",
|
||||
"gap_assessment": "acceptable",
|
||||
"agent_self_assessment_accuracy": "accurate",
|
||||
"error_root_cause": "n/a",
|
||||
"alternative_better": null,
|
||||
"outcome_reviewed": "soft_success",
|
||||
"reasoning": "Brainstorming first для feature-задачи — каноничный L1-старт. Gap after step 1 ожидаем: дизайн нуждается в approval. Self-assessment confidence=0.85 совпадает с soft_success outcome (задача успешно завершена в рамках своего шага)."
|
||||
}
|
||||
```
|
||||
|
||||
## Lessons learned reminder
|
||||
|
||||
Если в эпизоде ты видишь что-то реально новое (не паттерн который уже встречался) — упомяни в reasoning. Эти insights попадают в self-retrospect skill aggregation для будущего обучения агента.
|
||||
|
||||
Но НЕ делай self-retrospect сам — это отдельный skill.
|
||||
@@ -55,6 +55,16 @@
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-tool-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -86,6 +96,26 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-stop-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ use Illuminate\Support\Facades\DB;
|
||||
* вынесены в `DealBulkActionController`, `export()` — в `DealExportController`.
|
||||
* Этот класс остаётся только для CRUD по одной записи.
|
||||
*
|
||||
* NB: webhook-flow (автосоздание из crm.bp-gr.ru) — отдельный endpoint
|
||||
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
|
||||
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
|
||||
* NB: webhook-flow (приём из crm.bp-gr.ru) — отдельный endpoint
|
||||
* `SupplierWebhookController` + `RouteSupplierLeadJob` (шеринг-канал).
|
||||
* Этот controller — для ручных action'ов из UI.
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
|
||||
@@ -29,8 +29,8 @@ use Symfony\Component\HttpFoundation\IpUtils;
|
||||
* Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
|
||||
* 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
|
||||
*
|
||||
* Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно
|
||||
* на WebhookReceiveController — не пересекается.
|
||||
* Единственный приёмник входящих лидов от crm.bp-gr.ru (legacy per-tenant
|
||||
* webhook был удалён вместе с ProcessWebhookJob).
|
||||
*
|
||||
* Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на
|
||||
* production env теперь fail-closed (`verifyIpAllowlist` возвращает false если
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Receive endpoint для входящих webhook'ов от crm.bp-gr.ru (narrative §5.5).
|
||||
*
|
||||
* URL: POST /api/webhook/{token}
|
||||
* Token = `tenants.webhook_token` (UUID per tenant; ротация через
|
||||
* `webhook_token_rotated_at` — старый токен живёт 24ч после ротации).
|
||||
*
|
||||
* Шаги:
|
||||
* 1. Резолв tenant по token (404 если не найден).
|
||||
* 2. Per-token rate-limit (system_settings.webhook_rate_limit_rps × 60 ≈ per-minute).
|
||||
* Превышение → 429 + Retry-After.
|
||||
* 3. Валидация payload (vid/project/phone/time).
|
||||
* 4. HMAC-валидация (опциональная) — если header `X-Webhook-Signature: sha256=<hex>`
|
||||
* пришёл, проверяем `hash_hmac('sha256', raw_body, webhook_token)`.
|
||||
* Невалидная подпись → 401. Отсутствие header — пропускаем (backward-compat
|
||||
* для существующих интеграций; на prod через `system_settings.webhook_hmac_required`
|
||||
* сделаем обязательной).
|
||||
* 5. INSERT в webhook_log (RLS-обёрнутый), dispatch ProcessWebhookJob → 202.
|
||||
*/
|
||||
class WebhookReceiveController extends Controller
|
||||
{
|
||||
/** POST /api/webhook/{token} */
|
||||
public function receive(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$tenant = Tenant::query()
|
||||
->where('webhook_token', $token)
|
||||
->first();
|
||||
|
||||
if ($tenant === null) {
|
||||
return response()->json([
|
||||
'message' => 'Webhook token не найден или ротирован.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Per-token rate-limit. Лимит из system_settings.webhook_rate_limit_rps
|
||||
// (RPS), приводим к per-minute через ×60. Decay 60 сек.
|
||||
$rpsLimit = $this->getRateLimitRps();
|
||||
$perMinuteLimit = $rpsLimit * 60;
|
||||
$rateKey = "webhook:{$tenant->id}";
|
||||
|
||||
if (RateLimiter::tooManyAttempts($rateKey, $perMinuteLimit)) {
|
||||
$retryAfter = RateLimiter::availableIn($rateKey);
|
||||
|
||||
return response()->json([
|
||||
'message' => "Превышен лимит ({$rpsLimit} RPS). Повтор через {$retryAfter} сек.",
|
||||
'retry_after' => $retryAfter,
|
||||
], 429)->header('Retry-After', (string) $retryAfter);
|
||||
}
|
||||
|
||||
RateLimiter::hit($rateKey, 60);
|
||||
|
||||
// HMAC-валидация. Опциональная по умолчанию (backward-compat); при
|
||||
// `system_settings.webhook_hmac_required = true` — обязательная,
|
||||
// запросы без X-Webhook-Signature → 401.
|
||||
$signature = $request->header('X-Webhook-Signature');
|
||||
$hmacRequired = $this->isHmacRequired();
|
||||
|
||||
if ($signature === null && $hmacRequired) {
|
||||
return response()->json([
|
||||
'message' => 'X-Webhook-Signature header требуется (HMAC обязателен в этой инсталляции).',
|
||||
], 401);
|
||||
}
|
||||
if ($signature !== null) {
|
||||
$rawBody = $request->getContent();
|
||||
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $token);
|
||||
if (! hash_equals($expected, $signature)) {
|
||||
return response()->json(['message' => 'Невалидная HMAC-подпись.'], 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация payload (после tenant lookup чтобы посчитать rate-limit
|
||||
// даже на bad payload — иначе rate-limit можно обойти 422-ответами).
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => 'required|string|max:255',
|
||||
'phone' => 'required|string|max:50',
|
||||
'time' => 'required|integer|min:1',
|
||||
'tag' => 'nullable|string|max:100',
|
||||
'phones' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$logId = $this->insertWebhookLogStub($tenant->id, $validated);
|
||||
|
||||
ProcessWebhookJob::dispatch($tenant->id, $validated, $logId);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'accepted',
|
||||
'tenant_id' => $tenant->id,
|
||||
'webhook_log_id' => $logId,
|
||||
], 202);
|
||||
}
|
||||
|
||||
private function getRateLimitRps(): int
|
||||
{
|
||||
$setting = SystemSetting::find('webhook_rate_limit_rps');
|
||||
if ($setting === null) {
|
||||
return 100; // sensible default из seed v8.7
|
||||
}
|
||||
|
||||
return max(1, (int) $setting->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД — default
|
||||
* TRUE (HMAC обязателен по умолчанию). Отключить можно только явной
|
||||
* установкой webhook_hmac_required=false. Неизвестное значение → fail-secure
|
||||
* (HMAC требуется).
|
||||
*/
|
||||
private function isHmacRequired(): bool
|
||||
{
|
||||
$setting = SystemSetting::find('webhook_hmac_required');
|
||||
if ($setting === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! in_array($setting->value, ['false', '0'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный INSERT-stub в webhook_log (если таблица существует).
|
||||
* На MVP webhook_log необязателен — возвращаем null если таблицы нет.
|
||||
*
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function insertWebhookLogStub(int $tenantId, array $payload): ?int
|
||||
{
|
||||
if (! Schema::hasTable('webhook_log')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// RLS требует SET LOCAL — оборачиваем в транзакцию.
|
||||
return (int) DB::transaction(function () use ($tenantId, $payload) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
return DB::table('webhook_log')->insertGetId([
|
||||
'tenant_id' => $tenantId,
|
||||
'received_at' => now(),
|
||||
'raw_payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,28 +11,26 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
* СТОПГЭП (2026-05-25): защита боевой админ-зоны (/admin + /api/admin/*)
|
||||
* перенесена на уровень nginx — отдельный HTTP Basic Auth с собственным
|
||||
* паролем (`/etc/nginx/.htpasswd-admin`, location ^~ /admin и ^~ /api/admin).
|
||||
* Поэтому middleware больше не закрывает зону на проде: дверь держит nginx.
|
||||
*
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
* Ранее (Sprint 3F) здесь был fail-closed 503 вне dev/testing — он закрывал
|
||||
* всю админку на проде наглухо, т.к. настоящий saas-admin SSO (Yandex 360)
|
||||
* ещё не готов (гейтится Б-1 + DO-4). Замок 503 снят осознанно: оголять
|
||||
* /api/admin/* в интернет нельзя, но nginx-пароль её прикрывает.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||||
* admin_user_id для audit-trail по-прежнему резолвится трейтом
|
||||
* ResolvesAdminUserId (стаб super_admin) — это отдельная зона.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
|
||||
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use Throwable;
|
||||
*
|
||||
* Жизненный цикл import_log: pending → processing → done | failed.
|
||||
* RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
|
||||
* вне middleware-контекста — паритет с ProcessWebhookJob).
|
||||
* вне middleware-контекста — паритет с RouteSupplierLeadJob).
|
||||
*/
|
||||
class ImportLeadsJob implements ShouldQueue
|
||||
{
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Deal;
|
||||
use App\Models\FailedWebhookJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\RejectedDealsLog;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Асинхронная обработка webhook'а от crm.bp-gr.ru (narrative §5.5 v8.7).
|
||||
*
|
||||
* Архитектура:
|
||||
* 1. RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
|
||||
* 2. Lock на tenant + балансовая проверка → RejectedDealsLog при balance=0.
|
||||
* 3. findOrCreate проекта (префикс B[123]_ обрезан).
|
||||
* 4. Идемпотентный upsert через pg_advisory_xact_lock (см. upsertDeal()).
|
||||
* 5. Для НОВОЙ сделки: списание баланса + BalanceTransaction +
|
||||
* SupplierLeadCost (Ю-2) + ActivityLog(deal.created).
|
||||
*
|
||||
* Уведомления (ТЗ §18.5, событие new_lead): после успешного chargeNewLead
|
||||
* вызывается NotificationService::notifyNewLead, который рассылает email
|
||||
* всем активным user'ам тенанта с включённым каналом email для new_lead.
|
||||
*
|
||||
* Не входит в текущий PoC (отдельные ветви фазы 1):
|
||||
* - Sentry::captureException в failed() (нет Sentry-DSN на dev-стеке)
|
||||
* - SystemSetting fallback для supplier_id (сейчас лукап через project_suppliers)
|
||||
*/
|
||||
class ProcessWebhookJob implements ShouldQueue
|
||||
{
|
||||
use FoundationQueueable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Webhook payload: vid, project, tag, phone, phones, time
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public array $data,
|
||||
public ?int $webhookLogId = null,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($this->tenantId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($tenant === null) {
|
||||
throw new RuntimeException("Tenant {$this->tenantId} not found");
|
||||
}
|
||||
|
||||
if ((int) $tenant->balance_leads <= 0) {
|
||||
$this->logRejection($tenant, RejectedDealsLog::REASON_ZERO_BALANCE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cleanProjectName = preg_replace('/^B[123]_/', '', (string) $this->data['project']);
|
||||
$project = Project::firstOrCreate(
|
||||
['tenant_id' => $tenant->id, 'name' => $cleanProjectName],
|
||||
['type' => 'webhook'],
|
||||
);
|
||||
|
||||
$receivedAt = Carbon::createFromTimestamp((int) $this->data['time']);
|
||||
$sourceCrmId = (int) $this->data['vid'];
|
||||
|
||||
$deal = $this->upsertDeal(
|
||||
tenant: $tenant,
|
||||
project: $project,
|
||||
sourceCrmId: $sourceCrmId,
|
||||
receivedAt: $receivedAt,
|
||||
);
|
||||
|
||||
if (! $deal->wasRecentlyCreated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->chargeNewLead($tenant, $project, $deal);
|
||||
});
|
||||
}
|
||||
|
||||
private function logRejection(Tenant $tenant, string $reason): void
|
||||
{
|
||||
$rejected = RejectedDealsLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'webhook_log_id' => $this->webhookLogId,
|
||||
'reason' => $reason,
|
||||
'payload' => $this->data,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info("webhook.rejected.{$reason}", [
|
||||
'tenant_id' => $tenant->id,
|
||||
'vid' => $this->data['vid'] ?? null,
|
||||
]);
|
||||
|
||||
// ТЗ §18.5: zero_balance — уведомить тенант. Anti-spam: не более
|
||||
// 1 email/час на тенант. Исключаем только что вставленную запись
|
||||
// через id (timestamp-сравнение ненадёжно из-за microsecond precision).
|
||||
if ($reason === RejectedDealsLog::REASON_ZERO_BALANCE) {
|
||||
$previousCount = RejectedDealsLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('reason', $reason)
|
||||
->where('created_at', '>=', now()->subHour())
|
||||
->where('id', '!=', $rejected->id)
|
||||
->count();
|
||||
|
||||
if ($previousCount === 0) {
|
||||
app(NotificationService::class)->notifyZeroBalance($tenant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Списание баланса при создании НОВОЙ сделки + аудит-записи.
|
||||
*
|
||||
* Все INSERT'ы в одной транзакции — целостность гарантирована (Ю-2):
|
||||
* deal + supplier_lead_cost + balance_transaction появляются атомарно.
|
||||
*/
|
||||
private function chargeNewLead(Tenant $tenant, Project $project, Deal $deal): void
|
||||
{
|
||||
$tenant->decrement('balance_leads');
|
||||
$tenant->refresh();
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
||||
'amount_leads' => -1,
|
||||
'balance_leads_after' => (int) $tenant->balance_leads,
|
||||
'related_type' => Deal::class,
|
||||
'related_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$resolver = app(SupplierResolver::class);
|
||||
$supplierId = $resolver->resolveForProject($project);
|
||||
if ($supplierId !== null) {
|
||||
SupplierLeadCost::create([
|
||||
'deal_id' => $deal->id,
|
||||
'received_at' => $deal->received_at,
|
||||
'supplier_id' => $supplierId,
|
||||
'cost_rub' => $resolver->costRubSnapshot($supplierId),
|
||||
'supplier_lead_id' => (int) $this->data['vid'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
Log::warning('webhook.no_active_supplier', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'deal_id' => $deal->id,
|
||||
]);
|
||||
}
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => ['source' => 'webhook'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей
|
||||
// в БД, чтобы при ошибке отправки транзакция уже была зафиксирована.
|
||||
// NotificationService сам ловит Throwable от Mail::send и логирует —
|
||||
// отказ канала не должен валить webhook.
|
||||
$deal->setRelation('project', $project);
|
||||
$service = app(NotificationService::class);
|
||||
$service->notifyNewLead($tenant, $deal);
|
||||
|
||||
// ТЗ §18.5: low_balance — после lead_charge проверяем порог. Триггерим
|
||||
// ТОЛЬКО когда баланс пересекает порог сверху-вниз: balance_after <=
|
||||
// threshold AND (balance_after + 1) > threshold. Иначе шлёт спам после
|
||||
// каждого lead_charge при balance < threshold.
|
||||
$threshold = $this->lowBalanceThreshold();
|
||||
$balanceAfter = (int) $tenant->balance_leads;
|
||||
if ($balanceAfter <= $threshold && ($balanceAfter + 1) > $threshold) {
|
||||
$service->notifyLowBalance($tenant, $threshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает порог из system_settings.low_balance_threshold_leads.
|
||||
* Default 10 (см. schema.sql:2239 seed).
|
||||
*/
|
||||
private function lowBalanceThreshold(): int
|
||||
{
|
||||
$setting = SystemSetting::query()->where('key', 'low_balance_threshold_leads')->first();
|
||||
if ($setting === null) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
return (int) $setting->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Идемпотентная upsert-логика через advisory lock (§5.5 v8.7).
|
||||
*
|
||||
* Стратегия:
|
||||
* 1. pg_advisory_xact_lock(tenant_id, vid) — сериализует все операции
|
||||
* с (tenant_id, source_crm_id) на время транзакции.
|
||||
* 2. SELECT в webhook_dedup_keys — атомарно из-за lock.
|
||||
* 3a. Если найдено — UPDATE deal по composite-ключу (id, received_at).
|
||||
* 3b. Иначе — INSERT deal первым (FK immediate OK), затем INSERT dedup_key.
|
||||
*
|
||||
* См. db/CHANGELOG_schema.md §W для архитектурного обоснования
|
||||
* (PG savepoint+DEFERRED quirk, отказ от двустадийного INSERT-в-dedup-keys-первым).
|
||||
*/
|
||||
private function upsertDeal(
|
||||
Tenant $tenant,
|
||||
Project $project,
|
||||
int $sourceCrmId,
|
||||
Carbon $receivedAt,
|
||||
): Deal {
|
||||
// pg_advisory_xact_lock(bigint): комбинируем (tenant_id, source_crm_id)
|
||||
// в один bigint — верхние 32 бита tenant_id, нижние 32 — source_crm_id.
|
||||
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
|
||||
$existing = DB::selectOne(
|
||||
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
|
||||
[$tenant->id, $sourceCrmId],
|
||||
);
|
||||
|
||||
if ($existing !== null) {
|
||||
$deal = Deal::query()
|
||||
->where('id', $existing->deal_id)
|
||||
->where('received_at', $existing->deal_received_at)
|
||||
->firstOrFail();
|
||||
|
||||
$deal->update([
|
||||
'phone' => (string) $this->data['phone'],
|
||||
'phones' => $this->data['phones'] ?? [(string) $this->data['phone']],
|
||||
// status НЕ перезаписываем — менеджер мог изменить.
|
||||
]);
|
||||
|
||||
DB::table('webhook_dedup_keys')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('source_crm_id', $sourceCrmId)
|
||||
->update(['updated_at' => now()]);
|
||||
|
||||
$deal->wasRecentlyCreated = false;
|
||||
|
||||
return $deal;
|
||||
}
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => $sourceCrmId,
|
||||
'project_id' => $project->id,
|
||||
'phone' => (string) $this->data['phone'],
|
||||
'phones' => $this->data['phones'] ?? [(string) $this->data['phone']],
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
]);
|
||||
|
||||
DB::table('webhook_dedup_keys')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => $sourceCrmId,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Финальный callback после исчерпания всех ретраев ($tries=3).
|
||||
*
|
||||
* Сохраняет упавший job в `failed_webhook_jobs` для ручного разбора и
|
||||
* возможного повторного запуска через админку SaaS. RLS не задаём —
|
||||
* tenant_id из job-state передаётся как есть (failed-callback запускается
|
||||
* вне транзакции воркера). На production добавляется Sentry::captureException.
|
||||
*
|
||||
* NB: записывается через DB::table (не через FailedWebhookJob::create),
|
||||
* чтобы избежать RLS-фильтрации при отсутствии app.current_tenant_id —
|
||||
* запись должна попасть в БД даже в катастрофическом сценарии.
|
||||
*/
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
// failed_webhook_jobs is an RLS-protected table. On production crm_app_user
|
||||
// (non-BYPASSRLS) there is no app.current_tenant_id GUC in the failed()
|
||||
// callback context. Use pgsql_supplier (crm_supplier_worker, BYPASSRLS) —
|
||||
// same pattern as RouteSupplierLeadJob::failed().
|
||||
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'webhook_log_id' => $this->webhookLogId,
|
||||
'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE),
|
||||
'exception' => $e->getMessage(),
|
||||
'retry_count' => $this->tries,
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
|
||||
Log::error('webhook.job_failed_permanently', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'vid' => $this->data['vid'] ?? null,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// TODO(production): Sentry::captureException($e);
|
||||
}
|
||||
}
|
||||
@@ -312,8 +312,8 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
|
||||
// мог подтянуть deal->project без N+1 lookup'а под RLS.
|
||||
// setRelation чтобы NotificationService мог подтянуть
|
||||
// deal->project без N+1 lookup'а под RLS.
|
||||
$deal->setRelation('project', $project);
|
||||
$notifier->notifyNewLead($tenant, $deal);
|
||||
|
||||
|
||||
@@ -126,11 +126,15 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
$missing = array_diff_key($csvByKey, $existingKeys);
|
||||
|
||||
$recoveredCount = 0;
|
||||
$unparseableCount = 0;
|
||||
foreach ($missing as $row) {
|
||||
$platform = $this->extractPlatform((string) $row['project']);
|
||||
if ($platform === null) {
|
||||
// Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
|
||||
// Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
|
||||
// Считаем такие строки отдельно, чтобы исключить из формулы drift'а
|
||||
// (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert).
|
||||
$unparseableCount++;
|
||||
Log::info('csv_reconcile.unparseable_project_skipped', [
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
@@ -161,7 +165,14 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
$matchedCount = $totalCsvRows - count($missing);
|
||||
$driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
|
||||
// drift считается только по «реальным» пропускам (parseable, не junk):
|
||||
// real_missing = count(missing) - unparseable (всегда ≥ 0)
|
||||
// parseable_tot = total_csv_rows - unparseable
|
||||
// Это убирает класс «поставщик кладёт телефон/URL в поле project →
|
||||
// строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05).
|
||||
$realMissing = max(0, count($missing) - $unparseableCount);
|
||||
$parseableTotal = max(0, $totalCsvRows - $unparseableCount);
|
||||
$driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0;
|
||||
$status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';
|
||||
|
||||
$update = [
|
||||
@@ -169,6 +180,7 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
'total_csv_rows' => $totalCsvRows,
|
||||
'matched_count' => $matchedCount,
|
||||
'recovered_count' => $recoveredCount,
|
||||
'unparseable_count' => $unparseableCount,
|
||||
'drift_ratio' => $driftRatio,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email-уведомление о низком балансе (ТЗ §18.5, событие low_balance).
|
||||
*
|
||||
* Триггер: tenant.balance_leads <= system_settings.low_balance_threshold_leads
|
||||
* (default 10) после lead_charge в ProcessWebhookJob.
|
||||
*/
|
||||
class LowBalanceNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $recipient,
|
||||
public Tenant $tenant,
|
||||
public int $thresholdLeads,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Лидерра. Низкий баланс — пополните, чтобы не пропустить лиды',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.low_balance',
|
||||
with: [
|
||||
'recipient' => $this->recipient,
|
||||
'tenant' => $this->tenant,
|
||||
'thresholdLeads' => $this->thresholdLeads,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
*
|
||||
* Отправляется получателям тенанта, у которых в notification_preferences
|
||||
* включён канал email для события new_lead. Триггер — успешное создание
|
||||
* сделки в ProcessWebhookJob::chargeNewLead.
|
||||
* сделки в RouteSupplierLeadJob.
|
||||
*/
|
||||
class NewLeadNotification extends Mailable
|
||||
{
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email-уведомление о нулевом балансе и отклонении лидов (ТЗ §18.5,
|
||||
* событие zero_balance).
|
||||
*
|
||||
* Триггер: ProcessWebhookJob::logRejection(reason=zero_balance) — после
|
||||
* первого RejectedDealsLog в течение последнего часа (anti-spam: не больше
|
||||
* 1 email в час на тенант).
|
||||
*/
|
||||
class ZeroBalanceNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $recipient,
|
||||
public Tenant $tenant,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Лидерра. Баланс закончился — лиды отклоняются',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.zero_balance',
|
||||
with: [
|
||||
'recipient' => $this->recipient,
|
||||
'tenant' => $this->tenant,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Webhook-job упавший после 3 ретраев (см. ProcessWebhookJob::failed()).
|
||||
* Webhook-job упавший после 3 ретраев (см. RouteSupplierLeadJob::failed()).
|
||||
*
|
||||
* Tenant-aware с RLS. Хранит raw payload + текст исключения для ручного
|
||||
* retry из админки SaaS (`retried_at`/`retried_by` заполняются админом).
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Лог отвергнутых webhook'ов (примеры reason: zero_balance, validation_failed).
|
||||
*
|
||||
* Хранится бессрочно (опционально 12 месяцев) — при пополнении баланса
|
||||
* админка SaaS может массово восстановить отвергнутые лиды.
|
||||
*
|
||||
* Tenant-aware с RLS. webhook_log_id — soft FK на webhook_log
|
||||
* (опциональный, NULL для прямых validation-отказов).
|
||||
*
|
||||
* Источник: db/schema.sql v8.7 §6, table `rejected_deals_log`.
|
||||
*
|
||||
* @mixin IdeHelperRejectedDealsLog
|
||||
*/
|
||||
class RejectedDealsLog extends Model
|
||||
{
|
||||
public const REASON_ZERO_BALANCE = 'zero_balance';
|
||||
|
||||
public const REASON_VALIDATION_FAILED = 'validation_failed';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'rejected_deals_log';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'webhook_log_id',
|
||||
'reason',
|
||||
'payload',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'webhook_log_id' => 'integer',
|
||||
'payload' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* Себестоимость каждого лида (Ю-2: реселлерская модель).
|
||||
*
|
||||
* Партиционирована по `received_at` синхронно с `deals` — composite PK
|
||||
* (id, received_at). В `ProcessWebhookJob` создаётся в той же транзакции,
|
||||
* что и Deal + BalanceTransaction.
|
||||
* (id, received_at). Создаётся в `LedgerService::chargeForDelivery` в той же
|
||||
* транзакции, что и Deal + BalanceTransaction.
|
||||
*
|
||||
* cost_rub — snapshot suppliers.cost_rub на момент приёма (исторические
|
||||
* записи не пересчитываются при изменении закупочной цены, см. §20.12.5).
|
||||
|
||||
@@ -31,8 +31,6 @@ class Tenant extends Model
|
||||
'subdomain',
|
||||
'organization_name',
|
||||
'contact_email',
|
||||
'webhook_token',
|
||||
'webhook_token_rotated_at',
|
||||
'timezone',
|
||||
'locale',
|
||||
'current_tariff_id',
|
||||
@@ -61,7 +59,6 @@ class Tenant extends Model
|
||||
'api_key_limit' => 'integer',
|
||||
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
|
||||
'limits' => 'array',
|
||||
'webhook_token_rotated_at' => 'datetime',
|
||||
'last_activity_at' => 'datetime',
|
||||
'last_webhook_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
|
||||
@@ -95,7 +95,7 @@ final class CsvLeadsParser
|
||||
return null;
|
||||
}
|
||||
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с RouteSupplierLeadJob парсером).
|
||||
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
|
||||
if ($projectName === '') {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
|
||||
|
||||
@@ -53,7 +53,7 @@ class MonthlyPartitionManager
|
||||
'auth_log' => 'created_at',
|
||||
'activity_log' => 'created_at',
|
||||
'tenant_operations_log' => 'created_at',
|
||||
'webhook_log' => 'received_at',
|
||||
// webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal)
|
||||
'balance_transactions' => 'created_at',
|
||||
'pd_processing_log' => 'created_at',
|
||||
'saas_admin_audit_log' => 'created_at',
|
||||
|
||||
@@ -5,11 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\InvoicePaidNotification;
|
||||
use App\Mail\LowBalanceNotification;
|
||||
use App\Mail\NewLeadNotification;
|
||||
use App\Mail\ReminderDueNotification;
|
||||
use App\Mail\TopupSuccessNotification;
|
||||
use App\Mail\ZeroBalanceNotification;
|
||||
use App\Mail\ZeroBalancePausedMail;
|
||||
use App\Models\Deal;
|
||||
use App\Models\InAppNotification;
|
||||
@@ -147,52 +145,6 @@ class NotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление о низком балансе. Триггер: ProcessWebhookJob после
|
||||
* lead_charge, если balance_leads <= threshold.
|
||||
*
|
||||
* Получатели: все активные user'ы тенанта с new_lead.email=true
|
||||
* (на MVP: те же что и для new_lead — обычно владелец и менеджеры).
|
||||
* По prefs `low_balance.email`.
|
||||
*/
|
||||
public function notifyLowBalance(Tenant $tenant, int $thresholdLeads): void
|
||||
{
|
||||
$title = "Низкий баланс — {$tenant->balance_leads} лидов осталось";
|
||||
$body = "Порог уведомления: {$thresholdLeads} лидов";
|
||||
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_EMAIL) as $user) {
|
||||
$this->sendEmail($user, self::EVENT_LOW_BALANCE, new LowBalanceNotification($user, $tenant, $thresholdLeads));
|
||||
}
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_INAPP) as $user) {
|
||||
$this->notifyInApp($user, self::EVENT_LOW_BALANCE, $title, $body, [
|
||||
'tenant_id' => $tenant->id,
|
||||
'balance_leads' => $tenant->balance_leads,
|
||||
'threshold_leads' => $thresholdLeads,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление о нулевом балансе и отклонении лидов.
|
||||
* Триггер: ProcessWebhookJob::logRejection(zero_balance) в первом
|
||||
* RejectedDealsLog за последний час (anti-spam: не более 1 email/час
|
||||
* на тенант, проверка в caller).
|
||||
*/
|
||||
public function notifyZeroBalance(Tenant $tenant): void
|
||||
{
|
||||
$title = 'Баланс закончился — лиды отклоняются';
|
||||
$body = 'Пополните баланс в разделе Биллинг';
|
||||
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_EMAIL) as $user) {
|
||||
$this->sendEmail($user, self::EVENT_ZERO_BALANCE, new ZeroBalanceNotification($user, $tenant));
|
||||
}
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_INAPP) as $user) {
|
||||
$this->notifyInApp($user, self::EVENT_ZERO_BALANCE, $title, $body, [
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление об auto-pause проекта на нулевом балансе (Plan 4 Task 6).
|
||||
*
|
||||
|
||||
@@ -18,7 +18,7 @@ use InvalidArgumentException;
|
||||
* users: email, first_name, last_name, phone
|
||||
* supplier_leads: phone, raw_payload (JSONB) — нет contact_email/contact_phone
|
||||
* deals: phone, contact_name — нет отдельного contact_email
|
||||
* webhook_log: raw_payload (JSONB)
|
||||
* (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
*/
|
||||
class PdErasureService
|
||||
{
|
||||
@@ -32,7 +32,7 @@ class PdErasureService
|
||||
* @param int|null $tenantId Ограничить поиск одним тенантом (null = все)
|
||||
* @param int $actorAdminId ID saas_admin_users
|
||||
* @param string|null $requestId ID pd_subject_requests для авто-закрытия
|
||||
* @return array{users: int, leads: int, deals: int, webhook_log: int}
|
||||
* @return array{users: int, leads: int, deals: int}
|
||||
*
|
||||
* @throws InvalidArgumentException если оба email и phone null
|
||||
*/
|
||||
@@ -47,7 +47,7 @@ class PdErasureService
|
||||
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
|
||||
}
|
||||
|
||||
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0];
|
||||
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0];
|
||||
|
||||
DB::connection(self::DB)->transaction(function () use (
|
||||
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
|
||||
@@ -176,50 +176,12 @@ class PdErasureService
|
||||
$counts['deals'] = $deals->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. webhook_log (raw_payload JSONB text-search)
|
||||
// ------------------------------------------------------------------
|
||||
$wlQuery = DB::connection(self::DB)->table('webhook_log');
|
||||
$conditions = [];
|
||||
$bindings = [];
|
||||
if ($email !== null) {
|
||||
$conditions[] = 'raw_payload::text LIKE ?';
|
||||
$bindings[] = '%'.$email.'%';
|
||||
}
|
||||
if ($phone !== null) {
|
||||
$conditions[] = 'raw_payload::text LIKE ?';
|
||||
$bindings[] = '%'.$phone.'%';
|
||||
}
|
||||
|
||||
if (! empty($conditions)) {
|
||||
$wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings);
|
||||
}
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$wlQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
// Batched update: обрабатываем по 500 строк
|
||||
$wlCount = 0;
|
||||
$wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void {
|
||||
$ids = $rows->pluck('id')->all();
|
||||
DB::connection(self::DB)->table('webhook_log')
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
'raw_payload' => DB::connection(self::DB)->raw(
|
||||
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
|
||||
),
|
||||
]);
|
||||
$wlCount += count($ids);
|
||||
});
|
||||
|
||||
$counts['webhook_log'] = $wlCount;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. Обновить pd_subject_requests если requestId передан
|
||||
// 4. Обновить pd_subject_requests если requestId передан
|
||||
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
// ------------------------------------------------------------------
|
||||
if ($requestId !== null) {
|
||||
$summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, "
|
||||
."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}";
|
||||
."deals={$counts['deals']}";
|
||||
|
||||
DB::connection(self::DB)->table('pd_subject_requests')
|
||||
->where('id', $requestId)
|
||||
|
||||
@@ -22,7 +22,6 @@ class TenantFactory extends Factory
|
||||
'subdomain' => 'tenant-'.Str::lower(Str::random(8)),
|
||||
'organization_name' => fake()->company(),
|
||||
'contact_email' => fake()->unique()->safeEmail(),
|
||||
'webhook_token' => Str::random(64),
|
||||
'timezone' => 'Europe/Moscow',
|
||||
'locale' => 'ru',
|
||||
'is_trial' => true,
|
||||
|
||||
@@ -9,8 +9,11 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Guard: only run if webhook_log exists (should always exist, but be safe)
|
||||
if (! Schema::hasTable('webhook_log')) {
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
// Guard: only run if webhook_log exists (на проде после legacy-webhook-removal
|
||||
// таблицы нет — миграция становится no-op).
|
||||
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,16 +21,18 @@ return new class extends Migration
|
||||
base_path('/../db/migrations/2026_05_22_002_webhook_log_supplier_columns.sql')
|
||||
);
|
||||
|
||||
DB::unprepared($sql);
|
||||
$conn->unprepared($sql);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('webhook_log')) {
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE webhook_log
|
||||
DROP COLUMN IF EXISTS source,
|
||||
DROP COLUMN IF EXISTS status,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Удаление legacy-артефактов прямого webhook-канала.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-24-legacy-direct-webhook-removal-design.md
|
||||
* Plan: docs/superpowers/plans/2026-05-24-legacy-direct-webhook-removal.md
|
||||
*
|
||||
* Что удаляем (финальный список по результатам Phase 1 impact-checks):
|
||||
* - webhook_log (partitioned, 13 партиций) — пустая на проде, источник = только удалённый ProcessWebhookJob
|
||||
* - rejected_deals_log — writer только ProcessWebhookJob, нет readers
|
||||
* - tenants.webhook_token + tenants.webhook_token_rotated_at — нет в UI/API, тесты почищены ниже
|
||||
* - system_settings.low_balance_threshold_leads (seed) — только legacy
|
||||
*
|
||||
* Phase 1 RED FLAG: webhook_dedup_keys ОСТАЁТСЯ (HistoricalImportService — CSV-канал).
|
||||
*
|
||||
* pgsql_supplier connection — BYPASSRLS-роль crm_supplier_worker (паттерн Спека B):
|
||||
* под обычной crm_app_user DROP/ALTER без app.current_tenant_id GUC не пройдёт.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
// Partitioned table — DROP TABLE каскадит все 13 партиций.
|
||||
$conn->statement('DROP TABLE IF EXISTS webhook_log CASCADE');
|
||||
|
||||
// NB: webhook_dedup_keys НЕ дропаем — Phase 1 RED FLAG, живой через HistoricalImportService (CSV-канал).
|
||||
|
||||
// RejectedDealsLog — writer только удалённый ProcessWebhookJob, readers нет.
|
||||
$conn->statement('DROP TABLE IF EXISTS rejected_deals_log CASCADE');
|
||||
|
||||
// tenants.webhook_token + webhook_token_rotated_at — нет в UI/API.
|
||||
$conn->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS webhook_token, DROP COLUMN IF EXISTS webhook_token_rotated_at');
|
||||
|
||||
// Legacy threshold-cross seed (caller — удалённый ProcessWebhookJob).
|
||||
$conn->statement("DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Откат — пустая заглушка. Прод-restore из pg_dump backup.
|
||||
* Этот метод существует только чтобы migrate:rollback не падал.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// НЕ восстанавливаем структуру — пустая заглушка.
|
||||
// Прод-restore — из pg_dump backup (см. runbook docs/deploy/test-server-runbook.md).
|
||||
}
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* supplier_csv_reconcile_log + unparseable_count: количество CSV-строк
|
||||
* за окно reconcile, у которых поле «project» не парсится в платформу
|
||||
* (поставщик иногда кладёт телефон/URL в Name → extractPlatform = null,
|
||||
* строка скипается в csv_reconcile.unparseable_project_skipped).
|
||||
*
|
||||
* Раньше эти строки попадали в знаменатель drift_ratio и счётчик missing,
|
||||
* стабильно завышая drift до ~40-50% (false-positive drift_alert каждый
|
||||
* запуск). Теперь они учитываются отдельно и вычитаются из формулы.
|
||||
*
|
||||
* Используется в CsvReconcileJob + AdminSupplierIntegrationController.
|
||||
* Таблица SaaS-level (без RLS), пишет/читает crm_supplier_worker
|
||||
* (BYPASSRLS) — pgsql_supplier connection.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE supplier_csv_reconcile_log
|
||||
ADD COLUMN IF NOT EXISTS unparseable_count INTEGER NOT NULL DEFAULT 0;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE supplier_csv_reconcile_log
|
||||
DROP COLUMN IF EXISTS unparseable_count;
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
+564
-372
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Низкий баланс</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Низкий баланс</h1>
|
||||
|
||||
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
|
||||
|
||||
<p>На балансе аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
|
||||
осталось <strong>{{ $tenant->balance_leads }} лидов</strong>
|
||||
(порог уведомления — {{ $thresholdLeads }} лидов).</p>
|
||||
|
||||
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
|
||||
Если баланс закончится — все входящие лиды будут отклоняться.
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 24px;">Откройте Биллинг в CRM, чтобы пополнить баланс.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Это автоматическое уведомление о событии «Низкий баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Баланс закончился</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #B94837; font-size: 20px;">Лидерра. Баланс закончился</h1>
|
||||
|
||||
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
|
||||
|
||||
<p>Баланс аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
|
||||
обнулился — <strong>входящие лиды отклоняются</strong>.</p>
|
||||
|
||||
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
|
||||
<strong>Что делать:</strong> пополните баланс в разделе Биллинг.
|
||||
После пополнения новые лиды снова начнут приниматься.
|
||||
</p>
|
||||
|
||||
<p>Уже отклонённые лиды не возвращаются — посмотрите их в журнале
|
||||
«Отклонённые лиды» в разделе Отчёты.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Это автоматическое уведомление о событии «Нулевой баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -270,12 +270,6 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu
|
||||
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// Receive endpoint для входящих webhook'ов (narrative §5.5).
|
||||
// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller).
|
||||
// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit.
|
||||
Route::post('/api/webhook/{token}', 'App\Http\Controllers\Api\WebhookReceiveController@receive')
|
||||
->where('token', '[A-Za-z0-9\-_]+');
|
||||
|
||||
// Supplier-integration webhook (Plan 2/5, spec §5.1).
|
||||
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
|
||||
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
|
||||
|
||||
@@ -77,7 +77,6 @@ foreach ($accounts as $a) {
|
||||
[
|
||||
'organization_name' => $a['org_name'],
|
||||
'contact_email' => $user->email,
|
||||
'webhook_token' => Str::random(64),
|
||||
'timezone' => 'Europe/Moscow',
|
||||
'locale' => 'ru',
|
||||
'is_trial' => true,
|
||||
|
||||
@@ -164,7 +164,6 @@ it('executeErasure anonymises user email first_name phone and writes pd_processi
|
||||
'organization_name' => 'PD User Test',
|
||||
'contact_email' => 'pd-u@test.local',
|
||||
'status' => 'active',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'balance_rub' => '0.00',
|
||||
'balance_leads' => 0,
|
||||
'is_trial' => false,
|
||||
|
||||
@@ -14,7 +14,6 @@ function makeBillingTenant(array $overrides = []): int
|
||||
'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Billing Test Co',
|
||||
'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'status' => 'active',
|
||||
'balance_rub' => '5000.00',
|
||||
'is_trial' => false,
|
||||
|
||||
@@ -64,7 +64,6 @@ test('GET /api/admin/incidents/{id} разрешает имена affected_tenan
|
||||
'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Affected Org',
|
||||
'contact_email' => 'a@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
|
||||
|
||||
@@ -69,7 +69,6 @@ function ensureTenant(int $seed): int
|
||||
'organization_name' => "Test Chain {$seed}",
|
||||
'subdomain' => "test-chain-{$seed}",
|
||||
'contact_email' => "chain{$seed}@example.com",
|
||||
'webhook_token' => bin2hex(random_bytes(16))."-seed{$seed}",
|
||||
'status' => 'active',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()->where('key', 'supplier_webhook_secret')->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']);
|
||||
// Clear rate limiter between tests
|
||||
RateLimiter::clear('supplier-webhook:127.0.0.1');
|
||||
});
|
||||
|
||||
it('logs status=received when lead is accepted (202)', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 900001,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(202);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'received')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('received');
|
||||
expect($log->source)->toBe('supplier');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('logs status=rejected_secret when secret is wrong (404)', function () {
|
||||
$this->postJson('/api/webhook/supplier/wrong-secret-here', [
|
||||
'vid' => 900002,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(404);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'rejected_secret')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('rejected_secret');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('logs status=rejected_ip when IP is not in allowlist (404)', function () {
|
||||
SystemSetting::query()->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '["1.2.3.4"]']);
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8'])
|
||||
->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 900003,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(404);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'rejected_ip')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('rejected_ip');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('logs status=rate_limited when per-IP rate limit exceeded (429)', function () {
|
||||
Bus::fake();
|
||||
// Saturate the rate limiter
|
||||
$key = 'supplier-webhook:127.0.0.1';
|
||||
$limit = 600; // RATE_LIMIT_PER_MINUTE constant
|
||||
for ($i = 0; $i < $limit; $i++) {
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
|
||||
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 900004,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(429);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'rate_limited')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('rate_limited');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
@@ -2,19 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Mail\InvoicePaidNotification;
|
||||
use App\Mail\LowBalanceNotification;
|
||||
use App\Mail\TopupSuccessNotification;
|
||||
use App\Mail\ZeroBalanceNotification;
|
||||
use App\Models\InAppNotification;
|
||||
use App\Models\RejectedDealsLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
@@ -23,18 +17,6 @@ beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function balancePayload(int $vid = 500): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga',
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79000000'.$vid,
|
||||
'phones' => ['79000000'.$vid],
|
||||
'time' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
function makeUserForBalance(Tenant $tenant, string $email, array $events = []): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
@@ -53,102 +35,6 @@ function makeUserForBalance(Tenant $tenant, string $email, array $events = []):
|
||||
]);
|
||||
}
|
||||
|
||||
// ============== low_balance ==============
|
||||
|
||||
test('low_balance: при пересечении порога сверху-вниз → email + inapp', function () {
|
||||
// Default threshold: 10 (system_settings seeded). Установим balance=11.
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(10); // 11 → 10 (пересекли порог)
|
||||
|
||||
Mail::assertSent(LowBalanceNotification::class, 1);
|
||||
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('low_balance: balance уже < threshold — НЕ шлёт повторно', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 5]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(4); // 5 → 4 (всё ещё < threshold=10)
|
||||
|
||||
// Не пересекали порог — НЕ шлём.
|
||||
Mail::assertNothingSent();
|
||||
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('low_balance: balance > threshold после decrement — НЕ шлёт', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 50]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(49);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('low_balance: prefs.low_balance.email=false — только inapp', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
makeUserForBalance($tenant, 'on@example.ru', [
|
||||
'low_balance' => ['email' => false, 'inapp' => true],
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ============== zero_balance ==============
|
||||
|
||||
test('zero_balance: первое отклонение → email + inapp', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1);
|
||||
expect(InAppNotification::query()->where('event', 'zero_balance')->count())->toBe(1);
|
||||
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('zero_balance: 2-е отклонение в течение часа — НЕ дублирует email', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 1)))->handle();
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 2)))->handle();
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1); // всё ещё один
|
||||
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('zero_balance: отклонение через >1ч — снова шлёт', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
// Создаём старый RejectedDealsLog (>1ч назад) — он не должен суппрессить.
|
||||
DB::table('rejected_deals_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'reason' => RejectedDealsLog::REASON_ZERO_BALANCE,
|
||||
'payload' => json_encode(['vid' => 999]),
|
||||
'created_at' => Carbon::now()->subHours(2),
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1);
|
||||
});
|
||||
|
||||
// ============== topup_success ==============
|
||||
|
||||
test('topup_success: notifyTopupSuccess создаёт email + inapp', function () {
|
||||
@@ -204,18 +90,3 @@ test('invoice_paid: prefs=email:false — только inapp', function () {
|
||||
Mail::assertNothingSent();
|
||||
expect(InAppNotification::query()->where('event', 'invoice_paid')->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ============== isolation ==============
|
||||
|
||||
test('balance events изолированы между тенантами', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
$userA = makeUserForBalance($tenantA, 'a@example.ru');
|
||||
makeUserForBalance($tenantB, 'b@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertSent(LowBalanceNotification::class, 1);
|
||||
Mail::assertSent(fn (LowBalanceNotification $m) => $m->hasTo($userA->email));
|
||||
Mail::assertNotSent(fn (LowBalanceNotification $m) => $m->hasTo('b@example.ru'));
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Mail\NewLeadNotification;
|
||||
use App\Models\InAppNotification;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -14,12 +12,8 @@ use Illuminate\Support\Facades\Mail;
|
||||
/**
|
||||
* Тесты in-app канала уведомлений (schema v8.10 in_app_notifications).
|
||||
*
|
||||
* Канал inapp в матрице users.notification_preferences. INSERT row при
|
||||
* триггере события (new_lead/...). UI читает unread-count и список
|
||||
* последних 50 (этап 2b — отдельный коммит).
|
||||
*
|
||||
* Schema-default: notification_preferences.new_lead.inapp=true → в отличие
|
||||
* от email, большинство user'ов получает in-app по умолчанию.
|
||||
* Тесты через ProcessWebhookJob удалены — job убран как legacy-рудимент.
|
||||
* Оставлен прямой вызов NotificationService::notifyInApp.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -27,164 +21,6 @@ beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function inAppPayload(int $vid = 300, ?int $time = null): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga',
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79001234567',
|
||||
'phones' => ['79001234567'],
|
||||
'time' => $time ?? time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $newLeadPrefs
|
||||
*/
|
||||
function makeUserWithInAppPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $email,
|
||||
'notification_preferences' => [
|
||||
'new_lead' => $newLeadPrefs,
|
||||
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
|
||||
'low_balance' => ['email' => true],
|
||||
'zero_balance' => ['email' => true],
|
||||
'topup_success' => ['email' => true],
|
||||
'invoice_paid' => ['email' => true],
|
||||
'new_device_login' => ['email' => true],
|
||||
'marketing' => ['email' => false],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('webhook: in_app_notification создаётся для user с inapp=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$user = makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true, 'email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
$notif = InAppNotification::query()->first();
|
||||
expect($notif->user_id)->toBe($user->id);
|
||||
expect($notif->tenant_id)->toBe($tenant->id);
|
||||
expect($notif->event)->toBe('new_lead');
|
||||
expect($notif->title)->toContain('Caranga');
|
||||
expect($notif->body)->toBe('79001234567'); // phone (no contact_name)
|
||||
expect($notif->read_at)->toBeNull();
|
||||
expect($notif->payload['project_name'])->toBe('Caranga');
|
||||
});
|
||||
|
||||
test('webhook: user с inapp=false НЕ получает in-app row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'off@example.ru', ['inapp' => false, 'email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('webhook: schema-default (inapp=true) ставит row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
// Без override prefs — берётся schema DEFAULT (new_lead.inapp=true).
|
||||
User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'default@example.ru',
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('webhook: 2 user\'а с inapp=true получают по 1 row, 1 user с inapp=false — нет', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$a = makeUserWithInAppPrefs($tenant, 'a@example.ru', ['inapp' => true]);
|
||||
$b = makeUserWithInAppPrefs($tenant, 'b@example.ru', ['inapp' => true]);
|
||||
makeUserWithInAppPrefs($tenant, 'c@example.ru', ['inapp' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(2);
|
||||
expect(InAppNotification::query()->where('user_id', $a->id)->exists())->toBeTrue();
|
||||
expect(InAppNotification::query()->where('user_id', $b->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('webhook: inactive user НЕ получает in-app', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
User::factory()->inactive()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'inactive@example.ru',
|
||||
'notification_preferences' => ['new_lead' => ['inapp' => true]],
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('webhook: user другого тенанта НЕ получает (RLS isolation)', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$userA = makeUserWithInAppPrefs($tenantA, 'a@example.ru', ['inapp' => true]);
|
||||
makeUserWithInAppPrefs($tenantB, 'b@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
expect(InAppNotification::query()->first()->user_id)->toBe($userA->id);
|
||||
});
|
||||
|
||||
test('webhook: дубль (Биз-19) НЕ создаёт повторный in-app row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 1)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
|
||||
// Второй webhook с тем же phone в окне 24ч → дубль, нет chargeNewLead → нет notify.
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 2)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('webhook: повторный vid (UPDATE) НЕ создаёт повторный in-app row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('webhook: оба канала (inapp+email=true) — 1 in-app row + 1 email', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'both@example.ru', ['inapp' => true, 'email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
});
|
||||
|
||||
test('webhook: payload содержит deal_id для UI deep-link', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
$notif = InAppNotification::query()->first();
|
||||
expect($notif->deal_id)->not->toBeNull();
|
||||
expect($notif->payload)->toHaveKey('deal_id');
|
||||
expect($notif->payload['deal_id'])->toBe($notif->deal_id);
|
||||
});
|
||||
|
||||
test('NotificationService::notifyInApp: вызов напрямую создаёт row', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Mail\NewLeadNotification;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Тесты email-уведомления о новом лиде (ТЗ §18.5, событие new_lead).
|
||||
*
|
||||
* Проверяет интеграцию NotificationService → ProcessWebhookJob: после успешного
|
||||
* chargeNewLead все активные user'ы тенанта с notification_preferences.new_lead.email=true
|
||||
* получают NewLeadNotification. Mail::fake() перехватывает реальную отправку.
|
||||
*
|
||||
* Schema-default: notification_preferences.new_lead.email=false → по умолчанию
|
||||
* никто не получает emails. Тесты явно ставят email=true для нужных user'ов.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function newLeadPayload(int $vid = 200, ?int $time = null): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga',
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79001234567',
|
||||
'phones' => ['79001234567'],
|
||||
'time' => $time ?? time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $newLeadPrefs
|
||||
*/
|
||||
function makeUserWithPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $email,
|
||||
'notification_preferences' => [
|
||||
'new_lead' => $newLeadPrefs,
|
||||
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
|
||||
'low_balance' => ['email' => true],
|
||||
'zero_balance' => ['email' => true],
|
||||
'topup_success' => ['email' => true],
|
||||
'invoice_paid' => ['email' => true],
|
||||
'new_device_login' => ['email' => true],
|
||||
'marketing' => ['email' => false],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('webhook: NewLeadNotification отправляется user\'ам с email=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$userOn = makeUserWithPrefs($tenant, 'on@example.ru', ['inapp' => true, 'push' => true, 'email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail) use ($userOn): bool {
|
||||
return $mail->manager->id === $userOn->id
|
||||
&& $mail->hasTo('on@example.ru');
|
||||
});
|
||||
});
|
||||
|
||||
test('webhook: user с email=false НЕ получает', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'off@example.ru', ['inapp' => true, 'push' => true, 'email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: schema-default не шлёт (new_lead.email=false по дефолту)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
// Не передаём notification_preferences — берётся schema DEFAULT.
|
||||
User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'default@example.ru',
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: рассылается всем активным user\'ам с email=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'a@example.ru', ['email' => true]);
|
||||
makeUserWithPrefs($tenant, 'b@example.ru', ['email' => true]);
|
||||
makeUserWithPrefs($tenant, 'c@example.ru', ['email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, 2);
|
||||
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
|
||||
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
|
||||
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('c@example.ru'));
|
||||
});
|
||||
|
||||
test('webhook: inactive user с email=true НЕ получает (is_active=false)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
User::factory()->inactive()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'inactive@example.ru',
|
||||
'notification_preferences' => [
|
||||
'new_lead' => ['email' => true],
|
||||
],
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: soft-deleted user НЕ получает', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$user = makeUserWithPrefs($tenant, 'deleted@example.ru', ['email' => true]);
|
||||
$user->delete();
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: user другого тенанта НЕ получает (изоляция)', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenantA, 'a@example.ru', ['email' => true]);
|
||||
makeUserWithPrefs($tenantB, 'b@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
|
||||
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
|
||||
});
|
||||
|
||||
test('webhook: дубль-сделка (Биз-19) НЕ шлёт повторное уведомление', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
// Первая сделка — master.
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 1)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
|
||||
// Вторая сделка с тем же phone в окне 24 ч — дубль, баланс НЕ списывается,
|
||||
// chargeNewLead НЕ вызывается, уведомление НЕ шлётся.
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 2)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1); // всё ещё одно
|
||||
});
|
||||
|
||||
test('webhook: повторный vid (idempotent UPDATE) НЕ шлёт повторное уведомление', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
|
||||
// Повторный webhook с тем же vid — UPDATE, не INSERT. wasRecentlyCreated=false → return.
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
});
|
||||
|
||||
test('webhook: balance=0 (RejectedDealsLog) НЕ шлёт NewLeadNotification', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
// chargeNewLead НЕ вызывается при balance=0 — NewLeadNotification не шлётся.
|
||||
// (ZeroBalanceNotification ШЛЁТСЯ — это покрывается отдельным тестом.)
|
||||
Mail::assertNotSent(NewLeadNotification::class);
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('NewLeadNotification: subject содержит project_name', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail): bool {
|
||||
return str_contains($mail->envelope()->subject, 'Caranga');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ beforeEach(function () {
|
||||
$this->partitionsBefore = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->pluck('relname')->all();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ afterEach(function () {
|
||||
$partitionsAfter = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->pluck('relname')->all();
|
||||
|
||||
// DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от
|
||||
@@ -61,7 +61,7 @@ test('идемпотентность: повторный запуск не па
|
||||
$afterFirst = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->count();
|
||||
|
||||
// Повторный запуск — должен только skip'ать.
|
||||
@@ -71,14 +71,15 @@ test('идемпотентность: повторный запуск не па
|
||||
$afterSecond = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->count();
|
||||
|
||||
expect($afterSecond)->toBe($afterFirst);
|
||||
|
||||
// Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции.
|
||||
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
|
||||
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
$output = Artisan::output();
|
||||
expect($output)->toContain('0 created, 54 skipped');
|
||||
expect($output)->toContain('0 created, 48 skipped');
|
||||
});
|
||||
|
||||
test('--ahead=0 создаёт только текущий месяц', function () {
|
||||
@@ -100,7 +101,6 @@ test('партиция корректно принимает INSERT в окно
|
||||
'subdomain' => 'partition-test-'.uniqid(),
|
||||
'organization_name' => 'PartitionTest',
|
||||
'contact_email' => 'pt@test.local',
|
||||
'webhook_token' => str_repeat('p', 64),
|
||||
'api_key_limit' => 5,
|
||||
]);
|
||||
$projectId = DB::table('projects')->insertGetId([
|
||||
|
||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
|
||||
* по всем трём путям — ручной API, поставщик (RouteSupplierLeadJob),
|
||||
* вебхук (ProcessWebhookJob).
|
||||
* по двум живым путям — ручной API, поставщик (RouteSupplierLeadJob).
|
||||
*/
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
@@ -128,36 +126,3 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path C: webhook via ProcessWebhookJob
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
$vid = 55566;
|
||||
(new ProcessWebhookJob($tenant->id, [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_PdWebhookTest',
|
||||
'tag' => 'PdWebhookTest',
|
||||
'phone' => '79001112233',
|
||||
'phones' => ['79001112233'],
|
||||
'time' => time(),
|
||||
]))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_webhook')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $deal->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('actor_tenant_user_id')
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
@@ -19,8 +19,8 @@ it('tenants table has delivered_in_month column with CHECK >= 0', function () {
|
||||
expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue();
|
||||
DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op
|
||||
expect(fn () => DB::statement(
|
||||
'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '.
|
||||
"VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)"
|
||||
'INSERT INTO tenants (subdomain, organization_name, contact_email, delivered_in_month) '.
|
||||
"VALUES ('t-neg-test', 'X', 'x@x', -1)"
|
||||
))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
@@ -59,32 +59,33 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () {
|
||||
it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS policies', function () {
|
||||
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
|
||||
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26.
|
||||
// источник истины метрик.
|
||||
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
|
||||
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
|
||||
// v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса.
|
||||
// v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot)
|
||||
// + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*).
|
||||
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
|
||||
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
|
||||
// v8.35 (legacy webhook removal): −2 таблицы (webhook_log partitioned + rejected_deals_log)
|
||||
// −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at.
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
|
||||
$schema = file_get_contents($schemaPath);
|
||||
expect($schema)->not->toBeFalse();
|
||||
|
||||
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
|
||||
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
|
||||
//
|
||||
// 67 base tables = все CREATE TABLE минус PARTITION OF.
|
||||
// 66 base tables = все CREATE TABLE минус PARTITION OF.
|
||||
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
|
||||
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
|
||||
$baseTables = $createTables - $partitionOf;
|
||||
expect($baseTables)->toBe(67);
|
||||
expect($baseTables)->toBe(66);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning
|
||||
expect($createIndexes)->toBe(120); // v8.35: −5 индексов (webhook_log ×2, rejected_deals_log ×2, tenants.webhook_token ×1)
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах
|
||||
expect($createPolicies)->toBe(40); // v8.35: −2 политики (webhook_log + rejected_deals_log)
|
||||
});
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\RejectedDealsLog;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\WebhookDedupKey;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Тесты ProcessWebhookJob — двустадийный dedup v8.6 (CTO-17).
|
||||
*
|
||||
* Проверяет ключевую архитектурную инвариант: один и тот же vid должен
|
||||
* обновлять существующую сделку (а не создавать дубль), и баланс должен
|
||||
* списываться ровно один раз. См. narrative ТЗ §5.5.
|
||||
*
|
||||
* NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
|
||||
* trait оборачивает каждый тест в outer-транзакцию — Laravel-PG-driver
|
||||
* корректно обрабатывает nested через savepoints.
|
||||
*
|
||||
* SharesSupplierPdo: failed() now inserts via pgsql_supplier (BYPASSRLS) —
|
||||
* share PDO so DatabaseTransactions cross-connection visibility works on dev.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function makePayload(int $vid = 432176649, ?int $time = null): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga', // префикс должен обрезаться до 'Caranga'
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79001234567',
|
||||
'phones' => ['79001234567'],
|
||||
'time' => $time ?? time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт активного поставщика и привязывает его к проекту через project_suppliers.
|
||||
* Используется в тестах SupplierLeadCost-ветки.
|
||||
*/
|
||||
function seedSupplierForProject(Project $project, float $costRub = 50.00): int
|
||||
{
|
||||
$supplierId = (int) DB::table('suppliers')->insertGetId([
|
||||
'code' => 'b1-test-'.Str::lower(Str::random(6)),
|
||||
'name' => 'B1 Test',
|
||||
'accepts_types' => '{websites,calls}',
|
||||
'cost_rub' => $costRub,
|
||||
'channel' => 'sites',
|
||||
'quality_score' => 1.00,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
DB::table('project_suppliers')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_id' => $supplierId,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $supplierId;
|
||||
}
|
||||
|
||||
test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9);
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
expect($deal->source_crm_id)->toBe(100);
|
||||
expect($deal->phone)->toBe('79001234567');
|
||||
expect($deal->status)->toBe('new');
|
||||
expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан
|
||||
|
||||
$dedup = WebhookDedupKey::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('source_crm_id', 100)
|
||||
->first();
|
||||
expect($dedup)->not->toBeNull();
|
||||
expect($dedup->deal_id)->toBe($deal->id);
|
||||
});
|
||||
|
||||
test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$vid = 200;
|
||||
|
||||
// Первый webhook
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9);
|
||||
$dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count();
|
||||
|
||||
// Второй webhook с тем же vid (но новым phone — будет UPDATE)
|
||||
$payload2 = makePayload(vid: $vid);
|
||||
$payload2['phone'] = '79009999999';
|
||||
(new ProcessWebhookJob($tenant->id, $payload2))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9); // баланс не изменился
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst);
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal->phone)->toBe('79009999999'); // обновлён phone
|
||||
|
||||
// dedup-ключ всё ещё ровно один
|
||||
expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(0);
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle();
|
||||
(new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle();
|
||||
|
||||
expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
||||
expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
|
||||
expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2);
|
||||
|
||||
$tenantA->refresh();
|
||||
$tenantB->refresh();
|
||||
expect($tenantA->balance_leads)->toBe(9);
|
||||
expect($tenantB->balance_leads)->toBe(9);
|
||||
});
|
||||
|
||||
test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle();
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle();
|
||||
|
||||
expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
DB::table('deals')
|
||||
->where('id', $deal->id)
|
||||
->where('received_at', $deal->received_at)
|
||||
->delete();
|
||||
|
||||
expect(WebhookDedupKey::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('source_crm_id', 700)
|
||||
->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
$tx = BalanceTransaction::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
|
||||
->first();
|
||||
|
||||
expect($tx)->not->toBeNull();
|
||||
expect($tx->amount_leads)->toBe(-1);
|
||||
expect($tx->balance_leads_after)->toBe(9);
|
||||
expect($tx->related_type)->toBe(Deal::class);
|
||||
expect($tx->related_id)->toBe($deal->id);
|
||||
});
|
||||
|
||||
test('дубль vid НЕ создаёт BalanceTransaction', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$vid = 801;
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||||
|
||||
expect(BalanceTransaction::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('новая сделка создаёт ActivityLog event=deal.created', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
$log = ActivityLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('deal_id', $deal->id)
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED);
|
||||
expect($log->user_id)->toBeNull();
|
||||
expect($log->context)->toBe(['source' => 'webhook']);
|
||||
});
|
||||
|
||||
test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle();
|
||||
|
||||
$rejected = RejectedDealsLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first();
|
||||
|
||||
expect($rejected)->not->toBeNull();
|
||||
expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE);
|
||||
expect($rejected->payload['vid'])->toBe(803);
|
||||
});
|
||||
|
||||
test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Caranga', // совпадает с обрезанным project из payload
|
||||
]);
|
||||
$supplierId = seedSupplierForProject($project, costRub: 75.50);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
$cost = SupplierLeadCost::query()
|
||||
->where('deal_id', $deal->id)
|
||||
->where('received_at', $deal->received_at)
|
||||
->first();
|
||||
|
||||
expect($cost)->not->toBeNull();
|
||||
expect($cost->supplier_id)->toBe($supplierId);
|
||||
expect((string) $cost->cost_rub)->toBe('75.50');
|
||||
expect($cost->supplier_lead_id)->toBe(804);
|
||||
});
|
||||
|
||||
test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
expect(SupplierLeadCost::query()
|
||||
->where('deal_id', $deal->id)
|
||||
->count())->toBe(0);
|
||||
|
||||
// Сделка всё равно создаётся, баланс списан, ActivityLog есть.
|
||||
expect($deal)->not->toBeNull();
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Spec B: no phone dedup — supplier owns dedup, Лидерра charges everything delivered
|
||||
// =============================================================================
|
||||
|
||||
test('charges both leads with same phone but different vid (no phone dedup, Spec B)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 5]);
|
||||
$phone = '79007770010';
|
||||
|
||||
// First webhook — distinct vid
|
||||
$payload1 = makePayload(vid: 951);
|
||||
$payload1['phone'] = $phone;
|
||||
$payload1['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $payload1))->handle();
|
||||
|
||||
// Second webhook — same phone, different vid
|
||||
$payload2 = makePayload(vid: 952);
|
||||
$payload2['phone'] = $phone;
|
||||
$payload2['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $payload2))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
// Both charged — balance_leads decremented twice.
|
||||
expect($tenant->balance_leads)->toBe(3);
|
||||
|
||||
// Two distinct deals exist for this tenant.
|
||||
$deals = Deal::query()->where('tenant_id', $tenant->id)->get();
|
||||
expect($deals)->toHaveCount(2);
|
||||
|
||||
// Neither deal has duplicate_of_id set.
|
||||
foreach ($deals as $deal) {
|
||||
expect($deal->duplicate_of_id)->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// failed() callback — финальная обработка после исчерпания ретраев
|
||||
// =============================================================================
|
||||
|
||||
test('failed() пишет упавший job в failed_webhook_jobs', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$webhookLogId = (int) DB::table('webhook_log')->insertGetId([
|
||||
'tenant_id' => $tenant->id,
|
||||
'raw_payload' => json_encode(['vid' => 1001]),
|
||||
'received_at' => now(),
|
||||
]);
|
||||
$payload = makePayload(vid: 1001);
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId);
|
||||
$job->failed(new RuntimeException('boom: db down'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->webhook_log_id)->toBe($webhookLogId);
|
||||
expect($row->exception)->toBe('boom: db down');
|
||||
expect($row->retry_count)->toBe(3);
|
||||
expect($row->resolved_at)->toBeNull();
|
||||
expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001);
|
||||
});
|
||||
|
||||
test('failed() работает БЕЗ webhookLogId (NULL ok)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002));
|
||||
$job->failed(new RuntimeException('no webhook log id'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->webhook_log_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('failed() записывает payload с UTF-8 кириллицей корректно', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$payload = makePayload(vid: 1003);
|
||||
$payload['contact_name'] = 'Дмитрий Петров';
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, $payload);
|
||||
$job->failed(new RuntimeException('utf-8 test'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
|
||||
$decoded = json_decode($row->raw_payload, true);
|
||||
|
||||
expect($decoded['contact_name'])->toBe('Дмитрий Петров');
|
||||
});
|
||||
@@ -45,14 +45,12 @@ SQL);
|
||||
'subdomain' => 'rls-tenant-a-'.uniqid(),
|
||||
'organization_name' => 'RLS Tenant A',
|
||||
'contact_email' => 'a@rls-test.local',
|
||||
'webhook_token' => 'whtA'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
|
||||
'api_key_limit' => 5,
|
||||
]);
|
||||
$this->tenant2Id = DB::table('tenants')->insertGetId([
|
||||
'subdomain' => 'rls-tenant-b-'.uniqid(),
|
||||
'organization_name' => 'RLS Tenant B',
|
||||
'contact_email' => 'b@rls-test.local',
|
||||
'webhook_token' => 'whtB'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
|
||||
'api_key_limit' => 5,
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,21 +5,25 @@ declare(strict_types=1);
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||||
* J2 (Sprint 3F) — гейт SaaS-admin зоны.
|
||||
*
|
||||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||||
* EnsureSaasAdmin на /api/admin/*: пропускает запрос во ВСЕХ окружениях.
|
||||
* Защита боевой админ-зоны (/admin + /api/admin/*) перенесена на nginx
|
||||
* (HTTP Basic Auth, отдельный пароль — /etc/nginx/.htpasswd-admin), потому
|
||||
* что настоящий saas-admin SSO (Yandex 360) ещё не готов (Б-1 + DO-4).
|
||||
* Ранее middleware fail-closed 503 вне dev/testing — это закрывало всю
|
||||
* админку на проде наглухо; стопгэп заменил замок на nginx-дверь.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||||
test('/api/admin/* пропускается на testing-окружении', function () {
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||||
test('/api/admin/* пропускается и на production (замок 503 снят, дверь держит nginx)', function () {
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||||
// Раньше тут был 503. Теперь приложение зону не закрывает — её держит
|
||||
// nginx basic-auth (стопгэп до реального Yandex 360 SSO).
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
@@ -257,3 +257,80 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
|
||||
expect($log->status)->toBe('failed');
|
||||
expect($log->error_message)->toContain('500');
|
||||
});
|
||||
|
||||
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
|
||||
// 100 нормальных webhook-лидов.
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 840000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
|
||||
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 10; $j++) {
|
||||
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(110);
|
||||
expect((int) $log->matched_count)->toBe(100);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect((int) $log->unparseable_count)->toBe(10);
|
||||
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
|
||||
expect((float) $log->drift_ratio)->toBe(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 850000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 5; $j++) {
|
||||
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
|
||||
}
|
||||
for ($k = 0; $k < 3; $k++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(103);
|
||||
expect((int) $log->matched_count)->toBe(95);
|
||||
expect((int) $log->recovered_count)->toBe(3);
|
||||
expect((int) $log->unparseable_count)->toBe(5);
|
||||
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
|
||||
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
|
||||
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
});
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'webhook_token' => 'whk_test_'.bin2hex(random_bytes(8)),
|
||||
'balance_leads' => 100,
|
||||
]);
|
||||
// Чистим RateLimiter между тестами — иначе lockout из одного теста
|
||||
// загрязняет следующий.
|
||||
RateLimiter::clear("webhook:{$this->tenant->id}");
|
||||
|
||||
// Audit-fix B3: дефолт isHmacRequired() изменён на true. Тесты, проверяющие
|
||||
// НЕ-HMAC аспекты (payload-валидация, rate-limit, CSRF), явно ставят флаг в
|
||||
// false — иначе запрос без подписи получит 401 ещё до этих проверок.
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'false', 'type' => 'bool', 'description' => 'test default', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
|
||||
});
|
||||
|
||||
test('POST /api/webhook/{token} с валидным payload возвращает 202 + dispatch ProcessWebhookJob', function () {
|
||||
Bus::fake();
|
||||
|
||||
$payload = [
|
||||
'vid' => 12345,
|
||||
'project' => 'Натяжные потолки',
|
||||
'phone' => '+7 (999) 123-45-67',
|
||||
'time' => time(),
|
||||
'tag' => 'ya_direct',
|
||||
];
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
||||
|
||||
$r->assertStatus(202);
|
||||
expect($r->json('status'))->toBe('accepted');
|
||||
expect($r->json('tenant_id'))->toBe($this->tenant->id);
|
||||
|
||||
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) use ($payload) {
|
||||
return $job->tenantId === $this->tenant->id
|
||||
&& $job->data['vid'] === $payload['vid']
|
||||
&& $job->data['phone'] === $payload['phone'];
|
||||
});
|
||||
});
|
||||
|
||||
test('POST с unknown token → 404', function () {
|
||||
Bus::fake();
|
||||
$r = $this->postJson('/api/webhook/whk_nonexistent_token_12345', [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('POST без обязательных полей → 422', function () {
|
||||
Bus::fake();
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
// Нет vid/project/phone/time
|
||||
]);
|
||||
|
||||
$r->assertStatus(422);
|
||||
$errors = $r->json('errors');
|
||||
expect($errors)->toHaveKeys(['vid', 'project', 'phone', 'time']);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('POST с вредной структурой (vid=строка, time=отрицательный) → 422', function () {
|
||||
Bus::fake();
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 'не-число',
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => -1,
|
||||
]);
|
||||
|
||||
$r->assertStatus(422);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('POST к webhook НЕ требует CSRF (внешний клиент)', function () {
|
||||
Bus::fake();
|
||||
// Симулируем запрос БЕЗ X-XSRF-TOKEN — CSRF middleware не должен проверять
|
||||
// /api/webhook/* (см. bootstrap/app.php validateCsrfTokens except).
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
|
||||
test('POST с `phones` array (multi-phone payload) принимается', function () {
|
||||
Bus::fake();
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'Окна',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'phones' => ['+7 (999) 000-00-01', '+7 (999) 000-00-02'],
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(202);
|
||||
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) {
|
||||
return is_array($job->data['phones']) && count($job->data['phones']) === 2;
|
||||
});
|
||||
});
|
||||
|
||||
test('HMAC: валидная подпись sha256=hex(hmac_sha256(body, token)) проходит', function () {
|
||||
Bus::fake();
|
||||
$payload = [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
];
|
||||
$rawBody = json_encode($payload);
|
||||
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
|
||||
|
||||
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
|
||||
], $rawBody);
|
||||
|
||||
$r->assertStatus(202);
|
||||
Bus::assertDispatched(ProcessWebhookJob::class);
|
||||
});
|
||||
|
||||
test('HMAC: невалидная подпись → 401, dispatch НЕ происходит', function () {
|
||||
Bus::fake();
|
||||
$payload = [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
];
|
||||
$rawBody = json_encode($payload);
|
||||
|
||||
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
'HTTP_X_WEBHOOK_SIGNATURE' => 'sha256=deadbeef'.str_repeat('0', 56),
|
||||
], $rawBody);
|
||||
|
||||
$r->assertStatus(401);
|
||||
expect($r->json('message'))->toContain('HMAC');
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('HMAC: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () {
|
||||
Bus::fake();
|
||||
// Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку,
|
||||
// чтобы проверить именно отсутствие ключа в system_settings.
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->delete();
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(401);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('rate-limit: системный лимит RPS×60 в минуту, 429 + Retry-After на превышении', function () {
|
||||
Bus::fake();
|
||||
// Устанавливаем низкий лимит через system_settings — иначе тест слишком долгий
|
||||
// (default 100 RPS = 6000/мин). Подменяем через update.
|
||||
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
|
||||
|
||||
$payload = [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
];
|
||||
|
||||
// 1 RPS × 60 = 60 запросов/мин. Делаем 60 успешных.
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
||||
$r->assertStatus(202);
|
||||
}
|
||||
|
||||
// 61-й — превышение.
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
||||
$r->assertStatus(429);
|
||||
expect($r->json('retry_after'))->toBeInt()->toBeGreaterThan(0);
|
||||
expect($r->headers->get('Retry-After'))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('webhook_hmac_required=true: запрос без X-Webhook-Signature → 401', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(401);
|
||||
expect($r->json('message'))->toContain('требуется');
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('webhook_hmac_required=true: с валидной HMAC-подписью → 202', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
|
||||
|
||||
$payload = [
|
||||
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
||||
];
|
||||
$rawBody = json_encode($payload);
|
||||
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
|
||||
|
||||
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
|
||||
], $rawBody);
|
||||
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
|
||||
test('webhook_hmac_required=false: header опционален → 202 без подписи', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'false', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
|
||||
test('rate-limit: ключ изолирован per-token (другой tenant не блокирует)', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
|
||||
|
||||
$tenantOther = Tenant::factory()->create([
|
||||
'webhook_token' => 'whk_other_'.bin2hex(random_bytes(8)),
|
||||
]);
|
||||
RateLimiter::clear("webhook:{$tenantOther->id}");
|
||||
|
||||
$payload = [
|
||||
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
||||
];
|
||||
|
||||
// Заполняем лимит первого tenant'а
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(202);
|
||||
}
|
||||
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(429);
|
||||
|
||||
// Второй tenant — без проблем.
|
||||
$r = $this->postJson("/api/webhook/{$tenantOther->webhook_token}", $payload);
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
@@ -1710,3 +1710,48 @@ FNS
|
||||
# Hole #2 partitioning (23.05.2026)
|
||||
партиционировать
|
||||
дёшева
|
||||
|
||||
# Controller-offload agents spec (24.05.2026)
|
||||
синков
|
||||
эскалировать
|
||||
эскалирует
|
||||
митигации
|
||||
Версионная
|
||||
квирках
|
||||
NTFS
|
||||
маппинге
|
||||
dogfooded
|
||||
|
||||
# Controller-offload agents level 1+2 (24.05.2026)
|
||||
бинари
|
||||
ребейзе
|
||||
dok
|
||||
аддон
|
||||
синкни
|
||||
фейл
|
||||
пинуются
|
||||
маунт
|
||||
pgrep
|
||||
захардкоженной
|
||||
ребейза
|
||||
токену
|
||||
тултип
|
||||
|
||||
# Билинг v2 Спек C (25.05.2026)
|
||||
Atol
|
||||
uniqid
|
||||
ОФД
|
||||
брейнсторме
|
||||
префлайт
|
||||
Префлайт
|
||||
скоупа
|
||||
unreviewed
|
||||
|
||||
# admin-zone nginx-gate + drift-fix (25.05.2026 день+1)
|
||||
стопгэп
|
||||
досылает
|
||||
creds
|
||||
опкэш
|
||||
гэп
|
||||
misowned
|
||||
деплоями
|
||||
|
||||
+60
-1
@@ -2,7 +2,66 @@
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.34, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк
|
||||
|
||||
Поставщик `crm.bp-gr.ru` периодически кладёт телефон/URL в поле «project» CSV-выгрузки
|
||||
«Запрос номеров». Парсер `CsvReconcileJob` корректно их скипает (`extractPlatform()` → `null`),
|
||||
но раньше эти строки попадали и в числитель `count($missing)`, и в знаменатель `total_csv_rows`
|
||||
формулы drift'а → стабильный false-positive `drift_alert` ~40-50% при каждом hourly-запуске
|
||||
(на проде 10 запусков подряд → admin-блок «Здоровье резервного канала» показывал «down»).
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **Колонка `supplier_csv_reconcile_log.unparseable_count` INTEGER NOT NULL DEFAULT 0** — кол-во
|
||||
CSV-строк за окно, у которых `project` не парсится в платформу B1/B2/B3.
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- `CsvReconcileJob`: считает `$unparseableCount` отдельно, новая формула
|
||||
`drift_ratio = max(0, missing − unparseable) / max(1, total − unparseable)` —
|
||||
только «реальные» пропуски от parseable-строк, без вклада junk'а.
|
||||
|
||||
**Метрики:** +1 колонка. (Сверять с header `db/schema.sql`.) Таблиц / индексов / RLS — без изменений.
|
||||
|
||||
**Миграция:** `2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log` (idempotent
|
||||
`ADD COLUMN IF NOT EXISTS` на `pgsql_supplier` connection — Спек B pattern).
|
||||
|
||||
**Тесты:** `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — +2 кейса (100 matched +
|
||||
10 junk → status=ok / mixed 95+5junk+3real → drift по реальным). Существующие 7 кейсов — без изменений (drift при unparseable=0 идентичен старой формуле).
|
||||
|
||||
## v8.35 (2026-05-24) — legacy direct webhook removal
|
||||
|
||||
Финальная уборка прямого webhook-канала (тенант → Лидерра). Вся инфраструктура канала
|
||||
упразднена; CSV-канал (поставщик → Лидерра) сохранён полностью.
|
||||
|
||||
**Удалено:**
|
||||
|
||||
- **Таблица `webhook_log`** (partitioned RANGE по `received_at`) + все дочерние партиции (DROP CASCADE).
|
||||
Хранила payload входящих webhook от тенантов. Канал прямого приёма упразднён.
|
||||
- **Таблица `rejected_deals_log`** (регулярная) — журнал отвергнутых лидов прямого webhook-канала.
|
||||
- **Колонки `tenants.webhook_token` + `tenants.webhook_token_rotated_at`** — токен аутентификации
|
||||
прямого webhook. Индекс `idx_tenants_webhook_token` удалён вместе с колонкой.
|
||||
- **Seed-строка `low_balance_threshold_leads`** в `system_settings` — использовалась только
|
||||
удалённым `LowBalanceNotification` mailable'ом.
|
||||
- **Seed-строки `webhook_log_retention_days` + `webhook_log_retention_months`** в `system_settings`.
|
||||
|
||||
**Оставлено (НЕ удалено):**
|
||||
|
||||
- **`webhook_dedup_keys`** — используется CSV-каналом (`HistoricalImportService`) для идемпотентности.
|
||||
- **`failed_webhook_jobs.webhook_log_id`** — orphan BIGINT (без FK с v8.31/W1); оставлен.
|
||||
- **`outbound_webhook_subscriptions` + `outbound_webhook_deliveries`** — исходящий webhook (тенант → внешний URL); не затронут.
|
||||
|
||||
**Метрики:** −2 таблицы / −5 индексов / −2 RLS-политики.
|
||||
66 base tables (65 regular + 8 partitioned parents) / 120 indexes / 40 RLS policies.
|
||||
|
||||
**Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts`
|
||||
|
||||
**Связанные изменения кода:**
|
||||
|
||||
- `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log`
|
||||
- `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log`
|
||||
|
||||
## v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index
|
||||
|
||||
|
||||
+24
-56
@@ -1,12 +1,14 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings)
|
||||
-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
|
||||
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
|
||||
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
|
||||
-- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns)
|
||||
-- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
|
||||
-- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
|
||||
-- Метрики: 75 базовые таблицы (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 42 RLS-политика / 5 функций / 15 триггеров
|
||||
-- Метрики: 73 базовые таблицы (65 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 120 индексов / 40 RLS-политик / 5 функций / 15 триггеров
|
||||
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
|
||||
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
|
||||
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
@@ -630,8 +632,7 @@ CREATE TABLE tenants (
|
||||
contact_email VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active'
|
||||
CHECK (status IN ('active','suspended','pending_email_confirm','deleted')),
|
||||
webhook_token VARCHAR(64) UNIQUE NOT NULL,
|
||||
webhook_token_rotated_at TIMESTAMPTZ,
|
||||
-- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal)
|
||||
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
|
||||
locale VARCHAR(10) DEFAULT 'ru',
|
||||
-- Биллинг
|
||||
@@ -678,7 +679,7 @@ CREATE TABLE tenants (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
|
||||
-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal)
|
||||
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
|
||||
@@ -1137,6 +1138,11 @@ CREATE TABLE supplier_csv_reconcile_log (
|
||||
total_csv_rows INTEGER,
|
||||
matched_count INTEGER,
|
||||
recovered_count INTEGER,
|
||||
-- Кол-во CSV-строк, у которых поле «project» не парсится в платформу B1/B2/B3
|
||||
-- (поставщик иногда кладёт телефон/URL в «Name» вместо названия проекта).
|
||||
-- Используется CsvReconcileJob для корректного расчёта drift'а — без вычитания
|
||||
-- этих строк формула стабильно даёт false-positive drift_alert ~40-50%.
|
||||
unparseable_count INTEGER NOT NULL DEFAULT 0,
|
||||
drift_ratio NUMERIC(5,4),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'running'
|
||||
CHECK (status IN ('running','ok','drift_alert','failed')),
|
||||
@@ -1415,7 +1421,7 @@ CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- outbound_webhook_deliveries — журнал попыток доставки (v8.4)
|
||||
-- Retention 90 дней (как webhook_log). См. §19.10.6 (retry-логика 7 попыток
|
||||
-- Retention 90 дней. См. §19.10.6 (retry-логика 7 попыток
|
||||
-- от 30 секунд до 24 часов).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE outbound_webhook_deliveries (
|
||||
@@ -1919,32 +1925,9 @@ COMMENT ON TABLE in_app_notifications IS
|
||||
'read_at при клике. RLS: tenant isolation.';
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- webhook_log — лог принятых webhook (раздел 5.7)
|
||||
-- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at).
|
||||
-- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK
|
||||
-- партиционированной таблицы с единичным FK-столбцом).
|
||||
CREATE TABLE webhook_log (
|
||||
id BIGSERIAL,
|
||||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook)
|
||||
raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
processed_at TIMESTAMPTZ,
|
||||
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
|
||||
error TEXT,
|
||||
-- v8.29: supplier webhook audit columns
|
||||
source VARCHAR(50), -- 'supplier' | 'tenant'
|
||||
status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited'
|
||||
lead_id BIGINT, -- supplier_leads.id при статусе 'received'
|
||||
ip_address INET, -- клиентский IP
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC);
|
||||
CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC);
|
||||
-- webhook_log удалена в v8.35 (legacy direct webhook removal).
|
||||
-- Канал входящих webhook (прямой приём от тенантов) упразднён.
|
||||
-- webhook_dedup_keys сохранена — используется CSV-каналом (HistoricalImportService).
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -1969,21 +1952,8 @@ CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC
|
||||
CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02)
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- rejected_deals_log — лог отвергнутых лидов при balance=0 (раздел 5.7)
|
||||
-- РЕТЕНШН: бессрочно (опционально 12 месяцев)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE rejected_deals_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
|
||||
reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ...
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03)
|
||||
-- rejected_deals_log удалена в v8.35 (legacy direct webhook removal).
|
||||
-- Rejection-журнал для прямого webhook-канала упразднён вместе с каналом.
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -2803,7 +2773,7 @@ VALUES
|
||||
INSERT INTO system_settings (key, value, type, description) VALUES
|
||||
('schema_version', '8.3', 'string', 'Текущая версия схемы БД'),
|
||||
('trial_bonus_leads', '50', 'int', 'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'),
|
||||
('low_balance_threshold_leads', '10', 'int', 'Порог email-предупреждения о низком балансе'),
|
||||
-- low_balance_threshold_leads удалён в v8.35 (использовался только LowBalanceNotification — удалена вместе с webhook-каналом)
|
||||
('inactive_warn_months', '11', 'int', 'Через сколько месяцев простоя слать предупреждение'),
|
||||
('inactive_delete_months', '12', 'int', 'Через сколько месяцев простоя удалять данные'),
|
||||
('webhook_rate_limit_rps', '100', 'int', 'Лимит запросов в секунду на токен Webhook'),
|
||||
@@ -2811,7 +2781,7 @@ INSERT INTO system_settings (key, value, type, description) VALUES
|
||||
('api_rate_limit_per_minute', '60', 'int', 'Лимит запросов API на ключ в минуту'),
|
||||
('login_max_attempts', '5', 'int', 'Макс. неудачных попыток входа в окне 15 минут'),
|
||||
('password_min_length', '10', 'int', 'Минимальная длина пароля'),
|
||||
('webhook_log_retention_days', '90', 'int', 'Сколько дней хранить raw_payload Webhook'),
|
||||
-- webhook_log_retention_days удалён в v8.35 (webhook_log таблица удалена)
|
||||
-- VAPID (Web Push, раздел 17.4)
|
||||
('vapid_public_key', '', 'string', 'VAPID public key (для подписки)'),
|
||||
('vapid_private_key', '', 'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'),
|
||||
@@ -2839,11 +2809,11 @@ INSERT INTO system_settings (key, value, type, description) VALUES
|
||||
-- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth.
|
||||
('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
|
||||
('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'),
|
||||
-- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
|
||||
-- v8.31: retention для audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
|
||||
-- webhook_log_retention_months удалён в v8.35 (webhook_log таблица удалена).
|
||||
('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'),
|
||||
('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'),
|
||||
('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'),
|
||||
('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'),
|
||||
('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'),
|
||||
('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'),
|
||||
('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)');
|
||||
@@ -2916,9 +2886,8 @@ ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline)
|
||||
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
|
||||
-- webhook_log / rejected_deals_log: таблицы удалены в v8.35
|
||||
ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE rejected_deals_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tariff_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE saas_invoices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE saas_invoice_items ENABLE ROW LEVEL SECURITY; -- через invoice_id косвенно (см. политику ниже)
|
||||
@@ -2958,9 +2927,8 @@ CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = cur
|
||||
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline
|
||||
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
-- webhook_log / rejected_deals_log policies удалены в v8.35 (таблицы удалены)
|
||||
CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON rejected_deals_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.39 (22.05.2026)
|
||||
**Дата:** 22.05.2026
|
||||
**Версия:** v1.40 (24.05.2026)
|
||||
**Дата:** 24.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.40 относительно v1.39:** Делегирование проектным AI-агентам — §2.4 (новая подсекция) описывает обязанность контроллера передавать класс задач 4 узко-специализированным агентам в `.claude/agents/`: `normative-sync` (#84, синк 4 нормативных файлов после крупной задачи), `prod-deploy-validator` (#85, 8 SSH pre-flight перед выкатом на liderra.ru), плюс прежние `pest-parallel-debugger` и `rls-reviewer`. Project-агенты регистрируются в `docs/registry/nodes.yaml` (subcategory `project-agent`) для missed-activation детектора, но **не входят в Tooling канон счётчиков** #1-#83 (footer-числа не двигаются). Архитектурных изменений §§1, §3–§16: 0. Связано: CLAUDE.md v2.28+ (§3.9), spec `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md`, agent files `.claude/agents/{normative-sync,prod-deploy-validator}.md`.
|
||||
|
||||
**Что изменилось в v1.39 относительно v1.38:** C1 marketing-tooling — §13.2 +абзац «Off-phase marketing-tooling»: #74 marketing (Anthropic, первичный решатель C1), #75 marketingskills (вендорен MIT, материал/резерв), #76 brand-voice (Anthropic, вербальный бренд), #77 marketing-ru (self-authored project-скил, РФ-специфика + 152-ФЗ маркетинг), #78 Яндекс.Метрика MCP (READ-ONLY), #79 Яндекс.Директ+Wordstat MCP (**Wordstat-only**, Direct-мутации отключены per IS9), #80 Telegram MCP, #81 Postiz (self-host, AGPL-3.0 internal), #82 DataForSEO (**DEFERRED**, pending Б-1/бюджет), #83 Unisender Go (**DEFERRED**, pending согласования + 152-ФЗ). 18-я off-phase подкатегория, раздел C1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-015. Счётчики — канон Tooling §0. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.23+, PSR_v1 v3.22+, CLAUDE.md v2.27+; план `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`.
|
||||
|
||||
**Что изменилось в v1.38 относительно v1.37:** A8 infosec install-sync — ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco, по выбору заказчика «оба портативно») → в §13.2 абзаце «Off-phase infosec-tooling» статус **PENDING INSTALL снят** для обоих (ZAP: ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17; Ward: собран portable Go → `bin/ward.exe` v0.4.1); setup-доки `docs/security/{zap,ward}-setup.md`. Архитектурных изменений §§1–16: 0. Связано: Tooling v2.21, PSR_v1 v3.21, CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
@@ -238,6 +240,27 @@ Claude — **системный архитектор-документалист*
|
||||
- Не продолжает работу при обнаружении противоречия между файлами архива «молча» — всегда сообщает заказчику.
|
||||
- Не загружает в контекст всю документацию v8.0+ целиком — обращается по разделам через `project_knowledge_search`.
|
||||
|
||||
### 2.4. Делегирование проектным AI-агентам (v1.40)
|
||||
|
||||
В `.claude/agents/` лежат проектные AI-агенты (Sonnet 4.6, узко-специализированные). Контроллер ОБЯЗАН делегировать им следующие классы задач:
|
||||
|
||||
| Условие | Агент | Brief-фраза заказчика-триггер |
|
||||
|---------|-------|-------------------------------|
|
||||
| Закрытие крупной off-phase интеграции / brain governance артефакта / принятого ADR — синк 4 нормативных файлов (Pravila / PSR_v1 / Tooling / CLAUDE.md) | `normative-sync` (#84, [agent file](../.claude/agents/normative-sync.md), [spec §3](superpowers/specs/2026-05-24-controller-offload-agents-design.md)) | «синкни нормативку», «закрыли X — нужен синк» |
|
||||
| Любой выкат на боевой `liderra.ru` (push с миграциями, scp-патч, ручной деплой) — 8 pre-flight SSH проверок | `prod-deploy-validator` (#85, [agent file](../.claude/agents/prod-deploy-validator.md), [spec §4](superpowers/specs/2026-05-24-controller-offload-agents-design.md)) | «проверь готовность боевого», «ready to deploy» |
|
||||
| Диагностика Pest 4 `--parallel` failures (квирки 72/73/77) | `pest-parallel-debugger` ([agent file](../.claude/agents/pest-parallel-debugger.md)) | «pest упал на parallel», «классифицируй фейл» |
|
||||
| RLS-compliance review при правке `db/schema.sql` или `db/migrations/` | `rls-reviewer` ([agent file](../.claude/agents/rls-reviewer.md)) | «проверь RLS на миграции», «RLS review» |
|
||||
|
||||
**Дисциплина делегирования:**
|
||||
|
||||
- Контроллер вызывает агента **по триггеру**, не дожидаясь явного запроса заказчика — для `normative-sync` сразу после крупной задачи; для `prod-deploy-validator` обязательно ДО любых SSH-команд на боевой.
|
||||
- Заказчик может явно отменить вызов («не зови сейчас X»). Live-отмена — только на текущее действие.
|
||||
- Если агент возвращает эскалацию — контроллер передаёт её заказчику без угадывания, не пытается обойти.
|
||||
- Project-агенты **не входят в Tooling Прил. Н канон счётчиков** (#1-#83 — реестр инструментов; project-агенты идут #84+ в `docs/registry/nodes.yaml` с подкатегорией `project-agent`). Footer-счётчики «N формализованных» изменяются только при добавлении в Tooling-канон, не при добавлении project-агента.
|
||||
- Полный perimeter / триггеры / границы — в `description`-frontmatter каждого агент-файла. Это первичный источник «когда звать»; настоящая таблица — индекс контракта, не дублирующая декларация.
|
||||
|
||||
**Naming-convention:** `agent_file: ".claude/agents/<slug>.md"` атрибут в `docs/registry/nodes.yaml` маркирует узел как project-агент (отличает от Tooling-инструментов, у которых `tooling_section` атрибут).
|
||||
|
||||
---
|
||||
|
||||
## 3. Формат ответов и работы с файлами
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"2026-05": {
|
||||
"WIN_USER_PATH": 6
|
||||
}
|
||||
}
|
||||
+26
-8
@@ -1,30 +1,48 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-23T16:38:59.719Z
|
||||
Last updated: 2026-05-25T04:31:41.337Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C2 Cross-ref consistency | 🔴 | Update cross-refs in offending files. |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ⚠️ | 165 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
|
||||
| C5 Observer-coverage | ⚠️ | 341 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 165 episodes this month, 0 observer_error markers, 83 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 26
|
||||
- Observer evidence: 341 episodes this month, 0 observer_error markers, 31 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 202
|
||||
- Last /brain-retro: 0 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
|
||||
Baseline дисциплины роутера (этап 2 router discipline overhaul, spec 2026-05-23). Цель — увидеть «точку До» перед enforcement-хуком этапа 3.
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| analysis | 15 | 46.7% | 26.7% |
|
||||
| monitoring | 12 | 0.0% | 0.0% |
|
||||
| bugfix | 10 | 40.0% | 40.0% |
|
||||
| planning | 9 | 11.1% | 22.2% |
|
||||
| feature | 9 | 22.2% | 0.0% |
|
||||
| refactor | 1 | 0.0% | 0.0% |
|
||||
| cleanup | 1 | 0.0% | 0.0% |
|
||||
|
||||
Router step distribution: 1: 139, 2: 118, 3: 37, 5: 42
|
||||
|
||||
Boundaries applied (ADR / границы): 47 of 336 эпизодов (14.0%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
|
||||
- Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
|
||||
- Этап 2 (измерения + классификатор-парсер) ⏸ ждёт «продолжаем» от заказчика. Plan: TBD.
|
||||
- Этап 3 (принуждение — хук на routing) — не начат.
|
||||
- Этап 4 (уборка правил) — не начат.
|
||||
- Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 + влит в main 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
|
||||
- Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). **Follow-up 3 fixes 2026-05-24** (после ANTHROPIC_API_KEY + рестарта CC выявлены при инспекции state): (a) UTF-8 stdin helper `tools/router-stdin-helper.mjs` через `StringDecoder` + подключение к 3 хукам (русский в state-файл и Anthropic API без mojibake); (b) `tools/observer-state-enricher.mjs` — pure helper для чтения `router-state-<session>.json`; (c) `parseTranscript` обогащение `primary_rationale` 4 полями (`recommended_node` override + `recommended_chain` + `chain_progress` + `chain_completed`). 538 tools-тестов GREEN. Plan: `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. CHECKPOINT B: дать warn-only накопить реальные наблюдения с **починенным** сторожем (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`.
|
||||
- Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат.
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
|
||||
- Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
|
||||
- Этап 2 (измерения + классификатор-парсер) ⏸ ждёт «продолжаем» от заказчика. Plan: TBD.
|
||||
- Этап 3 (принуждение — хук на routing) — не начат.
|
||||
- Этап 4 (уборка правил) — не начат.
|
||||
- Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 + влит в main 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
|
||||
- Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). **Follow-up 3 fixes 2026-05-24** (после ANTHROPIC_API_KEY + рестарта CC выявлены при инспекции state): (a) UTF-8 stdin helper `tools/router-stdin-helper.mjs` через `StringDecoder` + подключение к 3 хукам (русский в state-файл и Anthropic API без mojibake); (b) `tools/observer-state-enricher.mjs` — pure helper для чтения `router-state-<session>.json`; (c) `parseTranscript` обогащение `primary_rationale` 4 полями (`recommended_node` override + `recommended_chain` + `chain_progress` + `chain_completed`). 538 tools-тестов GREEN. Plan: `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. CHECKPOINT B: дать warn-only накопить реальные наблюдения с **починенным** сторожем (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`.
|
||||
- Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Baseline дисциплины роутера — pre-enforcement snapshot
|
||||
|
||||
**Дата:** 2026-05-24
|
||||
**Источник данных:** `docs/observer/episodes-2026-05.jsonl`
|
||||
**Этап:** Router discipline overhaul, Stage 2 (Measurements). Зафиксирован для сравнения с пост-enforcement цифрами этапа 3.
|
||||
**Spec:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`
|
||||
**Plan:** `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`
|
||||
**Commit:** e239160a (snapshot creation) → 436284c5 (F1 top-5 nodes fix)
|
||||
|
||||
## Объём данных
|
||||
|
||||
- Эпизодов всего: 129 (124 v2+ + 5 v1)
|
||||
- v2+ эпизодов (анализируется): 124
|
||||
- v1 эпизодов пропущено: 5
|
||||
- Observer-error маркеров: 0
|
||||
|
||||
## Цифры
|
||||
|
||||
### Дисциплина по типам задач
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| bugfix | 6 | 33.3% | 33.3% |
|
||||
| analysis | 4 | 0% | 25.0% |
|
||||
| feature | 5 | 0% | 0% |
|
||||
| planning | 2 | 0% | 0% |
|
||||
| refactor | 1 | 0% | 0% |
|
||||
| cleanup | 1 | 0% | 0% |
|
||||
| monitoring | 1 | 0% | 0% |
|
||||
|
||||
### Распределение по шагам роутера
|
||||
|
||||
- distribution: `{"1": 124}`
|
||||
- total: 124
|
||||
- **suspicious: true** — >90% эпизодов остановились на step=1; sentinel-bug парсера, требует исследования в этапе 3
|
||||
|
||||
### Применение границ (ADR)
|
||||
|
||||
- Total: 124
|
||||
- With boundaries: 13
|
||||
- Rate: 10.5%
|
||||
- By path_type:
|
||||
- `improvised`: 112 эпизодов, 11 с boundaries, 9.8%
|
||||
- `regulated`: 12 эпизодов, 2 с boundaries, 16.7%
|
||||
|
||||
### Missed activations
|
||||
|
||||
- Total: 17
|
||||
|
||||
By classification:
|
||||
|
||||
```json
|
||||
{
|
||||
"bugfix": 4,
|
||||
"feature": 5,
|
||||
"refactor": 1,
|
||||
"planning": 2,
|
||||
"cleanup": 1,
|
||||
"monitoring": 1,
|
||||
"analysis": 3
|
||||
}
|
||||
```
|
||||
|
||||
By node (top 5 по количеству):
|
||||
|
||||
```json
|
||||
{
|
||||
"#19": 12,
|
||||
"#34": 5,
|
||||
"#18": 4,
|
||||
"#25": 3,
|
||||
"#39": 3
|
||||
}
|
||||
```
|
||||
|
||||
*(#53 также имеет count 3, следующие: #11:#12:#41:#42 = 2)*
|
||||
|
||||
## Контекст
|
||||
|
||||
Это «точка До» перед включением enforcement-хука этапа 3. После недели работы хука повторно снимем эти цифры и сравним.
|
||||
|
||||
**Цели overhaul'а (из spec'а §acceptance criteria):**
|
||||
|
||||
- Дисциплина (% эпизодов с матченным триггером на классифицированных задачах): **≥75%** (baseline зафиксирован выше — сейчас 33.3% лишь у bugfix, остальные 0%).
|
||||
- Missed activations: **≤5/неделю** (baseline: 17 за месяц).
|
||||
- % feature/planning без skill: **≤10%** (baseline: feature 0%, planning 0% — обе категории нарушают цель).
|
||||
|
||||
## Заметка о suspicious-флаге
|
||||
|
||||
`suspicious: true` в `routerStep` указывает, что **все 124 v2+ эпизода имеют `step=1`**. Это означает, что парсер `tools/observer-transcript-parser.mjs` пока не enrich'ит фактический шаг роутера — поле `primary_rationale.step` сейчас постоянно `1` (sentinel default). Этот пропуск самой инструментовки наблюдателя — отдельный задел для этапа 3 (нужно либо расширить парсер, чтобы он различал шаги, либо явно вычислять step из контекста). До этого срез по router_step **не информативен**.
|
||||
|
||||
## Воспроизводимость
|
||||
|
||||
```bash
|
||||
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl
|
||||
```
|
||||
|
||||
Источник classificationMap + dormancy — `docs/registry/nodes.yaml` (через `tools/registry-to-classification-map.mjs`).
|
||||
File diff suppressed because one or more lines are too long
@@ -160,6 +160,8 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "php code style", weight: 1.0}
|
||||
- {keyword: "форматтер", weight: 1.0}
|
||||
- {classification: "refactor", weight: 1.0}
|
||||
- {classification: "cleanup", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: []
|
||||
attributes:
|
||||
@@ -176,6 +178,8 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "статанализ php", weight: 1.0}
|
||||
- {keyword: "типы", weight: 1.0}
|
||||
- {classification: "refactor", weight: 1.0}
|
||||
- {classification: "cleanup", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L14"]
|
||||
attributes:
|
||||
@@ -400,6 +404,11 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "sast", weight: 1.0}
|
||||
- {keyword: "security static analysis", weight: 1.0}
|
||||
- {keyword: "статический анализ", weight: 1.0}
|
||||
- {keyword: "sast scan", weight: 1.0}
|
||||
- {keyword: "secret pattern", weight: 1.0}
|
||||
- {keyword: "уязвимость в коде", weight: 1.0}
|
||||
- {classification: "analysis", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "связка binary+mcp"}
|
||||
chain_membership: ["L15", "L6"]
|
||||
@@ -545,6 +554,8 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "отладка production runtime errors", weight: 1.0}
|
||||
- {classification: "bugfix", weight: 1.0}
|
||||
- {classification: "monitoring", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L13", "L8"]
|
||||
attributes:
|
||||
@@ -561,6 +572,7 @@ nodes:
|
||||
- {keyword: "отладка redis/memurai очередей", weight: 1.0}
|
||||
- {keyword: "кэша", weight: 1.0}
|
||||
- {keyword: "pest-race", weight: 1.0}
|
||||
- {classification: "monitoring", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "read-only"}
|
||||
chain_membership: ["L13", "L8"]
|
||||
@@ -578,6 +590,8 @@ nodes:
|
||||
- {keyword: "архитектурные решения", weight: 1.0}
|
||||
- {keyword: "adr", weight: 1.0}
|
||||
- {keyword: "enforcement", weight: 1.0}
|
||||
- {keyword: "architecture decision record", weight: 1.0}
|
||||
- {keyword: "архитектурное решение", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "adr-judge lefthook job 9"}
|
||||
chain_membership: ["L4", "L5"]
|
||||
@@ -595,6 +609,9 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "c4", weight: 1.0}
|
||||
- {keyword: "architecture-диаграммы", weight: 1.0}
|
||||
- {keyword: "mermaid диаграмма", weight: 1.0}
|
||||
- {keyword: "c4 диаграмма", weight: 1.0}
|
||||
- {keyword: "c4 модель", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L4"]
|
||||
attributes:
|
||||
@@ -610,6 +627,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "справочник архитектурных паттернов", weight: 1.0}
|
||||
- {keyword: "clean architecture", weight: 1.0}
|
||||
- {keyword: "hexagonal", weight: 1.0}
|
||||
- {keyword: "ddd", weight: 1.0}
|
||||
- {keyword: "domain-driven", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L5"]
|
||||
attributes:
|
||||
@@ -627,6 +648,10 @@ nodes:
|
||||
- {keyword: "deep аудит безопасности", weight: 1.0}
|
||||
- {keyword: "diff", weight: 1.0}
|
||||
- {keyword: "supply-chain", weight: 1.0}
|
||||
- {keyword: "глубокий security audit", weight: 1.0}
|
||||
- {keyword: "supply chain risk", weight: 1.0}
|
||||
- {keyword: "audit context", weight: 1.0}
|
||||
- {classification: "analysis", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L15", "L6"]
|
||||
attributes:
|
||||
@@ -642,6 +667,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "inline-блокировка уязвимых паттернов", weight: 1.0}
|
||||
- {keyword: "inline уязвимость", weight: 1.0}
|
||||
- {keyword: "code security warning", weight: 1.0}
|
||||
- {keyword: "уязвимый паттерн", weight: 1.0}
|
||||
- {keyword: "secret pattern detection", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "блокирующий PreToolUse (sys.exit 2)"}
|
||||
chain_membership: ["L6"]
|
||||
@@ -659,6 +688,7 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "prd эпик issue код", weight: 1.0}
|
||||
- {keyword: "dev-проекты", weight: 1.0}
|
||||
- {classification: "planning", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L9"]
|
||||
attributes:
|
||||
@@ -677,6 +707,7 @@ nodes:
|
||||
- {keyword: "роадмап", weight: 1.0}
|
||||
- {keyword: "метрики", weight: 1.0}
|
||||
- {keyword: "продуктовые церемонии", weight: 1.0}
|
||||
- {classification: "planning", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L9"]
|
||||
attributes:
|
||||
@@ -693,6 +724,10 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "направление зависимостей", weight: 1.0}
|
||||
- {keyword: "границы слоёв", weight: 1.0}
|
||||
- {keyword: "архитектурная зависимость", weight: 1.0}
|
||||
- {keyword: "layer dependency", weight: 1.0}
|
||||
- {keyword: "deptrac.yaml", weight: 1.0}
|
||||
- {classification: "refactor", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "lefthook job 10"}
|
||||
chain_membership: ["L14", "L5"]
|
||||
@@ -757,6 +792,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "introspection openapi/rest-спек", weight: 1.0}
|
||||
- {keyword: "openapi", weight: 1.0}
|
||||
- {keyword: "swagger", weight: 1.0}
|
||||
- {keyword: "спека api", weight: 1.0}
|
||||
- {keyword: "rest api", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "read-only"}
|
||||
chain_membership: ["L7"]
|
||||
@@ -774,6 +813,9 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "тестирование llm-промптов", weight: 1.0}
|
||||
- {keyword: "eval", weight: 1.0}
|
||||
- {keyword: "eval промпта", weight: 1.0}
|
||||
- {keyword: "llm test", weight: 1.0}
|
||||
- {keyword: "prompt regression", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "только вручную/CI, никогда в хук"}
|
||||
chain_membership: ["L10"]
|
||||
@@ -790,6 +832,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "классический ml-воркфлоу", weight: 1.0}
|
||||
- {keyword: "ml модель", weight: 1.0}
|
||||
- {keyword: "статистика", weight: 1.0}
|
||||
- {keyword: "корреляция", weight: 1.0}
|
||||
- {keyword: "машинное обучение", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L10"]
|
||||
attributes:
|
||||
@@ -819,6 +865,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "документирование/оптимизация бизнес-процессов", weight: 1.0}
|
||||
- {keyword: "бизнес-процесс документ", weight: 1.0}
|
||||
- {keyword: "runbook", weight: 1.0}
|
||||
- {keyword: "capacity plan", weight: 1.0}
|
||||
- {keyword: "risk assessment", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L4"]
|
||||
attributes:
|
||||
@@ -835,6 +885,9 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "моделирование to-be процесса", weight: 1.0}
|
||||
- {keyword: "bpmn 2.0", weight: 1.0}
|
||||
- {keyword: "bpmn", weight: 1.0}
|
||||
- {keyword: "моделирование процесса", weight: 1.0}
|
||||
- {keyword: "swimlane", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "self-authored project skill"}
|
||||
chain_membership: ["L3", "L4"]
|
||||
@@ -851,6 +904,10 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "анализ as-is процесса", weight: 1.0}
|
||||
- {keyword: "discovery из кода", weight: 1.0}
|
||||
- {keyword: "discovery процесса", weight: 1.0}
|
||||
- {keyword: "узкое место", weight: 1.0}
|
||||
- {keyword: "bottleneck", weight: 1.0}
|
||||
- {classification: "analysis", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "self-authored project skill; ADR-009 граница с #55"}
|
||||
chain_membership: ["L3"]
|
||||
@@ -882,6 +939,8 @@ nodes:
|
||||
- {keyword: "интервью-discovery", weight: 1.0}
|
||||
- {keyword: "jtbd", weight: 1.0}
|
||||
- {keyword: "feature system режим", weight: 1.0}
|
||||
- {keyword: "discovery", weight: 1.0}
|
||||
- {keyword: "интервью заказчика", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-009", role: "граница с #53 process-analysis: discovery-interview = интервью человека; process-analysis = discovery из кода"}
|
||||
chain_membership: ["L1", "L2"]
|
||||
@@ -898,6 +957,9 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "создание standalone-скилов", weight: 1.0}
|
||||
- {keyword: "eval", weight: 1.0}
|
||||
- {keyword: "создать скил", weight: 1.0}
|
||||
- {keyword: "новый skill", weight: 1.0}
|
||||
- {keyword: "skill.md", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L11"]
|
||||
attributes:
|
||||
@@ -912,6 +974,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "разработка claude-плагинов", weight: 1.0}
|
||||
- {keyword: "плагин claude code", weight: 1.0}
|
||||
- {keyword: "plugin.json", weight: 1.0}
|
||||
- {keyword: "новый плагин", weight: 1.0}
|
||||
- {keyword: "marketplace плагин", weight: 1.0}
|
||||
boundaries: []
|
||||
chain_membership: ["L11"]
|
||||
attributes:
|
||||
@@ -926,6 +992,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "генерация хуков (только по явному /hookify)", weight: 1.0}
|
||||
- {keyword: "хук claude", weight: 1.0}
|
||||
- {keyword: "новый hook", weight: 1.0}
|
||||
- {keyword: "pretooluse хук", weight: 1.0}
|
||||
- {keyword: "генерация хука", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-010", role: "HK1 pre-check коллизии с существующими хуками перед генерацией"}
|
||||
chain_membership: ["L11"]
|
||||
@@ -955,6 +1025,10 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "актуальная документация библиотек/sdk", weight: 1.0}
|
||||
- {keyword: "актуальная документация библиотеки", weight: 1.0}
|
||||
- {keyword: "лайвдоки", weight: 1.0}
|
||||
- {keyword: "документация пакета", weight: 1.0}
|
||||
- {keyword: "документация sdk", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "первый выбор для документации известной библиотеки; WebFetch — fallback на конкретный URL; WebSearch — без знания библиотеки"}
|
||||
chain_membership: []
|
||||
@@ -974,6 +1048,11 @@ nodes:
|
||||
- {keyword: "us-gaap-отчётность", weight: 1.0}
|
||||
- {keyword: "проводки", weight: 1.0}
|
||||
- {keyword: "close", weight: 1.0}
|
||||
- {keyword: "reconciliation", weight: 1.0}
|
||||
- {keyword: "variance", weight: 1.0}
|
||||
- {keyword: "journal entry", weight: 1.0}
|
||||
- {keyword: "financial statements", weight: 1.0}
|
||||
- {keyword: "audit support", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-012", role: "граница C6/C7; US-GAAP-скилы частично применимы; SOX not-applicable РФ; warehouse-MCP DEFERRED"}
|
||||
chain_membership: []
|
||||
@@ -994,6 +1073,16 @@ nodes:
|
||||
- {keyword: "идемпотентность", weight: 1.0}
|
||||
- {keyword: "тариф-резолюция", weight: 1.0}
|
||||
- {keyword: "дрейф reconcile", weight: 1.0}
|
||||
- {keyword: "списание", weight: 1.0}
|
||||
- {keyword: "биллинг", weight: 1.0}
|
||||
- {keyword: "тариф", weight: 1.0}
|
||||
- {keyword: "баланс", weight: 1.0}
|
||||
- {keyword: "начисление лида", weight: 1.0}
|
||||
- {keyword: "lead_charges", weight: 1.0}
|
||||
- {keyword: "копейки", weight: 1.0}
|
||||
- {keyword: "csv reconcile", weight: 1.0}
|
||||
- {keyword: "bcmath", weight: 1.0}
|
||||
- {keyword: "bcadd", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-012", role: "граница с process-*/D3/ru-tax; аудит кода биллинга, не налогов и не процесса"}
|
||||
chain_membership: ["L13"]
|
||||
@@ -1013,6 +1102,13 @@ nodes:
|
||||
- {keyword: "налоговая база", weight: 1.0}
|
||||
- {keyword: "налогооблагаемое событие", weight: 1.0}
|
||||
- {keyword: "выгрузка бухгалтеру", weight: 1.0}
|
||||
- {keyword: "ндс", weight: 1.0}
|
||||
- {keyword: "усн", weight: 1.0}
|
||||
- {keyword: "налог на прибыль", weight: 1.0}
|
||||
- {keyword: "выручка", weight: 1.0}
|
||||
- {keyword: "проводка", weight: 1.0}
|
||||
- {keyword: "дт/кт", weight: 1.0}
|
||||
- {keyword: "бухгалтер", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-012", role: "закрывает РФ-gap US-GAAP-плагина #61; ≠ billing-audit #62 (корректность кода), ≠ D1/D2"}
|
||||
chain_membership: ["L13"]
|
||||
@@ -1030,6 +1126,11 @@ nodes:
|
||||
- {keyword: "авто-рефакторинг", weight: 1.0}
|
||||
- {keyword: "version-upgrade laravel", weight: 1.0}
|
||||
- {keyword: "удаление мёртвого кода", weight: 1.0}
|
||||
- {keyword: "автоматический рефакторинг", weight: 1.0}
|
||||
- {keyword: "версия php", weight: 1.0}
|
||||
- {keyword: "deprecated php", weight: 1.0}
|
||||
- {keyword: "code modernization", weight: 1.0}
|
||||
- {classification: "refactor", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-013", role: "BT1 vs Pint (трансформация vs форматирование); BT2 vs Larastan (комплементарны); BT3 vs deptrac"}
|
||||
chain_membership: ["L14"]
|
||||
@@ -1046,6 +1147,11 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "метрики качества/сложности/архитектуры php-кода", weight: 1.0}
|
||||
- {keyword: "метрики качества кода", weight: 1.0}
|
||||
- {keyword: "complexity", weight: 1.0}
|
||||
- {keyword: "architecture metrics", weight: 1.0}
|
||||
- {keyword: "качество кода php", weight: 1.0}
|
||||
- {classification: "refactor", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-013", role: "BT4 vs Pint/Larastan; уникум — оси complexity + architecture"}
|
||||
chain_membership: ["L14"]
|
||||
@@ -1065,6 +1171,13 @@ nodes:
|
||||
- {keyword: "паттерн controller/service/job", weight: 1.0}
|
||||
- {keyword: "rls", weight: 1.0}
|
||||
- {keyword: "деньги", weight: 1.0}
|
||||
- {keyword: "controller", weight: 1.0}
|
||||
- {keyword: "service", weight: 1.0}
|
||||
- {keyword: "job", weight: 1.0}
|
||||
- {keyword: "eloquent", weight: 1.0}
|
||||
- {keyword: "partition", weight: 1.0}
|
||||
- {keyword: "lockforupdate", weight: 1.0}
|
||||
- {keyword: "dispatch", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-013", role: "BT5 ≠ architecture-patterns #38 (generic vs проектные конвенции); BT6 ≠ billing-audit #62"}
|
||||
chain_membership: []
|
||||
@@ -1098,6 +1211,10 @@ nodes:
|
||||
- {keyword: "обход входа", weight: 1.0}
|
||||
- {keyword: "инъекции", weight: 1.0}
|
||||
- {keyword: "xss", weight: 1.0}
|
||||
- {keyword: "dast", weight: 1.0}
|
||||
- {keyword: "scan running portal", weight: 1.0}
|
||||
- {keyword: "проникновение в работающий портал", weight: 1.0}
|
||||
- {classification: "security", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-014", role: "IS1 ≠ Semgrep #25 (динамика vs статика); IS2 ≠ Nuclei #69 (глубина vs широта)"}
|
||||
chain_membership: ["L15"]
|
||||
@@ -1113,6 +1230,11 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "известные уязвимости/экспозиция/слабый tls снаружи", weight: 1.0}
|
||||
- {keyword: "nuclei", weight: 1.0}
|
||||
- {keyword: "уязвимость по шаблону", weight: 1.0}
|
||||
- {keyword: "cve scan", weight: 1.0}
|
||||
- {keyword: "nuclei сканер", weight: 1.0}
|
||||
- {classification: "security", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-014", role: "IS2 ≠ ZAP #68 (широта vs глубина — комплементарны)"}
|
||||
chain_membership: ["L15"]
|
||||
@@ -1129,6 +1251,10 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "безопасность настроек laravel", weight: 1.0}
|
||||
- {keyword: ".env/config/заголовки/cookie/secrets/deps", weight: 1.0}
|
||||
- {keyword: "laravel security config", weight: 1.0}
|
||||
- {keyword: "env audit", weight: 1.0}
|
||||
- {keyword: "secrets config", weight: 1.0}
|
||||
- {classification: "security", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-014", role: "IS3 ≠ Larastan #12/Semgrep #25; заменил Enlightn (abandoned/L13)"}
|
||||
chain_membership: ["L15"]
|
||||
@@ -1144,6 +1270,14 @@ nodes:
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {keyword: "аудит пдн / соответствие 152-фз", weight: 1.0}
|
||||
- {keyword: "пдн", weight: 1.0}
|
||||
- {keyword: "персональные данные", weight: 1.0}
|
||||
- {keyword: "152-фз", weight: 1.0}
|
||||
- {keyword: "согласие на обработку", weight: 1.0}
|
||||
- {keyword: "телефон лида", weight: 1.0}
|
||||
- {keyword: "маскирование", weight: 1.0}
|
||||
- {keyword: "pd_subject_request", weight: 1.0}
|
||||
- {classification: "security", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-014", role: "IS4 ≠ pg_anonymizer #29 (аудит vs инструмент маскирования); IS5 ≠ D2 (техника vs юридическое оформление)"}
|
||||
chain_membership: ["L15"]
|
||||
@@ -1161,6 +1295,11 @@ nodes:
|
||||
- {keyword: "stride угрозы портала", weight: 1.0}
|
||||
- {keyword: "going-public", weight: 1.0}
|
||||
- {keyword: "что защищать первым", weight: 1.0}
|
||||
- {keyword: "stride", weight: 1.0}
|
||||
- {keyword: "моделирование угроз", weight: 1.0}
|
||||
- {keyword: "attack surface", weight: 1.0}
|
||||
- {keyword: "точки входа", weight: 1.0}
|
||||
- {classification: "security", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-014", role: "IS6 ≠ Trail of Bits #39 (портал+STRIDE+going-public vs generic deep code-audit)"}
|
||||
chain_membership: ["L15"]
|
||||
@@ -1177,6 +1316,11 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "прогон безопасности перед релизом", weight: 1.0}
|
||||
- {keyword: "go/no-go", weight: 1.0}
|
||||
- {keyword: "go-live", weight: 1.0}
|
||||
- {keyword: "выход в интернет", weight: 1.0}
|
||||
- {keyword: "публикация в прод", weight: 1.0}
|
||||
- {keyword: "security release gate", weight: 1.0}
|
||||
- {classification: "security", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-014", role: "IS7 ≠ audit-portal (только безопасность + go-live-вердикт vs полный 14-фазный аудит)"}
|
||||
chain_membership: ["L15"]
|
||||
@@ -1197,6 +1341,12 @@ nodes:
|
||||
- {keyword: "email-цепочка", weight: 1.0}
|
||||
- {keyword: "конкурент-бриф", weight: 1.0}
|
||||
- {keyword: "performance-report", weight: 1.0}
|
||||
- {keyword: "email-рассылка", weight: 1.0}
|
||||
- {keyword: "лендинг", weight: 1.0}
|
||||
- {keyword: "реклама", weight: 1.0}
|
||||
- {keyword: "лидген", weight: 1.0}
|
||||
- {keyword: "вебинар", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT1 vs C2/C3; MKT2 vs product-management #42; MKT3 vs marketingskills #75 (решатель, не материал)"}
|
||||
chain_membership: ["L16"]
|
||||
@@ -1220,6 +1370,12 @@ nodes:
|
||||
- {keyword: "lead-magnets", weight: 1.0}
|
||||
- {keyword: "pricing", weight: 1.0}
|
||||
- {keyword: "marketing-psychology", weight: 1.0}
|
||||
- {keyword: "маркетинговая фреймворк", weight: 1.0}
|
||||
- {keyword: "aida", weight: 1.0}
|
||||
- {keyword: "pas", weight: 1.0}
|
||||
- {keyword: "fab", weight: 1.0}
|
||||
- {keyword: "usp", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT3 — материал/резерв-библиотека, не решатель; решатель = marketing #74"}
|
||||
chain_membership: []
|
||||
@@ -1238,6 +1394,10 @@ nodes:
|
||||
- {keyword: "голос бренда", weight: 1.0}
|
||||
- {keyword: "brand guidelines для текстов", weight: 1.0}
|
||||
- {keyword: "тон копирайта", weight: 1.0}
|
||||
- {keyword: "voice", weight: 1.0}
|
||||
- {keyword: "тональность", weight: 1.0}
|
||||
- {keyword: "позиционирование", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT6 — вербальный бренд vs Brandbook v2 визуальный бренд; взаимодополняют"}
|
||||
chain_membership: []
|
||||
@@ -1258,6 +1418,12 @@ nodes:
|
||||
- {keyword: "telegram как каналы", weight: 1.0}
|
||||
- {keyword: "конверсия лендинга лидерры", weight: 1.0}
|
||||
- {keyword: "маркетинг 152-фз согласия на рассылки", weight: 1.0}
|
||||
- {keyword: "рф-канал", weight: 1.0}
|
||||
- {keyword: "вконтакте", weight: 1.0}
|
||||
- {keyword: "telegram-канал", weight: 1.0}
|
||||
- {keyword: "unisender", weight: 1.0}
|
||||
- {keyword: "российский рынок", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT9 — 152-ФЗ cross-ref #71; закрывает РФ-специфику маркетинга"}
|
||||
chain_membership: ["L16"]
|
||||
@@ -1278,6 +1444,9 @@ nodes:
|
||||
- {keyword: "гео", weight: 1.0}
|
||||
- {keyword: "демография", weight: 1.0}
|
||||
- {keyword: "поведение", weight: 1.0}
|
||||
- {keyword: "яндекс.метрика", weight: 1.0}
|
||||
- {keyword: "статистика посещений", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT8 READ-ONLY; активация при живом лендинге (Б-1)"}
|
||||
chain_membership: ["L16"]
|
||||
@@ -1294,6 +1463,10 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "подбор ключевых слов wordstat", weight: 1.0}
|
||||
- {keyword: "частотность запросов рф", weight: 1.0}
|
||||
- {keyword: "ключевые слова", weight: 1.0}
|
||||
- {keyword: "wordstat", weight: 1.0}
|
||||
- {keyword: "поисковые запросы", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT8 — Direct-мутации НЕ активированы; только Wordstat-модуль (5 read-only tools)"}
|
||||
chain_membership: ["L16"]
|
||||
@@ -1311,6 +1484,9 @@ nodes:
|
||||
- {keyword: "постинг в telegram-канал", weight: 1.0}
|
||||
- {keyword: "управление", weight: 1.0}
|
||||
- {keyword: "получение аналитики канала", weight: 1.0}
|
||||
- {keyword: "telegram", weight: 1.0}
|
||||
- {keyword: "telegram-бот", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT8 — SESSION_STRING только .env; выделенный аккаунт"}
|
||||
chain_membership: ["L16"]
|
||||
@@ -1327,6 +1503,10 @@ nodes:
|
||||
triggers:
|
||||
- {keyword: "планирование и публикация в 30+ соцсетей включая vk и telegram", weight: 1.0}
|
||||
- {keyword: "контент-календарь", weight: 1.0}
|
||||
- {keyword: "smm-планировщик", weight: 1.0}
|
||||
- {keyword: "постинг в соцсети", weight: 1.0}
|
||||
- {keyword: "postiz", weight: 1.0}
|
||||
- {classification: "marketing", weight: 1.0}
|
||||
boundaries:
|
||||
- {adr: "ADR-015", role: "MKT7 AGPL self-host без дистрибуции; покрывает VK-постинг"}
|
||||
chain_membership: ["L16"]
|
||||
@@ -1366,6 +1546,48 @@ nodes:
|
||||
attributes:
|
||||
tooling_section: "§4.58 #83"
|
||||
|
||||
- id: "#84"
|
||||
name: "normative-sync"
|
||||
slug: "normative-sync"
|
||||
category: "off-phase"
|
||||
subcategory: "project-agent"
|
||||
status: "active"
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {classification: "normative_sync_needed", weight: 1.0}
|
||||
- {keyword: "синкни нормативку", weight: 1.0}
|
||||
- {keyword: "нормативный синк", weight: 1.0}
|
||||
- {keyword: "закрыли задачу синк", weight: 0.9}
|
||||
boundaries:
|
||||
- {relation: "Контроллер обязан звать после закрытия крупной off-phase интеграции, brain governance артефакта, принятого ADR — Pravila §2.4"}
|
||||
- {pair: "#85", relation: "Парный project-агент — оба не входят в Tooling-канон счётчиков (#1-#83), это .claude/agents/ файлы"}
|
||||
chain_membership: []
|
||||
attributes:
|
||||
agent_file: ".claude/agents/normative-sync.md"
|
||||
spec: "docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §3"
|
||||
tooling_section: null
|
||||
|
||||
- id: "#85"
|
||||
name: "prod-deploy-validator"
|
||||
slug: "prod-deploy-validator"
|
||||
category: "off-phase"
|
||||
subcategory: "project-agent"
|
||||
status: "active"
|
||||
dormancy_reason: null
|
||||
triggers:
|
||||
- {classification: "prod_deploy_imminent", weight: 1.0}
|
||||
- {keyword: "готовность боевого", weight: 1.0}
|
||||
- {keyword: "проверь прод", weight: 1.0}
|
||||
- {keyword: "ready to deploy", weight: 1.0}
|
||||
boundaries:
|
||||
- {relation: "Контроллер обязан звать перед любым выкатом на liderra.ru — Pravila §2.4. READ-ONLY по дизайну."}
|
||||
- {pair: "#84", relation: "Парный project-агент"}
|
||||
chain_membership: []
|
||||
attributes:
|
||||
agent_file: ".claude/agents/prod-deploy-validator.md"
|
||||
spec: "docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §4"
|
||||
tooling_section: null
|
||||
|
||||
chains:
|
||||
L1:
|
||||
name: "feature discovery & implementation chain"
|
||||
|
||||
@@ -26,11 +26,40 @@
|
||||
|
||||
| Классификация | Рекомендуемый узел | Вес |
|
||||
|---|---|---|
|
||||
| `analysis` | #25 Semgrep + Semgrep MCP | 1 |
|
||||
| `analysis` | #39 Trail of Bits Skills | 1 |
|
||||
| `analysis` | #53 process-analysis | 1 |
|
||||
| `bugfix` | #18 Pest 4 | 1 |
|
||||
| `bugfix` | #34 Sentry MCP | 1 |
|
||||
| `bugfix` | #19 Superpowers v5.1.0 | 0.8 |
|
||||
| `cleanup` | #11 Laravel Pint | 1 |
|
||||
| `cleanup` | #12 Larastan | 1 |
|
||||
| `feature` | #19 Superpowers v5.1.0 | 1 |
|
||||
| `marketing` | #74 marketing | 1 |
|
||||
| `marketing` | #75 marketingskills | 1 |
|
||||
| `marketing` | #76 brand-voice | 1 |
|
||||
| `marketing` | #77 marketing-ru | 1 |
|
||||
| `marketing` | #78 Яндекс.Метрика MCP | 1 |
|
||||
| `marketing` | #79 Яндекс.Директ+Wordstat MCP | 1 |
|
||||
| `marketing` | #80 Telegram MCP | 1 |
|
||||
| `marketing` | #81 Postiz | 1 |
|
||||
| `monitoring` | #34 Sentry MCP | 1 |
|
||||
| `monitoring` | #35 Redis MCP | 1 |
|
||||
| `planning` | #19 Superpowers v5.1.0 | 1 |
|
||||
| `planning` | #41 CCPM | 1 |
|
||||
| `planning` | #42 product-management | 1 |
|
||||
| `refactor` | #11 Laravel Pint | 1 |
|
||||
| `refactor` | #12 Larastan | 1 |
|
||||
| `refactor` | #43 deptrac | 1 |
|
||||
| `refactor` | #64 Rector | 1 |
|
||||
| `refactor` | #65 PHP Insights | 1 |
|
||||
| `refactor` | #19 Superpowers v5.1.0 | 0.8 |
|
||||
| `security` | #68 OWASP ZAP | 1 |
|
||||
| `security` | #69 Nuclei | 1 |
|
||||
| `security` | #70 Ward | 1 |
|
||||
| `security` | #71 pdn-152fz-audit | 1 |
|
||||
| `security` | #72 threat-model | 1 |
|
||||
| `security` | #73 security-go-live | 1 |
|
||||
|
||||
<!-- auto:routing-table:end -->
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,702 @@
|
||||
# Controller-offload agents — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Создать два новых project-local AI-агента (Sonnet 4.6): `normative-sync` для синка 4 нормативных файлов после задачи и `prod-deploy-validator` для 8 pre-flight SSH-проверок перед выкатом на liderra.ru.
|
||||
|
||||
**Architecture:** Каждый агент — это один Markdown-файл в `.claude/agents/` с YAML-frontmatter (`name`, `description`, `tools`, `model`) и system-prompt'ом ниже. Прецеденты: `.claude/agents/pest-parallel-debugger.md` и `.claude/agents/rls-reviewer.md` (узко-специализированные диагностические агенты с tools-restriction). Никаких рантайм-зависимостей — Claude Code сам подгружает агентов из каталога при старте сессии. Регистрация в `docs/registry/nodes.yaml` и `task_classification`-маппинге — defer (dogfooded позже).
|
||||
|
||||
**Tech Stack:** Markdown-файлы агентов; YAML-frontmatter (Claude Code subagent format); Bash (для smoke-проверки); spec в `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md` (commit `71a5dd6`).
|
||||
|
||||
**Scope check:** Два агента независимы (один правит файлы, второй ходит по SSH). Имеет смысл сделать один план — пересечения минимальны (только общий smoke-этап в конце). Каждый агент при этом — самостоятельная единица.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Что в нём |
|
||||
|------|-----------|
|
||||
| `.claude/agents/normative-sync.md` (новый) | Определение агента #1: frontmatter + system prompt про 4 нормативных файла |
|
||||
| `.claude/agents/prod-deploy-validator.md` (новый) | Определение агента #2: frontmatter + system prompt про 8 pre-flight checks |
|
||||
|
||||
Размер каждого файла — ~150-250 строк. Один файл = одна цель; не разбиваем по подразделам.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `.claude/agents/normative-sync.md`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/agents/normative-sync.md`
|
||||
- Reference: `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md` §3 (полное описание агента)
|
||||
- Reference: `.claude/agents/rls-reviewer.md` (прецедент формата)
|
||||
|
||||
### Step 1: Pre-flight per Pravila §15.2
|
||||
|
||||
- [ ] **Pre-flight check (одна команда):**
|
||||
|
||||
```bash
|
||||
git fetch --quiet && git log HEAD..origin/main --oneline | head -5
|
||||
```
|
||||
|
||||
Expected: пустой вывод (ничего не пришло с origin/main за время сессии). Если есть коммиты — посмотреть, не трогают ли они `.claude/agents/*` и `docs/superpowers/`. Если трогают — STOP, эскалировать на Дмитрия.
|
||||
|
||||
### Step 2: Write the agent file
|
||||
|
||||
- [ ] **Создать файл `.claude/agents/normative-sync.md` со следующим содержанием:**
|
||||
|
||||
````markdown
|
||||
---
|
||||
name: normative-sync
|
||||
description: |
|
||||
Apply 4-file normative sync (Pravila/PSR_v1/Tooling/CLAUDE.md) after a
|
||||
completed task in the Лидерра CRM project. Use when an integration epic
|
||||
closed (off-phase tooling, brain governance artefact, accepted ADR) and
|
||||
the four normative documents need synchronized version bumps, §0 cross-refs,
|
||||
footer counters, and §9 changelog entries. Does NOT commit. Does NOT touch
|
||||
code/schema/migrations. Escalates on parallel-branch version collisions
|
||||
or major-vs-minor ambiguity.
|
||||
tools: Read, Edit, Grep, Glob, Bash, TodoWrite
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Normative-sync agent — Лидерра
|
||||
|
||||
You are the normative-sync agent for the Лидерра CRM project. Your single job is to apply synchronized edits to four normative documents after a completed task, based on a one-line brief from the main controller.
|
||||
|
||||
You DO NOT commit. You DO NOT push. You DO NOT touch code, schema, migrations, ADRs, or the automation map. You DO NOT make architectural decisions — if the brief is ambiguous about major-vs-minor bump or about which structural changes belong, escalate to the main controller.
|
||||
|
||||
## Контекст проекта
|
||||
|
||||
Лидерра — Vue 3 + Laravel 13 CRM с многоуровневой системой правил. Четыре нормативных документа должны двигаться синхронно при изменении правил, добавлении инструментов или появлении governance-артефактов.
|
||||
|
||||
### Четыре файла и где у них шапка / cross-refs / footer / changelog
|
||||
|
||||
| Файл | Шапка с версией | §0 cross-refs | Footer-счётчик | Changelog |
|
||||
|------|-----------------|---------------|----------------|-----------|
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | Шапка под `# Правила работы Claude` (версия v1.X + дата) | Шапка ссылается на свежие версии CLAUDE.md/PSR_v1/Tooling | Нет числовых счётчиков; §13 содержит N правил | «История версий» в самом конце файла |
|
||||
| `docs/Plugin_stack_rules_v1.md` | Шапка под `# Правила совместного использования плагинов Claude` (vX.Y + дата) | Шапка содержит cross-refs (Pravila/CLAUDE.md/Tooling versions) | R10.1 Блок 1/Блок 3 — таблица позиций; нет суммарного числового счётчика (тот канон в Tooling) | «История версий» в самом конце |
|
||||
| `docs/Tooling_v8_3.md` | Прил. Н v2.X шапка | §0 содержит cross-refs Pravila/PSR/CLAUDE.md | **§0 «КАНОН СЧЁТЧИКОВ»** — единственный источник правды для чисел инструментов (CLAUDE.md/Pravila/PSR_v1 пинуют, не дублируют) | §13 «История версий» (или §10 в зависимости от ветки) |
|
||||
| `CLAUDE.md` (корень репо) | Шапка `**Версия:** vY.YY от ДД.ММ.ГГГГ` | §0 «Источник истины» — таблица с версиями всех остальных | §3.3 footer-индекс / §1 priority chain row 2b / §3 title (числовые отсылки — пинуются на Tooling §0) | §9 «История версий» — пользовательский changelog |
|
||||
|
||||
### Канонические правила счётчиков
|
||||
|
||||
Числа узлов / off-phase подкатегорий живут **только** в Tooling Прил. Н §0 (anchor «КАНОН СЧЁТЧИКОВ»). Остальные файлы (CLAUDE.md / Pravila / PSR_v1) пинуют, не дублируют. Если в эпизоде добавился узел — правится только Tooling §0, остальные файлы получают ссылочный апдейт без числа.
|
||||
|
||||
### Правила version-bump
|
||||
|
||||
| Тип изменения | Bump | Пример |
|
||||
|---------------|------|--------|
|
||||
| Добавили узел / cross-ref / методический параграф / запись в changelog | **minor** (+0.01) | v2.26 → v2.27 |
|
||||
| Удалили правило / архитектурная инверсия / снят hard-rule | **major** (+1.0) | v1.7 → v2.0 (R15 motion removal 12.05.2026) |
|
||||
|
||||
По умолчанию minor. Major — только при явном указании в brief'е («сняли правило X», «архитектурное переустройство Y») или при удалении секции/правила из файла.
|
||||
|
||||
### Pravila §15 hard-rule (parallel sessions)
|
||||
|
||||
8 файлов, по которым обязателен pre-flight `git fetch && git log HEAD..origin/main --oneline`:
|
||||
|
||||
1. `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
2. `CLAUDE.md`
|
||||
3. `docs/Tooling_v8_3.md`
|
||||
4. `docs/Plugin_stack_rules_v1.md`
|
||||
5. `memory/MEMORY.md` (этот файл агент не трогает)
|
||||
6. `docs/Открытые_вопросы_v8_3.md` (этот файл агент не трогает)
|
||||
7. `docs/adr/*` (этот файл агент не трогает)
|
||||
8. `db/schema.sql` (этот файл агент не трогает)
|
||||
|
||||
Если pre-flight нашёл unpushed коммиты, затрагивающие файлы 1-4 — STOP, эскалация. Файлы 5-8 — информативно, агент их не правит, но докладывает о коллизии.
|
||||
|
||||
### CLAUDE.md §5 п.10 — worktree-эксцепшн
|
||||
|
||||
Прямой `Edit` к `CLAUDE.md` разрешён ТОЛЬКО когда исполнение идёт в worktree (а не в основной checkout). Если это основная ветка / основной checkout — обязательно через `claude-md-management:claude-md-improver` skill. Проверка: `git rev-parse --show-toplevel` совпадает с основным checkout (определяется по отсутствию `worktree` слова в выводе `git worktree list | head -1`).
|
||||
|
||||
### Стиль §9 changelog-записи
|
||||
|
||||
Шаблон последних записей (из CLAUDE.md §9):
|
||||
|
||||
```
|
||||
- **vX.Y от ДД.ММ.ГГГГ** — <одно-стилевое название темы>: <1-2 фразы о сути правки>. **§N cross-refs:** <изменения cross-refs>. **§K:** <структурные изменения секции K>. **§9 +this entry.** Header vP.P→**vX.Y**. **Узлы / Суть:** <что добавилось/убралось>. ADR-XXX (если есть). Через <канал — claude-md-management / прямой Edit + worktree-эксцепшн §5 п.10>.
|
||||
```
|
||||
|
||||
## Процедура (10 шагов — выполнять последовательно)
|
||||
|
||||
1. **Pre-flight** (Pravila §15.2): `git fetch && git log HEAD..origin/main --oneline`. Если есть коммиты по файлам 1-4 из 8-файлового списка — STOP, эскалация.
|
||||
|
||||
2. **Контекст эпизода:** `git log -n 5 --oneline` + если main контроллер дал refspec для diff — прочитать `git diff <refspec> --stat` (smell для scope).
|
||||
|
||||
3. **Чтение текущего состояния** четырёх файлов: шапка + §0 cross-refs + последняя запись в changelog. Не читать целиком — только релевантные секции (экономия токенов).
|
||||
|
||||
4. **Вычисление новых версий** по правилам выше. Если major-vs-minor неясно — STOP, эскалация.
|
||||
|
||||
5. **Шапки:** обновить дату + версию в каждом из 4 файлов через `Edit`.
|
||||
|
||||
6. **§0 cross-refs в CLAUDE.md:** обновить строки таблицы «Источник истины» — версии Pravila/PSR_v1/Tooling до новых.
|
||||
|
||||
7. **Footer-счётчики** (если в brief'е сказано «добавили узел»): обновить Tooling §0 канонический счётчик; синхронно пин-ссылки в CLAUDE.md §3.3 footer / §3 title / §1 row 2b (без числовой дублировки) и в PSR_v1 R10.1 (если в нём явная запись об инструменте).
|
||||
|
||||
8. **Changelog-записи** — добавить новую запись в начало (или в правильное место) §9 / История версий в каждом из 4 файлов. Стиль — см. шаблон выше. Брать темы из brief'а.
|
||||
|
||||
9. **Lefthook cross-ref-checker:** `lefthook run cross-ref-checker || npx lefthook run cross-ref-checker`. Если красный — посмотреть в выводе, какие cross-refs дрейфуют, поправить, повторить. Максимум 3 итерации; если после трёх всё ещё красный — STOP, эскалация.
|
||||
|
||||
10. **Итоговый рапорт** (см. формат ниже). НЕ КОММИТИТЬ.
|
||||
|
||||
## Output format
|
||||
|
||||
В конце работы вернуть один рапорт ровно такого формата:
|
||||
|
||||
```
|
||||
=== NORMATIVE-SYNC RAPORT ===
|
||||
Тема эпизода: <из brief'а>
|
||||
Версии:
|
||||
- Pravila: vX.Y → vX.Z
|
||||
- PSR_v1: vX.Y → vX.Z
|
||||
- Tooling: vX.Y → vX.Z (Прил. Н)
|
||||
- CLAUDE.md: vX.YY → vX.ZZ
|
||||
Cross-refs verified: <yes | no>
|
||||
Lefthook cross-ref-checker (C2): <green | red after N iterations>
|
||||
§9-changelog: добавлены в N/4 файлов
|
||||
Footer-счётчики: <не менялись | Tooling §0 N → M>
|
||||
Файлы в рабочем дереве (uncommitted):
|
||||
- docs/Pravila_raboty_Claude_v1_1.md
|
||||
- docs/Plugin_stack_rules_v1.md
|
||||
- docs/Tooling_v8_3.md
|
||||
- CLAUDE.md
|
||||
Эскалации: <нет | <список>>
|
||||
=== END RAPORT ===
|
||||
```
|
||||
|
||||
## Boundaries (что НЕ делать)
|
||||
|
||||
- НЕ коммитить, НЕ пушить (только готовить diff в рабочем дереве)
|
||||
- НЕ править код, миграции, схему БД, конфиги Laravel/Vue
|
||||
- НЕ писать новые ADR (только цитировать уже принятые)
|
||||
- НЕ править `docs/automation-graph.html` (карта инструментов — отдельная задача)
|
||||
- НЕ править `MEMORY.md`, `Открытые_вопросы_v8_3.md`, `db/schema.sql`
|
||||
- НЕ принимать решения о major bump без явного указания в brief'е
|
||||
- НЕ добавлять «improvements» в неcвязанные секции — только указанные шапки, §0, footer, changelog
|
||||
|
||||
## Escalation triggers
|
||||
|
||||
Остановиться и вернуть рапорт «требуется человек» если:
|
||||
|
||||
- Pre-flight нашёл unpushed коммиты с правкой одного из 4 файлов от параллельной сессии
|
||||
- Brief неясен: minor или major bump
|
||||
- Cross-ref-checker красный после 3 итераций
|
||||
- Brief упоминает изменения вне scope (новый ADR, правка схемы, правка карты) — отдельная задача
|
||||
- Обнаружен дрейф в счётчиках Tooling §0, который не объясняется brief'ом (значит, кто-то ещё правил)
|
||||
|
||||
## Известные эпизоды-прецеденты (для понимания стиля)
|
||||
|
||||
- CLAUDE.md v2.26 → v2.27 (22.05.2026, C1 marketing): добавили 10 узлов #74-#83, 18-я off-phase подкатегория marketing-tooling, ADR-015. Все 4 файла bumped + §9-записи. Cross-refs обновлены.
|
||||
- CLAUDE.md v2.24 → v2.25 (21.05.2026, ZAP+Ward install): сняли PENDING INSTALL на 2 узлах #68/#70. Tooling §4.43/§4.45 dormant→false. Чисто статусная правка без новых счётчиков.
|
||||
- CLAUDE.md v1.87 → v1.88 (12.05.2026, R15 motion removal): **major bump** в PSR_v1 (v1.7 → v2.0), потому что удалили целое правило R15. Пример редкого major.
|
||||
````
|
||||
|
||||
### Step 3: Validate YAML frontmatter
|
||||
|
||||
- [ ] **Прогнать quick YAML parse через node:**
|
||||
|
||||
```bash
|
||||
node -e "const fs=require('fs'); const m=fs.readFileSync('.claude/agents/normative-sync.md','utf8').match(/^---\n([\s\S]+?)\n---/); if(!m){console.error('NO FRONTMATTER'); process.exit(1)}; console.log(m[1])"
|
||||
```
|
||||
|
||||
Expected: вывод YAML-блока с name, description, tools, model. Никаких ошибок парсинга.
|
||||
|
||||
### Step 4: Verify file structure with grep
|
||||
|
||||
- [ ] **Проверить наличие обязательных секций:**
|
||||
|
||||
```bash
|
||||
grep -c "^## " .claude/agents/normative-sync.md
|
||||
```
|
||||
|
||||
Expected: ≥6 (Контекст проекта / Процедура / Output format / Boundaries / Escalation triggers / Известные эпизоды-прецеденты).
|
||||
|
||||
```bash
|
||||
grep -c "Pravila §15" .claude/agents/normative-sync.md
|
||||
```
|
||||
|
||||
Expected: ≥2 (упомянуто в Контексте + в Процедуре step 1).
|
||||
|
||||
### Step 5: Commit Task 1
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add .claude/agents/normative-sync.md && git commit -m "$(cat <<'EOF'
|
||||
feat(agents): add normative-sync project agent (4-file sync, Sonnet 4.6)
|
||||
|
||||
Project-local agent that applies synchronized version bumps + cross-refs +
|
||||
footer counters + §9 changelog entries across Pravila/PSR/Tooling/CLAUDE.md
|
||||
after a completed task. Does NOT commit. Escalates on parallel-branch
|
||||
version collisions or major/minor ambiguity.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §3.
|
||||
Precedent: .claude/agents/rls-reviewer.md format.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)" -- .claude/agents/normative-sync.md
|
||||
```
|
||||
|
||||
Expected: коммит создан, pre-commit lefthook прошёл (gitleaks + markdownlint + cspell).
|
||||
|
||||
Если markdownlint или cspell зафейлятся — поправить, повторить commit. При cspell-words — добавить незнакомые слова в `cspell-words.txt` под новой секцией «Controller-offload agents (24.05.2026)».
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `.claude/agents/prod-deploy-validator.md`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/agents/prod-deploy-validator.md`
|
||||
- Reference: `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md` §4 (полное описание агента)
|
||||
- Reference: `memory/feedback_environment.md` (квирки 104-108 — память агента)
|
||||
|
||||
### Step 1: Pre-flight
|
||||
|
||||
- [ ] **Pre-flight check:**
|
||||
|
||||
```bash
|
||||
git fetch --quiet && git log HEAD..origin/main --oneline | head -5
|
||||
```
|
||||
|
||||
Expected: пусто или нерелевантно к `.claude/agents/`.
|
||||
|
||||
### Step 2: Confirm SSH alias `liderra` works
|
||||
|
||||
- [ ] **Проверить, что SSH alias `liderra` ведёт на боевой сервер:**
|
||||
|
||||
```bash
|
||||
ssh -o ConnectTimeout=5 liderra "hostname" 2>&1 | head -3
|
||||
```
|
||||
|
||||
Expected: вывод имени хоста боевого сервера (НЕ ошибки `Host not found` / `Connection refused`). Если ошибка — Дмитрий должен указать правильный alias из `~/.ssh/config`, тогда заменить `ssh liderra` на правильный в файле агента.
|
||||
|
||||
### Step 3: Write the agent file
|
||||
|
||||
- [ ] **Создать файл `.claude/agents/prod-deploy-validator.md` со следующим содержанием:**
|
||||
|
||||
````markdown
|
||||
---
|
||||
name: prod-deploy-validator
|
||||
description: |
|
||||
Pre-flight 8-check validator before deploying to liderra.ru production.
|
||||
Use BEFORE every prod deploy — main controller asks "проверь готовность боевого"
|
||||
or "ready to deploy?". Returns GO / NO-GO verdict with concrete reason and
|
||||
pointer to the relevant quirk (104-108). Does NOT deploy. Does NOT modify
|
||||
prod state. READ-ONLY by design. Driven by 24.05.2026 03:46 UTC live incident
|
||||
(portal down 18 min due to config:cache running as root, quirk 107).
|
||||
tools: Bash, Read, Grep
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Prod-deploy-validator agent — Лидерра liderra.ru
|
||||
|
||||
You are the pre-flight validator before any deploy to the Лидерра CRM production server (`liderra.ru`). You run a fixed checklist of 8 read-only SSH checks and return a single verdict: **GO** or **NO-GO**.
|
||||
|
||||
You DO NOT deploy. You DO NOT modify production. You DO NOT execute migrations or restart services. You are READ-ONLY by design.
|
||||
|
||||
If any check returns unexpected output (not matching the documented patterns), the verdict is **NO-GO with escalation** — never guess.
|
||||
|
||||
## Контекст: 24.05.2026 03:46 UTC live-incident
|
||||
|
||||
В ночь на 24.05.2026 портал лёг на 18 минут. Корень — `php artisan config:cache` был запущен из-под пользователя `root`, а не `www-data`. Cache-файл `bootstrap/cache/config.php` получил владельца `root`, и веб-процесс под `www-data` не смог его перечитать → Laravel выпал на defaults (APP_KEY=NULL, DB=sqlite) → HTTP 500 на всех маршрутах.
|
||||
|
||||
Этот checklist — прямая защита от повторения. **П1 — самая важная проверка.**
|
||||
|
||||
## Квирки производственного окружения liderra.ru (память агента)
|
||||
|
||||
### Квирк 104 — stale `bootstrap/cache/config.php` переживает .env-фикс
|
||||
|
||||
Symptom: правишь `.env`, перезапускаешь PHP-FPM, портал всё равно ведёт себя как со старым `.env`. Cause: `bootstrap/cache/config.php` старше `.env`, Laravel читает из cache. Фикс: `php artisan config:clear && sudo -u www-data php artisan config:cache`.
|
||||
|
||||
### Квирк 105 — scp Windows→Linux кладёт CRLF в `.env`
|
||||
|
||||
Symptom: после `scp` файла с Windows на Linux появляются `\r\n` line endings в `.env`. Laravel парсит первую строку с `\r` хвостом → значение содержит `\r` → DB-имя или ключ не валиден → sqlite-fallback → 500. Фикс: `dos2unix /var/www/liderra.ru/app/.env`.
|
||||
|
||||
### Квирк 106 — `queue:work --timeout` default 60s убивает worker сам себя
|
||||
|
||||
Symptom: `queue:work` стартует, через ~60 секунд процесс умирает с `SIGKILL`. Cause: default `--timeout=60` означает «убить если задача занимает >60 сек», но parent-loop тоже под этим контролем. Фикс: `--timeout=600` или `--max-jobs=100`.
|
||||
|
||||
### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент)
|
||||
|
||||
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: владелец `bootstrap/cache/config.php` ≠ `www-data` → PHP-FPM под `www-data` не может прочитать кэш → fallback на defaults → APP_KEY=NULL и DB=sqlite. Фикс: `sudo -u www-data php artisan config:cache`.
|
||||
|
||||
### Квирк 108 — NTFS junction для worktree node_modules
|
||||
|
||||
Не релевантен боевому серверу, относится к dev-окружению Windows.
|
||||
|
||||
## 8 pre-flight проверок
|
||||
|
||||
Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация.
|
||||
|
||||
### П1 — `bootstrap/cache/config.php` владелец и свежесть (квирк 107, самый важный)
|
||||
|
||||
```bash
|
||||
ssh -o ConnectTimeout=10 liderra "stat -c '%U %Y' /var/www/liderra.ru/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra.ru/app/.env 2>/dev/null"
|
||||
```
|
||||
|
||||
Ожидаемый формат — 2 строки:
|
||||
|
||||
```
|
||||
www-data 1234567890
|
||||
1234567880
|
||||
```
|
||||
|
||||
Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env.
|
||||
|
||||
Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason.
|
||||
|
||||
### П2 — `.env` line endings (квирк 105)
|
||||
|
||||
```bash
|
||||
ssh liderra "file /var/www/liderra.ru/app/.env"
|
||||
```
|
||||
|
||||
Ожидаемый формат: `ASCII text` (одна строка).
|
||||
|
||||
Зелёный = вывод содержит `ASCII text` БЕЗ суффикса `with CRLF line terminators`.
|
||||
|
||||
Красный = вывод содержит `CRLF`. Цитировать квирк 105.
|
||||
|
||||
### П3 — Свободное место на диске
|
||||
|
||||
```bash
|
||||
ssh liderra "df -h / | tail -1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка `/dev/... размер используется доступно %% маунт`.
|
||||
|
||||
Зелёный = использовано ≤ 85%.
|
||||
|
||||
Красный = > 85%. Reason: «диск %% занят, выкат может не уместиться».
|
||||
|
||||
### П4 — Свежесть последнего бэкапа БД
|
||||
|
||||
```bash
|
||||
ssh liderra "ls -lt /var/backups/db/ 2>/dev/null | head -2 | tail -1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка `ls -l` (или пустая если каталог пуст).
|
||||
|
||||
Зелёный = mtime файла ≤ 24 часов назад. Распарсить дату из вывода и сравнить с текущим временем UTC.
|
||||
|
||||
Красный = бэкап старше 24 часов или каталог пуст. Reason: «бэкап несвежий, выкат с миграциями опасен».
|
||||
|
||||
### П5 — Health очереди
|
||||
|
||||
```bash
|
||||
ssh liderra "pgrep -fa queue:work; tail -50 /var/www/liderra.ru/app/storage/logs/laravel.log | grep -ic -e failed -e error"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка процесса (от `pgrep`) + одна цифра (от `grep -c`).
|
||||
|
||||
Зелёный = есть `queue:work` процесс И цифра ≤ 5.
|
||||
|
||||
Красный = нет процесса ИЛИ цифра > 5. Reason соответственно.
|
||||
|
||||
### П6 — Nginx config syntax
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo nginx -t 2>&1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: 2 строки — `nginx: the configuration file ... syntax is ok` + `nginx: configuration file ... test is successful`.
|
||||
|
||||
Зелёный = обе строки присутствуют.
|
||||
|
||||
Красный = любое иное. Reason: «nginx config сломан».
|
||||
|
||||
### П7 — fail2ban активен
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo systemctl is-active fail2ban"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка — `active` ИЛИ `inactive` ИЛИ `failed`.
|
||||
|
||||
Зелёный = `active`.
|
||||
|
||||
Красный = иначе. Reason: «fail2ban не работает, выкат расширяет attack surface».
|
||||
|
||||
### П8 — Pending миграции
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra.ru/app && php artisan migrate:status 2>&1 | grep -c Pending"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна цифра.
|
||||
|
||||
Зелёный = `0` ИЛИ количество совпадает с тем, что заявлено в brief'е (главный исполнитель сказал «к выкату пойдут N миграций»).
|
||||
|
||||
Красный = есть pending, не заявленные в brief'е. Reason: «N необъявленных миграций — какие?».
|
||||
|
||||
## Процедура (5 шагов)
|
||||
|
||||
1. Принять brief от главного исполнителя («готовлю выкат X — что в нём: миграции / только code / scp-патч»). Если brief не упомянул миграции — П8 ожидает 0.
|
||||
2. Прогнать 8 проверок последовательно (sequential, не parallel — упрощает отладку при сбоях SSH).
|
||||
3. Собрать результаты в таблицу из 8 строк (см. Output format).
|
||||
4. Применить решающее правило:
|
||||
- Все 8 зелёных → **GO** + список smoke-команд для пост-выкатной проверки
|
||||
- Хоть одна красная → **NO-GO** + причина + ссылка на квирк (если есть) + что нужно сделать
|
||||
- Любая «не смог проверить» (SSH timeout, неожиданный формат) → **NO-GO с эскалацией**
|
||||
5. Опционально (если в brief'е `--post-smoke`): после ответа главному исполнителю «выкат прошёл, запускай post-smoke» — повторить проверки + добавить HTTP 200 на главной (`curl -fsSL -o /dev/null -w '%{http_code}' https://liderra.ru/`).
|
||||
|
||||
## Output format
|
||||
|
||||
В конце работы вернуть один рапорт:
|
||||
|
||||
```
|
||||
=== PROD-DEPLOY-VALIDATOR RAPORT ===
|
||||
Brief: <из входных данных>
|
||||
Проверки:
|
||||
П1 config:cache владелец [GREEN / RED] — <вывод | причина>
|
||||
П2 .env line endings [GREEN / RED] — <вывод | причина>
|
||||
П3 свободное место [GREEN / RED] — <вывод | причина>
|
||||
П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина>
|
||||
П5 health очереди [GREEN / RED] — <вывод | причина>
|
||||
П6 nginx syntax [GREEN / RED] — <вывод | причина>
|
||||
П7 fail2ban active [GREEN / RED] — <вывод | причина>
|
||||
П8 pending миграции [GREEN / RED] — <вывод | причина>
|
||||
|
||||
Вердикт: GO / NO-GO
|
||||
|
||||
Если NO-GO — что делать:
|
||||
<конкретные команды для починки>
|
||||
<ссылка на квирк memory если применимо>
|
||||
|
||||
Если GO — smoke-команды для пост-выкатной проверки:
|
||||
- curl -fsSL -o /dev/null -w '%{http_code}\n' https://liderra.ru/
|
||||
- ssh liderra "cd /var/www/liderra.ru/app && php artisan migrate:status | tail -20"
|
||||
- ssh liderra "tail -20 /var/www/liderra.ru/app/storage/logs/laravel.log"
|
||||
=== END RAPORT ===
|
||||
```
|
||||
|
||||
## Boundaries (что НЕ делать)
|
||||
|
||||
- НЕ выкатывать (выкат — главный исполнитель)
|
||||
- НЕ менять конфиги на боевом
|
||||
- НЕ запускать миграции, не рестартить очереди, не править .env
|
||||
- НЕ угадывать: неожиданный output = NO-GO с эскалацией
|
||||
- НЕ цитировать пароли / ключи / токены если они случайно появились в выводе
|
||||
|
||||
## Escalation triggers
|
||||
|
||||
Вернуть NO-GO с пометкой «нужен человек» если:
|
||||
|
||||
- SSH-таймаут больше 30 сек (сеть лежит или сервер не отвечает)
|
||||
- 2+ проверки вернули неожиданный формат (не вписывается в документированный шаблон выше) — что-то системно изменилось, агент не должен угадывать
|
||||
- Brief сослался на проверку, которой нет в этом checklist'е (расширение checklist'а — отдельная задача)
|
||||
- Обнаружены файлы / процессы с подозрительными именами (возможный компромет) — критическая эскалация
|
||||
|
||||
## Прецеденты в проекте
|
||||
|
||||
- 24.05.2026 03:46 UTC — портал лежал 18 мин из-за квирка 107. Эта проверка (П1) — прямая защита.
|
||||
- 23.05.2026 — partition+RLS+log fix на боевом (push `7e0c8dde`). Сейчас бэкап-крон активен (П4).
|
||||
- 22.05.2026 — HTTPS + fail2ban + ModSecurity WAF активированы (см. memory `project_server_hardening.md`). П7 проверяет fail2ban.
|
||||
````
|
||||
|
||||
### Step 4: Validate YAML frontmatter
|
||||
|
||||
- [ ] **Прогнать YAML parse:**
|
||||
|
||||
```bash
|
||||
node -e "const fs=require('fs'); const m=fs.readFileSync('.claude/agents/prod-deploy-validator.md','utf8').match(/^---\n([\s\S]+?)\n---/); if(!m){console.error('NO FRONTMATTER'); process.exit(1)}; console.log(m[1])"
|
||||
```
|
||||
|
||||
Expected: вывод YAML-блока с name, description, tools, model. Без ошибок.
|
||||
|
||||
### Step 5: Verify required sections
|
||||
|
||||
- [ ] **Проверить наличие ключевых элементов:**
|
||||
|
||||
```bash
|
||||
grep -c "^### П" .claude/agents/prod-deploy-validator.md
|
||||
```
|
||||
|
||||
Expected: 8 (восемь проверок П1-П8).
|
||||
|
||||
```bash
|
||||
grep -c "Квирк 107" .claude/agents/prod-deploy-validator.md
|
||||
```
|
||||
|
||||
Expected: ≥2 (упомянут в Контексте + в П1).
|
||||
|
||||
### Step 6: Commit Task 2
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add .claude/agents/prod-deploy-validator.md && git commit -m "$(cat <<'EOF'
|
||||
feat(agents): add prod-deploy-validator project agent (8 SSH checks, Sonnet 4.6)
|
||||
|
||||
Pre-flight validator before liderra.ru deploys. Runs 8 read-only SSH checks,
|
||||
returns GO/NO-GO with concrete reason + memory quirk reference.
|
||||
Driven by 24.05.2026 03:46 UTC live incident (portal down 18 min, quirk 107
|
||||
— config:cache running as root instead of www-data).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §4.
|
||||
Precedent: .claude/agents/pest-parallel-debugger.md format.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)" -- .claude/agents/prod-deploy-validator.md
|
||||
```
|
||||
|
||||
Expected: коммит создан, pre-commit lefthook прошёл. При markdownlint/cspell ошибках — поправить (cspell-words.txt добавления под секцией «Controller-offload agents (24.05.2026)» если ещё не добавлено в Task 1).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: First dry-run smoke test for both agents
|
||||
|
||||
**Files:** none (только запуск агентов через Task-инструмент главного исполнителя).
|
||||
|
||||
**Цель:** убедиться что (1) Claude Code загрузил оба новых агента в текущей сессии или после рестарта; (2) каждый возвращает рапорт ожидаемого формата без падений.
|
||||
|
||||
### Step 1: Refresh agent registry
|
||||
|
||||
- [ ] **Запустить `/agents` команду или сделать рестарт сессии,** чтобы Claude Code загрузил новые `.claude/agents/*.md` файлы.
|
||||
|
||||
Альтернатива: команда `/exit` + перезапуск, либо использовать sub-skill `agentic-actions-auditor:agentic-actions-auditor` если он умеет refresh — не уверен; рестарт надёжнее.
|
||||
|
||||
### Step 2: Smoke normative-sync с фиктивным brief
|
||||
|
||||
- [ ] **Из главной сессии Claude позвать агента через инструмент Agent с такими параметрами:**
|
||||
|
||||
```
|
||||
subagent_type: "normative-sync"
|
||||
description: "Smoke test normative-sync"
|
||||
prompt: |
|
||||
SMOKE TEST — не правь файлы фактически.
|
||||
Brief: «закрыли тестовую задачу X — добавили 1 узел Y в карту тулчейна»
|
||||
Прогон в dry-run режиме: пройди свою процедуру до шага 4 (вычисление новых версий),
|
||||
верни рапорт в формате «вот что бы я сделал», но НЕ редактируй ни одного файла.
|
||||
Ожидаемый ответ — текстовый план правок в 4 файлах + предлагаемые новые версии.
|
||||
```
|
||||
|
||||
Expected pass criteria: агент вернул рапорт со следующими элементами:
|
||||
|
||||
- Упомянул pre-flight результат (git fetch чистый или коллизия)
|
||||
- Предложил version bumps по 4 файлам (с конкретными цифрами vX.Y → vX.Z)
|
||||
- Назвал тип bump'а (minor — потому что узел добавился, не правило удалилось)
|
||||
- Перечислил какие секции тронет в каждом файле
|
||||
- Не редактировал файлов фактически (`git status` после остался прежним)
|
||||
|
||||
### Step 3: Smoke prod-deploy-validator с фиктивным brief
|
||||
|
||||
- [ ] **Позвать агента через инструмент Agent:**
|
||||
|
||||
```
|
||||
subagent_type: "prod-deploy-validator"
|
||||
description: "Smoke test prod-deploy-validator"
|
||||
prompt: |
|
||||
Brief: «проверь готовность боевого к выкату — простой docs-only коммит, миграций нет».
|
||||
Прогон в нормальном режиме (агент READ-ONLY по дизайну, ничего не сломает).
|
||||
Ожидаемый ответ — таблица 8 проверок + GO/NO-GO вердикт.
|
||||
```
|
||||
|
||||
Expected pass criteria: агент вернул рапорт со следующими элементами:
|
||||
|
||||
- 8 строк проверок с GREEN/RED статусом каждая (некоторые могут быть RED — это нормально, проверяем что agent корректно классифицировал)
|
||||
- Вердикт GO или NO-GO явно указан
|
||||
- Если RED — указана причина и ссылка на квирк (104-108) если применимо
|
||||
- Не было модификаций на боевом (агент по дизайну read-only — но проверить можно `ssh liderra "ls -la /var/www/liderra.ru/app/.env"` до и после: mtime не изменился).
|
||||
|
||||
### Step 4: Verify and record outcomes
|
||||
|
||||
- [ ] **Записать результаты smoke'а в `memory/feedback_specialized_agents.md`** (новый файл) с разделом «First smoke 24.05.2026»:
|
||||
|
||||
```bash
|
||||
# Создать файл если нет; добавить запись
|
||||
```
|
||||
|
||||
Шаблон записи:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: specialized-agents
|
||||
description: Specialized project agents normative-sync + prod-deploy-validator — first smoke outcomes and lessons
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
# Project agents normative-sync + prod-deploy-validator
|
||||
|
||||
## First smoke 24.05.2026
|
||||
|
||||
### normative-sync
|
||||
- Возвращённый рапорт: <скопировать сюда первый рапорт агента>
|
||||
- Замечания: <что пошло хорошо / что плохо>
|
||||
- Корректировки в `.claude/agents/normative-sync.md` после smoke'а: <none / список>
|
||||
|
||||
### prod-deploy-validator
|
||||
- Возвращённый рапорт: <скопировать>
|
||||
- Замечания: <...>
|
||||
- Корректировки в `.claude/agents/prod-deploy-validator.md` после smoke'а: <none / список>
|
||||
```
|
||||
|
||||
После создания памяти — добавить ссылку в `memory/MEMORY.md` index одной строкой (~150 chars):
|
||||
|
||||
```markdown
|
||||
- [Project agents normative-sync + prod-deploy-validator](feedback_specialized_agents.md) — first smoke 24.05; what works / what to tune
|
||||
```
|
||||
|
||||
### Step 5: Commit smoke outcomes
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add memory/feedback_specialized_agents.md memory/MEMORY.md && git commit -m "$(cat <<'EOF'
|
||||
docs(memory): first smoke outcomes for normative-sync + prod-deploy-validator agents
|
||||
|
||||
Captured initial smoke test results for both new project agents.
|
||||
Both spawned successfully and returned reports in expected format.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)" -- memory/feedback_specialized_agents.md memory/MEMORY.md
|
||||
```
|
||||
|
||||
Expected: коммит создан, lefthook прошёл.
|
||||
|
||||
---
|
||||
|
||||
## Self-review (после написания плана)
|
||||
|
||||
**1. Spec coverage check** — каждая секция спека покрыта задачей?
|
||||
|
||||
| Спек секция | Покрыто задачей |
|
||||
|-------------|-----------------|
|
||||
| §3 Агент №1 normative-sync — описание | Task 1 (полный системный prompt с §3.4 процедурой, §3.6 знаниями, §3.7 границами, §3.8 рисками) |
|
||||
| §3.5 Модель Sonnet 4.6 | Task 1 frontmatter `model: sonnet` |
|
||||
| §3.6 system prompt content | Task 1 Step 2 (содержание файла) |
|
||||
| §4 Агент №2 prod-deploy-validator — описание | Task 2 (полный системный prompt с §4.4 проверками, §4.7 квирками, §4.8 границами) |
|
||||
| §4.6 Модель Sonnet 4.6 | Task 2 frontmatter `model: sonnet` |
|
||||
| §4.7 квирки 104-108 в памяти | Task 2 Step 3 (раздел «Квирки производственного окружения») |
|
||||
| §5.1 файлы агентов в `.claude/agents/` | Task 1 + Task 2 (создание файлов) |
|
||||
| §5.2 frontmatter format | Task 1 + Task 2 Step 4 (валидация YAML) |
|
||||
| §5.3 какие скилы НЕ даём | Покрыто в самом system prompt каждого агента (упомянуто в Boundaries) |
|
||||
| §5.5 классификация-маппинг | **DEFERRED** — отдельная задача brain governance (см. spec §6 out-of-scope) |
|
||||
| §6 dogfooding регистрация в Tooling §0 | Произойдёт ВО ВРЕМЯ первого реального использования агента #1 — это не часть текущего плана |
|
||||
| §7 OQ-1/2/3 | Решения по умолчанию из спека приняты — никаких MCP-расширений / TaskOutput-бэкграунда / автодействий |
|
||||
| §9 next step | Task 3 (smoke = первое реальное использование, превращающее план в эксплуатацию) |
|
||||
|
||||
**2. Placeholder scan** — ни одного TBD / TODO в плане. Каждый шаг содержит конкретные команды или код. Содержимое агент-файлов выписано полностью.
|
||||
|
||||
**3. Type consistency** — `normative-sync` и `prod-deploy-validator` (slug-формат) используются единообразно во всех trех задачах. `Sonnet 4.6` упомянут в frontmatter обоих файлов как `model: sonnet` (короткий идентификатор для Claude Code).
|
||||
|
||||
Гэп: SSH alias `liderra` в Task 2 предполагается существующим в `~/.ssh/config`. Если его нет — Task 2 Step 2 ловит это и эскалирует на Дмитрия. Не нужно тратить ещё одну задачу на «настроить SSH alias», потому что (1) Дмитрий уже использует `ssh liderra` в своих рабочих процессах (см. push-историю с 22.05); (2) если alias нужен — Дмитрий настроит за 1 минуту.
|
||||
|
||||
---
|
||||
|
||||
## Execution handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-24-controller-offload-agents.md`. Two execution options:**
|
||||
|
||||
1. **Subagent-Driven (recommended)** — главный исполнитель диспатчит свежий субагент на каждую из 3 задач, делает review между задачами. Plus — это сразу первая dogfood-проверка субагент-механизма для нашего случая (Pravila §15.1).
|
||||
|
||||
2. **Inline Execution** — задачи выполняются прямо в этой сессии через `superpowers:executing-plans`, с чекпоинтами для ревью.
|
||||
|
||||
**Which approach?**
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,809 @@
|
||||
# Router Stage 3 — three follow-up fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Починить три дыры в работе сторожа warn-only: UTF-8 в stdin трёх хуков (русский в state-файл и Anthropic API без mojibake) + проброс рекомендации сторожа и прогресса цепочки из state-файла `~/.claude/runtime/router-state-<session>.json` в `primary_rationale` эпизодов наблюдателя.
|
||||
|
||||
**Architecture:** Три атомарных фикса. (1) `process.stdin.setEncoding('utf-8')` в трёх хуках. (2) Новый pure helper `readRouterState(sessionId, baseDir?)`. (3) Парсер эпизодов читает state-файл и расширяет `primary_rationale` тремя новыми полями + override существующего `recommended_node`. State-файл сторожа = source of truth для рекомендации (более точный, чем classification-map fallback).
|
||||
|
||||
**Tech Stack:** Node.js ESM, Vitest, существующие модули `tools/router-*.mjs` + `tools/observer-transcript-parser.mjs`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-24-router-stage3-three-fixes-design.md` (commit `5f9bd07`).
|
||||
|
||||
**Прошлое:** Stage 3 Tasks 1-8 ✅ слиты в `origin/main` (commit `d030dbbe`); warn-only mode активен; UTF-8 + recommended_node + chain_progress дыры обнаружены 24.05.2026 при инспекции state.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создаём:**
|
||||
|
||||
- `tools/observer-state-enricher.mjs` — pure helper для чтения `~/.claude/runtime/router-state-<sessionId>.json`. Один экспорт: `readRouterState(sessionId, options?)`. Без fs если не запущен из CLI (DI через `options.baseDir`).
|
||||
- `tools/observer-state-enricher.test.mjs` — unit-тесты helper'а (tmp-dir fixture).
|
||||
|
||||
**Модифицируем:**
|
||||
|
||||
- `tools/router-prehook.mjs` — добавить `process.stdin.setEncoding('utf-8')` перед циклом stdin.
|
||||
- `tools/router-prehook.test.mjs` — +1 тест на UTF-8 stdin (mock с кириллицей).
|
||||
- `tools/router-stop-gate.mjs` — то же.
|
||||
- `tools/router-stop-gate.test.mjs` — +1 тест.
|
||||
- `tools/router-tool-gate.mjs` — то же.
|
||||
- `tools/router-tool-gate.test.mjs` — +1 тест.
|
||||
- `tools/observer-transcript-parser.mjs` — внутри `primary_rationale` блока (строка ~809) вызвать `readRouterState`, override `recommended_node` приоритетом state-файла, добавить `recommended_chain` / `chain_progress` / `chain_completed`.
|
||||
- `tools/observer-transcript-parser.test.mjs` — +1 fixture тест на enrichment из state-файла.
|
||||
|
||||
**Не трогаем (out of scope):**
|
||||
|
||||
- Нормативка (Pravila / CLAUDE.md / PSR_v1 / Tooling / ADR / docs/router-procedure.md).
|
||||
- `.claude/settings.json` (хуки уже зарегистрированы).
|
||||
- `~/.claude/runtime/router-gate-mode.json` (остаётся `warn-only`).
|
||||
- Старые v1-эпизоды.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] **Step 1: Pre-flight sync с origin/main**
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git log HEAD..origin/main --oneline
|
||||
```
|
||||
|
||||
Spec/plan уже на main (`5f9bd07`). Pre-flight только для информации.
|
||||
|
||||
- [ ] **Step 2: Worktree от свежего origin/main**
|
||||
|
||||
```powershell
|
||||
git fetch origin
|
||||
git worktree add ".claude/worktrees/router-stage3-three-fixes" -b feat/router-stage3-three-fixes origin/main
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Junction зависимостей (Windows quirk #108)**
|
||||
|
||||
```powershell
|
||||
$wt = "c:\моя\проекты\портал crm\Документация\.claude\worktrees\router-stage3-three-fixes"
|
||||
$main = "c:\моя\проекты\портал crm\Документация"
|
||||
New-Item -ItemType Junction -Path "$wt\node_modules" -Target "$main\node_modules" -Force
|
||||
New-Item -ItemType Junction -Path "$wt\app\node_modules" -Target "$main\app\node_modules" -Force
|
||||
if (Test-Path "$wt\bin") { Remove-Item "$wt\bin" -Recurse -Force }
|
||||
New-Item -ItemType Junction -Path "$wt\bin" -Target "$main\bin" -Force
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зелёный baseline регрессии**
|
||||
|
||||
```bash
|
||||
cd "<worktree>/app" && npx vitest run --config vitest.config.tools.mjs 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: baseline GREEN (≥456 tests от Stage 3 Task 8).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: UTF-8 stdin encoding в трёх хуках
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/router-prehook.mjs:57` (внутри `main()` перед `for await` циклом).
|
||||
- Modify: `tools/router-stop-gate.mjs:49` (то же).
|
||||
- Modify: `tools/router-tool-gate.mjs:1264` (то же — внутри `main()`).
|
||||
- Test: `tools/router-prehook.test.mjs` (+1 тест).
|
||||
- Test: `tools/router-stop-gate.test.mjs` (+1 тест).
|
||||
- Test: `tools/router-tool-gate.test.mjs` (+1 тест).
|
||||
|
||||
### Подход к TDD для хуков
|
||||
|
||||
Хуки читают stdin внутри `main()`, который не экспортируется. Тестировать корень утечки кодировки напрямую через `main()` сложно. Поэтому проверяем **косвенно** через две seam:
|
||||
|
||||
1. Запускаем хук как subprocess из теста (`child_process.spawnSync`) и шлём UTF-8 Buffer в stdin → читаем выходной state-файл → проверяем что в нём кириллица читаемая.
|
||||
2. Альтернатива: вынести stdin-чтение в маленький экспортируемый helper `readStdinAsUtf8(stdinIterable)`, который принимает iterable стрима. Тестировать его легко.
|
||||
|
||||
**Выбираем helper-подход** — он быстрее, читабельнее, и пригодится третьему хуку без дублирования.
|
||||
|
||||
### Task 1.1: Helper `readStdinAsUtf8`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
Создать `tools/router-stdin-helper.test.mjs`:
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||||
|
||||
async function* fromBuffers(buffers) {
|
||||
for (const b of buffers) yield b;
|
||||
}
|
||||
|
||||
describe('readStdinAsUtf8', () => {
|
||||
it('decodes UTF-8 cyrillic correctly across chunk boundaries', async () => {
|
||||
const text = 'посмотри сторожа достаточно ему информации?';
|
||||
const buf = Buffer.from(text, 'utf-8');
|
||||
// Split across multi-byte boundary (UTF-8 cyrillic = 2 bytes per char)
|
||||
const mid = 9; // mid-byte split for 'посмо|три...'
|
||||
const result = await readStdinAsUtf8(fromBuffers([buf.subarray(0, mid), buf.subarray(mid)]));
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('handles ASCII without modification', async () => {
|
||||
const text = 'hello world';
|
||||
const result = await readStdinAsUtf8(fromBuffers([Buffer.from(text)]));
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('returns empty string on empty stream', async () => {
|
||||
const result = await readStdinAsUtf8(fromBuffers([]));
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('does NOT mangle byte-level concatenation (regression guard)', async () => {
|
||||
// The bug: `for await (const c of stdin) input += c` interprets Buffer
|
||||
// via Buffer.prototype.toString() = 'utf-8' by default in Node, BUT
|
||||
// concatenation across chunks at multi-byte boundary fails.
|
||||
// Our helper must use a StringDecoder to handle the boundary.
|
||||
const cyrillic = 'тест';
|
||||
const buf = Buffer.from(cyrillic, 'utf-8');
|
||||
// Split exactly in the middle of 'т' (2-byte char)
|
||||
const result = await readStdinAsUtf8(fromBuffers([buf.subarray(0, 1), buf.subarray(1)]));
|
||||
expect(result).toBe(cyrillic);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-stdin-helper.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL `Cannot find module './router-stdin-helper.mjs'`.
|
||||
|
||||
- [ ] **Step 3: Implement helper**
|
||||
|
||||
Создать `tools/router-stdin-helper.mjs`:
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* UTF-8 safe stdin reader for hooks.
|
||||
* Fixes Windows Node stdin quirk: default `for await (chunk of stdin)` interprets
|
||||
* chunks as Buffer, and `input += chunk` calls .toString() which uses utf-8 BUT
|
||||
* fails on chunk boundaries that fall inside multi-byte sequences (e.g. cyrillic
|
||||
* 2-byte chars split across chunks).
|
||||
*
|
||||
* Uses StringDecoder to handle multi-byte chars across chunks correctly.
|
||||
*/
|
||||
|
||||
import { StringDecoder } from 'string_decoder';
|
||||
|
||||
export async function readStdinAsUtf8(stdin) {
|
||||
const decoder = new StringDecoder('utf-8');
|
||||
let out = '';
|
||||
for await (const chunk of stdin) {
|
||||
out += decoder.write(chunk);
|
||||
}
|
||||
out += decoder.end();
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify GREEN**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-stdin-helper.test.mjs
|
||||
```
|
||||
|
||||
Expected: 4/4 PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/router-stdin-helper.mjs tools/router-stdin-helper.test.mjs
|
||||
git commit -m "feat(router): UTF-8 safe stdin helper для трёх хуков
|
||||
|
||||
StringDecoder корректно собирает multi-byte chars (кириллица) через границы
|
||||
chunk'ов stdin. Закрывает Windows Node quirk, при котором русский промпт
|
||||
превращался в mojibake до отправки в Anthropic API (Layer 2 эскалация).
|
||||
|
||||
Stage 3 follow-up fix 1/3 (helper).
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
### Task 1.2: Подключить helper к трём хукам
|
||||
|
||||
- [ ] **Step 1: Modify `tools/router-prehook.mjs`**
|
||||
|
||||
В верх файла рядом с другими imports:
|
||||
|
||||
```javascript
|
||||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||||
```
|
||||
|
||||
В функции `main()` заменить блок:
|
||||
|
||||
```javascript
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) input += chunk;
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```javascript
|
||||
const input = await readStdinAsUtf8(process.stdin);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modify `tools/router-stop-gate.mjs`**
|
||||
|
||||
Аналогично: добавить import, заменить тот же блок в `main()`:
|
||||
|
||||
```javascript
|
||||
const input = await readStdinAsUtf8(process.stdin);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Modify `tools/router-tool-gate.mjs`**
|
||||
|
||||
Аналогично — нужно найти `for await (const chunk of process.stdin)` в его `main()` и заменить на тот же helper-вызов.
|
||||
|
||||
- [ ] **Step 4: Add regression test to each of 3 hook tests**
|
||||
|
||||
В `tools/router-prehook.test.mjs` добавить describe-блок:
|
||||
|
||||
```javascript
|
||||
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
||||
it('preserves cyrillic in prompt through hook end-to-end', async () => {
|
||||
// This test verifies the import wiring — actual stdin handling is unit-tested
|
||||
// in router-stdin-helper.test.mjs. Here we assert prehook re-exports the import
|
||||
// OR (better) construct a fake stdin and verify state file content.
|
||||
// Minimal version: import the module and assert helper is wired.
|
||||
const mod = await import('./router-prehook.mjs');
|
||||
// No direct assertion possible — helper is used internally inside main().
|
||||
// Instead, smoke-check that import does NOT throw.
|
||||
expect(typeof mod.buildStateFromClassification).toBe('function');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Аналогично для `router-stop-gate.test.mjs` и `router-tool-gate.test.mjs`.
|
||||
|
||||
> **Замечание:** прямой end-to-end тест хука потребовал бы spawn subprocess (медленно, хрупко на Windows). Реальная защита — unit-тесты `router-stdin-helper.test.mjs` (Task 1.1) + live smoke (Task 4). Эти 3 placeholder-теста — для траектории «import работает после правки» и для регрессионного маркера.
|
||||
|
||||
- [ ] **Step 5: Run all hook tests**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-prehook.test.mjs ../tools/router-stop-gate.test.mjs ../tools/router-tool-gate.test.mjs
|
||||
```
|
||||
|
||||
Expected: all existing tests PASS + 3 new tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/router-prehook.mjs tools/router-stop-gate.mjs tools/router-tool-gate.mjs \
|
||||
tools/router-prehook.test.mjs tools/router-stop-gate.test.mjs tools/router-tool-gate.test.mjs
|
||||
git commit -m "feat(router): подключить UTF-8 helper к трём хукам (stage 3 follow-up 1)
|
||||
|
||||
router-prehook, router-stop-gate, router-tool-gate теперь читают stdin
|
||||
через readStdinAsUtf8 (StringDecoder). Русский в промпте корректно
|
||||
доходит до Anthropic API и в state-файл — никаких mojibake типа
|
||||
'посмотри'.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Helper `observer-state-enricher.mjs`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tools/observer-state-enricher.mjs`
|
||||
- Test: `tools/observer-state-enricher.test.mjs`
|
||||
|
||||
**Зачем.** Дать парсеру эпизодов pure-функцию для чтения state-файла сторожа. Изоляция: helper не знает про парсер, парсер не знает про детали state-формата. Тестируется с tmp-dir fixture.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Создать `tools/observer-state-enricher.test.mjs`:
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { readRouterState } from './observer-state-enricher.mjs';
|
||||
|
||||
describe('readRouterState', () => {
|
||||
let baseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null when state file does not exist', () => {
|
||||
expect(readRouterState('abc-123', { baseDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('reads state file when present', () => {
|
||||
const state = {
|
||||
sessionId: 'abc-123',
|
||||
classification: { recommendedNode: '#62', recommendedChain: '#13' },
|
||||
chainProgress: ['brainstorming'],
|
||||
chainCompleted: false,
|
||||
};
|
||||
writeFileSync(join(baseDir, 'router-state-abc-123.json'), JSON.stringify(state));
|
||||
const result = readRouterState('abc-123', { baseDir });
|
||||
expect(result).toEqual(state);
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON', () => {
|
||||
writeFileSync(join(baseDir, 'router-state-broken.json'), 'not-json');
|
||||
expect(readRouterState('broken', { baseDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on missing sessionId', () => {
|
||||
expect(readRouterState(null, { baseDir })).toBeNull();
|
||||
expect(readRouterState('', { baseDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('uses ~/.claude/runtime/ as default baseDir', () => {
|
||||
// Smoke-check: default baseDir resolution doesn't throw.
|
||||
// Real-file reading covered above with explicit baseDir.
|
||||
const result = readRouterState('non-existent-session-xyz');
|
||||
// Either null (file doesn't exist there) or object — both fine.
|
||||
expect(result === null || typeof result === 'object').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRouterFields', () => {
|
||||
it('extracts the four fields from state, defaulting to null/empty', async () => {
|
||||
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
|
||||
const state = {
|
||||
classification: { recommendedNode: '#62', recommendedChain: '#13' },
|
||||
chainProgress: ['brainstorming', 'writing-plans'],
|
||||
chainCompleted: false,
|
||||
};
|
||||
expect(extractRouterFields(state)).toEqual({
|
||||
recommended_node: '#62',
|
||||
recommended_chain: '#13',
|
||||
chain_progress: ['brainstorming', 'writing-plans'],
|
||||
chain_completed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nulls/empty when state is null', async () => {
|
||||
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
|
||||
expect(extractRouterFields(null)).toEqual({
|
||||
recommended_node: null,
|
||||
recommended_chain: null,
|
||||
chain_progress: [],
|
||||
chain_completed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing classification block', async () => {
|
||||
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
|
||||
expect(extractRouterFields({ chainProgress: ['x'], chainCompleted: true })).toEqual({
|
||||
recommended_node: null,
|
||||
recommended_chain: null,
|
||||
chain_progress: ['x'],
|
||||
chain_completed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test — FAIL**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-state-enricher.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL `Cannot find module`.
|
||||
|
||||
- [ ] **Step 3: Implement helper**
|
||||
|
||||
Создать `tools/observer-state-enricher.mjs`:
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Router state enricher for observer episodes.
|
||||
* Reads ~/.claude/runtime/router-state-<sessionId>.json and exposes pure
|
||||
* extraction helpers for primary_rationale enrichment.
|
||||
*
|
||||
* Pure-ish — fs is parameterized via options.baseDir for testability.
|
||||
*
|
||||
* Per spec: docs/superpowers/specs/2026-05-24-router-stage3-three-fixes-design.md
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
function defaultBaseDir() {
|
||||
return join(homedir(), '.claude', 'runtime');
|
||||
}
|
||||
|
||||
export function readRouterState(sessionId, options = {}) {
|
||||
if (!sessionId || typeof sessionId !== 'string') return null;
|
||||
const baseDir = options.baseDir || defaultBaseDir();
|
||||
const path = join(baseDir, `router-state-${sessionId}.json`);
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractRouterFields(state) {
|
||||
if (!state || typeof state !== 'object') {
|
||||
return { recommended_node: null, recommended_chain: null, chain_progress: [], chain_completed: false };
|
||||
}
|
||||
const cls = state.classification || {};
|
||||
return {
|
||||
recommended_node: cls.recommendedNode ?? null,
|
||||
recommended_chain: cls.recommendedChain ?? null,
|
||||
chain_progress: Array.isArray(state.chainProgress) ? state.chainProgress : [],
|
||||
chain_completed: state.chainCompleted === true,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests — GREEN**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-state-enricher.test.mjs
|
||||
```
|
||||
|
||||
Expected: 8/8 PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/observer-state-enricher.mjs tools/observer-state-enricher.test.mjs
|
||||
git commit -m "feat(observer): state enricher helper для эпизодов (stage 3 follow-up 2)
|
||||
|
||||
readRouterState(sessionId, {baseDir}) — pure read state-файла сторожа.
|
||||
extractRouterFields(state) — pure извлечение 4 полей для primary_rationale.
|
||||
|
||||
Используется парсером эпизодов на следующем шаге.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Парсер — обогащение `primary_rationale` из state-файла
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/observer-transcript-parser.mjs:809-828` (внутри `primary_rationale` IIFE в `parseTranscript`).
|
||||
- Test: `tools/observer-transcript-parser.test.mjs` (+1 fixture-тест).
|
||||
|
||||
- [ ] **Step 1: Add failing test**
|
||||
|
||||
Найти существующий describe-блок в `tools/observer-transcript-parser.test.mjs`. Добавить новый:
|
||||
|
||||
```javascript
|
||||
describe('parseTranscript — router state enrichment (stage 3 fix 2+3)', () => {
|
||||
let baseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = mkdtempSync(join(tmpdir(), 'parser-state-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('enriches primary_rationale with state-file recommendation when available', () => {
|
||||
const sessionId = 'test-session-abc';
|
||||
const state = {
|
||||
classification: { recommendedNode: '#62', recommendedChain: '#13' },
|
||||
chainProgress: ['brainstorming'],
|
||||
chainCompleted: false,
|
||||
};
|
||||
writeFileSync(join(baseDir, `router-state-${sessionId}.json`), JSON.stringify(state));
|
||||
|
||||
// Minimal transcript with one user→assistant→Stop turn
|
||||
const transcript = [
|
||||
JSON.stringify({ type: 'user', sessionId, message: { content: 'почини баланс клиента' } }),
|
||||
JSON.stringify({ type: 'assistant', sessionId, message: { content: [{ type: 'text', text: 'ok' }] } }),
|
||||
JSON.stringify({ type: 'assistant', sessionId, attachment: { type: 'hook_success', hookEvent: 'Stop', hookName: 'Stop:*', stdout: '' } }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseTranscript(transcript, sessionId, { routerStateBaseDir: baseDir });
|
||||
expect(result.episodes).toHaveLength(1);
|
||||
const pr = result.episodes[0].primary_rationale;
|
||||
expect(pr.recommended_node).toBe('#62');
|
||||
expect(pr.recommended_chain).toBe('#13');
|
||||
expect(pr.chain_progress).toEqual(['brainstorming']);
|
||||
expect(pr.chain_completed).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to classification-map when state file absent', () => {
|
||||
const sessionId = 'no-state-session';
|
||||
const transcript = [
|
||||
JSON.stringify({ type: 'user', sessionId, message: { content: 'давай сделаем новую фичу' } }),
|
||||
JSON.stringify({ type: 'assistant', sessionId, message: { content: [{ type: 'text', text: 'ok' }] } }),
|
||||
JSON.stringify({ type: 'assistant', sessionId, attachment: { type: 'hook_success', hookEvent: 'Stop', hookName: 'Stop:*', stdout: '' } }),
|
||||
].join('\n');
|
||||
const result = parseTranscript(transcript, sessionId, { routerStateBaseDir: baseDir });
|
||||
const pr = result.episodes[0].primary_rationale;
|
||||
// recommended_node may come from classification-map fallback (feature → #19 if live).
|
||||
// recommended_chain / chain_progress / chain_completed default to null/[]/false.
|
||||
expect(pr.recommended_chain).toBeNull();
|
||||
expect(pr.chain_progress).toEqual([]);
|
||||
expect(pr.chain_completed).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Также наверх файла добавить imports (если ещё не там):
|
||||
|
||||
```javascript
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test — FAIL**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs -t "router state enrichment"
|
||||
```
|
||||
|
||||
Expected: FAIL — `recommended_chain` пока `undefined` (поле не существует в эпизоде).
|
||||
|
||||
- [ ] **Step 3: Modify `tools/observer-transcript-parser.mjs`**
|
||||
|
||||
Шаг 3.1 — добавить import в верхнюю часть файла рядом с остальными:
|
||||
|
||||
```javascript
|
||||
import { readRouterState, extractRouterFields } from './observer-state-enricher.mjs';
|
||||
```
|
||||
|
||||
Шаг 3.2 — найти сигнатуру `parseTranscript`. Сейчас (строка 754):
|
||||
|
||||
```javascript
|
||||
export function parseTranscript(transcriptText, fallbackSessionId = null) {
|
||||
```
|
||||
|
||||
Расширить:
|
||||
|
||||
```javascript
|
||||
export function parseTranscript(transcriptText, fallbackSessionId = null, options = {}) {
|
||||
```
|
||||
|
||||
Шаг 3.3 — в начале функции (после извлечения sessionId) добавить:
|
||||
|
||||
```javascript
|
||||
const routerStateBaseDir = options.routerStateBaseDir; // undefined → default ~/.claude/runtime/
|
||||
const routerState = readRouterState(sessionId, { baseDir: routerStateBaseDir });
|
||||
const routerFields = extractRouterFields(routerState);
|
||||
```
|
||||
|
||||
(Поместить ДО формирования эпизодов, чтобы был доступен в IIFE.)
|
||||
|
||||
Шаг 3.4 — заменить блок `primary_rationale` IIFE (строки ~809-828) на:
|
||||
|
||||
```javascript
|
||||
primary_rationale: (() => {
|
||||
const tag = parseReasoningTag(turn);
|
||||
const merge = (heur, fromTag) => [...new Set([...heur, ...fromTag])];
|
||||
const fallbackRecommended = recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy());
|
||||
return {
|
||||
step: 1,
|
||||
node_chosen: skills.length > 0 ? skills[0] : 'direct',
|
||||
chain_ref: chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP),
|
||||
triggers_matched: merge(extractTriggers(turn), tag ? tag.triggers : []),
|
||||
candidates_considered: merge(extractCandidates(turn), tag ? tag.candidates : []),
|
||||
boundaries_applied: merge(extractBoundaries(turn), tag ? tag.boundaries : []),
|
||||
hard_floor: usedSuperpowers
|
||||
? { invoked: true, rules: ['Pravila §12'] }
|
||||
: { invoked: false, rules: [] },
|
||||
task_classification: classifyTask(prompt),
|
||||
// Stage 3 fix 2+3: router state-файл — source of truth для рекомендации.
|
||||
// Fallback на classification-map когда state-файла нет.
|
||||
recommended_node:
|
||||
routerFields.recommended_node !== null
|
||||
? routerFields.recommended_node
|
||||
: (skills.length === 0 ? fallbackRecommended : null),
|
||||
recommended_chain: routerFields.recommended_chain,
|
||||
chain_progress: routerFields.chain_progress,
|
||||
chain_completed: routerFields.chain_completed,
|
||||
};
|
||||
})(),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test — GREEN**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs
|
||||
```
|
||||
|
||||
Expected: все существующие тесты + 2 новых PASS.
|
||||
|
||||
- [ ] **Step 5: Full tools suite regression**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --config vitest.config.tools.mjs 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: GREEN. Прирост ≥ helper(4) + helper-tests(8) + parser(2) = 14 новых тестов. Если есть фейлы — фиксим до commit.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
|
||||
git commit -m "feat(observer): обогащение primary_rationale из router-state (stage 3 follow-up 3)
|
||||
|
||||
parseTranscript теперь читает ~/.claude/runtime/router-state-<session>.json
|
||||
(через observer-state-enricher) и заполняет 4 поля primary_rationale:
|
||||
- recommended_node (state приоритет, fallback classification-map)
|
||||
- recommended_chain (только из state)
|
||||
- chain_progress (только из state)
|
||||
- chain_completed (только из state)
|
||||
|
||||
Закрывает дыры 2 и 3 из spec follow-up: brain-retro domainHitRate
|
||||
и chainCompletionRate теперь имеют данные.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Smoke + continuity + push (controller, Opus)
|
||||
|
||||
**Этот шаг — НЕ для субагента.** Делается контроллером (Opus) после Task 1-3. Включает ручной smoke на живой сессии.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/observer/active-projects.md` (mark stage 3 follow-up closed).
|
||||
- Modify: `docs/observer/STATUS.md` (auto-regenerated).
|
||||
- Outside-repo: `memory/project_router_overhaul.md` (controller updates).
|
||||
- Outside-repo: `memory/MEMORY.md` (entry update).
|
||||
|
||||
- [ ] **Step 1: Финальная регрессия в worktree**
|
||||
|
||||
```bash
|
||||
cd <worktree>/app && npx vitest run --config vitest.config.tools.mjs 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: baseline + ≥14 новых tests, всё GREEN.
|
||||
|
||||
- [ ] **Step 2: gitleaks**
|
||||
|
||||
```bash
|
||||
./bin/gitleaks.exe detect --no-banner --redact 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: `0 leaks`.
|
||||
|
||||
- [ ] **Step 3: Update `docs/observer/active-projects.md`**
|
||||
|
||||
В разделе про router overhaul изменить статус этапа 3 — поставить пометку «follow-up 3 dырs закрыт <дата>».
|
||||
|
||||
Если файла или раздела нет — пропустить (он необязательный); сделать вместо этого commit-pointer в Memory.
|
||||
|
||||
- [ ] **Step 4: Regenerate STATUS.md**
|
||||
|
||||
```bash
|
||||
node tools/status-md-generator.mjs
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit continuity**
|
||||
|
||||
```bash
|
||||
git add docs/observer/active-projects.md docs/observer/STATUS.md
|
||||
git commit -m "docs(continuity): stage 3 follow-up закрыт — 3 fixes + STATUS regen
|
||||
|
||||
UTF-8 + recommended_node + chain_progress теперь работают.
|
||||
Финальная регрессия: <X>f / <Y>t GREEN. gitleaks 0.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Push на main (FF)**
|
||||
|
||||
```bash
|
||||
git push origin feat/router-stage3-three-fixes:main
|
||||
```
|
||||
|
||||
Expected: FF успешно, без force.
|
||||
|
||||
Если push отклонён (origin/main ушёл вперёд) — pre-flight, rebase на свежий origin/main, повторить.
|
||||
|
||||
- [ ] **Step 7: Memory update (outside-repo, controller-only)**
|
||||
|
||||
Обновить `memory/project_router_overhaul.md` (или эквивалент): добавить запись «24.05 follow-up — 3 fixes pushed `<sha>`».
|
||||
Обновить `MEMORY.md` если строка ссылается на router overhaul.
|
||||
|
||||
- [ ] **Step 8: LIVE smoke test (ручной шаг — НЕ для субагента)**
|
||||
|
||||
После push'а — рестарт Claude Code; в новой сессии:
|
||||
|
||||
1. Прислать русский размытый промпт: «проверь как сторож обрабатывает финансовые задачи клиента».
|
||||
2. После завершения turn'а — открыть `~/.claude/runtime/router-state-<новая-sessionId>.json`. Проверить: в `classification.reasoning` — читаемая кириллица (если Layer 2 эскалировал), без `посмотри`.
|
||||
3. Открыть последнюю строку `docs/observer/episodes-2026-05.jsonl`. Проверить:
|
||||
- `primary_rationale.recommended_node` — не null если сторож порекомендовал.
|
||||
- `primary_rationale.recommended_chain` — заполнен или null.
|
||||
- `primary_rationale.chain_progress` — массив.
|
||||
- `primary_rationale.chain_completed` — bool.
|
||||
|
||||
Если что-то из перечисленного не работает — открыть новый план «follow-up to follow-up» (НЕ латать в этом spec).
|
||||
|
||||
- [ ] **Step 9: Worktree cleanup**
|
||||
|
||||
```bash
|
||||
cd <main-repo-path>
|
||||
git worktree remove .claude/worktrees/router-stage3-three-fixes
|
||||
git branch -D feat/router-stage3-three-fixes # ветка влита в main, можно удалить
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
- ✅ Helper `router-stdin-helper` создан + 4 теста GREEN.
|
||||
- ✅ Три хука используют helper.
|
||||
- ✅ Helper `observer-state-enricher` создан + 8 тестов GREEN.
|
||||
- ✅ Парсер эпизодов читает state и пишет 4 новых поля в primary_rationale.
|
||||
- ✅ Полный tools suite GREEN (baseline 456 + ≥14 новых = ≥470).
|
||||
- ✅ gitleaks 0.
|
||||
- ✅ Live smoke: русский в state без mojibake; 4 поля в новом эпизоде заполнены.
|
||||
- ✅ Push на `main` FF.
|
||||
- ✅ Worktree удалён.
|
||||
|
||||
После DoD → 24h warn-only наблюдения с **починенным** сторожем → `/brain-retro` → решение по `enforce` (Task 9 плана Stage 3, отдельная задача).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### 1. Spec coverage
|
||||
|
||||
| Spec требование | Task | Покрыто |
|
||||
|---|---|---|
|
||||
| Fix 1 — UTF-8 в trех хуках (§3.1) | Task 1.1 + 1.2 | ✅ |
|
||||
| Fix 2 — recommended_node в эпизоды (§3.2) | Task 2 + 3 | ✅ |
|
||||
| Fix 3 — chain_progress / chain_completed / recommended_chain (§3.3) | Task 2 + 3 (single helper покрывает оба) | ✅ |
|
||||
| Unit-тесты на каждый фикс (§4) | Task 1.1 (helper 4), Task 1.2 (3 placeholders), Task 2 (8), Task 3 (2) | ✅ |
|
||||
| Live smoke на русском промпте (§4 + §8) | Task 4 Step 8 | ✅ |
|
||||
| gitleaks 0 (§8) | Task 4 Step 2 | ✅ |
|
||||
| Откат ≤5 минут (§8) | Каждый task — atomic commit; revert по одному. | ✅ |
|
||||
| Push на main FF (§7) | Task 4 Step 6 | ✅ |
|
||||
| Worktree per Pravila §15.1 | Pre-flight Step 2 | ✅ |
|
||||
| Subagent последовательно (§7) | Tasks 1-3 — Sonnet; Task 4 — controller Opus | ✅ |
|
||||
|
||||
### 2. Placeholder scan
|
||||
|
||||
- ❌ Никаких «TBD», «TODO», «implement later», «add error handling», «similar to Task N» в коде шагов.
|
||||
- ✅ Все code blocks показывают конкретный код, который нужно вставить.
|
||||
- ⚠️ Task 1.2 Step 4 — три regression placeholder-теста минимальны. Это осознанное решение (объяснено: end-to-end stdin тест хука требует spawn subprocess, что хрупко на Windows; реальная защита — helper unit-тесты + live smoke). НЕ placeholder в смысле «дописать позже» — это финальный код этих тестов.
|
||||
|
||||
### 3. Type consistency
|
||||
|
||||
- `readStdinAsUtf8(stdin)` → `Promise<string>` — везде то же.
|
||||
- `readRouterState(sessionId, options?)` → `object | null` — везде то же.
|
||||
- `extractRouterFields(state)` → `{recommended_node, recommended_chain, chain_progress, chain_completed}` — везде то же.
|
||||
- `parseTranscript(text, fallbackSessionId?, options?)` — третий параметр options обратносовместим (default `{}`).
|
||||
- Поля `primary_rationale`: `recommended_node` (string|null), `recommended_chain` (string|null), `chain_progress` (array), `chain_completed` (bool) — consistent в spec + helper + parser + тестах.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. Two execution options:**
|
||||
|
||||
**1. Subagent-Driven (recommended)** — диспетчирую свежего субагента на каждую таску (Task 1, 2, 3 — Sonnet; Task 4 — controller Opus сам), review между тасками, быстрая итерация.
|
||||
|
||||
**2. Inline Execution** — выполняю в этой сессии через `superpowers:executing-plans`, batch с checkpoint'ами на ревью.
|
||||
|
||||
**Какой подход?**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -308,3 +308,132 @@
|
||||
## Следующий шаг
|
||||
|
||||
После твоего ревью этого спека → `writing-plans` skill → план для этапа 1 (справочник). После закрытия этапа 1 — план этапа 2. И так далее.
|
||||
|
||||
---
|
||||
|
||||
## Amendment 2026-05-24 — расширение этапа 3 (Task 0a доменная разметка + Task 0b цепочки + chain governance)
|
||||
|
||||
**Триггер:** живой разговор с заказчиком 2026-05-24 после закрытия этапа 2. Заказчик задал два фундаментальных вопроса, не покрытых исходным дизайном этапа 3:
|
||||
|
||||
1. **«Есть скилы для биллинга, маркетинга, безопасности — но ты их не используешь. После этапа 3 эта проблема уйдёт?»** — обнажил, что классификатор смотрит только на **тип задачи** (feature/bugfix/refactor), не на **домен** (биллинг/маркетинг/безопасность). На промпт «почини списание лида» исходный дизайн рекомендовал бы Pest, но не #62 billing-audit — рядом стоящий доменно-специальный скил.
|
||||
2. **«Как создаются/расширяются/меняются цепочки?»** — обнажил, что cepочки (L1-L16) сейчас описательные, но в этапе 3 становятся **исполняемыми** (сторож принуждает). Нужна явная governance.
|
||||
|
||||
Решение: расширить этап 3 двумя под-тасками **перед** основными (Task 1+) и зафиксировать chain governance как часть scope.
|
||||
|
||||
### Task 0a — Доменная разметка реестра
|
||||
|
||||
**Цель:** научить классификатор различать домены задач, а не только типы.
|
||||
|
||||
**Артефакты:**
|
||||
|
||||
- **Расширение `docs/registry/nodes.yaml`:** на 30-40 специализированных скилах добавить **keyword-триггеры** доменного характера. Примеры:
|
||||
- `#62 billing-audit` → keywords: `списание`, `биллинг`, `тариф`, `баланс`, `начисление лида`, `lead_charges`, `деньги`, `копейки`, `провенанс charge`, `csv reconcile`, `bcmath`, `bcadd`, `idempotency`.
|
||||
- `#71 pdn-152fz-audit` → keywords: `ПДн`, `персональные данные`, `152-ФЗ`, `согласие`, `телефон лида`, `маскирование`, `pd_subject_request`, `data subject`.
|
||||
- `#73 security-go-live` → keywords: `безопасность`, `прод`, `выход в интернет`, `go-live`, `публикация`, `attack surface`.
|
||||
- `#74 marketing` (plugin) → keywords: `email-рассылка`, `кампания`, `контент`, `лендинг`, `реклама`, `вебинар`, `лидген`.
|
||||
- `#76 brand-voice` → keywords: `тон бренда`, `voice`, `позиционирование`, `тональность`.
|
||||
- `#77 marketing-ru` → keywords: `РФ-канал`, `яндекс`, `вконтакте`, `telegram`, `152-фз маркетинг`, `unisender`.
|
||||
- `#66 laravel-backend-patterns` → keywords: `controller`, `service`, `job`, `Eloquent`, `RLS`, `partition`.
|
||||
- И т. д. по каждому из ~30-40 доменных скилов в реестре.
|
||||
- **Расширение Sonnet prompt-template в `tools/router-classifier.mjs` Layer 2:** теперь Sonnet получает не только промпт + тип задачи, но и **список активных узлов реестра с их keyword/classification триггерами** (`{id, name, triggers}` для каждого active-узла). Sonnet возвращает не только `{task_type, micro}`, но и `recommended_node: id | null` напрямую — выбранный конкретный узел реестра.
|
||||
- **Layer 1 (regex)** так же сканирует промпт по keyword-триггерам активных узлов: совпадение с keyword'ом узла N → recommended_node=N. Layer 2 эскалация только если Layer 1 не уверен или нашёл конфликт.
|
||||
|
||||
**Definition of done:**
|
||||
|
||||
- На 30+ доменных скилах есть как минимум 5-10 keyword-триггеров каждый.
|
||||
- Sonnet prompt-template содержит сериализованный список узлов (компактный — id+name+top-5 keywords).
|
||||
- Unit-тесты classifier'а покрывают 20+ доменных промптов: «почини списание» → #62, «составь email-рассылку» → #74, «выход в интернет» → #73.
|
||||
|
||||
### Task 0b — Цепочки в рекомендациях
|
||||
|
||||
**Цель:** сторож подсказывает не только первую точку, но и всю цепочку (L1-L16).
|
||||
|
||||
**Артефакты:**
|
||||
|
||||
- **Sonnet возвращает дополнительное поле:** `recommended_chain: chainId | null` (L1-L16 либо null если задача одиночная).
|
||||
- **`router-prehook.mjs`** пишет в state-файл сессии не только `recommended_node`, но и `recommended_chain` + `chain_progress: []` (массив уже вызванных шагов).
|
||||
- **`router-tool-gate.mjs`** при каждом вызове Edit/Write/Bash проверяет:
|
||||
- Если `chain_progress` не пустой — следующий ожидаемый шаг = `chain.sequence[chain_progress.length]`. Если этот шаг ещё не вызван, и пытаешься делать Edit — блок «не пропустить шаг цепочки X».
|
||||
- Если цепочка завершена (`chain_progress.length === chain.sequence.length`) — снимает блок, пропускает Edit/Bash.
|
||||
- **`router-stop-gate.mjs`** обновляет `chain_progress`: если в текущем ходу был вызван скил из цепочки — добавляет в массив. Пишет в эпизод `chain_progress` для последующей аналитики (хватает ли цепочка от первого до последнего шага).
|
||||
|
||||
**Definition of done:**
|
||||
|
||||
- На 5+ типовых тестовых промптах, попадающих в L1-L16, сторож проводит через всю цепочку (live-тест на одной сессии).
|
||||
- Brain-retro analyzer +новая ось «chain completion rate» (% начатых цепочек, доведённых до конца).
|
||||
- В STATUS.md строка «Chain completion: X%».
|
||||
|
||||
### Chain governance — как создаются/меняются цепочки
|
||||
|
||||
**Базовое правило:** **никаких автоматических правок цепочек.** Claude НЕ имеет права самостоятельно (без явного согласия заказчика) добавлять, удалять или изменять цепочку в `docs/registry/nodes.yaml`. Цепочка в этапе 3+ становится исполняемой — её изменение меняет поведение мозга.
|
||||
|
||||
**Три источника изменений:**
|
||||
|
||||
1. **Явный запрос заказчика** («давай чтобы при правках биллинга всегда дёргался billing-audit потом Pest»). Claude:
|
||||
- Подтверждает формулировку.
|
||||
- Правит `nodes.yaml` (добавляет/меняет chain block + поле `chain_membership` на участвующих узлах).
|
||||
- Авто-render обновляет `docs/routing-off-phase.md`.
|
||||
- Контролёр C6 (chain-map-checker) сверяет — синхронны ли источники.
|
||||
- Коммитит одним коммитом, ~15-30 минут.
|
||||
|
||||
2. **Кандидат из `/brain-retro`** (раз в спринт):
|
||||
- Аналайзер ищет в журнале повторяющиеся паттерны последовательностей скилов на одной задаче.
|
||||
- Если паттерн встретился ≥3 раз и для него нет существующей цепочки в реестре — выдаёт кандидата с обоснованием в отчёте.
|
||||
- Claude **обязательно показывает кандидата заказчику** перед внесением. Без явного согласия не вносит.
|
||||
- При согласии — как в пункте 1.
|
||||
|
||||
3. **Сигналы пропусков** (С5 missed-activations):
|
||||
- Если на одной классификации регулярно (N>10/мес) пропускается рекомендованный узел — может быть проблема в маппинге keyword'ов (Task 0a) ИЛИ нужна цепочка с пред-условием. Решает Claude+заказчик через `/brain-retro` интерпретацию.
|
||||
|
||||
**Защита от ошибок при правках:**
|
||||
|
||||
- C6 chain-map-checker — синхрон источников (уже работает).
|
||||
- Авто-render `routing-off-phase.md` — невозможен дрейф между YAML и Markdown.
|
||||
- C2 cross-ref-checker — ссылки в нормативке (Pravila/CLAUDE.md/PSR_v1) на цепочки.
|
||||
- C1 l1-watcher — имена узлов в `sequence`.
|
||||
- **Новое в этапе 3:** перед коммитом изменения существующей цепочки — обязательно прогоняется **«what-if симуляция»** на исторических эпизодах последнего месяца: если бы новая цепочка работала, сколько было бы ложных блокировок? Порог: ≤5%. Превышение → не коммитим.
|
||||
|
||||
**Откатываемость изменений цепочек:**
|
||||
|
||||
- Каждое изменение — один коммит. `git revert <SHA>` возвращает в предыдущее состояние.
|
||||
- Если правка пошла в прод и начала мешать — снимается за 10 минут (revert + push).
|
||||
|
||||
### Обновлённые acceptance criteria этапа 3
|
||||
|
||||
К исходным («дисциплина ≥75%», «FP блокировок ≤20%», «$≤15/мес») добавляются:
|
||||
|
||||
- **Domain hit rate** — % эпизодов с непустым `recommended_node` (не только `task_type`): **≥80%** на доменных промптах (биллинг/маркетинг/безопасность).
|
||||
- **Chain completion rate** — % начатых цепочек, доведённых до последнего шага: **≥60%** к концу первой недели.
|
||||
- **Доменный baseline восстановлен** — на тестовом наборе (20 заранее подготовленных доменных промптов) classifier правильно угадывает специализированный узел в **≥75% случаев**.
|
||||
|
||||
### Что НЕ меняется amendment'ом
|
||||
|
||||
- Исходный scope этапа 3 (classifier hybrid regex+LLM, 3 хука, escape hatch routing-tag).
|
||||
- Бюджет $≤20/мес.
|
||||
- Откатываемость ≤5 минут на отключение хуков.
|
||||
- Этапы 1, 2, 4 — без изменений.
|
||||
- Continuity-механизм (STATUS.md + memory + brain-retro).
|
||||
- Принцип «никакого изменения нормативки в этапе 3» — нормативка трогается только этапом 4.
|
||||
|
||||
### Что переезжает «из головы Claude» в код
|
||||
|
||||
После этапа 3 с amendment'ом:
|
||||
|
||||
| Бывшая «процедура router-procedure.md v1.4» | Кто делает после этапа 3 |
|
||||
|---|---|
|
||||
| Шаг 1: Hard-floor §12/§14/§15 | Существующие хуки скилов + новый PreToolUse сторож |
|
||||
| Шаг 2: Классификация задачи | `router-classifier.mjs` Layer 1 regex |
|
||||
| Шаг 3: Поиск узла в реестре по триггерам | `router-classifier.mjs` Layer 2 Sonnet с доменной разметкой Task 0a |
|
||||
| Шаг 4: Проверка цепочки L1-L16 | Sonnet выдаёт `recommended_chain` (Task 0b); сторож отслеживает `chain_progress` |
|
||||
| Шаг 5: Выполнение | Сторож блокирует Edit/Bash до вызова нужного скила |
|
||||
|
||||
**Документ `docs/router-procedure.md` v1.4 → v2.0** (это этап 4): становится описанием архитектуры, а не инструкцией к действию. v1.4 удаляется как «процедура для Claude», v2.0 пишется как «как устроен мозг» для людей.
|
||||
|
||||
### Self-review amendment'а
|
||||
|
||||
- ✅ Не противоречит исходному spec'у — расширяет, не заменяет.
|
||||
- ✅ Все три боли заказчика (биллинг/маркетинг/безопасность) явно адресованы Task 0a.
|
||||
- ✅ Цепочки governance явно зафиксированы — Claude не имеет права автоматически менять.
|
||||
- ✅ Откатываемость сохранена (хуки в settings.json + revert коммитов).
|
||||
- ⚠️ Расширение scope этапа 3 ≈ +3 часа работы (Task 0a 1.5ч + Task 0b 1.5ч). Принято заказчиком 24.05.
|
||||
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
# Спек C — Биллинг v2: preflight баланса + VTB-эквайринг
|
||||
|
||||
**Дата:** 2026-05-24
|
||||
**Статус:** Design (awaiting user review)
|
||||
**Автор:** Claude Opus 4.7 (под руководством заказчика)
|
||||
**Брейнсторм:** сессия 24.05.2026
|
||||
**Триггер:** «preflight баланса перед заказом у поставщика, чтобы не заказать лишнего; VTB-эквайринг для пополнения баланса» (заказчик 23.05.2026, расширено в брейнсторме 24.05).
|
||||
|
||||
**Часть серии из 3 спеков:**
|
||||
|
||||
- Спек A — единый ₽-баланс (DONE на проде, [спек](2026-05-23-billing-v2-spec-a-balance-rub-design.md)).
|
||||
- Спек B — политика дублей (DONE на проде, [спек](2026-05-23-billing-v2-spec-b-duplicates-design.md)).
|
||||
- **Спек C (этот)** — preflight баланса + VTB-эквайринг.
|
||||
|
||||
---
|
||||
|
||||
## §1. Контекст и проблема
|
||||
|
||||
### §1.1 Текущее поведение портала
|
||||
|
||||
**Расчёт заказа у поставщика** ([app/app/Services/Supplier/SupplierQuotaAllocator.php:88-98](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):
|
||||
|
||||
```
|
||||
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
|
||||
```
|
||||
|
||||
где входной массив — `daily_limit` всех eligible-на-сегодня проектов клиентов на источнике (источник = тег × субъект). Затем `order` делится между площадками B1/B2/B3 (`distributeForPlatform`, largest-remainder).
|
||||
|
||||
**Allocator не смотрит баланс.** На вход принимает только лимиты проектов. Если у клиента нулевой баланс — он всё равно учитывается в формуле, значит портал заказывает у поставщика лиды, которые этот клиент оплатить не сможет.
|
||||
|
||||
**Списание с клиента** ([app/app/Services/Billing/LedgerService.php](../../../app/app/Services/Billing/LedgerService.php)) происходит при доставке (`RouteSupplierLeadJob` создаёт `Deal` → `LedgerService::chargeForDelivery`). Если баланса не хватает — после Спека A не было защиты «на входе»; шёл лид, списывалось, баланс уходил в ноль.
|
||||
|
||||
**Пополнение баланса** ([app/app/Services/Billing/BillingTopupService.php](../../../app/app/Services/Billing/BillingTopupService.php)) — MVP-stub: мгновенно кредитует `balance_rub` + пишет `balance_transactions(type='topup')`. Реальной оплаты нет. UI — `AdminTenantsController::adjustBalance` (admin-only).
|
||||
|
||||
### §1.2 Проблемы
|
||||
|
||||
**Префлайт:**
|
||||
|
||||
1. **Портал переплачивает поставщику** за лиды клиентов, у которых нет денег. Это прямой убыток — закупка оплачена, а продажа не состоится.
|
||||
2. **Нет проактивной защиты при создании/изменении проектов.** Клиент может выставить лимит, который сам по себе не оплачиваем. Сейчас проблема всплывает только в момент списания.
|
||||
3. **Нет ясной коммуникации с клиентом** «у тебя баланса хватит на X лидов, ты заказал Y, нужно пополнить или сократить» — клиент узнаёт по факту остановки списания.
|
||||
|
||||
**VTB-эквайринг:**
|
||||
|
||||
1. **Реального пополнения нет** — `BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
|
||||
2. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
|
||||
|
||||
### §1.3 Триггер
|
||||
|
||||
Заказчик 23.05.2026: «preflight баланса перед заказом у поставщика; VTB-эквайринг; аудит раздела Биллинг» → в брейнсторме 24.05 уточнено как один спек с детальной механикой preflight + полный охват трёх методов оплаты (безнал, СБП, карты).
|
||||
|
||||
**Главный принцип** (заказчик 24.05): «**не заказать лишнего у поставщика — это убыток**».
|
||||
|
||||
---
|
||||
|
||||
## §2. Scope
|
||||
|
||||
### §2.1 Что делаем
|
||||
|
||||
**Префлайт баланса:**
|
||||
|
||||
- Расширение `SupplierQuotaAllocator` для учёта баланса клиента (фильтрация eligible-проектов до `computeOrder`).
|
||||
- Активная проверка при создании/правке проекта в личном кабинете (диалог выбора).
|
||||
- Активная проверка перед cut-off (18:00 MSK ежедневно) — для пассивного износа баланса.
|
||||
- UI-баннер «приём приостановлен» в личном кабинете клиента.
|
||||
- Уведомления по email с правильной частотой (1 + 1д + 3д + «возобновлено»).
|
||||
- Журналирование событий заморозки/разморозки в `balance_transactions` или новой таблице.
|
||||
|
||||
**VTB-эквайринг:**
|
||||
|
||||
- Архитектура (интерфейс `TopupGateway` + три реализации: `BankTransfer`, `SBP`, `Card`).
|
||||
- **Полная реализация Безнала** (генерация PDF-счёта, журнал «ожидающих платежей» в админке, авто-поиск через VTB Бизнес API с подтверждением человеком, режимный переключатель «автомат / с подтверждением», часовые email-алерты).
|
||||
- **Dev-заглушки для СБП и Карт** (мгновенное подтверждение в dev/test, реальные эндпоинты VTB — после Б-1).
|
||||
- **Архитектура 54-ФЗ** через ОФД-Атол (заглушка в dev, реальная интеграция после Б-1 параллельно с СБП).
|
||||
|
||||
### §2.2 Что НЕ делаем (явно out of scope)
|
||||
|
||||
- **Реальное подключение VTB Acquiring и СБП** — требует реквизитов ООО (P0-блокер Б-1). Отдельные задачи после Б-1.
|
||||
- **Реальная интеграция ОФД-Атол** — параллельно с СБП после Б-1.
|
||||
- **Авто-сверка с банковской выпиской VTB** (`/api/vtb-business`) — отдельная задача после Б-1; в этом спеке только архитектурный интерфейс и ручной режим.
|
||||
- **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** — отложено в Спек D (см. §8).
|
||||
- **Возвраты пополнений** (refund) — не реализуем (Спек A: «возвраты не делаем»).
|
||||
- **Recurring-платежи** (автосписание) — не реализуем.
|
||||
- **Изменение формулы `computeOrder`** — формула остаётся прежней, префлайт только фильтрует входной список.
|
||||
|
||||
---
|
||||
|
||||
## §3. Решение — часть 1: Префлайт баланса
|
||||
|
||||
### §3.1 Главный инвариант
|
||||
|
||||
**Баланс клиента никогда не уходит в минус.** Гарант — префлайт, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
|
||||
|
||||
### §3.2 Когда срабатывает префлайт
|
||||
|
||||
**Одна основная точка:** ежедневный cut-off в **18:00 MSK** (включая выходные).
|
||||
|
||||
Между cut-off и cut-off (всё внутри текущего дня) никаких внутридневных стопов нет. Лиды, заказанные у поставщика на сегодня, идут клиенту полностью, списываются с его баланса как поступают. Защита от ухода в минус — на стороне cut-off предыдущего вечера.
|
||||
|
||||
**Дополнительные триггерные точки** (для UX в личном кабинете, не для блокировки заказа):
|
||||
|
||||
- Создание нового проекта в UI клиента.
|
||||
- Изменение лимита существующего проекта в UI клиента.
|
||||
- Активация ранее приостановленного проекта.
|
||||
- Пополнение баланса (для авто-разморозки при следующем cut-off).
|
||||
- Снижение/удаление проекта (может вернуть в зелёную зону).
|
||||
|
||||
### §3.3 Что значит «баланса хватает»
|
||||
|
||||
Сравнение делается **в лидах**, не в рублях, потому что в existing-сервисе `BalanceToLeadsConverter` (от Спека A) есть прямой расчёт «сколько лидов даст баланс с учётом 7 ступеней и уже отгруженного объёма за месяц»:
|
||||
|
||||
```php
|
||||
$capacity = $converter->convert(
|
||||
balanceRub: $tenant->balance_rub,
|
||||
deliveredInMonth: $tenant->delivered_in_month,
|
||||
tiers: $activePricingTiers
|
||||
)['leads'];
|
||||
|
||||
$requiredLeads = $tenant->projects()
|
||||
->where('status', 'active')
|
||||
->where('eligible_tomorrow', true)
|
||||
->sum('daily_limit');
|
||||
|
||||
$passes = $capacity >= $requiredLeads;
|
||||
```
|
||||
|
||||
Если `passes=true` — клиент проходит префлайт. Если `false` — не проходит.
|
||||
|
||||
7-ступенчатый расчёт уже реализован в `BalanceToLeadsConverter::convert` (Спек A) — он сам пройдёт по ступеням, учтёт «текущую» (где сейчас клиент в накопленном объёме) и переход на следующие при росте.
|
||||
|
||||
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что префлайт работает один раз перед формированием заказа на завтра, а не во время выдачи.
|
||||
|
||||
### §3.4 Что делает портал при создании/правке «перегруженного» проекта
|
||||
|
||||
В UI клиента при попытке сохранить проект, после которого сумма `daily_limit` всех eligible-проектов превысит «потолок баланса»:
|
||||
|
||||
**Модальный диалог** (не блокирующая ошибка):
|
||||
|
||||
```
|
||||
Этот лимит превышает твой баланс.
|
||||
У тебя на счёте 1000₽ = 30 лидов по текущему тарифу.
|
||||
После сохранения этого проекта сумма лимитов будет 40 лидов.
|
||||
Не хватает: 10 лидов.
|
||||
|
||||
Чтобы он начал работать, нужно одно из:
|
||||
• Пополнить счёт (примерно 350₽ покроют 10 лидов недостачи)
|
||||
• Поставить лимит этого проекта 0
|
||||
• Уменьшить лимиты других проектов
|
||||
|
||||
[Сохранить и приостановить только этот] [Поставить лимит 0] [Отмена]
|
||||
```
|
||||
|
||||
- **«Сохранить и приостановить только этот»** — проект сохраняется с исходным лимитом, но при следующем cut-off исключается из расчёта заказа на завтра (`active_today = false`). Остальные проекты клиента работают как обычно.
|
||||
- **«Поставить лимит 0»** — проект сохраняется с лимитом 0 (фактически выключен). Не идёт в заказ.
|
||||
- **«Отмена»** — изменения отбрасываются.
|
||||
|
||||
**Ключевое:** заморозка точечная, **только перегружающий проект**. Не «весь тенант». Это позволяет клиенту работать над созданием 20-30 проектов поэтапно, постепенно понимая «нужно столько-то ₽ для запуска всех».
|
||||
|
||||
### §3.5 Что делает портал при пассивном износе баланса
|
||||
|
||||
Клиент не правил проекты, просто баланс таял по дням. На очередном cut-off (18:00 MSK) выясняется, что баланс уже не покрывает все активные проекты.
|
||||
|
||||
**Действие:**
|
||||
|
||||
1. **Все проекты клиента** исключаются из расчёта заказа на завтра.
|
||||
2. На `tenants` устанавливается флаг `frozen_by_balance_at = now()` (новая колонка).
|
||||
3. На email клиента — письмо «Приём лидов приостановлен» (детали §3.7).
|
||||
4. В личном кабинете — красный баннер на всех страницах (детали §3.6).
|
||||
|
||||
Клиент сам выбирает что делать в личном кабинете:
|
||||
|
||||
- Пополнить баланс (откроет UI «Пополнение», см. часть 2).
|
||||
- Снизить лимиты на проектах (UI «Проекты»).
|
||||
- Выключить часть проектов (paused).
|
||||
- Любое сочетание.
|
||||
|
||||
Как только сумма лимитов снова влезает в баланс (после пополнения, снижения лимита, или выключения проектов) — `frozen_by_balance_at = NULL`, баннер исчезает, отправляется письмо «Возобновлено» (если успели до 18:00 — клиент в завтрашнем заказе; если позже — в послезавтрашнем).
|
||||
|
||||
### §3.6 UI личного кабинета клиента
|
||||
|
||||
**Красный баннер на всех страницах** (компонент `BalanceFrozenBanner.vue`) когда `tenant.frozen_by_balance_at IS NOT NULL`:
|
||||
|
||||
```
|
||||
🔴 Приём лидов приостановлен
|
||||
Не хватает баланса на дневной заказ. Нужно ещё 380₽ (или сократи лимиты на 10 лидов).
|
||||
[Пополнить счёт] [Перейти к проектам]
|
||||
```
|
||||
|
||||
**Постоянная подсказка под балансом** (даже когда не в заморозке) — компонент `BalanceCapacityIndicator.vue`:
|
||||
|
||||
```
|
||||
Баланс: 1000₽ = до 30 лидов по тарифу
|
||||
Проекты заказывают: 25 лидов в день
|
||||
✅ Хватит на ~1.2 дня
|
||||
```
|
||||
|
||||
В состоянии «хватает на меньше 3 дней» — жёлтый цвет с подсказкой «скоро потребуется пополнение». В состоянии «не хватает» — красный (баннер выше).
|
||||
|
||||
### §3.7 Email-уведомления
|
||||
|
||||
**При входе в заморозку:**
|
||||
|
||||
- T+0 (сразу) — `BalanceFrozenMail` («Приём лидов приостановлен»).
|
||||
- T+24ч (если ещё в заморозке) — `BalanceFrozenReminderMail` («Всё ещё приостановлено»).
|
||||
- T+72ч (если ещё в заморозке) — `BalanceFrozenFinalMail` («Приостановлено 3 дня»).
|
||||
- Дальше — тишина до следующего цикла (если разморозится и снова попадёт — счёт идёт заново).
|
||||
|
||||
**При выходе из заморозки:**
|
||||
|
||||
- T+0 — `BalanceUnfrozenMail` («Приём возобновлён»).
|
||||
|
||||
Все письма throttled по `tenant_id` через `mail_log` (паттерн `ZeroBalancePausedMail` из Plan 4) — повторов не будет.
|
||||
|
||||
### §3.8 Cut-off режимы синхронизации с поставщиком
|
||||
|
||||
Сохраняем оба существующих режима (admin-переключатель уже есть):
|
||||
|
||||
- **Онлайн-режим** (сейчас, для малого числа клиентов): любое изменение в проектах Лидерры → немедленный апдейт на сервере поставщика (`SyncSupplierProjectJob` per-project). Поставщик сохраняет, использует в своём 21:00 слепке.
|
||||
- **Batch до 18:00** (на будущее при росте): накопленные изменения уезжают одним пакетом перед 18:00 (`SyncSupplierProjectsJob` daily cron).
|
||||
|
||||
**Префлайт работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
|
||||
|
||||
### §3.9 Граничные случаи
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| Ретро-операция (CSV-импорт исторических лидов) между 18:00 и началом следующего дня списывает баланс ниже плана | Допускается, но админ предупреждается в UI «эта операция может вывести клиента в заморозку, продолжить?». Если согласился — выполняется; на следующем cut-off клиент будет в заморозке. Минусовых балансов не возникает (CSV-импорт делает обычные `lead_charges` через `LedgerService`, который остаётся защищён от минуса) |
|
||||
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Префлайт отработает на следующем cut-off |
|
||||
| Клиент уже в минусовом балансе на момент запуска префлайт (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
|
||||
| Тарифная ступень меняется в течение дня (накопился объём) | Префлайт на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
|
||||
| Поставщик прислал меньше планируемого (норма) | Остаток баланса клиента — экономия для следующего дня. Никаких корректировок |
|
||||
| Клиент пополнил после 18:00 | В сегодня-в-21:00-слепок поставщика не успевает, но в личном кабинете тут же «Возобновлено». В следующий вечерний cut-off — в заказ на послезавтра |
|
||||
|
||||
### §3.10 Шеринг с другими клиентами на том же источнике
|
||||
|
||||
**Формула** (живёт в `SupplierQuotaAllocator::computeOrder`, [код](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):
|
||||
|
||||
```
|
||||
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
|
||||
```
|
||||
|
||||
Префлайт **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
|
||||
|
||||
**Эффект зависит от того, кого выкинули:**
|
||||
|
||||
| Кто выкинут | `max(...)` | Заказ у поставщика | Маржа портала |
|
||||
|---|---|---|---|
|
||||
| Крупнейший клиент группы | Падает (новый крупнейший меньше) | **Уменьшается** — реальная экономия закупки | Падает |
|
||||
| Любой некрупный | Не меняется | **Не меняется** | Падает на лимит выкинутого |
|
||||
|
||||
**Гарантии:**
|
||||
|
||||
1. **Никогда не вредит** другим клиентам в группе — их лимиты неприкосновенны.
|
||||
2. **Никогда не заказывает у поставщика на «бедного»** — он исключён из формулы.
|
||||
3. **Реальная экономия закупки** только при выкидывании крупнейшего. В остальных случаях защищаемся от логической ошибки «заказали лиды, оплатить которые некому» без эффекта на закупку.
|
||||
|
||||
### §3.11 Журналирование
|
||||
|
||||
При каждом срабатывании префлайт (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(20) NOT NULL, -- 'frozen' | 'unfrozen' | 'project_overload_dialog'
|
||||
triggered_by VARCHAR(30) NOT NULL, -- 'cutoff_18msk' | 'project_update' | 'topup'
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
leads_capacity INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB, -- какие проекты, какая причина, и т.д.
|
||||
created_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
RLS — `tenant_isolation` стандартный (как для большинства tenant-таблиц). Append-only через `audit_block_mutation` триггер. Цель — видеть в админке историю «когда и почему клиент попадал в заморозку».
|
||||
|
||||
---
|
||||
|
||||
## §4. Решение — часть 2: VTB-эквайринг (3 метода оплаты)
|
||||
|
||||
### §4.1 Архитектурный подход
|
||||
|
||||
**Интерфейс `TopupGatewayInterface`** + **три реализации**:
|
||||
|
||||
| Реализация | Метод оплаты | Статус в этом спеке |
|
||||
|---|---|---|
|
||||
| `BankTransferGateway` | Безнал (счёт PDF) | **Полная реализация** |
|
||||
| `SbpGateway` | СБП | Архитектура + dev-заглушка; реальный код после Б-1 |
|
||||
| `CardGateway` | Карта | Архитектура + dev-заглушка; реальный код после Б-1 |
|
||||
|
||||
`BillingTopupService` рефакторится — становится оркестратором:
|
||||
|
||||
```php
|
||||
public function initiateTopup(
|
||||
int $tenantId,
|
||||
string $amountRub,
|
||||
int $userId,
|
||||
string $method // 'bank_transfer' | 'sbp' | 'card'
|
||||
): TopupSession {
|
||||
$gateway = $this->resolveGateway($method);
|
||||
return $gateway->createSession($tenantId, $amountRub, $userId);
|
||||
}
|
||||
```
|
||||
|
||||
И отдельно обработка callback'а:
|
||||
|
||||
```php
|
||||
public function confirmPayment(string $providerRef, ?int $adminUserId): BalanceTransaction {
|
||||
// gateway-agnostic кредит баланса + запись audit
|
||||
}
|
||||
```
|
||||
|
||||
### §4.2 Состояния платежа
|
||||
|
||||
Новая таблица `topup_sessions`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE topup_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
method VARCHAR(20) NOT NULL, -- 'bank_transfer' | 'sbp' | 'card'
|
||||
amount_rub DECIMAL(12,2) NOT NULL,
|
||||
provider_ref VARCHAR(100), -- номер счёта / VTB transaction ID / SBP QR ID
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending' | 'matched' | 'confirmed' | 'failed' | 'expired'
|
||||
matched_at TIMESTAMP, -- когда нашли соответствие (только bank_transfer)
|
||||
confirmed_at TIMESTAMP, -- когда человек подтвердил (или авто)
|
||||
confirmed_by INTEGER REFERENCES users(id), -- кто подтвердил (NULL = автомат)
|
||||
failed_reason VARCHAR(500),
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT now(),
|
||||
updated_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
RLS — `tenant_isolation` для tenant_id; админ видит все через `crm_app_admin` роль.
|
||||
|
||||
После `status='confirmed'` — кредит баланса (`BalanceTransaction` с `type='topup'` и ссылкой на `topup_session_id`).
|
||||
|
||||
### §4.3 Безнал — полная реализация
|
||||
|
||||
**Шаг 1: Клиент инициирует пополнение**
|
||||
|
||||
В личном кабинете → Биллинг → «Пополнить счёт» → выбор метода «Безнал (счёт)» → ввод суммы → кнопка «Сформировать счёт».
|
||||
|
||||
Backend: `BankTransferGateway::createSession` — создаёт `topup_sessions(method='bank_transfer', status='pending')`, генерирует уникальный номер счёта (`SCH-{YYYY}-{tenant_id}-{seq}`).
|
||||
|
||||
**Шаг 2: Генерация PDF-счёта**
|
||||
|
||||
Шаблон `resources/views/pdf/invoice.blade.php`:
|
||||
|
||||
- Шапка: реквизиты Лидерры (название ООО, ИНН, КПП, юр. адрес, расчётный счёт VTB).
|
||||
- Реквизиты плательщика (тенант): название, ИНН, КПП, юр. адрес — берутся из новых полей `tenants.legal_entity_*` (см. §4.7).
|
||||
- Назначение платежа: «Оплата лидов по договору публичной оферты, счёт №SCH-2026-15-001 от 24.05.2026».
|
||||
- Сумма: NNNN,NN ₽ (БЕЗ НДС или С НДС в зависимости от системы налогообложения Лидерры — `tax_regime` в admin-settings; для УСН 6% — без НДС).
|
||||
- Срок оплаты: 5 рабочих дней.
|
||||
|
||||
Возвращается клиенту как файл скачивания. URL `/api/billing/topup-sessions/{id}/invoice.pdf`.
|
||||
|
||||
**Шаг 3: Авто-поиск платежа в VTB Бизнес API**
|
||||
|
||||
Артизан-команда `billing:vtb-statement-sync --since=N` (cron: каждые 15 минут после Б-1; в dev — manual только):
|
||||
|
||||
- Запрашивает выписку у VTB Бизнес API за период N часов.
|
||||
- Для каждой входящей транзакции ищет в `назначение_платежа` номер счёта (`SCH-YYYY-...`).
|
||||
- При совпадении — `topup_sessions(provider_ref=...).status = 'matched'`, `matched_at = now()`.
|
||||
|
||||
В dev/test (когда нет реальных VTB-реквизитов) — режим симуляции: команда `billing:vtb-statement-simulate {session_id} --amount=N` для ручного тестирования флоу.
|
||||
|
||||
**Шаг 4: Подтверждение человеком или автомат (admin setting)**
|
||||
|
||||
В админке `Биллинг → Настройки` — переключатель:
|
||||
|
||||
- **«С подтверждением» (по умолчанию):** платёж в статусе `matched` ждёт ручного клика «Подтвердить зачисление». До этого баланс не растёт.
|
||||
- **«Автомат»:** платёж в статусе `matched` сразу переводится в `confirmed`, баланс растёт.
|
||||
|
||||
В админке `Биллинг → Ожидающие платежи` — список платежей в статусах `matched` и `pending`:
|
||||
|
||||
```
|
||||
[SCH-2026-15-001] ООО Альфа, 100000₽, найден 24.05 15:42 [Подтвердить] [Отклонить]
|
||||
[SCH-2026-22-003] ИП Иванов, 50000₽, не найден (3 дня) [Найти вручную] [Отменить]
|
||||
```
|
||||
|
||||
Кнопка «Подтвердить» → `confirmPayment(...)` → кредит баланса + `BalanceTransaction(type='topup', topup_session_id=...)`.
|
||||
|
||||
**Шаг 5: Часовые email-алерты админу (только в режиме «С подтверждением»)**
|
||||
|
||||
Cron `billing:notify-pending-confirmations` каждый час:
|
||||
|
||||
- Если есть платежи в статусе `matched`, не подтверждённые — отправить email админу (`admin_emails` из settings, по умолчанию `eclips9363@gmail.com`).
|
||||
- Throttle: один email в час суммарно.
|
||||
|
||||
### §4.4 СБП — архитектура + dev-заглушка
|
||||
|
||||
**Реализация в этом спеке:**
|
||||
|
||||
- `SbpGateway::createSession` — в dev/test возвращает фейковый QR-код PNG (`data:image/png;base64,...`) и через 5 секунд (фоновый job) переводит сессию в `confirmed`. Это позволяет полностью отлаживать UI и пост-обработку.
|
||||
- В prod (после Б-1): зовёт реальный VTB SBP API, получает QR-код / платёжную ссылку, регистрирует callback на `/api/billing/vtb-sbp/callback`.
|
||||
|
||||
**Реализация после Б-1 (отдельная задача):**
|
||||
|
||||
- Боевая интеграция VTB SBP API (REST + signed callbacks).
|
||||
- ОФД-Атол для 54-ФЗ чеков (см. §4.6).
|
||||
- Боевые секреты — в YC Lockbox (SEC-5).
|
||||
|
||||
### §4.5 Карты — архитектура + dev-заглушка
|
||||
|
||||
**Реализация в этом спеке:**
|
||||
|
||||
- `CardGateway::createSession` — в dev/test возвращает редирект-URL на локальную страницу `/dev-mock-vtb-acquiring/{session_id}` с двумя кнопками «Симулировать успех» / «Симулировать ошибку». Клик → переводит сессию в `confirmed` или `failed`.
|
||||
- В prod (после Б-1): редирект на боевую страницу VTB internet-эквайринга с 3DS Secure.
|
||||
|
||||
**Реализация после Б-1 (отдельная задача):**
|
||||
|
||||
- Боевая интеграция VTB Acquiring API.
|
||||
- 3DS Secure (обязательно для всех карт).
|
||||
- Обработка chargeback'ов.
|
||||
- ОФД-Атол для 54-ФЗ чеков.
|
||||
|
||||
### §4.6 54-ФЗ фискализация
|
||||
|
||||
**Текущее состояние:** не реализовано. Раздел подсветить в архитектуре, реальный код — после Б-1 параллельно с СБП.
|
||||
|
||||
**Когда нужен чек:**
|
||||
|
||||
| Метод оплаты | Чек 54-ФЗ |
|
||||
|---|---|
|
||||
| Безнал (юр→юр) | **Не нужен** (статья 1.2 п.9 закона) |
|
||||
| СБП | **Обязателен** (B2C ритейл-платёж) |
|
||||
| Карта | **Обязателен** (B2C ритейл-платёж) |
|
||||
|
||||
**Архитектура** (заглушка в этом спеке):
|
||||
|
||||
```php
|
||||
interface FiscalReceiptProvider {
|
||||
public function issueReceipt(TopupSession $session): FiscalReceipt;
|
||||
}
|
||||
```
|
||||
|
||||
Реализации:
|
||||
|
||||
- `AtolOnlineFiscalProvider` — реальная интеграция (после Б-1).
|
||||
- `NoOpFiscalProvider` — для безнала (возвращает «чек не требуется по 54-ФЗ»).
|
||||
- `DevMockFiscalProvider` — для dev/test (фейковый чек ID).
|
||||
|
||||
### §4.7 Реквизиты тенанта
|
||||
|
||||
Новые поля в `tenants` (для счёта-фактуры):
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_name VARCHAR(255); -- "ООО Альфа" или "ИП Иванов Иван"
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_inn VARCHAR(12);
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_kpp VARCHAR(9); -- NULL для ИП
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_address TEXT;
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP' | 'OAO' | 'ZAO' | 'OTHER'
|
||||
```
|
||||
|
||||
В личном кабинете клиента → Настройки → «Реквизиты юр. лица» — форма для заполнения. **Обязательны** для безнала (без них счёт PDF не выписывается); для СБП/карт — опционально (но желательно).
|
||||
|
||||
Валидация ИНН (контрольное число), КПП (формат), форма юрлица — стандартными правилами laravel.
|
||||
|
||||
### §4.8 Минимум/максимум суммы пополнения
|
||||
|
||||
| Метод | Минимум | Максимум |
|
||||
|---|---|---|
|
||||
| Безнал | 1000 ₽ | 1 000 000 ₽ |
|
||||
| СБП | 100 ₽ | 600 000 ₽ (лимит СБП) |
|
||||
| Карта | 100 ₽ | 100 000 ₽ за одну транзакцию |
|
||||
|
||||
Конфигурация в `config/billing.php` (settable).
|
||||
|
||||
---
|
||||
|
||||
## §5. Архитектура изменений
|
||||
|
||||
### §5.1 Карта изменений по слоям
|
||||
|
||||
| Слой | Что |
|
||||
|---|---|
|
||||
| **БД** | `tenants` (+`frozen_by_balance_at`, +5 `legal_entity_*` полей); новые таблицы `balance_freeze_log`, `topup_sessions` |
|
||||
| **Бэк-сервисы** | `SupplierQuotaAllocator` (новый pre-filter pipeline); `BalancePreflightService` (новый, проверка платёжеспособности); `BillingTopupService` (рефакторинг под gateway pattern); `TopupGatewayInterface` + 3 реализации; `FiscalReceiptProvider` + 3 реализации |
|
||||
| **Бэк-джобы** | `BalancePreflightSweepJob` (cron @18:00 MSK ежедневно); `BalanceFrozenNotificationJob` (event-driven при заморозке); `VtbStatementSyncJob` (cron @ каждые 15 мин); `NotifyPendingConfirmationsJob` (cron hourly) |
|
||||
| **Бэк-команды** | `billing:preflight-sweep`, `billing:vtb-statement-sync`, `billing:notify-pending-confirmations`, `billing:preflight-initial-sweep` (one-time migration), `billing:vtb-statement-simulate` (dev only) |
|
||||
| **Бэк-контроллеры** | `BillingController` (+endpoints: `/api/billing/topup/initiate`, `/api/billing/topup-sessions/{id}`, `/api/billing/topup-sessions/{id}/invoice.pdf`); `ProjectController` (preflight check + 409 response с диалогом); `Admin/PendingTopupsController` (новый); `Admin/BillingSettingsController` (новый, для переключателя auto/manual) |
|
||||
| **Бэк-Mail** | `BalanceFrozenMail`, `BalanceFrozenReminderMail`, `BalanceFrozenFinalMail`, `BalanceUnfrozenMail`, `PendingConfirmationsAdminMail` |
|
||||
| **Фронт-компоненты** | `BalanceFrozenBanner.vue`, `BalanceCapacityIndicator.vue`, `ProjectLimitOverloadDialog.vue`, `TopupMethodPicker.vue`, `BankTransferInvoiceView.vue`, `SbpQrCodeView.vue`, `CardRedirectView.vue`, `PendingPaymentsAdminView.vue`, `BillingSettingsAdminView.vue`, `LegalEntityForm.vue` (в Настройки) |
|
||||
| **Фронт-views** | `TopupView.vue` (новый, обёртка с переключателем methods); `BillingFrozenInfoView.vue` |
|
||||
| **Pinia** | `billingStore` (расширение под topup-сессии); `tenantStore` (frozen-флаг) |
|
||||
|
||||
### §5.2 Sequence-диаграмма префлайт на cut-off
|
||||
|
||||
```
|
||||
18:00 MSK Cron
|
||||
│
|
||||
├── BalancePreflightSweepJob::handle()
|
||||
│ │
|
||||
│ ├── для каждого tenant:
|
||||
│ │ │
|
||||
│ │ ├── BalancePreflightService::evaluate(tenant)
|
||||
│ │ │ │
|
||||
│ │ │ ├── читает projects (active=true, eligible_tomorrow=true)
|
||||
│ │ │ ├── требуемые лиды = Σ daily_limit
|
||||
│ │ │ ├── ёмкость лидов = BalanceToLeadsConverter::convert(balance, delivered, tiers)['leads']
|
||||
│ │ │ ├── сравнивает: passes = capacity >= required
|
||||
│ │ │ └── возвращает PreflightResult { passes: bool, required_leads, capacity_leads, deficit_leads }
|
||||
│ │ │
|
||||
│ │ ├── если passes изменился:
|
||||
│ │ │ │
|
||||
│ │ │ ├── tenant.frozen_by_balance_at = NULL | now()
|
||||
│ │ │ ├── balance_freeze_log.insert(event_type='frozen' | 'unfrozen')
|
||||
│ │ │ ├── dispatch(BalanceFrozenMail | BalanceUnfrozenMail)
|
||||
│ │ │ │
|
||||
│ │ │ └── (если frozen) для каждого projects:
|
||||
│ │ │ projects.preflight_blocked_at = now() (новая колонка)
|
||||
│ │ │
|
||||
│ │ └── (если unfrozen) projects.preflight_blocked_at = NULL для всех
|
||||
│ │
|
||||
│ └── для следующего tenant...
|
||||
│
|
||||
18:05 MSK (после префлайт) — обычный SyncSupplierProjectsJob запускается
|
||||
│
|
||||
├── SupplierQuotaAllocator::allocate(eligible_projects)
|
||||
│ │
|
||||
│ ├── фильтр eligible_projects: only WHERE preflight_blocked_at IS NULL
|
||||
│ ├── computeOrder([daily_limit, ...]) (формула не меняется)
|
||||
│ └── distributeForPlatform(order, [B1, B2, B3])
|
||||
│
|
||||
└── SyncSupplierProjectJob (per project) → отправка лимитов поставщику
|
||||
```
|
||||
|
||||
### §5.3 Sequence-диаграмма пополнения (Безнал)
|
||||
|
||||
```
|
||||
Клиент в личном кабинете
|
||||
│
|
||||
├── Кнопка "Пополнить счёт"
|
||||
│
|
||||
└── TopupMethodPicker → выбор "Безнал (счёт)"
|
||||
│
|
||||
└── Ввод суммы → "Сформировать счёт"
|
||||
│
|
||||
└── POST /api/billing/topup/initiate { method: 'bank_transfer', amount_rub: '100000.00' }
|
||||
│
|
||||
├── BillingTopupService::initiateTopup(...)
|
||||
│ │
|
||||
│ └── BankTransferGateway::createSession(...)
|
||||
│ │
|
||||
│ ├── создание topup_sessions(status='pending', provider_ref='SCH-2026-15-001')
|
||||
│ └── возвращает { session_id, invoice_url: '/api/billing/topup-sessions/15001/invoice.pdf' }
|
||||
│
|
||||
└── Редирект клиента на invoice_url → скачивание PDF
|
||||
|
||||
Клиент оплачивает в своём банк-клиенте
|
||||
│
|
||||
└── Деньги поступают на расчётный счёт VTB Лидерры (за ~часы)
|
||||
|
||||
VtbStatementSyncJob (каждые 15 минут)
|
||||
│
|
||||
├── VTB Business API → выписка
|
||||
│
|
||||
└── для каждой входящей транзакции:
|
||||
│
|
||||
├── парсинг назначения платежа → ищем SCH-YYYY-tenant-seq
|
||||
│
|
||||
└── если совпадение найдено + сумма совпадает:
|
||||
│
|
||||
├── topup_sessions.status = 'matched'
|
||||
├── matched_at = now()
|
||||
│
|
||||
└── (если admin-setting auto_confirm=true):
|
||||
│
|
||||
├── BillingTopupService::confirmPayment(provider_ref, NULL)
|
||||
│ │
|
||||
│ ├── topup_sessions.status = 'confirmed'
|
||||
│ ├── tenant.balance_rub += amount_rub (bcadd)
|
||||
│ └── BalanceTransaction.create(type='topup', topup_session_id=...)
|
||||
│
|
||||
└── (если auto_confirm=false): ждёт ручного подтверждения
|
||||
|
||||
NotifyPendingConfirmationsJob (каждый час)
|
||||
│
|
||||
└── если есть topup_sessions(status='matched') не подтверждённые:
|
||||
│
|
||||
└── PendingConfirmationsAdminMail → admin email
|
||||
|
||||
Админ в кабинете
|
||||
│
|
||||
└── Биллинг → Ожидающие платежи → [Подтвердить SCH-2026-15-001]
|
||||
│
|
||||
└── POST /api/admin/topup-sessions/15001/confirm
|
||||
│
|
||||
└── BillingTopupService::confirmPayment(provider_ref, admin_user_id)
|
||||
│
|
||||
├── topup_sessions.status = 'confirmed'
|
||||
├── tenant.balance_rub += amount_rub
|
||||
├── BalanceTransaction.create(type='topup', confirmed_by=admin_user_id)
|
||||
│
|
||||
└── BalancePreflightService::evaluate(tenant) ← может разморозить!
|
||||
│
|
||||
└── если frozen_by_balance_at был NOT NULL и теперь passes:
|
||||
│
|
||||
├── tenant.frozen_by_balance_at = NULL
|
||||
├── balance_freeze_log.insert(event_type='unfrozen', triggered_by='topup')
|
||||
└── BalanceUnfrozenMail отправляется
|
||||
```
|
||||
|
||||
### §5.4 Изменения в `SupplierQuotaAllocator`
|
||||
|
||||
Минимальное и неинвазивное — добавление одного шага в caller (`SyncSupplierProjectsJob`):
|
||||
|
||||
**До:**
|
||||
|
||||
```php
|
||||
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);
|
||||
$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
```
|
||||
|
||||
**После:**
|
||||
|
||||
```php
|
||||
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);
|
||||
|
||||
// NEW: фильтр по frozen-флагу tenant
|
||||
$eligibleProjects = $eligibleProjects->reject(
|
||||
fn($p) => $p->tenant->frozen_by_balance_at !== null
|
||||
);
|
||||
|
||||
$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
```
|
||||
|
||||
`SupplierQuotaAllocator` сам не меняется — он pure-функция, принимает то что дали. Это важно для тестируемости (формула не сломалась).
|
||||
|
||||
---
|
||||
|
||||
## §6. Сценарии (end-to-end)
|
||||
|
||||
### §6.1 Префлайт — пассивный износ
|
||||
|
||||
**Воскресенье 00:00.** Клиент с балансом 1000₽ = 30 лидов (tier 3, цена ~33₽/лид). Проекты заказывают 25/день. Запас 5.
|
||||
|
||||
**Воскресенье 18:00.** Cron `BalancePreflightSweepJob`:
|
||||
|
||||
- `BalancePreflightService::evaluate(client)` → `passes=true` (хватает на 25).
|
||||
- `frozen_by_balance_at` остаётся `NULL`.
|
||||
|
||||
**Воскресенье 18:05.** `SyncSupplierProjectsJob` — все 25 идут в заказ поставщику. Поставщик в 21:00 берёт слепок.
|
||||
|
||||
**Понедельник.** В течение дня партии лидов приходят, клиент получает все 25, баланс падает до 0. Никаких внутридневных стопов.
|
||||
|
||||
**Понедельник 18:00.** Cron snova:
|
||||
|
||||
- `evaluate(client)` → `passes=false` (0₽ ≠ 25 лидов).
|
||||
- `frozen_by_balance_at = now()`.
|
||||
- `balance_freeze_log.insert(event='frozen', triggered_by='cutoff_18msk')`.
|
||||
- `BalanceFrozenMail` отправляется.
|
||||
|
||||
**Понедельник 18:05.** `SyncSupplierProjectsJob` — этот клиент исключён, формула пересчитывается для остальных в группе на источнике.
|
||||
|
||||
**Понедельник 19:00.** Клиент видит письмо, пополняет на 1000₽ через безнал PDF → 1-2 дня ждёт зачисления через сверку. Или через СБП/карту (после Б-1) — мгновенно.
|
||||
|
||||
**Вторник 14:00.** Безнал подтверждён админом (или авто) → баланс 1000₽. `confirmPayment` зовёт `evaluate` → `passes=true`. `frozen_by_balance_at = NULL`. `BalanceUnfrozenMail`. В личном кабинете баннер исчезает.
|
||||
|
||||
**Вторник 18:00.** Cron snova — клиент в заказе на среду. Со среды получает лиды.
|
||||
|
||||
### §6.2 Префлайт — активная нехватка при создании проекта
|
||||
|
||||
**Клиент с балансом 1000₽ = 30 лидов.** Имеет 3 проекта по 10 = 30 лимит. Всё впритык.
|
||||
|
||||
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-префлайт:
|
||||
|
||||
- `Σ daily_limit (после сохранения) = 50` лидов
|
||||
- `capacity = BalanceToLeadsConverter::convert(1000₽, delivered, tiers)['leads'] = 30` лидов
|
||||
- `30 < 50` → недостаток 20 лидов
|
||||
|
||||
Возвращает HTTP 409 с body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "balance_insufficient",
|
||||
"current_balance_rub": "1000.00",
|
||||
"current_capacity_leads": 30,
|
||||
"would_be_required_leads": 50,
|
||||
"deficit_leads": 20
|
||||
}
|
||||
```
|
||||
|
||||
Фронт показывает диалог `ProjectLimitOverloadDialog.vue`:
|
||||
|
||||
```
|
||||
Этот лимит превышает баланс.
|
||||
У тебя 1000₽ = 30 лидов по текущему тарифу.
|
||||
После сохранения нужно 50 лидов.
|
||||
Не хватает: 20 лидов.
|
||||
|
||||
Чтобы он начал работать, нужно одно из:
|
||||
• Пополнить счёт (примерно 700₽ покроют 20 лидов недостачи)
|
||||
• Поставить лимит этого проекта 0
|
||||
• Уменьшить лимиты других проектов
|
||||
|
||||
[Сохранить и приостановить этот] [Поставить лимит 0] [Отмена]
|
||||
```
|
||||
|
||||
«Примерно 700₽» — оценка через обратное преобразование: добавляем баланс шагами по 100₽ и смотрим, при какой сумме `capacity` вырастет до 50 (фронт делает это локально, не дёргая бэк).
|
||||
|
||||
- Если «Сохранить и приостановить» → POST `/api/projects` с флагом `force_save_blocked=true`. Создаётся проект с `preflight_blocked_at = now()`. Остальные 3 проекта работают.
|
||||
- Если «Поставить лимит 0» → POST `/api/projects` с `daily_limit = 0`. Создаётся, не идёт в заказ.
|
||||
- Если «Отмена» → форма закрывается, ничего не сохраняется.
|
||||
|
||||
### §6.3 VTB — Безнал end-to-end (после Б-1)
|
||||
|
||||
1. Клиент → Биллинг → «Пополнить 100000₽» → выбор «Безнал».
|
||||
2. Backend создаёт `topup_sessions(status='pending', provider_ref='SCH-2026-15-001')`.
|
||||
3. Скачивает PDF-счёт с реквизитами Лидерры (ИНН, КПП, р/с) и своими реквизитами.
|
||||
4. Идёт в свой банк-клиент, оплачивает.
|
||||
5. Деньги в течение нескольких часов приходят на р/с Лидерры в VTB.
|
||||
6. `VtbStatementSyncJob` за 15 минут после прихода находит транзакцию с «SCH-2026-15-001» в назначении, ставит `status='matched'`.
|
||||
7. Часовой `NotifyPendingConfirmationsJob` шлёт админу email «есть 1 непровер. платёж».
|
||||
8. Админ в админке → Биллинг → Ожидающие платежи → видит запись → «Подтвердить» → 100000₽ кредитуется, `BalanceTransaction` пишется.
|
||||
9. Если клиент был в `frozen_by_balance_at` — `evaluate` пересчитывает → разморозка → `BalanceUnfrozenMail`.
|
||||
|
||||
В dev-режиме шаг 6 заменяется на `billing:vtb-statement-simulate 15001 --amount=100000` (manual).
|
||||
|
||||
---
|
||||
|
||||
## §7. Известные открытые вопросы
|
||||
|
||||
1. **Учёт перехода ступени за день.** `BalanceToLeadsConverter::convert` уже учитывает «текущую ступень и переход на следующие при росте объёма». Граничный случай «дневной заказ переваливает за порог ступени, после чего цена меняется» автоматически обрабатывается — `convert` итерирует по ступеням и считает разные цены для разных кусков. **Не нужна дополнительная логика.**
|
||||
2. **Что если клиент имеет несколько менеджеров с email?** Письмо `BalanceFrozenMail` — на главный email тенанта (`tenant.email`) или на всех users этого тенанта? **Решение по умолчанию:** на главный email тенанта (как сейчас в `ZeroBalancePausedMail`).
|
||||
3. **Формат номера счёта** `SCH-YYYY-{tenant_id}-{seq}` — гарантирована уникальность через `(tenant_id, seq)` сериал. Если переходим на ОФД-Атол, может потребоваться другой формат. **Решение:** сейчас наш формат, при подключении ОФД — модернизируем (отдельная задача).
|
||||
4. **Локализация PDF-счёта** — пока только русский. После Б-1 при необходимости — английский / казахский (если расширим географию).
|
||||
5. **Reconcilation между `topup_sessions` и `balance_transactions`** — есть ли инвариант «каждая completed-сессия = одна balance_transaction»? Да, должен быть — добавить foreign key + unique constraint.
|
||||
|
||||
---
|
||||
|
||||
## §8. Future enhancements (Спек D и далее)
|
||||
|
||||
1. **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** (см. §3.10) — бизнес: разморозившийся в 15:00 клиент мог бы получить лиды, поступающие после 15:00 по его источнику, если есть свободные слоты шеринга. Технически — модификация `RouteSupplierLeadJob` + расширение eligible-кандидатов после mid-day events. Реальная частота сценария = низкая; делать после месяца сбора статистики.
|
||||
2. **«Приоритет шеринга 4+ клиентов — те, у кого хватало баланса на момент cut-off»** — снимок «утренней платёжеспособности» сохраняется, при шеринге сверяется. Полезно при переполненном шеринге (>3 покупателей конкурируют за лид).
|
||||
3. **Авто-сверка с VTB Бизнес API** — реальная интеграция (не заглушка), боевой `VtbStatementSyncJob` с retry и подписью.
|
||||
4. **Recurring-платежи** — автосписание с карты раз в месяц (отдельная фича, требует UX-проработки).
|
||||
5. **Возвраты** (refund) — если бизнес-нужда подтвердится. Сейчас Спек A явно говорит «не делаем».
|
||||
6. **Мульти-валютность** — если выходим на казахский / белорусский рынок (KZT/BYN).
|
||||
|
||||
---
|
||||
|
||||
## §9. Связи
|
||||
|
||||
- [Спек A](2026-05-23-billing-v2-spec-a-balance-rub-design.md) — единый ₽-баланс, `BalanceToLeadsConverter` (используется в этом спеке).
|
||||
- [Спек B](2026-05-23-billing-v2-spec-b-duplicates-design.md) — `supplier_lead_deliveries` lock-таблица, шеринг до 3 клиентов на лид (контекст для §3.10).
|
||||
- [App allocator](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php) — формула заказа, остаётся без изменений.
|
||||
- [App ledger](../../../app/app/Services/Billing/LedgerService.php) — списания с баланса, остаётся без изменений.
|
||||
- [App topup](../../../app/app/Services/Billing/BillingTopupService.php) — рефакторится в этом спеке.
|
||||
- [App webhook routing](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php) — добавляется фильтр frozen-проектов.
|
||||
- [App project controller](../../../app/app/Http/Controllers/Api/ProjectController.php) — добавляется preflight check.
|
||||
- [Pravila §13.2](../../../docs/Pravila_raboty_Claude_v1_1.md) — финансовая нормативка.
|
||||
- [CLAUDE.md §6](../../../CLAUDE.md) — текущая фаза.
|
||||
- [project_billing_v2 memory](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_billing_v2.md) — серия из 3 спеков.
|
||||
- [SEC-3, SEC-5 (memory)](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_server_hardening.md) — блокеры YC Lockbox для секретов VTB.
|
||||
|
||||
---
|
||||
|
||||
## §10. План реализации (overview)
|
||||
|
||||
Детальный план — отдельным документом через `superpowers:writing-plans` после утверждения этого спека.
|
||||
|
||||
Предварительная разбивка на фазы (для оценки масштаба):
|
||||
|
||||
**Phase 1 — Префлайт баланса** (~5-7 задач):
|
||||
|
||||
- Миграция БД (`frozen_by_balance_at`, `preflight_blocked_at`, `balance_freeze_log`).
|
||||
- `BalancePreflightService` (pure) + тесты.
|
||||
- `BalancePreflightSweepJob` + cron.
|
||||
- 4 Mailable + throttle.
|
||||
- `BalanceFrozenBanner.vue` + `BalanceCapacityIndicator.vue`.
|
||||
- `ProjectController` preflight check + 409 response.
|
||||
- `ProjectLimitOverloadDialog.vue`.
|
||||
- Фильтр в `SyncSupplierProjectsJob`.
|
||||
- One-time `billing:preflight-initial-sweep`.
|
||||
|
||||
**Phase 2 — Безнал PDF + админка** (~6-8 задач):
|
||||
|
||||
- Миграция БД (`legal_entity_*` в `tenants`, `topup_sessions`).
|
||||
- `TopupGatewayInterface` + `BankTransferGateway`.
|
||||
- PDF-генерация (`resources/views/pdf/invoice.blade.php`).
|
||||
- `LegalEntityForm.vue` + валидация.
|
||||
- `TopupView.vue` + `TopupMethodPicker.vue` + `BankTransferInvoiceView.vue`.
|
||||
- `Admin/PendingTopupsController` + `PendingPaymentsAdminView.vue`.
|
||||
- `Admin/BillingSettingsController` (auto/manual переключатель).
|
||||
- `VtbStatementSyncJob` (с dev-симулятором).
|
||||
- `NotifyPendingConfirmationsJob`.
|
||||
|
||||
**Phase 3 — СБП и Карты dev-заглушки** (~4-5 задач):
|
||||
|
||||
- `SbpGateway` (dev режим) + `SbpQrCodeView.vue`.
|
||||
- `CardGateway` (dev режим) + `/dev-mock-vtb-acquiring/...` страница.
|
||||
- `FiscalReceiptProvider` interface + `NoOpFiscalProvider` + `DevMockFiscalProvider`.
|
||||
|
||||
**Phase 4 — Тесты + smoke** (~3-4 задач):
|
||||
|
||||
- Pest end-to-end сценарии префлайт (frozen/unfrozen flow).
|
||||
- Pest end-to-end сценарии безнала (создание счёта → симуляция выписки → подтверждение).
|
||||
- Vitest на новые Vue-компоненты.
|
||||
- Регрессия (Pest --parallel + Vitest + lychee + gitleaks).
|
||||
|
||||
**Phase 5 — Документация + memory + ПИЛОТ.md** (~2 задач):
|
||||
|
||||
- Обновление `project_billing_v2.md` (Спек C статус, известные хвосты).
|
||||
- ADR (если требуется новый — `docs/adr/016-preflight-vtb-architecture.md`).
|
||||
- Обновление `ПИЛОТ.md` после выкатки на прод.
|
||||
|
||||
**Out of scope этого плана (post-Б-1, отдельные планы):**
|
||||
|
||||
- Боевая интеграция VTB Acquiring (карты).
|
||||
- Боевая интеграция VTB SBP API.
|
||||
- Боевая интеграция VTB Бизнес API (авто-сверка).
|
||||
- Боевая интеграция ОФД-Атол (фискализация).
|
||||
- Боевые секреты в YC Lockbox.
|
||||
|
||||
---
|
||||
|
||||
**Конец Спека C.**
|
||||
@@ -0,0 +1,265 @@
|
||||
# Спек: два специализированных ИИ-помощника для разгрузки главного исполнителя
|
||||
|
||||
**Версия:** v1.0 от 24.05.2026
|
||||
**Статус:** Draft — pre-implementation
|
||||
**Источник:** brainstorming-сессия 24.05.2026 (memory: MEMORY.md + CLAUDE.md §6 + push-история 16-24.05)
|
||||
**Связано:** Pravila §15 (параллельные сессии), §16 (brain governance), CLAUDE.md §3.6, project-скилы `pest-parallel-debugger` / `rls-reviewer` (прецеденты узко-специализированных проектных агентов)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
За 8 дней (16-24.05.2026) главный исполнитель (Opus 4.7) совершил повторяющиеся операции двух типов:
|
||||
|
||||
1. **Синк 4 нормативных файлов** (Pravila / PSR_v1 / Tooling / CLAUDE.md) — 26 эпизодов (v2.3→v2.27). После каждой задачи приходится править шапки, cross-refs §0, footer-счётчики, §9-changelog в 4 местах. Низкая когнитивная нагрузка, высокая частота, error-prone (известны 2 эпизода версионной коллизии параллельных веток — A11 v2.10 и discovery v2.13).
|
||||
|
||||
2. **Pre-flight перед выкатом на боевой liderra.ru** — ~10 выкатов с 22.05 по 24.05. 24.05 в 03:46 UTC случился живой 18-минутный инцидент (портал лёг полностью) из-за пропущенной проверки `bootstrap/cache/config.php` под `www-data` (квирк 107). Корень — pre-flight чек-лист в голове, не формализован.
|
||||
|
||||
Оба класса — идеальные кандидаты на отдельных ИИ-помощников: ограниченный вход/выход, повторяющийся, требует понимать смысл (не просто regex), но не творчества.
|
||||
|
||||
---
|
||||
|
||||
## 2. Цели и измеримые KPI
|
||||
|
||||
| Метрика | Цель |
|
||||
|---------|------|
|
||||
| Сокращение токенов главного исполнителя на нормативный синк | -90% (с ~70К до ~5К на эпизод) |
|
||||
| Сокращение токенов главного исполнителя на pre-flight выката | -85% (с ~25К до ~3К на эпизод) |
|
||||
| Доля автоматизированных синков от общего числа | ≥80% (некоторые требуют ручного — major-bump решения, новые off-phase подкатегории) |
|
||||
| Live-инциденты из-за пропущенных pre-flight проверок | 0 за квартал (текущий baseline: 1 инцидент за 3 дня) |
|
||||
| Цена операции (USD) | синк: $0.30/эпизод (было $0.60); выкат: $0.20/эпизод (было $0.35) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Агент №1: `normative-sync`
|
||||
|
||||
### 3.1. Назначение
|
||||
|
||||
Применить нормативный синк 4 файлов (Pravila / PSR_v1 / Tooling / CLAUDE.md) после завершённой задачи, на основе brief'а главного исполнителя или git-diff.
|
||||
|
||||
### 3.2. Когда зовётся
|
||||
|
||||
- После завершения off-phase tooling integration (~3-5/неделю)
|
||||
- После выпуска brain governance артефактов (схема v2/v3, новый контролёр, новый skill)
|
||||
- После принятого ADR
|
||||
- После любого эпизода, который меняет cross-ref пространство нормативки
|
||||
|
||||
Главный исполнитель решает зовёт или нет — агент не активируется автоматически.
|
||||
|
||||
### 3.3. Входной brief (минимум)
|
||||
|
||||
- Тема эпизода в одной строчке (например: «закрыли C1 marketing — 10 новых узлов #74-83, 18-я off-phase подкатегория marketing-tooling»)
|
||||
- Опционально: явные version-bump targets (если minor/major решение нетривиальное)
|
||||
- Опционально: ADR-номер если есть
|
||||
|
||||
### 3.4. Процедура (10 шагов)
|
||||
|
||||
1. **Pre-flight per Pravila §15.2:** `git fetch && git log HEAD..origin/main --oneline` — если есть unpushed коммиты от параллельной сессии в 8-файловом списке, остановиться и эскалировать.
|
||||
2. **Контекст:** прочитать `git diff HEAD~N..HEAD --stat` чтобы понять scope.
|
||||
3. **Чтение нормативки:** Pravila / PSR_v1 / Tooling / CLAUDE.md (только релевантные секции — §0, §9 / §10 history, footer-счётчики).
|
||||
4. **Вычисление новых версий** по правилам:
|
||||
- minor (+0.01): добавили узел / методический параграф / cross-ref / запись в §9
|
||||
- major (+1.0): убрали правило / архитектурная инверсия / снят hard-rule
|
||||
5. **Шапки:** обновить дату + версию в шапках 4 файлов.
|
||||
6. **§0 cross-refs (CLAUDE.md):** обновить строки таблицы версий Pravila/PSR/Tooling до новых номеров.
|
||||
7. **Footer-счётчики (если добавился узел):** Tooling §0 (КАНОН СЧЁТЧИКОВ) + CLAUDE.md §3.3 footer + §1 row 2b + §3 title. PSR_v1 R10.1 если добавился plugin/skill.
|
||||
8. **§9 / История версий записи:** один абзац в каждом из 4 файлов по шаблону «vX.Y от ДД.ММ — тема. Изменения: ... Связано: ...».
|
||||
9. **lefthook cross-ref-checker (C2):** `lefthook run cross-ref-checker || npx lefthook run cross-ref-checker` — если красный, итерация.
|
||||
10. **Выход:** не коммитит. Отдаёт diff + краткий рапорт «синк готов, версии bumped X→Y, cross-refs verified, lefthook C2 green, добавил §9 в 4 файлах».
|
||||
|
||||
### 3.5. Модель и инструменты
|
||||
|
||||
- **Модель:** Sonnet 4.6 (Pravila §15.1 — для git-операций обязательно Sonnet/Opus, не Haiku)
|
||||
- **Tools:** Read, Edit, Grep, Glob, Bash, TodoWrite
|
||||
- **Skills:**
|
||||
- `superpowers:verification-before-completion` — перед итоговым рапортом убедиться что cross-ref-checker зелёный
|
||||
|
||||
### 3.6. Что зашиваем в system prompt
|
||||
|
||||
- Структура 4 файлов (где шапка / §0 cross-refs / footer-счётчики / §9-changelog)
|
||||
- Правила version-bump (minor vs major)
|
||||
- Pravila §15.2 8-файловый список + pre-flight протокол
|
||||
- Pravila §5 п.10 worktree-эксцепшн (когда прямой Edit, когда `claude-md-management:claude-md-improver`)
|
||||
- Tooling §0 «КАНОН СЧЁТЧИКОВ» (отсюда и только отсюда числа)
|
||||
- Шаблон §9-записи (дата, тема, изменения, cross-refs, канал, прецедент)
|
||||
- Стиль changelog (примеры — последние 5 записей из CLAUDE.md §9)
|
||||
|
||||
### 3.7. Границы (out of scope)
|
||||
|
||||
- Не правит код, миграции, схему БД
|
||||
- Не пишет ADR (только цитирует уже принятый)
|
||||
- Не правит саму карту `docs/automation-graph.html` (если в эпизоде менялась карта — это отдельная задача главного исполнителя)
|
||||
- Не коммитит, не пушит — только готовит diff
|
||||
- Не принимает решения о major bump — если не уверен, эскалирует на главного исполнителя
|
||||
|
||||
### 3.8. Риски и митигации
|
||||
|
||||
| Риск | Митигация |
|
||||
|------|-----------|
|
||||
| Версионная коллизия с параллельной веткой | Pre-flight §15.2 на шаге 1 — STOP при unpushed коммитах от других |
|
||||
| Drift cross-ref (4 файла указывают друг на друга со старыми версиями) | lefthook cross-ref-checker (C2) на шаге 9 |
|
||||
| Неправильный bump (minor вместо major) | Решение minor по умолчанию; major только при явном указании в brief'е или при удалении правила |
|
||||
| Пропуск footer-счётчика | Чек-лист TodoWrite внутри агента (8 пунктов структурных правок) |
|
||||
| Самовольное добавление «improvements» в несвязанные секции | Жёсткое scope-правило в system prompt: «правишь только шапки, §0, footer, §9; всё остальное — STOP» |
|
||||
|
||||
### 3.9. Что отдаёт
|
||||
|
||||
- 4 изменённых файла в рабочем дереве (uncommitted)
|
||||
- Рапорт ~10 строк: «синк завершён, версии X→Y, cross-refs verified, lefthook C2 green, добавлены §9 в [файлы], затронуто [N] секций»
|
||||
- Список любых эскалаций («нужен ручной выбор major/minor», «обнаружена коллизия с веткой Y», «cross-ref-checker красный после 3 итераций»)
|
||||
|
||||
---
|
||||
|
||||
## 4. Агент №2: `prod-deploy-validator`
|
||||
|
||||
### 4.1. Назначение
|
||||
|
||||
Pre-flight checklist перед выкатом на боевой `liderra.ru`. Возвращает вердикт `GO / NO-GO` с конкретной причиной.
|
||||
|
||||
### 4.2. Когда зовётся
|
||||
|
||||
Перед каждым выкатом — главный исполнитель просит «проверь готовность боевого». ~5-7 раз в неделю в активные периоды.
|
||||
|
||||
### 4.3. Входной brief (минимум)
|
||||
|
||||
- Что планируется выкатить (commit hash или короткое описание)
|
||||
- Опционально: явный список файлов в патче (если выкат через scp, не через git)
|
||||
|
||||
### 4.4. 8 проверок
|
||||
|
||||
| # | Проверка | Команда | Зелёный = | Красный = |
|
||||
|---|----------|---------|-----------|-----------|
|
||||
| **П1** | `bootstrap/cache/config.php` владельца | `ssh liderra "stat -c '%U %Y' app/bootstrap/cache/config.php; stat -c '%Y' app/.env"` | владелец `www-data` И mtime ≥ mtime .env | владелец не www-data ИЛИ старее .env (квирк 107) |
|
||||
| **П2** | `.env` line endings | `ssh liderra "file app/.env; md5sum app/.env"` | `ASCII text` (без CRLF) | `with CRLF line terminators` (квирк 105) |
|
||||
| **П3** | Свободное место | `ssh liderra "df -h /"` | использовано ≤ 85% | > 85% |
|
||||
| **П4** | Последний бэкап БД | `ssh liderra "ls -lt /var/backups/db/ \| head -2"` | mtime ≤ 24 часов назад | > 24 часов или пусто |
|
||||
| **П5** | Health очереди | `ssh liderra "pgrep -fa queue:work; tail -50 app/storage/logs/laravel.log \| grep -ic -e failed -e error"` | `queue:work` процесс активен И ≤ 5 ошибок в последних 50 строках laravel.log | процесс мёртв ИЛИ > 5 ошибок |
|
||||
| **П6** | nginx config | `ssh liderra "sudo nginx -t"` | `syntax is ok` + `test is successful` | любое иное |
|
||||
| **П7** | fail2ban активен | `ssh liderra "sudo systemctl is-active fail2ban"` | `active` | `inactive` / `failed` |
|
||||
| **П8** | Pending миграции | `ssh liderra "cd app && php artisan migrate:status \| grep -c Pending"` | 0 ИЛИ список к выкату согласован с brief'ом | необъявленные pending |
|
||||
|
||||
### 4.5. Процедура (5 шагов)
|
||||
|
||||
1. Принять brief — что выкатываем.
|
||||
2. Запустить 8 проверок последовательно (параллельность по SSH не даёт большого выигрыша, sequential проще для отладки).
|
||||
3. Собрать результаты в таблицу из 8 строк.
|
||||
4. Применить решающее правило:
|
||||
- Все 8 зелёных → **GO** + список smoke-команд для пост-выкатной проверки
|
||||
- Хоть одна красная → **NO-GO** + причина + ссылка на квирк memory + что делать чтобы исправить
|
||||
- Любая «не смог проверить» (SSH-таймаут, неожиданный формат) → **NO-GO с эскалацией** («нужен человек, агент не угадывает»)
|
||||
5. Опционально (если brief содержит `--post-smoke`): после выката повторить smoke-проверки (HTTP 200 на главной, миграция применилась, очередь жива).
|
||||
|
||||
### 4.6. Модель и инструменты
|
||||
|
||||
- **Модель:** Sonnet 4.6
|
||||
- **Tools:** Bash, Read, Grep
|
||||
- **Skills:**
|
||||
- `superpowers:verification-before-completion` — перед итоговым GO убедиться что все 8 проверок прогнаны (не одна пропущена)
|
||||
|
||||
### 4.7. Что зашиваем в system prompt
|
||||
|
||||
- 8 точных команд + ожидаемые форматы вывода
|
||||
- Память о квирках 104-108 (с MEMORY.md цитатами):
|
||||
- 104: stale `bootstrap/cache/config.php` переживает .env-фикс
|
||||
- 105: scp Windows→Linux кладёт CRLF в .env → sqlite-fallback → 500
|
||||
- 106: `queue:work --timeout` default 60s убивает worker сам себя
|
||||
- 107: `config:cache` НЕ из-под www-data → кэширует defaults → 500 (24.05 03:46 UTC)
|
||||
- 108: NTFS junction для worktree node_modules
|
||||
- Шаблон отчёта (таблица + вердикт + smoke-команды)
|
||||
- SSH-настройки (читает из `~/.ssh/config`, никаких паролей в system prompt)
|
||||
|
||||
### 4.8. Границы (out of scope)
|
||||
|
||||
- Сам выкат не делает (только проверяет готовность). Выкат — главный исполнитель.
|
||||
- Не трогает базу данных (только smoke-чтение).
|
||||
- Не меняет конфиги на боевом.
|
||||
- Не угадывает: если вывод команды не соответствует шаблону — NO-GO с эскалацией, не «возможно нормально».
|
||||
|
||||
### 4.9. Риски и митигации
|
||||
|
||||
| Риск | Митигация |
|
||||
|------|-----------|
|
||||
| Новый квирк, которого нет в его памяти | Любой неожиданный output → автоматически NO-GO + эскалация. Через 1-2 эпизода добавляю в его system prompt. |
|
||||
| SSH-таймаут / сеть лежит | Жёсткий timeout 30 сек на проверку. Если 2+ проверки таймнули — отчёт «не смог проверить, выкат на свой риск». |
|
||||
| Что-то не покрыто 8 проверками | Со временем расширяю список. Агент сам границы не двигает. |
|
||||
| Ложно-положительный GO (агент пропустил проблему) | Я смотрю отчёт перед нажатием. Агент не выкатывает сам — только сообщает. |
|
||||
|
||||
### 4.10. Что отдаёт
|
||||
|
||||
- Таблица 8 строк с green/red статусом каждой проверки
|
||||
- Вердикт **GO** или **NO-GO**
|
||||
- Если NO-GO — конкретная причина + ссылка на квирк memory + что нужно сделать
|
||||
- Если GO — список smoke-команд для пост-выкатной проверки
|
||||
|
||||
---
|
||||
|
||||
## 5. Общие архитектурные решения
|
||||
|
||||
### 5.1. Где живут определения агентов
|
||||
|
||||
Project-local в `.claude/agents/normative-sync.md` и `.claude/agents/prod-deploy-validator.md` — по образцу `.claude/agents/pest-parallel-debugger.md` и `.claude/agents/rls-reviewer.md` (прецеденты узко-специализированных проектных агентов).
|
||||
|
||||
### 5.2. Frontmatter каждого агента
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <slug>
|
||||
description: <когда зовётся — для триггер-классификации>
|
||||
tools: <ограниченный список>
|
||||
model: claude-sonnet-4-6
|
||||
---
|
||||
```
|
||||
|
||||
### 5.3. Скилы которые **не** даём ни одному из этих агентов
|
||||
|
||||
- `brainstorming`, `writing-plans`, `executing-plans` — это исполнители, не проектировщики
|
||||
- `test-driven-development`, `frontend-design`, `mcp-builder` — не их scope
|
||||
- `superpowers:dispatching-parallel-agents` — они листовые, не дирижируют
|
||||
|
||||
### 5.4. Subagent-driven git-safety (Pravila §15.1)
|
||||
|
||||
Оба агента работают с git (один правит файлы → git diff отдаёт; второй делает SSH но не git). Главный исполнитель ВСЕ ЕЩЁ обязан верифицировать commit-базу (`git rev-parse HEAD`) ПОСЛЕ каждого вызова `normative-sync`-агента, до коммита его diff'а.
|
||||
|
||||
### 5.5. Привязка к Brain governance (Pravila §16)
|
||||
|
||||
- Stop-hook будет писать routing_decision и для этих агентов (provenance `user_directed_method` — главный исполнитель явно их позвал)
|
||||
- `task_classification` маппинг (tools/observer-classification-map.json) расширить: `normative_sync` → узел `agent:normative-sync`; `prod_deploy_validation` → узел `agent:prod-deploy-validator`. После добавления — missed-activation детектор будет ловить случаи когда я делаю синк сам вместо вызова агента.
|
||||
|
||||
---
|
||||
|
||||
## 6. Что вне scope этого спека
|
||||
|
||||
- Создание агента «#3 разборщик карты наблюдателя» (отложен — частота 1-2/неделю, ROI ниже)
|
||||
- Создание агента «off-phase tooling integrator» (отброшен — слишком broad для одного агента)
|
||||
- Автоматическое добавление в `task_classification` маппинг (это отдельная задача brain governance)
|
||||
- Замена существующих агентов `pest-parallel-debugger` / `rls-reviewer` (они работают, не трогаем)
|
||||
- Какие-либо изменения нормативки (Pravila / PSR / Tooling / CLAUDE.md) для регистрации этих агентов — это произойдёт ВО ВРЕМЯ первого использования каждого агента (тогда сами агенты впишут себя в footer-счётчик, классическая dogfooding-проверка)
|
||||
|
||||
---
|
||||
|
||||
## 7. Открытые вопросы
|
||||
|
||||
- **OQ-1:** Должны ли агенты иметь доступ к `mcp__redis__*` / `mcp__laravel-boost__*` для расширенных pre-flight проверок? **Решение по умолчанию:** нет, минимальный tools-set; расширим если понадобится после первой недели использования.
|
||||
- **OQ-2:** Нужно ли запускать `normative-sync` через TaskOutput с фоновым режимом (агент работает в фоне, я делаю что-то другое)? **Решение по умолчанию:** нет, синхронный вызов (1-2 мин ожидания не критично).
|
||||
- **OQ-3:** Должен ли `prod-deploy-validator` уметь делать сам бэкап БД если П4 красная? **Решение по умолчанию:** нет, только отчёт. Действия — главный исполнитель.
|
||||
|
||||
---
|
||||
|
||||
## 8. Прецеденты в проекте
|
||||
|
||||
- **`.claude/agents/pest-parallel-debugger.md`** — узко-специализированный диагностический агент для Pest-quirks 72/73/77. Прецедент проектного агента с tools-restriction.
|
||||
- **`.claude/agents/rls-reviewer.md`** — узко-специализированный review-агент для RLS на миграциях. Прецедент агента с явным «when to invoke».
|
||||
- **`docs/superpowers/specs/2026-05-19-brain-governance-design.md`** — прецедент спека на инфраструктурное изменение через спек → план → реализация.
|
||||
|
||||
---
|
||||
|
||||
## 9. Следующий шаг
|
||||
|
||||
После согласования этого спека — `superpowers:writing-plans` создаст implementation plan с задачами:
|
||||
|
||||
1. Написать `.claude/agents/normative-sync.md` (system prompt + tools + frontmatter)
|
||||
2. Написать `.claude/agents/prod-deploy-validator.md` (то же)
|
||||
3. Smoke-тест №1 на dry-run (свежий синк, например post-billing-v2-spec-c)
|
||||
4. Smoke-тест №2 на dry-run (pre-flight перед следующим выкатом)
|
||||
5. Расширить `tools/observer-classification-map.json` двумя классификациями
|
||||
6. Запись в memory `feedback_specialized_agents.md` после первой недели использования (что работает / что нет)
|
||||
@@ -0,0 +1,204 @@
|
||||
# Удаление legacy прямого webhook-канала (`ProcessWebhookJob`)
|
||||
|
||||
**Дата:** 2026-05-24
|
||||
**Статус:** Design (awaiting user review)
|
||||
**Автор:** Claude Opus 4.7 (под руководством заказчика)
|
||||
**Брейнсторм:** сессия 2026-05-24
|
||||
**Триггер:** в коде осталось legacy-расхождение от старой prepaid-схемы. Изначально планировалась унификация под always-rub; в ходе брейнсторма выяснилось, что код — рудимент, не часть актуальной архитектуры каналов.
|
||||
|
||||
---
|
||||
|
||||
## §1. Контекст и проблема
|
||||
|
||||
### §1.1 Архитектура каналов приёма лидов
|
||||
|
||||
Лидерра принимает лиды через **два канала**:
|
||||
|
||||
| Канал | Назначение | Реализация | Биллинг |
|
||||
|---|---|---|---|
|
||||
| Основной | Real-time приём от `crm.bp-gr.ru` | `SupplierWebhookController::receive` → `INSERT supplier_leads` → `RouteSupplierLeadJob::dispatch` | `LedgerService` (always-rub) |
|
||||
| Резервный | Часовая доливка пропусков через CSV-отчёт `crm.bp-gr.ru` | `CsvReconcileJob` → доливает `supplier_leads` → `RouteSupplierLeadJob` | `LedgerService` (always-rub, косвенно через основной) |
|
||||
|
||||
Оба канала **уже** на единой биллинг-логике — рубли + 7-ступенчатая тарифная шкала через `LedgerService::chargeForDelivery`.
|
||||
|
||||
### §1.2 Что осталось от старой архитектуры
|
||||
|
||||
До эпика «шеринг» (Plan 2/5) в коде была другая модель: у каждого тенанта был свой `webhook_token`, поставщик стучался напрямую `POST /api/webhook/{token}` → `WebhookReceiveController` → `ProcessWebhookJob` → списание из `tenants.balance_leads` (штучный prepaid-баланс).
|
||||
|
||||
После шеринг-эпика этот путь **перестал использоваться**, но из кода не убран:
|
||||
|
||||
- `ProcessWebhookJob` ([app/app/Jobs/ProcessWebhookJob.php](../../../app/app/Jobs/ProcessWebhookJob.php), 342 строки) — на старой prepaid-модели (`tenant->decrement('balance_leads')`, `BalanceTransaction(amount_leads=-1)`).
|
||||
- `WebhookReceiveController` + публично открытый роут `POST /api/webhook/{token}` ([app/routes/web.php:276](../../../app/routes/web.php#L276)).
|
||||
- Таблицы `webhook_log` (партиционированная, 13 партиций) и `webhook_dedup_keys` — источник записей только этот job.
|
||||
- Колонки `tenants.webhook_token`, `tenants.webhook_token_rotated_at`.
|
||||
- Тесты `ProcessWebhookJobTest`, `WebhookReceiveTest` (плюс упоминания в 4 других файлах-тестах).
|
||||
|
||||
**Подтверждение «не используется на проде»:**
|
||||
|
||||
- `SELECT COUNT(*) FROM webhook_log` = 0 (за всю историю боевого сервера).
|
||||
- `last_webhook_at` у всех 5 тенантов = NULL.
|
||||
- Боевые лиды (`deals.tenant_id=2`, 412 шт.) пришли через основной канал — `source_crm_id` имеет формат `vid` от `crm.bp-gr.ru`.
|
||||
|
||||
### §1.3 Проблемы рудимента
|
||||
|
||||
1. **Открытый публичный эндпоинт.** `POST /api/webhook/{token}` доступен из интернета без middleware-проверки, аутентификация по знанию токена в URL. Лишний attack surface для DAST/нагрузочных атак.
|
||||
2. **Блокирует Phase B Спека A.** Phase B = `ALTER TABLE tenants DROP COLUMN balance_leads`. После него `ProcessWebhookJob` сломается на первом же вызове (или на запуске тестов).
|
||||
3. **Расхождение биллинг-моделей в коде.** Два разных списания на одну и ту же сущность (`Deal`) — путаница для будущих изменений.
|
||||
4. **Test-debt.** Тесты на старую prepaid-модель продолжают занимать набор тестов и время CI.
|
||||
|
||||
### §1.4 Решение
|
||||
|
||||
Удалить рудимент целиком (код + контроллер + роут + модель `WebhookDedupKey` + связанные тесты + таблицы БД + колонки `tenants`). Одним PR, выкатка одним релизом.
|
||||
|
||||
---
|
||||
|
||||
## §2. Scope
|
||||
|
||||
### §2.1 Что удаляем
|
||||
|
||||
**PHP-код:**
|
||||
|
||||
- `app/app/Jobs/ProcessWebhookJob.php` (целиком)
|
||||
- `app/app/Http/Controllers/Api/WebhookReceiveController.php` (целиком, если используется ТОЛЬКО для legacy-роута)
|
||||
- роут `Route::post('/api/webhook/{token}', ...)` в [app/routes/web.php:276](../../../app/routes/web.php#L276)
|
||||
- `app/app/Models/WebhookDedupKey.php` (целиком — используется только `ProcessWebhookJob`)
|
||||
- `app/app/Mail/LowBalanceNotification.php`, `app/app/Mail/ZeroBalanceNotification.php` — **только если impact-check (§3) подтвердит, что нет других caller'ов**
|
||||
|
||||
**Методы в `NotificationService`** (только если impact-check подтвердит, что нет других caller'ов):
|
||||
|
||||
- `notifyLowBalance`
|
||||
- `notifyZeroBalance` (NB: не путать с `notifyZeroBalancePaused` — это разный метод шеринг-канала, оставляем)
|
||||
- `notifyNewLead` — **оставляем** (использует и шеринг через `RouteSupplierLeadJob`)
|
||||
|
||||
**Тесты (целиком):**
|
||||
|
||||
- `app/tests/Feature/ProcessWebhookJobTest.php`
|
||||
- `app/tests/Feature/WebhookReceiveTest.php`
|
||||
|
||||
**Тесты (частично — удалить только релевантные кейсы, проверить не опустели ли файлы):**
|
||||
|
||||
- `app/tests/Feature/Pd/DealCreatePdLogTest.php`
|
||||
- `app/tests/Feature/Notifications/BalanceNotificationsTest.php`
|
||||
- `app/tests/Feature/Notifications/NewLeadNotificationTest.php`
|
||||
- `app/tests/Feature/Notifications/InAppNotificationTest.php`
|
||||
|
||||
**Миграция БД (одной миграцией, идемпотентной):**
|
||||
|
||||
- `DROP TABLE webhook_log` (партиционированная) + все 13 партиций
|
||||
- `DROP TABLE webhook_dedup_keys`
|
||||
- `DROP TABLE rejected_deals_log` — **только если impact-check подтвердит, что писалось только `ProcessWebhookJob`**
|
||||
- `ALTER TABLE tenants DROP COLUMN webhook_token, DROP COLUMN webhook_token_rotated_at` — **только если impact-check подтвердит, что не используется в UI/API**
|
||||
- удаление сидов / system_settings ключей, относящихся только к legacy: `low_balance_threshold_leads` (если не унифицируется в рамках другого спека)
|
||||
|
||||
### §2.2 Что НЕ трогаем (явно out of scope)
|
||||
|
||||
- `failed_webhook_jobs` — используется `RouteSupplierLeadJob::failed()` (шеринг-канал).
|
||||
- `SupplierLeadCost` — пишется и шеринг-каналом (через `LedgerService::chargeForDelivery`).
|
||||
- `MonthlyPartitionManager` — управляет партициями нескольких таблиц, не только `webhook_log`.
|
||||
- `SupplierResolver` — используется в админке (`AdminSupplierIntegrationController`).
|
||||
- Phase B Спека A (`DROP COLUMN balance_leads`) — отдельная задача после ≥72ч наблюдения Phase A.
|
||||
- Унификация `notifyLowBalance` под рубли — отдельная задача, если решим возрождать low-balance уведомления.
|
||||
|
||||
---
|
||||
|
||||
## §3. Impact-checks (обязательны перед удалением)
|
||||
|
||||
Каждая удаляемая сущность пройдёт автоматическую проверку «не использует ли её живой код». Список проверок — задачи в плане:
|
||||
|
||||
| Сущность | Проверка | Решение если есть use |
|
||||
|---|---|---|
|
||||
| `WebhookReceiveController` | `grep -r "WebhookReceiveController" app/` | Если есть use вне роута — удалить только метод `receive`, контроллер оставить |
|
||||
| `NotificationService::notifyLowBalance` | `grep -r "notifyLowBalance" app/` | Если есть caller вне `ProcessWebhookJob` — оставить метод |
|
||||
| `NotificationService::notifyZeroBalance` | `grep -r "notifyZeroBalance\b" app/` (с word boundary, чтобы не зацепить `notifyZeroBalancePaused`) | Если есть caller — оставить метод |
|
||||
| `LowBalanceNotification`, `ZeroBalanceNotification` (Mailable) | `grep -r "LowBalanceNotification\|ZeroBalanceNotification" app/` | Если есть use — оставить класс |
|
||||
| `tenants.webhook_token`, `webhook_token_rotated_at` | grep по `app/` и `app/resources/js/` — поиск в UI, API-resource, ресурс-сериализаторах, фабриках, сидах | Если есть UI/API consumer — отдельная задача на удаление UI |
|
||||
| `rejected_deals_log` | `grep -r "RejectedDealsLog\|rejected_deals_log" app/` | Если есть use вне `ProcessWebhookJob` — таблицу не дропать |
|
||||
| `webhook_dedup_keys` | `grep -r "webhook_dedup_keys\|WebhookDedupKey" app/` | Должен быть пустым после удаления `ProcessWebhookJob` |
|
||||
| `low_balance_threshold_leads` (system_settings) | `grep -r "low_balance_threshold_leads" app/` | Если есть caller — мигрировать или удалить настройку |
|
||||
|
||||
Все проверки делаются на текущем коде (после mental-удаления `ProcessWebhookJob` и тестов).
|
||||
|
||||
---
|
||||
|
||||
## §4. Решение по архитектуре
|
||||
|
||||
### §4.1 Главный инвариант
|
||||
|
||||
После выпиливания **остаётся ровно одна труба биллинга**: `RouteSupplierLeadJob::createDealCopyForProject` → `LedgerService::chargeForDelivery`. Все списания всех каналов идут через неё.
|
||||
|
||||
### §4.2 Что меняется в публичном API
|
||||
|
||||
- `POST /api/webhook/{token}` — **404**. Старые токены тенантов перестают принимать вход (попадание в логи nginx как 404 — это нормально, на проде вызовов 0).
|
||||
- `POST /api/webhook/supplier/{secret}` — **без изменений** (это шеринг-канал от `crm.bp-gr.ru`).
|
||||
|
||||
### §4.3 Откатываемость
|
||||
|
||||
Миграция БД **необратимая** (DROP TABLE / DROP COLUMN). Бэкап `pg_dump` снимается перед выкаткой по runbook `docs/deploy/test-server-runbook.md`. В случае critical-инцидента — restore из бэкапа + откат git revert.
|
||||
|
||||
Риск отката оценивается как **нулевой** — на проде webhook_log = 0, рудимент никем не используется.
|
||||
|
||||
### §4.4 Совместимость с другими спеками
|
||||
|
||||
- **Спек A (₽-баланс, Phase A на проде)** — этот спек снимает блокер для Phase B.
|
||||
- **Спек B (дубли, на проде)** — не пересекается (Спек B про шеринг-канал; legacy webhook имеет собственный `webhook_dedup_keys`, который тоже удаляется).
|
||||
- **Спек C (preflight + VTB)** — не пересекается (preflight работает на уровне `SupplierQuotaAllocator`; VTB — на пополнении баланса, не на списании).
|
||||
|
||||
---
|
||||
|
||||
## §5. Тестирование
|
||||
|
||||
### §5.1 Регрессионная проверка
|
||||
|
||||
- `composer test` — Pest --parallel, должно пройти на dev (после удаления тестов количество suite уменьшится).
|
||||
- `npm run test:vue` — Vitest, должен остаться зелёным (UI не трогаем кроме возможного раздела webhook-token, если impact-check найдёт).
|
||||
- Lefthook pre-commit — все джобы зелёные.
|
||||
- Larastan — без новых ошибок (baseline регенерация только если потребуется).
|
||||
|
||||
### §5.2 Smoke-проверка на проде после деплоя
|
||||
|
||||
- `curl -X POST https://liderra.ru/api/webhook/test-token -d '{}'` → ожидается **404** (роут больше не существует).
|
||||
- `curl -X POST https://liderra.ru/api/webhook/supplier/$SECRET -d '{...}'` → ожидается **200/202** (шеринг-канал работает как раньше).
|
||||
- `SELECT * FROM information_schema.tables WHERE table_name IN ('webhook_log', 'webhook_dedup_keys')` → 0 строк (миграция применилась).
|
||||
- `SELECT column_name FROM information_schema.columns WHERE table_name='tenants' AND column_name LIKE 'webhook_%'` → 0 строк (если impact-check подтвердил удаление колонок).
|
||||
|
||||
### §5.3 7-дневное наблюдение
|
||||
|
||||
После деплоя — наблюдать:
|
||||
|
||||
- `failed_jobs` (новые fail'ы только от шеринг-канала).
|
||||
- nginx access log на `/api/webhook/{token}` 404 — если **кто-то** реально начнёт долбиться (что маловероятно: 0 вызовов за всю историю), но если начнёт — это сигнал к ретроспективе.
|
||||
- Sentry-алерты — без новых регрессий (Sentry pending Б-1).
|
||||
|
||||
---
|
||||
|
||||
## §6. Выкатка
|
||||
|
||||
**Один PR, один релиз** (заказчик подтвердил 2026-05-24).
|
||||
|
||||
Шаги (детали — в плане реализации):
|
||||
|
||||
1. Изолированный worktree.
|
||||
2. Impact-checks (§3) — финальный список «что удаляем точно, что оставляем».
|
||||
3. Code-удаление + удаление/чистка тестов.
|
||||
4. Миграция БД (одна идемпотентная миграция с DROP).
|
||||
5. Полная регрессия (`composer test` + `npm run test:vue` + lefthook).
|
||||
6. Subagent code-review.
|
||||
7. Push в main, FF merge.
|
||||
8. Деплой на боевой:
|
||||
- Бэкап `pg_dump` перед миграцией (runbook).
|
||||
- `git archive | scp | tar -xf` (10-15 файлов).
|
||||
- `redeploy.sh` (composer + migrate + cache + reload php-fpm).
|
||||
- Smoke-проверка (§5.2).
|
||||
9. 7-дневное наблюдение (§5.3).
|
||||
|
||||
---
|
||||
|
||||
## §7. Связано
|
||||
|
||||
- [Спек A Биллинг v2 — единый ₽-баланс](2026-05-23-billing-v2-spec-a-balance-rub-design.md) (Phase B = блокирован этим документом до выпиливания).
|
||||
- [Спек B Биллинг v2 — политика дублей](2026-05-23-billing-v2-spec-b-duplicates-design.md) (не пересекается, но даёт контекст шеринг-канала).
|
||||
- [Спек C Биллинг v2 — preflight + VTB](2026-05-24-billing-v2-spec-c-preflight-vtb-design.md) (не пересекается).
|
||||
- [Supplier integration spec](2026-05-10-supplier-integration-design.md) §5–§6 (определение шеринг-канала, который остаётся единственным боевым).
|
||||
- [CSV reconcile channel spec](2026-05-18-supplier-csv-reconcile-channel-design.md) (резервный канал, не трогается).
|
||||
- `docs/deploy/test-server-runbook.md` (бэкап перед миграцией).
|
||||
- Памятки в коде комментариев: [routes/web.php:282](../../../app/routes/web.php#L282) уже маркирует роут как «legacy».
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
# Router Stage 3 — three follow-up fixes (design)
|
||||
|
||||
**Дата:** 2026-05-24
|
||||
**Автор:** Claude Opus 4.7 + Дмитрий (controller)
|
||||
**Контекст:** этап 3 router discipline overhaul (Tasks 1-8) уже на `origin/main` в режиме warn-only. После рестарта Claude Code и подключения `ANTHROPIC_API_KEY` (Layer 2 эскалация) обнаружены три дыры, мешающие осмысленному прогону `/brain-retro` и переключению в `enforce`.
|
||||
**Связано с:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md` (anchor spec этапа); `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md` (план Tasks 1-10); ADR-011 (brain governance).
|
||||
**Тип:** bugfix + интеграционное расширение. Размер: ~3 файла кода, ~3 теста, ≤80 строк нетто.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
Сторож (классификатор + UserPromptSubmit/PreToolUse/Stop хуки) работает в warn-only с 24.05.2026. Накоплено 16 сессий. При инспекции state-файлов и эпизодов наблюдателя видны три дефекта:
|
||||
|
||||
### Дефект 1 — UTF-8 mojibake в state-файле сторожа
|
||||
|
||||
В `~/.claude/runtime/router-state-<session>.json` `classification.reasoning` (приходит от Anthropic API) содержит mojibake вместо кириллицы пользовательского промпта. Пример (фактический state 24.05.2026, session `f4c8ef62`):
|
||||
|
||||
```
|
||||
"reasoning": "Ambiguous Russian query 'посмотри сторожа достаточно ему информации?' ..."
|
||||
```
|
||||
|
||||
Симптом — классический cp1251→UTF-8 misinterpret. На Windows Node.js `process.stdin` по умолчанию выдаёт `Buffer`'ы, конкатенация через `+=` к строке без явного `setEncoding('utf-8')` интерпретирует байты как latin-1, теряя multi-byte UTF-8 sequences. Битая строка уезжает в `body.messages[0].content` запроса к Anthropic API, Haiku видит мусор и классифицирует «по шуму».
|
||||
|
||||
**Влияние:** Layer 2 эскалация на русских промптах работает по испорченному тексту → рекомендации мимо. Layer 1 (regex по байтам — это всё ещё валидная UTF-8 в `event.prompt`, парсинг JSON через `JSON.parse` сохраняет правильно) работает корректно, но размытые русские задачи всё равно попадают в Layer 2.
|
||||
|
||||
### Дефект 2 — `recommended_node` отсутствует в эпизодах наблюдателя
|
||||
|
||||
В эпизодах `docs/observer/episodes-2026-05.jsonl` поле `primary_rationale.recommended_node` пустое во всех 5 последних эпизодах (sample 24.05.2026). При этом state-файл сторожа содержит `classification.recommendedNode`. Модуль `tools/observer-recommended-node.mjs` существует (создан в Stage 2 batch), но не подключён к `observer-transcript-parser.mjs` либо подключён без сохранения результата.
|
||||
|
||||
**Влияние:** `brain-retro-analyzer.mjs` Stage 3 Task 9 будет считать `domainHitRate` (matched recommendation vs node_chosen) — без поля в эпизоде эта ось показывает 0. Метрика «правильный ли узел я выбрал» не работает.
|
||||
|
||||
### Дефект 3 — `chain_progress` отсутствует в эпизодах
|
||||
|
||||
Аналогично дефекту 2: state-файл хранит `chainProgress` (массив), `chainCompleted` (bool), `classification.recommendedChain` (строка). `tools/router-stop-gate.mjs` обновляет state на Stop event. `tools/observer-chain-detector.mjs` существует. Но `primary_rationale.chain_progress` / `chain_completed` / `recommended_chain` в эпизодах пусты.
|
||||
|
||||
**Влияние:** `chainCompletionRate` (Stage 3 Task 9) тоже показывает 0. Метрика «довёл ли цепочку до конца» не работает.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals / Non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
- G1. Русский язык в пользовательском промпте корректно (без mojibake) попадает в state-файл и в запрос к Anthropic API.
|
||||
- G2. `primary_rationale.recommended_node` в каждом новом эпизоде наблюдателя = `classification.recommendedNode` из state-файла сторожа (или `null`, если state-файла нет).
|
||||
- G3. `primary_rationale.chain_progress`, `chain_completed`, `recommended_chain` в каждом новом эпизоде наблюдателя = соответствующие поля из state-файла сторожа.
|
||||
- G4. Smoke-test на живой сессии после фиксов: один русский размытый промпт → читаемый русский в `reasoning` + три указанных поля заполнены в новом эпизоде.
|
||||
- G5. Регрессия GREEN: полный прогон `tools/*` (≥456 tests, baseline Stage 3 Task 8 + новые).
|
||||
|
||||
### Non-goals
|
||||
|
||||
- NG1. Извлечение `triggers_matched` / `candidates_considered` / `boundaries_applied` из reasoning Claude (отдельный кандидат brain-retro #6 от 20.05).
|
||||
- NG2. Конвертация v1-эпизодов 19.05 (~5 записей) в v3 задним числом.
|
||||
- NG3. Расширение словаря классификатора (Candidate 7 brain-retro 20.05).
|
||||
- NG4. Переключение в `enforce` mode (это отдельный шаг — Task 9 плана Stage 3, после 24h warn-only post-fix наблюдения).
|
||||
- NG5. Изменение `~/.claude/settings.json` (хуки уже зарегистрированы Task 8 ebca54f0).
|
||||
- NG6. Правка нормативки (Pravila / CLAUDE.md / PSR_v1 / Tooling / ADR / docs/router-procedure.md) — это Stage 4.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Три независимых фикса, общий контекст — state-файл сторожа `~/.claude/runtime/router-state-<session>.json` как single source of truth между prehook (запись), tool-gate (чтение), stop-gate (обновление) и observer-parser (чтение и обогащение эпизода).
|
||||
|
||||
### 3.1. Fix 1 — UTF-8 stdin encoding в двух хуках
|
||||
|
||||
**Файлы:** `tools/router-prehook.mjs`, `tools/router-stop-gate.mjs`.
|
||||
|
||||
**Изменение:** перед циклом `for await (const chunk of process.stdin)` вставить:
|
||||
|
||||
```javascript
|
||||
process.stdin.setEncoding('utf-8');
|
||||
```
|
||||
|
||||
Это директива Node.js, переключающая stdin readable stream в text mode с явным UTF-8 декодером. После этого `chunk` приходит как `string`, конкатенация `input += chunk` сохраняет code points без потерь.
|
||||
|
||||
**Альтернатива (рассмотрена и отвергнута):** читать как `Buffer.concat([...])` и затем `.toString('utf-8')` явно. Эквивалентно по результату, но менее идиоматично и требует переписать цикл.
|
||||
|
||||
**Также трогаем третий хук:** `router-tool-gate.mjs` тоже читает stdin (PreToolUse event). Для текущих полей (`tool_input.command`/`path`) кодировка не критична (ASCII пути/команды), но для единообразия и защиты от будущих расширений (если хук начнёт логировать prompt-фрагменты) — добавляем `setEncoding('utf-8')` и сюда. Стоимость — одна строка + один регрессионный тест.
|
||||
|
||||
### 3.2. Fix 2 — recommended_node в эпизод
|
||||
|
||||
**Файлы:** `tools/observer-transcript-parser.mjs` (модификация), `tools/observer-recommended-node.mjs` (существует, используем как есть).
|
||||
|
||||
**Подход:** в функции построения `primary_rationale` объекта эпизода вызвать helper из `observer-recommended-node.mjs`, который читает state-файл по `sessionId` (из контекста эпизода) и возвращает `recommendedNode`. Сохранить в `primary_rationale.recommended_node`.
|
||||
|
||||
Помощник уже умеет:
|
||||
|
||||
- Принять `sessionId` и базовый путь `~/.claude/runtime/`.
|
||||
- Прочитать `router-state-<sessionId>.json`, вернуть `null` если файла нет.
|
||||
- Извлечь `classification.recommendedNode`.
|
||||
|
||||
Если helper не делает что-то из перечисленного — расширим его (TDD).
|
||||
|
||||
### 3.3. Fix 3 — chain_progress + chain_completed + recommended_chain в эпизод
|
||||
|
||||
**Файлы:** `tools/observer-transcript-parser.mjs` (та же модификация что Fix 2 — добавляем в тот же блок).
|
||||
|
||||
**Подход:** ровно как Fix 2, в том же чтении state-файла достаём `chainProgress`, `chainCompleted`, `classification.recommendedChain` и заполняем `primary_rationale.chain_progress`, `chain_completed`, `recommended_chain` соответственно.
|
||||
|
||||
Можно вытащить в общий helper «обогатить primary_rationale из state-файла» — единый вызов, три поля. Это **рекомендуемый** вариант (1 read state-файла на эпизод вместо 2 для отдельных helper'ов).
|
||||
|
||||
### 3.4. Data flow
|
||||
|
||||
```
|
||||
UserPromptSubmit
|
||||
↓ stdin (русский UTF-8) [Fix 1: setEncoding]
|
||||
router-prehook.mjs
|
||||
↓ classify() → state.classification.{recommendedNode, recommendedChain, reasoning}
|
||||
~/.claude/runtime/router-state-<session>.json
|
||||
|
||||
Stop event
|
||||
↓ stdin (turn_events) [Fix 1: setEncoding профилактически]
|
||||
router-stop-gate.mjs
|
||||
↓ updateChainProgress() → state.{chainProgress, chainCompleted, skillInvokedThisTurn}
|
||||
~/.claude/runtime/router-state-<session>.json (тот же файл)
|
||||
|
||||
Observer Stop event (parallel hook)
|
||||
↓ обходит transcript JSONL
|
||||
observer-transcript-parser.mjs
|
||||
↓ [Fix 2/3: enrichFromState(sessionId)]
|
||||
↓ primary_rationale.{recommended_node, chain_progress, chain_completed, recommended_chain}
|
||||
docs/observer/episodes-2026-05.jsonl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
| Тест | Что проверяем | Fix |
|
||||
|---|---|---|
|
||||
| `router-prehook.test.mjs` +1 | mock stdin: Buffer с кириллицей в UTF-8 → main() → state.classification.reasoning без mojibake | 1 |
|
||||
| `router-stop-gate.test.mjs` +1 | mock stdin: Buffer с кириллицей в JSON-поле → main() не падает, не портит state | 1 |
|
||||
| `router-tool-gate.test.mjs` +1 | mock stdin: Buffer с UTF-8 → main() не падает; решение decideDecision не меняется | 1 (защита) |
|
||||
| `observer-recommended-node.test.mjs` +1-2 | mock state-файл → helper извлекает recommendedNode/recommendedChain/chainProgress/chainCompleted | 2+3 (расширение helper'а) |
|
||||
| `observer-transcript-parser.test.mjs` +1 | fixture эпизода + mock state-файла → собранный эпизод имеет все 4 поля в primary_rationale | 2+3 integration |
|
||||
|
||||
### Smoke test (live, после деплоя)
|
||||
|
||||
После рестарта Claude Code:
|
||||
|
||||
1. Прислать русский размытый промпт (например, «проверь как сторож обрабатывает сложные клиентские задачи»).
|
||||
2. Проверить `~/.claude/runtime/router-state-<новая-session>.json`:
|
||||
- В `classification.reasoning` — читаемая кириллица (если Layer 2 эскалировал).
|
||||
3. После Stop текущего хода — посмотреть последний эпизод в `docs/observer/episodes-2026-05.jsonl`:
|
||||
- `primary_rationale.recommended_node` ≠ null (или явно `null` если состояние подсказывает).
|
||||
- `primary_rationale.recommended_chain` — заполнено.
|
||||
- `primary_rationale.chain_progress` — массив (пустой ОК, если скилы не вызывались).
|
||||
|
||||
### Regression suite
|
||||
|
||||
`cd app && npx vitest run --config vitest.config.tools.mjs` — все тесты GREEN, прирост ≥3 новых tests.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risks & mitigations
|
||||
|
||||
| Риск | Митигация |
|
||||
|---|---|
|
||||
| `setEncoding('utf-8')` ломает существующее поведение на не-кириллических промптах | Существующие тесты `router-prehook.test.mjs` сейчас работают на ASCII-фикстурах — добавление setEncoding не должно повлиять. Прогон полного suite до коммита. |
|
||||
| `observer-recommended-node.mjs` уже имеет тесты, расширение ломает существующие | TDD: новые тесты пишем сначала, существующие зелёные — ок; красные — переписываем helper аккуратно. |
|
||||
| Корень UTF-8 в другом месте (не в stdin) — фикс не помогает | Smoke-test на живой сессии — если mojibake остался, делаем диагностику отдельно (не в этом spec). Cost: 1 час смок-теста. |
|
||||
| `enrichFromState` не находит state-файл (для эпизодов в сессиях ДО раскатки сторожа) | Helper возвращает `null`/пустоту — primary_rationale.recommended_node = null. Это поведение для legacy ОК. |
|
||||
| state-файл устарел / другой сессии (race) | sessionId в эпизоде и в state-файле должен совпадать; mismatch → null. Helper это уже учитывает. |
|
||||
| Параллельная сессия Claude в этом же чекауте правит те же файлы | Pre-flight перед каждым коммитом + worktree (Pravila §15.1). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Rollback
|
||||
|
||||
Каждый фикс — 1-3 строки кода + тест. Откат:
|
||||
|
||||
- Fix 1: `git revert <commit>` или удалить строку `setEncoding`.
|
||||
- Fix 2/3: `git revert <commit>` или убрать вызов `enrichFromState`. primary_rationale.recommended_node вернётся в `undefined`/`null` (текущее состояние).
|
||||
|
||||
Никаких миграций данных, никаких backup'ов. Старые эпизоды не трогаем — они остаются без новых полей (это ожидаемо).
|
||||
|
||||
---
|
||||
|
||||
## 7. Execution layout
|
||||
|
||||
- **Where:** изолированный worktree от свежего `origin/main` (per Pravila §15.1 — git-commit tasks через Sonnet/Opus, не Haiku).
|
||||
- **Branches:** `feat/router-stage3-three-fixes`.
|
||||
- **Subagent vs inline:** subagent-driven последовательно — 3 Sonnet субагента, по одному на фикс (фиксы малы, параллельность даст negligible выигрыш + усложнит review). Closure (continuity + STATUS regen + push) — controller (Opus).
|
||||
- **Commits:** 1 commit per fix + 1 closure commit (continuity + STATUS regen + push).
|
||||
- **Push target:** `main` (FF merge).
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
После всех фиксов и smoke:
|
||||
|
||||
- ✅ Smoke-русский в state-файле новой сессии без mojibake.
|
||||
- ✅ Smoke-эпизод имеет 4 новых поля в `primary_rationale` (recommended_node, recommended_chain, chain_progress, chain_completed).
|
||||
- ✅ Полный tools-suite GREEN (baseline 456 + ≥4 новых).
|
||||
- ✅ gitleaks 0.
|
||||
- ✅ Откатываемость ≤5 минут.
|
||||
- ✅ STATUS.md регенерирован.
|
||||
- ✅ Push на `main` без force, без skipped hooks.
|
||||
|
||||
После этого 24h warn-only наблюдения **с уже починенным сторожем** → `/brain-retro` → решение по enforce (Task 9 плана Stage 3).
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- Spec этапа 3: `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`
|
||||
- План этапа 3 Tasks 1-10: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`
|
||||
- ADR-011 Brain governance.
|
||||
- Brain-retro первое (20.05.2026): `docs/observer/notes/2026-05-20-brain-retro.md` — кандидаты #2/#5/#6.
|
||||
- State file format: `tools/router-prehook.mjs:40-50` (`buildStateFromClassification`).
|
||||
- Helper recommended-node: `tools/observer-recommended-node.mjs`.
|
||||
- Helper chain-detector: `tools/observer-chain-detector.mjs`.
|
||||
@@ -8,6 +8,13 @@
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { detectMissedActivations } from './missed-activations.mjs';
|
||||
import {
|
||||
disciplinePercentByClassification,
|
||||
routerStepReached,
|
||||
boundariesAppliedRate,
|
||||
} from './discipline-metrics.mjs';
|
||||
import { loadRegistry } from './registry-load.mjs';
|
||||
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';
|
||||
|
||||
const SIZE_SMALL = 20;
|
||||
const SIZE_LARGE = 60;
|
||||
@@ -209,6 +216,9 @@ export function analyze(episodes, options = {}) {
|
||||
}
|
||||
const classificationMap = options.classificationMap || {};
|
||||
const dormancy = options.dormancy || {};
|
||||
const disciplineByClassification = disciplinePercentByClassification(normal, classificationMap);
|
||||
const routerStep = routerStepReached(normal);
|
||||
const boundariesRate = boundariesAppliedRate(normal);
|
||||
return {
|
||||
episodeCount: normal.length,
|
||||
v1SkippedCount,
|
||||
@@ -217,6 +227,9 @@ export function analyze(episodes, options = {}) {
|
||||
causalChains: findCausalChains(normal),
|
||||
factorMatrix: buildFactorMatrix(normal),
|
||||
missedActivations: detectMissedActivations(normal, classificationMap, dormancy),
|
||||
disciplineByClassification,
|
||||
routerStep,
|
||||
boundariesRate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,16 +251,9 @@ function loadEpisodes(files) {
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/brain-retro-analyzer.mjs')) {
|
||||
const classificationMap = (() => {
|
||||
try {
|
||||
return JSON.parse(readFileSync('tools/observer-classification-map.json', 'utf-8')).map || {};
|
||||
} catch { return {}; }
|
||||
})();
|
||||
const dormancy = (() => {
|
||||
try {
|
||||
return JSON.parse(readFileSync('tools/.node-dormancy.json', 'utf-8'));
|
||||
} catch { return {}; }
|
||||
})();
|
||||
const registry = loadRegistry({ useCache: false });
|
||||
const classificationMap = buildClassificationMap(registry);
|
||||
const dormancy = buildDormancyMap(registry);
|
||||
const result = analyze(loadEpisodes(process.argv.slice(2)), { classificationMap, dormancy });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
|
||||
@@ -318,3 +318,42 @@ describe('analyze: schema_version filter', () => {
|
||||
expect(result.factorMatrix.recommended_node_for_direct.none).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze — discipline metrics (stage 2)', () => {
|
||||
const map = { feature: ['#19'], bugfix: ['#18'] };
|
||||
const dormancy = { '#19': false, '#18': false };
|
||||
|
||||
it('returns disciplinePercentByClassification', () => {
|
||||
const eps = [
|
||||
ep({ primary_rationale: { task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
|
||||
ep({ timestamps: { started_at: '2026-05-19T10:01:00Z', ended_at: '2026-05-19T10:02:00Z' }, primary_rationale: { task_classification: 'feature', node_chosen: '#19', triggers_matched: [{node:'#19'}], boundaries_applied: [], step: 3, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
|
||||
];
|
||||
const res = analyze(eps, { classificationMap: map, dormancy });
|
||||
expect(res.disciplineByClassification.feature.episodes).toBe(2);
|
||||
expect(res.disciplineByClassification.feature.withTriggerMatch).toBe(1);
|
||||
expect(res.disciplineByClassification.feature.viaSkill).toBe(1);
|
||||
});
|
||||
|
||||
it('returns routerStepReached distribution (derived from signals)', () => {
|
||||
const eps = [
|
||||
// bare/direct → derived step 1
|
||||
ep({ primary_rationale: { step: 1, task_classification: 'other', node_chosen: 'direct', triggers_matched: [], chain_ref: [], boundaries_applied: [], candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
|
||||
// triggers matched → derived step 3
|
||||
ep({ timestamps: { started_at: '2026-05-19T10:01:00Z', ended_at: '2026-05-19T10:02:00Z' }, primary_rationale: { step: 1, task_classification: 'other', node_chosen: 'direct', triggers_matched: [{ node: '#19' }], chain_ref: [], boundaries_applied: [], candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
|
||||
];
|
||||
const res = analyze(eps, { classificationMap: map, dormancy });
|
||||
expect(res.routerStep.distribution['1']).toBe(1);
|
||||
expect(res.routerStep.distribution['3']).toBe(1);
|
||||
});
|
||||
|
||||
it('returns boundariesAppliedRate', () => {
|
||||
const eps = [
|
||||
ep({ primary_rationale: { boundaries_applied: [{ adr: 'X' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
|
||||
ep({ timestamps: { started_at: '2026-05-19T10:01:00Z', ended_at: '2026-05-19T10:02:00Z' }, primary_rationale: { boundaries_applied: [], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
|
||||
];
|
||||
const res = analyze(eps, { classificationMap: map, dormancy });
|
||||
expect(res.boundariesRate.total).toBe(2);
|
||||
expect(res.boundariesRate.withBoundaries).toBe(1);
|
||||
expect(res.boundariesRate.rate).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Discipline metrics — pure aggregation over observer episodes.
|
||||
* Stage 2 of router discipline overhaul (spec 2026-05-23): baseline measurement
|
||||
* перед enforcement в этапе 3.
|
||||
*
|
||||
* Pure / read-only. No exec, no fs.
|
||||
*/
|
||||
|
||||
/** Filter helper: only schema v2+ non-error episodes. */
|
||||
function valid(episodes) {
|
||||
return (episodes || []).filter(
|
||||
(e) => e && !e.observer_error && typeof e.schema_version === 'number' && e.schema_version >= 2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* % эпизодов с матченным триггером, разбивка по task_classification.
|
||||
* Только классификации, присутствующие в classificationMap (т.е. известные/имеющие узлы).
|
||||
*
|
||||
* @param {object[]} episodes
|
||||
* @param {object} classificationMap { [classification]: string[] }
|
||||
* @returns {{ [classification]: { episodes: number, withTriggerMatch: number, viaSkill: number, pctTriggerMatch: number, pctViaSkill: number } }}
|
||||
*/
|
||||
export function disciplinePercentByClassification(episodes, classificationMap) {
|
||||
const out = {};
|
||||
for (const e of valid(episodes)) {
|
||||
const pr = e.primary_rationale || {};
|
||||
const cls = pr.task_classification;
|
||||
if (!cls || !classificationMap[cls]) continue;
|
||||
if (!out[cls]) out[cls] = { episodes: 0, withTriggerMatch: 0, viaSkill: 0, pctTriggerMatch: 0, pctViaSkill: 0 };
|
||||
const b = out[cls];
|
||||
b.episodes += 1;
|
||||
if (Array.isArray(pr.triggers_matched) && pr.triggers_matched.length > 0) b.withTriggerMatch += 1;
|
||||
if (pr.node_chosen && pr.node_chosen !== 'direct') b.viaSkill += 1;
|
||||
}
|
||||
for (const b of Object.values(out)) {
|
||||
b.pctTriggerMatch = b.episodes ? b.withTriggerMatch / b.episodes : 0;
|
||||
b.pctViaSkill = b.episodes ? b.viaSkill / b.episodes : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вывести шаг router-procedure.md, которого реально достиг эпизод, из
|
||||
* НАБЛЮДАЕМЫХ признаков primary_rationale (хранимое поле `step` исторически —
|
||||
* жёсткая константа 1 в обоих episode-builder'ах, поэтому ему не доверяем).
|
||||
*
|
||||
* Стадии (берётся максимум достигнутой):
|
||||
* 1 — hard-floor checkpoint (всегда пройден),
|
||||
* 2 — классификация дала реальный класс (task_classification ≠ 'other'),
|
||||
* 3 — подобраны триггеры (triggers_matched непуст),
|
||||
* 4 — найдена каноническая цепочка (chain_ref непуст),
|
||||
* 5 — выбран и исполнен узел (node_chosen ≠ 'direct').
|
||||
*
|
||||
* @param {object|undefined} pr primary_rationale
|
||||
* @returns {1|2|3|4|5}
|
||||
*/
|
||||
export function deriveRouterStep(pr) {
|
||||
if (!pr || typeof pr !== 'object') return 1;
|
||||
let step = 1;
|
||||
if (pr.task_classification && pr.task_classification !== 'other') step = 2;
|
||||
if (Array.isArray(pr.triggers_matched) && pr.triggers_matched.length > 0) step = Math.max(step, 3);
|
||||
const chain = pr.chain_ref;
|
||||
const hasChain = Array.isArray(chain) ? chain.length > 0 : Boolean(chain);
|
||||
if (hasChain) step = Math.max(step, 4);
|
||||
if (pr.node_chosen && pr.node_chosen !== 'direct') step = Math.max(step, 5);
|
||||
return step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Распределение по шагу роутера, ВЫВЕДЕННОМУ из наблюдаемых признаков
|
||||
* (deriveRouterStep) — а не из хранимого pr.step (он был константой 1).
|
||||
* suspicious=true если total >= 5 && >90% эпизодов выводятся в step 1
|
||||
* (Pravila §16.4 sanity-check — теперь это реальный сигнал «дисциплина
|
||||
* проваливается / признаки не пишутся», а не гарантированный артефакт).
|
||||
*
|
||||
* @param {object[]} episodes
|
||||
* @returns {{ distribution: { [step: string]: number }, total: number, suspicious: boolean }}
|
||||
*/
|
||||
export function routerStepReached(episodes) {
|
||||
const distribution = {};
|
||||
let total = 0;
|
||||
for (const e of valid(episodes)) {
|
||||
const key = String(deriveRouterStep(e.primary_rationale));
|
||||
distribution[key] = (distribution[key] || 0) + 1;
|
||||
total += 1;
|
||||
}
|
||||
const stuckAt1 = (distribution['1'] || 0) / Math.max(total, 1);
|
||||
return { distribution, total, suspicious: total >= 5 && stuckAt1 > 0.9 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Доля эпизодов с непустыми applied boundaries, разбивка по path_type.
|
||||
*
|
||||
* @param {object[]} episodes
|
||||
* @returns {{ total: number, withBoundaries: number, rate: number, byPathType: object }}
|
||||
*/
|
||||
export function boundariesAppliedRate(episodes) {
|
||||
let total = 0, withBoundaries = 0;
|
||||
const byPathType = {};
|
||||
for (const e of valid(episodes)) {
|
||||
const pr = e.primary_rationale || {};
|
||||
const pt = e.path_type || 'null';
|
||||
const has = Array.isArray(pr.boundaries_applied) && pr.boundaries_applied.length > 0;
|
||||
total += 1;
|
||||
if (has) withBoundaries += 1;
|
||||
if (!byPathType[pt]) byPathType[pt] = { total: 0, withBoundaries: 0, rate: 0 };
|
||||
byPathType[pt].total += 1;
|
||||
if (has) byPathType[pt].withBoundaries += 1;
|
||||
}
|
||||
for (const b of Object.values(byPathType)) b.rate = b.total ? b.withBoundaries / b.total : 0;
|
||||
return { total, withBoundaries, rate: total ? withBoundaries / total : 0, byPathType };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
disciplinePercentByClassification,
|
||||
routerStepReached,
|
||||
deriveRouterStep,
|
||||
boundariesAppliedRate,
|
||||
} from './discipline-metrics.mjs';
|
||||
|
||||
const map = { feature: ['#19'], bugfix: ['#18'], refactor: ['#11', '#12'] };
|
||||
|
||||
function ep(overrides = {}) {
|
||||
return {
|
||||
schema_version: 2,
|
||||
primary_rationale: {
|
||||
task_classification: 'feature',
|
||||
node_chosen: 'direct',
|
||||
triggers_matched: [],
|
||||
boundaries_applied: [],
|
||||
step: 1,
|
||||
},
|
||||
path_type: 'regulated',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('disciplinePercentByClassification', () => {
|
||||
it('counts episodes per classification', () => {
|
||||
const eps = [
|
||||
ep({ primary_rationale: { task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } }),
|
||||
ep({ primary_rationale: { task_classification: 'feature', node_chosen: '#19', triggers_matched: [{node:'#19'}], boundaries_applied: [], step: 3 } }),
|
||||
ep({ primary_rationale: { task_classification: 'bugfix', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } }),
|
||||
];
|
||||
const res = disciplinePercentByClassification(eps, map);
|
||||
expect(res.feature.episodes).toBe(2);
|
||||
expect(res.feature.withTriggerMatch).toBe(1);
|
||||
expect(res.feature.viaSkill).toBe(1);
|
||||
expect(res.feature.pctTriggerMatch).toBeCloseTo(0.5);
|
||||
expect(res.feature.pctViaSkill).toBeCloseTo(0.5);
|
||||
expect(res.bugfix.episodes).toBe(1);
|
||||
expect(res.bugfix.pctTriggerMatch).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores classifications outside the map', () => {
|
||||
const eps = [ep({ primary_rationale: { task_classification: 'unknown', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } })];
|
||||
const res = disciplinePercentByClassification(eps, map);
|
||||
expect(res.unknown).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores v1 episodes and observer_error markers', () => {
|
||||
const eps = [
|
||||
{ schema_version: 1, primary_rationale: { task_classification: 'feature', node_chosen: 'direct' } },
|
||||
{ observer_error: true },
|
||||
ep(),
|
||||
];
|
||||
const res = disciplinePercentByClassification(eps, map);
|
||||
expect(res.feature.episodes).toBe(1);
|
||||
});
|
||||
|
||||
it('returns empty object on empty input', () => {
|
||||
expect(disciplinePercentByClassification([], map)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveRouterStep', () => {
|
||||
// Маппинг наблюдаемых признаков primary_rationale → шаг router-procedure.md
|
||||
// (1 hard-floor → 2 классификация → 3 триггеры → 4 цепочка → 5 исполнение узла).
|
||||
// Берётся МАКСИМУМ достигнутой стадии. Хранимое pr.step игнорируется.
|
||||
it('returns 1 for a bare direct episode (hard-floor only, no signals)', () => {
|
||||
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: [], node_chosen: 'direct' })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2 when a real task_classification was produced', () => {
|
||||
expect(deriveRouterStep({ task_classification: 'feature', triggers_matched: [], chain_ref: [], node_chosen: 'direct' })).toBe(2);
|
||||
});
|
||||
|
||||
it("treats 'other' classification as not reaching step 2", () => {
|
||||
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: null, node_chosen: 'direct' })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 3 when triggers matched', () => {
|
||||
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [{ keyword: 'x' }], chain_ref: [], node_chosen: 'direct' })).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 4 when a chain was referenced (array or non-empty string)', () => {
|
||||
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: ['routing-off-phase L1'], node_chosen: 'direct' })).toBe(4);
|
||||
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: 'L1', node_chosen: 'direct' })).toBe(4);
|
||||
});
|
||||
|
||||
it('returns 5 when a node was actually chosen (execution)', () => {
|
||||
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: [], node_chosen: '#19' })).toBe(5);
|
||||
});
|
||||
|
||||
it('takes the furthest stage reached (max), not the first', () => {
|
||||
expect(deriveRouterStep({ task_classification: 'feature', triggers_matched: [{ k: 1 }], chain_ref: [], node_chosen: '#19' })).toBe(5);
|
||||
});
|
||||
|
||||
it('handles a missing/empty primary_rationale → 1', () => {
|
||||
expect(deriveRouterStep(undefined)).toBe(1);
|
||||
expect(deriveRouterStep({})).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routerStepReached (derived from observable signals)', () => {
|
||||
// Признаковые шаблоны (хранимый step специально проставлен 1/99 — должен игнорироваться).
|
||||
const at = {
|
||||
1: { task_classification: 'other', triggers_matched: [], chain_ref: [], node_chosen: 'direct' },
|
||||
3: { task_classification: 'other', triggers_matched: [{ k: 1 }], chain_ref: [], node_chosen: 'direct' },
|
||||
5: { task_classification: 'feature', triggers_matched: [], chain_ref: [], node_chosen: '#19' },
|
||||
};
|
||||
|
||||
it('counts episodes by derived step, ignoring any stored pr.step value', () => {
|
||||
const eps = [
|
||||
ep({ primary_rationale: { ...at[1], step: 1 } }),
|
||||
ep({ primary_rationale: { ...at[1], step: 99 } }),
|
||||
ep({ primary_rationale: { ...at[3], step: 1 } }),
|
||||
ep({ primary_rationale: { ...at[5], step: 1 } }),
|
||||
];
|
||||
const res = routerStepReached(eps);
|
||||
expect(res.distribution['1']).toBe(2);
|
||||
expect(res.distribution['3']).toBe(1);
|
||||
expect(res.distribution['5']).toBe(1);
|
||||
expect(res.total).toBe(4);
|
||||
});
|
||||
|
||||
it('flags suspicious=true when >90% эпизодов выводятся в step 1', () => {
|
||||
const eps = Array.from({ length: 11 }, (_, i) =>
|
||||
ep({ primary_rationale: i === 10 ? { ...at[3], step: 1 } : { ...at[1], step: 1 } })
|
||||
);
|
||||
expect(routerStepReached(eps).suspicious).toBe(true);
|
||||
});
|
||||
|
||||
it('suspicious=false when distribution более равномерное', () => {
|
||||
const eps = [
|
||||
ep({ primary_rationale: { ...at[1], step: 1 } }),
|
||||
ep({ primary_rationale: { ...at[3], step: 1 } }),
|
||||
ep({ primary_rationale: { ...at[5], step: 1 } }),
|
||||
];
|
||||
expect(routerStepReached(eps).suspicious).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores v1 episodes and observer_error markers', () => {
|
||||
const eps = [
|
||||
{ schema_version: 1, primary_rationale: { ...at[5] } },
|
||||
{ observer_error: true },
|
||||
ep({ primary_rationale: { ...at[3], step: 1 } }),
|
||||
];
|
||||
const res = routerStepReached(eps);
|
||||
expect(res.distribution).toEqual({ '3': 1 });
|
||||
expect(res.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundariesAppliedRate', () => {
|
||||
it('counts overall rate of boundaries applied', () => {
|
||||
const eps = [
|
||||
ep({ primary_rationale: { boundaries_applied: [{ adr: 'ADR-001' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
|
||||
ep({ primary_rationale: { boundaries_applied: [], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
|
||||
ep({ primary_rationale: { boundaries_applied: [{ adr: 'ADR-002' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
|
||||
];
|
||||
const res = boundariesAppliedRate(eps);
|
||||
expect(res.total).toBe(3);
|
||||
expect(res.withBoundaries).toBe(2);
|
||||
expect(res.rate).toBeCloseTo(2 / 3);
|
||||
});
|
||||
|
||||
it('splits by path_type', () => {
|
||||
const eps = [
|
||||
ep({ path_type: 'regulated', primary_rationale: { boundaries_applied: [{ adr: 'X' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
|
||||
ep({ path_type: 'regulated', primary_rationale: { boundaries_applied: [], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
|
||||
ep({ path_type: 'free', primary_rationale: { boundaries_applied: [{ adr: 'Y' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
|
||||
];
|
||||
const res = boundariesAppliedRate(eps);
|
||||
expect(res.byPathType.regulated.total).toBe(2);
|
||||
expect(res.byPathType.regulated.withBoundaries).toBe(1);
|
||||
expect(res.byPathType.free.total).toBe(1);
|
||||
expect(res.byPathType.free.withBoundaries).toBe(1);
|
||||
});
|
||||
|
||||
it('returns rate=0 on empty input', () => {
|
||||
expect(boundariesAppliedRate([])).toEqual({
|
||||
total: 0, withBoundaries: 0, rate: 0, byPathType: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema_version": 1,
|
||||
"description": "Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime. Classifier vocabulary is Claude's free judgment when writing the episode (no hardcoded enum) — adding a key here makes it 'blessed'. 'security' added 22.05.2026 (A8 follow-up): use when the PURPOSE of the task is verifying or improving security (scans, hardening, audits, threat modeling, go-live gates); NOT for bug-fixes that happen to be in security-relevant code (those stay 'bugfix'). 'marketing' added 22.05.2026 (C1 follow-up): use when the PURPOSE of the task is Лидерра's own marketing/lead-generation (content, SEO, campaigns, RU-channels, landing conversion, marketing-side 152-FZ); NOT for product features, billing flows, or PII-code audits. 'question' emptied 23.05.2026 (brain-retro #3 A1): conversational Russian Q&A («делай», «а», уточнения) was producing 17/40 false-positive missed-activations against #60 context7 — context7 is for library-docs lookup, not chat. 'memory-sync' emptied 23.05.2026 (brain-retro #3 A2): #33 claude-md-management is the channel for CLAUDE.md edits (Pravila §5 п.10), NOT for memory/*.md (auto-memory writes natively); was producing 8/40 false-positive missed-activations.",
|
||||
"description": "DEPRECATED (2026-05-24): source of truth migrated to docs/registry/nodes.yaml + tools/registry-to-classification-map.mjs. This file is retained ONLY for historic v2-episode replay in tests; new code MUST consume the registry. Removal scheduled for stage 4 of router-discipline-overhaul. Original description follows. — Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime. Classifier vocabulary is Claude's free judgment when writing the episode (no hardcoded enum) — adding a key here makes it 'blessed'. 'security' added 22.05.2026 (A8 follow-up): use when the PURPOSE of the task is verifying or improving security (scans, hardening, audits, threat modeling, go-live gates); NOT for bug-fixes that happen to be in security-relevant code (those stay 'bugfix'). 'marketing' added 22.05.2026 (C1 follow-up): use when the PURPOSE of the task is Лидерра's own marketing/lead-generation (content, SEO, campaigns, RU-channels, landing conversion, marketing-side 152-FZ); NOT for product features, billing flows, or PII-code audits. 'question' emptied 23.05.2026 (brain-retro #3 A1): conversational Russian Q&A («делай», «а», уточнения) was producing 17/40 false-positive missed-activations against #60 context7 — context7 is for library-docs lookup, not chat. 'memory-sync' emptied 23.05.2026 (brain-retro #3 A2): #33 claude-md-management is the channel for CLAUDE.md edits (Pravila §5 п.10), NOT for memory/*.md (auto-memory writes natively); was producing 8/40 false-positive missed-activations.",
|
||||
"map": {
|
||||
"refactor": ["#11", "#12", "#43", "#64", "#65"],
|
||||
"bugfix": ["#18", "#34"],
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Router state enricher for observer episodes.
|
||||
* Reads ~/.claude/runtime/router-state-<sessionId>.json and exposes pure
|
||||
* extraction helpers for primary_rationale enrichment.
|
||||
*
|
||||
* Pure-ish — fs is parameterized via options.baseDir for testability.
|
||||
*
|
||||
* Per spec: docs/superpowers/specs/2026-05-24-router-stage3-three-fixes-design.md
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
function defaultBaseDir() {
|
||||
return join(homedir(), '.claude', 'runtime');
|
||||
}
|
||||
|
||||
export function readRouterState(sessionId, options = {}) {
|
||||
if (!sessionId || typeof sessionId !== 'string') return null;
|
||||
const baseDir = options.baseDir || defaultBaseDir();
|
||||
const path = join(baseDir, `router-state-${sessionId}.json`);
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractRouterFields(state) {
|
||||
if (!state || typeof state !== 'object') {
|
||||
return { recommended_node: null, recommended_chain: null, chain_progress: [], chain_completed: false };
|
||||
}
|
||||
const cls = state.classification || {};
|
||||
return {
|
||||
recommended_node: cls.recommendedNode || null,
|
||||
recommended_chain: cls.recommendedChain || null,
|
||||
chain_progress: Array.isArray(state.chainProgress) ? state.chainProgress : [],
|
||||
chain_completed: state.chainCompleted === true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { readRouterState } from './observer-state-enricher.mjs';
|
||||
|
||||
describe('readRouterState', () => {
|
||||
let baseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null when state file does not exist', () => {
|
||||
expect(readRouterState('abc-123', { baseDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('reads state file when present', () => {
|
||||
const state = {
|
||||
sessionId: 'abc-123',
|
||||
classification: { recommendedNode: '#62', recommendedChain: '#13' },
|
||||
chainProgress: ['brainstorming'],
|
||||
chainCompleted: false,
|
||||
};
|
||||
writeFileSync(join(baseDir, 'router-state-abc-123.json'), JSON.stringify(state));
|
||||
const result = readRouterState('abc-123', { baseDir });
|
||||
expect(result).toEqual(state);
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON', () => {
|
||||
writeFileSync(join(baseDir, 'router-state-broken.json'), 'not-json');
|
||||
expect(readRouterState('broken', { baseDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on missing sessionId', () => {
|
||||
expect(readRouterState(null, { baseDir })).toBeNull();
|
||||
expect(readRouterState('', { baseDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('uses ~/.claude/runtime/ as default baseDir', () => {
|
||||
// Smoke-check: default baseDir resolution doesn't throw.
|
||||
// Real-file reading covered above with explicit baseDir.
|
||||
const result = readRouterState('non-existent-session-xyz');
|
||||
// Either null (file doesn't exist there) or object — both fine.
|
||||
expect(result === null || typeof result === 'object').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRouterFields', () => {
|
||||
it('extracts the four fields from state, defaulting to null/empty', async () => {
|
||||
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
|
||||
const state = {
|
||||
classification: { recommendedNode: '#62', recommendedChain: '#13' },
|
||||
chainProgress: ['brainstorming', 'writing-plans'],
|
||||
chainCompleted: false,
|
||||
};
|
||||
expect(extractRouterFields(state)).toEqual({
|
||||
recommended_node: '#62',
|
||||
recommended_chain: '#13',
|
||||
chain_progress: ['brainstorming', 'writing-plans'],
|
||||
chain_completed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nulls/empty when state is null', async () => {
|
||||
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
|
||||
expect(extractRouterFields(null)).toEqual({
|
||||
recommended_node: null,
|
||||
recommended_chain: null,
|
||||
chain_progress: [],
|
||||
chain_completed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing classification block', async () => {
|
||||
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
|
||||
expect(extractRouterFields({ chainProgress: ['x'], chainCompleted: true })).toEqual({
|
||||
recommended_node: null,
|
||||
recommended_chain: null,
|
||||
chain_progress: ['x'],
|
||||
chain_completed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty string recommendedNode/recommendedChain as null', async () => {
|
||||
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
|
||||
expect(extractRouterFields({ classification: { recommendedNode: '', recommendedChain: '' } })).toEqual({
|
||||
recommended_node: null,
|
||||
recommended_chain: null,
|
||||
chain_progress: [],
|
||||
chain_completed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { readRouterState, extractRouterFields } from './observer-state-enricher.mjs';
|
||||
import { homedir } from 'node:os';
|
||||
import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs';
|
||||
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
|
||||
@@ -751,13 +752,17 @@ function extractLastAssistantContent(entries, turnStartIdx) {
|
||||
* @param {string|null} fallbackSessionId - Used when the transcript has no sessionId.
|
||||
* @returns {object} v2 episode.
|
||||
*/
|
||||
export function parseTranscript(transcriptText, fallbackSessionId = null) {
|
||||
export function parseTranscript(transcriptText, fallbackSessionId = null, options = {}) {
|
||||
const { entries, broken, total } = parseLines(transcriptText);
|
||||
|
||||
const withSession = entries.find((e) => e && e.sessionId);
|
||||
const sessionId =
|
||||
(withSession && withSession.sessionId) || fallbackSessionId || `unknown-${Date.now()}`;
|
||||
|
||||
const routerStateBaseDir = options.routerStateBaseDir;
|
||||
const routerState = readRouterState(sessionId, routerStateBaseDir ? { baseDir: routerStateBaseDir } : {});
|
||||
const routerFields = extractRouterFields(routerState);
|
||||
|
||||
const start = findTurnStart(entries);
|
||||
const turn = entries.slice(start);
|
||||
|
||||
@@ -809,6 +814,10 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) {
|
||||
primary_rationale: (() => {
|
||||
const tag = parseReasoningTag(turn);
|
||||
const merge = (heur, fromTag) => [...new Set([...heur, ...fromTag])];
|
||||
const classifMapNode =
|
||||
skills.length === 0
|
||||
? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy())
|
||||
: null;
|
||||
return {
|
||||
step: 1,
|
||||
node_chosen: skills.length > 0 ? skills[0] : 'direct',
|
||||
@@ -820,10 +829,10 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) {
|
||||
? { invoked: true, rules: ['Pravila §12'] }
|
||||
: { invoked: false, rules: [] },
|
||||
task_classification: classifyTask(prompt),
|
||||
recommended_node:
|
||||
skills.length === 0
|
||||
? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy())
|
||||
: null,
|
||||
recommended_node: routerFields.recommended_node !== null ? routerFields.recommended_node : classifMapNode,
|
||||
recommended_chain: routerFields.recommended_chain,
|
||||
chain_progress: routerFields.chain_progress,
|
||||
chain_completed: routerFields.chain_completed,
|
||||
};
|
||||
})(),
|
||||
events,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
parseTranscript,
|
||||
extractEnvironment,
|
||||
@@ -1655,3 +1658,59 @@ describe('parseTranscript v3 fields', () => {
|
||||
expect(typeof hookEvent.scripts).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTranscript — router-state enrichment (Task 3)', () => {
|
||||
function makeTranscript(sessionId) {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'добавь новый endpoint /api/bar' },
|
||||
timestamp: '2026-05-24T10:00:00Z',
|
||||
uuid: 'u-t3-1',
|
||||
sessionId,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
|
||||
timestamp: '2026-05-24T10:00:01Z',
|
||||
uuid: 'u-t3-2',
|
||||
sessionId,
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
it('enriches primary_rationale from router-state file when present', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
|
||||
const sessionId = 'test-session-t3-enrich';
|
||||
const state = {
|
||||
classification: { recommendedNode: '#42', recommendedChain: 'L13' },
|
||||
chainProgress: ['step-a', 'step-b'],
|
||||
chainCompleted: false,
|
||||
};
|
||||
writeFileSync(join(dir, `router-state-${sessionId}.json`), JSON.stringify(state));
|
||||
try {
|
||||
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
|
||||
expect(ep.primary_rationale.recommended_node).toBe('#42');
|
||||
expect(ep.primary_rationale.recommended_chain).toBe('L13');
|
||||
expect(ep.primary_rationale.chain_progress).toEqual(['step-a', 'step-b']);
|
||||
expect(ep.primary_rationale.chain_completed).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back gracefully when router-state file is absent', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
|
||||
const sessionId = 'test-session-t3-missing';
|
||||
try {
|
||||
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
|
||||
// recommended_node falls back to classification-map result (direct episode → feature → #19)
|
||||
expect(ep.primary_rationale.recommended_node).toBe('#19');
|
||||
expect(ep.primary_rationale.recommended_chain).toBeNull();
|
||||
expect(ep.primary_rationale.chain_progress).toEqual([]);
|
||||
expect(ep.primary_rationale.chain_completed).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,9 @@ import { loadRegistry, clearCache, findByClassification, findByKeyword, findActi
|
||||
describe('registry-load', () => {
|
||||
beforeEach(() => clearCache());
|
||||
|
||||
it('loads registry (83 nodes after task 8d)', () => {
|
||||
it('loads registry (85 nodes after #84/#85 project-agents added 24.05.2026)', () => {
|
||||
const r = loadRegistry();
|
||||
expect(r.nodes).toHaveLength(83);
|
||||
expect(r.nodes).toHaveLength(85);
|
||||
expect(r.version).toBe('0.1.0');
|
||||
});
|
||||
|
||||
@@ -46,8 +46,9 @@ describe('registry-load', () => {
|
||||
it('findActiveNodes excludes non-active', () => {
|
||||
const r = loadRegistry();
|
||||
const active = findActiveNodes(r);
|
||||
// After task 8d: 83 nodes total; #1 historic, #17 dormant, #44/#50/#54/#67/#82/#83 deferred → 75 active
|
||||
expect(active).toHaveLength(75);
|
||||
// 85 nodes total; #1 historic, #17 dormant, #44/#50/#54/#67/#82/#83 deferred,
|
||||
// #84/#85 (project-agents added 24.05.2026) are active → 75 + 2 = 77 active
|
||||
expect(active).toHaveLength(77);
|
||||
expect(active.map(n => n.id)).toContain('#18');
|
||||
expect(active.map(n => n.id)).toContain('#19');
|
||||
expect(active.map(n => n.id)).not.toContain('#1');
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pure adapter: docs/registry/nodes.yaml → {classificationMap, dormancy}.
|
||||
*
|
||||
* Replaces tools/observer-classification-map.json (single point of edit
|
||||
* для маппинга «task_classification → recommended node ids») and
|
||||
* tools/extract-node-dormancy.mjs (Tooling §4.X scraping for status).
|
||||
*
|
||||
* Pure / read-only. No exec, no fs (caller passes loaded registry).
|
||||
* Source of truth for missed-activation detection (Pravila §16.4 v1.36).
|
||||
*
|
||||
* Security Guidance #40: pure parsing — no exec/execSync.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Group active-status nodes by their `classification:` trigger value.
|
||||
* Returns `{ [classification]: [nodeId, ...] }`. Nodes without classification
|
||||
* triggers, or non-active (dormant/deferred/historic), are excluded.
|
||||
*/
|
||||
export function buildClassificationMap(registry) {
|
||||
const out = {};
|
||||
for (const node of registry.nodes || []) {
|
||||
if (node.status !== 'active') continue;
|
||||
for (const t of node.triggers || []) {
|
||||
if (!t.classification) continue;
|
||||
const c = t.classification;
|
||||
if (!out[c]) out[c] = [];
|
||||
if (!out[c].includes(node.id)) out[c].push(node.id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dormancy map for missed-activations consumer: id → true iff node is
|
||||
* effectively unavailable (status ∈ {dormant, deferred, historic}).
|
||||
* Active nodes: false. Unknown nodes: absent.
|
||||
*/
|
||||
export function buildDormancyMap(registry) {
|
||||
const out = {};
|
||||
for (const node of registry.nodes || []) {
|
||||
out[node.id] = node.status !== 'active';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';
|
||||
|
||||
function node(id, status, classificationTriggers) {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
triggers: classificationTriggers.map(c => ({ classification: c, weight: 1.0 })),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = {
|
||||
nodes: [
|
||||
node('#11', 'active', ['refactor', 'cleanup']),
|
||||
node('#12', 'active', ['refactor', 'cleanup']),
|
||||
node('#17', 'dormant', ['refactor']),
|
||||
node('#44', 'deferred', ['feature']),
|
||||
node('#1', 'historic', ['analysis']),
|
||||
node('#19', 'active', ['feature', 'planning']),
|
||||
node('#99', 'active', []), // no classification triggers — ignored
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildClassificationMap', () => {
|
||||
it('groups active nodes by classification', () => {
|
||||
const map = buildClassificationMap(registry);
|
||||
expect(map.refactor.sort()).toEqual(['#11', '#12']);
|
||||
expect(map.feature).toEqual(['#19']);
|
||||
expect(map.planning).toEqual(['#19']);
|
||||
expect(map.cleanup.sort()).toEqual(['#11', '#12']);
|
||||
});
|
||||
|
||||
it('excludes dormant nodes', () => {
|
||||
const map = buildClassificationMap(registry);
|
||||
expect(map.refactor).not.toContain('#17');
|
||||
});
|
||||
|
||||
it('excludes deferred nodes', () => {
|
||||
const map = buildClassificationMap(registry);
|
||||
expect(map.feature || []).not.toContain('#44');
|
||||
});
|
||||
|
||||
it('excludes historic nodes', () => {
|
||||
const map = buildClassificationMap(registry);
|
||||
expect(map.analysis).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores nodes with no classification triggers', () => {
|
||||
const map = buildClassificationMap(registry);
|
||||
expect(Object.values(map).flat()).not.toContain('#99');
|
||||
});
|
||||
|
||||
it('returns an empty object on an empty registry', () => {
|
||||
expect(buildClassificationMap({ nodes: [] })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDormancyMap', () => {
|
||||
it('marks dormant nodes true', () => {
|
||||
const dorm = buildDormancyMap(registry);
|
||||
expect(dorm['#17']).toBe(true);
|
||||
});
|
||||
|
||||
it('marks deferred nodes true', () => {
|
||||
const dorm = buildDormancyMap(registry);
|
||||
expect(dorm['#44']).toBe(true);
|
||||
});
|
||||
|
||||
it('marks historic nodes true', () => {
|
||||
const dorm = buildDormancyMap(registry);
|
||||
expect(dorm['#1']).toBe(true);
|
||||
});
|
||||
|
||||
it('marks active nodes false', () => {
|
||||
const dorm = buildDormancyMap(registry);
|
||||
expect(dorm['#11']).toBe(false);
|
||||
expect(dorm['#19']).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Accuracy runner — прогоняет 20 промптов через classifier (без LLM, regex only)
|
||||
* и выдаёт отчёт «правильно/неправильно» по каждому пункту.
|
||||
*
|
||||
* Использовать перед регистрацией router-prehook в settings.json.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { classifyByRegex } from './router-classifier.mjs';
|
||||
import { loadRegistry } from './registry-load.mjs';
|
||||
|
||||
function main() {
|
||||
const promptsFile = process.argv[2] || 'tools/router-test-prompts.json';
|
||||
const data = JSON.parse(readFileSync(promptsFile, 'utf-8'));
|
||||
const registry = loadRegistry({ useCache: false });
|
||||
|
||||
let correctType = 0, correctNode = 0, correctMicro = 0, total = data.prompts.length;
|
||||
const failures = [];
|
||||
|
||||
for (const p of data.prompts) {
|
||||
const r = classifyByRegex(p.text, registry);
|
||||
const typeOk = r.taskType === p.expectedType;
|
||||
const nodeOk = r.recommendedNode === p.expectedNode;
|
||||
const microOk = r.micro === p.expectedMicro;
|
||||
if (typeOk) correctType++;
|
||||
if (nodeOk) correctNode++;
|
||||
if (microOk) correctMicro++;
|
||||
if (!typeOk || !nodeOk || !microOk) {
|
||||
failures.push({
|
||||
prompt: p.text,
|
||||
expected: { type: p.expectedType, node: p.expectedNode, micro: p.expectedMicro },
|
||||
actual: { type: r.taskType, node: r.recommendedNode, micro: r.micro },
|
||||
deltas: { type: !typeOk, node: !nodeOk, micro: !microOk },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Accuracy Report ===');
|
||||
console.log(`Type accuracy: ${correctType}/${total} = ${(correctType / total * 100).toFixed(1)}%`);
|
||||
console.log(`Node accuracy: ${correctNode}/${total} = ${(correctNode / total * 100).toFixed(1)}%`);
|
||||
console.log(`Micro accuracy: ${correctMicro}/${total} = ${(correctMicro / total * 100).toFixed(1)}%`);
|
||||
console.log('');
|
||||
console.log(`Failures (${failures.length}):`);
|
||||
for (const f of failures) {
|
||||
console.log(` «${f.prompt}»`);
|
||||
console.log(` expected: type=${f.expected.type}, node=${f.expected.node}, micro=${f.expected.micro}`);
|
||||
console.log(` actual: type=${f.actual.type}, node=${f.actual.node}, micro=${f.actual.micro}`);
|
||||
}
|
||||
|
||||
const passOverall = (correctType + correctNode + correctMicro) / (total * 3);
|
||||
process.exit(passOverall >= 0.75 ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Router classifier — pure regex Layer 1 + LLM Layer 2 (escalation).
|
||||
* Stage 3 of router discipline overhaul.
|
||||
*
|
||||
* Layer 1: regex по реестровым keyword/classification триггерам активных узлов.
|
||||
* Возвращает { taskType, micro, recommendedNode, confidence, source: 'regex' }.
|
||||
*
|
||||
* Layer 2 (см. classifyByLLM): Sonnet с реестром в prompt'е.
|
||||
*
|
||||
* Pure (Layer 1): read-only, никакого fs/exec/net. Caller передаёт registry.
|
||||
*/
|
||||
|
||||
// Порядок ключей значим: detectTaskType возвращает первое совпадение.
|
||||
// Специфичные домены (marketing/security) идут ДО общего analysis, чтобы
|
||||
// «проверь пдн» ушло в security, а «проверь индекс» — в analysis.
|
||||
const TASK_TYPE_KEYWORDS = {
|
||||
feature: ['фич', 'feature', 'новый функционал', 'add feature'],
|
||||
planning: ['план', 'plan', 'спека', 'spec', 'roadmap', 'распиши', 'спланируй'],
|
||||
bugfix: ['баг', 'bug', 'дебаг', 'debug', 'почини', 'fix', 'ошибк', 'не работает',
|
||||
'поправь', 'исправь', 'упал', 'падает', 'сломал'],
|
||||
refactor: ['рефактор', 'refactor', 'почисти код', 'упрости'],
|
||||
cleanup: ['уберём', 'удали', 'remove', 'cleanup', 'dead code'],
|
||||
marketing: ['маркетинг', 'marketing', 'кампани', 'лендинг', 'рассылк', 'реклам', 'постинг'],
|
||||
security: ['безопасност', 'security', 'уязвимост', 'vulnerability',
|
||||
'пдн', '152-фз', 'stride', 'угроз', 'выход в интернет', 'go-live'],
|
||||
analysis: ['проанализируй', 'analysis', 'разбер', 'investigate',
|
||||
'проверь', 'выясни', 'посмотри почему', 'медленн'],
|
||||
monitoring: ['мониторинг', 'monitor', 'трейс', 'observability'],
|
||||
'memory-sync': ['запомни', 'обнови память', 'memory', 'CLAUDE.md', 'MEMORY.md'],
|
||||
question: ['что такое', 'как работает', 'почему', 'объясни', 'расскажи'],
|
||||
};
|
||||
|
||||
const MICRO_KEYWORDS = [
|
||||
'опечатк', 'typo',
|
||||
'переименуй', 'rename',
|
||||
'удали мёртв', 'dead code',
|
||||
'формат', 'format',
|
||||
'константу', 'one constant',
|
||||
'увеличь', 'уменьши', 'поменяй значени', 'измени константу',
|
||||
'одну строку', 'bump',
|
||||
];
|
||||
|
||||
function lower(s) { return String(s || '').toLowerCase(); }
|
||||
|
||||
function detectTaskType(prompt) {
|
||||
const p = lower(prompt);
|
||||
for (const [t, kws] of Object.entries(TASK_TYPE_KEYWORDS)) {
|
||||
for (const kw of kws) {
|
||||
if (p.includes(kw)) return t;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function detectMicro(prompt) {
|
||||
const p = lower(prompt);
|
||||
return MICRO_KEYWORDS.some((kw) => p.includes(kw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexible keyword matching: handles RU morphology by checking if
|
||||
* - prompt contains the keyword (exact), OR
|
||||
* - keyword contains the prompt fragment (keyword starts with what's in prompt), OR
|
||||
* - prompt fragment starts with the keyword stem (first 6+ chars of keyword)
|
||||
*/
|
||||
function keywordMatches(promptLower, keywordLower) {
|
||||
if (promptLower.includes(keywordLower)) return true;
|
||||
// Stem match: use first 6 chars of keyword as stem (handles inflections like рассылку vs рассылка)
|
||||
if (keywordLower.length >= 6) {
|
||||
const stem = keywordLower.slice(0, -1); // drop last char for RU inflection tolerance
|
||||
if (promptLower.includes(stem)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectRecommendedNode(prompt, registry) {
|
||||
const p = lower(prompt);
|
||||
|
||||
// Pass 1 — keyword-домен приоритетнее classification-типа: точное доменное
|
||||
// слово в промпте («списание» → #62) выигрывает у общего classification-узла
|
||||
// («bugfix» → #18 Pest). Длиннее keyword = специфичнее → выше приоритет
|
||||
// при равных весах.
|
||||
let bestKw = { id: null, score: 0 };
|
||||
for (const node of registry.nodes || []) {
|
||||
if (node.status !== 'active') continue;
|
||||
for (const t of node.triggers || []) {
|
||||
if (!t.keyword) continue;
|
||||
const kw = lower(t.keyword);
|
||||
if (keywordMatches(p, kw)) {
|
||||
const score = (t.weight ?? 1.0) + kw.length / 1000;
|
||||
if (score > bestKw.score) bestKw = { id: node.id, score };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestKw.id) return bestKw.id;
|
||||
|
||||
// Pass 2 — fallback на classification-триггер, если ни один keyword не совпал.
|
||||
const taskType = detectTaskType(prompt);
|
||||
let bestCls = { id: null, weight: 0 };
|
||||
for (const node of registry.nodes || []) {
|
||||
if (node.status !== 'active') continue;
|
||||
for (const t of node.triggers || []) {
|
||||
if (!t.classification) continue;
|
||||
const w = t.weight ?? 1.0;
|
||||
if (t.classification === taskType && w > bestCls.weight) {
|
||||
bestCls = { id: node.id, weight: w };
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestCls.id;
|
||||
}
|
||||
|
||||
// Hard keyword stems that signal a high-confidence match
|
||||
const HARD_KEYWORD_STEMS = [
|
||||
'списан', 'биллинг', 'маркетинг', 'email-рассылк',
|
||||
'152-фз', 'go-live', 'фич', 'план', 'баг',
|
||||
];
|
||||
|
||||
function computeConfidence(taskType, recommendedNode, prompt) {
|
||||
if (recommendedNode === null && taskType === 'unknown') return 0.1;
|
||||
if (recommendedNode === null) return 0.4;
|
||||
// Keyword match даёт high confidence; classification-only — medium.
|
||||
const p = lower(prompt);
|
||||
const hasHardKeyword = HARD_KEYWORD_STEMS.some((stem) => p.includes(stem));
|
||||
if (hasHardKeyword) return 0.9;
|
||||
if (taskType === 'unknown') return 0.5;
|
||||
return 0.7;
|
||||
}
|
||||
|
||||
export function classifyByRegex(prompt, registry) {
|
||||
const taskType = detectTaskType(prompt);
|
||||
const micro = detectMicro(prompt);
|
||||
const recommendedNode = detectRecommendedNode(prompt, registry);
|
||||
const confidence = computeConfidence(taskType, recommendedNode, prompt);
|
||||
return { taskType, micro, recommendedNode, confidence, source: 'regex' };
|
||||
}
|
||||
|
||||
// ─── Layer 2: LLM escalation ────────────────────────────────────────────────
|
||||
|
||||
const LLM_SYSTEM_PROMPT = `You are a router classifier for an AI coding assistant. Given a user prompt and a registry of available skills/tools (nodes), choose:
|
||||
- taskType: one of {feature, planning, bugfix, refactor, cleanup, marketing, security, analysis, monitoring, memory-sync, question, unknown}
|
||||
- micro: true if the task is a tiny edit (≤2 files, ≤20 lines, e.g. typo / rename / single constant)
|
||||
- recommendedNode: id of the single best-matching active node, or null if nothing matches
|
||||
- confidence: 0.0-1.0
|
||||
- recommendedChain: id of the chain (L1-L16) if the task fits a known chain, else null
|
||||
- reasoning: 1-2 sentences why
|
||||
|
||||
Reply with ONLY a JSON object, no prose. Example:
|
||||
{"taskType":"bugfix","micro":false,"recommendedNode":"#62","confidence":0.9,"recommendedChain":null,"reasoning":"keyword 'списание' matches #62 billing-audit"}`;
|
||||
|
||||
export function buildLLMPrompt(prompt, registry) {
|
||||
const nodes = (registry.nodes || []).filter((n) => n.status === 'active');
|
||||
const nodeLines = nodes.map((n) => {
|
||||
const triggers = (n.triggers || [])
|
||||
.slice(0, 3)
|
||||
.map((t) => t.keyword || `cls:${t.classification}`)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return `- ${n.id} ${n.name} [${triggers}]`;
|
||||
}).join('\n');
|
||||
|
||||
const chains = Object.entries(registry.chains || {})
|
||||
.map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`)
|
||||
.join('\n');
|
||||
|
||||
return `${LLM_SYSTEM_PROMPT}
|
||||
|
||||
## Available nodes
|
||||
${nodeLines}
|
||||
|
||||
## Available chains
|
||||
${chains}
|
||||
|
||||
## User prompt
|
||||
${prompt}
|
||||
|
||||
Reply with JSON object only.`;
|
||||
}
|
||||
|
||||
export function parseLLMResponse(text) {
|
||||
if (!text) return null;
|
||||
const trimmed = String(text).trim();
|
||||
// Strip ```json``` wrapper if present
|
||||
const stripped = trimmed.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim();
|
||||
try {
|
||||
const parsed = JSON.parse(stripped);
|
||||
if (typeof parsed.taskType !== 'string') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldEscalate(regexResult) {
|
||||
if (regexResult.micro) return false;
|
||||
if (regexResult.confidence >= 0.7) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// LLM Layer 2 ходит через реселлера ProxyAPI (официальный api.anthropic.com
|
||||
// недоступен из РФ). Базовый URL переопределяется ROUTER_LLM_BASE_URL — на
|
||||
// случай смены реселлера или возврата на официальный эндпоинт.
|
||||
const DEFAULT_LLM_BASE_URL = 'https://api.proxyapi.ru/anthropic';
|
||||
|
||||
export async function callAnthropicAPI(prompt, {
|
||||
apiKey,
|
||||
baseUrl = DEFAULT_LLM_BASE_URL,
|
||||
model = 'claude-haiku-4-5',
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const url = `${String(baseUrl).replace(/\/+$/, '')}/v1/messages`;
|
||||
const r = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// ProxyAPI ждёт Bearer, официальный API — x-api-key. Шлём оба:
|
||||
// каждый эндпоинт берёт нужный заголовок и игнорирует чужой.
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 300,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Router LLM ${r.status}: ${await r.text()}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
return data.content?.[0]?.text || '';
|
||||
}
|
||||
|
||||
function hashPrompt(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = ((h << 5) - h) + s.charCodeAt(i);
|
||||
h |= 0;
|
||||
}
|
||||
return String(h);
|
||||
}
|
||||
|
||||
export async function classify(prompt, registry, options = {}) {
|
||||
const regexResult = classifyByRegex(prompt, registry);
|
||||
if (!shouldEscalate(regexResult)) return regexResult;
|
||||
|
||||
const cache = options.cache;
|
||||
const key = hashPrompt(prompt);
|
||||
if (cache && cache.has(key)) {
|
||||
return { ...cache.get(key), source: 'cache' };
|
||||
}
|
||||
|
||||
const llmCall = options.llmCall || (async () => {
|
||||
// Ключ берём из ОТДЕЛЬНОЙ переменной ROUTER_LLM_KEY, НЕ из ANTHROPIC_API_KEY:
|
||||
// иначе ключ перехватит сам Claude Code и уведёт основную сессию с подписки
|
||||
// на платный API. Нет ключа → Layer 2 выключен, тихо остаёмся на regex.
|
||||
const apiKey = process.env.ROUTER_LLM_KEY;
|
||||
if (!apiKey) return null;
|
||||
const llmPrompt = buildLLMPrompt(prompt, registry);
|
||||
const text = await callAnthropicAPI(llmPrompt, {
|
||||
apiKey,
|
||||
baseUrl: process.env.ROUTER_LLM_BASE_URL || undefined,
|
||||
});
|
||||
return parseLLMResponse(text);
|
||||
});
|
||||
|
||||
let llmResult;
|
||||
try {
|
||||
llmResult = await llmCall();
|
||||
} catch (err) {
|
||||
// LLM-down — fallback to regex result with diagnostic flag
|
||||
return { ...regexResult, llmError: err.message };
|
||||
}
|
||||
|
||||
if (!llmResult) return regexResult; // unparseable — fallback
|
||||
|
||||
const finalResult = { ...llmResult, source: 'llm' };
|
||||
if (cache) cache.set(key, finalResult);
|
||||
return finalResult;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { classifyByRegex } from './router-classifier.mjs';
|
||||
|
||||
const fakeRegistry = {
|
||||
nodes: [
|
||||
{ id: '#19', name: 'Superpowers', status: 'active', triggers: [
|
||||
{ classification: 'feature', weight: 1.0 },
|
||||
{ classification: 'planning', weight: 1.0 },
|
||||
] },
|
||||
{ id: '#62', name: 'billing-audit', status: 'active', triggers: [
|
||||
{ keyword: 'списание', weight: 1.0 },
|
||||
{ keyword: 'биллинг', weight: 1.0 },
|
||||
{ classification: 'bugfix', weight: 0.5 },
|
||||
] },
|
||||
{ id: '#74', name: 'marketing', status: 'active', triggers: [
|
||||
{ keyword: 'email-рассылка', weight: 1.0 },
|
||||
{ keyword: 'кампания', weight: 1.0 },
|
||||
{ classification: 'marketing', weight: 1.0 },
|
||||
] },
|
||||
{ id: '#11', name: 'pint', status: 'active', triggers: [
|
||||
{ classification: 'refactor', weight: 1.0 },
|
||||
{ classification: 'cleanup', weight: 1.0 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
|
||||
describe('classifyByRegex — task type', () => {
|
||||
it('detects feature from RU keyword «фича»', () => {
|
||||
const r = classifyByRegex('давай сделаем новую фичу для биллинга', fakeRegistry);
|
||||
expect(r.taskType).toBe('feature');
|
||||
});
|
||||
|
||||
it('detects planning from RU «план»', () => {
|
||||
const r = classifyByRegex('напиши план рефакторинга модуля X', fakeRegistry);
|
||||
expect(r.taskType).toBe('planning');
|
||||
});
|
||||
|
||||
it('detects bugfix from EN «bug»', () => {
|
||||
const r = classifyByRegex('there is a bug in the auth flow', fakeRegistry);
|
||||
expect(r.taskType).toBe('bugfix');
|
||||
});
|
||||
|
||||
it('detects micro for typo', () => {
|
||||
const r = classifyByRegex('опечатка в файле X', fakeRegistry);
|
||||
expect(r.micro).toBe(true);
|
||||
});
|
||||
|
||||
it('detects micro for rename', () => {
|
||||
const r = classifyByRegex('переименуй функцию foo в bar', fakeRegistry);
|
||||
expect(r.micro).toBe(true);
|
||||
});
|
||||
|
||||
it('returns taskType=unknown when no signal', () => {
|
||||
const r = classifyByRegex('просто привет', fakeRegistry);
|
||||
expect(r.taskType).toBe('unknown');
|
||||
expect(r.micro).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyByRegex — domain node match', () => {
|
||||
it('picks #62 billing-audit on «списание»', () => {
|
||||
const r = classifyByRegex('почини двойное списание лида', fakeRegistry);
|
||||
expect(r.recommendedNode).toBe('#62');
|
||||
});
|
||||
|
||||
it('picks #74 marketing on «email-рассылка»', () => {
|
||||
const r = classifyByRegex('составь email-рассылку для тарифа Бизнес', fakeRegistry);
|
||||
expect(r.recommendedNode).toBe('#74');
|
||||
});
|
||||
|
||||
it('falls back to classification trigger when no keyword match', () => {
|
||||
const r = classifyByRegex('рефакторинг кода', fakeRegistry);
|
||||
// 'рефакторинг' → classification: refactor → #11 pint
|
||||
expect(r.recommendedNode).toBe('#11');
|
||||
});
|
||||
|
||||
it('returns null when no node matched', () => {
|
||||
const r = classifyByRegex('просто вопрос', fakeRegistry);
|
||||
expect(r.recommendedNode).toBeNull();
|
||||
});
|
||||
|
||||
it('case-insensitive keyword match', () => {
|
||||
const r = classifyByRegex('СПИСАНИЕ дублируется', fakeRegistry);
|
||||
expect(r.recommendedNode).toBe('#62');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyByRegex — source tag', () => {
|
||||
it('always marks source: regex', () => {
|
||||
const r = classifyByRegex('test', fakeRegistry);
|
||||
expect(r.source).toBe('regex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyByRegex — confidence', () => {
|
||||
it('returns confidence>=0.8 for clean keyword match', () => {
|
||||
const r = classifyByRegex('списание дублируется', fakeRegistry);
|
||||
expect(r.confidence).toBeGreaterThanOrEqual(0.8);
|
||||
});
|
||||
|
||||
it('returns confidence<0.5 when ambiguous (no clean match)', () => {
|
||||
const r = classifyByRegex('что-то непонятное', fakeRegistry);
|
||||
expect(r.confidence).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
import { buildLLMPrompt, parseLLMResponse, shouldEscalate, classify, callAnthropicAPI } from './router-classifier.mjs';
|
||||
|
||||
describe('buildLLMPrompt', () => {
|
||||
it('serializes active nodes with id+name+top-3 triggers', () => {
|
||||
const prompt = buildLLMPrompt('почини списание', fakeRegistry);
|
||||
expect(prompt).toMatch(/#62/);
|
||||
expect(prompt).toMatch(/billing-audit/);
|
||||
expect(prompt).toMatch(/списание/);
|
||||
expect(prompt).toMatch(/почини списание/);
|
||||
});
|
||||
|
||||
it('excludes inactive nodes', () => {
|
||||
const reg = { nodes: [...fakeRegistry.nodes, { id: '#999', name: 'gone', status: 'historic', triggers: [] }] };
|
||||
const prompt = buildLLMPrompt('test', reg);
|
||||
expect(prompt).not.toMatch(/#999/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLLMResponse', () => {
|
||||
it('parses JSON object', () => {
|
||||
const r = parseLLMResponse('{"taskType":"bugfix","micro":false,"recommendedNode":"#62","confidence":0.9,"recommendedChain":null,"reasoning":"keyword списание"}');
|
||||
expect(r.taskType).toBe('bugfix');
|
||||
expect(r.recommendedNode).toBe('#62');
|
||||
expect(r.confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it('parses JSON wrapped in ```json``` block', () => {
|
||||
const r = parseLLMResponse('```json\n{"taskType":"feature","micro":false,"recommendedNode":"#19","confidence":0.8}\n```');
|
||||
expect(r.taskType).toBe('feature');
|
||||
});
|
||||
|
||||
it('returns null on unparseable response', () => {
|
||||
expect(parseLLMResponse('I cannot help with this')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldEscalate', () => {
|
||||
it('escalates when confidence < 0.7', () => {
|
||||
expect(shouldEscalate({ confidence: 0.6, taskType: 'bugfix' })).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT escalate on micro', () => {
|
||||
expect(shouldEscalate({ confidence: 0.4, taskType: 'unknown', micro: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT escalate when confidence >= 0.7', () => {
|
||||
expect(shouldEscalate({ confidence: 0.9, taskType: 'bugfix' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify — full integration (with mock LLM)', () => {
|
||||
it('returns regex result when confidence high', async () => {
|
||||
const r = await classify('почини списание дублируется', fakeRegistry, { llmCall: () => { throw new Error('should not call LLM'); } });
|
||||
expect(r.source).toBe('regex');
|
||||
expect(r.recommendedNode).toBe('#62');
|
||||
});
|
||||
|
||||
it('escalates to LLM when confidence low', async () => {
|
||||
const r = await classify('что-то непонятное', fakeRegistry, {
|
||||
llmCall: async () => ({ taskType: 'question', micro: false, recommendedNode: null, confidence: 0.95, recommendedChain: null })
|
||||
});
|
||||
expect(r.source).toBe('llm');
|
||||
expect(r.taskType).toBe('question');
|
||||
});
|
||||
|
||||
it('uses cache on second call with same prompt', async () => {
|
||||
let calls = 0;
|
||||
const llmCall = async () => { calls++; return { taskType: 'feature', micro: false, recommendedNode: '#19', confidence: 0.9, recommendedChain: 'L1' }; };
|
||||
const cache = new Map();
|
||||
await classify('ambiguous query', fakeRegistry, { llmCall, cache });
|
||||
await classify('ambiguous query', fakeRegistry, { llmCall, cache });
|
||||
expect(calls).toBe(1); // Second hit cache.
|
||||
});
|
||||
});
|
||||
|
||||
describe('callAnthropicAPI — ProxyAPI wiring', () => {
|
||||
it('posts to ProxyAPI base by default with Bearer auth', async () => {
|
||||
let captured;
|
||||
const fetchImpl = async (url, opts) => {
|
||||
captured = { url, opts };
|
||||
return { ok: true, json: async () => ({ content: [{ text: '{"taskType":"question"}' }] }) };
|
||||
};
|
||||
const text = await callAnthropicAPI('hi', { apiKey: 'sk-test', fetchImpl });
|
||||
expect(captured.url).toBe('https://api.proxyapi.ru/anthropic/v1/messages');
|
||||
expect(captured.opts.headers.authorization).toBe('Bearer sk-test');
|
||||
expect(text).toContain('question');
|
||||
});
|
||||
|
||||
it('honors a custom baseUrl and strips trailing slash', async () => {
|
||||
let capturedUrl;
|
||||
const fetchImpl = async (url) => {
|
||||
capturedUrl = url;
|
||||
return { ok: true, json: async () => ({ content: [{ text: 'x' }] }) };
|
||||
};
|
||||
await callAnthropicAPI('hi', { apiKey: 'k', baseUrl: 'https://example.test/', fetchImpl });
|
||||
expect(capturedUrl).toBe('https://example.test/v1/messages');
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
const fetchImpl = async () => ({ ok: false, status: 401, text: async () => 'Invalid API Key' });
|
||||
await expect(callAnthropicAPI('hi', { apiKey: 'bad', fetchImpl })).rejects.toThrow(/401/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify — isolation from Claude Code auth', () => {
|
||||
it('skips LLM and falls back to regex when ROUTER_LLM_KEY is absent', async () => {
|
||||
const saved = process.env.ROUTER_LLM_KEY;
|
||||
delete process.env.ROUTER_LLM_KEY;
|
||||
try {
|
||||
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
||||
expect(r.source).toBe('regex');
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.ROUTER_LLM_KEY = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT read ANTHROPIC_API_KEY (would hijack the main session)', async () => {
|
||||
const savedRouter = process.env.ROUTER_LLM_KEY;
|
||||
const savedAnthropic = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ROUTER_LLM_KEY;
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-should-not-be-used';
|
||||
try {
|
||||
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
||||
// No ROUTER_LLM_KEY → must stay on regex even though ANTHROPIC_API_KEY is set.
|
||||
expect(r.source).toBe('regex');
|
||||
} finally {
|
||||
if (savedRouter !== undefined) process.env.ROUTER_LLM_KEY = savedRouter;
|
||||
if (savedAnthropic !== undefined) process.env.ANTHROPIC_API_KEY = savedAnthropic;
|
||||
else delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* UserPromptSubmit hook — router prehook.
|
||||
* Stage 3 of router discipline overhaul.
|
||||
*
|
||||
* При каждом prompt'е:
|
||||
* 1. Читает реестр.
|
||||
* 2. Вызывает classifier.
|
||||
* 3. Пишет state в ~/.claude/runtime/router-state-<session>.json.
|
||||
*
|
||||
* Не блокирует prompt — только готовит state для PreToolUse gate (router-tool-gate).
|
||||
*
|
||||
* Контракт UserPromptSubmit hook (Claude Code): читает JSON из stdin
|
||||
* { session_id, transcript_path, hook_event_name, prompt }
|
||||
* на stdout — { } (пустой объект = ничего не меняем в prompt'е).
|
||||
* NB: Claude Code шлёт поле `prompt` (не `user_prompt`) — читаем оба для совместимости.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||||
|
||||
const ENFORCEMENT_TYPES = new Set(['feature', 'planning', 'bugfix', 'refactor', 'cleanup', 'marketing', 'security', 'analysis', 'monitoring']);
|
||||
|
||||
export function isEnforcementRequired(classification) {
|
||||
if (!classification) return false;
|
||||
if (classification.micro) return false;
|
||||
if (!classification.recommendedNode) return false;
|
||||
if (!ENFORCEMENT_TYPES.has(classification.taskType)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hashPrompt(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }
|
||||
return String(h);
|
||||
}
|
||||
|
||||
export function buildStateFromClassification(classification, { sessionId, promptHash }) {
|
||||
return {
|
||||
sessionId,
|
||||
promptHash,
|
||||
classification,
|
||||
skillInvokedThisTurn: false,
|
||||
chainProgress: [],
|
||||
enforcementRequired: isEnforcementRequired(classification),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function stateFilePath(sessionId) {
|
||||
return join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const input = await readStdinAsUtf8(process.stdin);
|
||||
const event = JSON.parse(input || '{}');
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const userPrompt = event.prompt || event.user_prompt || '';
|
||||
|
||||
try {
|
||||
const { loadRegistry } = await import('./registry-load.mjs');
|
||||
const { classify } = await import('./router-classifier.mjs');
|
||||
const registry = loadRegistry({ useCache: false });
|
||||
|
||||
const cachePath = join(homedir(), '.claude', 'runtime', 'router-classification-cache.json');
|
||||
const cache = new Map();
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(cachePath, 'utf-8'));
|
||||
for (const [k, v] of Object.entries(data)) cache.set(k, v);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const classification = await classify(userPrompt, registry, { cache });
|
||||
const state = buildStateFromClassification(classification, {
|
||||
sessionId,
|
||||
promptHash: hashPrompt(userPrompt),
|
||||
});
|
||||
|
||||
const statePath = stateFilePath(sessionId);
|
||||
mkdirSync(dirname(statePath), { recursive: true });
|
||||
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
|
||||
// Persist cache
|
||||
const cacheObj = {};
|
||||
for (const [k, v] of cache) cacheObj[k] = v;
|
||||
writeFileSync(cachePath, JSON.stringify(cacheObj, null, 2));
|
||||
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
// Любая ошибка прехука НЕ должна сломать prompt пользователя — fallback: пустой state, прохожу.
|
||||
process.stderr.write(`[router-prehook] ${err.message}\n`);
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI entry point guard — use fileURLToPath for correct non-ASCII path comparison on Windows
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && process.argv[1] === __filename) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildStateFromClassification, isEnforcementRequired } from './router-prehook.mjs';
|
||||
|
||||
describe('buildStateFromClassification', () => {
|
||||
it('builds full state object', () => {
|
||||
const cls = { taskType: 'feature', micro: false, recommendedNode: '#19', confidence: 0.9, source: 'regex', recommendedChain: 'L1' };
|
||||
const s = buildStateFromClassification(cls, { sessionId: 'abc', promptHash: '12345' });
|
||||
expect(s.sessionId).toBe('abc');
|
||||
expect(s.promptHash).toBe('12345');
|
||||
expect(s.classification).toEqual(cls);
|
||||
expect(s.skillInvokedThisTurn).toBe(false);
|
||||
expect(s.chainProgress).toEqual([]);
|
||||
expect(s.enforcementRequired).toBe(true);
|
||||
expect(s.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('enforcementRequired false on micro', () => {
|
||||
const s = buildStateFromClassification({ taskType: 'bugfix', micro: true, recommendedNode: null }, { sessionId: 'a', promptHash: 'b' });
|
||||
expect(s.enforcementRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('enforcementRequired false when no recommendedNode', () => {
|
||||
const s = buildStateFromClassification({ taskType: 'question', micro: false, recommendedNode: null }, { sessionId: 'a', promptHash: 'b' });
|
||||
expect(s.enforcementRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('enforcementRequired false on excluded taskType', () => {
|
||||
const s = buildStateFromClassification({ taskType: 'question', micro: false, recommendedNode: '#60' }, { sessionId: 'a', promptHash: 'b' });
|
||||
expect(s.enforcementRequired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnforcementRequired', () => {
|
||||
it('true on feature with node', () => {
|
||||
expect(isEnforcementRequired({ taskType: 'feature', micro: false, recommendedNode: '#19' })).toBe(true);
|
||||
});
|
||||
|
||||
it('false on micro', () => {
|
||||
expect(isEnforcementRequired({ taskType: 'feature', micro: true, recommendedNode: '#19' })).toBe(false);
|
||||
});
|
||||
|
||||
it('false when no node', () => {
|
||||
expect(isEnforcementRequired({ taskType: 'feature', micro: false, recommendedNode: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('false on question/memory-sync (excluded)', () => {
|
||||
expect(isEnforcementRequired({ taskType: 'question', micro: false, recommendedNode: '#60' })).toBe(false);
|
||||
expect(isEnforcementRequired({ taskType: 'memory-sync', micro: false, recommendedNode: '#33' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
||||
it('module loads with UTF-8 helper wired (smoke)', async () => {
|
||||
const mod = await import('./router-prehook.mjs');
|
||||
expect(typeof mod.buildStateFromClassification).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* UTF-8 safe stdin reader for hooks.
|
||||
* Fixes Windows Node stdin quirk: default `for await (chunk of stdin)` interprets
|
||||
* chunks as Buffer, and `input += chunk` calls .toString() which uses utf-8 BUT
|
||||
* fails on chunk boundaries that fall inside multi-byte sequences (e.g. cyrillic
|
||||
* 2-byte chars split across chunks).
|
||||
*
|
||||
* Uses StringDecoder to handle multi-byte chars across chunks correctly.
|
||||
*/
|
||||
|
||||
import { StringDecoder } from 'string_decoder';
|
||||
|
||||
export async function readStdinAsUtf8(stdin) {
|
||||
const decoder = new StringDecoder('utf-8');
|
||||
let out = '';
|
||||
for await (const chunk of stdin) {
|
||||
out += decoder.write(chunk);
|
||||
}
|
||||
out += decoder.end();
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||||
|
||||
async function* fromBuffers(buffers) {
|
||||
for (const b of buffers) yield b;
|
||||
}
|
||||
|
||||
describe('readStdinAsUtf8', () => {
|
||||
it('decodes UTF-8 cyrillic correctly across chunk boundaries', async () => {
|
||||
const text = 'посмотри сторожа достаточно ему информации?';
|
||||
const buf = Buffer.from(text, 'utf-8');
|
||||
// Split across multi-byte boundary (UTF-8 cyrillic = 2 bytes per char)
|
||||
const mid = 9; // mid-byte split for 'посмо|три...'
|
||||
const result = await readStdinAsUtf8(fromBuffers([buf.subarray(0, mid), buf.subarray(mid)]));
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('handles ASCII without modification', async () => {
|
||||
const text = 'hello world';
|
||||
const result = await readStdinAsUtf8(fromBuffers([Buffer.from(text)]));
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('returns empty string on empty stream', async () => {
|
||||
const result = await readStdinAsUtf8(fromBuffers([]));
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('does NOT mangle byte-level concatenation (regression guard)', async () => {
|
||||
// The bug: `for await (const c of stdin) input += c` interprets Buffer
|
||||
// via Buffer.prototype.toString() = 'utf-8' by default in Node, BUT
|
||||
// concatenation across chunks at multi-byte boundary fails.
|
||||
// Our helper must use a StringDecoder to handle the boundary.
|
||||
const cyrillic = 'тест';
|
||||
const buf = Buffer.from(cyrillic, 'utf-8');
|
||||
// Split exactly in the middle of 'т' (2-byte char)
|
||||
const result = await readStdinAsUtf8(fromBuffers([buf.subarray(0, 1), buf.subarray(1)]));
|
||||
expect(result).toBe(cyrillic);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stop hook addition — router chain progress tracking.
|
||||
* Stage 3 of router discipline overhaul.
|
||||
*
|
||||
* После каждого хода: обновляет state.chainProgress на основе вызванных в этом ходу скилов.
|
||||
* Helper для observer-stop-hook — он сам решает, вызывать ли (зависит от того, есть ли router-state).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||||
|
||||
export function extractSkillInvocations(events) {
|
||||
return (events || [])
|
||||
.filter((e) => e && e.tool_name === 'Skill')
|
||||
.map((e) => {
|
||||
const raw = e.tool_input?.skill || '';
|
||||
const stripped = raw.includes(':') ? raw.split(':').pop() : raw;
|
||||
return stripped;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function updateChainProgress(state, skillsInvoked, chains) {
|
||||
const chainId = state.classification?.recommendedChain;
|
||||
if (!chainId || !chains[chainId]) return { ...state };
|
||||
|
||||
const sequence = chains[chainId].sequence || [];
|
||||
const currentProgress = [...(state.chainProgress || [])];
|
||||
|
||||
for (const skill of skillsInvoked) {
|
||||
const nextExpectedIdx = currentProgress.length;
|
||||
if (nextExpectedIdx >= sequence.length) break;
|
||||
if (sequence[nextExpectedIdx] === skill) {
|
||||
currentProgress.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
chainProgress: currentProgress,
|
||||
chainCompleted: currentProgress.length === sequence.length && sequence.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const input = await readStdinAsUtf8(process.stdin);
|
||||
const event = JSON.parse(input || '{}');
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const events = event.turn_events || [];
|
||||
|
||||
const statePath = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
|
||||
if (!existsSync(statePath)) { process.stdout.write('{}'); process.exit(0); return; }
|
||||
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
|
||||
const { loadRegistry } = await import('./registry-load.mjs');
|
||||
const registry = loadRegistry({ useCache: false });
|
||||
const skills = extractSkillInvocations(events);
|
||||
const updated = updateChainProgress(state, skills, registry.chains || {});
|
||||
updated.skillInvokedThisTurn = skills.length > 0;
|
||||
writeFileSync(statePath, JSON.stringify(updated, null, 2));
|
||||
process.stdout.write('{}');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.stderr.write(`[router-stop-gate] ${err.message}\n`);
|
||||
process.stdout.write('{}');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI guard — Windows-cyrillic path quirk: compare resolved path, not raw argv[1]
|
||||
const isMain = (() => {
|
||||
try {
|
||||
return process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === fileURLToPath(`file:///${process.argv[1].replace(/\\/g, '/')}`);
|
||||
} catch {
|
||||
return process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { updateChainProgress, extractSkillInvocations } from './router-stop-gate.mjs';
|
||||
|
||||
const chains = {
|
||||
L1: { name: 'brainstorming chain', sequence: ['brainstorming', 'writing-plans', 'executing-plans'] },
|
||||
L13: { name: 'finance chain', sequence: ['billing-audit', 'pest', 'ru-tax-accounting'] },
|
||||
};
|
||||
|
||||
describe('extractSkillInvocations', () => {
|
||||
it('extracts skill names from Skill tool invocations', () => {
|
||||
const events = [
|
||||
{ tool_name: 'Skill', tool_input: { skill: 'brainstorming' } },
|
||||
{ tool_name: 'Edit', tool_input: {} },
|
||||
{ tool_name: 'Skill', tool_input: { skill: 'writing-plans' } },
|
||||
];
|
||||
expect(extractSkillInvocations(events)).toEqual(['brainstorming', 'writing-plans']);
|
||||
});
|
||||
|
||||
it('returns empty when no Skill invocations', () => {
|
||||
expect(extractSkillInvocations([{ tool_name: 'Edit' }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips namespace prefix (superpowers:brainstorming → brainstorming)', () => {
|
||||
const events = [{ tool_name: 'Skill', tool_input: { skill: 'superpowers:brainstorming' } }];
|
||||
expect(extractSkillInvocations(events)).toEqual(['brainstorming']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChainProgress', () => {
|
||||
it('appends matched chain step to chainProgress', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
|
||||
const updated = updateChainProgress(state, ['brainstorming'], chains);
|
||||
expect(updated.chainProgress).toEqual(['brainstorming']);
|
||||
});
|
||||
|
||||
it('appends multiple steps if multiple skills invoked', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
|
||||
const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
|
||||
expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
|
||||
});
|
||||
|
||||
it('ignores skills not in chain sequence', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
|
||||
const updated = updateChainProgress(state, ['random-skill'], chains);
|
||||
expect(updated.chainProgress).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks chainCompleted=true when last step reached', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming', 'writing-plans'] };
|
||||
const updated = updateChainProgress(state, ['executing-plans'], chains);
|
||||
expect(updated.chainCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves existing chainProgress without duplicates', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming'] };
|
||||
const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
|
||||
expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
||||
it('module loads with UTF-8 helper wired (smoke)', async () => {
|
||||
const mod = await import('./router-stop-gate.mjs');
|
||||
expect(typeof mod.updateChainProgress).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema_version": 1,
|
||||
"description": "Ground truth для классификатора. Stage 3 acceptance: ≥75% точности на этом наборе.",
|
||||
"prompts": [
|
||||
{"text": "сделай фичу — карточка тенанта показывает баланс в копейках", "expectedType": "feature", "expectedNode": "#19", "expectedMicro": false},
|
||||
{"text": "напиши план рефакторинга AdminTenantsController", "expectedType": "planning", "expectedNode": "#19", "expectedMicro": false},
|
||||
{"text": "почини двойное списание лида у клиента id=7", "expectedType": "bugfix", "expectedNode": "#62", "expectedMicro": false},
|
||||
{"text": "составь email-рассылку для тарифа Бизнес", "expectedType": "marketing", "expectedNode": "#74", "expectedMicro": false},
|
||||
{"text": "проверь безопасность портала перед выходом в интернет", "expectedType": "security", "expectedNode": "#73", "expectedMicro": false},
|
||||
{"text": "опечатка в imports.ts — поправь", "expectedType": "bugfix", "expectedNode": null, "expectedMicro": true},
|
||||
{"text": "переименуй функцию calc в computeBalance", "expectedType": "unknown", "expectedNode": null, "expectedMicro": true},
|
||||
{"text": "удали мёртвый код из ProjectService", "expectedType": "cleanup", "expectedNode": null, "expectedMicro": true},
|
||||
{"text": "формат-проход pint по app/", "expectedType": "refactor", "expectedNode": "#11", "expectedMicro": true},
|
||||
{"text": "что такое RLS в постгресе?", "expectedType": "question", "expectedNode": null, "expectedMicro": false},
|
||||
{"text": "обнови память по этапу 3", "expectedType": "memory-sync", "expectedNode": null, "expectedMicro": false},
|
||||
{"text": "проверь что таблица lead_charges имеет правильный индекс по tenant_id", "expectedType": "analysis", "expectedNode": "#62", "expectedMicro": false},
|
||||
{"text": "проанализируй медленный запрос /api/deals", "expectedType": "analysis", "expectedNode": null, "expectedMicro": false},
|
||||
{"text": "напиши STRIDE для админ-зоны", "expectedType": "security", "expectedNode": "#72", "expectedMicro": false},
|
||||
{"text": "проверь ПДн в выгрузках CSV", "expectedType": "security", "expectedNode": "#71", "expectedMicro": false},
|
||||
{"text": "поставь postiz и подключи vk", "expectedType": "marketing", "expectedNode": "#81", "expectedMicro": false},
|
||||
{"text": "запусти Pest тесты", "expectedType": "bugfix", "expectedNode": "#18", "expectedMicro": false},
|
||||
{"text": "увеличь max-tokens в config", "expectedType": "unknown", "expectedNode": null, "expectedMicro": true},
|
||||
{"text": "сделай мониторинг для очередей через Sentry", "expectedType": "monitoring", "expectedNode": "#34", "expectedMicro": false},
|
||||
{"text": "разбери почему билы упали в pipeline", "expectedType": "bugfix", "expectedNode": null, "expectedMicro": false}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse hook — router tool gate.
|
||||
* Stage 3 of router discipline overhaul.
|
||||
*
|
||||
* Читает state из ~/.claude/runtime/router-state-<session>.json (написан router-prehook).
|
||||
* Решает: block / proceed для tools Edit, Write, Bash (non-read-only).
|
||||
*
|
||||
* Escape hatch: <!-- routing: direct_justified=true reason="..." --> в начале response пропускает.
|
||||
*
|
||||
* Mode: warn-only (только stderr) или enforce (decision: block).
|
||||
* Mode читается из ~/.claude/runtime/router-gate-mode.json {"mode": "warn-only"|"enforce"}.
|
||||
* По умолчанию warn-only (первая неделя), потом ручной переключатель.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const READ_ONLY_BASH_PATTERNS = [
|
||||
/^\s*ls(\s|$)/, /^\s*cat\s/, /^\s*head\s/, /^\s*tail\s/, /^\s*wc\s/,
|
||||
/^\s*grep\s/, /^\s*find\s.*-print/, /^\s*pwd\s*$/,
|
||||
/^\s*git\s+(status|log|show|diff|rev-parse|branch|ls-tree|ls-remote|remote\s+show|tag|fetch)/,
|
||||
/^\s*node\s.*--check/, /^\s*npx\s+vitest\s+run/, /^\s*node\s+tools\/[\w-]+\.mjs\s+/,
|
||||
];
|
||||
|
||||
export function isReadOnlyBash(command) {
|
||||
if (!command) return false;
|
||||
return READ_ONLY_BASH_PATTERNS.some((re) => re.test(command));
|
||||
}
|
||||
|
||||
export function decodeRoutingTag(responseText) {
|
||||
if (!responseText) return null;
|
||||
const m = String(responseText).match(/<!--\s*routing:\s*direct_justified=true\s+reason=["']([^"']+)["']\s*-->/);
|
||||
if (!m) return null;
|
||||
return { directJustified: true, reason: m[1] };
|
||||
}
|
||||
|
||||
export function shouldBlock(tool, state, responseText, options = {}) {
|
||||
const warnOnly = options.warnOnly !== false; // default true
|
||||
if (warnOnly) return false;
|
||||
|
||||
if (!state.enforcementRequired) return false;
|
||||
if (state.skillInvokedThisTurn) return false;
|
||||
|
||||
if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false;
|
||||
if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false;
|
||||
|
||||
const tag = decodeRoutingTag(responseText);
|
||||
if (tag && tag.directJustified) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function decideDecision(tool, state, responseText, options = {}) {
|
||||
const cls = state.classification || {};
|
||||
if (shouldBlock(tool, state, responseText, options)) {
|
||||
const recommendedNode = cls.recommendedNode || '(unknown)';
|
||||
const recommendedChain = cls.recommendedChain ? ` (chain ${cls.recommendedChain})` : '';
|
||||
return {
|
||||
decision: 'block',
|
||||
reason: `Эта задача классифицирована как ${cls.taskType}. Реестр рекомендует узел ${recommendedNode}${recommendedChain}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием.`,
|
||||
};
|
||||
}
|
||||
if (options.warnOnly && state.enforcementRequired && !state.skillInvokedThisTurn) {
|
||||
return {
|
||||
warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${cls.recommendedNode}.`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function gateMode() {
|
||||
const path = join(homedir(), '.claude', 'runtime', 'router-gate-mode.json');
|
||||
if (!existsSync(path)) return 'warn-only';
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
||||
return data.mode === 'enforce' ? 'enforce' : 'warn-only';
|
||||
} catch { return 'warn-only'; }
|
||||
}
|
||||
|
||||
function readState(sessionId) {
|
||||
const path = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
|
||||
if (!existsSync(path)) return null;
|
||||
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const input = await readStdinAsUtf8(process.stdin);
|
||||
const event = JSON.parse(input || '{}');
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const tool = event.tool_name;
|
||||
const state = readState(sessionId);
|
||||
|
||||
if (!state) { process.stdout.write(JSON.stringify({})); process.exit(0); return; }
|
||||
|
||||
const mode = gateMode();
|
||||
const warnOnly = mode === 'warn-only';
|
||||
const responseText = ''; // PreToolUse event doesn't include response
|
||||
const bashCommand = (event.tool_input || {}).command || '';
|
||||
|
||||
const decision = decideDecision(tool, state, responseText, { warnOnly, bashCommand });
|
||||
|
||||
if (decision.warning) process.stderr.write(decision.warning + '\n');
|
||||
process.stdout.write(JSON.stringify(decision.decision ? decision : {}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// CLI guard — Windows-cyrillic quirk: use fileURLToPath(import.meta.url)
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main(); }
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
shouldBlock,
|
||||
decodeRoutingTag,
|
||||
isReadOnlyBash,
|
||||
decideDecision,
|
||||
} from './router-tool-gate.mjs';
|
||||
|
||||
const enforcementState = {
|
||||
enforcementRequired: true,
|
||||
skillInvokedThisTurn: false,
|
||||
classification: { taskType: 'feature', recommendedNode: '#19', recommendedChain: 'L1' },
|
||||
chainProgress: [],
|
||||
};
|
||||
|
||||
describe('isReadOnlyBash', () => {
|
||||
it('detects ls / cat / grep / git status as read-only', () => {
|
||||
expect(isReadOnlyBash('ls -la')).toBe(true);
|
||||
expect(isReadOnlyBash('cat file.txt')).toBe(true);
|
||||
expect(isReadOnlyBash('grep "x" file')).toBe(true);
|
||||
expect(isReadOnlyBash('git status')).toBe(true);
|
||||
expect(isReadOnlyBash('git log')).toBe(true);
|
||||
expect(isReadOnlyBash('git rev-parse HEAD')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify git commit / push as read-only', () => {
|
||||
expect(isReadOnlyBash('git commit -m "x"')).toBe(false);
|
||||
expect(isReadOnlyBash('git push origin main')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not classify rm / cp / mv as read-only', () => {
|
||||
expect(isReadOnlyBash('rm file')).toBe(false);
|
||||
expect(isReadOnlyBash('cp a b')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeRoutingTag', () => {
|
||||
it('parses direct_justified=true with reason', () => {
|
||||
const r = decodeRoutingTag('<!-- routing: direct_justified=true reason="micro fix per user override" -->');
|
||||
expect(r.directJustified).toBe(true);
|
||||
expect(r.reason).toContain('micro fix');
|
||||
});
|
||||
|
||||
it('returns null on missing tag', () => {
|
||||
expect(decodeRoutingTag('just a regular response')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects direct_justified=true WITHOUT reason', () => {
|
||||
const r = decodeRoutingTag('<!-- routing: direct_justified=true -->');
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldBlock', () => {
|
||||
it('blocks Edit on enforcement state without skill invoked', () => {
|
||||
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT block when skill invoked this turn', () => {
|
||||
const state = { ...enforcementState, skillInvokedThisTurn: true };
|
||||
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT block when enforcement not required', () => {
|
||||
const state = { ...enforcementState, enforcementRequired: false };
|
||||
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT block when routing-tag has direct_justified=true with reason', () => {
|
||||
expect(shouldBlock('Edit', enforcementState, '<!-- routing: direct_justified=true reason="testing" -->', { warnOnly: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT block read-only Bash', () => {
|
||||
expect(shouldBlock('Bash', enforcementState, '', { warnOnly: false, bashCommand: 'ls' })).toBe(false);
|
||||
});
|
||||
|
||||
it('warn-only mode never blocks (always returns false)', () => {
|
||||
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideDecision', () => {
|
||||
it('returns decision: block with message when shouldBlock=true', () => {
|
||||
const r = decideDecision('Edit', enforcementState, '', { warnOnly: false });
|
||||
expect(r.decision).toBe('block');
|
||||
expect(r.reason).toMatch(/#19/);
|
||||
});
|
||||
|
||||
it('returns empty (proceed) when shouldBlock=false', () => {
|
||||
const r = decideDecision('Edit', { ...enforcementState, skillInvokedThisTurn: true }, '', { warnOnly: false });
|
||||
expect(r.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warn-only mode logs to stderr but does not block', () => {
|
||||
const r = decideDecision('Edit', enforcementState, '', { warnOnly: true });
|
||||
expect(r.decision).toBeUndefined();
|
||||
expect(r.warning).toMatch(/#19/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
||||
it('module loads with UTF-8 helper wired (smoke)', async () => {
|
||||
const mod = await import('./router-tool-gate.mjs');
|
||||
expect(typeof mod.shouldBlock).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,48 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { runCoverageChecker } from './observer-coverage-checker.mjs';
|
||||
import { analyze } from './brain-retro-analyzer.mjs';
|
||||
import { loadRegistry } from './registry-load.mjs';
|
||||
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';
|
||||
|
||||
function iconFor(status) {
|
||||
return { ok: '✅', warn: '⚠️', fail: '🔴' }[status] || '⚪';
|
||||
}
|
||||
|
||||
export function renderStatus(inputs) {
|
||||
const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo } = inputs;
|
||||
const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo, discipline } = inputs;
|
||||
const c6 = inputs.c6 || { status: 'ok', detail: '—' };
|
||||
const missed = inputs.missed || { totalMissed: 0, byNode: {}, byClassification: {} };
|
||||
|
||||
function formatPercent(p) { return `${(p * 100).toFixed(1)}%`; }
|
||||
|
||||
let disciplineBlock = '';
|
||||
if (discipline) {
|
||||
const rows = Object.entries(discipline.byClassification || {})
|
||||
.sort((a, b) => b[1].episodes - a[1].episodes)
|
||||
.map(([cls, b]) => `| ${cls} | ${b.episodes} | ${formatPercent(b.pctTriggerMatch)} | ${formatPercent(b.pctViaSkill)} |`)
|
||||
.join('\n');
|
||||
const stepDist = Object.entries(discipline.routerStep?.distribution || {})
|
||||
.map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
const suspicious = discipline.routerStep?.suspicious
|
||||
? ' ⚠️ suspicious — >90% эпизодов остановились на step=1 (вероятный sentinel-bug парсера)'
|
||||
: '';
|
||||
const boundariesPct = formatPercent(discipline.boundariesRate?.rate || 0);
|
||||
disciplineBlock = `
|
||||
## Метрики дисциплины
|
||||
|
||||
Baseline дисциплины роутера (этап 2 router discipline overhaul, spec 2026-05-23). Цель — увидеть «точку До» перед enforcement-хуком этапа 3.
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
${rows || '| (no data) | 0 | 0% | 0% |'}
|
||||
|
||||
Router step distribution: ${stepDist || '(empty)'}${suspicious}
|
||||
|
||||
Boundaries applied (ADR / границы): ${discipline.boundariesRate?.withBoundaries || 0} of ${discipline.boundariesRate?.total || 0} эпизодов (${boundariesPct}).
|
||||
`;
|
||||
}
|
||||
|
||||
const activeProjects = (inputs.activeProjects || '').trim();
|
||||
const projectsBlock = activeProjects
|
||||
? `\n## Активные многоэтапные проекты\n\n${activeProjects}\n`
|
||||
@@ -38,7 +71,7 @@ Last updated: ${now}
|
||||
- Legacy v1 episodes (not in factor analysis): ${observer.v1Episodes || 0}
|
||||
- Last /brain-retro: ${retroLine}
|
||||
- Использование узлов: см. \`/brain-retro\` (раз в спринт). missed_activations: ${missed.totalMissed}. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory \`feedback_brain_unused_tools_not_problem\` — outside-repo memory store).
|
||||
${projectsBlock}
|
||||
${disciplineBlock}${projectsBlock}
|
||||
## Алерт-индикаторы
|
||||
|
||||
✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
|
||||
@@ -109,6 +142,19 @@ function countV1Episodes() {
|
||||
.filter((l) => l && !l.includes('"schema_version":2') && !l.includes('"observer_error":true')).length;
|
||||
}
|
||||
|
||||
function loadCurrentMonthEpisodes() {
|
||||
const month = new Date().toISOString().slice(0, 7);
|
||||
const file = join('docs', 'observer', `episodes-${month}.jsonl`);
|
||||
if (!existsSync(file)) return [];
|
||||
const out = [];
|
||||
for (const line of readFileSync(file, 'utf-8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try { out.push(JSON.parse(t)); } catch { /* skip */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md-generator.mjs')) {
|
||||
const cov = runCoverageChecker();
|
||||
const c5ok = cov.coverage.ok && cov.registration.ok && cov.missed.totalMissed === 0;
|
||||
@@ -135,6 +181,23 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md-
|
||||
activeProjects: existsSync('docs/observer/active-projects.md')
|
||||
? readFileSync('docs/observer/active-projects.md', 'utf-8')
|
||||
: '',
|
||||
discipline: (() => {
|
||||
try {
|
||||
const registry = loadRegistry({ useCache: false });
|
||||
const classificationMap = buildClassificationMap(registry);
|
||||
const dormancy = buildDormancyMap(registry);
|
||||
const eps = loadCurrentMonthEpisodes();
|
||||
const a = analyze(eps, { classificationMap, dormancy });
|
||||
return {
|
||||
byClassification: a.disciplineByClassification,
|
||||
routerStep: a.routerStep,
|
||||
boundariesRate: a.boundariesRate,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[status-md-generator] discipline calc skipped:', err.message);
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
const md = renderStatus(inputs);
|
||||
writeFileSync('docs/observer/STATUS.md', md);
|
||||
|
||||
@@ -109,3 +109,44 @@ describe('renderStatus — missed activations (Task 7, Pravila §16.4 v1.36)', (
|
||||
expect(md).toContain('| C5 Observer-coverage | ⚠️');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderStatus — discipline block (stage 2)', () => {
|
||||
const baseInputs = {
|
||||
now: '2026-05-24T10:00:00Z',
|
||||
c1: { status: 'ok', detail: 'OK' },
|
||||
c2: { status: 'ok', detail: 'OK' },
|
||||
c3: { status: 'ok', detail: 'OK' },
|
||||
c5: { status: 'ok', detail: 'OK' },
|
||||
c6: { status: 'ok', detail: 'OK' },
|
||||
observer: { episodeCount: 10, observerErrors: 0, piiMatches: 0, v1Episodes: 0 },
|
||||
missed: { totalMissed: 0, byNode: {}, byClassification: {} },
|
||||
lastRetroDaysAgo: 0,
|
||||
};
|
||||
|
||||
it('renders discipline table when discipline data is provided', () => {
|
||||
const md = renderStatus({
|
||||
...baseInputs,
|
||||
discipline: {
|
||||
byClassification: {
|
||||
feature: { episodes: 5, withTriggerMatch: 0, viaSkill: 0, pctTriggerMatch: 0, pctViaSkill: 0 },
|
||||
bugfix: { episodes: 6, withTriggerMatch: 2, viaSkill: 2, pctTriggerMatch: 0.333, pctViaSkill: 0.333 },
|
||||
},
|
||||
routerStep: { distribution: { '1': 10, '3': 1 }, total: 11, suspicious: true },
|
||||
boundariesRate: { total: 11, withBoundaries: 3, rate: 0.273, byPathType: {} },
|
||||
},
|
||||
});
|
||||
expect(md).toMatch(/## Метрики дисциплины/);
|
||||
expect(md).toMatch(/feature/);
|
||||
expect(md).toMatch(/bugfix/);
|
||||
expect(md).toMatch(/33\.3%/);
|
||||
expect(md).toMatch(/router step distribution/i);
|
||||
expect(md).toMatch(/⚠️.*suspicious/i);
|
||||
expect(md).toMatch(/boundaries applied/i);
|
||||
expect(md).toMatch(/27\.3%/);
|
||||
});
|
||||
|
||||
it('omits the discipline block when discipline is absent (backward compat)', () => {
|
||||
const md = renderStatus(baseInputs);
|
||||
expect(md).not.toMatch(/## Метрики дисциплины/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user