Merge main into feat/billing-v2-spec-c — sync legacy webhook removal + router stages 2+3 + lint-fix спека/плана
# Conflicts: # db/CHANGELOG_schema.md # db/schema.sql # docs/superpowers/plans/2026-05-24-billing-v2-spec-c-preflight-vtb.md # docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md
This commit is contained in:
@@ -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.
|
||||
@@ -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),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -62,7 +60,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',
|
||||
// Billing v2 Spec C: флаг заморозки по балансу (NULL = не заморожен).
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
};
|
||||
+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
|
||||
|
||||
@@ -12,7 +12,6 @@ declare(strict_types=1);
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. Проверяем исходное состояние
|
||||
@@ -77,7 +76,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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,38 @@ 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
|
||||
ОФД
|
||||
брейнсторме
|
||||
префлайт
|
||||
Префлайт
|
||||
матченным
|
||||
|
||||
+36
-3
@@ -1,10 +1,10 @@
|
||||
# CHANGELOG schema.sql — Лидерра
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит записи в обратном хронологическом порядке (v8.35 → v8.34 → 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.36 → v8.35 → v8.34 → 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.35, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.35 (2026-05-24) — Billing v2 Spec C: флаги заморозки + balance_freeze_log
|
||||
## v8.36 (2026-05-25) — Billing v2 Spec C: флаги заморозки + balance_freeze_log
|
||||
|
||||
- **+колонка `tenants.frozen_by_balance_at TIMESTAMP NULL`** — флаг заморозки тенанта по балансу. Не NULL = баланс < стоимость дня лидов. Устанавливается `PreflightBalanceService`.
|
||||
- **+колонка `projects.preflight_blocked_at TIMESTAMP NULL`** — флаг точечной блокировки проекта по preflight-проверке.
|
||||
@@ -14,6 +14,39 @@
|
||||
- **+индекс `balance_freeze_log_tenant_idx`** — по (tenant_id, created_at DESC).
|
||||
- Миграция: `2026_05_24_100000_add_balance_freeze_to_tenants_and_projects`
|
||||
- Метрики: +1 таблица, +3 индекса, +1 RLS-политика, +2 колонки. (Сверять с header `db/schema.sql`.)
|
||||
- **NB:** номер версии v8.36 (поверх v8.35 legacy webhook removal) — ветка `feat/billing-v2-spec-c` ребейзнута через merge main 25.05.2026.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+19
-57
@@ -1,13 +1,14 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.35 (24.05.2026 — Billing v2 Spec C: +balance_freeze_log / +tenants.frozen_by_balance_at / +projects.preflight_blocked_at)
|
||||
-- Версия: v8.36 (25.05.2026 — Billing v2 Spec C: +balance_freeze_log / +tenants.frozen_by_balance_at / +projects.preflight_blocked_at)
|
||||
-- Базовая версия: 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 / 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.31 (23.05.2026 — партиционирование 6 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()))
|
||||
-- Метрики: 76 базовые таблицы (67 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 127 индексов / 43 RLS-политика / 5 функций / 15 триггеров
|
||||
-- Метрики: 74 базовые таблицы (66 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 123 индекса / 41 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 лимитов)
|
||||
@@ -631,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',
|
||||
-- Биллинг
|
||||
@@ -682,9 +682,9 @@ 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;
|
||||
-- v8.35: частичный индекс для заморозки (sparse — большинство тенантов не заморожены)
|
||||
-- v8.36: частичный индекс для заморозки (sparse — большинство тенантов не заморожены)
|
||||
CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
|
||||
|
||||
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
|
||||
@@ -1426,7 +1426,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 (
|
||||
@@ -1930,32 +1930,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).
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -1980,21 +1957,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-канала упразднён вместе с каналом.
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -2814,7 +2778,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'),
|
||||
@@ -2822,7 +2786,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 (ШИФРОВАН) — заполнить при инсталляции'),
|
||||
@@ -2850,11 +2814,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)');
|
||||
@@ -2927,9 +2891,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 косвенно (см. политику ниже)
|
||||
@@ -2969,9 +2932,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
|
||||
}
|
||||
}
|
||||
+27
-9
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-23T16:38:59.719Z
|
||||
Last updated: 2026-05-24T13:01:24.122Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,23 +8,41 @@ Last updated: 2026-05-23T16:38:59.719Z
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 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 | ⚠️ | 135 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 17 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
|
||||
- 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).
|
||||
- Observer evidence: 135 episodes this month, 0 observer_error markers, 6 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 11
|
||||
- Last /brain-retro: 1 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 17. **Неиспользованные узлы — не алерт, если профильной задачи не было** (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.
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| bugfix | 7 | 28.6% | 42.9% |
|
||||
| feature | 5 | 0.0% | 0.0% |
|
||||
| analysis | 4 | 0.0% | 25.0% |
|
||||
| planning | 2 | 0.0% | 0.0% |
|
||||
| refactor | 1 | 0.0% | 0.0% |
|
||||
| cleanup | 1 | 0.0% | 0.0% |
|
||||
| monitoring | 1 | 0.0% | 0.0% |
|
||||
|
||||
Router step distribution: 1: 55, 2: 45, 3: 12, 5: 18
|
||||
|
||||
Boundaries applied (ADR / границы): 13 of 130 эпизодов (10.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 -->
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ scope P2. Решение заказчика — `disabled` + tooltip (как 5A
|
||||
ловит pointer-события → активатор tooltip навешивается на оборачивающий `<span>`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/billing/BalanceCard.vue`
|
||||
- Create: `app/tests/Frontend/BalanceCard.spec.ts`
|
||||
|
||||
@@ -144,6 +145,7 @@ git commit -m "feat(billing): E2 — disabled+tooltip на кнопках Авт
|
||||
заказчика — убрать баннер и файл `mockBilling.ts` целиком.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/BillingView.vue`
|
||||
- Delete: `app/resources/js/composables/mockBilling.ts`
|
||||
- Modify: `app/tests/Frontend/BillingView.spec.ts`
|
||||
@@ -214,6 +216,7 @@ CSRF — латентный баг для прода). Остальные адм
|
||||
ванный `api/admin.ts` + `apiClient`. Задача — вынести вызовы pricing-tiers/suppliers в `api/admin.ts`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue`
|
||||
@@ -295,11 +298,13 @@ export async function updateAdminSupplier(
|
||||
`scheduled: ref<Record<string, AdminPricingTier[]>>({})`, `editor: ref<PricingTierEditorRow[]>(...)`,
|
||||
`defaultEditor: PricingTierEditorRow[]`).
|
||||
4. `load()` — заменить тело:
|
||||
|
||||
```ts
|
||||
const data = await getPricingTiers();
|
||||
active.value = data.active;
|
||||
scheduled.value = data.scheduled;
|
||||
```
|
||||
|
||||
5. `submit()` — заменить `await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });` на
|
||||
`await createPricingTiers(editor.value);`.
|
||||
6. `confirmDelete()` — заменить `await axios.delete(\`/api/admin/pricing-tiers/scheduled/${effectiveFrom}\`);`
|
||||
@@ -319,8 +324,10 @@ export async function updateAdminSupplier(
|
||||
- [ ] **Step 4: Переписать `AdminPricingTiersView.spec.ts` на мок api/admin**
|
||||
|
||||
Эталон паттерна — `app/tests/Frontend/AdminBillingViewActions.spec.ts`. Ключевые правки:
|
||||
|
||||
1. Убрать `import axios from 'axios';` и `vi.mock('axios');`.
|
||||
2. Добавить partial-мок:
|
||||
|
||||
```ts
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
@@ -328,19 +335,24 @@ export async function updateAdminSupplier(
|
||||
});
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
```
|
||||
|
||||
3. Добавить хелпер ошибки (копия из эталона):
|
||||
|
||||
```ts
|
||||
function makeAxiosError(message: string, status = 422): unknown {
|
||||
return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } } });
|
||||
}
|
||||
```
|
||||
|
||||
4. `mockTiers` — оставить (это `AdminPricingTier[]`).
|
||||
5. Первый `describe` `beforeEach`:
|
||||
|
||||
```ts
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
|
||||
```
|
||||
|
||||
6. Тест `submits POST ...` → `expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]));`
|
||||
7. Тест `confirmDelete triggers DELETE ...` → `expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');` (`window.confirm = vi.fn(() => true)` — оставить, T5 уберёт).
|
||||
8. `describe` error handling — убрать `axios.isAxiosError` блок; в каждом тесте заменить
|
||||
@@ -351,6 +363,7 @@ export async function updateAdminSupplier(
|
||||
- [ ] **Step 5: Переписать `AdminSupplierPricesView.spec.ts` на мок api/admin**
|
||||
|
||||
Аналогично Step 4:
|
||||
|
||||
1. Убрать axios; `vi.mock('../../resources/js/api/admin', ...)` с `getAdminSuppliers`/`updateAdminSupplier` как `vi.fn()`.
|
||||
2. `makeAxiosError` хелпер.
|
||||
3. `beforeEach`: `vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);`
|
||||
@@ -386,6 +399,7 @@ backend `AdminPricingTiersController@store:92` хардкодит `startOfMonth(
|
||||
показывает `nextMonthStart` в кнопке и заголовке диалога. G7 — дать админу выбрать дату.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminPricingTiersController.php`
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
@@ -430,14 +444,17 @@ Expected: FAIL — `effective_from` сейчас игнорируется (пе
|
||||
- [ ] **Step 3: Backend — принять `effective_from` в `store()`**
|
||||
|
||||
В `AdminPricingTiersController@store`:
|
||||
|
||||
1. Перед `$request->validate([...])` вычислить `$todayMsk = Carbon::now('Europe/Moscow')->toDateString();`
|
||||
2. В массив правил добавить:
|
||||
`'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],`
|
||||
3. Заменить строку `$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();` на:
|
||||
|
||||
```php
|
||||
$effectiveFrom = $request->input('effective_from')
|
||||
?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
||||
```
|
||||
|
||||
(`$todayMsk` из шага 1 переиспользуется правилом валидации; вычислять до `validate`.)
|
||||
|
||||
- [ ] **Step 4: Прогнать Pest — убедиться, что проходит**
|
||||
@@ -465,9 +482,11 @@ export async function createPricingTiers(
|
||||
- [ ] **Step 6: Frontend — date-picker в редакторе сетки**
|
||||
|
||||
В `AdminPricingTiersView.vue`:
|
||||
|
||||
1. Добавить ref после `nextMonthStart` computed:
|
||||
`const effectiveFrom = ref<string>(nextMonthStart.value);`
|
||||
2. Добавить computed для `min` (завтра):
|
||||
|
||||
```ts
|
||||
const minEffectiveFrom = computed(() => {
|
||||
const d = new Date();
|
||||
@@ -475,7 +494,9 @@ export async function createPricingTiers(
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
```
|
||||
|
||||
3. В диалоге-редакторе перед `<table class="editor-table">` добавить поле:
|
||||
|
||||
```vue
|
||||
<v-text-field
|
||||
v-model="effectiveFrom"
|
||||
@@ -488,6 +509,7 @@ export async function createPricingTiers(
|
||||
data-testid="effective-from-input"
|
||||
/>
|
||||
```
|
||||
|
||||
4. Заголовок диалога: `Новая сетка (effective_from = {{ effectiveFrom }})` (вместо `nextMonthStart`).
|
||||
Кнопку открытия редактора `Редактировать сетку (с {{ nextMonthStart }})` — оставить
|
||||
`nextMonthStart` (это дефолтная подсказка до открытия диалога).
|
||||
@@ -548,6 +570,7 @@ confirm()», но в `AdminBillingView` `confirm()` уже нет — Sprint 3D
|
||||
`v-dialog`'и; фактический оставшийся браузерный confirm в админ-биллинге — здесь, в pricing-tiers.)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
|
||||
@@ -591,15 +614,19 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не
|
||||
- [ ] **Step 3: Заменить `window.confirm` на `v-dialog`-flow**
|
||||
|
||||
В `AdminPricingTiersView.vue`:
|
||||
|
||||
1. Добавить state: `const deleteDialogOpen = ref(false);` и `const deleteTarget = ref<string | null>(null);`
|
||||
2. Заменить функцию `confirmDelete` — теперь только открывает диалог:
|
||||
|
||||
```ts
|
||||
function confirmDelete(effectiveFrom: string): void {
|
||||
deleteTarget.value = effectiveFrom;
|
||||
deleteDialogOpen.value = true;
|
||||
}
|
||||
```
|
||||
|
||||
3. Добавить `performDelete` — фактическое удаление (тело — бывший `confirmDelete` без `window.confirm`):
|
||||
|
||||
```ts
|
||||
async function performDelete(): Promise<void> {
|
||||
const effectiveFrom = deleteTarget.value;
|
||||
@@ -619,7 +646,9 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. В `<template>` после диалога-редактора добавить confirm-диалог:
|
||||
|
||||
```vue
|
||||
<v-dialog v-model="deleteDialogOpen" max-width="440">
|
||||
<v-card>
|
||||
@@ -636,6 +665,7 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
```
|
||||
|
||||
5. `defineExpose` — добавить `deleteDialogOpen`, `deleteTarget`, `performDelete`.
|
||||
|
||||
- [ ] **Step 4: Прогнать FE-тест — убедиться, что проходит**
|
||||
@@ -666,4 +696,4 @@ markdownlint, cspell, lychee, gitleaks) и `superpowers:finishing-a-development-
|
||||
**Ожидаемые изменения относительно базы `345d14d`:** 5 feat/refactor-коммитов + этот plan-коммит.
|
||||
Файлы: `BalanceCard.vue`, `BillingView.vue`, `mockBilling.ts` (удалён), `api/admin.ts`,
|
||||
`AdminPricingTiersView.vue`, `AdminSupplierPricesView.vue`, `AdminPricingTiersController.php`,
|
||||
+ 5 spec-файлов (1 новый `BalanceCard.spec.ts`). БД/schema — без изменений.
|
||||
- 5 spec-файлов (1 новый `BalanceCard.spec.ts`). БД/schema — без изменений.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
**Goal:** Закрыть журнал входа `auth_log` на все остальные auth-события (выход, 2FA setup/verify/recovery, password reset, регистрация) и заполнять `user_id`/`ip_address`/`user_agent` во **всех** `ActivityLog::create` (сейчас все 8 точек проставляют NULL).
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. Существующая приватная `logAuthEvent()` в `AuthController` ([:416-435](../../../app/app/Http/Controllers/Api/AuthController.php#L416)) выносится в трейт `App\Http\Controllers\Concerns\WritesAuthLog`. Подключается в `AuthController`, `TwoFactorController`, `TwoFactorSetupController`, `PasswordResetController` — единая точка записи (решение E=a).
|
||||
2. Все `ActivityLog::create` в `DealController` (4 точки) и `DealBulkActionController` (3 точки) получают `user_id` из `$request->user()->id`, плюс `ip_address` и `user_agent`. Прошлое не бэкфилим (решение B=a).
|
||||
3. Hash-chain trigger на `auth_log` уже стоит ([db/schema.sql:3032](../../../db/schema.sql#L3032)) — новые записи защищены автоматически.
|
||||
@@ -18,12 +19,14 @@
|
||||
## File Structure
|
||||
|
||||
**New:**
|
||||
|
||||
- `app/app/Http/Controllers/Concerns/WritesAuthLog.php` — трейт.
|
||||
- `app/tests/Unit/Concerns/WritesAuthLogTest.php`
|
||||
- `app/tests/Feature/Auth/AuthLogCoverageTest.php` — все auth-события.
|
||||
- `app/tests/Feature/Deals/ActivityLogAttributionTest.php` — автор/IP в `activity_log`.
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `app/app/Http/Controllers/Api/AuthController.php` — `logout`, `registerVerify`; убрать локальную `logAuthEvent`, использовать трейт.
|
||||
- `app/app/Http/Controllers/Api/TwoFactorController.php` — `verifyTwoFactor` (успех+неудача), `useRecoveryCode` (успех+неудача).
|
||||
- `app/app/Http/Controllers/Api/TwoFactorSetupController.php` — `init`, `confirm`, `disable`, `regenerateRecoveryCodes`.
|
||||
@@ -36,6 +39,7 @@
|
||||
## Task 1 — `WritesAuthLog` трейт
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Controllers/Concerns/WritesAuthLog.php`
|
||||
- Test: `app/tests/Unit/Concerns/WritesAuthLogTest.php`
|
||||
|
||||
@@ -130,6 +134,7 @@ git commit -m "feat(auth): WritesAuthLog trait — shared auth_log writer"
|
||||
## Task 2 — AuthController → use trait, log `logout` + `register_success`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AuthController.php`
|
||||
- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` (NEW, накапливается)
|
||||
|
||||
@@ -191,6 +196,7 @@ class AuthController extends Controller
|
||||
## Task 3 — TwoFactorController → log verify (success+fail) + recovery (success+fail)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/TwoFactorController.php:41,110`
|
||||
|
||||
- [ ] **Step 1: failing test (4 кейса)** — `2fa_verify_success`, `2fa_verify_failed`, `2fa_recovery_used`, `2fa_recovery_failed` (с правильным `failure_reason`).
|
||||
@@ -223,6 +229,7 @@ $this->logAuthEvent('2fa_recovery_failed', $user->id, $user->tenant_id, $user->e
|
||||
## Task 4 — TwoFactorSetupController → log init/confirm/disable/regen
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/TwoFactorSetupController.php:39,80,133,163`
|
||||
|
||||
- [ ] **Step 1: failing test (4 кейса)** — `2fa_setup_init`, `2fa_setup_confirmed`, `2fa_disabled`, `2fa_recovery_regenerated`. Для disable — отдельно неудачный пароль = `2fa_disable_failed` (failure_reason='invalid_password').
|
||||
@@ -255,6 +262,7 @@ $this->logAuthEvent('2fa_recovery_regenerated', $user->id, $user->tenant_id, $us
|
||||
## Task 5 — PasswordResetController → log forgot/reset (success+fail)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/PasswordResetController.php:57,94`
|
||||
|
||||
- [ ] **Step 1: failing test (3 кейса)** — `password_reset_requested` (всегда пишется, даже если email неизвестен — anti-enumeration на UI остаётся, но в журнале фиксируется), `password_reset_completed` (на success Password::reset), `password_reset_failed` (на статусе != PASSWORD_RESET).
|
||||
@@ -300,6 +308,7 @@ class PasswordResetController extends Controller
|
||||
## Task 6 — DealController: автор/IP в 4 ActivityLog::create
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php:387,400,412,523`
|
||||
- Test: `app/tests/Feature/Deals/ActivityLogAttributionTest.php` (NEW)
|
||||
|
||||
@@ -350,6 +359,7 @@ git commit -m "feat(audit): activity_log captures actor user_id + ip + UA in Dea
|
||||
## Task 7 — DealBulkActionController: автор/IP в 3 ActivityLog::insert
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php:99-112,170-179,234-243`
|
||||
|
||||
- [ ] **Step 1: failing test (3 кейса: bulk transition, bulk destroy, bulk restore)** — для каждой записи в logRows ожидаем `user_id = $request->user()->id, ip_address = '...'`.
|
||||
@@ -383,6 +393,7 @@ git commit -m "feat(audit): activity_log captures actor in bulk deal actions"
|
||||
## Task 8 — Integration: full auth-flow coverage
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` — финальный E2E прогон
|
||||
|
||||
- [ ] **Step 1: test — единый сценарий «полный auth-flow одного user'а»**
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
**Goal:** Закрыть операционные дыры аудита: мутации проектов и settings безопасности (API-ключ, исходящий webhook URL), админ-действия по интеграции с поставщиком, входящий supplier-webhook (включая отказы 404/429) и **авто-наполнение `incidents_log`** на основе порога падений (решение D=a: cron-watcher).
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. Новый журнал `tenant_operations_log` — для мутаций тенант-уровня вне сделок (проекты, API-ключи, webhook-URL). По структуре повторяет `activity_log`, но без `deal_id NOT NULL`. Защищён теми же `audit_chain_hash()` и `audit_block_mutation()` триггерами.
|
||||
2. Сервис `App\Services\Audit\OperationsLogger` — единственный писатель `tenant_operations_log`.
|
||||
3. Admin supplier-integration действия пишутся в существующий `saas_admin_audit_log` (структура подходит).
|
||||
@@ -20,6 +21,7 @@
|
||||
## File Structure
|
||||
|
||||
**New (миграция + код + тесты):**
|
||||
|
||||
- `db/migrations/2026_05_22_<seq>_tenant_operations_log.sql` (raw SQL — паттерн схемы Лидерры) + дополнения к `db/schema.sql`.
|
||||
- `app/app/Services/Audit/OperationsLogger.php`
|
||||
- `app/app/Models/TenantOperationsLog.php` (Eloquent для чтения, INSERT через сервис).
|
||||
@@ -33,6 +35,7 @@
|
||||
- `app/tests/Feature/Console/IncidentsWatchFailuresTest.php`
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `db/schema.sql` — добавить определение `tenant_operations_log` + индексы + RLS + триггеры hash-chain.
|
||||
- `db/CHANGELOG_schema.md` — запись v8.X.
|
||||
- `app/app/Services/Project/ProjectService.php` — create/update/delete/bulk → запись.
|
||||
@@ -47,6 +50,7 @@
|
||||
## Task 1 — Миграция `tenant_operations_log`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `db/schema.sql` (вставить новый раздел).
|
||||
- Create: `db/migrations/2026_05_22_001_tenant_operations_log.sql`
|
||||
- Modify: `db/CHANGELOG_schema.md` — запись.
|
||||
@@ -130,6 +134,7 @@ git commit -m "feat(schema): tenant_operations_log table with hash-chain protect
|
||||
## Task 2 — `OperationsLogger` сервис
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Audit/OperationsLogger.php`
|
||||
- Test: `app/tests/Unit/Services/Audit/OperationsLoggerTest.php`
|
||||
|
||||
@@ -195,6 +200,7 @@ final class OperationsLogger
|
||||
## Task 3 — ProjectService мутации → `tenant_operations_log`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Project/ProjectService.php` (create, update, delete, bulk*)
|
||||
- Test: `app/tests/Feature/Projects/ProjectMutationsAuditTest.php` (NEW)
|
||||
|
||||
@@ -267,6 +273,7 @@ class ProjectService
|
||||
## Task 4 — ApiKeyController.regenerate → `tenant_operations_log`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/ApiKeyController.php:41-72`
|
||||
- Test: `app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php` (NEW)
|
||||
|
||||
@@ -299,6 +306,7 @@ public function regenerate(Request $request, \App\Services\Audit\OperationsLogge
|
||||
## Task 5 — WebhookSettingsController.update → `tenant_operations_log`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/WebhookSettingsController.php:50-86`
|
||||
- Test: `app/tests/Feature/Security/WebhookUrlChangeAuditTest.php` (NEW)
|
||||
|
||||
@@ -314,6 +322,7 @@ public function regenerate(Request $request, \App\Services\Audit\OperationsLogge
|
||||
## Task 6 — AdminSupplierIntegrationController (3 mutating action) → `saas_admin_audit_log`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php:89,158,234`
|
||||
- Test: `app/tests/Feature/Admin/SupplierIntegrationAuditTest.php` (NEW)
|
||||
|
||||
@@ -365,6 +374,7 @@ SaasAdminAuditLog::create([
|
||||
## Task 7 — SupplierWebhookController.receive → `webhook_log` (success + отказы)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php:47-114`
|
||||
- Test: `app/tests/Feature/Webhook/SupplierWebhookLoggingTest.php` (NEW)
|
||||
|
||||
@@ -422,6 +432,7 @@ $this->logSupplierWebhook($request, $lead->id, 'received', null);
|
||||
## Task 8 — Cron-watcher `incidents:watch-failures`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Console/Commands/IncidentsWatchFailures.php`
|
||||
- Modify: `app/routes/console.php` — добавить расписание.
|
||||
- Test: `app/tests/Feature/Console/IncidentsWatchFailuresTest.php` (NEW)
|
||||
@@ -538,6 +549,7 @@ git commit -m "feat(incidents): cron-watcher auto-populates incidents_log on fai
|
||||
## Task 9 — Integration: полный operational-flow
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `app/tests/Feature/Audit/OperationalFullFlowTest.php`
|
||||
|
||||
- [ ] **Step 1: test «полный сценарий»**
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
**Goal:** Закрыть журнал `pd_processing_log` во всех точках обработки ПДн (created/viewed/exported/deleted) и защищённый аудит impersonation (`saas_admin_audit_log` + ПДн-след) — соответствие 152-ФЗ ст.18 ч.2.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. Сервис `App\Services\Pd\PdAuditLogger` — единственная точка записи в `pd_processing_log`. Через DI внедряется в контроллеры/джобы/команды; явные вызовы в местах операций.
|
||||
2. Hash-chain и append-only защита стоит триггерами схемы ([db/schema.sql:3046-3051](../../../db/schema.sql#L3046)) — сервис только формирует строку, БД гарантирует целостность.
|
||||
3. Impersonation использует `App\Services\Pd\ImpersonationAuditService` — пишет `saas_admin_audit_log` на init/verify/end и `pd_processing_log` один раз на сессию (гибрид C=c из решений: session-level + per-export если экспорт идёт изнутри impersonation).
|
||||
@@ -21,6 +22,7 @@
|
||||
## File Structure
|
||||
|
||||
**New (10 файлов):**
|
||||
|
||||
- `app/app/Services/Pd/PdAuditLogger.php` — запись в `pd_processing_log`.
|
||||
- `app/app/Services/Pd/ImpersonationAuditService.php` — оркестратор impersonation-событий в оба журнала.
|
||||
- `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php`
|
||||
@@ -33,6 +35,7 @@
|
||||
- `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php`
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `app/app/Http/Controllers/Api/DealController.php` — `show()` + `store()`.
|
||||
- `app/app/Http/Controllers/Api/DealExportController.php` — `export()`.
|
||||
- `app/app/Http/Controllers/Api/ReportJobController.php` — `destroy()`.
|
||||
@@ -47,6 +50,7 @@
|
||||
## Task 1 — `PdAuditLogger` service
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Pd/PdAuditLogger.php`
|
||||
- Test: `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php`
|
||||
|
||||
@@ -96,6 +100,7 @@ it('rejects two-actor row (chk_pd_actor violation)', function () {
|
||||
```bash
|
||||
cd app && php artisan test --filter=PdAuditLoggerTest
|
||||
```
|
||||
|
||||
Expected: FAIL (`Class "App\Services\Pd\PdAuditLogger" not found`).
|
||||
|
||||
- [ ] **Step 3: implement**
|
||||
@@ -148,6 +153,7 @@ final class PdAuditLogger
|
||||
```bash
|
||||
cd app && php artisan test --filter=PdAuditLoggerTest
|
||||
```
|
||||
|
||||
Expected: 3/3 PASS.
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
@@ -162,6 +168,7 @@ git commit -m "feat(pd): PdAuditLogger service (152-ФЗ pd_processing_log write
|
||||
## Task 2 — DealController.show → pd 'viewed'
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php:244-315`
|
||||
- Test: `app/tests/Feature/Pd/DealViewAccessLogTest.php` (NEW)
|
||||
|
||||
@@ -206,6 +213,7 @@ it('does not write pd_processing_log for 404 lookups', function () {
|
||||
```bash
|
||||
cd app && php artisan test --filter=DealViewAccessLogTest
|
||||
```
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: implement — inject logger + добавить вызов в `DealController::show()` после `if ($deal === null) return 404`**
|
||||
@@ -239,6 +247,7 @@ public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonRespo
|
||||
```bash
|
||||
cd app && php artisan test --filter=DealViewAccessLogTest
|
||||
```
|
||||
|
||||
Expected: 2/2 PASS.
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
@@ -253,6 +262,7 @@ git commit -m "feat(pd): pd_processing_log 'viewed' on deal card open (152-ФЗ)
|
||||
## Task 3 — Deal-creation paths → pd 'created' (3 точки)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php:523` (manual store)
|
||||
- Modify: `app/app/Jobs/ProcessWebhookJob.php:147`, `:232` (webhook + duplicate)
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:285`, `:308` (supplier route + duplicate)
|
||||
@@ -324,6 +334,7 @@ app(PdAuditLogger::class)->record(
|
||||
```bash
|
||||
cd app && php artisan test --filter=DealCreatePdLogTest
|
||||
```
|
||||
|
||||
Expected: 3/3 PASS.
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
@@ -338,6 +349,7 @@ git commit -m "feat(pd): pd_processing_log 'created' on deal creation (manual/we
|
||||
## Task 4 — DealExportController → pd 'exported'
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealExportController.php:43-127`
|
||||
- Test: `app/tests/Feature/Pd/DealExportPdLogTest.php` (NEW)
|
||||
|
||||
@@ -398,6 +410,7 @@ git commit -m "feat(pd): pd_processing_log 'exported' on deals export (152-ФЗ)
|
||||
## Task 5 — ReportJobController.destroy → pd 'deleted'
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/ReportJobController.php:308-343`
|
||||
- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (NEW)
|
||||
|
||||
@@ -430,6 +443,7 @@ app(\App\Services\Pd\PdAuditLogger::class)->record(
|
||||
## Task 6 — ReportsCleanupExpired (cron) → pd 'deleted' (per file)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Console/Commands/ReportsCleanupExpired.php:60-75`
|
||||
- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (расширить)
|
||||
|
||||
@@ -458,6 +472,7 @@ if (! $dryRun) {
|
||||
## Task 7 — HistoricalImportService → pd 'created' (per row)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Import/HistoricalImportService.php:250-270`
|
||||
- Test: `app/tests/Feature/Pd/DealCreatePdLogTest.php` (расширить — кейс «импорт N лидов → N pd-строк action=created, purpose='lead_create_import_'.$importLogId»).
|
||||
|
||||
@@ -484,6 +499,7 @@ $this->pdLog->record(
|
||||
## Task 8 — `ImpersonationAuditService` (unit-tested)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Pd/ImpersonationAuditService.php`
|
||||
- Test: `app/tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php`
|
||||
|
||||
@@ -591,6 +607,7 @@ final class ImpersonationAuditService
|
||||
## Task 9 — Wire `ImpersonationController::init`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/ImpersonationController.php:94-141`
|
||||
- Test: `app/tests/Feature/Pd/ImpersonationAuditTest.php` (NEW)
|
||||
|
||||
@@ -640,6 +657,7 @@ $audit->recordEnd($token, adminId: $token->requested_by, ip: $request->ip());
|
||||
## Task 12 — Integration test: полный ПДн-цикл
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php`
|
||||
|
||||
- [ ] **Step 1: test — сценарий «вебхук → создание сделки → просмотр → экспорт → удаление отчёта»**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Биллинг v2 Спек C — Преfflight баланса + VTB-эквайринг — План реализации
|
||||
# Биллинг v2 Спек C — Префлайт баланса + VTB-эквайринг — План реализации
|
||||
|
||||
> **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. **Pravila §15.1** — git-коммит задачи только Sonnet/Opus субагентами (не Haiku); контроллер верифицирует `git rev-parse HEAD` после каждого субагента.
|
||||
|
||||
**Goal:** Защитить портал от заказа лидов у поставщика на клиентов без баланса (преfflight) + дать реальное пополнение баланса через безнал (PDF-счёт + сверка) с архитектурой под СБП/карты VTB.
|
||||
**Goal:** Защитить портал от заказа лидов у поставщика на клиентов без баланса (префлайт) + дать реальное пополнение баланса через безнал (PDF-счёт + сверка) с архитектурой под СБП/карты VTB.
|
||||
|
||||
**Architecture:** Преfflight — фильтр eligible-проектов до формулы `computeOrder` (формула не меняется) + флаги заморозки на tenant/project + ежедневный cron 18:00 MSK. VTB — интерфейс `TopupGatewayInterface` + 3 реализации (безнал полный, СБП/карты dev-заглушки) + журнал `topup_sessions`.
|
||||
**Architecture:** Префлайт — фильтр eligible-проектов до формулы `computeOrder` (формула не меняется) + флаги заморозки на tenant/project + ежедневный cron 18:00 MSK. VTB — интерфейс `TopupGatewayInterface` + 3 реализации (безнал полный, СБП/карты dev-заглушки) + журнал `topup_sessions`.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Pest 4, Vue 3 + Vuetify 3, Vitest, barryvdh/laravel-dompdf (новый).
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
**Что уже есть на `origin/main` (зависимости):**
|
||||
|
||||
- `App\Services\Billing\BalanceToLeadsConverter::convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array` — возвращает `['leads' => int, 'breakdown' => [...], 'current_tier' => ..., 'next_tier' => ...]`. Pure, bcmath. Используется в преfflight для «сколько лидов даёт баланс».
|
||||
- `App\Services\Billing\BalanceToLeadsConverter::convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array` — возвращает `['leads' => int, 'breakdown' => [...], 'current_tier' => ..., 'next_tier' => ...]`. Pure, bcmath. Используется в префлайт для «сколько лидов даёт баланс».
|
||||
- `App\Services\Billing\PricingTierResolver` — резолвер ступени по порядковому номеру лида.
|
||||
- `App\Services\Billing\LedgerService` — списания (не трогаем).
|
||||
- `App\Services\Billing\BillingTopupService::topup(int $tenantId, string $amountRub, ?int $userId)` — MVP-stub пополнения (рефакторим).
|
||||
@@ -72,11 +72,12 @@ Expected: миграции прошли, converter-тесты GREEN (завис
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Преfflight баланса
|
||||
## Phase 1 — Префлайт баланса
|
||||
|
||||
### Task 1.1: Миграция БД — флаги заморозки + журнал
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php`
|
||||
- Create: `db/migrations/2026_05_24_balance_freeze_log.sql` (для prod-синка вручную, как в Спеке B)
|
||||
- Modify: `db/schema.sql` (добавить колонки + таблицу + запись в `db/CHANGELOG_schema.md`)
|
||||
@@ -172,6 +173,7 @@ git commit -m "feat(billing-v2-c): миграция — флаги заморо
|
||||
### Task 1.2: `BalancePreflightService` — pure-проверка платёжеспособности
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/BalancePreflightService.php`
|
||||
- Create: `app/app/Services/Billing/PreflightResult.php`
|
||||
- Test: `app/tests/Unit/Billing/BalancePreflightServiceTest.php`
|
||||
@@ -270,7 +272,7 @@ namespace App\Services\Billing;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Pure: проходит ли клиент преfflight — хватает ли баланса на ПОЛНЫЙ дневной
|
||||
* Pure: проходит ли клиент префлайт — хватает ли баланса на ПОЛНЫЙ дневной
|
||||
* лимит всех его eligible-проектов по текущему тарифу.
|
||||
*
|
||||
* Сравнение в ЛИДАХ (capacity vs required), не в рублях — переиспользует
|
||||
@@ -328,6 +330,7 @@ git commit -m "feat(billing-v2-c): BalancePreflightService — pure-провер
|
||||
### Task 1.3: Tenant/Project — хелперы платёжеспособности + резолвер eligible-лимита
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Models/Tenant.php` (метод `requiredLeadsForTomorrow()`, scope, casts флага)
|
||||
- Modify: `app/app/Models/Project.php` (cast `preflight_blocked_at`)
|
||||
- Test: `app/tests/Feature/Billing/TenantPreflightTest.php`
|
||||
@@ -369,7 +372,7 @@ Expected: FAIL — метод `requiredLeadsForTomorrow` не существуе
|
||||
```php
|
||||
/**
|
||||
* Сумма daily_limit активных проектов — «сколько лидов клиент хочет в день».
|
||||
* Используется преfflight'ом как requiredLeads.
|
||||
* Используется префлайт'ом как requiredLeads.
|
||||
*/
|
||||
public function requiredLeadsForTomorrow(): int
|
||||
{
|
||||
@@ -398,6 +401,7 @@ git commit -m "feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast фл
|
||||
### Task 1.4: `BalancePreflightSweepJob` — ежедневный пересчёт заморозок
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/BalancePreflightSweepJob.php`
|
||||
- Create: `app/app/Console/Commands/BillingPreflightSweepCommand.php`
|
||||
- Modify: `app/routes/console.php` (Schedule @18:00 MSK + heartbeat)
|
||||
@@ -485,7 +489,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
|
||||
* Ежедневный префлайт всех тенантов перед формированием заказа поставщику.
|
||||
* Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
|
||||
*
|
||||
* NB: crm_supplier_worker / системный контекст — джоб бегает без tenant-RLS;
|
||||
@@ -574,12 +578,12 @@ final class BillingPreflightSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:preflight-sweep';
|
||||
|
||||
protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
|
||||
protected $description = 'Ежедневный префлайт баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalancePreflightSweepJob())->handle();
|
||||
$this->info('Преfflight sweep завершён.');
|
||||
$this->info('Префлайт sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
@@ -591,7 +595,7 @@ final class BillingPreflightSweepCommand extends Command
|
||||
Добавить после существующих задач (с heartbeat-обёрткой по образцу `projects:reset-delivered-today`):
|
||||
|
||||
```php
|
||||
// Спек C §3.2: преfflight баланса в 18:00 MSK — формирование заказа поставщику без «бедных» клиентов.
|
||||
// Спек C §3.2: префлайт баланса в 18:00 MSK — формирование заказа поставщику без «бедных» клиентов.
|
||||
Schedule::command('billing:preflight-sweep')
|
||||
->dailyAt('18:00')
|
||||
->timezone('Europe/Moscow')
|
||||
@@ -625,6 +629,7 @@ git commit -m "feat(billing-v2-c): BalancePreflightSweepJob + cron 18:00 MSK"
|
||||
### Task 1.5: Mailable — 4 письма заморозки/разморозки
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Mail/BalanceFrozenMail.php` + `app/app/Mail/BalanceFrozenReminderMail.php` + `app/app/Mail/BalanceFrozenFinalMail.php` + `app/app/Mail/BalanceUnfrozenMail.php`
|
||||
- Create: 4 blade-шаблона в `app/resources/views/emails/billing/`
|
||||
- Test: `app/tests/Feature/Billing/BalanceFreezeMailTest.php`
|
||||
@@ -746,6 +751,7 @@ git commit -m "feat(billing-v2-c): 4 Mailable заморозки/разморо
|
||||
### Task 1.6: Повторные письма — reminder через 1 день + final через 3 дня
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/BalanceFrozenReminderJob.php`
|
||||
- Modify: `app/routes/console.php` (Schedule daily + heartbeat)
|
||||
- Test: `app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php`
|
||||
@@ -888,9 +894,10 @@ git commit -m "feat(billing-v2-c): повторные письма заморо
|
||||
|
||||
---
|
||||
|
||||
### Task 1.7: `ProjectController` — преfflight при создании/правке проекта
|
||||
### Task 1.7: `ProjectController` — префлайт при создании/правке проекта
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (store + update — 409 при перегрузке)
|
||||
- Test: `app/tests/Feature/Project/ProjectPreflightTest.php`
|
||||
|
||||
@@ -958,12 +965,12 @@ it('creates normally when within balance', function () {
|
||||
Run: `cd app && php artisan test --filter=ProjectPreflightTest`
|
||||
Expected: FAIL — 409 не возвращается (создаёт нормально).
|
||||
|
||||
- [ ] **Step 3: Реализовать преfflight-проверку в `ProjectController::store`** (и аналогично `update`)
|
||||
- [ ] **Step 3: Реализовать префлайт-проверку в `ProjectController::store`** (и аналогично `update`)
|
||||
|
||||
Вставить ПОСЛЕ валидации, ДО создания проекта:
|
||||
|
||||
```php
|
||||
// Спек C §3.4: преfflight при создании — если сумма лимитов перевешивает баланс.
|
||||
// Спек C §3.4: префлайт при создании — если сумма лимитов перевешивает баланс.
|
||||
$tenant = $request->user()->tenant;
|
||||
$tiers = \App\Models\PricingTier::query()->where('is_active', true)->get();
|
||||
$service = new \App\Services\Billing\BalancePreflightService();
|
||||
@@ -1005,7 +1012,7 @@ Expected: PASS (3 теста).
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/ProjectController.php app/tests/Feature/Project/ProjectPreflightTest.php
|
||||
git commit -m "feat(billing-v2-c): ProjectController преfflight — 409 при перегрузке баланса"
|
||||
git commit -m "feat(billing-v2-c): ProjectController префлайт — 409 при перегрузке баланса"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1013,6 +1020,7 @@ git commit -m "feat(billing-v2-c): ProjectController преfflight — 409 пр
|
||||
### Task 1.8: Фильтр frozen-проектов в `SyncSupplierProjectsJob`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (исключить frozen перед allocator)
|
||||
- Test: `app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php`
|
||||
|
||||
@@ -1094,6 +1102,7 @@ git commit -m "feat(billing-v2-c): SyncSupplierProjectsJob исключает fr
|
||||
### Task 1.9: One-time команда `billing:preflight-initial-sweep`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Console/Commands/BillingPreflightInitialSweepCommand.php`
|
||||
- Test: `app/tests/Feature/Billing/BillingPreflightInitialSweepTest.php`
|
||||
|
||||
@@ -1140,18 +1149,18 @@ use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-time: при выкатке преfflight прогнать всех тенантов и заморозить
|
||||
* One-time: при выкатке префлайт прогнать всех тенантов и заморозить
|
||||
* недофинансированных. Запускается ОДИН раз вручную после миграции. Спек §3.9.
|
||||
*/
|
||||
final class BillingPreflightInitialSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:preflight-initial-sweep';
|
||||
|
||||
protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';
|
||||
protected $description = 'Разовый префлайт при внедрении — заморозить недофинансированных тенантов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки.');
|
||||
$this->warn('Разовый префлайт всех тенантов. Запускать ОДИН раз после выкатки.');
|
||||
(new BalancePreflightSweepJob())->handle();
|
||||
$this->info('Initial sweep завершён.');
|
||||
|
||||
@@ -1177,6 +1186,7 @@ git commit -m "feat(billing-v2-c): one-time billing:preflight-initial-sweep"
|
||||
### Task 1.10: Frontend — баннер заморозки + индикатор ёмкости + диалог перегрузки
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/components/billing/BalanceFrozenBanner.vue`
|
||||
- Create: `app/resources/js/components/billing/BalanceCapacityIndicator.vue`
|
||||
- Create: `app/resources/js/components/projects/ProjectLimitOverloadDialog.vue`
|
||||
@@ -1271,7 +1281,7 @@ Expected: PASS.
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/billing/ app/resources/js/components/projects/ProjectLimitOverloadDialog.vue app/resources/js/layouts/AppLayout.vue
|
||||
git commit -m "feat(billing-v2-c): UI преfflight — баннер, индикатор ёмкости, диалог перегрузки"
|
||||
git commit -m "feat(billing-v2-c): UI префлайт — баннер, индикатор ёмкости, диалог перегрузки"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1281,6 +1291,7 @@ git commit -m "feat(billing-v2-c): UI преfflight — баннер, индик
|
||||
### Task 2.1: Миграция БД — реквизиты тенанта + topup_sessions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_24_110000_add_legal_entity_and_topup_sessions.php`
|
||||
- Create: `db/migrations/2026_05_24_topup_sessions.sql` (prod-синк)
|
||||
- Modify: `db/schema.sql` + `db/CHANGELOG_schema.md`
|
||||
@@ -1377,6 +1388,7 @@ git commit -m "feat(billing-v2-c): миграция — реквизиты юр.
|
||||
### Task 2.2: Установить PDF-генератор
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/composer.json` (+ `barryvdh/laravel-dompdf`)
|
||||
|
||||
- [ ] **Step 1: Установить пакет**
|
||||
@@ -1401,6 +1413,7 @@ git commit -m "chore(billing-v2-c): + barryvdh/laravel-dompdf для счето
|
||||
### Task 2.3: Модель TopupSession + `TopupGatewayInterface` + `BankTransferGateway`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Models/TopupSession.php`
|
||||
- Create: `app/app/Services/Billing/Gateway/TopupGatewayInterface.php`
|
||||
- Create: `app/app/Services/Billing/Gateway/TopupSessionResult.php`
|
||||
@@ -1604,6 +1617,7 @@ git commit -m "feat(billing-v2-c): TopupSession + TopupGatewayInterface + BankTr
|
||||
### Task 2.4: PDF-счёт (Blade-шаблон + endpoint)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/views/pdf/invoice.blade.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/BillingController.php` (+ `invoicePdf`)
|
||||
- Modify: `app/routes/web.php` (route)
|
||||
@@ -1698,6 +1712,7 @@ git commit -m "feat(billing-v2-c): PDF-счёт на пополнение + RLS-
|
||||
### Task 2.5: Рефакторинг `BillingTopupService` под gateway + `confirmPayment`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Billing/BillingTopupService.php`
|
||||
- Test: `app/tests/Feature/Billing/BillingTopupConfirmTest.php`
|
||||
|
||||
@@ -1797,7 +1812,7 @@ public function confirmPayment(string $providerRef, ?int $adminUserId): ?Balance
|
||||
Run: `cd app && php artisan test --filter=BillingTopupConfirmTest`
|
||||
Expected: PASS (2 теста).
|
||||
|
||||
- [ ] **Step 5: Связка с преfflight — после confirm перепроверить заморозку**
|
||||
- [ ] **Step 5: Связка с префлайт — после confirm перепроверить заморозку**
|
||||
|
||||
Добавить в конце `confirmPayment` (после коммита транзакции, или внутри): если `tenant.frozen_by_balance_at !== null` — прогнать `BalancePreflightService::evaluate`, и если теперь проходит — снять заморозку + `BalanceUnfrozenMail` + лог. Написать доп. тест `it('unfreezes tenant on topup confirm')`.
|
||||
|
||||
@@ -1813,6 +1828,7 @@ git commit -m "feat(billing-v2-c): BillingTopupService::confirmPayment + авт
|
||||
### Task 2.6: `VtbStatementSyncJob` (авто-поиск) + dev-симулятор
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/VtbStatementSyncJob.php`
|
||||
- Create: `app/app/Console/Commands/VtbStatementSimulateCommand.php` (dev only)
|
||||
- Create: `app/app/Services/Billing/VtbBusinessClient.php` (интерфейс + null/dev реализация)
|
||||
@@ -1891,6 +1907,7 @@ git commit -m "feat(billing-v2-c): VtbStatementSyncJob авто-поиск пл
|
||||
### Task 2.7: Админка — журнал ожидающих платежей + подтверждение + настройка режима
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Controllers/Api/Admin/PendingTopupsController.php`
|
||||
- Create: `app/app/Http/Controllers/Api/Admin/BillingSettingsController.php`
|
||||
- Modify: `app/routes/web.php` (admin routes)
|
||||
@@ -1964,6 +1981,7 @@ git commit -m "feat(billing-v2-c): админка ожидающих плате
|
||||
### Task 2.8: `NotifyPendingConfirmationsJob` — часовой алерт админу
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Billing/NotifyPendingConfirmationsJob.php`
|
||||
- Create: `app/app/Mail/PendingConfirmationsAdminMail.php` + шаблон
|
||||
- Modify: `app/routes/console.php` (hourly + heartbeat)
|
||||
@@ -2034,6 +2052,7 @@ git commit -m "feat(billing-v2-c): часовой алерт админу о н
|
||||
### Task 2.9: Frontend — пополнение (выбор метода + безнал) + реквизиты + админка
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/views/billing/TopupView.vue`
|
||||
- Create: `app/resources/js/components/billing/TopupMethodPicker.vue`
|
||||
- Create: `app/resources/js/components/billing/BankTransferInvoiceView.vue`
|
||||
@@ -2084,6 +2103,7 @@ git commit -m "feat(billing-v2-c): UI пополнения (безнал) + ре
|
||||
### Task 3.1: `SbpGateway` (dev-режим) + фоновое авто-подтверждение
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/Gateway/SbpGateway.php`
|
||||
- Create: `app/app/Jobs/Billing/DevAutoConfirmSessionJob.php`
|
||||
- Test: `app/tests/Feature/Billing/SbpGatewayTest.php`
|
||||
@@ -2184,6 +2204,7 @@ git commit -m "feat(billing-v2-c): SbpGateway dev-заглушка + авто-п
|
||||
### Task 3.2: `CardGateway` (dev-режим) + dev-mock страница эквайринга
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/Gateway/CardGateway.php`
|
||||
- Create: `app/resources/js/views/dev/DevMockAcquiringView.vue` (только dev)
|
||||
- Modify: router (dev-only route `/dev-mock-vtb-acquiring/:sessionId`)
|
||||
@@ -2241,6 +2262,7 @@ git commit -m "feat(billing-v2-c): CardGateway dev-заглушка + mock-ст
|
||||
### Task 3.3: `FiscalReceiptProvider` — архитектура 54-ФЗ
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Billing/Fiscal/FiscalReceiptProvider.php` (interface)
|
||||
- Create: `app/app/Services/Billing/Fiscal/NoOpFiscalProvider.php`
|
||||
- Create: `app/app/Services/Billing/Fiscal/DevMockFiscalProvider.php`
|
||||
@@ -2334,6 +2356,7 @@ git commit -m "feat(billing-v2-c): архитектура 54-ФЗ — FiscalRece
|
||||
### Task 3.4: Подключить выбор gateway + fiscal в `BillingTopupService::initiateTopup`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Billing/BillingTopupService.php` (resolveGateway + initiateTopup)
|
||||
- Modify: `app/app/Http/Controllers/Api/BillingController.php` (+ `initiateTopup` endpoint)
|
||||
- Modify: `app/routes/web.php`
|
||||
@@ -2398,9 +2421,10 @@ git commit -m "feat(billing-v2-c): initiateTopup — единый вход дл
|
||||
|
||||
## Phase 4 — End-to-end сценарии + регрессия
|
||||
|
||||
### Task 4.1: Pest E2E — преfflight (заморозка → пополнение → разморозка)
|
||||
### Task 4.1: Pest E2E — префлайт (заморозка → пополнение → разморозка)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Feature/Billing/PreflightE2ETest.php`
|
||||
|
||||
- [ ] **Step 1: Написать E2E-тест полного цикла**
|
||||
@@ -2429,7 +2453,7 @@ it('full cycle: sufficient → drained → frozen → topup → unfrozen', funct
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1250.00']); // 1250/50 = 25
|
||||
Project::factory()->for($tenant)->create(['status' => 'active', 'daily_limit' => 25]);
|
||||
|
||||
// Преfflight cut-off — проходит.
|
||||
// Префлайт cut-off — проходит.
|
||||
(new BalancePreflightSweepJob())->handle();
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
|
||||
@@ -2462,7 +2486,7 @@ Expected: PASS. (Если FAIL — починить связку confirmPayment
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Billing/PreflightE2ETest.php
|
||||
git commit -m "test(billing-v2-c): E2E преfflight — полный цикл заморозки/разморозки"
|
||||
git commit -m "test(billing-v2-c): E2E префлайт — полный цикл заморозки/разморозки"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2470,6 +2494,7 @@ git commit -m "test(billing-v2-c): E2E преfflight — полный цикл
|
||||
### Task 4.2: Pest E2E — безнал (счёт → выписка → подтверждение)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Feature/Billing/BankTransferE2ETest.php`
|
||||
|
||||
- [ ] **Step 1: Написать E2E-тест**
|
||||
@@ -2571,18 +2596,19 @@ Expected: 0 leaks, 0 broken links.
|
||||
|
||||
## Phase 5 — Документация + ADR + push
|
||||
|
||||
### Task 5.1: ADR-016 — архитектура преfflight + VTB
|
||||
### Task 5.1: ADR-016 — архитектура префлайт + VTB
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/adr/016-preflight-vtb-architecture.md`
|
||||
|
||||
- [ ] **Step 1: Написать ADR** (Context — переплата поставщику + нет реального пополнения; Decision — преfflight как фильтр eligible до формулы + gateway-pattern для 3 методов + dev-заглушки до Б-1; Consequences — границы PF1..PFN; Status — Accepted). REQUIRED SUB-SKILL: `adr-kit:adr`.
|
||||
- [ ] **Step 1: Написать ADR** (Context — переплата поставщику + нет реального пополнения; Decision — префлайт как фильтр eligible до формулы + gateway-pattern для 3 методов + dev-заглушки до Б-1; Consequences — границы PF1..PFN; Status — Accepted). REQUIRED SUB-SKILL: `adr-kit:adr`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/adr/016-preflight-vtb-architecture.md
|
||||
git commit -m "docs(adr): ADR-016 — преfflight баланса + VTB gateway-архитектура"
|
||||
git commit -m "docs(adr): ADR-016 — префлайт баланса + VTB gateway-архитектура"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2590,12 +2616,13 @@ git commit -m "docs(adr): ADR-016 — преfflight баланса + VTB gateway
|
||||
### Task 5.2: Обновить memory + нормативку (по необходимости)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `memory/project_billing_v2.md` (Спек C: статус, артефакты, что выкачено)
|
||||
- Modify: `memory/MEMORY.md` (одна строка-указатель, если меняется)
|
||||
|
||||
- [ ] **Step 1: Обновить `project_billing_v2.md`** — раздел «Спек C» из PENDING brainstorm → IMPLEMENTED (worktree, ветка, фазы, что dev-заглушки до Б-1, что pending). Через Write tool (это memory, не код).
|
||||
|
||||
- [ ] **Step 2: CLAUDE.md / Pravila / Tooling** — **проверить**, нужны ли правки. Преfflight/VTB — это фича портала, не новый инструмент тулчейна → **скорее всего нормативка не меняется**. Если нужно — через `/claude-md-management:claude-md-improver` (НЕ прямой Edit CLAUDE.md, кроме worktree-эксцепшна §5 п.10).
|
||||
- [ ] **Step 2: CLAUDE.md / Pravila / Tooling** — **проверить**, нужны ли правки. Префлайт/VTB — это фича портала, не новый инструмент тулчейна → **скорее всего нормативка не меняется**. Если нужно — через `/claude-md-management:claude-md-improver` (НЕ прямой Edit CLAUDE.md, кроме worktree-эксцепшна §5 п.10).
|
||||
|
||||
- [ ] **Step 3: Commit (если были правки)**
|
||||
|
||||
@@ -2618,7 +2645,7 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
|
||||
|
||||
Варианты: PR в main (через `gh`) ИЛИ FF-merge в main (паттерн Спека B `git push origin feat/billing-v2-spec-c:main` через worktree). **Только после явного «выкатываем» от заказчика** (Pravila §2.2 — не закрывать без подтверждения).
|
||||
|
||||
- [ ] **Step 3: НЕ выкатывать на прод автоматически.** Боевой деплой VTB-части невозможен до Б-1 (нет реквизитов). Преfflight-часть можно выкатить — но **только по явному решению заказчика** + ручной прогон `billing:preflight-initial-sweep` на проде с предупреждением клиентам.
|
||||
- [ ] **Step 3: НЕ выкатывать на прод автоматически.** Боевой деплой VTB-части невозможен до Б-1 (нет реквизитов). Префлайт-часть можно выкатить — но **только по явному решению заказчика** + ручной прогон `billing:preflight-initial-sweep` на проде с предупреждением клиентам.
|
||||
|
||||
- [ ] **Step 4: Обновить `ПИЛОТ.md`** (если/когда выкачено на боевой liderra.ru).
|
||||
|
||||
@@ -2650,7 +2677,7 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
|
||||
|
||||
## Заметки для исполнителя
|
||||
|
||||
**По §3.9 спека (граничные случаи):** главный инвариант «баланс не уходит в минус» держится `LedgerService` (он уже защищён от списания ниже нуля) + ежедневным преfflight. Поэтому отдельное «предупреждение админа при ретро-операциях» (CSV-импорт исторических лидов / ручная правка баланса между cut-off и началом дня) — **опциональная UX-полировка**, не блокер. Если делать — добавить confirm-диалог в `ImportController` и в `AdminTenantsController::adjustBalance` UI с текстом «эта операция может вывести клиента в заморозку на следующем cut-off». Минимально — можно вынести в отдельную мелкую задачу или Спек D. Преfflight отработает корректно в любом случае.
|
||||
**По §3.9 спека (граничные случаи):** главный инвариант «баланс не уходит в минус» держится `LedgerService` (он уже защищён от списания ниже нуля) + ежедневным префлайт. Поэтому отдельное «предупреждение админа при ретро-операциях» (CSV-импорт исторических лидов / ручная правка баланса между cut-off и началом дня) — **опциональная UX-полировка**, не блокер. Если делать — добавить confirm-диалог в `ImportController` и в `AdminTenantsController::adjustBalance` UI с текстом «эта операция может вывести клиента в заморозку на следующем cut-off». Минимально — можно вынести в отдельную мелкую задачу или Спек D. Префлайт отработает корректно в любом случае.
|
||||
|
||||
**По UI-задачам (1.10, 2.9, 3.2):** для критичных компонентов (`BalanceCapacityIndicator`) дан полный код. Для остальных Vue-компонентов дан контракт (props / emit / поведение) — исполнитель пишет разметку по образцу первого компонента и существующих паттернов проекта (Vuetify 3, Composition API, `<script setup lang="ts">`). Это сознательное решение: расписывать 10 Vue-компонентов построчно раздуло бы план без пользы. Каждый UI-компонент имеет Vitest-тест с явными ожиданиями — они и фиксируют контракт.
|
||||
|
||||
@@ -2666,4 +2693,3 @@ REQUIRED SUB-SKILL: `superpowers:finishing-a-development-branch`.
|
||||
- Боевая интеграция ОФД-Атол (фискализация).
|
||||
- Боевые секреты в YC Lockbox (SEC-5).
|
||||
- Спек D: «отдать разморозившемуся клиенту лиды через шеринг» + «приоритет шеринга по платёжеспособности».
|
||||
|
||||
|
||||
@@ -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'ами на ревью.
|
||||
|
||||
**Какой подход?**
|
||||
@@ -308,3 +308,131 @@
|
||||
## Следующий шаг
|
||||
|
||||
После твоего ревью этого спека → `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.
|
||||
|
||||
@@ -34,7 +34,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
### §1.2 Проблемы
|
||||
|
||||
**Преfflight:**
|
||||
**Префлайт:**
|
||||
|
||||
1. **Портал переплачивает поставщику** за лиды клиентов, у которых нет денег. Это прямой убыток — закупка оплачена, а продажа не состоится.
|
||||
2. **Нет проактивной защиты при создании/изменении проектов.** Клиент может выставить лимит, который сам по себе не оплачиваем. Сейчас проблема всплывает только в момент списания.
|
||||
@@ -42,8 +42,8 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
**VTB-эквайринг:**
|
||||
|
||||
4. **Реального пополнения нет** — `BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
|
||||
5. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
|
||||
1. **Реального пополнения нет** — `BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
|
||||
2. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
|
||||
|
||||
### §1.3 Триггер
|
||||
|
||||
@@ -57,7 +57,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
### §2.1 Что делаем
|
||||
|
||||
**Преfflight баланса:**
|
||||
**Префлайт баланса:**
|
||||
|
||||
- Расширение `SupplierQuotaAllocator` для учёта баланса клиента (фильтрация eligible-проектов до `computeOrder`).
|
||||
- Активная проверка при создании/правке проекта в личном кабинете (диалог выбора).
|
||||
@@ -81,17 +81,17 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
- **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** — отложено в Спек D (см. §8).
|
||||
- **Возвраты пополнений** (refund) — не реализуем (Спек A: «возвраты не делаем»).
|
||||
- **Recurring-платежи** (автосписание) — не реализуем.
|
||||
- **Изменение формулы `computeOrder`** — формула остаётся прежней, преfflight только фильтрует входной список.
|
||||
- **Изменение формулы `computeOrder`** — формула остаётся прежней, префлайт только фильтрует входной список.
|
||||
|
||||
---
|
||||
|
||||
## §3. Решение — часть 1: Преfflight баланса
|
||||
## §3. Решение — часть 1: Префлайт баланса
|
||||
|
||||
### §3.1 Главный инвариант
|
||||
|
||||
**Баланс клиента никогда не уходит в минус.** Гарант — преfflight, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
|
||||
**Баланс клиента никогда не уходит в минус.** Гарант — префлайт, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
|
||||
|
||||
### §3.2 Когда срабатывает преfflight
|
||||
### §3.2 Когда срабатывает префлайт
|
||||
|
||||
**Одна основная точка:** ежедневный cut-off в **18:00 MSK** (включая выходные).
|
||||
|
||||
@@ -124,11 +124,11 @@ $requiredLeads = $tenant->projects()
|
||||
$passes = $capacity >= $requiredLeads;
|
||||
```
|
||||
|
||||
Если `passes=true` — клиент проходит преfflight. Если `false` — не проходит.
|
||||
Если `passes=true` — клиент проходит префлайт. Если `false` — не проходит.
|
||||
|
||||
7-ступенчатый расчёт уже реализован в `BalanceToLeadsConverter::convert` (Спек A) — он сам пройдёт по ступеням, учтёт «текущую» (где сейчас клиент в накопленном объёме) и переход на следующие при росте.
|
||||
|
||||
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что преfflight работает один раз перед формированием заказа на завтра, а не во время выдачи.
|
||||
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что префлайт работает один раз перед формированием заказа на завтра, а не во время выдачи.
|
||||
|
||||
### §3.4 Что делает портал при создании/правке «перегруженного» проекта
|
||||
|
||||
@@ -218,16 +218,16 @@ $passes = $capacity >= $requiredLeads;
|
||||
- **Онлайн-режим** (сейчас, для малого числа клиентов): любое изменение в проектах Лидерры → немедленный апдейт на сервере поставщика (`SyncSupplierProjectJob` per-project). Поставщик сохраняет, использует в своём 21:00 слепке.
|
||||
- **Batch до 18:00** (на будущее при росте): накопленные изменения уезжают одним пакетом перед 18:00 (`SyncSupplierProjectsJob` daily cron).
|
||||
|
||||
**Преfflight работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
|
||||
**Префлайт работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
|
||||
|
||||
### §3.9 Граничные случаи
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| Ретро-операция (CSV-импорт исторических лидов) между 18:00 и началом следующего дня списывает баланс ниже плана | Допускается, но админ предупреждается в UI «эта операция может вывести клиента в заморозку, продолжить?». Если согласился — выполняется; на следующем cut-off клиент будет в заморозке. Минусовых балансов не возникает (CSV-импорт делает обычные `lead_charges` через `LedgerService`, который остаётся защищён от минуса) |
|
||||
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Преfflight отработает на следующем cut-off |
|
||||
| Клиент уже в минусовом балансе на момент запуска преfflight (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
|
||||
| Тарифная ступень меняется в течение дня (накопился объём) | Преfflight на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
|
||||
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Префлайт отработает на следующем cut-off |
|
||||
| Клиент уже в минусовом балансе на момент запуска префлайт (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
|
||||
| Тарифная ступень меняется в течение дня (накопился объём) | Префлайт на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
|
||||
| Поставщик прислал меньше планируемого (норма) | Остаток баланса клиента — экономия для следующего дня. Никаких корректировок |
|
||||
| Клиент пополнил после 18:00 | В сегодня-в-21:00-слепок поставщика не успевает, но в личном кабинете тут же «Возобновлено». В следующий вечерний cut-off — в заказ на послезавтра |
|
||||
|
||||
@@ -239,7 +239,7 @@ $passes = $capacity >= $requiredLeads;
|
||||
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
|
||||
```
|
||||
|
||||
Преfflight **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
|
||||
Префлайт **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
|
||||
|
||||
**Эффект зависит от того, кого выкинули:**
|
||||
|
||||
@@ -256,7 +256,7 @@ order = max(самый_большой_лимит, ceil(сумма_лимитов
|
||||
|
||||
### §3.11 Журналирование
|
||||
|
||||
При каждом срабатывании преfflight (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
|
||||
При каждом срабатывании префлайт (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE balance_freeze_log (
|
||||
@@ -488,7 +488,7 @@ ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP
|
||||
| **Фронт-views** | `TopupView.vue` (новый, обёртка с переключателем methods); `BillingFrozenInfoView.vue` |
|
||||
| **Pinia** | `billingStore` (расширение под topup-сессии); `tenantStore` (frozen-флаг) |
|
||||
|
||||
### §5.2 Sequence-диаграмма преfflight на cut-off
|
||||
### §5.2 Sequence-диаграмма префлайт на cut-off
|
||||
|
||||
```
|
||||
18:00 MSK Cron
|
||||
@@ -518,7 +518,7 @@ ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP
|
||||
│ │
|
||||
│ └── для следующего tenant...
|
||||
│
|
||||
18:05 MSK (после преfflight) — обычный SyncSupplierProjectsJob запускается
|
||||
18:05 MSK (после префлайт) — обычный SyncSupplierProjectsJob запускается
|
||||
│
|
||||
├── SupplierQuotaAllocator::allocate(eligible_projects)
|
||||
│ │
|
||||
@@ -635,11 +635,12 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
## §6. Сценарии (end-to-end)
|
||||
|
||||
### §6.1 Преfflight — пассивный износ
|
||||
### §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`.
|
||||
|
||||
@@ -648,6 +649,7 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
**Понедельник.** В течение дня партии лидов приходят, клиент получает все 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')`.
|
||||
@@ -661,11 +663,11 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
**Вторник 18:00.** Cron snova — клиент в заказе на среду. Со среды получает лиды.
|
||||
|
||||
### §6.2 Преfflight — активная нехватка при создании проекта
|
||||
### §6.2 Префлайт — активная нехватка при создании проекта
|
||||
|
||||
**Клиент с балансом 1000₽ = 30 лидов.** Имеет 3 проекта по 10 = 30 лимит. Всё впритык.
|
||||
|
||||
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-преfflight:
|
||||
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-префлайт:
|
||||
|
||||
- `Σ daily_limit (после сохранения) = 50` лидов
|
||||
- `capacity = BalanceToLeadsConverter::convert(1000₽, delivered, tiers)['leads'] = 30` лидов
|
||||
@@ -764,7 +766,7 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
Предварительная разбивка на фазы (для оценки масштаба):
|
||||
|
||||
**Phase 1 — Преfflight баланса** (~5-7 задач):
|
||||
**Phase 1 — Префлайт баланса** (~5-7 задач):
|
||||
|
||||
- Миграция БД (`frozen_by_balance_at`, `preflight_blocked_at`, `balance_freeze_log`).
|
||||
- `BalancePreflightService` (pure) + тесты.
|
||||
@@ -796,7 +798,7 @@ $dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
|
||||
**Phase 4 — Тесты + smoke** (~3-4 задач):
|
||||
|
||||
- Pest end-to-end сценарии преfflight (frozen/unfrozen flow).
|
||||
- Pest end-to-end сценарии префлайт (frozen/unfrozen flow).
|
||||
- Pest end-to-end сценарии безнала (создание счёта → симуляция выписки → подтверждение).
|
||||
- Vitest на новые Vue-компоненты.
|
||||
- Регрессия (Pest --parallel + Vitest + lychee + gitleaks).
|
||||
|
||||
@@ -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».
|
||||
@@ -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,260 @@
|
||||
#!/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;
|
||||
}
|
||||
|
||||
export async function callAnthropicAPI(prompt, { apiKey, model = 'claude-haiku-4-5-20251001', fetchImpl = fetch }) {
|
||||
const r = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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(`Anthropic API ${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 () => {
|
||||
const llmPrompt = buildLLMPrompt(prompt, registry);
|
||||
const text = await callAnthropicAPI(llmPrompt, { apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
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,180 @@
|
||||
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 } 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.
|
||||
});
|
||||
});
|
||||
@@ -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