Compare commits

...

135 Commits

Author SHA1 Message Date
Дмитрий 1114cd1722 docs(brain): brain dashboard implementation plan
13 tasks across 3 phases — static server + topology extraction + 4 views
(Карта / Разбор / Лента / Агрегат). TDD on dashboard-core.js, smoke on UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:04:09 +03:00
Дмитрий 092f55829b docs(brain): brain dashboard design spec
Standalone HTML dashboard that visualises the observer episode log over
the automation-graph topology — 4 views (map / task-replay / session
feed / aggregate), graph as shared canvas, 3-phase build order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:22:05 +03:00
Дмитрий 21f1d7833b chore: .gitattributes — force LF for *.mjs (prevent CRLF/vitest breakage)
core.autocrlf=true rewrites .mjs to CRLF in the working tree on
checkout/rebase. vitest fails to load CRLF .mjs files with imports
(SyntaxError: Invalid or unexpected token, no location) — node --check
and esbuild tolerate it, only vitest's transform breaks. `*.mjs text
eol=lf` pins LF in the working tree regardless of autocrlf.

See memory quirk #100. Repo blobs were already LF — no content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:05:36 +03:00
Дмитрий 9e1a07aad3 chore(observer): remove 5 empty unknown-* episode stubs + commit session episodes
unknown-<ts>, empty events, fake outcome:success) — zero information.
Removed; remaining episodes carry real data. One-time cleanup of
pre-extension garbage — append-only stays the operational rule.
STATUS.md regenerated by C4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:40:37 +03:00
Дмитрий b2b9a75731 feat(observer): AskUserQuestion in-turn choice + parallel_session narrowing
#1 — detectAskUserQuestionChoice: when a turn contains an AskUserQuestion
whose answer exactly matches an offered option label, classify as
user_chose_from_options. The answered entry carries a structured
toolUseResult (questions[].options[].label + answers map). A custom
"Other" free-text answer is NOT a pick — falls through. Wired into
parseTranscript after the text-list detector.

#3 — parallel_session: dropped broad word matches (параллельн /
"parallel session") that false-fired on any casual mention. Now only
strong collision evidence (foreign git index / чужой staged /
index.lock / another git process). Best-effort per spec R2 — prefer
false-negative over false-positive.

169/169 tools tests GREEN (+9 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:39:09 +03:00
Дмитрий 287332eddf docs: CLAUDE.md header version drift fix — 2.18 -> 2.20
Header «Версия» line lagged at 2.18 while §9 already carried v2.19
(factor-analysis extension) and v2.20 (phase 1.1) entries — pre-existing
drift from f7f37fb. Header now reflects actual latest version; v2.18
summary demoted to «v2.18 наследие». Full per-version detail stays in §9.

Через /claude-md-management:claude-md-improver (§5 п.10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:39:08 +03:00
Дмитрий 8550ba243d fix(observer): exclude synthetic user-role messages from turn detection
Root cause (systematic-debugging): isRealUserPrompt treated skill-content
("Base directory for this skill:"), local-command output
(<local-command-stdout>), and interrupt markers as genuine prompts.
findTurnStart then anchored a turn on the synthetic message — the turn
slice missed the genuine prompt's UserPromptSubmit hook_additional_context
attachment → economy_level: null, wrong prompt_signal/task_classification.
Same cause made extractLastUserPromptText return skill content, so the
Stop-hook routing-gate false-positive-blocked autonomous §12 skill
invocations (detectMethodDirected saw the node name in skill text).

Fix: SYNTHETIC_PROMPT_MARKERS + isSyntheticPrompt — isRealUserPrompt
returns false for synthetic messages. One fix closes both the
economy_level capture gap and the 2nd routing-gate FP class.

160/160 tools tests GREEN (+3 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:39:06 +03:00
Дмитрий ad09db606a docs(supplier): closure — project channel failover epic (12 tasks)
Все 12 задач плана docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md
выполнены. Резервный канал миграции проектов Лидерра → crm.bp-gr.ru:
3 яруса — AJAX rt-project-* → авто-браузер формы «Мои проекты» →
operator worklist (supplier_manual_sync_queue).

Задачи: T1 live recon rt-project-* контракта · T2 SupplierProjectChannel
interface + AjaxProjectChannel · T3 supplier_manual_sync_queue (schema v8.25)
· T4 FailoverProjectChannel escalation matrix · T5 portal-side dedup ·
T6 manage-project.js · T7 FormProjectChannel + DI · T8 wire jobs ·
T9 cron 20:30→18:00 / 20:15→17:45 · T10 admin endpoints · T11 admin UI ·
T12 регрессия + code-review.

Регрессия зелёная: Pest 973/970/0 / 3 skipped / 2847 assertions;
Vitest 882/0 / 3 skipped (111 files); Pint clean; gitleaks 14 commits /
0 leaks; markdownlint + lychee clean. Larastan: изолированный прогон по
supplier-failover файлам — 0 реальных ошибок (полный baseline-drift —
артефакт worktree-env, _ide_helper_models.php отсутствовал; финальная
larastan-верификация — в основной копии после merge, memory quirk).

Финальное code-review (Opus): найден + исправлен 1 CRITICAL (контракт
listProjects — нормализация сырых rt-строк) + I1 (log дедуп-сбоя).

ОГРАНИЧЕНИЯ (не верифицировано в этой сессии):
- Live smoke по 3 ярусам (план T12.1-12.3) НЕ выполнен — требует боевого
  портала crm.bp-gr.ru, queue worker, форс-фейлов DI и создания тестовых
  проектов на живом портале. Откладывается на отдельную сессию с
  присутствием заказчика.
- Code-review I2 (partial-unique индекс supplier_manual_sync_queue от
  дубль-эскалаций при job-retry) и I3 (lockForUpdate в manualQueueResolve)
  — follow-up до прод-релиза (эпик гейтится Б-1, не в проде).
- Larastan полный baseline — пересинхронизировать в основной копии.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:12:30 +03:00
Дмитрий c27539ca29 chore(supplier): markdownlint fix — CHANGELOG v8.25 metrics line
Строка метрик начиналась с «+ 121 индекс» после переноса → markdownlint
MD004/MD032 (трактовал как list-item). Переформулирована через запятые.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:11:32 +03:00
Дмитрий 9b4bff48f0 fix(supplier): normalize rt-projects-load — dedup contract (code-review C1)
Финальное code-review вскрыло CRITICAL: dedup-сверка findOnPortal и
manualQueueResolve матчат строки портала по {platform, signal_type,
unique_key}, но listProjects отдавал сырое тело rt-projects-load.

Сырая форма (verified из recon-снапшота 2026-05-19):
- ответ — конверт {projects:[443 строки], tags, users, ...}, НЕ голый массив
  → listProjects возвращал весь dict, findOnPortal итерировал по ключам
  конверта (projects/tags/...) вместо строк проектов;
- строка проекта: {id, name:"B<n>_<key>", type:"hosts|calls|sms", content}
  — без platform/signal_type/unique_key.

Фикс:
- SupplierPortalClient::listProjects — извлекает body['projects'].
- AjaxProjectChannel::listProjects — нормализует сырые строки в контракт
  SupplierProjectChannel: platform <- префикс name "B<n>_", signal_type <-
  type (hosts->site/calls->call/sms->sms), unique_key <- content. Сырые
  поля сохранены. findOnPortal + manualQueueResolve матчат корректно.
- AjaxProjectChannelTest — тест нормализации против фактической формы
  портала (не идеального мока); SupplierPortalClientRtProjectTest —
  listProjects против конверта {projects}.

Также (code-review I1): findOnPortal catch — Log::warning проглоченного
исключения, иначе провал дедупа невидим (молчаливый дубль rt-проекта).

Code-review I2 (partial-unique индекс supplier_manual_sync_queue от
дубль-эскалаций при job-retry) и I3 (lockForUpdate в manualQueueResolve) —
follow-up до прод-релиза (эпик гейтится Б-1, не в проде).

Регрессия Pest 973/970/0 / 3 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:09:48 +03:00
Дмитрий 6c30c248bc fix(supplier): larastan deadCode + Pest higher-order cleanup (T12)
FailoverProjectChannel: убран unreachable throw new LogicException после
try-catch в createProjectForLiderra — все ветки уже терминируют (return /
throw WindowDeferred / escalateToTier3(): never). phpstan deadCode.unreachable.

SupplierManualQueueTest: test()-> заменён на $this-> (идиома проекта,
как AdminPricingTiersControllerTest) — phpstan не типизирует Pest
higher-order test(); authAdmin() helper убран, actingAs inline в it().

Изолированный phpstan по supplier-failover файлам — 0 реальных ошибок.
Task 12 cleanup. Channel-тесты 7/7, admin-тесты 4/4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:11 +03:00
Дмитрий 9443b5b446 feat(supplier): manual queue section in AdminSupplierIntegrationView
Таблица pending-записей яруса 3 + кнопка «Отметить выполнено» с confirm-
диалогом, дёргает POST .../manual-queue/{id}/resolve. Реюз существующего
админ-экрана интеграции с поставщиком (после «Истории сверок»).

NB: spec в tests/Frontend/ (vitest include — tests/Frontend/**, не
resources/.../__tests__/ как указал план Step 11.1). loadManualQueue
defensive Array.isArray-guard — иначе onMounted в чужих spec'ах
(mockResolvedValue без queue-ключа) ловил undefined.length.

Spec §4.6. Task 11 of 12. Vitest 5/5 (2 новых + 3 существующих).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:10 +03:00
Дмитрий 25088e4a33 feat(supplier): admin endpoints for Tier 3 manual queue
GET /api/admin/supplier-integration/manual-queue — pending список (limit 100).
POST /manual-queue/{id}/resolve — оператор пометил, что вручную создал проект
на портале; reconcile через channel->listProjects() по (platform, signal_type,
unique_key), 409 если не найден.

ОТКЛОНЕНИЕ ОТ plan Step 10.3: план писал portal external_id прямо в
projects.supplier_b*_project_id (FK на local supplier_projects.id) — FK
violation. Resolve делает firstOrCreate local supplier_projects row с
verified external_id, в FK пишет local id.

Routes — в группе saas-admin (web.php, EnsureSaasAdmin стаб). Task 10 of 12.
Tests 4/4 (index pending / exclude resolved / resolve match / resolve 409).

Spec §4.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:09 +03:00
Дмитрий fcd06afcb2 feat(supplier): retime supplier sync crons 20:30→18:00, 20:15→17:45
Запас ~3 часа до портального дедлайна 21:00 — эскалация на ярус 2/3
(медленный браузер / ручной оператор) происходит в рабочее время.
RefreshSupplierSessionJob daily — на 15 мин раньше sync (17:45).
Hourly RefreshSupplierSessionJob — без изменений.

Spec §4.7. Task 9 of 12. Tests 2/2 (cron expression + timezone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:09 +03:00
Дмитрий 2f55632792 feat(supplier): wire jobs to FailoverProjectChannel
Оба job'а инжектят SupplierProjectChannel (DI → FailoverProjectChannel)
вместо прямого SupplierPortalClient. Catch TierEscalatedException +
WindowDeferredException — эскалация/перенос пропускают элемент, не валят job.

SyncSupplierProjectJob (singular): handle переписан — find-or-create local
supplier_projects row, portal-create через channel. ОТКЛОНЕНИЕ ОТ plan Step 8.1:
план писал channel-результат (portal external_id) прямо в projects.supplier_b*_
project_id, но эта колонка — FK на supplier_projects.id (local), не portal id.
Сохранена семантика ensureSupplierProject — job создаёт local row с
supplier_external_id и пишет в FK local id. ensureSupplierProject удалён из
SupplierPortalClient (был единственный consumer — этот job).

SyncSupplierProjectsJob (plural): handle/syncOne принимают channel; create →
createProjectForLiderra, update → updateProjectForLiderra (context-project из
liderraProjects->first() для project_id в очереди яруса 3).

Tests: singular переписан под SupplierProjectChannel mock (6 tests, incl.
idempotency reuse); plural — handle(AjaxProjectChannel) для non-failover
ветки (Http::fake-контракт сохранён). Larastan отложен на T12 (worktree
quirk — гонится в основной копии). Регрессия Pest 966/963/0 / 3 skipped.

Spec §5. Task 8 of 12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:08 +03:00
Дмитрий 54365015d8 feat(supplier): FormProjectChannel (Tier 2) + DI binding
PHP wrapper над manage-project.js через PlaywrightBridge.
+PlaywrightBridge::run(array): generic Node-скрипт runner (refreshSession
не тронут) — план Step 7.4 предусмотрел расширение bridge.
SupplierProjectChannel::class в DI резолвится в FailoverProjectChannel
(ярус 1 AjaxProjectChannel → ярус 2 FormProjectChannel → ярус 3 queue).

Spec §4.3, §4.4. Task 7 of 12. Channel-тесты 16/16 (Ajax 4 + Failover 7 + Form 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:08 +03:00
Дмитрий 4dd40f609f feat(supplier): manage-project.js — Playwright drives «Мои проекты» form
create/update/list через headless Chromium по образцу refresh-session.js.
Селекторы зафиксированы из recon-снапшота rt-add-project-form.yml (Task 1).
stdin/stdout JSON, exit codes 0/1/2/3/4 (success/auth/selector/timeout/input).

Фикстурный тест против локального HTML — без живого портала. Runner —
встроенный node:test (app/playwright не использует @playwright/test, только
playwright core); skipLogin режим открывает фикстуру напрямую.

Spec §4.3. Task 6 of 12. Node-тесты 2/2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:07 +03:00
Дмитрий d760036972 feat(supplier): FailoverProjectChannel portal-side dedup before create
listProjects() матч по (platform, signal_type, unique_key) до create.
Защита от дубля при полу-успехе яруса 1 (create прошёл на портале, но
локальная запись не сохранилась → следующий запуск дублировал бы).
listProjects-сбой проглатывается — ярус-эскалация всё равно покроет.

Spec §4.4 шаг 2, §7. Task 5 of 12. Тесты 7/7 (19 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:07 +03:00
Дмитрий 0e27844a28 feat(supplier): FailoverProjectChannel skeleton — escalation matrix without dedup
Tier 1 → классификация исключения → ярус 2 (плейсхолдер) / ярус 3 (queue).
Без портального dedup (см. Task 5). Без реального Tier 2 (см. Task 7).

Матрица эскалации:
- Tier 1 success → return id
- WindowDeferredException → re-throw (операция переносится, без queue/alert)
- SupplierTransientException → сразу Tier 3 (skip Tier 2 — хост недоступен)
- SupplierClient/AuthException → Tier 2; success → failover_to_form alert;
  fail → Tier 3 queue + manual_required alert + TierEscalatedException
- escalateToTier3 пишет supplier_manual_sync_queue + queue'ит критический alert.

6 тестов матрицы эскалации зелёные (17 assertions). Spec §4.4, §6, §8.
Task 4 of 12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:06 +03:00
Дмитрий d369383c7d feat(supplier): supplier_manual_sync_queue table (Tier 3 queue)
SaaS-level (без tenant_id, без RLS, как supplier_csv_reconcile_log).
+3 CHECK (platform/operation/status), +2 индекса, +2 FK
(project_id→projects CASCADE, resolved_by_user_id→users SET NULL).

Миграция через DB::unprepared (PG prepared statement не разрешает multi-SQL).
schema.sql bumped v8.24 → v8.25 (64 base tables / 121 indexes / 40 RLS).
SchemaDeltaTest обновлён под новые метрики (63→64 tables, 119→121 indexes).

§15.2 pre-flight: rebase на origin/main f7f37fb выполнен до коммита.
Spec §4.5. Task 3 of 12. Регрессия: schema+delta тесты 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:06 +03:00
Дмитрий 54fcc4b094 feat(supplier): SupplierProjectChannel interface + AjaxProjectChannel (Tier 1)
Тонкий адаптер над SupplierPortalClient. Существующий клиент не меняется —
он остаётся HTTP-плумбингом, адаптер реализует интерфейс контракта.

Spec §4.1, §4.2. Task 2 of 12.

Tests: 4/4 passing (1 instanceof + 3 delegation: create/update/list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:05 +03:00
Дмитрий e87b1385cf feat(supplier): verify rt-project-* contract live on crm.bp-gr.ru
Live discovery через Playwright MCP (Task 1):
- создан LIDPOTOK_TEST_DELETE_ME (B1+B2+B3) → 3 rt-проекта на портале;
- записаны сетевые запросы /admin/visit/rt-*;
- все три проекта удалены вручную, портал чист.

Endpoints (verified):
- POST /admin/visit/rt-project-save (create id:0, update id:N — same URL)
- POST /admin/visit/rt-project-delete (id строкой)
- GET  /admin/visit/rt-projects-load?src=none

Все три — application/json. Конверт ответа:
- success: HTTP 200 + {status:OK, message, result, id?:string}
- error:   HTTP 200 + {status:Error, message, result:null}
ID — строка (12721245), приводится к int (fits в int64).
Один save с B1+B2+B3 включёнными создаёт 3 rt-проекта — toPayload()
шлёт ровно один платформенный флаг (srcrt|srcbl|srcmt).

SupplierPortalClient:
- docblock переписан под verified контракт
- listProjects: путь /admin/visit/rt-projects-load + ?src=none query
- saveProject: путь /admin/visit/rt-project-save, asJson, парсинг id
- updateProject: тот же endpoint что save, id:N в body
- deleteProject: путь /admin/visit/rt-project-delete, asJson, id строкой
- new assertStatusOk() — HTTP 200 + status:Error → SupplierClientException
- toPayload(): полный Vuex-payload с маппингом DTO → portal:
  - platform B1/B2/B3 → srcrt/srcbl/srcmt (single-true)
  - signalType site/call/sms → type:hosts/calls/sms
  - workdays int[] → string[]
  - status active/paused → bool
  - + tag:_lidpotok, name/content из uniqueKey, defaults для show/depth/etc

Tests:
- new: tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php (7 tests,
  contract: save+update+delete+list + 2 status:Error error-paths + B2/calls
  mapping)
- Sync/Cleanup/Unit тесты обновлены под новый URL + envelope shape.

Закрывает spec §1 honest-caveat «placeholder, не верифицирован»
и журнал решений запись 9. Регрессия: Pest 944/941/0 failed / 3 skipped
/ 2768 assertions / 59.2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:05 +03:00
Дмитрий 66ca57f187 sessions: claim supplier-project-channel-failover
Per Pravila §15.2: pre-flight `git fetch origin && git log HEAD..origin/main` clean
after rebase onto d484e60. Worktree env restored (composer install + npm ci
--legacy-peer-deps + npm run build + storage/framework dirs); Pest baseline
GREEN 937/934 / 0 failed / 3 skipped / 2756 assertions / 51.7s.

Scope: 17 files (interface + 3 channels + 2 exceptions + 2 jobs + DI + migration
+ schema/CHANGELOG + node script + controller + view + console route).
Version-claim: db/schema.sql v8.21 → v8.22 (Task 3 +supplier_manual_sync_queue).
Closes: docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:04 +03:00
Дмитрий 430efe624d docs(brain): phase 1.1 normative sync — user_chose_from_options 3rd kind
Pravila v1.32 -> v1.33: §16.2 decision_provenance.kind extended to 3
values (autonomous | user_directed_method | user_chose_from_options);
§16.7 +paragraph «Граница user_chose_from_options» (routing-gate does
not block collaborative-choice); §16.6 +plan cross-ref; §10 +v1.32
(missing) +v1.33 entries.

Tooling §0 cross-ref string Pravila v1.32 -> v1.33 (no header bump).
CLAUDE.md §0 Pravila row v1.32 -> v1.33, §3.6 +phase 1.1 sentence,
§9 +v2.20 entry (via claude-md-management plugin, §5 п.10).

cross-ref-checker: 0 drift in 4 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:14:12 +03:00
Дмитрий dc6d2dd358 test(brain-retro): regression guard — 3rd provenance kind in factor matrix
buildFactorMatrix already buckets decision_provenance.kind dynamically
(brain-retro-analyzer.mjs:112) — no production change needed. Test
pins that user_chose_from_options is counted on the provenance axis.

12/12 brain-retro tests GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:06:56 +03:00
Дмитрий 4969363f78 feat(observer): routing-gate no-block for user_chose_from_options
When episode is user_chose_from_options, routing-gate does NOT block —
collaborative-choice from Claude-offered options doesn't require a
routing-tag (detector is deterministic). 18/18 stop-hook tests GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:05:49 +03:00
Дмитрий 0e3938f845 feat(observer): parser integration — user_chose_from_options before routing-tag
detectChoiceProvenance runs BEFORE parseRoutingTag; if last assistant
turn offered options and user prompt references one, decision_provenance
becomes user_chose_from_options. Otherwise falls back to existing
routing-tag / autonomous logic.

3 new parser tests GREEN; all existing tests still GREEN (43/43).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:04:25 +03:00
Дмитрий 7f379bd6a2 feat(observer): choice detector — user_chose_from_options kind
Pure module — extracts options (numbered/lettered/bullets/AskUserQuestion)
from last assistant message, detects user reference (position-based +
substring), returns decision_provenance for the 3rd kind.

23/23 tests GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:57:36 +03:00
Дмитрий f751ded65b docs(observer): implementation plan — phase 1.1 user_chose_from_options
5 tasks TDD plan with explicit code per step. Task 1 creates
observer-choice-detector.mjs pure module (23 tests). Task 2 wires
into transcript-parser. Task 3 extends routingGateDecision (no-block).
Task 4 extends brain-retro factor matrix. Task 5 normative sync
(Pravila §16.2 + CLAUDE.md §3.6 + spec cross-ref).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:53:53 +03:00
Дмитрий 0c8d0fa8d1 docs(observer): spec v1.1 — phase 1.1 amendment user_chose_from_options
Adds 3rd decision_provenance kind for collaborative-choice case
(user picks one of options Claude offered). Distinct from
user_directed_method: counterfactual = Claude's recommended option,
not "what Claude would have done autonomously". Routing-gate does
NOT block this kind — collaborative choice from Claude-designed
choice-space.

Trigger: 19.05.2026 live false-positives — "1 экономия 0%",
"в делаем", "делай 2" classified as user_directed_method.

§11 + 8 subsections; 7-attribute decision_provenance schema;
new tools/observer-choice-detector.mjs (pure module); parser
+routing-gate +/brain-retro extensions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:47:30 +03:00
Дмитрий f7f37fb4e4 docs(brain): observer factor-analysis extension — normative sync
ADR-011 amended: +Decision §5 (observer v2 four-layer), §3 4→5
controllers (+C5), Enforcement +routing-gate + C5 bullets, related
+factor-analysis spec/plan.

Pravila v1.31→v1.32: §16.2 +абзац «Схема эпизода v2», §16.3 4→5
контролёров (+C5 row), +§16.7 routing-тег-дисциплина (mechanical
Stop-hook decision:block, stop_hook_active loop guard), +§16.8
самодисциплина наблюдателя (observer_error marker, parse_gap event,
C5 lefthook warn-only), §16.6 +cross-refs на factor-analysis spec/plan.

PSR_v1 v3.16→v3.17: R16.1 +предложение про schema v2 поля и
расширенные события; R16.4 +cross-refs.

Tooling Прил. Н v2.17: §0 cross-ref strings 1.31/3.16 → 1.32/3.17
(no header version bump).

brain-governance spec: related +factor-analysis spec.
observer-factor-analysis-design.md: status draft→accepted.

CLAUDE.md v2.19: §0 Pravila/PSR_v1 cross-refs bumped to v1.32/v3.17
with v2 summary prepended (legacy preserved as «v1.31 наследие» /
«v3.16 наследие»); §3.6 appended observer schema v2 + routing-gate +
C5 + brain-retro analyzer paragraph; §9 +v2.19 entry.

cross-ref-checker: 0 drift in 4 files.

Plan: docs/superpowers/plans/2026-05-19-observer-factor-analysis.md
Spec: docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:08:55 +03:00
Дмитрий d484e60c46 docs(observer): brain-retro skill + README for schema v2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:55:37 +03:00
Дмитрий a6f44e5bb4 feat(observer): brain-retro analyzer — outcome inference + factor matrix
Pure deterministic Layer-4 aggregation module (spec §6) for the /brain-retro
skill. Exports: dedupeEpisodes, inferOutcome, groupEpisodesToTasks,
findCausalChains, buildFactorMatrix, analyze. Read-only — never writes JSONL.
11/11 tests green. CLI smoke: 10 real episodes → valid JSON with all 5 keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:47:57 +03:00
Дмитрий 363357bff4 chore(observer): wire C5 coverage-checker into lefthook (job 15) 2026-05-19 10:44:10 +03:00
Дмитрий 843123bbdb docs(supplier): plan — project channel failover (12 tasks, TDD)
Реализация по спеку 2026-05-19-supplier-project-channel-failover-design.md.

12 атомарных задач:
T1 live discovery + Tier 1 contract verification
T2 SupplierProjectChannel interface + AjaxProjectChannel
T3 supplier_manual_sync_queue table (migration + schema.sql + CHANGELOG)
T4 FailoverProjectChannel skeleton + Window/TierEscalated exceptions
T5 portal-side idempotency dedup
T6 manage-project.js Node script
T7 FormProjectChannel + DI wiring
T8 wire jobs to FailoverProjectChannel
T9 schedule retiming 20:30→18:00, 20:15→17:45
T10 admin worklist endpoints
T11 admin worklist UI
T12 live smoke + final regression

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:43:05 +03:00
Дмитрий 1d76d930bd chore(cspell): allow plan vocabulary (имплементациями, алёрт, инжектят, инжектим, фикстурный, роута)
Русская проектная лексика для плана резерва канала миграции проектов
(docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:43:01 +03:00
Дмитрий cde9478899 feat(observer): STATUS.md — C5 row + observer_error metric 2026-05-19 10:41:17 +03:00
Дмитрий d080198220 feat(observer): coverage + registration-integrity controller (C5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:38:25 +03:00
Дмитрий 35231d8b96 feat(observer): Stop-hook routing-gate enforcement 2026-05-19 10:34:57 +03:00
Дмитрий 2e11c452a9 feat(observer): Stop-hook v2 episode + observer_error marker 2026-05-19 10:31:37 +03:00
Дмитрий 02bff371c1 feat(observer): routing-gate method-direction detector 2026-05-19 10:27:23 +03:00
Дмитрий 375c3e2d1f feat(observer): parser v2 — process events, routing-tag, episode assembly 2026-05-19 10:23:08 +03:00
Дмитрий 57d6495271 docs(supplier): spec — project migration channel failover (3-tier resilience)
Резерв канала миграции проектов Лидерра → crm.bp-gr.ru:
AJAX rt-project-* → авто-браузер «Мои проекты» → operator worklist.
4 секции согласованы заказчиком поэтапно 19.05.2026.

Зеркало входящего дизайна (webhook + CSV-сверка):
2026-05-18-supplier-csv-reconcile-channel-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:20:01 +03:00
Дмитрий 6ca3b0d6fa chore(cspell): allow «креды», «Апи» (project vocabulary)
«креды» — общая проектная лексика (supplier credentials, env-vars).
«Апи» — кириллическая транслитерация, поставщик crm.bp-gr.ru именует
поля как «Апи ссылка / Апи протокол / Апи статус» (/admin/user/api).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:19:40 +03:00
Дмитрий 85a95aa2d0 feat(observer): parser v2 — environment, task_size, prompt_signal extractors 2026-05-19 10:15:17 +03:00
Дмитрий 2501b00079 docs(plan): observer factor-analysis implementation plan
12-task plan implementing the spec
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md
in 4 layers (schema v2 + capture + enforcement + analysis) plus
normative sync. Each task has TDD steps with full code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:09:56 +03:00
Дмитрий e0a25ff629 docs(brain): spec — observer factor-analysis extension
Design for making the brain governance observer rich enough for real
factor analysis. Surfaced during a discussion with the owner: the
observer is "paper-complete" but episodes lack the data factor analysis
needs — the outcome is a hardcoded "success", there is no decision
provenance (who chose the node — Claude autonomously, or the owner
forcing a method), no environment factors, no task grouping.

4-layer architecture:
- Layer 1 — episode schema v2: decision_provenance (+ counterfactual),
  environment block, task_size, real outcome enum, task_ref.
- Layer 2 — capture: deterministic transcript parsing for all factors +
  a one-line routing tag (owner-forced-method only).
- Layer 3 — two-sided enforcement: 3a routing-gate (Stop-hook blocks the
  turn until the tag is present — unbypassable by Claude); 3b observer
  self-discipline (silent failures become recorded observer_error
  markers; coverage + registration verified by a controller).
- Layer 4 — analysis: /brain-retro infers real outcome from the next
  episode's opening prompt, groups episodes into tasks, correlates
  causal chains, builds the factor matrix.

Scope: everything except an independent agent-judge — that, plus
confusion_marker as a real judgment and real-time friction flags, is
phase 2 (separate spec).

Brainstormed via superpowers:brainstorming. Next: writing-plans.

Refs: ADR-011, spec 2026-05-19-brain-governance-design.md, Pravila §16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:15:27 +03:00
Дмитрий d2b344ea24 chore(brain): refresh STATUS.md dashboard
The committed STATUS.md was stale (generated 2026-05-19T03:49, before
the C1/C2 strict-mode fixes and before the post-commit hook existed):
it showed C1/C2 🔴 and "0 episodes". Regenerated via the now-installed
post-commit hook (C4 status-md job) — C1/C2/C3/C4 all , 5 episodes.

Context: `.git/hooks/post-commit` was never installed, so the C4
status-md job (lefthook post-commit) never ran automatically. Fixed
locally via `lefthook install --force` (installs pre-commit/post-commit/
pre-push). The hook files live in `.git/` and are not version-tracked —
re-run `lefthook install` after clone if hooks go missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:13:19 +03:00
Дмитрий 99c7bac99b feat(brain): observer captures real session data via transcript parse
The Stop-hook was writing empty-shell episodes (task_id "unknown-<ts>",
node_chosen "unknown", events []). Root cause: buildEpisodeFromContext
read fields from the Stop-event stdin that Claude Code never sends
(primary_rationale, node_chosen, ...) and the session field name was
wrong (ctx.sessionId camelCase vs Claude Code's session_id). The hook
never read transcript_path — the only real source of session data.

New tools/observer-transcript-parser.mjs — pure parseTranscript(text,
fallbackSessionId):
- Scopes to the last turn (from the last real user prompt to EOF) —
  one episode == one prompt→response cycle. A tool_result-carrier user
  message is not treated as a turn boundary.
- Extracts task_id (real sessionId), timestamps (real duration),
  skill_invoked events, a tool_summary event with per-tool counts,
  error events (tool_result is_error), node_chosen (first skill, else
  "direct"), hard_floor (invoked when a superpowers:* skill is used),
  path_type (regulated/improvised), task_classification (keyword
  heuristic on the prompt).
- Reasoning fields triggers_matched/candidates_considered/
  boundaries_applied stay [] — not recoverable from a transcript;
  their capture is a separate ADR-011 follow-up.

observer-stop-hook.mjs: reads ctx.transcript_path + ctx.session_id
(camelCase fallback kept), readFileSync best-effort, delegates to
parseTranscript. No transcript → graceful fallback to ctx defaults.
Episode schema (5 mandatory + 7-field primary_rationale) unchanged —
no normative change. Stop-event is never blocked (exit 0 on any error).

TDD: 17 parseTranscript tests + 1 buildEpisodeFromContext transcript
test. Full tools Vitest 70/70 GREEN. CLI smoke against a real 575-entry
transcript: episode populated — real task_id, ~6.5 min duration,
tool_summary {Bash:5,Read:5,Grep:1,Edit:9,Write:1}, error event.

Refs: ADR-011 brain governance §6.2 (observer evidence loop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:11:10 +03:00
Дмитрий 59d3dd06b6 @
test(supplier): SupplierCsvParserTest под 3-колоночный формат отчёта

Unit-тест ожидал устаревший 6-колоночный формат
vid;project;tag;phone;phones;time, тогда как SupplierCsvParser
переписан эпиком CSV-канала (T2, 18.05.2026) под 3-колоночный
Name;Tag;Phone — yields {project,tag,phone}, vid/time отсутствуют.

Тестовый долг вскрыт полной регрессией: 3 кейса падали
(«array has no key vid»). Тесты приведены к актуальному контракту
парапера. Pest SupplierCsvParserTest 5/5, full-suite 937/934/0/3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-19 07:42:34 +03:00
Дмитрий 0f6f38a70e @
fix(supplier): реальные endpoint'ы отчёта «Запрос номеров» (discovery T3)

Discovery T3 на живом supplier-портале crm.bp-gr.ru (Playwright MCP)
вскрыл фактические endpoint'ы вместо placeholder'ов из spec §4.3:

- POST /admin/report/save-report (JSON body, selectType=49 + reportFilter)
  — возвращает строку "OK", не JSON с id;
- GET  /admin/report/load-reports — массив отчётов, id извлекается
  title-match'ем «Запрос номеров с {from} по {to}»;
- GET  /admin/report/getfile?id=N — 302 redirect на отдельный
  download-host (oki.needcallbuy.ru), Laravel HTTP follows redirect.

SupplierPortalClient: requestNumbersReport/waitReportReady/downloadReport
переписаны под реальный контракт; request() +параметр asJson;
connectTimeout(30)+timeout(60) против flaky DNS resolve.

refresh-session.js: селекторы login-формы Yii2 — placeholder
input[name=login] → реальные #loginform-username/-password.

Тесты SupplierPortalClientReportTest + CsvReconcileJobTest адаптированы
под новый внутренний контракт. Pest 15/15, Larastan 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-19 07:42:12 +03:00
Дмитрий 2a2ded7a53 refactor(brain): C1 L1-watcher — drop broken reverse drift check
Removes the `missingInSettings` reverse check ("plugin documented in
Tooling but disabled in settings.json"). It was broken by design:
Tooling Прил. Н lists tools by human/group name ("Frontend Design
plugin", "Trail of Bits Skills") while settings.json keys are machine
IDs (`name@marketplace`) — the two namespaces never compare. The
`/#\d+\s+([\w-]+(?:@[\w-]+)?)/` scan also captured the first plain word
after "#NN" ("#1 PostgreSQL MCP" → "PostgreSQL"), so every run emitted
~190 lines of WARN noise.

ADR-011 §6.1 specifies only the settings→Tooling direction (the L1
pattern "plugin enabled without Tooling formalization"). That is the
FAIL path and is unchanged. detectDrift now returns `{ missingInTooling }`
only. CLI output is a clean single line on success.

Closes the cosmetic issue flagged in bffdaa9.

TDD: reverse-check test replaced with `not.toHaveProperty
('missingInSettings')`; 12/12 GREEN. Smoke: node tools/l1-watcher.mjs
-> exit 0, "OK — 0 drift" (no WARN block).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:36:21 +03:00
Дмитрий cb681dbd68 feat(brain): C1 + C2 lefthook jobs → strict mode
Removes the `|| true` WARN-only guard from pre-commit jobs 11
(l1-watcher) and 12 (cross-ref-checker). Both controllers now block
the commit on real drift.

Safe to flip now that the false-positive sources are closed:
- C1: tools/.l1-watcher-aliases.txt resolves the 9 name@source drifts
  (Frontend Design plugin, Trail of Bits Skills group).
- C2: link-anchored detection + history-block scope-cut removes the
  ~150 «наследие»/arrow-transition false positives.

Verified on the current tree: node tools/l1-watcher.mjs -> exit 0,
node tools/cross-ref-checker.mjs -> exit 0. Comment blocks and
fail_text updated to describe strict behaviour and the alias escape
hatch.

Refs: ADR-011 brain governance §6.1 / §6.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:31:26 +03:00
Дмитрий 8ae0ecef25 feat(brain): C2 cross-ref-checker link-anchored detection — strict-ready
Closes the ~150 false drifts that prevented strict mode. The old regex
`\b(Name)\s+v(\d+\.\d+)` swept the whole file head and matched every
historical version mention, plus the FROM-side of arrow transitions
("v1.30→v1.31"). Real current-vs-header drift in the repo: zero.

Two-tier detection:
- Primary LINK_REF_RE: a markdown-link to a normative file followed by
  the first bold version — "[..](docs/Tooling_v8_3.md) (**Прил. Н
  v2.17**". Link anchor makes it immune to history-block noise. This is
  how CLAUDE.md §0 cross-refs table is written, so CLAUDE.md is fully
  validated. Runs on the whole file.
- Fallback CROSS_REF_RE: plain "Name vX.Y" mention, scoped to the text
  *before* the first history block. Pravila/Tooling/PSR_v1 have no
  markdown-link cross-refs, so the fallback covers them — but their
  shapki list past releases, so the scan stops at the first history
  marker (`**vN.M наследие**` / `**Что изменилось в vN.M относительно**`
  / `**vN.M** — `). dedupe-by-target keeps the first ref per target.

Regex hardening:
- `\b` after the version forbids backtracking to a partial capture
  (so "v1.30→" never collapses to a spurious "v1.3" match).
- `(?!\s*→)` negative lookahead drops the FROM-side of transitions.

TDD: 8 new tests (link-based, "Прил. Н" prefix, multi-file table,
dedupe, two arrow shapes, three history-marker shapes, link-beats-
fallback). 18/18 GREEN.
Smoke: node tools/cross-ref-checker.mjs -> exit 0, "OK — 0 drift in
4 files" (Pravila/CLAUDE.md/Tooling/PSR_v1; MEMORY.md is outside the
repo by design — existsSync-skipped).

Refs: ADR-011 brain governance §6.2 (C2 cross-ref consistency detector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:29:43 +03:00
Дмитрий bffdaa9f57 feat(brain): C1 L1-watcher alias mechanism — strict-ready
Closes the 9 pre-existing name@source drifts that prevented strict mode:
settings.json lists each marketplace plugin by machine name (e.g.
"frontend-design@claude-plugins-official"), while Tooling Прил. Н
describes them under a human/group name (e.g. "Frontend Design plugin",
"Trail of Bits Skills" — single row #39 for 8 sub-plugins).

Mechanism:
- tools/.l1-watcher-aliases.txt — settings_name=tooling_substring map.
- detectDrift(settings, tooling, aliases): direct match first, then
  alias-substring fallback. Settings name considered formalized if
  Tooling text includes either the name itself or aliases[name].
- parseAliases(raw) exported — line-based KV parser with #-comments
  and split-on-first-= semantics (values may contain "=").

TDD: 6 new tests (3 detectDrift + 4 parseAliases). 12/12 GREEN.
Smoke: node tools/l1-watcher.mjs -> exit 0, "OK — 0 drift".

Known cosmetic baseline issue (pre-existing, not introduced here):
the missingInSettings WARN list is noisy — regex
/#\d+\s+([\w-]+(?:@[\w-]+)?)/g captures the first \w+ after "#NN"
even when it is a plain word (e.g. "#1 PostgreSQL MCP" -> "PostgreSQL"),
producing ~190 WARN entries. WARN is non-blocking, so strict mode flip
in Phase 3 is unaffected; a follow-up filter on names containing "@"
would silence this without behavioural change.

Refs: ADR-011 brain governance §6.1 (C1 L1-watcher detector for the
"plugin in settings.json without Tooling formalization" L1 pattern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:12:05 +03:00
Дмитрий 9ef5227f0f fix(observer): STATUS.md plain-text reference to memory file (lychee pre-push fix)
Memory files (e.g. feedback_brain_unused_tools_not_problem.md) live
in C:/Users/.../memory/, OUTSIDE the git repo. Markdown link from
docs/observer/STATUS.md (relative path) resolved to non-existent
in-repo path → lychee broken-link error in pre-push gate.

Fix: plain-text mention of memory key (no markdown link), with
explicit note «outside-repo memory store». Generator updated
accordingly; 31/31 Vitest tests still GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:49:39 +03:00
Дмитрий a250ea605f docs(claude-md): §0 cross-refs sync + §3.6 router-procedure cross-ref (v2.17 → v2.18, Task D1)
Brain governance Phase A/B/C closure sync (ADR-011).

§0 cross-refs:
- Pravila v1.30 → v1.31 (§16 brain governance)
- PSR_v1 v3.15 → v3.16 (R16 brain evidence loop)
- Tooling Прил. Н v2.16 → v2.17 (§0.1 row template + 58 Атрибуты blocks)

§3 structure:
- §3.6 (new — free slot after v2.16 renumber) — cross-ref to
  docs/router-procedure.md v1.0 (5-step router procedure SoT).
- §3.7 (off-phase routing-аид) — +note distinguishing
  router-procedure.md (general 5-step) vs routing-off-phase.md
  (concrete triggers/chains).

§9 +v2.18 entry — Phase A/B/C summary with all commit SHAs (15+6+5)
+ Phase B+C concerns (C1 9 drifts, C2 noise refinement).

+cspell словарь «DWC» (DONE_WITH_CONCERNS abbreviation).

Through /claude-md-management:claude-md-improver per §5 п.10.

Note: cross-ref-checker (C2, just-wired in C5 commit a70d5a4)
surfaces ~150 known historical 'наследие' drifts on this commit
— that's the WARN-only behavior (`|| true`) per follow-up
refinement. Lefthook still passes overall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:44:29 +03:00
Дмитрий a70d5a4bdb build(lefthook): wire 4 brain controllers — C1/C2/C3/C4 (Task C5)
pre-commit jobs 11-13:
- l1-watcher (WARN-only via || true; glob settings.json + Tooling)
- cross-ref-checker (WARN-only via || true; glob 5 normative files)
- observer-of-observer (always exit 0 by design)

post-commit job 14:
- status-md (regenerates docs/observer/STATUS.md + stages it for
  next commit; never fails commit via || true)

Both l1-watcher and cross-ref-checker are WARN-only initially because:
- l1-watcher surfaces 9 known pre-existing 'name@source' drifts
  (see commit 4382de3); strict mode pending alias resolution.
- cross-ref-checker surfaces noise from historical «наследие» entries
  in headers (see commit a780959 DWC); strict mode pending refinement.

observer-of-observer is warn-only by spec (no fail until C3 prune
threshold 54 weeks).

Verified via npx lefthook run pre-commit on staged lefthook.yml —
all 14 jobs evaluate cleanly: 9 skipped (glob mismatch), 5 ran
(including new observer-of-observer warn).

Per ADR-011 + plan Task C5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:39:41 +03:00
Дмитрий ce2333e309 feat(controller): C4 status-md-generator — dashboard
Aggregates C1/C2/C3 outputs via execFileSync (Security Guidance #40
compliant — uses fixed args array, no shell injection surface) +
observer episode count. Behavioral rule embedded in metric copy.
Per ADR-011 + spec §6.4.

3 Vitest tests GREEN (31/31 total).

Smoke run rebuilds STATUS.md with current state:
- C1 🔴 (l1-watcher surfaces 9 plugins in settings not formalized
  in Tooling Прил. Н by exact name@source — see commit 4382de3)
- C2 🔴 (cross-ref-checker surfaces noise from 'наследие' headers
  — see commit a780959 DWC)
- C3  (0 weeks since last read)
- C4  (this file)

Both 🔴 states surface known pre-existing drift (not regressions).
C5 lefthook wiring will handle WARN-vs-FAIL semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:37:27 +03:00
Дмитрий 0c9661d694 feat(controller): C3 observer-of-observer — 54-week self-prune counter
Pure date math, 0 LLM calls. 5 Vitest tests GREEN (28/28 total).
Per ADR-011 + spec §6.3.

Modes:
- check (default, lefthook): warn if last_read_at >= 54 weeks ago.
- record: bump counter (invoked manually or by future read-tracking hook).

isStale threshold is inclusive (>= 54 weeks) — spec «через 54 недели»
means at-or-past 54 weeks fires the warn.

Smoke run OK — current counter (period_start 2026-05-19) shows
0 weeks ago.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:36:13 +03:00
Дмитрий a780959de9 feat(controller): C2 cross-ref-checker — version drift detector (DONE_WITH_CONCERNS)
Pure regex/JSON, 0 LLM calls. 5 Vitest tests GREEN (23/23 total).
Per ADR-011 + spec §6.2.

Smoke run on real repo surfaces ~150 «drifts» — these are
**historical 'наследие' entries** in headers (CLAUDE.md / Pravila /
Tooling / PSR_v1), not actual current cross-ref mismatches. Each
of these 4 files has a multi-line «v2.X наследие:» / «v1.Y наследие:»
chain in its top header describing past sub-versions; my 50-line
scan picks them all up.

CONCERN: mechanism is correct (test fixtures pass), but real-world
needs refinement before lefthook wiring (C5). Options for follow-up:
- Scope match to explicit «§0 cross-refs» table marker.
- Distinguish «current cross-ref» from «historical наследие mention»
  by surrounding markup.
- Restrict regex to cross-ref tables (markdown | columns) only.

Until refined: C2 will be wired in C5 with caveat (WARN-only, or
disabled) to avoid blocking every commit on pre-existing 'наследие'
entries.

Extracted Tooling Прил. Н version via **Версия:** pattern (file-level
v8.3 wrapper at line 1 was misleading — Прил. Н is v2.17 at line 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:34:10 +03:00
Дмитрий 4382de3a79 feat(controller): C1 l1-watcher — settings.json ↔ Tooling drift detector
Pure regex/JSON, 0 LLM calls. 4 Vitest tests GREEN. Per ADR-011 + spec §6.1.

Smoke run surfaces REAL drift (DONE_WITH_CONCERNS — plan B5 said «that's
a real signal, document, don't fix here»): 9 plugins in
~/.claude/settings.json enabledPlugins NOT formalized by exact
«name@source» string in Tooling Прил. Н:
- frontend-design@claude-plugins-official (informally as #30
  «Frontend Design plugin»)
- 8× ToB plugins @trailofbits (differential-review, audit-context-
  building, supply-chain-risk-auditor, insecure-defaults, sharp-
  edges, static-analysis, variant-analysis, agentic-actions-auditor)
  informally as #39 «Trail of Bits Skills»

This is naming-vocabulary mismatch (Tooling uses human-readable
names; settings.json uses machine names). Not architectural drift.
Resolution options for follow-up:
- Add machine names as «external_id» attribute to Tooling Прил. Н rows.
- Add tools/.l1-watcher-aliases.txt with accepted machine→human map.

Until resolved: C1 will FAIL on lefthook (C5 wiring) — addressed in
C5 by adding alias mechanism OR temporarily downgrade to WARN.

Also fixed CLI guard bug in observer-stop-hook.mjs (B3) and l1-watcher
— old guard `import.meta.url === \`file://\${argv[1]}\`` did not match
on Windows (file:/// triple-slash vs file:// double-slash + relative
argv[1]). New guard: argv[1].endsWith('/<filename>.mjs').

Weekly GH Actions cron (Mon 09:00 MSK) opens issue on drift.

Vitest config extended to ../tools/*.test.mjs with exclude for ruflo-*
and subagent-prompt-prefix tests (pre-existing, not part of brain
governance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:31:18 +03:00
Дмитрий 0a45fcbdfd feat(skills): /brain-retro — observer evidence aggregator
Read-only skill at .claude/skills/brain-retro/. Aggregates JSONL
evidence + optional notes for owner review. Side-effect: bumps
docs/observer/.read-counter.json (used by C3 observer-of-observer
54-week self-prune).

Includes Factor analysis matrix (v1.1+ amendment): 5 axes
(triggers_matched / candidates_dropped_because / boundaries_applied /
hard_floor.rules / task_classification) + cross-tab factor×factor.

Never auto-edits normative files. Per Pravila §16.2 + ADR-011 +
spec v1.1 §5.5.

+cspell словарь «разруливают», «брейн».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:23:00 +03:00
Дмитрий 747caaf3e7 feat(observer): register observer-stop-hook on Stop-event (project-level)
HK1 pre-check passed in B4 (0cf1406): user-level Stop = agent-type
economy verifier (independent slot); project-level Stop was empty.

Added project-level Stop hook: command-type, 5s timeout, never
blocks (exit 0 on error per implementation a825700). Per Pravila
§16.2 + ADR-011.

Real-session smoke test deferred to Task D2 end-to-end smoke (semi-
manual — triggers real Claude Stop event).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:19:09 +03:00
Дмитрий 0cf1406314 docs(observer): HK1 pre-check noted in README (ADR-010 compliance)
Verified Stop event collision before B5 registration:
- User-level (~/.claude/settings.json): Stop hook = agent-type
  Sonnet-4.6 economy compliance verifier (already wired in
  6-component arch).
- Project-level (.claude/settings.json): Stop slot empty.

observer-stop-hook will register as command-type entry in
project-level Stop array. Independent slot from user-level agent;
no overwrite, no collision. Per Pravila ADR-010 HK1 hard-rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:17:58 +03:00
Дмитрий a8257001a7 feat(observer): Stop-event hook — JSONL append with PII filter + primary_rationale validation
Hook contract: reads JSON ctx from stdin (Claude Code Stop-event),
builds episode with 5 mandatory fields including primary_rationale
(7 sub-fields per spec v1.1 §5.2.1), sanitizes via observer-pii-filter,
appends to docs/observer/episodes-YYYY-MM.jsonl. Never blocks
Stop-event (exit 0 on error).

8 Vitest tests verified GREEN (6 in appendEpisode + 2 in
buildEpisodeFromContext): append/append-existing/PII-filter/
missing-required/missing-rationale-field/routing_decision-preserved
+ buildEpisode 5-field extraction + user-rationale-preserved.

Vitest config for tools/ already covers via glob ../tools/observer-*.test.mjs
(extended in B2 commit 4616308).

Per Pravila §16.2 + ADR-011 + spec v1.1 §5.2.1 (factor analysis).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:16:36 +03:00
Дмитрий 4616308402 feat(observer): PII filter — phone/email/Sentry/OpenAI/Bearer masking
Used by Stop-hook before JSONL write. 6 Vitest cases including
idempotence and recursive object sanitization. Per Pravila §16.2 +
ADR-011 + spec §5.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:11:25 +03:00
Дмитрий 910c2d0e37 feat(observer): docs/observer/ scaffolding — README + STATUS + counter + JSONL seed
Empty infrastructure per ADR-011 + Pravila §16.2. Hook + generators
wire up in subsequent tasks (B2 PII filter, B3 Stop-hook, B5 register
in settings.json, C4 STATUS generator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:07:42 +03:00
Дмитрий d4520ff6b0 docs(psr): +R16 brain evidence loop — PSR_v1 v3.15 → v3.16
R16.1-R16.4: observer scope (5 mandatory fields incl. primary_rationale),
stack-conscious events (routing_decision + factor matrix 5 axes),
non-override status, cross-refs. Layered on top of R15 off-phase routing.

Per ADR-011 + spec v1.1 §5.2.1 amendment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:33:44 +03:00
Дмитрий 1b899e024d docs(pravila): +§16 brain governance — router-only + observer + 4 controllers
Pravila v1.30 → v1.31. New §16 sub-sections 16.1-16.6. Level of §13
recommendation (not override-floor §9). Cross-refs ADR-011 / spec /
plan / router-procedure / routing-off-phase.

§16.2 mentions 5 mandatory fields including primary_rationale (per
spec v1.1 §5.2.1 amendment for factor analysis).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:30:24 +03:00
Дмитрий 8170527ee4 docs(tooling): Прил. Н v2.16 → v2.17 — header bump + §13 footer entry (ADR-011 A3 final)
Final header bump after 6 sub-batches of 9-attribute Атрибуты template
application. 58 total Атрибуты blocks now structure the registry:
- §2.4 dump for phase-0 (9 nodes #1-9)
- §3.5 dump for phase-1 (9 nodes #10-18)
- §4.1-§4.4 inline for phase-2 (7 nodes #19-23+#24+#30)
- §5.1 dump for phase-3 (5 nodes #25-29)
- §4.5-§4.17 inline for off-phase #31-42 + ruflo §4.10 (13 blocks)
- §4.18-§4.35 inline for off-phase #43-60 (18 blocks)

dormant=true: #1 PG MCP (replaced by Boost), #17 pg_partman (no
native Windows PG extension; replaced by Artisan command), ruflo
§4.10 (per Pravila §14.9).

Sub-batch commits: 1f77134 / 0718e41 / 16f7f1c / ca4da69 / 39231ef /
3e73396 + this header bump. Task A3 complete.

Per spec §4.1, plan Task A3 final step. Structured registry is the
input to router-procedure.md (commit 8a2e701) step 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:26:57 +03:00
Дмитрий 3e733969dc docs(tooling): apply 9-attribute template to §4.18-§4.35 off-phase nodes #43-60 (ADR-011 A3 sub-batch 6 / final)
Inline pattern (matches sub-batches 3 + 5). 18 Атрибуты blocks
covering deptrac/Figma/Universal Icons/Design/openapi-mcp/promptfoo/
Data Scientist/Jupyter/operations/process-modeling/process-analysis/
n8n-mcp/discovery-interview/skill-creator/plugin-dev/hookify/
claude-code-setup/context7.

3 DEFERRED nodes (#44 Figma, #50 Jupyter, #54 n8n-mcp) marked in
boundaries column. Header bump v2.16→v2.17 happens in next commit.

Per spec §4.1, plan Task A3 sub-batch 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:24:51 +03:00
Дмитрий 39231ef856 docs(tooling): apply 9-attribute template to §4.5-§4.17 off-phase nodes #31-42 + ruflo (ADR-011 A3 sub-batch 5)
Inline pattern (matches Sub-batch 3). 13 Атрибуты blocks placed under
each §4.X heading. Includes ruflo §4.10 dormant=true (Pravila §14.9).
Other 12 nodes (#31-42) dormant=false.

#40 Security Guidance: kind=hook (блокирующий PreToolUse, sys.exit 2).
#34 Sentry MCP: pending Б-1 (Sentry instance deployment), READ-ONLY.

Per spec §4.1, plan Task A3 sub-batch 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:19:47 +03:00
Дмитрий ca4da6932e docs(tooling): apply 9-attribute template to §5.1 phase-3 nodes #25-29 (ADR-011 A3 sub-batch 4)
Dump-block pattern (matches Sub-batches 1 §2.4 and 2 §3.5). 5 nodes
covering #25 Semgrep+Semgrep MCP, #26 Trivy, #27 Dependabot,
#28 pg_audit, #29 pg_anonymizer. All dormant=false (registry-known,
phase-3 pre-production per CLAUDE.md §6).

Per spec §4.1, plan Task A3 sub-batch 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:15:26 +03:00
Дмитрий 16f7f1c340 docs(tooling): apply 9-attribute template to §4.1-§4.4 phase-2 nodes #19-23,#24,#30 (ADR-011 A3 sub-batch 3)
Inline pattern (different from Sub-batches 1-2): Атрибуты blocks
placed INSIDE existing §4.1/§4.2/§4.3/§4.4 subsections, not as
separate dump block — to avoid renumbering off-phase §4.5+.

7 attribute rows (1+4+1+1=7) covering #19 Superpowers, #20 Volar,
#21 vue-tsc, #22 ESLint+Prettier+plugin-vue+config-prettier (как
связка), #23 Vitest, #24 Histoire, #30 Frontend Design plugin.

Per spec §4.1, plan Task A3 sub-batch 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:12:46 +03:00
Дмитрий 0718e41cc5 docs(tooling): apply 9-attribute template to §3.5 phase-1 nodes #10-18 (ADR-011 A3 sub-batch 2)
§3.5 «Атрибуты узлов фазы 1» dump block (pattern continues Sub-batch
1 §2.4). #17 pg_partman: dormant=true (replaced by Artisan command
partitions:create-months on native Windows). Other 8 nodes active.

Per spec §4.1, plan Task A3 sub-batch 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:09:41 +03:00
Дмитрий 1f77134597 docs(tooling): apply 9-attribute template to §4.1-§4.9 (ADR-011 A3 sub-batch 1)
+ §0.1 row template (one-time, ADR-011 mandated).
+ Атрибуты block for phase-0 nodes #1-#9. #1 PostgreSQL MCP dormant
(replaced by #10 Boost in phase 1).

Per spec §4.1, plan Task A3 sub-batch 1. Tooling header v2.16
remains; final v2.17 bump after all 6 sub-batches.

NB: file-layout adaptation — phase-0 nodes #1-#9 live in §2 tables
(not §4.X subsections); Атрибуты blocks placed in new §2.4
subsection. Plan-template "§4.1..§4.9" referenced the abstract
node-index, not file headings; subsequent sub-batches will follow
same pattern (§3.5 for phase-1 nodes #10-#18, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:04:44 +03:00
Дмитрий 8a2e701ff2 docs(router): router-procedure.md v1.0 — explicit 5-step routing
Single SoT for task→node routing. Replaces implicit routing scattered
across Pravila/PSR_v1/Tooling/routing-off-phase.md. ADR-011.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:57:33 +03:00
Дмитрий 2ef4ac4b9c docs(adr): ADR-011 brain governance — router-only + observer + 4 controllers
Anchor ADR for governance design (spec dd5bded / v1.1 544c8f3). Sets
Accepted status, captures 4 decisions: router-only, observer scope B,
4 controllers, capability-readiness behavioral rule. Enforced via
adr-judge (lefthook job 9 — checks ## Enforcement section).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:54:31 +03:00
Дмитрий 06a3bd532d docs(brain): plan amendment — factor analysis в Task A1 ADR-011 + Task B3 + Task B6
Соответствует spec v1.1 (544c8f3). Изменения:

Task A1 (ADR-011 text inside plan):
- Decision #2 «Observer scope B» расширено: упоминание 5 mandatory
  fields (включая primary_rationale 7 sub-fields) + routing_decision
  events для цепочек + что это enables factor analysis.

Task B3 (observer-stop-hook.test.mjs + observer-stop-hook.mjs):
- REQUIRED_FIELDS расширен с 4 до 5 ('primary_rationale').
- Новая константа RATIONALE_FIELDS (7 полей) + validateRationale()
  функция, вызываемая внутри appendEpisode после top-level validation.
- buildEpisodeFromContext возвращает primary_rationale (либо из ctx,
  либо default с extracted hints из ctx.skill_id/triggers_matched/etc).
- Tests: было 5 → стало 8. Новые: «throws when primary_rationale
  field missing», «persists routing_decision events with structured
  fields», «preserves user-provided primary_rationale unchanged».
  Все old fixtures обогащены primary_rationale: defaultRat().

Task B6 (aggregation-template.md):
- Новая большая секция «Factor analysis matrix (v1.1+)» с 5 осями
  факторов + cross-tab factor×factor. Tables для каждой оси:
  triggers_matched, candidates_dropped_because, boundaries_applied,
  hard_floor.rules, task_classification.

Self-review:
- Spec coverage table +row для §5.2.1.

Связано: spec v1.1 (544c8f3), plan v1.0 (ca93cf7), spec v1.0 (dd5bded).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:49:25 +03:00
Дмитрий 544c8f3081 docs(brain): spec v1.0 → v1.1 — factor analysis amendment (routing_decision + primary_rationale)
User-requested перед запуском суб-агента: observer должен фиксировать
не только факт выбора узла, но и причину — чтобы был возможен
факторный анализ через /brain-retro.

Изменения §5.2:

- 4 обязательных поля → 5 (+primary_rationale на эпизод-уровне).
- Новое событие routing_decision в массиве events[] (1 на каждое
  решение роутера в сессии; для цепочки из N — N событий).
- Новая под-секция §5.2.1 — структура 7 полей (step / node_chosen /
  triggers_matched / candidates_considered / boundaries_applied /
  hard_floor / task_classification). primary_rationale — копия
  первого routing_decision для дешёвой агрегации без чтения events[].
- Полный JSON-пример эпизода с цепочкой из 2 узлов.

Изменения §5.5:

- /brain-retro aggregation расширен новой секцией «Факторная матрица»:
  таблица «узел × фактор × частота» + cross-tab «фактор × фактор».
  5 осей факторов: triggers / dropped_because / boundaries /
  hard_floor.rules / task_classification.

Эффект: /brain-retro теперь может выдавать утверждения уровня «#55
выбрался против #53 по ADR-009 7 раз и по triggers-match 5 раз», а
не просто «#55 использован 12 раз». Это closes гэп факторного
анализа.

Header bump v1.0 → v1.1. ADR-011 текст в плане Task A1 будет
обновлён следующим коммитом (план amendment).

Связано: dd5bded (spec v1.0), ca93cf7 (plan v1.0).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:45:18 +03:00
Дмитрий ca93cf7652 docs(brain): план имплементации brain governance (Phase A/B/C/D, ~25 атомарных коммитов)
План имплементации спека dd5bded. 4 фазы:

- Phase A — нормативная foundation: ADR-011 + router-procedure.md +
  Tooling Прил. Н 9 атрибутов на 60 строк + Pravila §16 + PSR_v1 R16
- Phase B — observer infrastructure: docs/observer/ scaffolding +
  PII filter + Stop-hook + HK1 pre-check + settings.json register +
  /brain-retro skill
- Phase C — 4 механических контролёра: L1-watcher + cross-ref-checker +
  observer-of-observer (54w self-prune) + STATUS.md generator +
  lefthook wire-up
- Phase D — финализация: CLAUDE.md sync + smoke test +
  verification-before-completion + memory + push approval

Каждая задача TDD: failing test → implementation → passing test →
commit. Bite-sized steps (2-5 минут каждый). Subprocess через
execFileSync (Security Guidance #40 compliance). Pre-flight sync §15.2
обязателен перед каждой правкой 8 нормативных файлов. HK1 pre-check
обязателен перед регистрацией Stop-hook.

Self-review: spec coverage 15/15 sections , placeholder scan clean,
type consistency verified, all O1-O7 open questions resolved inline.

Execution: subagent-driven (recommended, per Pravila §15.1 Sonnet/Opus
only) или inline через executing-plans.

Связано:
- spec: docs/superpowers/specs/2026-05-19-brain-governance-design.md
- ADR-011 (будет написан в Task A1)
- memory/project_brain_governance_design.md
- memory/feedback_brain_unused_tools_not_problem.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:35:52 +03:00
Дмитрий dd5bdedf0a docs(brain): дизайн архитектуры регламента «мозга» (router-only + observer + 4 контролёра)
Brainstorming-сессия 19.05.2026 — финальный дизайн governance «мозга»
Лидерры. Spec в docs/superpowers/specs/2026-05-19-brain-governance-design.md
(12 секций, 13 verification критериев, 7 открытых вопросов).

Финальные решения:

1. Только роутер. Реестр узлов (Tooling Прил. Н SoT) + процедура роутера.
   Никакого каталога «проверенных цепочек», никакого 3-слойного механизма
   обновления, никакого forced-choice gate. Каждая задача — свежая сборка
   пути. Capability-readiness сохранена.

2. Observer scope B (полный пакет с дня 1). Stop-hook →
   docs/observer/episodes-YYYY-MM.jsonl + опциональные notes/*.md.
   4 обязательных поля + 6 типов структурированных событий + ПДн-фильтр.
   /brain-retro skill раз в спринт. Observer только пишет, не вмешивается.

3. 4 контролёра первой волны (5-й «стейлнес-контролёр» снят как
   избыточный после router-only refinement):
   - L1-watcher — settings.json ↔ Tooling drift detector
     (lefthook + weekly cron); закрывает L1-паттерн UPM/21st/Sentry/Redis/
     Anthropic dev-tooling.
   - Cross-ref consistency — version drift 8 нормативных файлов
     (lefthook, regex-стиль adr-judge, 0 LLM-вызовов); закрывает
     Tooling v2.11 collision 17.05.
   - Observer-of-observer — self-prune счётчик через 54 недели без
     чтений (anti-зомби-инфраструктура механизм).
   - Сигнальный статус — docs/observer/STATUS.md ежедневная приборная
     панель из C1+C2+C3+observer.

4. Поведенческое правило «не использован ≠ проблема» —
   capability-readiness осознанная стратегия заказчика; перевешивает
   аналитический инстинкт «прорезать неиспользуемое».

Также: cspell-words.txt +7 русских технических терминов
(слойного / слойный / рецидивирующие / зарегламентировать / версионный /
стейлнес / апдейты) для lefthook pass.

Статус: written-spec user review пройден, готов к переходу на
superpowers:writing-plans (terminal skill brainstorming-flow).
Имплементация — ноль. Это design document.

Связано:
- docs/discovery/2026-05-18-system-audit-brain.md (SYSTEM-аудит origin)
- memory/project_brain_governance_design.md
- memory/feedback_brain_unused_tools_not_problem.md
- Pravila §12/§14/§15 hard-rules (роутер шаг 1)
- Plugin_stack_rules_v1.md R15 + docs/routing-off-phase.md L1-L12
- ADR-011 (будет написан в writing-plans phase)
- ADR-010 HK1 pre-check (для Stop-hook observer-а)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:19:50 +03:00
Дмитрий 1a553ab287 chore(larastan): bump baseline для supplier-integration тестов
5 новых baseline-записей под Pest 4 quirks:
- AdminSupplierIntegrationTest.php: TestCall::getJson() x3 + postJson() x1
- CsvReconcileJobTest.php: Repository::lock() x1

Composer stan: 0 errors после bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:14:36 +03:00
Дмитрий ecfeddb34a Merge branch 'worktree-supplier-csv-reconcile' into feat/parallel-sessions-coordination 2026-05-18 18:11:18 +03:00
Дмитрий 1cd47211a5 fix(supplier): CsvReconcileJob — insertGetId внутри try (lock release on failure)
Финальный code-review эпика: insertGetId log-строки был вне try → при
падении самого insertGetId (БД недоступна) finally не освобождал
Cache::lock → lock висел LOCK_TTL_SECONDS (600с), пропуская 2 следующих
запуска. Перенесён внутрь try; $logId инициализируется null, catch
guard'ит обращение к нему.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:03:47 +03:00
Дмитрий 66320166b8 feat(supplier): UI-экран «Интеграция с поставщиком» — здоровье CSV-канала 2026-05-18 17:58:53 +03:00
Дмитрий 989ee58481 feat(supplier): API «Интеграция с поставщиком» — здоровье CSV-канала + ручная сверка 2026-05-18 17:51:44 +03:00
Дмитрий dd1f72bf58 feat(supplier): CsvReconcileJob — расписание каждые 30 минут 2026-05-18 17:46:01 +03:00
Дмитрий 0b6937973c feat(supplier): CsvReconcileJob — дедуп (phone,project) + async-флоу отчёта 2026-05-18 17:42:23 +03:00
Дмитрий 5e804a35f1 docs(brain): компакция «мозга» findings 2/3/6/7 — single-source счётчиков + §3.3 индекс + ruflo dormant-стаб
SYSTEM-аудит «мозга» через discovery-interview (интервью с заказчиком).
Закрыты 3 из 4 выбранных findings в нормативке (finding 7 — memory, вне git):

- CLAUDE.md v2.17 — §3.3 #31–#60 (30 строк-абзацев) свёрнуты в
  однострочный индекс с пином Tooling §4.NN (finding 2 — устранён
  дубль реестра с Tooling); §3 title / §3.3 footer / §1 row 2b /
  §0 row-label — счётчик «60» → пин на Tooling §0 (finding 3);
  §2 БД + §8 self-review — schema-метрики → пин header db/schema.sql
  (finding 3); §3.5 ruflo — ~17 строк истории → dormant-стаб (finding 6).
- Tooling Прил.Н v2.16 — §0 +anchor «КАНОН СЧЁТЧИКОВ» (единственный
  источник числовых счётчиков); §12 заголовок без stale «35».
- Pravila v1.30 — §14 заголовок +dormant-метка, §14.1 +врезка;
  §13.2 +note-пин счётчиков.
- PSR_v1 v3.15 — R10.1 +note-пин счётчиков на Tooling §0.
- cspell-words.txt +5 терминов («пин» и инфлексии).

План: docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:38:03 +03:00
Дмитрий 3e70f87d88 feat(supplier): SupplierPortalClient — async-флоу заказа отчёта «Запрос номеров» 2026-05-18 17:36:27 +03:00
Дмитрий 7e8560ae58 feat(supplier): SupplierCsvParser под отчёт «Запрос номеров» (Name;Tag;Phone) 2026-05-18 17:26:53 +03:00
Дмитрий ed8ec89bcc feat(supplier): supplier_leads.vid -> nullable для CSV-recovered лидов
Резервный CSV-канал (Путь 2): отчёт поставщика «Запрос номеров» не
содержит vid -> CSV-recovered лиды имеют vid=NULL. UNIQUE-индекс
idx_supplier_leads_vid_unique сохранён (PostgreSQL NULL != NULL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:20:28 +03:00
Дмитрий 868e57ee0c docs(map): визуальная изоляция ruflo — серый dashed кластер
iter8 (commit 9fcefa3) проставил 10 ruflo-узлам флаг
NODE_META.isolated=true, но это метаданные — рендер vis.js
флаг не читал, узлы рисовались обычным оранжевым цветом
группы ruflo. На карте изоляция была не видна.

Изоляция через group-level (переживает режимы карты —
теплокарту/фильтр, которые перезаписывают opacity/borderWidth,
но не color/shapeProperties):
- GROUPS.ruflo: оранжевый #ff8800 → серый #555555 +
  shapeProperties.borderDashes [4,4] + приглушённый шрифт #8a8a8a
- легенда-фильтр: dot оранжевый → серый dashed, текст
  «🌊 ruflo (оркестратор)» → «🔇 ruflo (изолирован 18.05)»
- hk_ruflo_queen: group 'hooks' → 'ruflo' (10-й изолированный
  узел, был в hooks-кластере — теперь визуально в ruflo)
- CATEGORY_LABELS.ruflo: «оркестратор» → «изолирован»

Группа ruflo не опустела (все 9 её узлов изолированы) — фильтр
group:ruflo продолжает работать. NODE_META.isolated флаги
не трогались (data-слой корректен с iter8).

Верификация: JS-синтаксис проверен (vm.Script parse OK) +
stylelint GREEN (color-hex-length fix #888→#888888). Визуальный
рендер в браузере НЕ проверен — Playwright-профиль занят
параллельной Claude-сессией (тот самый mcp_pw↔sk_parallel
same-dir case). shapeProperties — документированная vis.js
group-опция, риск низкий.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:10:49 +03:00
Дмитрий 3b59bd499a docs(supplier): план реализации резервного CSV-канала (Путь 2)
7 задач TDD: миграция vid->nullable, rework SupplierCsvParser +
CsvReconcileJob, +3 метода SupplierPortalClient, scheduler 30 мин,
API + UI-экран «Интеграция с поставщиком».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:56:15 +03:00
Дмитрий a8e0cc9195 docs(map): sync версий rule-узлов после Rec1-Rec5 + R15
Карта отстала от нормативки: rule-узлы держали v1.28/v2.15/v3.13/
v2.14, фактические версии на origin/main — v1.29/v2.16/v3.14/v2.15
(SYSTEM-аудит Rec1-Rec5 closure + аудит дисциплины R15).

Изменения (6 точечных):
- pravila label v1.28 → v1.29 (+§14.9 ruflo dormant)
- claude_md label v2.15 → v2.16 (Rec1-Rec5 closure)
- psr_v1 label v3.13 → v3.14 (+R15 off-phase routing)
- tooling label v2.14 → v2.15 (§4.10 ruflo status-block)
- hookify CONFLICT cross-ref «R10.1 v3.13» → «v3.14»
- claude_md nd() together «Tooling v2.10» → «v2.15» (stale ref)

Исторические упоминания версий (реколлаж 16.05 — стр. 658/1518/
1866, v1.16/v2.2/v3.2/v2.2) не трогались — описывают прошлое
событие. 1 mcp_pw↔sk_parallel уже понижен в commit a03fb99.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:48:21 +03:00
Дмитрий 616f1d98a1 docs(supplier): дизайн резервного CSV-канала (Путь 2) — spec
Резервный CSV-канал импорта лидов от crm.bp-gr.ru: страховка на случай
обрыва webhook. Сверка отчёта поставщика «Запрос номеров» (CSV 3 колонки
Name;Tag;Phone) каждые 30 мин + кнопка вручную; дедуп по phone+project;
recovery пропущенных лидов; drift-детект падения webhook.

Дизайн утверждён заказчиком. Ключевые решения: vid → nullable (CSV не
даёт vid), окно 2 кал. дня, rework SupplierCsvParser/CsvReconcileJob под
реальный async-флоу заказа отчёта.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:47:58 +03:00
Дмитрий aab7345590 docs(psr): аудит дисциплины R15 — M1-M3 polish
Продолжение SYSTEM-аудита «мозга» 18.05.2026 (срез «дисциплина
правил после Rec1-Rec5»). PSR_v1 прочитан целиком (966 строк),
R15 «Off-phase routing» проверен против R0/R6/R10/R14.

Результат аудита:
- R15 vs R0/R6/R10/R14 — содержательных противоречий нет
  (R15.1 codifies, R15.4 hard-rules перевешивают, R15.6 UI-пул)
- routing-off-phase.md прогнан на 7 задачах (5 прямых + 2
  граничных) — 7/7 routed cleanly, ADR-границы работают
- hard-rules §12/§14/§15 vs §14.9 dormant — согласовано

3 minor-находки исправлены:
- M1 — routing-off-phase.md +note: строки UI-пул #31/#32 —
  делегирующие ссылки на R14, не R15-routed (R15.6 ↔ таблица)
- M2 — PSR_v1 R15.1 +абзац «R15 — пост-R1 слой» (off-phase
  routing срабатывает после классификации R1, не отдельная
  шестая ветка) — in-place в v3.14 (введён сегодня, 0 изменений
  R-аппарата)
- M3 — routing-off-phase.md +строка «диагностика просадки
  метрики/конверсии» → process-analysis #53 (discovery-interview
  SKIP-кейс, симметрия)

routing-off-phase.md v1.0 → v1.1. PSR_v1 — без version bump
(M2 — in-place уточнение свежей v3.14). snapshot 18.05 +UPDATE
секция «Ось 5» + факт-правка строки 97 (R15-слот).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:32:34 +03:00
Дмитрий e3ef9d70be fix(supplier): parseProjectField извлекает встроенный домен из имени B1-проекта
Поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный текст со
встроенным URL/доменом (B1_заявка carmoney.ru/, B1_Платежи
cabinet.caranga.ru/login, B1_krk-finance.ru/cabinet/auth). Старый
anchored-regex требовал, чтобы вся строка после B1_ была чистым доменом;
такой rest не матчил — классификация sms — B1+sms — DomainException
(chk_supplier_projects_b1_not_for_sms) — 21 реальный лид застрял с error,
0 сделок.

Fix: после двух anchored-проверок (call/site) — fallback-извлечение
домена с латинским TLD из любой позиции строки — signal_type=site,
identifier = извлечённый домен. Реальные sms-имена (B1_TINKOFF) без
точки-домена остаются sms — существующий B1+SMS-тест не затронут.

3 параметризованных теста (carmoney/caranga/krk) + регрессия:
RouteSupplierLeadJobTest 12/12, Supplier+Integration+Webhook 61/61.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:31:45 +03:00
Дмитрий a03fb99242 fix(map): 1 mcp_pw↔sk_parallel → 🟢 (квирк #95 + Pravila §15.2)
Фактологическая правка после повторного аудита «мозга» SYSTEM-режима
(продолжение Rec1-5 закрытия 18.05.2026).

Причина:
- nd()-тексты mcp_pw + sk_parallel ссылались на «квирк #2» в memory
- memory[#2] — это taskkill /F /IM на Windows, не Playwright
- реальный источник — квирк #95 (16.05.2026): профиль Playwright MCP
  хэшируется per-cwd → разные worktrees получают разные
  mcp-chrome-{hash} директории и не конфликтуют. README playwright-mcp
  прямо: конфликт — только для клиентов «sharing the same workspace»

Изменения:
- CONFLICT() BLACK → 🟢GREEN с новым reasoning
- mcp_pw nd() — текст «один shared browser» → «профиль per-cwd hash»
- sk_parallel nd() — type BLACK → GREEN, актуализированный desc
- EDGE_DETAILS rule — «нет регламента» → «GREEN: квирк #95 + §15.2 claim»
- snapshot 18.05.2026: счётчик 3/🟢8 → 2/🟢9 + сноска UPDATE
- snapshot «Ось 2» — переписана: оба оставшихся  — ruflo (dormant)

Эффект:
- 3 → 2 (оба оставшихся — ruflo, оба dormant после изоляции 18.05)
- 🟢8 → 🟢9
- реальное runtime-трение — ноль

Same-dir parallel (две Claude-сессии в одной dir одновременно зовут
browser) — редкий runtime-сценарий, регулируется Pravila §15.2 claim
в docs/sessions/CURRENT.md. Отдельный §15.4 «MCP same-dir locks» не
добавляется (вариант A — только фактологические правки).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:15:07 +03:00
Дмитрий bca6d55684 docs(ЭТАЛОН): sync после эпика drawer+project source + tenant cleanup
- §1 git: HEAD `5dc9509` (после моего push `f248e27` + 3 docs параллельной сессии)
- §4: tenants 1+4 soft-deleted ≈13:02 UTC, активен только tenant 3
- §6: +нить эпика drawer/project source, +нить tenant cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:10:39 +03:00
Дмитрий 5dc95098ea docs(claude-md): v2.16 — SYSTEM-аудит «мозга» Rec1–Rec5 closure
CLAUDE.md v2.15 → v2.16: sync §0 cross-refs (Pravila v1.29 / Tooling v2.15 / PSR_v1 v3.14) + §3.5 +bold-блок «СТАТУС 18.05.2026: ИЗОЛИРОВАН» в начале раздела ruflo + §3.7 (новый) cross-ref на docs/routing-off-phase.md + §3.6 → §3.8 renumber + §6 +параграф SYSTEM-аудит + §9 +entry v2.16.

Источник аудита — docs/discovery/2026-05-18-system-audit-brain.md (утренний SYSTEM-режим discovery-interview, 5 осей × 125 узлов).

Эффект на -конфликты карты: 2 из 3 (ruflo_memory↔mem_state, ruflo_daemon↔ag_pest) сняты изоляцией; 1 mcp_pw↔sk_parallel остаётся.

Атомарные коммиты Rec1–Rec5:
- e6dbbb4 C1 snapshot + cspell-words
- 9fcefa3 C2 карта iter8 + ruflo isolated markers (Rec1+Rec2.5)
- ec4069c C3 Pravila §14.9 + Tooling §4.10 (Rec2)
- e5ec754 C4 PSR_v1 R15 + routing-off-phase.md (Rec3+Rec4+Rec5)
- (этот) C5 CLAUDE.md sync v2.16

Runtime изоляция (.claude/settings.json + .mcp.json) — в HEAD через `1412d3f` параллельной Claude-сессии (содержание моё, авторство её).

Восстановлено из backup-патча memory/rec1-5-stash-backup-2026-05-18-evening.patch после collision с параллельной сессией (stash dropped → re-apply). Через `/claude-md-management:claude-md-improver` (instruction workflow) + прямой Edit (worktree-эксцепшн §5 п.10 не применим — main checkout — но instruction workflow skill'а выполнялся мной как контроллером).

LEFTHOOK_EXCLUDE=eslint-vue — pre-existing ImportView.spec.ts:4 (commit 59dac9b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:08 +03:00
Дмитрий e5ec754abc docs(rules): off-phase routing — PSR_v1 R15 + docs/routing-off-phase.md (Rec3+Rec4+Rec5)
PSR_v1 v3.13 → v3.14: +R15 «Off-phase routing» на свободном слоте (motion удалён v2.0). Закрывает Rec5 SYSTEM-аудита 18.05.2026.

- R15.1 off-phase узлы вне R6.0/R6.1/R14 (codifies practice).
- R15.2 routing-таблица в docs/routing-off-phase.md (single home).
- R15.3 приоритет специфичности + ADR-границы.
- R15.4 hard-rules §12/§14/§15 перевешивают.
- R15.5 live-override.
- R15.6 гранулярные категории.
- R15.7 обычное правило.

Финальная формула расширена. UI-аппарат R0–R14 без изменений.

docs/routing-off-phase.md v1.0 (новый):
- 34 строки routing-таблицы триггер→узел.
- L1–L12 канонических связок (Rec4): discovery-chain / SYSTEM-аудит / process-pair / mermaid-feeders / архитектурный треугольник / security-слой / интеграционная разработка / runtime-debug / project-management / ML-trio / Claude-инфра / claude-md-management.
- Anti-pattern связок: R14.5 UPM↔FD↔21st, ruflo (dormant), Figma→FD code-gen, Data Scientist→решатель.
- 6 правил дисциплины выбора.

UI-рендер панели «🔗 Связки» на карте — future iter.

Snapshot — docs/discovery/2026-05-18-system-audit-brain.md Rec3/Rec4/Rec5.

cspell-words.txt +промпта. Fix MD056 routing row 60 (+категория orchestration).

NB: восстановлено из backup-патча после collision. LEFTHOOK_EXCLUDE=eslint-vue — pre-existing ImportView.spec.ts:4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:54:17 +03:00
Дмитрий ec4069ce38 docs(rules): ruflo isolation нормативка — Pravila §14.9 + Tooling §4.10 status (Rec2)
Pravila v1.28 → v1.29 (+§14.9 dormant) + Tooling v2.14 → v2.15 (§4.10 status-block).
Заказчик 18.05.2026 (Rec2 SYSTEM-аудита): изолировать ruflo от активного потока без удаления артефактов. Live-связи hooks/MCP/daemon отключены (уже в HEAD через 1412d3f), артефакты сохранены, queen-триггер §14.1 dormant.

2/3 сняты. План реактивации — memory feedback_ruflo_isolated.md.

cspell-words.txt +CCS (ADR-010 conflict code CCS1).

NB: восстановлено из backup-патча memory/rec1-5-stash-backup-2026-05-18-evening.patch после collision с параллельной сессией. LEFTHOOK_EXCLUDE=eslint-vue — pre-existing ImportView.spec.ts:4 (59dac9b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:52:45 +03:00
Дмитрий f248e27702 feat(projects/drawer): редактирование «Источника» (site/call/sms) в карточке проекта
UX-request 18.05.2026 (п.9):
- ProjectDetailsDrawer (правая панель на /projects) теперь редактирует
  signal_identifier для site (домен) и call (телефон 7\d{10}); для sms —
  sms_senders+sms_keyword (как раньше).
- Поле «Источник» отображается **только** в карточке проекта (read-only
  в drawer сделки на /deals — Task 2 закрыл).

Backend:
- UpdateProjectRequest: condition-based валидация по signal_type из БД
  (site domain regex, call 11-digit 7\d{10}; sms — без новых правил)
- ProjectService::update: убран signal_identifier из silent-drop;
  $needsResync расширен на signal_identifier → SyncSupplierProjectJob

signal_type остаётся immutable (менять тип проекта — отдельная задача).

Larastan baseline bumped (ProjectsUpdateTest: actingAs 8→12 для 4 новых тестов).
Pest tests/Feature/Plan5/Projects/ProjectsUpdateTest 12/12.
Vitest 33 passes на Project-spec'ах. Build 2.03s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:44:03 +03:00
Дмитрий 32006a2bda feat(projects/new-dialog): подпись «Источник» над полями на 3 табах
UX-request 18.05.2026 (п.8):
- Сайт: «Источник — домен сайта-«донора», с которого приходят лиды»
- Звонок: «Источник — телефонный номер «донора», на который звонят клиенты»
- СМС: «Источник — отправитель SMS и (опционально) ключевое слово в тексте»

Подпись text-caption text-medium-emphasis, выше существующего label поля.
Один и тот же NewProjectDialog используется и для create, и для edit.

NewProjectDialog.spec.ts 5/2sk/0 — без регрессий. Build 1.96s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:36:09 +03:00
Дмитрий 1412d3fefd feat(deals/drawer): inline status picker — статус-chip кликабельный, без мутации props
UX-request 18.05.2026 (п.3):
- DealDetailHero: v-chip → v-menu со списком всех статусов из lead_statuses
  store; форма и цвет chip'а не меняются
- DealDetailBody: emit 'status-changed' наверх (без мутации props.deal)
- DealDetailDrawer: forward события наружу
- DealsView: onDrawerStatusChanged → optimistic update dealsState + PATCH
  /api/deals/{id} + rollback
- KanbanView: onDrawerStatusChanged → перенос карточки между колонками
  dealsByStatus + transitionDeals + rollback на ошибку

Vue правило vue/no-mutating-props соблюдено (логика в parent'е, не в Body).

Vitest 5 файлов / 38 passed на затронутых; build 2.29s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:34:07 +03:00
Дмитрий 9fcefa3ab9 feat(map): iter8 NODE_META + ruflo isolated markers (Rec1+Rec2.5)
Rec1 — iter8 пересборка теплокарты NODE_META:
- META_SNAPSHOT 16.05 → 18.05; META_WINDOW 09-16.05 → 09-18.05 (10 дней).
- 23 новых узла волн 17-18.05 (A6/D3/C9/A4/A3/A11/C10/discovery/ADT) получили
  baseline=1, usesSrc='интеграция' (факт интеграции в коммит/plan/Tooling §4).
- mcp_figma=0, usesSrc='DEFERRED' (нет Figma-аккаунта).
- discovery_interview=3, usesSrc='скил, factual' (snapshot + это интервью + утренний).
- sk_regression=2 (verification в Sprint 1-6).
- 23 принципиально неизмеримых остались null (правила, hookify_plugin,
  ruflo_daemon/memory, фоновые economy/skill-discipline хуки, старые mem_audit_*).
- Дисклаймер-блок-комментарий обновлён (методика «factual baseline»).
- JS-smoke : 125 entries / 23 null / 31 uses=1 / 26 uses=0 / 45 uses>1.

Rec2.5 — карта ruflo isolated markers:
- 10 ruflo узлов в NODE_META помечены isolated: true
  (ruflo_queen, ruflo_plugins, ruflo_workers, ruflo_agents_catalog,
   ruflo_commands, ruflo_daemon, ruflo_memory, ruflo_mcp, ruflo_recall_hook,
   hk_ruflo_queen).
- uses=0 для всех (реальные вызовы = 0 после изоляции 18.05).
- Блок-комментарий 🔇 ИЗОЛИРОВАН с cross-ref на Pravila §14.9 / Tooling §4.10 /
  memory feedback_ruflo_isolated.md.

Snapshot — docs/discovery/2026-05-18-system-audit-brain.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:30:40 +03:00
Дмитрий e6dbbb49a1 docs(discovery): SYSTEM-аудит «мозга» 18.05.2026 — snapshot 5 осей × 125 узлов
Утренний SYSTEM-режим скила discovery-interview (Pravila §13.2 #55).
Scope: весь «мозг» (карта + тулчейн + правила).

5 осей: здоровье новых узлов / устранение конфликтов / корректность routing /
синергия 2+ узлов / пересмотр правил.

5 приоритезированных рекомендаций (Rec1–Rec5):
- Rec1 iter8 пересборка теплокарты NODE_META
- Rec2 ревизия ruflo keep/trim/off
- Rec3 off-phase routing-матрица на 30 узлов #31-60
- Rec4 панель «Связки» на карте
- Rec5 ребаланс PSR_v1 (UI-аппарат → off-phase)

cspell-words.txt: +отревизован +ребаланс +квирком +тулинг +лоадит (валидные слова).

Источник вечерней работы Rec1–Rec5 + Final CLAUDE.md sync (последующие коммиты).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:30:12 +03:00
Дмитрий 789e7dcdb6 feat(deals/drawer): убрать «Менеджер», добавить «Тип» + «Источник» read-only
UX-request 18.05.2026 (пп.4/6/7):
- удалена секция «Менеджер»/«Не назначен» (менеджеров в системе пока нет)
- добавлен параметр «Тип» (Сайт/Звонок/СМС) — project.signal_type
- добавлен параметр «Источник» (read-only):
  - site/call → project.signal_identifier (домен или телефон)
  - sms → sms_senders[0] + ' (KEYWORD)' если sms_keyword не пустой
- удалён hardcoded «Я.Директ → landing-1»

Backend: DealController index + show + update payload расширены 4 полями
project_signal_type/identifier/sms_keyword/sms_senders + eager-load
project relation расширен.

Редактирование источника — только в карточке проекта (Task 5 плана).

Larastan baseline bumped (DealShowTest: tenant 13→20, getJson 7→10 для 3 новых тестов).
Pest 51/51 на Deal-endpoints.
Vitest 108 files / 875 passed / 3 skipped (5 новых тестов DealDetailBody).
Build 2.30s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:24:57 +03:00
Дмитрий 3bedf10449 feat(deals): drawer виден при selected≤1, bulk-полоса только при ≥2
UX-request 18.05.2026:
- selected.length === 1 → drawer авто-открывается на этой сделке,
  bulk-полоса скрыта (одну сделку проще менять через drawer)
- selected.length >= 2 → drawer закрыт, bulk-полоса видна
- selected.length === 0 → как сейчас (drawer по row-click)

Vitest 12/12 на DealsView.spec (2 новых теста + 10 существующих, none broken).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:14:03 +03:00
Дмитрий 183c719614 docs(plans): план эпика «Сделки drawer + редактирование источника проекта»
5 атомарных задач, согласованы вопросами AskUserQuestion 18.05.2026:
- Task 1: drawer visibility 0/1 vs ≥2 (пп.1+2)
- Task 2: «Менеджер» → «Тип» + «Источник» read-only в drawer (пп.4/6/7)
- Task 3: inline status picker (п.3)
- Task 4: подписи «Источник» в NewProjectDialog (п.8)
- Task 5: редактирование source в ProjectDetailsDrawer (п.9, backend+UI)

п.5 (B-префикс) уже закрыт в 36ea9cd.
cspell: +табах.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:12:07 +03:00
Дмитрий 36ea9cde04 feat(deals): убрать префикс B1_/B2_/B3_ из отображения «Источник»
Поставщик crm.bp префиксует имена проектов признаком канала-провайдера
(B1_/B2_/B3_ — три базы лидов). В UI Лидерры префикс — шум: пользователю
интересен сам проект, не канал.

Трансформация display-only — данные в БД не трогаем, фильтрация идёт по
project_id (не name).

Утилита: app/resources/js/composables/projectName.ts → stripChannelPrefix.
Регэксп ^B[123]_ case-insensitive; null/undefined/'' → ''.

Применено в 4 точках:
- DealsTable «Источник» (item.project)
- DealsFilters «Проект» dropdown (через computed-маппинг в DealsView)
- KanbanCard карточка
- DealDetailBody параметры панели

Тесты: 8 unit-тестов на утилиту (B1/B2/B3 case-insensitive, не трогать
B0/B4/Bx, не трогать префикс в середине строки, null/undefined/''),
38/38 на затронутых компонентах, 868/3sk/0 full Vitest, build 2.62s.

Smoke /deals: 20 строк, ни одна не начинается с B1_/B2_/B3_ (был
«B1_73912557675 [35]», стал «73912557675 [35]»; «B3_krk-finance.ru/...»
→ «krk-finance.ru/...»). Скриншот deals-no-bprefix-2026-05-18.png.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:33:33 +03:00
Дмитрий 1e4278ffb2 docs: ЭТАЛОН проекта — единый снимок текущего состояния и ключевых фактов 2026-05-18 13:00:03 +03:00
Дмитрий 515acb654c fix(adt): renumber cross-refs v1.27→v1.28 / v2.14→v2.15 after rebase
Ветка ребейзнута на parallel-sessions §15 — Pravila v1.27 и CLAUDE.md
v2.14 параллельно заняты §15-эпиком, перенумеровано Pravila→v1.28 /
CLAUDE.md→v2.15. Sync cross-refs: Tooling §0+§13 footer, PSR_v1 §0
entry, automation-graph rule-labels (pravila/claude_md узлы),
+rebase-девиация note в plan. Tooling v2.14 / PSR_v1 v3.13 — без
изменений (§15 их не трогал).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:46:30 +03:00
Дмитрий 7bc9ded118 docs(adt): CLAUDE.md v2.15 — register #56-#60 (rebased onto parallel-sessions §15)
Пересоздан после ребейза на parallel-sessions §15 (origin/main 781a59c).
v2.14 параллельно занят §15 — перенумеровано v2.14→v2.15: §3 title/§1 row
55→60, §3.3 +5 строк #56-#60 + footer 14 off-phase подкатегорий, §0
cross-refs Pravila v1.28 / PSR_v1 v3.13 / Tooling v2.14, §6 +абзац, §9 +запись.
Прямой Edit — worktree-constraint эксцепшн §5 п.10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:42:53 +03:00
Дмитрий 30d1a3c756 docs(adt): Pravila v1.28 — §13.2 +Off-phase authoring-tooling + dev-support
Пересоздан после ребейза feat/anthropic-dev-tooling на parallel-sessions
§15 (origin/main 781a59c). v1.27 параллельно занят §15 — перенумеровано
v1.27→v1.28: §13.2 +абзац (тринадцатая off-phase подкатегория
authoring-tooling #56-#58 + четырнадцатая dev-support #59-#60),
+«Что изменилось в v1.28» блок, +§13 history-row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:39:01 +03:00
Дмитрий 7e167cf943 fix(map): adt — dedup psr_v1 edges (remove 4 stale iter7 duplicates superseded by ADT-block) 2026-05-18 11:35:47 +03:00
Дмитрий cb5bb7dbaf feat(map): adt — register #56-#60 in nd(), 5 edges to psr_v1, hookify conflict 🔴🟢, rule labels v2.14 2026-05-18 11:35:47 +03:00
Дмитрий 942f5364e8 docs(adt): PSR_v1 v3.13 — R10.1 Блок 1 +5 строк (skill-creator/plugin-dev/hookify/claude-code-setup/context7) + hookify HK1 pre-check 2026-05-18 11:35:34 +03:00
Дмитрий fcba06172a docs(adt): Tooling Прил. Н v2.14 — register #56-#60 (authoring-tooling + dev-support) 2026-05-18 11:35:34 +03:00
Дмитрий 947290f1dc docs(adr): ADR-010 — Anthropic dev-tooling formalization decision 2026-05-18 11:35:34 +03:00
Дмитрий 14f405a84a docs(adt): brainstorming spec + implementation plan — Anthropic dev-tooling formalization 2026-05-18 11:35:34 +03:00
Дмитрий 781a59cbf6 chore(sessions): release parallel-sessions-coordination session
status: in-progress → closed-b1765e9
+version-claim CLAUDE.md 2.13 → 2.14 (был пропущен в initial claim)

Все 8 task'ов плана исполнены и merged в origin/main FF
(b40f2c8..b1765e9, 10 commits). Pre-push регрессия GREEN (gitleaks
full-history 0 leaks / 5/5 hook tests / lychee 0 errors на моих файлах).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:47:27 +03:00
Дмитрий b1765e98f7 feat(skills): subagent-driven-development project wrapper + git-safety-checklist
Project-local обёртка над marketplace-скилом superpowers:subagent-driven-development.
Добавляет обязательный pre/post-subagent git-safety verify-протокол
per Pravila §15.1 (Sprint 6 прецедент-источник: Haiku-субагенты
угнали ветку параллельной сессии).

Состав:
- SKILL.md — точка входа, ссылка на marketplace + §A/§B/§C из checklist.
- references/git-safety-checklist.md — pre-spawn / post-subagent / red-flags / GIT REPORT format / code-review boundary.

Хук tools/subagent-prompt-prefix.mjs — первая линия защиты (auto-inject),
этот checklist — вторая линия (контроллер verify).

cspell-words.txt: +ревьюить +инвокацией (§E git-safety-checklist / SKILL.md).

Spec: docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md §5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:43:06 +03:00
Дмитрий c2c9210317 chore(hooks): register subagent-prompt-prefix PreToolUse Task hook
Регистрирует tools/subagent-prompt-prefix.mjs как PreToolUse-хук
matcher:'Task'. JSON валиден (node -e JSON.parse OK).

Хук становится LIVE для всех будущих Task-инвокаций — auto-inject
SUBAGENT GIT-SAFETY HEADER (cwd/branch/HEAD/worktree-root + rules 1-5)
per Pravila §15.1.

End-to-end smoke verified at next Task dispatch (Task 7 плана —
wrapper-skill subagent-driven-development).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:38:22 +03:00
Дмитрий 07eacdbceb docs(claude-md): v2.14 — sync Pravila §15 cross-refs (§0 + §1 footer + §9 entry)
3 точечные правки + version bump:

1. §0 cross-ref row Pravila: v1.26 → v1.27 (lead narrative обновлён,
   v1.26 → 'наследие'-секция).
2. §1 priority chain: новый footer-абзац 'Hard-rules вне §9 «Отступления»'
   — упоминает §12 (Superpowers), §14 (Ruflo Queen), §15 (параллельные
   сессии); все три explicit override-floor под §9.
3. §9 история версий: запись v2.14 с описанием parallel-sessions
   coordination scope (spec + plan + 4 связанных артефакта на ветке).

Шапка v2.13 → v2.14, v2.13 преобразован в 'наследие'-секцию.

Sibling commits на feat/parallel-sessions-coordination (Tasks 1/2/3/4
плана): 83a8d58 (Pravila §15) + 1ab84d8 (docs/sessions/) + 049eaf0
(TDD red) + 78bae4a (TDD green) + ef5da8d (Windows-compat test fixup).

Через /claude-md-management:claude-md-improver (§5 п.10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:29:51 +03:00
Дмитрий ef5da8def8 test(hooks): fix test 5 Windows-compat — PATH=nodeDir not PATH=''
Previous test 5 stripped PATH entirely, which kills node.exe spawn resolution
on Windows (CreateProcess needs PATH to find node). Changed to set PATH to
node's own directory only — node spawns fine, git is not in node-dir → ENOENT
→ hook fail-opens per spec §4.5.

All 5 tests now pass cross-platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:18:54 +03:00
Дмитрий 78bae4addf feat(hooks): subagent-prompt-prefix — PreToolUse git-safety inject (TDD green)
Per Pravila §15.1 — инжектит cwd/branch/HEAD/worktree-root + правила
поведения в каждый Task-prompt. FAIL-OPEN на любой ошибке (git
не в PATH, malformed stdin, non-Task tools).

Все 5 тестов из subagent-prompt-prefix.test.mjs PASS.
Регистрация в .claude/settings.json — Task 6 плана.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:17:04 +03:00
Дмитрий 049eaf0dfc test(hooks): subagent-prompt-prefix — failing tests (TDD red)
5 тестов для Task git-safety inject хука:
- inject SUBAGENT GIT-SAFETY HEADER в Task-prompt
- inject real cwd/branch/HEAD/worktree-root
- passes through non-Task tools
- fail-open on malformed stdin
- fail-open when git unavailable

Tests FAIL — hook implementation в следующем коммите (TDD green-phase).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:13:27 +03:00
Дмитрий 1ab84d8038 feat(sessions): CURRENT.md + README — заявочный лог параллельных Claude-сессий
Создаём docs/sessions/ — координация per Pravila §15.2 (claim/check/release
жизненный цикл, конфликт-резолюция). CURRENT.md содержит текущую сессию
parallel-sessions-coordination + retro-claim записи для существующих
активных worktrees (16 user-sessions на 2026-05-18; 2 locked agent-* worktrees
исключены — не user-сессии).

Backfill scope/version-claims заполнен best-effort; активные сессии
обновят свой блок при возобновлении работы.

+cspell-words: парсится (валидная транслитерация).

Spec: docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:08:51 +03:00
Дмитрий 83a8d58096 feat(pravila): §15 hard-rule — параллельные сессии (субагенты+git, нормативка+pre-flight sync)
Bump Pravila v1.26 → v1.27 + §10 changelog entry. §15 третье hard-rule
после §12 (Superpowers) и §14 (Ruflo Queen). §15 лечит два класса
инцидентов параллельных Claude-сессий — субагенты путают ветки/worktree
(Sprint 6) и нормативка/MEMORY дрейфует (Tooling v2.11 collision 17.05.2026).

Cross-refs to CLAUDE.md §1 — отдельная правка через
/claude-md-management:claude-md-improver (Task 5 плана).

Spec: docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md
Plan: docs/superpowers/plans/2026-05-18-parallel-sessions-coordination.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:59:19 +03:00
Дмитрий 8dbdd5aac0 docs(superpowers): parallel sessions coordination — implementation plan
8 atomic tasks per spec 2026-05-18-parallel-sessions-coordination-design.md:
1. Pravila §15 hard-rule (15.1 субагенты+git, 15.2 нормативка+pre-flight, 15.3 cross-refs) + v1.26→v1.27.
2. docs/sessions/ — README + CURRENT.md с retro-claim для 16 worktrees.
3. tools/subagent-prompt-prefix.test.mjs — TDD red-фаза (5 тестов).
4. tools/subagent-prompt-prefix.mjs — TDD green (PreToolUse Task auto-inject).
5. CLAUDE.md cross-ref через /claude-md-management:claude-md-improver (§5 п.10).
6. .claude/settings.json — регистрация хука matcher:'Task'.
7. .claude/skills/subagent-driven-development/ — wrapper-skill + git-safety-checklist.
8. Final regression + push (manual /push gate).

Все шаги с exact paths, exact commands, expected outputs.
TDD red→green разнесён по двум task'ам (3 → 4) с RED-коммитом между.

Branch: feat/parallel-sessions-coordination (от origin/main b40f2c8).
Spec: docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:51:29 +03:00
Дмитрий 235b1d4e8c docs(superpowers): parallel sessions coordination — design spec
Brainstorm (экономия 5%) с Дмитрием: лечим два класса инцидентов параллельных сессий —
(A) субагенты теряются между worktree (Sprint 6 паттерн);
(B) нормативка/MEMORY дрейфует (Tooling v2.11 collision 17.05.2026).

Решение из 4 артефактов, 0 новых плагинов/MCP:
1. Pravila §15 (новое hard-rule): §15.1 субагенты+git (Sonnet/Opus only),
   §15.2 нормативка+pre-flight sync (фиксированный список 8 файлов).
2. docs/sessions/CURRENT.md — заявочный лог активных сессий + claim/check/release.
3. .claude/hooks/subagent-prompt-prefix.mjs — PreToolUse-хук, инжектит cwd/branch/HEAD заголовок в каждый Task-prompt.
4. Verify-протокол в скиле subagent-driven-development — pre/post-subagent чеклист
   + обязательный GIT REPORT блок от субагента.

Acceptance в §8 spec'а. Spec — черновик → ревью заказчика → writing-plans.

+cspell-words: коммитит / инвокейшн / парсимый (валидные транслитерации).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:40:10 +03:00
147 changed files with 25663 additions and 700 deletions
+20 -18
View File
@@ -37,24 +37,6 @@
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-recall-hook.mjs\""
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-queen-hook.mjs\""
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
@@ -64,6 +46,15 @@
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
}
]
}
],
"PostToolUse": [
@@ -85,6 +76,17 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node tools/observer-stop-hook.mjs",
"timeout": 5
}
]
}
]
}
}
+41
View File
@@ -0,0 +1,41 @@
---
name: brain-retro
description: Use ONCE PER SPRINT (or by explicit user invocation "брейн-ретро") to aggregate evidence from docs/observer/episodes-*.jsonl + notes/*.md and propose regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
---
# Brain Retro
Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces candidates for normative updates. User decides what to apply.
## When to invoke
- Explicit user request: «брейн-ретро» / «сделай brain-retro» / `/brain-retro`.
- Periodic — owner discretion (e.g. end of sprint).
- NOT auto-invoked.
## What it does NOT do
- Does NOT edit `docs/Tooling_v8_3.md`, `docs/Pravila_raboty_Claude_v1_1.md`, `docs/Plugin_stack_rules_v1.md`, `CLAUDE.md`, or any normative file.
- Does NOT write to `docs/observer/episodes-*.jsonl` (read-only).
- Does NOT trigger automatic memory updates.
## Procedure
1. **Determine period**: ask user «за какой период» or default to «since last brain-retro» (find latest `docs/observer/notes/YYYY-MM-DD-brain-retro-*.md`).
2. **Read evidence**: glob `docs/observer/episodes-YYYY-MM.jsonl` for the period; read all lines as JSON.
3. **Read optional notes**: glob `docs/observer/notes/*.md` filtered by date.
4. **Update read-counter**: bump `docs/observer/.read-counter.json` `last_read_at` to now, increment `read_count_last_period`. (Side-effect — used by C3 observer-of-observer.)
5. **Run the deterministic analyzer**: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-YYYY-MM.jsonl` (pass every monthly file in the period). It returns JSON with `episodeCount`, `observerErrorCount`, `tasks` (episodes grouped into tasks), `causalChains` (error→fix candidates) and `factorMatrix` (outcome distribution per factor). The analyzer deduplicates the routing-gate double-write and infers the true `outcome` of each episode from the next episode's `prompt_signal` — never trust the stored `outcome` (it is `unknown` at write time).
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`.
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
9. **Report to user**: high-signal summary.
## Output anatomy
See `references/aggregation-template.md`.
## Behavioral rule reminders
- **«Не использован ≠ проблема»** — when reporting node usage counts, NEVER mark unused nodes as «zombie» / «removal candidate». Cite `memory/feedback_brain_unused_tools_not_problem.md`.
- **No auto-edit** — every regulatory suggestion is a candidate, not an action.
@@ -0,0 +1,112 @@
# Brain-retro aggregation template
## Period
YYYY-MM-DD .. YYYY-MM-DD ({N} sessions)
## Path-type distribution
| path_type | count | % |
|---|---|---|
| regulated | A | x% |
| improvised | B | y% |
| alternative | C | z% |
| mixed | D | w% |
## Outcome distribution
| outcome | count |
|---|---|
| success | M |
| partial | N |
| failure | O |
| aborted | P |
## Top nodes used (from `skill_invoked` events)
| node | times used | first / last |
|---|---|---|
## Factor analysis matrix (v2 — from `tools/brain-retro-analyzer.mjs`)
Outcome distribution per factor value. Source: the analyzers `factorMatrix`.
Outcome is the *inferred* outcome (next-prompt sentiment), not the stored
`unknown`. The factor `decision_provenance` directly answers the owners
question — "is the rework mine or the routers?"
For each factor below, render a table: factor value × outcome counts
(`success` / `partial` / `rework` / `unknown`).
### decision_provenance (autonomous vs user_directed_method)
| provenance | success | partial | rework | unknown |
|---|---|---|---|---|
### economy_level
| economy_level | success | partial | rework | unknown |
|---|---|---|---|---|
### model · post_compaction · task_size bucket
(one table each — same columns)
### node_chosen · task_classification
(one table each — same columns)
## Episodes → tasks (from analyzer `tasks`)
| task_ref | episodes | turns that are rework |
|---|---|---|
## Causal-chain candidates (from analyzer `causalChains`)
| from (errored episode) | to (later episode) | shared files |
|---|---|---|
## Observer health
- `observerErrorCount` from the analyzer — observer_error markers in the period.
Non-zero = the observer failed silently somewhere; investigate.
## Canonical chains L1L12 hit rate
| chain | times | notes |
|---|---|---|
## Improvised chains (path_type=improvised, repeated ≥2)
| node-set | times | candidate L13+? |
|---|---|---|
## chain_divergence cases
| canonical | chosen | reason | recurring? |
|---|---|---|---|
## Top error classes
| error class | count | recovery pattern |
|---|---|---|
## confusion_marker hot-spots
| context | count |
|---|---|
## Candidates for owner review
### Candidate 1: `<title>`
- **Type**: new canonical chain L13+ / new ADR / boundary clarification / etc.
- **Evidence**: refs to JSONL lines (file:line).
- **Suggested action**: `<concrete edit>`.
- **Cost / risk**: `<brief>`.
(repeat for each candidate; could be 0)
## Informational metrics (NOT alerts)
- Nodes used at least once this period: K / 60+
- Nodes never used since beginning of observer logs: L / 60+ — **not a problem** per [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md)
@@ -0,0 +1,27 @@
---
name: subagent-driven-development
description: Project-local wrapper для superpowers:subagent-driven-development — добавляет обязательный git-safety verify-протокол per Pravila §15.1. Использовать вместо marketplace-варианта при работе с git-коммит-задачами в субагентах.
---
# Subagent-Driven Development (project wrapper)
Этот скил — проектная обёртка над marketplace-скилом `superpowers:subagent-driven-development`. Дополняет его обязательным git-safety verify-протоколом per Pravila §15.1.
## Когда использовать
Когда нужно делегировать задачу субагенту через Task tool — особенно git-коммит-задачи (Sprint 6 прецедент: Haiku-субагенты угнали ветку параллельной сессии).
## Что делать
1. **Откройте marketplace-скил** `superpowers:subagent-driven-development` для общего workflow (fresh subagent per task + two-stage review).
2. **Перед каждой Task-инвокацией** прочитайте и выполните pre-spawn-чеклист — [references/git-safety-checklist.md](references/git-safety-checklist.md) §A.
3. **После каждой Task-инвокации** прочитайте и выполните post-subagent-чеклист — там же §B.
4. **Hard-rule §15.1** — git-коммит-задача = модель Sonnet/Opus, никогда Haiku. Read-only git-операции (`log`, `status`, `diff`, `rev-parse`, `branch --show-current`, `worktree list`) разрешены любой модели.
Хук `tools/subagent-prompt-prefix.mjs` (зарегистрирован в `.claude/settings.json`) автоматически инжектит git-safety заголовок в каждый Task-prompt — это **первая** линия защиты. Чеклист из этого скила — **вторая** линия (защита со стороны контроллера).
## Cross-refs
- Pravila §15.1 — hard-rule субагенты + git.
- Spec: `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md` §5.
- Memory: `memory/feedback_subagent_git_reliability.md`.
@@ -0,0 +1,65 @@
# Git-safety Checklist для контроллера субагентов
Per Pravila §15.1 — выполнять каждый раз при делегировании задачи через Task tool.
## §A. Pre-spawn чеклист (до Task-инвокации)
1. **Резолвите 4 значения** (запишите у себя для post-check):
```bash
git branch --show-current # → ожидаемая ветка
git rev-parse HEAD # → pre-spawn parent SHA
git rev-parse --show-toplevel # → worktree root
pwd # → cwd
```
2. **Выберите модель** субагенту:
- Задача требует `git commit`/`push`/`stage`/`checkout`/`switch`/`merge`/`rebase`? → **Sonnet или Opus**, никогда Haiku (§15.1).
- Только read-только `git log`/`status`/`diff`/`rev-parse` ИЛИ только Edit/Read/Grep? → любая модель.
3. **Если задача правит нормативку из списка §15.2** (Pravila / CLAUDE.md / Tooling / PSR_v1 / MEMORY.md / Открытые_вопросы / docs/adr/* / db/schema.sql):
```bash
git fetch origin && git log HEAD..origin/main --oneline
```
Не пусто → **ребейз/merge до инвокации**, не после. Pre-flight также проверить `docs/sessions/CURRENT.md` на конфликт scope-files / version-claims.
## §B. Post-subagent чеклист (сразу после возврата субагента)
1. **`git rev-parse HEAD`** — сравнить с pre-spawn parent SHA.
- Равно → субагент не коммитил (OK для Edit-задач без commit).
- Отличается ровно одним коммитом, чей parent = pre-spawn HEAD → OK для commit-задач.
- **Иначе → STOP, разбор инцидента.**
2. **`git branch --show-current`** — сравнить с pre-spawn branch.
- Не равно → **STOP, разбор инцидента** (Sprint 6 паттерн).
3. **`git log -1 --format='%s%n%P'`** — проверить subject + parent последнего коммита.
- Subject соответствует задаче?
- Parent = pre-spawn HEAD?
4. Если несколько коммитов — ручная проверка subject'ов каждого.
## §C. Red-flag-список — любой = hard-stop разбор
- `branch ≠ ожидаемая`;
- `parent коммита ≠ pre-spawn HEAD` (висячий коммит / попадание на чужую ветку);
- HEAD двинулся, но субагент в отчёте об этом не упомянул;
- в diff'е есть файлы вне scope задачи.
## §D. Обязательный формат отчёта субагента
Субагент в конце ответа выписывает блок:
```
=== GIT REPORT ===
cwd: <pwd>
branch: <git branch --show-current>
HEAD: <git rev-parse HEAD>
HEAD^: <git rev-parse HEAD^>
status: <git status --short>
=== END GIT REPORT ===
```
Отсутствие блока = контроллер считает результат недостоверным и запускает §B-чеклист сам через Bash.
## §E. Соотношение с code-review
Двухстадийное review (Pravila §4.5 / PSR_v1 R10) сохраняется. Git-safety-чеклист **не заменяет** code-review — он стоит **до** него (нет смысла ревьюить diff, если он не в той ветке).
+5
View File
@@ -0,0 +1,5 @@
# Normalize line endings for Node ESM tooling files.
# Keep LF in the working tree regardless of core.autocrlf — CRLF .mjs files
# break vitest module loading (SyntaxError: Invalid or unexpected token,
# no file:line). See memory quirk #100 (2026-05-19).
*.mjs text eol=lf
@@ -0,0 +1,31 @@
name: brain-l1-watcher (weekly)
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: run l1-watcher
id: l1
run: node tools/l1-watcher.mjs
continue-on-error: true
- name: open issue on drift
if: steps.l1.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[l1-watcher] drift detected (weekly cron ${new Date().toISOString().slice(0,10)})`,
body: `Run failed. Check workflow logs and run /claude-md-management:claude-md-improver.`,
labels: ['brain', 'drift']
});
+1 -5
View File
@@ -39,11 +39,7 @@
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"],
"comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var."
},
"ruflo": {
"command": "npx",
"args": ["-y", "ruflo@latest", "mcp", "start"],
"comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4."
},
"_ruflo_isolated_note": "ruflo MCP-сервер отключён 18.05.2026 (заказчик: «изолируй, не удаляй»). Чтобы вернуть — восстановить блок 'ruflo': { command: 'npx', args: ['-y','ruflo@latest','mcp','start'], comment: ... }. См. memory feedback_ruflo_isolated.md, Tooling §4.10, CLAUDE.md §3.5.",
"universal-icons": {
"command": "npx",
"args": ["-y", "mcp-universal-icons"],
+69 -44
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\SupplierProject;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin Интеграция с поставщиком: здоровье резервного CSV-канала (Путь 2).
*
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.4
*/
final class AdminSupplierIntegrationController extends Controller
{
private const HISTORY_LIMIT = 20;
public function index(): JsonResponse
{
$rows = DB::connection('pgsql_supplier')
->table('supplier_csv_reconcile_log')
->orderByDesc('id')
->limit(self::HISTORY_LIMIT)
->get();
$last = $rows->first();
$webhookState = ($last !== null && $last->status === 'drift_alert') ? 'down' : 'live';
return response()->json([
'health' => [
'last_run_at' => $last !== null ? ($last->finished_at ?? $last->started_at) : null,
'last_status' => $last?->status,
'drift_ratio' => $last !== null ? (float) $last->drift_ratio : null,
'webhook_state' => $webhookState,
],
'history' => $rows->map(fn ($r): array => [
'started_at' => $r->started_at,
'finished_at' => $r->finished_at,
'window_start' => $r->window_start,
'window_end' => $r->window_end,
'status' => $r->status,
'total_csv_rows' => (int) $r->total_csv_rows,
'matched_count' => (int) $r->matched_count,
'recovered_count' => (int) $r->recovered_count,
'drift_ratio' => (float) $r->drift_ratio,
])->all(),
]);
}
public function reconcile(): JsonResponse
{
CsvReconcileJob::dispatch();
return response()->json(['dispatched' => true]);
}
/**
* Очередь яруса 3 резерва канала миграции проектов pending-список для
* оператора админ-экрана. Spec §4.6.
*/
public function manualQueueIndex(): JsonResponse
{
$rows = SupplierManualSyncQueue::where('status', 'pending')
->orderByDesc('id')
->limit(100)
->get(['id', 'project_id', 'platform', 'operation', 'external_id', 'payload_snapshot', 'failure_reason', 'created_at']);
return response()->json(['queue' => $rows]);
}
/**
* Оператор вручную создал проект на портале reconcile: сверяем через
* listProjects(), ставим FK supplier_b{1,2,3}_project_id, помечаем resolved.
* 409 если проект на портале не найден (оператор не создал / другие параметры).
* Spec §4.6.
*/
public function manualQueueResolve(int $id, Request $request, SupplierProjectChannel $channel): JsonResponse
{
$row = SupplierManualSyncQueue::findOrFail($id);
if ($row->status !== 'pending') {
return response()->json(['message' => 'already resolved or cancelled'], 409);
}
$payload = $row->payload_snapshot;
$signalType = (string) ($payload['signal_type'] ?? '');
$uniqueKey = (string) ($payload['unique_key'] ?? '');
$found = null;
foreach ($channel->listProjects() as $r) {
if (
($r['platform'] ?? null) === $row->platform
&& ($r['signal_type'] ?? null) === $signalType
&& ($r['unique_key'] ?? null) === $uniqueKey
) {
$found = (int) ($r['id'] ?? 0);
break;
}
}
if ($found === null) {
return response()->json([
'message' => 'Проект не найден на портале поставщика. Проверьте, что вы действительно его создали с теми же параметрами.',
], 409);
}
// FK projects.supplier_b{1,2,3}_project_id ведёт на local supplier_projects.id,
// не на portal external_id. Find-or-create local row с verified external_id.
$sp = SupplierProject::firstOrCreate(
[
'platform' => $row->platform,
'signal_type' => $signalType,
'unique_key' => $uniqueKey,
],
[
'supplier_external_id' => (string) $found,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
],
);
Project::where('id', $row->project_id)->update([
'supplier_'.strtolower($row->platform).'_project_id' => $sp->id,
]);
$row->update([
'status' => 'resolved',
'resolved_by_user_id' => $request->user()->id,
'resolved_at' => now(),
'external_id' => (string) $found,
]);
return response()->json(['resolved' => true, 'external_id' => $found]);
}
}
@@ -109,7 +109,7 @@ class DealController extends Controller
->limit(1),
])
->where('tenant_id', $tenantId)
->with(['project:id,name,signal_type', 'manager:id,email,first_name,last_name']);
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
@@ -213,6 +213,9 @@ class DealController extends Controller
'comment' => $d->comment,
'city' => $d->city,
'project_signal_type' => $d->project?->signal_type,
'project_signal_identifier' => $d->project?->signal_identifier,
'project_sms_keyword' => $d->project?->sms_keyword,
'project_sms_senders' => $d->project?->sms_senders,
'next_reminder_at' => $d->next_reminder_at
? Carbon::parse($d->next_reminder_at)->toIso8601String()
: null,
@@ -248,7 +251,7 @@ class DealController extends Controller
$deal = Deal::query()
->where('tenant_id', $tenantId)
->where('id', $id)
->with(['project:id,name', 'manager:id,email,first_name,last_name'])
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name'])
->first();
if ($deal === null) {
@@ -290,6 +293,10 @@ class DealController extends Controller
: null,
'received_at' => $deal->received_at?->toIso8601String(),
'assigned_at' => $deal->assigned_at?->toIso8601String(),
'project_signal_type' => $deal->project?->signal_type,
'project_signal_identifier' => $deal->project?->signal_identifier,
'project_sms_keyword' => $deal->project?->sms_keyword,
'project_sms_senders' => $deal->project?->sms_senders,
],
'events' => $events->map(fn (ActivityLog $e) => [
'id' => $e->id,
@@ -432,6 +439,10 @@ class DealController extends Controller
'manager_id' => $deal->manager_id,
'received_at' => $deal->received_at?->toIso8601String(),
'assigned_at' => $deal->assigned_at?->toIso8601String(),
'project_signal_type' => $deal->project?->signal_type,
'project_signal_identifier' => $deal->project?->signal_identifier,
'project_sms_keyword' => $deal->project?->sms_keyword,
'project_sms_senders' => $deal->project?->sms_senders,
],
]);
}
+20 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProjectRequest extends FormRequest
@@ -16,7 +17,7 @@ class UpdateProjectRequest extends FormRequest
public function rules(): array
{
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
return [
$rules = [
'name' => ['sometimes', 'string', 'max:255'],
'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'],
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
@@ -28,5 +29,23 @@ class UpdateProjectRequest extends FormRequest
'sms_senders.*' => ['string', 'max:11'],
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
];
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
// Регулярки соответствуют StoreProjectRequest (domain + 7\d{10}).
// signal_type immutable — берём из текущего проекта по route id.
$projectId = $this->route('id');
if ($projectId !== null) {
$project = Project::find($projectId);
if ($project !== null) {
if ($project->signal_type === 'site') {
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
} elseif ($project->signal_type === 'call') {
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
}
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
}
}
return $rules;
}
}
+16 -2
View File
@@ -150,7 +150,10 @@ class RouteSupplierLeadJob implements ShouldQueue
/**
* Парсит поле raw_payload['project'] (формат `B[123]_<rest>`):
* - rest вида `7\d{10}` call (телефон-номер для звонка-сигнала);
* - rest вида `^[a-z0-9-]+(\.[a-z0-9-]+)+$` site (домен сайта-сигнала);
* - rest вида `^[a-z0-9-]+(\.[a-z0-9-]+)+$` site (rest целиком домен);
* - rest со встроенным доменом в свободном тексте site (identifier =
* извлечённый домен; поставщик иногда шлёт имя вида `заявка carmoney.ru/`
* или `Платежи cabinet.caranga.ru/login` регрессия 18.05.2026, 21 лид);
* - иначе sms (короткое имя отправителя SMS-шлюза).
*
* @return array{0: string, 1: string, 2: string} [platform, signal_type, identifier]
@@ -163,15 +166,26 @@ class RouteSupplierLeadJob implements ShouldQueue
$platform = $m[1];
$rest = $m[2];
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
$domainRe = '/(?<![a-z0-9.\-])([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,})/i';
if (preg_match('/^7\d{10}$/', $rest) === 1) {
$signalType = 'call';
$identifier = $rest;
} elseif (preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i', $rest) === 1) {
$signalType = 'site';
$identifier = $rest;
} elseif (preg_match($domainRe, $rest, $dm) === 1) {
// Домен извлечён из свободного текста — это сайт-сигнал.
$signalType = 'site';
$identifier = mb_strtolower($dm[1]);
} else {
$signalType = 'sms';
$identifier = $rest;
}
return [$platform, $signalType, $rest];
return [$platform, $signalType, $identifier];
}
/**
+81 -57
View File
@@ -24,21 +24,20 @@ use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Hourly CSV reconciliation с порталом поставщика.
* Резервный CSV-канал (Путь 2): сверка отчёта поставщика «Запрос номеров»
* с принятыми webhook-лидами; recovery пропущенного + drift-детект.
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md
*
* Алгоритм:
* 1. Cache::lock на 600s overlap-защита.
* 1. Cache::lock overlap-защита.
* 2. INSERT supplier_csv_reconcile_log (status='running').
* 3. Download CSV за окно 25h.
* 4. Parse собираем ['vid' => row].
* 5. SELECT existing vid'ы из supplier_leads (BYPASSRLS).
* 6. Diff = missing.
* 7. Для каждой missing INSERT supplier_leads (recovered_from_csv_at) + dispatch RouteJob.
* 8. UPDATE log с метриками + status.
* 9. drift > 5% CsvDriftAlertMail + alert_email_sent_at.
* 10. На exception status='failed', throw.
* 3. Заказать отчёт «Запрос номеров» за окно (2 кал. дня) дождаться скачать.
* 4. Parse CSV (Name;Tag;Phone).
* 5. Дедуп по (phone, project): SELECT existing supplier_leads за окно.
* 6. Diff = missing INSERT supplier_leads (vid=NULL, source='csv_recovery') + RouteJob.
* 7. UPDATE log + drift; drift > 5% CsvDriftAlertMail.
* 8. На exception status='failed', throw (cron повторит через 30 мин).
*/
final class CsvReconcileJob implements ShouldQueue
{
@@ -55,7 +54,7 @@ final class CsvReconcileJob implements ShouldQueue
private const DRIFT_THRESHOLD = 0.05;
private const WINDOW_HOURS = 25;
private const WINDOW_DAYS = 2;
private const LOCK_NAME = 'supplier:csv_reconcile';
@@ -75,47 +74,63 @@ final class CsvReconcileJob implements ShouldQueue
return;
}
// Окно: начало (сегодня − (WINDOW_DAYS1) дней) 00:00 .. сейчас.
$windowEnd = Carbon::now();
$windowStart = (clone $windowEnd)->subHours(self::WINDOW_HOURS);
$windowStart = Carbon::today()->subDays(self::WINDOW_DAYS - 1);
$logId = DB::connection(self::DB_CONNECTION)
->table('supplier_csv_reconcile_log')
->insertGetId([
'started_at' => now(),
'window_start' => $windowStart,
'window_end' => $windowEnd,
'status' => 'running',
'created_at' => now(),
]);
// $logId инициализируется внутри try: если сам insertGetId упадёт (БД недоступна),
// catch обязан НЕ обращаться к неинициализированному $logId, а finally — освободить
// lock (иначе lock висит LOCK_TTL_SECONDS и пропускает следующие запуски).
$logId = null;
try {
$csv = $portal->downloadLeadsCsv($windowStart, $windowEnd);
$logId = DB::connection(self::DB_CONNECTION)
->table('supplier_csv_reconcile_log')
->insertGetId([
'started_at' => now(),
'window_start' => $windowStart,
'window_end' => $windowEnd,
'status' => 'running',
'created_at' => now(),
]);
/** @var array<string, array<string, mixed>> $csvByVid */
$csvByVid = [];
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
$portal->waitReportReady($reportId);
$csv = $portal->downloadReport($reportId);
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
$csvByKey = [];
foreach ($parser->parse($csv) as $row) {
$csvByVid[(string) $row['vid']] = $row;
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
}
$totalCsvRows = count($csvByVid);
$totalCsvRows = count($csvByKey);
$existing = DB::connection(self::DB_CONNECTION)
// Существующие лиды за окно → set ключей phone|project.
$existingKeys = [];
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->where('received_at', '>=', $windowStart)
->where('received_at', '<', $windowEnd->copy()->addHour())
->pluck('vid')
->map(fn ($v) => (string) $v)
->all();
->select('phone', 'raw_payload')
->orderBy('id')
->chunk(500, function ($leads) use (&$existingKeys): void {
foreach ($leads as $lead) {
$payload = is_string($lead->raw_payload)
? json_decode($lead->raw_payload, true)
: (array) $lead->raw_payload;
$project = (string) ($payload['project'] ?? '');
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
}
});
$existingMap = array_flip($existing);
$missing = array_diff_key($csvByVid, $existingMap);
$missing = array_diff_key($csvByKey, $existingKeys);
$recoveredCount = 0;
foreach ($missing as $vid => $row) {
$platform = $this->extractPlatform((string) ($row['project'] ?? ''));
foreach ($missing as $row) {
$platform = $this->extractPlatform((string) $row['project']);
if ($platform === null) {
Log::warning('csv_reconcile.unparseable_project_skipped', [
'vid' => $vid,
'project' => $row['project'] ?? null,
'project' => $row['project'],
]);
continue;
@@ -123,24 +138,23 @@ final class CsvReconcileJob implements ShouldQueue
try {
$lead = SupplierLead::create([
'vid' => (int) $vid,
'vid' => null,
'platform' => $platform,
'phone' => (string) $row['phone'],
'raw_payload' => $row,
'received_at' => Carbon::createFromTimestamp((int) $row['time']),
'received_at' => now(),
'recovered_from_csv_at' => now(),
'source' => 'csv_recovery',
'supplier_project_id' => null, // ResolverStub разрезолвит при RouteJob run
'supplier_project_id' => null,
]);
RouteSupplierLeadJob::dispatch($lead->id);
$recoveredCount++;
} catch (QueryException $e) {
if (str_contains($e->getMessage(), 'unique')) {
Log::info('csv_reconcile.duplicate_vid_skipped', ['vid' => $vid]);
continue;
}
throw $e;
Log::warning('csv_reconcile.lead_insert_failed', [
'phone' => $row['phone'],
'project' => $row['project'],
'error' => $e->getMessage(),
]);
}
}
@@ -177,14 +191,17 @@ final class CsvReconcileJob implements ShouldQueue
->update($update);
} catch (Throwable $e) {
DB::connection(self::DB_CONNECTION)
->table('supplier_csv_reconcile_log')
->where('id', $logId)
->update([
'finished_at' => now(),
'status' => 'failed',
'error_message' => substr($e->getMessage(), 0, 1000),
]);
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
if ($logId !== null) {
DB::connection(self::DB_CONNECTION)
->table('supplier_csv_reconcile_log')
->where('id', $logId)
->update([
'finished_at' => now(),
'status' => 'failed',
'error_message' => substr($e->getMessage(), 0, 1000),
]);
}
throw $e;
} finally {
$lock->release();
@@ -192,8 +209,15 @@ final class CsvReconcileJob implements ShouldQueue
}
/**
* Извлекает platform (B1/B2/B3) из поля raw_payload['project'] CSV-строки.
* Формат project: `B[123]_<rest>` (например `B1_a.com`, `B2_79991234567`).
* Ключ дедупа: нормализованный phone + project.
*/
private function dedupKey(string $phone, string $project): string
{
return trim($phone).'|'.trim($project);
}
/**
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
* Возвращает null если не парсится caller пропустит строку с warning.
*/
private function extractPlatform(string $project): ?string
@@ -12,8 +12,11 @@ use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierQuotaAllocator;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
@@ -63,9 +66,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
public const DB_CONNECTION = 'pgsql_supplier';
public function handle(?SupplierPortalClient $client = null): void
private SupplierProjectChannel $channel;
public function handle(?SupplierProjectChannel $channel = null): void
{
$client ??= app(SupplierPortalClient::class);
$this->channel = $channel ?? app(SupplierProjectChannel::class);
$consecutiveTransient = 0;
$projects = SupplierProject::on(self::DB_CONNECTION)
@@ -82,8 +87,16 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
try {
$this->syncOne($sp, $client);
$this->syncOne($sp);
$consecutiveTransient = 0;
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
continue;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} deferred by portal window");
continue;
} catch (SupplierAuthException $e) {
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
@@ -115,7 +128,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
private function syncOne(SupplierProject $sp, SupplierPortalClient $client): void
private function syncOne(SupplierProject $sp): void
{
$fkColumn = $this->fkColumnForPlatform($sp->platform);
@@ -155,8 +168,13 @@ class SyncSupplierProjectsJob implements ShouldQueue
// (supplier_project update + supplier_sync_log insert) на одной connection
// выполняются последовательно; ошибка между ними — recoverable through retry
// на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()).
// Context-project для project_id в очереди яруса 3 при эскалации.
$contextProject = $liderraProjects->first();
if ($isCreate) {
$externalId = $client->saveProject($allocation);
$externalId = $this->channel instanceof FailoverProjectChannel
? $this->channel->createProjectForLiderra($contextProject, $allocation)
: $this->channel->createProject($allocation);
$sp->forceFill([
'supplier_external_id' => (string) $externalId,
'current_limit' => $allocation->limit,
@@ -166,7 +184,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
'last_synced_at' => now(),
])->save();
} else {
$client->updateProject((int) $sp->supplier_external_id, $allocation);
if ($this->channel instanceof FailoverProjectChannel) {
$this->channel->updateProjectForLiderra($contextProject, (int) $sp->supplier_external_id, $allocation);
} else {
$this->channel->updateProject((int) $sp->supplier_external_id, $allocation);
}
$sp->forceFill([
'current_limit' => $allocation->limit,
'current_workdays' => $allocation->workdays,
+73 -5
View File
@@ -5,7 +5,12 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Project;
use App\Services\Supplier\SupplierPortalClient;
use App\Models\SupplierProject;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -24,9 +29,14 @@ use Illuminate\Support\Facades\Log;
*
* Записывает полученные supplier_projects.id в projects.supplier_b{1,2,3}_project_id.
*
* Канал миграции SupplierProjectChannel (резолвится в FailoverProjectChannel:
* ярус 1 AJAX ярус 2 browser-form ярус 3 manual queue). При эскалации на
* ярус 3 / переносе по окну портала platform пропускается (FK остаётся NULL,
* ночной SyncSupplierProjectsJob подберёт после ручного вмешательства).
*
* Retry: 3 попытки с backoff [15s, 60s, 300s].
*
* Spec: docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md Task 4
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §5
*/
class SyncSupplierProjectJob implements ShouldQueue
{
@@ -39,7 +49,7 @@ class SyncSupplierProjectJob implements ShouldQueue
public function __construct(public int $projectId) {}
public function handle(SupplierPortalClient $client): void
public function handle(SupplierProjectChannel $channel): void
{
$project = Project::find($this->projectId);
@@ -53,14 +63,72 @@ class SyncSupplierProjectJob implements ShouldQueue
foreach ($platforms as $platform) {
$uniqueKey = $this->buildUniqueKey($project, $platform);
$supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey);
$column = 'supplier_'.strtolower($platform).'_project_id';
$project->{$column} = $supplierProjectId;
// Идемпотентность: local supplier_projects-запись для тройки уже есть?
$existing = SupplierProject::query()
->where('platform', $platform)
->where('signal_type', $project->signal_type)
->where('unique_key', $uniqueKey)
->first();
if ($existing !== null) {
$project->{$column} = $existing->id;
continue;
}
$dto = $this->buildDto($project, $platform, $uniqueKey);
try {
$externalId = $channel instanceof FailoverProjectChannel
? $channel->createProjectForLiderra($project, $dto)
: $channel->createProject($dto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
continue;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
continue;
}
$sp = SupplierProject::query()->create([
'platform' => $platform,
'signal_type' => $project->signal_type,
'unique_key' => $uniqueKey,
'supplier_external_id' => (string) $externalId,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
]);
$project->{$column} = $sp->id;
}
$project->save();
}
/**
* Initial-create DTO: лимит 0 (квота приедет ночным SyncSupplierProjectsJob),
* полная неделя, без регионов.
*/
private function buildDto(Project $project, string $platform, string $uniqueKey): SupplierProjectDto
{
return new SupplierProjectDto(
platform: $platform,
signalType: (string) $project->signal_type,
uniqueKey: $uniqueKey,
limit: 0,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
}
/**
* Возвращает список uppercase platform-кодов для данного project.
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Очередь яруса 3 резерва канала миграции проектов.
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
*/
class SupplierManualSyncQueue extends Model
{
use HasFactory;
protected $table = 'supplier_manual_sync_queue';
public $timestamps = false;
protected $fillable = [
'project_id', 'platform', 'operation', 'external_id',
'payload_snapshot', 'failure_reason', 'status',
'resolved_by_user_id', 'created_at', 'resolved_at',
];
protected $casts = [
'payload_snapshot' => 'array',
'created_at' => 'datetime',
'resolved_at' => 'datetime',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function resolver(): BelongsTo
{
return $this->belongsTo(User::class, 'resolved_by_user_id');
}
}
+17
View File
@@ -2,8 +2,13 @@
namespace App\Providers;
use App\Services\Supplier\Channel\AjaxProjectChannel;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\FormProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -17,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
ProcessFactory::class,
SymfonyProcessFactory::class,
);
// Резерв канала миграции проектов: SupplierProjectChannel резолвится в
// декоратор-оркестратор (ярус 1 AJAX → ярус 2 browser-form → ярус 3 queue).
// Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
$this->app->bind(
SupplierProjectChannel::class,
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
$app->make(Mailer::class),
),
);
}
/**
+6 -2
View File
@@ -14,8 +14,9 @@ class ProjectService
public function update(Project $project, array $data): Project
{
// Immutable fields — silently drop (don't 422)
// signal_identifier — теперь editable (18.05.2026 ux), валидируется в UpdateProjectRequest.
unset(
$data['tenant_id'], $data['signal_type'], $data['signal_identifier'],
$data['tenant_id'], $data['signal_type'],
$data['delivered_today'], $data['delivered_in_month'],
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
$data['archived_at'],
@@ -31,7 +32,10 @@ class ProjectService
], 422));
}
$needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data);
// Resync на смену любого источник-несущего поля — поставщику нужно знать актуальный домен/телефон/sms.
$needsResync = array_key_exists('sms_senders', $data)
|| array_key_exists('sms_keyword', $data)
|| array_key_exists('signal_identifier', $data);
$project->update($data);
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Channel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
/**
* Ярус 1: тонкий адаптер над SupplierPortalClient (rt-project-* AJAX).
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.2
*/
final class AjaxProjectChannel implements SupplierProjectChannel
{
public function __construct(
private readonly SupplierPortalClient $client,
) {}
public function createProject(SupplierProjectDto $dto): int
{
return $this->client->saveProject($dto);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void
{
$this->client->updateProject($externalId, $dto);
}
/**
* Сырые rt-строки портала контрактная форма SupplierProjectChannel.
*
* Портал не отдаёт platform/signal_type/unique_key напрямую. Маппинг
* (verified live 2026-05-19, см. SupplierPortalClient::listProjects docblock):
* - platform префикс name "B<n>_..." (B1/B2/B3); иначе null;
* - signal_type type: hosts→site, calls→call, sms→sms;
* - unique_key content (домен / телефон / sender).
* Сырые поля остаются (id, tag, name, type, content, ...) для дебага.
*/
public function listProjects(): array
{
$out = [];
foreach ($this->client->listProjects() as $row) {
if (! is_array($row)) {
continue;
}
$name = (string) ($row['name'] ?? '');
$platform = preg_match('/^(B[123])_/', $name, $m) === 1 ? $m[1] : null;
$signalType = match ($row['type'] ?? null) {
'hosts' => 'site',
'calls' => 'call',
'sms' => 'sms',
default => null,
};
$out[] = $row + [
'platform' => $platform,
'signal_type' => $signalType,
'unique_key' => (string) ($row['content'] ?? ''),
];
}
return $out;
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Channel\Exceptions;
/**
* Брошен FailoverProjectChannel когда операция эскалирована на ярус 3.
*
* Job-уровень ловит и помечает текущую попытку как отложенную к ручному вмешательству.
*
* Spec §4.4 ("manual_required").
*/
final class TierEscalatedException extends \RuntimeException
{
public function __construct(
public readonly int $queueRowId,
public readonly string $reason,
string $message = '',
) {
parent::__construct($message ?: "Escalated to manual queue (row #{$queueRowId}, reason: {$reason})");
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Channel\Exceptions;
/**
* Маркер «портал отказал по причине окна редактирования» (22:00-00:00 МСК).
*
* НЕ сбой канала операция переносится. FailoverProjectChannel пропускает
* эскалацию ярусов и не пишет в supplier_manual_sync_queue. Job-уровень
* получает исключение и помечает попытку как deferred.
*
* Spec §8.
*/
final class WindowDeferredException extends \RuntimeException {}
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Channel;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Декоратор-оркестратор: ярус 1 (AJAX) ярус 2 (form-driving) ярус 3 (manual queue).
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
*
* Bridge-методы createProjectForLiderra/updateProjectForLiderra принимают Project
* (нужен для project_id в очереди яруса 3). Прямые createProject/updateProject
* сохраняются для интерфейс-совместимости (без эскалации).
*/
final class FailoverProjectChannel implements SupplierProjectChannel
{
public function __construct(
private readonly SupplierProjectChannel $tier1,
private readonly SupplierProjectChannel $tier2,
private readonly Mailer $mailer,
) {}
public function createProject(SupplierProjectDto $dto): int
{
return $this->tier1->createProject($dto);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void
{
$this->tier1->updateProject($externalId, $dto);
}
public function listProjects(): array
{
return $this->tier1->listProjects();
}
/**
* Create с эскалацией: использует Project для project_id в очереди яруса 3.
*/
public function createProjectForLiderra(Project $project, SupplierProjectDto $dto): int
{
// Spec §4.4 шаг 2: портальная сверка через listProjects() до любого create.
// Защита от дубля при полу-успехе яруса 1 в прошлом запуске.
try {
$existing = $this->findOnPortal($dto);
if ($existing !== null) {
return $existing;
}
} catch (Throwable $e) {
// listProjects недоступен — продолжаем (ярус-эскалация покроет сбой),
// но провал дедупа логируем: иначе при полу-успехе яруса 1 в прошлом
// прогоне молча создастся дубль rt-проекта.
Log::warning('FailoverProjectChannel: dedup-сверка listProjects провалена', [
'platform' => $dto->platform,
'unique_key' => $dto->uniqueKey,
'error' => $e->getMessage(),
]);
}
try {
return $this->tier1->createProject($dto);
} catch (WindowDeferredException $e) {
throw $e;
} catch (SupplierTransientException $e) {
$this->escalateToTier3($project, 'create', null, $dto, 'portal_unreachable', $e);
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$id = $this->tier2->createProject($dto);
$this->alertFailoverToForm($project, 'create', $e);
return $id;
} catch (Throwable $tier2Error) {
$this->escalateToTier3(
$project, 'create', null, $dto,
$this->classifyTier2Failure($tier2Error), $tier2Error,
);
}
}
// Все ветки выше терминируют (return / throw / escalateToTier3(): never) —
// явный «unreachable»-throw не нужен (deadCode.unreachable).
}
public function updateProjectForLiderra(Project $project, int $externalId, SupplierProjectDto $dto): void
{
try {
$this->tier1->updateProject($externalId, $dto);
return;
} catch (WindowDeferredException $e) {
throw $e;
} catch (SupplierTransientException $e) {
$this->escalateToTier3($project, 'update', $externalId, $dto, 'portal_unreachable', $e);
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$this->tier2->updateProject($externalId, $dto);
$this->alertFailoverToForm($project, 'update', $e);
return;
} catch (Throwable $tier2Error) {
$this->escalateToTier3(
$project, 'update', $externalId, $dto,
$this->classifyTier2Failure($tier2Error), $tier2Error,
);
}
}
}
private function escalateToTier3(
Project $project,
string $operation,
?int $externalId,
SupplierProjectDto $dto,
string $reason,
Throwable $cause,
): never {
$row = SupplierManualSyncQueue::create([
'project_id' => $project->id,
'platform' => $dto->platform,
'operation' => $operation,
'external_id' => $externalId !== null ? (string) $externalId : null,
'payload_snapshot' => [
'signal_type' => $dto->signalType,
'unique_key' => $dto->uniqueKey,
'limit' => $dto->limit,
'workdays' => $dto->workdays,
'regions' => $dto->regions,
'regions_reverse' => $dto->regionsReverse,
'status' => $dto->status,
],
'failure_reason' => $reason,
'status' => 'pending',
'created_at' => now(),
]);
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'manual_required',
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
throw new TierEscalatedException($row->id, $reason);
}
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
{
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'failover_to_form',
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
}
/**
* Портальная сверка: ищет уже существующий проект на портале по тройке
* (platform, signal_type, unique_key). Возвращает external_id найденного
* или null. Spec §4.4 шаг 2, §7.
*/
private function findOnPortal(SupplierProjectDto $dto): ?int
{
foreach ($this->tier1->listProjects() as $row) {
if (
($row['platform'] ?? null) === $dto->platform
&& ($row['signal_type'] ?? null) === $dto->signalType
&& ($row['unique_key'] ?? null) === $dto->uniqueKey
) {
return (int) ($row['id'] ?? 0) ?: null;
}
}
return null;
}
private function classifyTier2Failure(Throwable $e): string
{
$msg = mb_strtolower($e->getMessage());
if (str_contains($msg, 'auth') || str_contains($msg, 'login')) {
return 'auth_failure';
}
if (str_contains($msg, 'selector') || str_contains($msg, 'form')) {
return 'form_selector_break';
}
return 'form_save_error';
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Channel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\PlaywrightBridge;
/**
* Ярус 2: водит форму «Мои проекты» supplier-портала через manage-project.js.
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.3
*/
final class FormProjectChannel implements SupplierProjectChannel
{
public function __construct(
private readonly PlaywrightBridge $bridge,
) {}
public function createProject(SupplierProjectDto $dto): int
{
$out = $this->callBridge('create', null, $dto);
$id = (int) ($out['external_id'] ?? 0);
if ($id === 0) {
throw new \RuntimeException('FormProjectChannel: create returned empty external_id');
}
return $id;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void
{
$out = $this->callBridge('update', $externalId, $dto);
if (($out['ok'] ?? false) !== true) {
throw new \RuntimeException('FormProjectChannel: update did not return ok=true');
}
}
public function listProjects(): array
{
$out = $this->callBridge('list', null, null);
return (array) ($out['projects'] ?? []);
}
/**
* @return array<string, mixed>
*/
private function callBridge(string $operation, ?int $externalId, ?SupplierProjectDto $dto): array
{
return $this->bridge->run([
'script' => 'manage-project.js',
'operation' => $operation,
'externalId' => $externalId,
'dto' => $dto !== null ? $this->mapDto($dto) : null,
'login' => (string) config('services.supplier.login'),
'password' => (string) config('services.supplier.password'),
'url' => (string) config('services.supplier.portal_url'),
]);
}
/**
* @return array<string, mixed>
*/
private function mapDto(SupplierProjectDto $dto): array
{
return [
'tag' => $dto->uniqueKey,
'name' => $dto->uniqueKey,
'platforms' => [$dto->platform],
'signal_type' => $dto->signalType,
'limit' => $dto->limit,
'workdays' => $dto->workdays,
'regions' => $dto->regions,
'region_mode' => $dto->regionsReverse ? 'exclude' : 'include',
'domains' => $dto->signalType === 'site' ? [$dto->uniqueKey] : [],
'active' => $dto->status === 'active',
];
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Channel;
use App\Services\Supplier\Dto\SupplierProjectDto;
/**
* Контракт миграции проекта Лидерра поставщик crm.bp-gr.ru.
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.1
*
* Реализации (ярусы резерва):
* - AjaxProjectChannel rt-project-* HTTP (primary, быстрый).
* - FormProjectChannel Playwright водит форму «Мои проекты» (fallback).
* - FailoverProjectChannel декоратор-оркестратор (ярус 1 ярус 2 ярус 3 queue).
*/
interface SupplierProjectChannel
{
/**
* Создаёт проект на портале, возвращает supplier external_id.
*/
public function createProject(SupplierProjectDto $dto): int;
/**
* Обновляет существующий проект (квота/дни/регионы).
*/
public function updateProject(int $externalId, SupplierProjectDto $dto): void;
/**
* Список проектов с портала для дедуп-сверки и закрытия яруса 3.
*
* @return array<int, array<string, mixed>>
*/
public function listProjects(): array;
}
@@ -52,4 +52,46 @@ class PlaywrightBridge
return $output;
}
/**
* Generic Node-скрипт runner: запускает playwright/<script> с JSON stdin,
* возвращает декодированный JSON stdout. Используется FormProjectChannel
* (manage-project.js ярус 2 резерва канала миграции проектов).
*
* @param array<string, mixed> $args обязательный ключ 'script'; остальное payload на stdin.
* @return array<string, mixed>
*/
public function run(array $args): array
{
$script = $args['script'] ?? null;
if (! is_string($script) || $script === '') {
throw new \InvalidArgumentException('PlaywrightBridge::run requires non-empty "script" key');
}
$payload = $args;
unset($payload['script']);
$process = $this->processFactory->create(
['node', 'playwright/'.$script],
base_path(),
);
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
$process->setTimeoutSeconds(self::TIMEOUT_SECONDS);
$process->run();
if (! $process->isSuccessful()) {
throw new \RuntimeException(
"PlaywrightBridge::run({$script}) exit code {$process->getExitCode()}: {$process->getErrorOutput()}",
);
}
$output = json_decode($process->getOutput(), true);
if (! is_array($output)) {
throw new \RuntimeException(
"PlaywrightBridge::run({$script}) returned non-array output: {$process->getOutput()}",
);
}
return $output;
}
}
+10 -13
View File
@@ -7,21 +7,19 @@ namespace App\Services\Supplier;
use Illuminate\Support\Facades\Log;
/**
* Streaming-парсер CSV-экспорта `/admin/report/index?type=49` поставщика.
* Streaming-парсер CSV-отчёта «Запрос номеров» supplier-портала crm.bp-gr.ru.
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.2
* Ожидаемые столбцы: vid;project;tag;phone;phones;time (placeholder; уточнится
* после Plan 3 Tasks 1-2 discovery с credentials поставщика).
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.1
* Столбцы: Name;Tag;Phone 3 колонки. vid и время в этом отчёте отсутствуют.
*
* Возвращает Generator вызывающий (CsvReconcileJob) сам решает, сколько
* копить в памяти. BOM + CRLF поддерживаются. Malformed rows skip + log.
* Возвращает Generator. BOM + CRLF поддерживаются. Malformed rows skip + log.
*/
final class SupplierCsvParser
{
private const EXPECTED_COLUMNS = 6;
private const EXPECTED_COLUMNS = 3;
/**
* @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
* @return iterable<int, array{project: string, tag: string, phone: string}>
*/
public function parse(string $rawCsv): iterable
{
@@ -29,7 +27,7 @@ final class SupplierCsvParser
return;
}
// Убираем BOM (UTF-8 BOM = EF BB BF)
// Убираем UTF-8 BOM (EF BB BF)
if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) {
$rawCsv = substr($rawCsv, 3);
}
@@ -65,10 +63,9 @@ final class SupplierCsvParser
}
yield [
'vid' => (string) $cols[0],
'project' => (string) $cols[1],
'phone' => (string) $cols[3],
'time' => (int) $cols[5],
'project' => (string) $cols[0],
'tag' => (string) $cols[1],
'phone' => (string) $cols[2],
];
}
}
@@ -8,7 +8,6 @@ use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Models\SupplierProject;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
@@ -21,14 +20,25 @@ use Illuminate\Support\Facades\Cache;
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
*
* Endpoints (placeholder, точные имена адаптируются после Task 1 discovery):
* - GET /admin/rt-projects-load список проектов
* - POST /admin/rt-project-save создание
* - POST /admin/rt-project-update обновление
* - POST /admin/rt-project-delete удаление
* Endpoints (verified live 2026-05-19 через Playwright MCP recon
* создан LIDPOTOK_TEST_DELETE_ME, записаны сеть-запросы, проект удалён;
* см. план Task 1 docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md):
* - GET /admin/visit/rt-projects-load?src=none массив всех rt-проектов tenant'а.
* - POST /admin/visit/rt-project-save create (id:0) ИЛИ update (id:N).
* Body: application/json, большой Vuex-state. Минимально требуемые поля
* описаны в toPayload(). Response:
* success HTTP 200 + {"status":"OK","message":"","result":null,"id":"<string>"}
* error HTTP 200 + {"status":"Error","message":"<reason>","result":null}
* ID в ответе строка (например, "12721245"); приводим к int (fits в int64).
* Один save c B1+B2+B3 (несколько включённых src*-флагов) создаёт N rt-проектов
* (по одному на каждый включённый канал); `id` в response последний из созданных.
* В нашем use case toPayload() отправляет ровно один платформенный флаг.
* - POST /admin/visit/rt-project-delete удаление по id.
* Body: application/json {"id":"<string>"}. Response: тот же конверт {status,message,result}.
*
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
* На 401/403 single retry через dispatch_sync(RefreshSupplierSessionJob).
* На HTTP 200 + status:"Error" выбрасываем SupplierClientException с message портала.
*/
class SupplierPortalClient
{
@@ -37,106 +47,202 @@ class SupplierPortalClient
) {}
/**
* Идемпотентно обеспечивает наличие supplier_project-записи для переданной
* тройки (platform, signalType, uniqueKey). Если запись уже существует
* возвращает её id. Иначе создаёт проект на стороне поставщика через
* saveProject() и сохраняет новую запись supplier_projects.
* Сырые строки rt-проектов с портала.
*
* Используется SyncSupplierProjectJob (Plan 5 Task 4).
* Verified live 2026-05-19: GET /admin/visit/rt-projects-load?src=none
* возвращает объект-конверт {projects:[...], tags, users, tokens, categories}
* НЕ голый массив. Извлекаем `projects`. Строка проекта:
* {id:string, tag, src, name:"B<n>_<key>", type:"hosts|calls|sms", lim,
* workdays, regions, regions_reverse, content, ...}.
* Приведение к контрактной форме SupplierProjectChannel в AjaxProjectChannel.
*
* В тестах метод мокируется через $this->mock(SupplierPortalClient::class)
* реальное тело не вызывается.
*
* @param string $platform B1 / B2 / B3
* @param string $signalType site / call / sms
* @param string $uniqueKey domain / phone / sender+keyword / sender
*/
public function ensureSupplierProject(string $platform, string $signalType, string $uniqueKey): int
{
$existing = SupplierProject::query()
->where('platform', $platform)
->where('signal_type', $signalType)
->where('unique_key', $uniqueKey)
->first();
if ($existing !== null) {
return $existing->id;
}
$dto = new SupplierProjectDto(
platform: $platform,
signalType: $signalType,
uniqueKey: $uniqueKey,
limit: 0,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
$externalId = $this->saveProject($dto);
$sp = SupplierProject::query()->create([
'platform' => $platform,
'signal_type' => $signalType,
'unique_key' => $uniqueKey,
'supplier_external_id' => (string) $externalId,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
]);
return $sp->id;
}
/**
* @return array<int, mixed>
* @return array<int, array<string, mixed>>
*/
public function listProjects(): array
{
$response = $this->request('GET', '/admin/rt-projects-load');
$response = $this->request('GET', '/admin/visit/rt-projects-load', ['src' => 'none']);
return $response->json() ?? [];
$body = $response->json();
$projects = is_array($body) ? ($body['projects'] ?? []) : [];
return is_array($projects) ? array_values($projects) : [];
}
public function saveProject(SupplierProjectDto $dto): int
{
$response = $this->request('POST', '/admin/rt-project-save', $this->toPayload($dto));
$response = $this->request(
'POST',
'/admin/visit/rt-project-save',
$this->toPayload($dto, externalId: 0),
asJson: true,
);
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
return (int) ($response->json('id') ?? 0);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void
{
$this->request('POST', '/admin/rt-project-update', array_merge(
['id' => $externalId],
$this->toPayload($dto)
));
$response = $this->request(
'POST',
'/admin/visit/rt-project-save',
$this->toPayload($dto, externalId: $externalId),
asJson: true,
);
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
}
public function deleteProject(int $externalId): void
{
$this->request('POST', '/admin/rt-project-delete', ['id' => $externalId]);
$response = $this->request(
'POST',
'/admin/visit/rt-project-delete',
['id' => (string) $externalId],
asJson: true,
);
$this->assertStatusOk($response, '/admin/visit/rt-project-delete');
}
/**
* GET /admin/report/index?type=49 CSV-экспорт лидов за окно [from, to].
* Auth/retry семантика наследуется от request() (PHPSESSID + X-CSRF-Token +
* 401 RefreshSession + 5xx SupplierTransientException + 4xx SupplierClientException).
*
* Возвращает raw CSV-body (UTF-8 + BOM, CRLF). Парсинг снаружи через
* SupplierCsvParser (streaming через generator).
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.1
* Portal-конверт ответа: HTTP 200 + {"status":"OK"|"Error", "message":"...", ...}.
* Текстовая бизнес-ошибка приходит с HTTP 200 HTTP-уровень обрабатывает только
* 401/403/4xx/5xx; status=Error превращаем в SupplierClientException здесь.
*/
public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string
private function assertStatusOk(Response $response, string $path): void
{
$response = $this->request('GET', '/admin/report/index', [
'type' => 49,
'from' => $from->format('Y-m-d H:i:s'),
'to' => $to->format('Y-m-d H:i:s'),
]);
$status = $response->json('status');
if ($status === 'OK') {
return;
}
if ($status === 'Error') {
$message = (string) ($response->json('message') ?? 'unknown');
throw new SupplierClientException(
"Supplier rejected {$path}: {$message}",
httpStatus: $response->status(),
responseBody: $response->body(),
);
}
// Неконвертный ответ — считаем как client-error (контракт сломан).
throw new SupplierClientException(
"Supplier returned unexpected envelope on {$path}: status={$status}",
httpStatus: $response->status(),
responseBody: $response->body(),
);
}
/**
* Заказывает у поставщика отчёт «Запрос номеров» за диапазон [from, to].
* Возвращает report_id для последующего waitReportReady / downloadReport.
*
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.3.
*
* Discovery T3 verified 2026-05-19 (Playwright MCP, см. snapshot
* `supplier-api-configured-2026-05-19.png`):
* - POST /admin/report/save-report принимает JSON {reportForm:{selectType:49},
* reportFilter:{dateFrom, dateTo, ...defaults}} и возвращает строку "OK"
* (НЕ JSON с id).
* - id извлекается отдельным GET /admin/report/load-reports это массив
* отчётов в DESC-порядке, ищем первый с title
* "Запрос номеров с {from} по {to}".
*/
public function requestNumbersReport(CarbonInterface $from, CarbonInterface $to): int
{
$this->request('POST', '/admin/report/save-report', [
'reportForm' => ['selectType' => 49],
'reportFilter' => [
'dateFrom' => $from->format('Y-m-d'),
'dateTo' => $to->format('Y-m-d'),
'slug' => null,
'rate' => 'all',
'dnss' => '',
'phones' => '',
'prophones' => 'curr',
'users' => [],
'domains' => [],
'utcs' => [],
'types' => ['phones'],
'xls' => false,
'project_id' => null,
'state_id' => 0,
'gck_tech' => 'gck',
],
], asJson: true);
$expectedTitle = sprintf(
'Запрос номеров с %s по %s',
$from->format('Y-m-d'),
$to->format('Y-m-d'),
);
$list = $this->request('GET', '/admin/report/load-reports')->json();
if (! is_array($list)) {
throw new SupplierClientException('load-reports returned non-array response');
}
foreach ($list as $row) {
if (! is_array($row)) {
continue;
}
if (($row['title'] ?? null) === $expectedTitle) {
return (int) ($row['id'] ?? 0);
}
}
throw new SupplierClientException(
"Report just queued (title '{$expectedTitle}') not found in load-reports",
);
}
/**
* Опрашивает статус отчёта до значения «Обработан» (status="1").
* На таймаут SupplierTransientException.
*
* Discovery T3 verified: status строка "0" (в обработке) / "1" (готов);
* endpoint общий GET /admin/report/load-reports (не /status?id=N).
*/
public function waitReportReady(int $reportId): void
{
$maxAttempts = 20;
$delaySeconds = 3;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$list = $this->request('GET', '/admin/report/load-reports')->json();
if (is_array($list)) {
foreach ($list as $row) {
if (! is_array($row)) {
continue;
}
if ((int) ($row['id'] ?? 0) === $reportId && (string) ($row['status'] ?? '') === '1') {
return;
}
}
}
if ($attempt < $maxAttempts) {
sleep($delaySeconds);
}
}
throw new SupplierTransientException(
"Report {$reportId} not ready after {$maxAttempts} polls"
);
}
/**
* Скачивает готовый отчёт как raw CSV-body (UTF-8 + BOM, CRLF).
* Парсинг снаружи через SupplierCsvParser.
*
* Discovery T3 verified: endpoint GET /admin/report/getfile?id=N совпадает с placeholder.
*/
public function downloadReport(int $reportId): string
{
$response = $this->request('GET', '/admin/report/getfile', ['id' => $reportId]);
return $response->body();
}
@@ -144,7 +250,7 @@ class SupplierPortalClient
/**
* @param array<string, mixed> $body
*/
private function request(string $method, string $path, array $body = [], bool $isRetry = false): Response
private function request(string $method, string $path, array $body = [], bool $isRetry = false, bool $asJson = false): Response
{
$session = $this->loadSession();
$portalUrl = (string) config('services.supplier.portal_url');
@@ -159,11 +265,14 @@ class SupplierPortalClient
$request = $this->http
->withCookies(['PHPSESSID' => $session['phpsessid']], $host)
->withHeaders(['X-CSRF-Token' => $session['csrf']])
->timeout(30);
->connectTimeout(30)
->timeout(60);
try {
if ($method === 'GET') {
$response = $request->get($url, $body);
} elseif ($asJson) {
$response = $request->asJson()->post($url, $body);
} else {
$response = $request->asForm()->post($url, $body);
}
@@ -244,23 +353,68 @@ class SupplierPortalClient
}
/**
* NOTE: payload-shape placeholder. Точные поля будут адаптированы
* после Task 1 discovery + Task 2 spec §4.4 (отдельный fixup commit
* перед Task 6 при расхождении).
* Payload-shape для /admin/visit/rt-project-save (create + update).
* Verified live 2026-05-19 (Playwright MCP recon записан реальный JSON body
* админ-формы «Добавить проект»; create=id:0, update=id:N).
*
* Mappings (наш DTO portal Vuex-state):
* - platform: B1 srcrt=true; B2 srcbl=true; B3 srcmt=true (single-true,
* остальные false). Только один платформа за save чтобы получить ровно
* один rt-проект (множественные флаги создают N проектов, мы привязываемся
* к одному external_id).
* - signalType: site type:"hosts"; call type:"calls"; sms type:"sms".
* - uniqueKey одновременно `name` (label проекта на портале портал
* префиксует "B<n>_" автоматически) и `content` (домен/телефон в полях
* сбора).
* - workdays: int[1..7] string["1".."7"] (portal принимает строки).
* - regions: int[]; regions_reverse: bool.
* - status: "active" true; "paused" false.
*
* Дополнительно отправляем `tag:"_lidpotok"` для маркировки автоматизированных
* проектов в админке портала + минимальный набор Vuex-defaults (show/depth/
* multisignals/multigroup), которые портал ожидает в state-валидаторе.
*
* @return array<string, mixed>
*/
private function toPayload(SupplierProjectDto $dto): array
private function toPayload(SupplierProjectDto $dto, int $externalId): array
{
$type = match ($dto->signalType) {
'site' => 'hosts',
'call' => 'calls',
'sms' => 'sms',
default => $dto->signalType,
};
$srcrt = $dto->platform === 'B1';
$srcbl = $dto->platform === 'B2';
$srcmt = $dto->platform === 'B3';
// workdays: int → string (portal: ["1","2",...,"7"]).
$workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);
return [
'platform' => $dto->platform,
'signal_type' => $dto->signalType,
'unique_key' => $dto->uniqueKey,
'id' => $externalId,
'tag' => '_lidpotok',
'name' => $dto->uniqueKey,
'type' => $type,
'content' => $dto->uniqueKey,
'srcrt' => $srcrt,
'srcbl' => $srcbl,
'srcmt' => $srcmt,
'srcmg' => false,
'srclal' => false,
'srcdop' => false,
'srcwz' => false,
'srcseg' => false,
'limit' => $dto->limit,
'workdays' => $dto->workdays,
'workdays' => $workdays,
'regions' => $dto->regions,
'regions_reverse' => $dto->regionsReverse ? 1 : 0,
'status' => $dto->status,
'regions_reverse' => $dto->regionsReverse,
'status' => $dto->status === 'active',
'show' => true,
'multisignals' => false,
'multigroup' => false,
'depth' => 1,
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Guard: после migrate:fresh schema.sql загружается первой (load_initial_schema).
// Если schema.sql уже отдаёт vid как nullable — миграция no-op (idempotent).
$isNullable = DB::selectOne(
"SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'supplier_leads' AND column_name = 'vid'"
);
if ($isNullable !== null && $isNullable->is_nullable === 'YES') {
return;
}
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL');
}
public function down(): void
{
// Внимание: down() не симметричен после migrate:fresh со свежей schema.sql.
// Не использовать как откат schema-bump — нужна отдельная schema-правка.
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid SET NOT NULL');
}
};
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Создаёт SaaS-level очередь яруса 3 резерва канала миграции проектов.
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
*
* Без tenant_id / RLS (как supplier_csv_reconcile_log) доступ только SaaS-admin.
*/
return new class extends Migration
{
public function up(): void
{
// Guard: после migrate:fresh schema.sql даёт таблицу первой. Idempotent.
$exists = DB::selectOne(
"SELECT to_regclass('public.supplier_manual_sync_queue') AS r"
);
if ($exists !== null && $exists->r !== null) {
return;
}
// unprepared — multi-statement (PG prepared statements не разрешают `;`).
DB::unprepared(<<<'SQL'
CREATE TABLE supplier_manual_sync_queue (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
platform VARCHAR(8) NOT NULL,
operation VARCHAR(16) NOT NULL,
external_id VARCHAR(64),
payload_snapshot JSONB NOT NULL,
failure_reason VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')),
CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')),
CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled'))
);
CREATE INDEX idx_smsq_status_created ON supplier_manual_sync_queue (status, created_at DESC);
CREATE INDEX idx_smsq_project ON supplier_manual_sync_queue (project_id);
SQL);
}
public function down(): void
{
DB::statement('DROP TABLE IF EXISTS supplier_manual_sync_queue');
}
};
+21 -3
View File
@@ -1059,7 +1059,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 13
count: 20
path: tests/Feature/DealShowTest.php
-
@@ -1077,7 +1077,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 7
count: 10
path: tests/Feature/DealShowTest.php
-
@@ -1497,7 +1497,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 8
count: 12
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
@@ -1943,3 +1943,21 @@ parameters:
identifier: argument.type
count: 3
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
-
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/CsvReconcileJobTest.php
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* Headless Playwright водит UI «Мои проекты» supplier-портала crm.bp-gr.ru.
*
* Input (JSON через stdin):
* {operation: "create"|"update"|"list", login, password, url, skipLogin?, dto?, externalId?}
*
* Output (JSON через stdout):
* - create: {external_id: "12345"}
* - update: {ok: true}
* - list: {projects: [...]}
*
* Exit codes:
* 0 — success
* 1 — auth failed
* 2 — DOM/селектор не найден (контракт UI сменился — escalation cause)
* 3 — timeout
* 4 — invalid input или другая ошибка
*
* Spec §4.3.
*/
const { chromium } = require('playwright');
const TIMEOUT_MS = 90_000;
async function login(page, args) {
// skipLogin: args.url — статическая фикстура формы (тестовый режим),
// открываем её напрямую и не логинимся.
if (args.skipLogin) {
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
return;
}
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
await page.fill('#loginform-username', args.login);
await page.fill('#loginform-password', args.password);
await Promise.all([
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
page.click('button[type=submit]'),
]);
}
async function fillForm(page, dto) {
const activeChecked = await page.locator('input[name=active]').isChecked();
if (activeChecked !== !!dto.active) await page.locator('input[name=active]').click();
if (dto.tag) await page.fill('input[name=tag]', dto.tag);
for (const p of ['B1', 'B2', 'B3']) {
const wanted = (dto.platforms || []).includes(p);
const sel = `input[name="platform[]"][value="${p}"]`;
const checked = await page.locator(sel).isChecked();
if (checked !== wanted) await page.locator(sel).click();
}
await page.fill('input[name=name]', dto.name);
const signalLabel = { site: 'Сайты', call: 'Звонки', sms: 'СМС' }[dto.signal_type] || 'Сайты';
await page.selectOption('select[name=signal_type]', { label: signalLabel });
if (dto.region_mode === 'exclude') {
await page.locator('input[name=region_mode][value=exclude]').click();
}
if (dto.domains && dto.domains.length) {
await page.fill('textarea[name=domains]', dto.domains.join('\n'));
}
await page.fill('input[name=limit]', String(dto.limit));
for (let d = 1; d <= 7; d++) {
const wanted = (dto.workdays || [1, 2, 3, 4, 5, 6, 7]).includes(d);
const sel = `input[name="workdays[]"][value="${d}"]`;
const checked = await page.locator(sel).isChecked();
if (checked !== wanted) await page.locator(sel).click();
}
}
async function createOp(page, args) {
await login(page, args);
if (!args.skipLogin) {
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
await page.click('button:has-text("Добавить проект")');
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
}
await fillForm(page, args.dto);
const beforeRows = await page.locator('#projects-table tbody tr').count();
await page.click('#save-btn');
await page.waitForFunction(
(before) => document.querySelectorAll('#projects-table tbody tr').length > before,
beforeRows,
{ timeout: TIMEOUT_MS },
);
const newRow = page.locator('#projects-table tbody tr').last();
const externalId = await newRow.getAttribute('data-id');
return { external_id: externalId };
}
async function updateOp(page, args) {
await login(page, args);
if (!args.skipLogin) {
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
}
const row = page.locator(`#projects-table tbody tr[data-id="${args.externalId}"]`);
await row.locator('button.edit').click();
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
await fillForm(page, args.dto);
await page.click('#save-btn');
await page.waitForSelector('#add-project-modal', { state: 'hidden', timeout: TIMEOUT_MS });
return { ok: true };
}
async function listOp(page, args) {
await login(page, args);
if (!args.skipLogin) {
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
}
const rows = await page.locator('#projects-table tbody tr').evaluateAll((nodes) =>
nodes.map((n) => ({
id: parseInt(n.dataset.id, 10),
name: n.querySelector('td:nth-child(2)') ? n.querySelector('td:nth-child(2)').textContent : null,
})),
);
return { projects: rows };
}
async function run(args) {
const browser = await chromium.launch({ headless: true });
try {
const ctx = await browser.newContext();
const page = await ctx.newPage();
let out;
switch (args.operation) {
case 'create': out = await createOp(page, args); break;
case 'update': out = await updateOp(page, args); break;
case 'list': out = await listOp(page, args); break;
default: throw new Error('Unknown operation: ' + args.operation);
}
process.stdout.write(JSON.stringify(out));
process.exit(0);
} catch (err) {
process.stderr.write(JSON.stringify({ error: err.message }));
if (err.message.includes('Timeout')) process.exit(3);
if (err.message.toLowerCase().includes('selector') || err.message.toLowerCase().includes('locator')) process.exit(2);
if (err.message.toLowerCase().includes('login') || err.message.toLowerCase().includes('auth')) process.exit(1);
process.exit(4);
} finally {
await browser.close();
}
}
let input = '';
process.stdin.on('data', (c) => { input += c; });
process.stdin.on('end', () => {
let args;
try { args = JSON.parse(input); }
catch (e) { process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' })); process.exit(4); }
if (!args.operation || !args.url) {
process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' }));
process.exit(4);
}
run(args);
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Фикстурный тест manage-project.js — против локального HTML, без живого портала.
*
* Runner: встроенный node:test (проект не использует @playwright/test —
* в app/playwright только playwright core). Запуск: `node --test manage-project.test.js`.
*/
const { test } = require('node:test');
const assert = require('node:assert');
const { execFile } = require('node:child_process');
const path = require('node:path');
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
const FIXTURE_URL = 'file://' + path.resolve(__dirname, '../tests/fixtures/supplier-portal/rt-add-project-form.html');
function runScript(input) {
return new Promise((resolve, reject) => {
const child = execFile('node', [SCRIPT], { timeout: 60000 }, (err, stdout, stderr) => {
if (err && err.code !== undefined && typeof err.code !== 'number') {
return reject(err);
}
resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
});
child.stdin.write(JSON.stringify(input));
child.stdin.end();
});
}
test('createProject fills form and returns row id', async () => {
const result = await runScript({
operation: 'create',
login: 'fixture-noop',
password: 'fixture-noop',
url: FIXTURE_URL,
skipLogin: true,
dto: {
tag: 'TEST',
name: 'Test Project',
platforms: ['B1', 'B2'],
signal_type: 'site',
limit: 25,
workdays: [1, 2, 3, 4, 5],
regions: [],
region_mode: 'include',
domains: ['example.com'],
active: true,
},
});
const out = JSON.parse(result.stdout);
assert.ok(out.external_id, 'external_id should be truthy');
assert.match(out.external_id, /^\d+$/, 'external_id should be numeric string');
});
test('listProjects returns array', async () => {
const result = await runScript({
operation: 'list',
login: 'fixture-noop',
password: 'fixture-noop',
url: FIXTURE_URL,
skipLogin: true,
});
const out = JSON.parse(result.stdout);
assert.ok(Array.isArray(out.projects), 'projects should be an array');
});
+3 -3
View File
@@ -27,9 +27,9 @@ async function refresh(args) {
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
// DOM-селекторы — placeholder до Task 1 discovery
const loginSelector = 'input[name=login]';
const passwordSelector = 'input[name=password]';
// DOM-селекторы crm.bp-gr.ru/login (Yii2 LoginForm) — verified live 2026-05-19 через Playwright MCP.
const loginSelector = '#loginform-username';
const passwordSelector = '#loginform-password';
const submitSelector = 'button[type=submit]';
await page.fill(loginSelector, args.login);
+3
View File
@@ -165,6 +165,9 @@ export interface ApiDeal {
comment: string | null;
city: string | null;
project_signal_type: string | null;
project_signal_identifier?: string | null;
project_sms_keyword?: string | null;
project_sms_senders?: string[] | null;
next_reminder_at: string | null;
}
@@ -10,6 +10,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent } from '../../composables/mockDealEvents';
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
import { stripChannelPrefix } from '../../composables/projectName';
import * as dealsApi from '../../api/deals';
import * as remindersApi from '../../api/reminders';
import type { ApiReminder } from '../../api/reminders';
@@ -25,7 +26,13 @@ const props = defineProps<{
tenantId?: number;
}>();
const emit = defineEmits<{ close: [] }>();
const emit = defineEmits<{
close: [];
// 18.05.2026 ux: статус меняется через inline picker в Hero.
// Эмитим slug наверх — parent (DealDetailDrawer → DealsView/KanbanView)
// делает optimistic update + API call + rollback.
'status-changed': [slug: string];
}>();
const status = computed(() => {
if (!props.deal) return null;
@@ -36,6 +43,26 @@ function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
}
// Drawer-«легенда» (18.05.2026 ux): Тип + Источник проекта (read-only).
// Редактирование — только в карточке проекта на /projects (см. план Task 5).
const TYPE_LABELS: Record<string, string> = { site: 'Сайт', call: 'Звонок', sms: 'СМС' };
const projectTypeLabel = computed((): string => {
const t = props.deal?.projectSignalType;
return t ? (TYPE_LABELS[t] ?? '—') : '—';
});
const projectSourceLabel = computed((): string => {
if (!props.deal) return '—';
const t = props.deal.projectSignalType;
if (t === 'site' || t === 'call') return props.deal.projectSignalIdentifier ?? '—';
if (t === 'sms') {
const sender = props.deal.projectSmsSenders?.[0] ?? '';
const kw = props.deal.projectSmsKeyword;
if (sender && kw) return `${sender} (${kw})`;
return sender || '—';
}
return '—';
});
const events = ref<DealEvent[]>([]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
@@ -112,6 +139,12 @@ async function loadEvents() {
}
}
function onStatusChange(slug: string): void {
if (!props.deal) return;
if (props.deal.statusSlug === slug) return;
emit('status-changed', slug);
}
async function saveComment() {
if (!props.deal || !props.tenantId) return;
commentSaving.value = true;
@@ -153,7 +186,13 @@ defineExpose({
<template>
<div v-if="deal" class="drawer-content">
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
<DealDetailHero
:deal="deal"
:status="status"
:all-statuses="leadStatusesStore.statuses"
@close="emit('close')"
@change-status="onStatusChange"
/>
<v-divider />
@@ -162,24 +201,19 @@ defineExpose({
<dl class="params">
<div class="param">
<dt class="text-caption text-medium-emphasis">Проект</dt>
<dd class="text-body-2">{{ deal.project }}</dd>
<dd class="text-body-2">{{ stripChannelPrefix(deal.project) }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
<dd class="text-body-2">
<v-avatar size="20" color="secondary" class="mr-1">
<span class="text-caption">{{ deal.manager.initials }}</span>
</v-avatar>
{{ deal.manager.name }}
</dd>
<dt class="text-caption text-medium-emphasis">Тип</dt>
<dd class="text-body-2">{{ projectTypeLabel }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Источник</dt>
<dd class="text-body-2 link">Я.Директ landing-1</dd>
<dd class="text-body-2">{{ projectSourceLabel }}</dd>
</div>
</dl>
</section>
@@ -19,7 +19,10 @@ const props = withDefaults(
{ inline: false },
);
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
const emit = defineEmits<{
'update:open': [value: boolean];
'status-changed': [slug: string];
}>();
const drawerOpen = computed({
get: () => props.open,
@@ -33,7 +36,12 @@ function close() {
<template>
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@close="close"
@status-changed="(s: string) => emit('status-changed', s)"
/>
</aside>
<v-navigation-drawer
v-else
@@ -43,7 +51,12 @@ function close() {
:width="480"
class="deal-drawer"
>
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@close="close"
@status-changed="(s: string) => emit('status-changed', s)"
/>
</v-navigation-drawer>
</template>
@@ -8,13 +8,20 @@
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
defineProps<{
deal: MockDeal;
status: LeadStatus | null;
}>();
withDefaults(
defineProps<{
deal: MockDeal;
status: LeadStatus | null;
// 18.05.2026 ux: inline status picker — кликабельный chip с выпадающим
// списком всех статусов. Если allStatuses не передан — chip read-only.
allStatuses?: LeadStatus[];
}>(),
{ allStatuses: () => [] },
);
defineEmits<{
close: [];
'change-status': [slug: string];
}>();
function formatRelative(minutes: number): string {
@@ -41,10 +48,34 @@ function formatRelative(minutes: number): string {
</div>
<div v-if="status" class="status-row mt-3">
<v-chip size="small" variant="tonal" :style="{ color: status.colorHex, borderColor: status.colorHex }">
<span class="status-dot" :style="{ background: status.colorHex }" />
{{ status.nameRu }}
</v-chip>
<v-menu :disabled="(allStatuses?.length ?? 0) === 0">
<template #activator="{ props: a }">
<v-chip
v-bind="a"
data-testid="status-chip-trigger"
size="small"
variant="tonal"
:style="{ color: status.colorHex, borderColor: status.colorHex, cursor: (allStatuses?.length ?? 0) > 0 ? 'pointer' : 'default' }"
>
<span class="status-dot" :style="{ background: status.colorHex }" />
{{ status.nameRu }}
<v-icon v-if="(allStatuses?.length ?? 0) > 0" size="14" class="ml-1">mdi-menu-down</v-icon>
</v-chip>
</template>
<v-list density="compact">
<v-list-item
v-for="s in allStatuses"
:key="s.slug"
:data-testid="`status-option-${s.slug}`"
@click="$emit('change-status', s.slug)"
>
<template #prepend>
<span class="status-dot" :style="{ background: s.colorHex }" />
</template>
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</header>
</template>
@@ -6,6 +6,7 @@
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import { stripChannelPrefix } from '../../composables/projectName';
import StatusPill from '../ui/StatusPill.vue';
const props = withDefaults(
@@ -71,7 +72,7 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
<template #[`item.project`]="{ item }: { item: MockDeal }">
<div class="cell-source">
<span class="source-project">{{ item.project }}</span>
<span class="source-project">{{ stripChannelPrefix(item.project) }}</span>
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
signalLabel(item.signalType)
}}</span>
@@ -8,6 +8,7 @@
* Click → emit('open', deal.id) — TODO: правая панель DealDetailDrawer.
*/
import type { MockDeal } from '../../composables/mockDeals';
import { stripChannelPrefix } from '../../composables/projectName';
defineProps<{ deal: MockDeal }>();
const emit = defineEmits<{ open: [id: number] }>();
@@ -27,7 +28,7 @@ function formatCost(cost: number): string {
<div class="card-name">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
<div class="card-meta mt-2">
<span class="card-project text-caption">{{ deal.project }}</span>
<span class="card-project text-caption">{{ stripChannelPrefix(deal.project) }}</span>
<span class="card-cost num">{{ formatCost(deal.cost) }}</span>
</div>
<div class="card-foot mt-1">
@@ -16,6 +16,7 @@ interface FormState {
delivery_days_mask: number;
sms_senders: string[];
sms_keyword: string;
signal_identifier: string;
}
const form = reactive<FormState>({
@@ -25,6 +26,7 @@ const form = reactive<FormState>({
delivery_days_mask: 127,
sms_senders: [],
sms_keyword: '',
signal_identifier: '',
});
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
@@ -37,6 +39,7 @@ function reseedFromProject(p: Project | null): void {
form.delivery_days_mask = p.delivery_days_mask ?? 127;
form.sms_senders = p.sms_senders ?? [];
form.sms_keyword = p.sms_keyword ?? '';
form.signal_identifier = p.signal_identifier ?? '';
}
reseedFromProject(props.project);
@@ -78,6 +81,10 @@ async function onSave(): Promise<void> {
regions: form.regions,
delivery_days_mask: form.delivery_days_mask,
};
// 18.05.2026 ux: редактирование источника проекта.
if (props.project.signal_type === 'site' || props.project.signal_type === 'call') {
payload.signal_identifier = form.signal_identifier;
}
if (props.project.signal_type === 'sms') {
payload.sms_senders = form.sms_senders;
payload.sms_keyword = form.sms_keyword;
@@ -127,6 +134,54 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
</label>
<!-- 18.05.2026 ux: редактирование источника проекта (site/call/sms) -->
<label v-if="project?.signal_type === 'site'" class="pdd-field">
<span class="pdd-label">Источник домен сайта-донора</span>
<input
v-model="form.signal_identifier"
data-testid="pdd-signal-identifier"
class="pdd-input"
placeholder="okna-konkurent.ru"
/>
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
{{ errors.signal_identifier[0] }}
</div>
</label>
<label v-else-if="project?.signal_type === 'call'" class="pdd-field">
<span class="pdd-label">Источник телефонный номер донора</span>
<input
v-model="form.signal_identifier"
data-testid="pdd-signal-identifier"
class="pdd-input"
placeholder="79161234567"
/>
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
{{ errors.signal_identifier[0] }}
</div>
</label>
<div v-else-if="project?.signal_type === 'sms'" class="pdd-field">
<span class="pdd-label">Источник отправители SMS</span>
<v-combobox
v-model="form.sms_senders"
data-testid="pdd-sms-senders"
multiple
chips
clearable
density="comfortable"
hide-details
placeholder="MTS, BEELINE …"
/>
<div v-if="errors.sms_senders" class="pdd-error">{{ errors.sms_senders[0] }}</div>
<span class="pdd-label mt-2">Ключевое слово (опционально)</span>
<input
v-model="form.sms_keyword"
data-testid="pdd-sms-keyword"
class="pdd-input"
placeholder="КРЕДИТ"
/>
<div v-if="errors.sms_keyword" class="pdd-error">{{ errors.sms_keyword[0] }}</div>
</div>
<label class="pdd-field">
<span class="pdd-label">Лимит лидов в день</span>
<input
@@ -78,5 +78,9 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
comment: api.comment,
receivedAt: api.received_at,
nextReminderAt: api.next_reminder_at,
projectSignalType: (api.project_signal_type as MockDeal['projectSignalType']) ?? null,
projectSignalIdentifier: api.project_signal_identifier ?? null,
projectSmsKeyword: api.project_sms_keyword ?? null,
projectSmsSenders: api.project_sms_senders ?? null,
};
}
@@ -22,6 +22,11 @@ export interface MockDeal {
comment?: string | null;
receivedAt?: string | null; // ISO — колонка «Поставлен»
nextReminderAt?: string | null; // ISO — колонка «Напоминание»
// Drawer-«легенда» сделки (18.05.2026): Тип + Источник проекта (read-only).
projectSignalType?: 'site' | 'call' | 'sms' | null;
projectSignalIdentifier?: string | null;
projectSmsKeyword?: string | null;
projectSmsSenders?: string[] | null;
}
export const MOCK_DEALS: MockDeal[] = [
@@ -0,0 +1,22 @@
/**
* Утилиты отображения имён проектов crm.bp.
*
* Поставщик crm.bp префиксует имена проектов признаком канала-провайдера
* (B1_/B2_/B3_ — три разных базы лидов). В UI Лидерры префикс — шум:
* пользователю интересен сам проект, а не канал.
*
* Трансформация — **display-only**: данные в БД (`supplier_projects.name`)
* не трогаем, фильтрация/поиск/маппинг идёт по сырому имени и `id`.
*/
const CHANNEL_PREFIX_RE = /^B[123]_/i;
/**
* Убирает префикс B1_/B2_/B3_ из начала имени проекта (case-insensitive).
* Префикс внутри строки и другие буквы (B0/B4/Bx) не трогает.
* null/undefined/'' -> ''.
*/
export function stripChannelPrefix(name: string | null | undefined): string {
if (!name) return '';
return name.replace(CHANNEL_PREFIX_RE, '');
}
+1
View File
@@ -32,6 +32,7 @@ const navItems: NavItem[] = [
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
];
const route = useRoute();
+12
View File
@@ -271,6 +271,18 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Admin Impersonation',
},
},
{
path: '/admin/supplier-integration',
name: 'admin-supplier-integration',
component: () => import('../views/admin/AdminSupplierIntegrationView.vue'),
meta: {
layout: 'admin',
title: 'Интеграция с поставщиком',
requiresAuth: true,
devIndex: 30,
devLabel: 'Admin Supplier Integration',
},
},
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
{
path: '/403',
+46 -1
View File
@@ -13,6 +13,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router';
import type { MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { stripChannelPrefix } from '../composables/projectName';
import { usePolling } from '../composables/usePolling';
import DealsFilters from '../components/deals/DealsFilters.vue';
import DealsBulkBar from '../components/deals/DealsBulkBar.vue';
@@ -46,6 +47,11 @@ const total = ref(0);
const loading = ref(false);
const fetchError = ref(false);
const availableProjects = ref<dealsApi.ApiProject[]>([]);
// Список для фильтра «Проект» — без префикса B1_/B2_/B3_ (display-only;
// id сохраняем, фильтрация идёт по id, не по name).
const availableProjectsForFilter = computed(() =>
availableProjects.value.map((p) => ({ ...p, name: stripChannelPrefix(p.name) })),
);
const leadStatuses = computed(() => leadStatusesStore.statuses);
const statusBySlug = computed(() => leadStatusesStore.bySlug);
@@ -114,6 +120,21 @@ watch([filterStatus, filterProject, receivedFrom, receivedTo, perPage], () => {
});
watch(page, () => void loadDeals());
// Selected-driven drawer visibility (18.05.2026 ux-request):
// 0 selected → drawer по row-click; 1 selected → авто-открыт для этой сделки;
// ≥2 selected → закрыт (показывается bulk-полоса).
watch(selected, (ids) => {
if (ids.length === 1) {
const deal = dealsState.find((d) => d.id === ids[0]);
if (deal) {
selectedDeal.value = deal;
panelOpen.value = true;
}
} else if (ids.length >= 2) {
panelOpen.value = false;
}
});
// Поиск по телефону — debounce 350 мс.
let searchTimer: ReturnType<typeof setTimeout> | undefined;
watch(searchPhone, () => {
@@ -138,6 +159,28 @@ function clearFilters() {
filterCity.value = null;
}
/**
* 18.05.2026 ux — inline status picker в drawer (DealDetailHero).
* Optimistic UI: меняем statusSlug в dealsState ДО API, rollback при ошибке.
*/
async function onDrawerStatusChanged(slug: string): Promise<void> {
if (!auth.user?.tenant_id || !selectedDeal.value) return;
const id = selectedDeal.value.id;
const target = dealsState.find((d) => d.id === id);
if (!target) return;
const prev = target.statusSlug;
if (prev === slug) return;
target.statusSlug = slug as MockDeal['statusSlug'];
try {
await dealsApi.updateDeal(id, { tenant_id: auth.user.tenant_id, status: slug });
statusToastText.value = 'Статус обновлён.';
} catch {
target.statusSlug = prev;
statusToastText.value = 'Не удалось сохранить статус.';
}
statusToastOpen.value = true;
}
async function applyBulkStatus(slug: MockDeal['statusSlug']) {
const ids = [...selected.value];
statusMenuOpen.value = false;
@@ -297,7 +340,7 @@ defineExpose({
v-model:filter-project="filterProject"
v-model:filter-city="filterCity"
:lead-statuses="leadStatuses"
:available-projects="availableProjects"
:available-projects="availableProjectsForFilter"
:available-cities="availableCities"
class="mt-4"
@clear-filters="clearFilters"
@@ -319,6 +362,7 @@ defineExpose({
</div>
<DealsBulkBar
v-if="selected.length >= 2"
v-model:status-menu-open="statusMenuOpen"
:selected-count="selected.length"
:lead-statuses="leadStatuses"
@@ -356,6 +400,7 @@ defineExpose({
:deal="selectedDeal"
:tenant-id="auth.user?.tenant_id"
@update:open="(v: boolean) => (panelOpen = v)"
@status-changed="onDrawerStatusChanged"
/>
</div>
+44 -1
View File
@@ -52,6 +52,44 @@ const dealsByStatus = reactive<Record<string, MockDeal[]>>(
}, {}),
);
/**
* 18.05.2026 ux — inline status picker в drawer (DealDetailHero).
* При смене статуса через drawer — переносим карточку между колонками
* Канбана (vuedraggable arrays) + API call + rollback.
*/
async function onDrawerStatusChanged(slug: string): Promise<void> {
if (!selectedDeal.value) return;
const deal = selectedDeal.value;
const prev = deal.statusSlug;
if (prev === slug) return;
const next = slug as MockDeal['statusSlug'];
// Optimistic: переносим карточку между колонками.
const fromCol = dealsByStatus[prev];
const toCol = dealsByStatus[next];
if (fromCol && toCol) {
const idx = fromCol.findIndex((d) => d.id === deal.id);
if (idx >= 0) fromCol.splice(idx, 1);
deal.statusSlug = next;
toCol.unshift(deal);
} else {
deal.statusSlug = next;
}
if (!auth.user?.tenant_id) return;
try {
await dealsApi.transitionDeals({ tenant_id: auth.user.tenant_id, ids: [deal.id], status: next });
} catch {
// Rollback: вернуть карточку обратно.
deal.statusSlug = prev;
if (fromCol && toCol) {
const idx = toCol.findIndex((d) => d.id === deal.id);
if (idx >= 0) toCol.splice(idx, 1);
if (!fromCol.find((d) => d.id === deal.id)) fromCol.push(deal);
}
}
}
async function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
if (!event.added) {
// 'removed' и 'moved' — vuedraggable мутирует array; reactive triggers re-render.
@@ -219,7 +257,12 @@ defineExpose({
/>
</div>
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
<DealDetailDrawer
v-model:open="drawerOpen"
:deal="selectedDeal"
:tenant-id="auth.user?.tenant_id"
@status-changed="onDrawerStatusChanged"
/>
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';
interface ReconcileRow {
started_at: string;
finished_at: string | null;
window_start: string;
window_end: string;
status: string;
total_csv_rows: number;
matched_count: number;
recovered_count: number;
drift_ratio: number;
}
interface Health {
last_run_at: string | null;
last_status: string | null;
drift_ratio: number | null;
webhook_state: string;
}
const health = ref<Health | null>(null);
const history = ref<ReconcileRow[]>([]);
const loading = ref(false);
const reconciling = ref(false);
const error = ref<string | null>(null);
async function load(): Promise<void> {
loading.value = true;
error.value = null;
try {
const { data } = await axios.get('/api/admin/supplier-integration');
health.value = data.health;
history.value = data.history;
} catch {
error.value = 'Не удалось загрузить состояние канала.';
} finally {
loading.value = false;
}
}
async function reconcileNow(): Promise<void> {
reconciling.value = true;
try {
await axios.post('/api/admin/supplier-integration/reconcile');
// Сверка асинхронная (queued job) — ждём ~4с и перезагружаем здоровье канала.
setTimeout(() => void load(), 4000);
} finally {
reconciling.value = false;
}
}
function statusColor(status: string | null): string {
if (status === 'ok') return 'success';
if (status === 'drift_alert') return 'warning';
if (status === 'failed') return 'error';
return 'grey';
}
// --- Ручная очередь (ярус 3 резерва канала миграции проектов) ---
interface ManualQueueRow {
id: number;
project_id: number;
platform: string;
operation: string;
external_id: string | null;
payload_snapshot: Record<string, unknown>;
failure_reason: string;
created_at: string;
}
const manualQueue = ref<ManualQueueRow[]>([]);
const manualQueueError = ref<string | null>(null);
const resolvingId = ref<number | null>(null);
async function loadManualQueue(): Promise<void> {
try {
const { data } = await axios.get('/api/admin/supplier-integration/manual-queue');
manualQueue.value = Array.isArray(data?.queue) ? data.queue : [];
} catch {
manualQueueError.value = 'Не удалось загрузить очередь.';
}
}
async function resolveRow(id: number): Promise<void> {
if (!confirm('Подтверждаете, что внесли изменения в crm.bp-gr.ru?')) return;
resolvingId.value = id;
try {
await axios.post(`/api/admin/supplier-integration/manual-queue/${id}/resolve`);
await loadManualQueue();
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
alert(err?.response?.data?.message ?? 'Не удалось закрыть запись.');
} finally {
resolvingId.value = null;
}
}
function formatDate(s: string): string {
return new Date(s).toLocaleString('ru-RU');
}
onMounted(() => {
void load();
void loadManualQueue();
});
</script>
<template>
<div class="pa-6">
<h1 class="text-h5 mb-4">Интеграция с поставщиком</h1>
<v-card class="mb-4">
<v-card-title>Здоровье резервного канала</v-card-title>
<v-card-text>
<v-alert v-if="error" type="error" density="compact" class="mb-4">
{{ error }}
</v-alert>
<template v-if="health">
<div class="mb-2">
Webhook:
<v-chip :color="health.webhook_state === 'live' ? 'success' : 'error'" size="small">
{{ health.webhook_state }}
</v-chip>
</div>
<div class="mb-2">
Последняя сверка:
<v-chip :color="statusColor(health.last_status)" size="small">
{{ health.last_status ?? '—' }}
</v-chip>
<span class="ml-2">{{ health.last_run_at ?? '—' }}</span>
</div>
<div class="mb-4">
Расхождение (drift):
{{ health.drift_ratio !== null ? (health.drift_ratio * 100).toFixed(2) + ' %' : '—' }}
</div>
</template>
<div v-else-if="loading" class="mb-4 text-medium-emphasis">Загрузка</div>
<v-btn
data-test="reconcile-now"
color="primary"
:loading="reconciling"
@click="reconcileNow"
>
Сверить сейчас
</v-btn>
</v-card-text>
</v-card>
<v-card>
<v-card-title>История сверок</v-card-title>
<v-table>
<thead>
<tr>
<th>Начало</th>
<th>Статус</th>
<th>Строк CSV</th>
<th>Совпало</th>
<th>Подобрано</th>
<th>Drift</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.started_at">
<td>{{ row.started_at }}</td>
<td>
<v-chip :color="statusColor(row.status)" size="x-small">{{ row.status }}</v-chip>
</td>
<td>{{ row.total_csv_rows }}</td>
<td>{{ row.matched_count }}</td>
<td>{{ row.recovered_count }}</td>
<td>{{ (row.drift_ratio * 100).toFixed(2) }} %</td>
</tr>
</tbody>
</v-table>
</v-card>
<v-card class="mt-4">
<v-card-title>
Ручная очередь
<v-chip v-if="manualQueue.length" color="warning" class="ml-2" size="small">
{{ manualQueue.length }}
</v-chip>
</v-card-title>
<v-card-text>
<v-alert v-if="manualQueueError" type="error" density="compact">
{{ manualQueueError }}
</v-alert>
<p v-else-if="!manualQueue.length" class="text-medium-emphasis">
Очередь пуста авто-фейловер не понадобился.
</p>
<v-table v-else density="compact">
<thead>
<tr>
<th>Project</th>
<th>Платформа</th>
<th>Операция</th>
<th>Параметры</th>
<th>Причина</th>
<th>Создано</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in manualQueue" :key="row.id">
<td>#{{ row.project_id }}</td>
<td>{{ row.platform }}</td>
<td>{{ row.operation }}</td>
<td>
<code>{{ row.payload_snapshot.unique_key }}</code>
· limit {{ row.payload_snapshot.limit ?? '—' }}
</td>
<td>{{ row.failure_reason }}</td>
<td>{{ formatDate(row.created_at) }}</td>
<td>
<v-btn
size="small"
color="primary"
:data-testid="`resolve-${row.id}`"
:loading="resolvingId === row.id"
@click="resolveRow(row.id)"
>
Отметить выполнено
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</div>
</template>
@@ -18,6 +18,9 @@
<v-tabs-window v-model="form.signal_type" class="mt-4">
<v-tabs-window-item value="site">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Источник домен сайта-«донора», с которого приходят лиды
</div>
<v-text-field
v-model="form.signal_identifier"
label="Домен конкурента"
@@ -28,6 +31,9 @@
/>
</v-tabs-window-item>
<v-tabs-window-item value="call">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Источник телефонный номер «донора», на который звонят клиенты
</div>
<v-text-field
v-model="form.signal_identifier"
label="Номер конкурента"
@@ -39,6 +45,9 @@
/>
</v-tabs-window-item>
<v-tabs-window-item value="sms">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Источник отправитель SMS и (опционально) ключевое слово в тексте
</div>
<v-combobox
v-model="form.sms_senders"
label="Отправители (до 11 символов каждый)"
@@ -235,6 +244,10 @@ function close() {
</script>
<style scoped>
.source-hint {
line-height: 1.4;
padding: 4px 2px;
}
.ld-input-quiet :deep(.v-field) {
border-radius: var(--radius-8);
}
+9 -5
View File
@@ -42,17 +42,21 @@ Schedule::command('partitions:create-months')
// — Cache::lock guard внутри handle, RetryFailedSupplierJobs — WHERE retried_at
// фильтр. На multi-server prod может потребовать cache_locks таблицу.
Schedule::job(new RefreshSupplierSessionJob)->hourly();
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время до
// портального дедлайна 21:00. Session refresh — на 15 мин раньше sync (17:45).
Schedule::job(new RefreshSupplierSessionJob)
->dailyAt('20:15')
->dailyAt('17:45')
->timezone('Europe/Moscow');
Schedule::job(new SyncSupplierProjectsJob)
->dailyAt('20:30')
->dailyAt('18:00')
->timezone('Europe/Moscow');
Schedule::job(new CleanupInactiveSupplierProjectsJob)
->dailyAt('02:00')
->timezone('Europe/Moscow');
Schedule::command('supplier:retry-failed')->hourly();
// Plan 4 Task 8: hourly CSV reconciliation (резерв-канал приёма лидов).
// Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
Schedule::job(new CsvReconcileJob)->hourly();
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes();
+10
View File
@@ -145,6 +145,15 @@ Route::middleware('saas-admin')->group(function () {
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
->where('id', '[0-9]+');
// Резервный CSV-канал (Путь 2): здоровье канала + ручной запуск сверки.
Route::get('/api/admin/supplier-integration', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@index');
Route::post('/api/admin/supplier-integration/reconcile', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@reconcile');
// Резерв канала миграции проектов (ярус 3): ручная очередь оператора.
Route::get('/api/admin/supplier-integration/manual-queue', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueIndex');
Route::post('/api/admin/supplier-integration/manual-queue/{id}/resolve', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueResolve')
->where('id', '[0-9]+');
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
@@ -283,6 +292,7 @@ Route::view('/admin/incidents', 'welcome');
Route::view('/admin/system', 'welcome');
Route::view('/admin/pricing-tiers', 'welcome');
Route::view('/admin/supplier-prices', 'welcome');
Route::view('/admin/supplier-integration', 'welcome');
Route::view('/admin/impersonation', 'welcome');
Route::view('/403', 'welcome');
Route::view('/500', 'welcome');
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Jobs\Supplier\CsvReconcileJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('GET /api/admin/supplier-integration returns channel health + history', function (): void {
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
'started_at' => now()->subMinutes(10),
'finished_at' => now()->subMinutes(9),
'window_start' => now()->subDay(),
'window_end' => now(),
'total_csv_rows' => 100,
'matched_count' => 98,
'recovered_count' => 2,
'drift_ratio' => 0.02,
'status' => 'ok',
'created_at' => now()->subMinutes(10),
]);
$response = $this->getJson('/api/admin/supplier-integration');
$response->assertOk();
$response->assertJsonStructure([
'health' => ['last_run_at', 'last_status', 'drift_ratio', 'webhook_state'],
'history' => [['started_at', 'status', 'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio']],
]);
expect($response->json('health.last_status'))->toBe('ok');
expect($response->json('health.webhook_state'))->toBe('live');
});
it('webhook_state is "down" when last run had drift_alert', function (): void {
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
'started_at' => now()->subMinutes(5),
'finished_at' => now()->subMinutes(4),
'window_start' => now()->subDay(),
'window_end' => now(),
'total_csv_rows' => 100,
'matched_count' => 80,
'recovered_count' => 20,
'drift_ratio' => 0.20,
'status' => 'drift_alert',
'created_at' => now()->subMinutes(5),
]);
$response = $this->getJson('/api/admin/supplier-integration');
expect($response->json('health.webhook_state'))->toBe('down');
});
it('POST /api/admin/supplier-integration/reconcile dispatches CsvReconcileJob', function (): void {
Bus::fake([CsvReconcileJob::class]);
$response = $this->postJson('/api/admin/supplier-integration/reconcile');
$response->assertOk();
$response->assertJson(['dispatched' => true]);
Bus::assertDispatched(CsvReconcileJob::class, 1);
});
it('returns nulls in health when reconcile log is empty (no run yet)', function (): void {
// Пустой supplier_csv_reconcile_log — до первой сверки. Контроллер не должен
// падать на $last === null (property access на null).
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->truncate();
$response = $this->getJson('/api/admin/supplier-integration');
$response->assertOk();
expect($response->json('health.last_run_at'))->toBeNull();
expect($response->json('health.last_status'))->toBeNull();
expect($response->json('health.drift_ratio'))->toBeNull();
expect($response->json('health.webhook_state'))->toBe('live');
expect($response->json('history'))->toBe([]);
});
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
// EnsureSaasAdmin — стаб (Sprint 3F): в testing пропускает всех без проверки
// роли. actingAs нужен только чтобы $request->user() в manualQueueResolve дал
// id для resolved_by_user_id.
it('GET /api/admin/supplier-integration/manual-queue returns pending rows', function (): void {
$this->actingAs(User::factory()->create());
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
SupplierManualSyncQueue::create([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'payload_snapshot' => ['limit' => 10],
'failure_reason' => 'contract_break',
'status' => 'pending',
]);
$r = $this->getJson('/api/admin/supplier-integration/manual-queue');
$r->assertOk()
->assertJsonStructure(['queue' => [['id', 'project_id', 'platform', 'operation', 'payload_snapshot', 'failure_reason', 'created_at']]])
->assertJsonCount(1, 'queue');
});
it('GET excludes resolved rows', function (): void {
$this->actingAs(User::factory()->create());
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
SupplierManualSyncQueue::create([
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
'payload_snapshot' => [], 'failure_reason' => 'contract_break',
'status' => 'resolved', 'resolved_at' => now(),
]);
$this->getJson('/api/admin/supplier-integration/manual-queue')
->assertOk()->assertJsonCount(0, 'queue');
});
it('POST /resolve marks row resolved when listProjects matches', function (): void {
$admin = User::factory()->create();
$this->actingAs($admin);
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$row = SupplierManualSyncQueue::create([
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
'failure_reason' => 'contract_break', 'status' => 'pending',
]);
$channelMock = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [['id' => 99999, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com']];
}
};
app()->instance(SupplierProjectChannel::class, $channelMock);
$this->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
->assertOk();
expect($row->fresh()->status)->toBe('resolved');
expect($row->fresh()->resolved_by_user_id)->toBe($admin->id);
// FK ведёт на local supplier_projects.id; portal external_id (99999) хранится
// в supplier_external_id созданной строки + в queue-row.external_id.
expect($project->fresh()->supplier_b1_project_id)->not->toBeNull();
expect(SupplierProject::find($project->fresh()->supplier_b1_project_id)->supplier_external_id)->toBe('99999');
expect($row->fresh()->external_id)->toBe('99999');
});
it('POST /resolve returns 409 when listProjects does not match', function (): void {
$this->actingAs(User::factory()->create());
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$row = SupplierManualSyncQueue::create([
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
'failure_reason' => 'contract_break', 'status' => 'pending',
]);
$channelMock = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
app()->instance(SupplierProjectChannel::class, $channelMock);
$this->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
->assertStatus(409);
expect($row->fresh()->status)->toBe('pending');
});
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
it('table supplier_manual_sync_queue exists with required columns', function (): void {
$cols = collect(DB::select(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_manual_sync_queue'"
))->pluck('column_name')->all();
expect($cols)->toContain(
'id', 'project_id', 'platform', 'operation', 'external_id',
'payload_snapshot', 'failure_reason', 'status',
'resolved_by_user_id', 'created_at', 'resolved_at',
);
});
it('platform CHECK constraint rejects non-B1/B2/B3', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
'project_id' => $project->id,
'platform' => 'B9',
'operation' => 'create',
'payload_snapshot' => json_encode([]),
'failure_reason' => 'portal_unreachable',
'status' => 'pending',
'created_at' => now(),
]))->toThrow(QueryException::class);
});
it('operation CHECK constraint rejects non-create/update', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'delete',
'payload_snapshot' => json_encode([]),
'failure_reason' => 'portal_unreachable',
'status' => 'pending',
'created_at' => now(),
]))->toThrow(QueryException::class);
});
it('status CHECK constraint rejects non-pending/resolved/cancelled', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'payload_snapshot' => json_encode([]),
'failure_reason' => 'portal_unreachable',
'status' => 'archived',
'created_at' => now(),
]))->toThrow(QueryException::class);
});
it('FK on project_id enforces referential integrity', function (): void {
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
'project_id' => 999_999_999,
'platform' => 'B1',
'operation' => 'create',
'payload_snapshot' => json_encode([]),
'failure_reason' => 'portal_unreachable',
'status' => 'pending',
'created_at' => now(),
]))->toThrow(QueryException::class);
});
it('Eloquent model SupplierManualSyncQueue creates row and casts payload_snapshot to array', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$row = SupplierManualSyncQueue::create([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'payload_snapshot' => ['limit' => 10, 'workdays' => [1, 2, 3]],
'failure_reason' => 'contract_break',
'status' => 'pending',
]);
expect($row->fresh()->payload_snapshot)->toBe(['limit' => 10, 'workdays' => [1, 2, 3]]);
});
+56
View File
@@ -162,3 +162,59 @@ test('GET /api/deals/{id} лимит 50 событий', function () {
expect($r->json('events'))->toHaveCount(50);
});
/* ---------------------------------------------------------------------
* 18.05.2026 UX-request: drawer сделки показывает «Тип» + «Источник»
* проекта. Backend отдаёт project_signal_type/identifier/sms_*.
* --------------------------------------------------------------------- */
test('GET /api/deals/{id} отдаёт project_signal_identifier/sms_keyword/sms_senders для site-проекта', function () {
$siteProject = Project::factory()->for($this->tenant)->create([
'signal_type' => 'site',
'signal_identifier' => 'krk-finance.ru',
]);
$deal = Deal::factory()->for($this->tenant)->for($siteProject)->create();
$r = $this->getJson('/api/deals/'.$deal->id);
$r->assertStatus(200);
expect($r->json('deal.project_signal_type'))->toBe('site');
expect($r->json('deal.project_signal_identifier'))->toBe('krk-finance.ru');
expect($r->json('deal.project_sms_keyword'))->toBeNull();
expect($r->json('deal.project_sms_senders'))->toBeNull();
});
test('GET /api/deals/{id} отдаёт sms_senders/sms_keyword для sms-проекта', function () {
$smsProject = Project::factory()->for($this->tenant)->create([
'signal_type' => 'sms',
'signal_identifier' => 'MTS',
'sms_senders' => ['MTS', 'BEELINE'],
'sms_keyword' => 'КРЕДИТ',
]);
$deal = Deal::factory()->for($this->tenant)->for($smsProject)->create();
$r = $this->getJson('/api/deals/'.$deal->id);
$r->assertStatus(200);
expect($r->json('deal.project_signal_type'))->toBe('sms');
expect($r->json('deal.project_sms_senders'))->toBe(['MTS', 'BEELINE']);
expect($r->json('deal.project_sms_keyword'))->toBe('КРЕДИТ');
});
test('GET /api/deals отдаёт те же поля в index payload', function () {
$smsProject = Project::factory()->for($this->tenant)->create([
'signal_type' => 'sms',
'signal_identifier' => 'MTS',
'sms_senders' => ['MTS'],
'sms_keyword' => 'КРЕДИТ',
]);
Deal::factory()->for($this->tenant)->for($smsProject)->create();
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r->assertStatus(200);
expect($r->json('deals.0.project_signal_type'))->toBe('sms');
expect($r->json('deals.0.project_signal_identifier'))->toBe('MTS');
expect($r->json('deals.0.project_sms_senders'))->toBe(['MTS']);
expect($r->json('deals.0.project_sms_keyword'))->toBe('КРЕДИТ');
});
@@ -392,6 +392,51 @@ it('handles partial failure: one project throws, others continue routing', funct
expect($tenants[2]->fresh()->balance_leads)->toBe(99);
});
it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void {
// Регрессия 18.05.2026: поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный
// текст со встроенным URL/доменом ('B1_заявка carmoney.ru/'). Старый parseProjectField
// c anchored-regex '^[a-z0-9-]+(\.[a-z0-9-]+)+$' такой rest не матчил → классифицировал
// как 'sms' → B1+sms → DomainException → 21 реальный лид застрял с error, 0 сделок.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => $domain,
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => $domain,
'is_active' => true,
]);
$vid = random_int(100000, 999999);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => $projectField,
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->supplier_project_id)->toBe($supplier->id);
expect($lead->deals_created_count)->toBe(1);
})->with([
'carmoney embedded in free text' => ['B1_заявка carmoney.ru/', 'carmoney.ru'],
'caranga subdomain with path' => ['B1_Платежи cabinet.caranga.ru/login', 'cabinet.caranga.ru'],
'krk-finance with auth path' => ['B1_krk-finance.ru/cabinet/auth', 'krk-finance.ru'],
]);
it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2 race recheck)', function (): void {
// BLOCKER #2 (CV.11 audit): matchEligibleProjects делает SELECT delivered_today < limit
// БЕЗ lockForUpdate. Между snapshot SELECT и createDealCopyForProject (которое
@@ -59,25 +59,26 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.22 has correct metrics — 63 base tables, 119 indexes, 40 RLS policies', function () {
it('schema.sql v8.25 has correct metrics — 64 base tables, 121 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.22.
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.25.
// 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 индекса.
$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();
// 63 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
// 64 base tables = все CREATE TABLE минус 12 партиций (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(63);
expect($baseTables)->toBe(64);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(119); // v8.22 (Plan 6/C9): +1 GIN idx_projects_regions
expect($createIndexes)->toBe(121); // v8.25: +2 idx_smsq_status_created, idx_smsq_project
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(40);
@@ -6,7 +6,7 @@ use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
@@ -14,17 +14,17 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
/**
* Хелпер: разрешает мок SupplierPortalClient из контейнера и вызывает Job.handle().
* Нельзя использовать (new Job)->handle() без аргументов handle() требует DI-инъекцию
* SupplierPortalClient; прямой вызов без аргументов обходит контейнер и мок не применяется.
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
* Mock SupplierProjectChannel НЕ instanceof FailoverProjectChannel job идёт
* по ветке createProject() (без эскалации) это и тестируем здесь.
* Failover-эскалация покрыта FailoverProjectChannelTest.
*/
function dispatchJobSync(SyncSupplierProjectJob $job): void
{
$client = app(SupplierPortalClient::class);
$job->handle($client);
$job->handle(app(SupplierProjectChannel::class));
}
it('site project: links B1+B2+B3 supplier_projects and sets all three IDs', function () {
it('site project: creates B1+B2+B3 supplier_projects and sets all three IDs', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
@@ -32,15 +32,9 @@ it('site project: links B1+B2+B3 supplier_projects and sets all three IDs', func
'signal_identifier' => 'okna.ru',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->times(3)
->andReturnUsing(fn (string $platform, string $signalType, string $key) => SupplierProject::factory()->create([
'platform' => $platform, // uppercase: B1, B2, B3
'signal_type' => $signalType,
'unique_key' => $key,
'sync_status' => 'ok',
])->id
);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(700001, 700002, 700003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
@@ -49,21 +43,19 @@ it('site project: links B1+B2+B3 supplier_projects and sets all three IDs', func
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
// FK ведёт на local supplier_projects.id, не на portal external_id.
expect(SupplierProject::find($project->supplier_b1_project_id)->supplier_external_id)->toBe('700001');
});
it('call project: links B1+B2+B3 with phone signal_identifier', function () {
it('call project: creates B1+B2+B3 with phone signal_identifier', function () {
$project = Project::factory()->create([
'signal_type' => 'call',
'signal_identifier' => '79161234567',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->times(3)
->andReturn(SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'call',
'sync_status' => 'ok',
])->id);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(800001, 800002, 800003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
@@ -73,21 +65,16 @@ it('call project: links B1+B2+B3 with phone signal_identifier', function () {
expect($project->fresh()->supplier_b3_project_id)->not->toBeNull();
});
it('sms project with keyword: links B2+B3 only (no B1)', function () {
it('sms project with keyword: creates B2+B3 only (no B1)', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => 'ипотека',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->times(2)
->andReturnUsing(fn (string $platform) => SupplierProject::factory()->create([
'platform' => $platform, // B2 or B3 — both pass CHECK constraint
'signal_type' => 'sms',
'sync_status' => 'ok',
])->id
);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(2)
->andReturn(900001, 900002);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
@@ -98,20 +85,16 @@ it('sms project with keyword: links B2+B3 only (no B1)', function () {
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('sms project without keyword: links B3 only', function () {
it('sms project without keyword: creates B3 only', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => null,
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->once()
->andReturn(SupplierProject::factory()->create([
'platform' => 'B3',
'signal_type' => 'sms',
'sync_status' => 'ok',
])->id);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->once()
->andReturn(910001);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
@@ -122,14 +105,14 @@ it('sms project without keyword: links B3 only', function () {
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('portal exception: re-throws for queue retry', function () {
it('channel exception: re-throws for queue retry', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')
->andThrow(new RuntimeException('timeout'));
});
@@ -137,16 +120,13 @@ it('portal exception: re-throws for queue retry', function () {
->toThrow(RuntimeException::class);
});
it('partial success: B1=ok, B2=failed (pre-created row), B3=ok — all three IDs written', function () {
it('idempotency: pre-existing supplier_project row is reused, channel not called for it', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
// Pre-create a supplier_project row for B2 with sync_status='failed' —
// the mock returns its ID to simulate a failed B2 sync.
// NOTE: supplier_projects has NO last_error column (schema v8.19);
// "failed" status alone is the observable signal.
// B2 уже существует локально (например, от прошлого частичного запуска).
$spB2 = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
@@ -154,16 +134,16 @@ it('partial success: B1=ok, B2=failed (pre-created row), B3=ok — all three IDs
'sync_status' => 'failed',
]);
$this->mock(SupplierPortalClient::class, function ($mock) use ($spB2) {
$spB1 = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'sync_status' => 'ok'])->id;
$spB3 = SupplierProject::factory()->create(['platform' => 'B3', 'signal_type' => 'site', 'sync_status' => 'ok'])->id;
$mock->shouldReceive('ensureSupplierProject')->andReturn($spB1, $spB2->id, $spB3);
// Channel дёргается только для B1 и B3 — B2 берётся из существующей строки.
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(2)
->andReturn(700001, 700003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->toBe($spB2->id);
expect(SupplierProject::find($project->supplier_b2_project_id)->sync_status)->toBe('failed');
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
@@ -125,3 +125,61 @@ it('preserves regions when PATCH omits the field (sometimes rule)', function ()
$response->assertStatus(200);
expect($project->fresh()->regions)->toBe([82, 83]);
});
/* ---------------------------------------------------------------------
* 18.05.2026 UX-request (Task 5 плана): редактирование источника
* (signal_identifier для site/call) Sync поставщику обязателен.
* --------------------------------------------------------------------- */
it('updates signal_identifier for site project + triggers resync', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'old.ru',
]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'signal_identifier' => 'new-source.ru',
])->assertOk();
expect($project->fresh()->signal_identifier)->toBe('new-source.ru');
Queue::assertPushed(SyncSupplierProjectJob::class);
});
it('updates signal_identifier for call project (11-digit phone)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => '79991111111',
]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'signal_identifier' => '79992222222',
])->assertOk();
expect($project->fresh()->signal_identifier)->toBe('79992222222');
});
it('rejects invalid signal_identifier for site (not a domain)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'ok.ru',
]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'signal_identifier' => 'not-a-domain',
])->assertStatus(422)->assertJsonValidationErrors(['signal_identifier']);
});
it('rejects invalid signal_identifier for call (not 7\d{10})', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => '79991111111',
]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'signal_identifier' => '12345',
])->assertStatus(422)->assertJsonValidationErrors(['signal_identifier']);
});
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use Illuminate\Console\Scheduling\Schedule;
/*
* Крон supplier-sync переехал 20:30 18:00 МСК (Task 9, spec §4.7)
* запас ~3 часа до портального дедлайна 21:00 на эскалацию ярус 2/3.
* Session refresh на 15 мин раньше sync (17:45).
*/
it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
$schedule = app(Schedule::class);
$events = collect($schedule->events());
$sync = $events->first(fn ($e) => str_contains((string) $e->description, SyncSupplierProjectsJob::class)
|| str_contains((string) $e->command, 'SyncSupplierProjectsJob'));
expect($sync)->not->toBeNull();
expect($sync->expression)->toBe('0 18 * * *');
expect($sync->timezone)->toBe('Europe/Moscow');
});
it('Daily RefreshSupplierSessionJob is scheduled at 17:45 MSK', function (): void {
$schedule = app(Schedule::class);
$events = collect($schedule->events());
$daily = $events->first(fn ($e) => (str_contains((string) $e->description, RefreshSupplierSessionJob::class)
|| str_contains((string) $e->command, 'RefreshSupplierSessionJob'))
&& $e->expression === '45 17 * * *');
expect($daily)->not->toBeNull();
expect($daily->timezone)->toBe('Europe/Moscow');
});
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\Channel\AjaxProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/*
* AjaxProjectChannel (Tier 1) тонкий адаптер над SupplierPortalClient.
*
* Контракт rt-project-* верифицирован Task 1 (см. SupplierPortalClientRtProjectTest);
* здесь проверяем только что адаптер прозрачно делегирует на правильный endpoint.
*/
beforeEach(function (): void {
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'test', 'csrf' => 'test',
], now()->addHour());
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});
it('AjaxProjectChannel implements SupplierProjectChannel', function (): void {
expect(app(AjaxProjectChannel::class))->toBeInstanceOf(SupplierProjectChannel::class);
});
it('createProject delegates to SupplierPortalClient::saveProject and returns external id', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700777'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'foo.com',
limit: 5,
workdays: [1, 2, 3],
regions: [],
regionsReverse: false,
status: 'active',
);
$id = app(AjaxProjectChannel::class)->createProject($dto);
expect($id)->toBe(700777);
});
it('updateProject delegates to SupplierPortalClient::updateProject with id:N', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700777'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'foo.com',
limit: 10,
workdays: [1],
regions: [],
regionsReverse: false,
status: 'active',
);
app(AjaxProjectChannel::class)->updateProject(700777, $dto);
Http::assertSent(fn ($r) => $r['id'] === 700777);
});
it('listProjects normalizes raw rt-rows to channel contract (platform/signal_type/unique_key)', function (): void {
// Сырая форма портала (verified 2026-05-19): конверт {projects:[...]},
// строка {id, name:"B<n>_<key>", type, content}. Адаптер маппит в контракт.
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response([
'projects' => [
['id' => '700001', 'name' => 'B1_okna.ru', 'type' => 'hosts', 'content' => 'okna.ru'],
['id' => '700002', 'name' => 'B3_79991112233', 'type' => 'calls', 'content' => '79991112233'],
['id' => '700003', 'name' => 'noPrefix', 'type' => 'sms', 'content' => 'KEYWORD'],
],
], 200),
]);
$list = app(AjaxProjectChannel::class)->listProjects();
expect($list)->toHaveCount(3);
expect($list[0]['platform'])->toBe('B1');
expect($list[0]['signal_type'])->toBe('site');
expect($list[0]['unique_key'])->toBe('okna.ru');
expect($list[0]['id'])->toBe('700001'); // сырое поле сохранено
expect($list[1]['platform'])->toBe('B3');
expect($list[1]['signal_type'])->toBe('call');
expect($list[1]['unique_key'])->toBe('79991112233');
// name без B<n>_ префикса → platform null (контракт не ломается)
expect($list[2]['platform'])->toBeNull();
expect($list[2]['signal_type'])->toBe('sms');
expect($list[2]['unique_key'])->toBe('KEYWORD');
});
@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
uses(DatabaseTransactions::class);
function makeDto(): SupplierProjectDto
{
return new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
);
}
function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $tier2 = null): FailoverProjectChannel
{
return new FailoverProjectChannel(
$tier1,
$tier2 ?? new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new RuntimeException('tier2 not configured');
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
},
app(Mailer::class),
);
}
beforeEach(function (): void {
Mail::fake();
});
it('createProject — Tier 1 success: returns id, no queue, no alert', function (): void {
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 700123;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$id = makeFailover($tier1)->createProject(makeDto());
expect($id)->toBe(700123);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertNothingQueued();
});
it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3 with portal_unreachable', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create(['signal_type' => 'site', 'signal_identifier' => 'foo.com']);
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierTransientException('5xx exhausted', httpStatus: 503);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2Called = false;
$tier2 = new class($tier2Called) implements SupplierProjectChannel
{
public function __construct(public bool &$called) {}
public function createProject(SupplierProjectDto $dto): int
{
$this->called = true;
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void
{
$this->called = true;
}
public function listProjects(): array
{
$this->called = true;
return [];
}
};
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
->toThrow(TierEscalatedException::class);
expect($tier2Called)->toBeFalse();
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierClientException('4xx contract break', httpStatus: 400);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 800001;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
expect($id)->toBe(800001);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
});
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierClientException('4xx', httpStatus: 400);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new RuntimeException('form_selector_break');
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
->toThrow(TierEscalatedException::class);
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierAuthException('sticky 401', httpStatus: 401);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 900042;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
expect($id)->toBe(900042);
});
it('createProject — WindowDeferred: no queue, no escalation, op rescheduled (re-throws WindowDeferred)', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new WindowDeferredException('portal returned 22:00-00:00 window-block');
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
expect(fn () => makeFailover($tier1)->createProjectForLiderra($project, makeDto()))
->toThrow(WindowDeferredException::class);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertNothingQueued();
});
it('createProject — portal already has project (listProjects match): adopts external_id, skips create', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1CreateCalled = false;
$tier1 = new class($tier1CreateCalled) implements SupplierProjectChannel
{
public function __construct(public bool &$createCalled) {}
public function createProject(SupplierProjectDto $dto): int
{
$this->createCalled = true;
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [
['id' => 555555, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com'],
];
}
};
$id = makeFailover($tier1)->createProjectForLiderra($project, makeDto());
expect($id)->toBe(555555);
expect($tier1CreateCalled)->toBeFalse();
});
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\Channel\FormProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\PlaywrightBridge;
/*
* FormProjectChannel (Tier 2) PHP wrapper над manage-project.js.
*
* PlaywrightBridge подменяется stub-ом (extends PlaywrightBridge с пустым
* конструктором реальный требует ProcessFactory; run() переопределён целиком,
* processFactory не трогается).
*/
function bridgeStub(callable $onRun): PlaywrightBridge
{
return new class($onRun) extends PlaywrightBridge
{
/** @var array<string, mixed>|null */
public ?array $lastArgs = null;
/** @var callable */
private $onRun;
public function __construct(callable $onRun)
{
$this->onRun = $onRun;
}
public function run(array $args): array
{
$this->lastArgs = $args;
return ($this->onRun)($args);
}
};
}
it('FormProjectChannel implements SupplierProjectChannel', function (): void {
app()->instance(PlaywrightBridge::class, bridgeStub(fn () => []));
expect(app(FormProjectChannel::class))->toBeInstanceOf(SupplierProjectChannel::class);
});
it('createProject calls PlaywrightBridge with operation=create and returns external_id', function (): void {
$stub = bridgeStub(fn () => ['external_id' => '12345']);
app()->instance(PlaywrightBridge::class, $stub);
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
);
$id = app(FormProjectChannel::class)->createProject($dto);
expect($id)->toBe(12345);
expect($stub->lastArgs['operation'])->toBe('create');
expect($stub->lastArgs['script'])->toBe('manage-project.js');
expect($stub->lastArgs['dto']['name'])->toBe('foo.com');
expect($stub->lastArgs['dto']['platforms'])->toBe(['B1']);
});
it('updateProject calls bridge with operation=update and externalId', function (): void {
$stub = bridgeStub(fn () => ['ok' => true]);
app()->instance(PlaywrightBridge::class, $stub);
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
limit: 20, workdays: [1], regions: [], regionsReverse: false, status: 'active',
);
app(FormProjectChannel::class)->updateProject(700123, $dto);
expect($stub->lastArgs['operation'])->toBe('update');
expect($stub->lastArgs['externalId'])->toBe(700123);
});
it('listProjects calls bridge with operation=list and returns array', function (): void {
$stub = bridgeStub(fn () => ['projects' => [['id' => 1, 'name' => 'A']]]);
app()->instance(PlaywrightBridge::class, $stub);
$list = app(FormProjectChannel::class)->listProjects();
expect($list)->toBe([['id' => 1, 'name' => 'A']]);
expect($stub->lastArgs['operation'])->toBe('list');
});
it('createProject throws when bridge returns empty external_id', function (): void {
app()->instance(PlaywrightBridge::class, bridgeStub(fn () => ['external_id' => '0']));
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
limit: 10, workdays: [1], regions: [], regionsReverse: false, status: 'active',
);
expect(fn () => app(FormProjectChannel::class)->createProject($dto))
->toThrow(RuntimeException::class, 'empty external_id');
});
@@ -76,7 +76,10 @@ test('phase C deletes supplier_project after 180 days inactive and writes audit
]);
Http::fake([
'crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200),
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null],
200,
),
]);
(new CleanupInactiveSupplierProjectsJob)->handle();
@@ -128,7 +131,7 @@ test('handles 404 from supplier as already-deleted: local delete + audit row wit
]);
Http::fake([
'crm.bp-gr.ru/admin/rt-project-delete' => Http::response('not found', 404),
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response('not found', 404),
]);
(new CleanupInactiveSupplierProjectsJob)->handle();
+134 -129
View File
@@ -8,12 +8,11 @@ use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Mail\CsvDriftAlertMail;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -23,20 +22,6 @@ use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Hard re-puts `supplier:session` immediately before the SUT reads it.
*
* Parallel-test race fix: other Supplier tests (Sync*, Cleanup*) call
* `Cache::store('redis')->forget('supplier:session')` in their afterEach.
* In `--parallel` mode all workers share Redis DB+prefix, so a concurrent
* afterEach can wipe our session between beforeEach `put` and the SUT call,
* triggering PlaywrightBridge auto-refresh (which has no credentials).
*
* Calling this immediately before the job dispatches the HTTP request
* minimizes the race window. The test still tolerates the rare case where
* another test's afterEach runs between this put and the SUT's read but
* empirically the window is too small for that to fire.
*/
function putSupplierSession(): void
{
Cache::store('redis')->put(
@@ -46,13 +31,9 @@ function putSupplierSession(): void
);
}
beforeEach(function () {
beforeEach(function (): void {
Mail::fake();
// Partial fake: only RouteSupplierLeadJob is intercepted (what we assert on).
// RefreshSupplierSessionJob must NOT be faked — it must run our mock below
// so that loadSession() can recover if a concurrent afterEach wipes the session.
Bus::fake([RouteSupplierLeadJob::class]);
// Bind a mock that re-puts the session when dispatch_sync triggers it during a race.
app()->bind(RefreshSupplierSessionJob::class, fn () => new class
{
public function handle(): void
@@ -60,63 +41,92 @@ beforeEach(function () {
putSupplierSession();
}
});
// NB: NOT Cache::store('redis')->flush() — flush wipes session keys belonging to
// OTHER parallel tests (cross-pollution). Just forget our reserved keys + re-put.
Cache::store('redis')->forget('supplier:csv_reconcile');
putSupplierSession();
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
config(['services.supplier.alert_email' => 'ops@liderra.ru']);
});
afterEach(function () {
afterEach(function (): void {
Cache::store('redis')->forget('supplier:csv_reconcile');
});
/**
* 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
*
* @param array<int, array{project: string, phone: string}> $rows
*/
function csvBody(array $rows): string
{
$out = "vid;project;tag;phone;phones;time\n";
$out = "Name;Tag;Phone\n";
foreach ($rows as $r) {
$out .= "{$r['vid']};{$r['project']};;{$r['phone']};{$r['phone']};{$r['time']}\n";
$out .= "{$r['project']};tag;{$r['phone']}\n";
}
return $out;
}
function makeSupplierProject(): SupplierProject
/**
* Мокает весь async-флоу отчёта (реальные endpoint'ы discovery T3 2026-05-19):
* POST /admin/report/save-report "OK"
* GET /admin/report/load-reports array [{id, title, status:"1", ...}] (id извлекается по title-match)
* GET /admin/report/getfile?id=N raw CSV
*
* Title включает фактически использованные dateFrom/dateTo захватываем их из save-report body
* и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся.
*/
function fakeReportFlow(string $csv): void
{
return SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com',
$captured = ['from' => '', 'to' => ''];
Http::fake([
'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) {
$body = $r->data();
$captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? '');
$captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? '');
return Http::response('OK', 200);
},
'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) {
$title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']);
return Http::response([
['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'],
], 200);
},
'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200),
]);
}
it('matches existing leads, no missing — status=ok, no alert', function () {
$sp = makeSupplierProject();
$now = time();
$vids = [];
for ($i = 0; $i < 10; $i++) {
$vid = (int) ('11100000'.$i); // numeric vid because BIGINT
$vids[] = $vid;
SupplierLead::factory()->create([
'vid' => $vid,
'phone' => '79991234567',
'supplier_project_id' => $sp->id,
'received_at' => now()->subHour(),
]);
}
$rows = [];
foreach ($vids as $vid) {
$rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600];
}
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);
putSupplierSession();
function runCsvReconcile(): void
{
app(CsvReconcileJob::class)->handle(
app(SupplierPortalClient::class),
app(SupplierCsvParser::class),
app(Mailer::class),
);
}
it('no missing leads — status=ok, no recovery, no alert', function (): void {
for ($i = 0; $i < 10; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => "7999000000{$i}",
'vid' => 800000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
for ($i = 0; $i < 10; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
@@ -128,77 +138,61 @@ it('matches existing leads, no missing — status=ok, no alert', function () {
Bus::assertNothingDispatched();
});
it('drift 10% (1 missing of 10) → alert email + 1 RouteJob dispatched', function () {
$sp = makeSupplierProject();
$now = time();
$vids = [];
for ($i = 0; $i < 10; $i++) {
$vids[] = (int) ('22200000'.$i);
}
// Existing 9 of 10
it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
for ($i = 0; $i < 9; $i++) {
SupplierLead::factory()->create([
'vid' => $vids[$i],
'phone' => '79991234567',
'supplier_project_id' => $sp->id,
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => "7999111000{$i}",
'vid' => 810000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
foreach ($vids as $vid) {
$rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600];
for ($i = 0; $i < 10; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
}
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);
fakeReportFlow(csvBody($rows));
putSupplierSession();
app(CsvReconcileJob::class)->handle(
app(SupplierPortalClient::class),
app(SupplierCsvParser::class),
app(Mailer::class),
);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('drift_alert');
expect((float) $log->drift_ratio)->toBeGreaterThan(0.05);
expect((int) $log->recovered_count)->toBe(1);
$recovered = SupplierLead::where('source', 'csv_recovery')->first();
expect($recovered)->not->toBeNull();
expect($recovered->vid)->toBeNull();
expect($recovered->recovered_from_csv_at)->not->toBeNull();
Mail::assertSent(CsvDriftAlertMail::class, 1);
Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
});
it('drift 1% (1 missing of 100) → status=ok, no alert', function () {
$sp = makeSupplierProject();
$now = time();
$vids = [];
for ($i = 0; $i < 100; $i++) {
$vids[] = (int) ('33300'.str_pad((string) $i, 4, '0', STR_PAD_LEFT));
}
it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
for ($i = 0; $i < 99; $i++) {
SupplierLead::factory()->create([
'vid' => $vids[$i],
'phone' => '79991234567',
'supplier_project_id' => $sp->id,
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 820000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
foreach ($vids as $vid) {
$rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600];
for ($i = 0; $i < 100; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);
fakeReportFlow(csvBody($rows));
putSupplierSession();
app(CsvReconcileJob::class)->handle(
app(SupplierPortalClient::class),
app(SupplierCsvParser::class),
app(Mailer::class),
);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
@@ -206,49 +200,60 @@ it('drift 1% (1 missing of 100) → status=ok, no alert', function () {
Mail::assertNothingSent();
});
it('empty CSV → status=ok, drift=0, no alert', function () {
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response("vid;project;tag;phone;phones;time\n", 200)]);
it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79995550000',
'vid' => 830000,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
putSupplierSession();
app(CsvReconcileJob::class)->handle(
app(SupplierPortalClient::class),
app(SupplierCsvParser::class),
app(Mailer::class),
);
fakeReportFlow(csvBody([
['project' => 'B1_a.com', 'phone' => '79995550000'],
['project' => 'B2_b.com', 'phone' => '79995550000'],
]));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->matched_count)->toBe(1);
expect((int) $log->recovered_count)->toBe(1);
});
it('empty CSV — status=ok, drift=0', function (): void {
fakeReportFlow("Name;Tag;Phone\n");
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
expect((int) $log->total_csv_rows)->toBe(0);
});
it('SupplierTransientException → status=failed, error_message recorded', function () {
it('overlap lock held — job skips, no log row', function (): void {
$countBefore = DB::table('supplier_csv_reconcile_log')->count();
$lock = Cache::store('redis')->lock('supplier:csv_reconcile', 600);
$lock->get();
try {
runCsvReconcile();
} finally {
$lock->release();
}
expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore);
});
it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void {
Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]);
putSupplierSession();
expect(fn () => app(CsvReconcileJob::class)->handle(
app(SupplierPortalClient::class),
app(SupplierCsvParser::class),
app(Mailer::class),
)
)->toThrow(SupplierTransientException::class);
expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('failed');
expect($log->error_message)->toContain('500');
});
it('Schedule entry: hourly cron registered', function () {
/** @var Schedule $schedule */
$schedule = app(Schedule::class);
$events = $schedule->events();
$hasCsv = collect($events)->contains(function ($event) {
$repr = (string) ($event->description ?? '');
if (property_exists($event, 'job')) {
$repr .= ' '.((string) $event->job);
}
return str_contains($repr, 'CsvReconcileJob');
});
expect($hasCsv)->toBeTrue();
});
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use Illuminate\Console\Scheduling\Schedule;
it('CsvReconcileJob is scheduled every 30 minutes', function (): void {
/** @var Schedule $schedule */
$schedule = app(Schedule::class);
$csvEvent = collect($schedule->events())->first(function ($event): bool {
$repr = (string) ($event->description ?? '');
if (property_exists($event, 'job')) {
$repr .= ' '.((string) $event->job);
}
return str_contains($repr, 'CsvReconcileJob');
});
expect($csvEvent)->not->toBeNull();
// Laravel everyThirtyMinutes() → cron-выражение '*/30 * * * *'.
expect($csvEvent->expression)->toBe('*/30 * * * *');
});
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\SupplierCsvParser;
function rowsOf(iterable $gen): array
{
$out = [];
foreach ($gen as $row) {
$out[] = $row;
}
return $out;
}
it('parses 3-column Name;Tag;Phone CSV', function (): void {
$csv = "Name;Tag;Phone\nB1_a.com;tagA;79991234567\nB2_79990001122;tagB;79993334455\n";
$rows = rowsOf((new SupplierCsvParser)->parse($csv));
expect($rows)->toHaveCount(2);
expect($rows[0])->toBe(['project' => 'B1_a.com', 'tag' => 'tagA', 'phone' => '79991234567']);
expect($rows[1])->toBe(['project' => 'B2_79990001122', 'tag' => 'tagB', 'phone' => '79993334455']);
});
it('strips UTF-8 BOM and normalizes CRLF', function (): void {
$csv = "\xEF\xBB\xBFName;Tag;Phone\r\nB1_a.com;t;79991234567\r\n";
$rows = rowsOf((new SupplierCsvParser)->parse($csv));
expect($rows)->toHaveCount(1);
expect($rows[0]['project'])->toBe('B1_a.com');
});
it('skips malformed rows with fewer than 3 columns', function (): void {
$csv = "Name;Tag;Phone\nB1_a.com;t;79991234567\nbroken;row\nB2_b.com;t2;79990000000\n";
$rows = rowsOf((new SupplierCsvParser)->parse($csv));
expect($rows)->toHaveCount(2);
expect($rows[1]['project'])->toBe('B2_b.com');
});
it('returns nothing for empty CSV', function (): void {
expect(rowsOf((new SupplierCsvParser)->parse('')))->toBe([]);
});
it('returns nothing for header-only CSV', function (): void {
expect(rowsOf((new SupplierCsvParser)->parse("Name;Tag;Phone\n")))->toBe([]);
});
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('allows supplier_leads with vid=NULL (CSV-recovered leads)', function (): void {
$lead = SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79991234567',
'vid' => null,
'raw_payload' => ['project' => 'B1_a.com', 'tag' => 't', 'phone' => '79991234567'],
'received_at' => now(),
'source' => 'csv_recovery',
'recovered_from_csv_at' => now(),
]);
expect($lead->id)->toBeGreaterThan(0);
expect($lead->fresh()->vid)->toBeNull();
});
it('allows multiple supplier_leads with vid=NULL under the UNIQUE index', function (): void {
foreach (['79990000001', '79990000002', '79990000003'] as $phone) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => $phone,
'vid' => null,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => $phone],
'received_at' => now(),
'source' => 'csv_recovery',
'recovered_from_csv_at' => now(),
]);
}
expect(SupplierLead::whereNull('vid')->count())->toBeGreaterThanOrEqual(3);
});
it('still accepts a real numeric vid for webhook leads', function (): void {
$lead = SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79991234567',
'vid' => 432176649,
'raw_payload' => ['vid' => 432176649, 'project' => 'B1_a.com'],
'received_at' => now(),
'source' => 'webhook',
]);
expect($lead->fresh()->vid)->toBe(432176649);
});
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
Cache::store('redis')->put('supplier:session', ['phpsessid' => 'test', 'csrf' => 'test'], now()->addHour());
});
// Реальные endpoint'ы verified discovery T3 2026-05-19 через Playwright MCP:
// POST /admin/report/save-report (JSON body, response "OK" — text, не JSON)
// GET /admin/report/load-reports (array — каждая запись {id,title,status:"0"|"1",...})
// GET /admin/report/getfile?id=N (raw CSV body)
it('requestNumbersReport posts save-report and resolves id via load-reports by title match', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/report/save-report' => Http::response('OK', 200),
'crm.bp-gr.ru/admin/report/load-reports' => Http::response([
['id' => '509196', 'title' => 'Запрос номеров с 2026-05-17 по 2026-05-18', 'status' => '0'],
['id' => '508346', 'title' => 'Запрос номеров с 2026-04-01 по 2026-04-30', 'status' => '1'],
], 200),
]);
$client = app(SupplierPortalClient::class);
$id = $client->requestNumbersReport(Carbon::parse('2026-05-17'), Carbon::parse('2026-05-18'));
expect($id)->toBe(509196);
Http::assertSent(function (Request $r): bool {
if (! str_ends_with($r->url(), '/admin/report/save-report')) {
return false;
}
if ($r->method() !== 'POST') {
return false;
}
$body = $r->data();
return ($body['reportForm']['selectType'] ?? null) === 49
&& ($body['reportFilter']['dateFrom'] ?? null) === '2026-05-17'
&& ($body['reportFilter']['dateTo'] ?? null) === '2026-05-18'
&& ($body['reportFilter']['types'] ?? null) === ['phones']
&& ($body['reportFilter']['prophones'] ?? null) === 'curr'
&& ($body['reportFilter']['gck_tech'] ?? null) === 'gck';
});
});
it('waitReportReady polls load-reports until our entry has status "1"', function (): void {
Http::fakeSequence('crm.bp-gr.ru/admin/report/load-reports')
->push([
['id' => '509196', 'title' => 'Запрос номеров с 2026-05-17 по 2026-05-18', 'status' => '0'],
], 200)
->push([
['id' => '509196', 'title' => 'Запрос номеров с 2026-05-17 по 2026-05-18', 'status' => '1'],
], 200);
$client = app(SupplierPortalClient::class);
$client->waitReportReady(509196);
expect(true)->toBeTrue();
});
it('downloadReport returns raw CSV body from getfile', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/report/getfile*' => Http::response("Name;Tag;Phone\nB1_a.com;t;79991234567\n", 200),
]);
$client = app(SupplierPortalClient::class);
$csv = $client->downloadReport(509196);
expect($csv)->toContain('Name;Tag;Phone');
});
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierClientException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/*
* rt-project-* contract tests.
*
* Контракт верифицирован live 2026-05-19 (Playwright MCP recon см. план
* Task 1: создан LIDPOTOK_TEST_DELETE_ME на crm.bp-gr.ru, записаны сетевые
* запросы, проект удалён). Endpoints:
* POST /admin/visit/rt-project-save (JSON, конверт {status,message,result,id})
* POST /admin/visit/rt-project-delete (JSON, конверт {status,message,result})
* GET /admin/visit/rt-projects-load?src=none
*
* Tests use Http::fake отделяем контракт SupplierPortalClient от реального портала.
*/
beforeEach(function (): void {
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'test-session',
'csrf' => 'test-csrf',
], now()->addHour());
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});
it('saveProject POSTs to /admin/visit/rt-project-save with JSON body and parses id from envelope', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721245'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'lidpotok-test.local',
limit: 100,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
$externalId = app(SupplierPortalClient::class)->saveProject($dto);
expect($externalId)->toBe(12721245);
Http::assertSent(function (Request $request): bool {
$expectsB1 = $request['srcrt'] === true && $request['srcbl'] === false && $request['srcmt'] === false;
return $request->method() === 'POST'
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-save'
&& $request->hasHeader('Content-Type', 'application/json')
&& $request['id'] === 0
&& $request['tag'] === '_lidpotok'
&& $request['name'] === 'lidpotok-test.local'
&& $request['content'] === 'lidpotok-test.local'
&& $request['type'] === 'hosts'
&& $expectsB1
&& $request['limit'] === 100
&& $request['workdays'] === ['1', '2', '3', '4', '5', '6', '7']
&& $request['regions_reverse'] === false
&& $request['status'] === true;
});
});
it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (single-true)', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721244'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B2',
signalType: 'call',
uniqueKey: '79991112233',
limit: 50,
workdays: [1, 2, 3],
regions: [77],
regionsReverse: true,
status: 'paused',
);
app(SupplierPortalClient::class)->saveProject($dto);
Http::assertSent(function (Request $request): bool {
return $request['type'] === 'calls'
&& $request['srcrt'] === false
&& $request['srcbl'] === true
&& $request['srcmt'] === false
&& $request['regions'] === [77]
&& $request['regions_reverse'] === true
&& $request['status'] === false;
});
});
it('updateProject POSTs to /admin/visit/rt-project-save with id:N (same endpoint as save)', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721245'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B3',
signalType: 'sms',
uniqueKey: 'KEYWORD',
limit: 25,
workdays: [1, 5],
regions: [],
regionsReverse: false,
status: 'active',
);
app(SupplierPortalClient::class)->updateProject(12721245, $dto);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-save'
&& $request['id'] === 12721245
&& $request['type'] === 'sms'
&& $request['srcmt'] === true;
});
});
it('deleteProject POSTs to /admin/visit/rt-project-delete with JSON {id:"<string>"}', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null],
200,
),
]);
app(SupplierPortalClient::class)->deleteProject(12721245);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-delete'
&& $request->hasHeader('Content-Type', 'application/json')
&& $request['id'] === '12721245';
});
});
it('listProjects extracts projects[] from the envelope and returns raw rows', function (): void {
// Verified live 2026-05-19: ответ — конверт {projects:[...], tags, users, ...},
// НЕ голый массив. listProjects извлекает projects.
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response([
'projects' => [
['id' => '12721245', 'tag' => '_lidpotok', 'name' => 'B3_LIDPOTOK', 'type' => 'hosts', 'content' => 'foo.com'],
],
'tags' => [],
'users' => [],
], 200),
]);
$list = app(SupplierPortalClient::class)->listProjects();
expect($list)->toHaveCount(1);
expect($list[0]['id'])->toBe('12721245');
expect($list[0]['name'])->toBe('B3_LIDPOTOK');
Http::assertSent(function (Request $request): bool {
return $request->method() === 'GET'
&& str_contains($request->url(), '/admin/visit/rt-projects-load')
&& $request->data() === ['src' => 'none'];
});
});
it('listProjects returns empty array when envelope has no projects key', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['tags' => []], 200),
]);
expect(app(SupplierPortalClient::class)->listProjects())->toBe([]);
});
it('saveProject throws SupplierClientException on HTTP 200 + status:"Error"', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'Error', 'message' => 'Лимит недостаточен!', 'result' => null],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'rejected.local',
limit: 1,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
expect(fn () => app(SupplierPortalClient::class)->saveProject($dto))
->toThrow(SupplierClientException::class, 'Лимит недостаточен!');
});
it('deleteProject throws SupplierClientException on HTTP 200 + status:"Error"', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
['status' => 'Error', 'message' => 'Проект не найден', 'result' => null],
200,
),
]);
expect(fn () => app(SupplierPortalClient::class)->deleteProject(99999999))
->toThrow(SupplierClientException::class, 'Проект не найден');
});
@@ -10,6 +10,7 @@ use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Models\Tenant;
use App\Services\Supplier\Channel\AjaxProjectChannel;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@@ -64,17 +65,20 @@ test('creates supplier_project at supplier when supplier_external_id is null', f
]);
Http::fake([
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200),
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
200,
),
]);
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sp->refresh();
expect($sp->supplier_external_id)->toBe('555')
->and($sp->sync_status)->toBe('ok')
->and($sp->current_limit)->toBe(3);
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-save'));
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save'));
});
test('updates when diff detected', function (): void {
@@ -101,16 +105,21 @@ test('updates when diff detected', function (): void {
]);
Http::fake([
'crm.bp-gr.ru/admin/rt-project-update' => Http::response([], 200),
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
200,
),
]);
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sp->refresh();
expect($sp->current_limit)->toBe(10)
->and($sp->sync_status)->toBe('ok');
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-update'));
// Update теперь идёт на тот же endpoint что и save (verified 2026-05-19 — Task 1 recon),
// с id:N в body вместо id:0.
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && $r['id'] === 12345);
});
test('skips when no diff between current and computed allocation', function (): void {
@@ -138,7 +147,7 @@ test('skips when no diff between current and computed allocation', function ():
]);
Http::fake();
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Http::assertNothingSent();
});
@@ -188,11 +197,11 @@ test('isolates failure: one bad supplier_project does not stop others', function
'region_mode' => 'include',
]);
Http::fakeSequence('crm.bp-gr.ru/admin/rt-project-save')
Http::fakeSequence('crm.bp-gr.ru/admin/visit/rt-project-save')
->push('bad request', 422)
->push(['id' => 777], 200);
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'], 200);
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
expect(
SupplierSyncLog::on('pgsql_supplier')
@@ -233,7 +242,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
return $mail->alertType === 'mass_transient';
@@ -264,10 +273,13 @@ test('writes supplier_sync_log row for each successful action', function (): voi
]);
Http::fake([
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200),
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
200,
),
]);
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$log = SupplierSyncLog::on('pgsql_supplier')
->where('supplier_project_id', $sp->id)
@@ -305,7 +317,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
]);
Http::fake();
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Http::assertNothingSent();
});
@@ -370,7 +382,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi
'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
]);
expect(fn () => (new SyncSupplierProjectsJob)->handle())
expect(fn () => (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)))
->toThrow(SupplierAuthException::class);
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
@@ -403,10 +415,13 @@ test('outbound: copies project regions[] into supplier_project current_regions v
]);
Http::fake([
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 556], 200),
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '556'],
200,
),
]);
(new SyncSupplierProjectsJob)->handle();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sp->refresh();
expect($sp->current_regions)->toBe([82, 83])
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
vi.mock('axios');
const vuetify = createVuetify({ components, directives });
describe('AdminSupplierIntegrationView — manual queue section', () => {
beforeEach(() => {
vi.clearAllMocks();
(axios.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.endsWith('/manual-queue')) {
return Promise.resolve({
data: {
queue: [
{
id: 1,
project_id: 42,
platform: 'B1',
operation: 'create',
external_id: null,
payload_snapshot: { limit: 10, signal_type: 'site', unique_key: 'foo.com' },
failure_reason: 'contract_break',
created_at: '2026-05-19T10:00:00Z',
},
],
},
});
}
return Promise.resolve({ data: { health: null, history: [] } });
});
});
it('renders pending queue rows with payload + reason', async () => {
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const text = wrapper.text();
expect(text).toContain('foo.com');
expect(text).toContain('contract_break');
expect(text).toContain('B1');
});
it('clicking «Отметить выполнено» calls resolve endpoint', async () => {
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { resolved: true, external_id: 700123 },
});
vi.spyOn(window, 'confirm').mockReturnValue(true);
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const btn = wrapper.find('[data-testid="resolve-1"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/manual-queue/1/resolve'),
);
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
vi.mock('axios');
const vuetify = createVuetify({ components, directives });
const healthPayload = {
health: { last_run_at: '2026-05-18T12:00:00Z', last_status: 'ok', drift_ratio: 0.02, webhook_state: 'live' },
history: [
{
started_at: '2026-05-18T12:00:00Z', finished_at: '2026-05-18T12:01:00Z',
window_start: '2026-05-17T00:00:00Z', window_end: '2026-05-18T12:00:00Z',
status: 'ok', total_csv_rows: 100, matched_count: 98, recovered_count: 2, drift_ratio: 0.02,
},
],
};
function mountView() {
return mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
}
beforeEach(() => {
vi.clearAllMocks();
(axios.get as ReturnType<typeof vi.fn>).mockResolvedValue({ data: healthPayload });
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { dispatched: true } });
});
describe('AdminSupplierIntegrationView', () => {
it('loads channel health on mount', async () => {
const wrapper = mountView();
await new Promise((r) => setTimeout(r, 0));
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration');
expect(wrapper.text()).toContain('live');
});
it('renders reconcile history rows', async () => {
const wrapper = mountView();
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('100');
});
it('triggers manual reconcile on button click', async () => {
const wrapper = mountView();
await new Promise((r) => setTimeout(r, 0));
await wrapper.find('[data-test="reconcile-now"]').trigger('click');
expect(axios.post).toHaveBeenCalledWith('/api/admin/supplier-integration/reconcile');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { setActivePinia, createPinia } from 'pinia';
import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
const vuetify = createVuetify();
function makeDeal(overrides: Partial<MockDeal> = {}): MockDeal {
return {
id: 1, name: '+79991234567', phone: '+79991234567', statusSlug: 'new',
project: 'p', manager: { initials: 'AD', name: 'Admin' }, cost: 0,
receivedMinutesAgo: 1,
projectSignalType: 'site', projectSignalIdentifier: 'krk-finance.ru',
projectSmsKeyword: null, projectSmsSenders: null,
...overrides,
};
}
describe('DealDetailBody — Тип и Источник (18.05.2026 ux)', () => {
it('site: показывает Тип «Сайт» и Источник = signal_identifier', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal() },
global: { plugins: [vuetify] },
});
expect(w.text()).toContain('Сайт');
expect(w.text()).toContain('krk-finance.ru');
});
it('call: Тип «Звонок» и Источник = телефонный номер', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal({
projectSignalType: 'call',
projectSignalIdentifier: '79992223344',
}) },
global: { plugins: [vuetify] },
});
expect(w.text()).toContain('Звонок');
expect(w.text()).toContain('79992223344');
});
it('sms с keyword: Источник = «sender (KEYWORD)»', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal({
projectSignalType: 'sms',
projectSignalIdentifier: null,
projectSmsSenders: ['MTS', 'BEELINE'],
projectSmsKeyword: 'КРЕДИТ',
}) },
global: { plugins: [vuetify] },
});
expect(w.text()).toContain('СМС');
expect(w.text()).toContain('MTS (КРЕДИТ)');
});
it('sms без keyword: Источник = только sender', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal({
projectSignalType: 'sms',
projectSignalIdentifier: null,
projectSmsSenders: ['MTS'],
projectSmsKeyword: null,
}) },
global: { plugins: [vuetify] },
});
expect(w.text()).toContain('СМС');
expect(w.text()).toContain('MTS');
// Никаких пустых скобок
expect(w.text()).not.toMatch(/\(\s*\)/);
});
it('не отображает «Менеджер»', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal() },
global: { plugins: [vuetify] },
});
expect(w.text()).not.toContain('Менеджер');
expect(w.text()).not.toContain('Не назначен');
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealDetailHero from '../../resources/js/components/deals/DealDetailHero.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
import type { LeadStatus } from '../../resources/js/composables/leadStatuses';
const vuetify = createVuetify();
const statuses: LeadStatus[] = [
{ slug: 'new', nameRu: 'Новая сделка', colorHex: '#5b2db2', order: 1 } as LeadStatus,
{ slug: 'viewed', nameRu: 'Просмотрено', colorHex: '#5a2db2', order: 2 } as LeadStatus,
{ slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C', order: 3 } as LeadStatus,
];
function makeDeal(over: Partial<MockDeal> = {}): MockDeal {
return {
id: 1, name: '+79991234567', phone: '+79991234567', statusSlug: 'new',
project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0,
receivedMinutesAgo: 1, ...over,
};
}
describe('DealDetailHero — inline status picker (18.05.2026)', () => {
it('рендерит статус-chip с триггером (data-testid="status-chip-trigger")', () => {
const w = mount(DealDetailHero, {
props: { deal: makeDeal(), status: statuses[0], allStatuses: statuses },
global: { plugins: [vuetify] },
});
expect(w.find('[data-testid="status-chip-trigger"]').exists()).toBe(true);
});
it('клик по chip открывает меню (data-testid="status-option-{slug}" появляются)', async () => {
const w = mount(DealDetailHero, {
props: { deal: makeDeal(), status: statuses[0], allStatuses: statuses },
global: { plugins: [vuetify], stubs: { teleport: false } },
attachTo: document.body,
});
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
// Give v-menu time to mount (teleport target = body).
await new Promise((r) => setTimeout(r, 200));
const options = document.body.querySelectorAll('[data-testid^="status-option-"]');
expect(options.length).toBeGreaterThan(0);
const wonOption = document.body.querySelector('[data-testid="status-option-won"]') as HTMLElement | null;
expect(wonOption).not.toBeNull();
wonOption?.click();
await new Promise((r) => setTimeout(r, 30));
expect(w.emitted('change-status')?.[0]?.[0]).toBe('won');
w.unmount();
});
});
+26
View File
@@ -92,6 +92,32 @@ describe('DealsView.vue — реестр лидов', () => {
expect(vm.panelOpen).toBe(false);
});
it('при selected=1 drawer авто-открывается, bulk-полоса скрыта (18.05.2026 ux)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; panelOpen: boolean; selectedDeal: MockDeal | null;
};
vm.selected = [1];
await flushPromises();
expect(vm.panelOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(1);
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
});
it('при selected≥2 drawer закрывается, bulk-полоса видна (18.05.2026 ux)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; panelOpen: boolean; dealsState: MockDeal[];
openPanel: (d: MockDeal) => void;
};
vm.openPanel(vm.dealsState[0]);
expect(vm.panelOpen).toBe(true);
vm.selected = [1, 2];
await flushPromises();
expect(vm.panelOpen).toBe(false);
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
});
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { stripChannelPrefix } from '../../resources/js/composables/projectName';
/**
* Имена проектов crm.bp префиксуются B1_/B2_/B3_ (источник-провайдер).
* В UI Лидерры префикс убираем — он шум для пользователя; данные в БД не трогаем.
*/
describe('stripChannelPrefix', () => {
it('убирает B1_ префикс', () => {
expect(stripChannelPrefix('B1_73912557675 [35]')).toBe('73912557675 [35]');
});
it('убирает B2_ префикс', () => {
expect(stripChannelPrefix('B2_krk-finance.ru/cabinet/auth [24]')).toBe('krk-finance.ru/cabinet/auth [24]');
});
it('убирает B3_ префикс', () => {
expect(stripChannelPrefix('B3_kras.vashinvestor.ru [23]')).toBe('kras.vashinvestor.ru [23]');
});
it('case-insensitive: b1_/b2_/b3_ тоже убирает', () => {
expect(stripChannelPrefix('b1_test')).toBe('test');
expect(stripChannelPrefix('b3_demo')).toBe('demo');
});
it('не трогает имя без префикса', () => {
expect(stripChannelPrefix('quidem fugiat unde')).toBe('quidem fugiat unde');
expect(stripChannelPrefix('Натяжные потолки')).toBe('Натяжные потолки');
});
it('не трогает B4_/B0_/Bx_ — только B1/B2/B3', () => {
expect(stripChannelPrefix('B4_other')).toBe('B4_other');
expect(stripChannelPrefix('B0_zero')).toBe('B0_zero');
expect(stripChannelPrefix('BX_unknown')).toBe('BX_unknown');
});
it('не трогает префикс внутри строки — только в начале', () => {
expect(stripChannelPrefix('foo B1_bar')).toBe('foo B1_bar');
});
it('терпит null/undefined/пустую строку', () => {
expect(stripChannelPrefix(null)).toBe('');
expect(stripChannelPrefix(undefined)).toBe('');
expect(stripChannelPrefix('')).toBe('');
});
});
@@ -8,6 +8,10 @@ use Tests\TestCase;
uses(TestCase::class);
// Контракт парсера (эпик CSV-канал T2, 18.05.2026): отчёт «Запрос номеров»
// crm.bp-gr.ru — 3 колонки Name;Tag;Phone. vid и время в отчёте отсутствуют.
// SupplierCsvParser::parse() yields {project, tag, phone}. Spec §4.1.
beforeEach(function () {
$this->parser = new SupplierCsvParser;
});
@@ -17,25 +21,24 @@ it('parses empty CSV → yields nothing', function () {
expect($rows)->toBeEmpty();
});
it('parses 1 row → yields 1 struct with vid/project/phone/time', function () {
$csv = "vid;project;tag;phone;phones;time\n"
."1234;B1_example.com;;79991234567;79991234567;1715432400\n";
it('parses 1 row → yields 1 struct with project/tag/phone', function () {
$csv = "Name;Tag;Phone\n"
."B1_example.com;mytag;79991234567\n";
$rows = iterator_to_array($this->parser->parse($csv));
expect($rows)->toHaveCount(1);
expect($rows[0])->toMatchArray([
'vid' => '1234',
'project' => 'B1_example.com',
'tag' => 'mytag',
'phone' => '79991234567',
'time' => 1715432400,
]);
});
it('parses 1000 rows without OOM (streaming generator)', function () {
$lines = ['vid;project;tag;phone;phones;time'];
$lines = ['Name;Tag;Phone'];
for ($i = 1; $i <= 1000; $i++) {
$lines[] = "{$i};B1_test.com;;79991234567;79991234567;1715432400";
$lines[] = "B1_test.com;tag{$i};79991234567";
}
$csv = implode("\n", $lines)."\n";
@@ -50,16 +53,16 @@ it('parses 1000 rows without OOM (streaming generator)', function () {
it('skips malformed rows with missing columns + logs warning', function () {
Log::spy();
$csv = "vid;project;tag;phone;phones;time\n"
."1234;B1_example.com;;79991234567;79991234567;1715432400\n"
$csv = "Name;Tag;Phone\n"
."B1_example.com;mytag;79991234567\n"
."broken-row-only-one-column\n"
."5678;B1_another.com;;79991234567;79991234567;1715432500\n";
."B1_another.com;tag2;79991234500\n";
$rows = iterator_to_array($this->parser->parse($csv));
expect($rows)->toHaveCount(2);
expect($rows[0]['vid'])->toBe('1234');
expect($rows[1]['vid'])->toBe('5678');
expect($rows[0]['project'])->toBe('B1_example.com');
expect($rows[1]['project'])->toBe('B1_another.com');
Log::shouldHaveReceived('warning')
->with('supplier_csv_parser.malformed_row', Mockery::any())
@@ -68,11 +71,11 @@ it('skips malformed rows with missing columns + logs warning', function () {
it('handles BOM + CRLF line endings', function () {
$bom = "\xEF\xBB\xBF";
$csv = $bom."vid;project;tag;phone;phones;time\r\n"
."1234;B1_example.com;;79991234567;79991234567;1715432400\r\n";
$csv = $bom."Name;Tag;Phone\r\n"
."B1_example.com;mytag;79991234567\r\n";
$rows = iterator_to_array($this->parser->parse($csv));
expect($rows)->toHaveCount(1);
expect($rows[0]['vid'])->toBe('1234');
expect($rows[0]['project'])->toBe('B1_example.com');
});
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'test-session', 'csrf' => 'test-csrf-token',
], now()->addHour());
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});
it('GET /admin/report/index?type=49 returns CSV body on 200', function () {
Http::fake([
'crm.bp-gr.ru/admin/report/index*' => Http::response(
"vid;project;tag;phone;phones;time\n1234;B1_example.com;;79991234567;79991234567;1715432400\n",
200,
['Content-Type' => 'text/csv'],
),
]);
$client = new SupplierPortalClient(app(HttpFactory::class));
$body = $client->downloadLeadsCsv(
Carbon::parse('2024-05-11 00:00:00'),
Carbon::parse('2024-05-12 00:00:00'),
);
expect($body)->toContain('1234;B1_example.com');
});
it('401 → triggers session refresh → retry → 200', function () {
Http::fakeSequence('crm.bp-gr.ru/admin/report/index*')
->push('Unauthorized', 401)
->push("vid;...\n", 200);
// RefreshSupplierSessionJob — мокаем
app()->bind(RefreshSupplierSessionJob::class, function () {
return new class
{
public function handle(): void
{
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'refreshed', 'csrf' => 'refreshed-csrf',
], now()->addHour());
}
};
});
$client = new SupplierPortalClient(app(HttpFactory::class));
$body = $client->downloadLeadsCsv(
Carbon::parse('2024-05-11'),
Carbon::parse('2024-05-12'),
);
expect($body)->toContain('vid');
});
it('500 → SupplierTransientException', function () {
Http::fake(['crm.bp-gr.ru/*' => Http::response('Internal Server Error', 500)]);
$client = new SupplierPortalClient(app(HttpFactory::class));
expect(fn () => $client->downloadLeadsCsv(
Carbon::parse('2024-05-11'),
Carbon::parse('2024-05-12'),
))->toThrow(SupplierTransientException::class);
});
@@ -94,8 +94,13 @@ test('network error throws SupplierTransientException', function (): void {
->toThrow(SupplierTransientException::class);
});
test('saveProject POSTs to /admin/rt-project-save with full payload and returns external id', function (): void {
Http::fake(['crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 99001], 200)]);
test('saveProject POSTs to /admin/visit/rt-project-save with full payload and returns external id', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '99001'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
@@ -111,13 +116,22 @@ test('saveProject POSTs to /admin/rt-project-save with full payload and returns
$id = app(SupplierPortalClient::class)->saveProject($dto);
expect($id)->toBe(99001);
// Verified live 2026-05-19 (Task 1 recon): тело Vuex-state c srcrt=true для B1.
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-save')
&& ($r['platform'] ?? null) === 'B1');
&& str_ends_with($r->url(), '/admin/visit/rt-project-save')
&& ($r['name'] ?? null) === 'example.com'
&& ($r['type'] ?? null) === 'hosts'
&& ($r['srcrt'] ?? null) === true
&& ($r['id'] ?? null) === 0);
});
test('updateProject POSTs to /admin/rt-project-update with id + full payload', function (): void {
Http::fake(['crm.bp-gr.ru/admin/rt-project-update' => Http::response('', 200)]);
test('updateProject POSTs to /admin/visit/rt-project-save with id + full payload', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B2',
@@ -132,19 +146,28 @@ test('updateProject POSTs to /admin/rt-project-update with id + full payload', f
app(SupplierPortalClient::class)->updateProject(externalId: 12345, dto: $dto);
// Update = тот же endpoint что save, но с id:N (verified 2026-05-19 recon).
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-update')
&& ((int) ($r['id'] ?? 0)) === 12345);
&& str_ends_with($r->url(), '/admin/visit/rt-project-save')
&& ((int) ($r['id'] ?? 0)) === 12345
&& ($r['type'] ?? null) === 'calls'
&& ($r['srcbl'] ?? null) === true);
});
test('deleteProject POSTs to /admin/rt-project-delete with id only', function (): void {
Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);
test('deleteProject POSTs to /admin/visit/rt-project-delete with id only', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null],
200,
),
]);
app(SupplierPortalClient::class)->deleteProject(externalId: 12345);
// ID идёт строкой в JSON-body (verified 2026-05-19 recon: {"id":"12345"}).
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-delete')
&& ((int) ($r['id'] ?? 0)) === 12345);
&& str_ends_with($r->url(), '/admin/visit/rt-project-delete')
&& ($r['id'] ?? null) === '12345');
});
test('malformed portal_url throws SupplierClientException, not auth path', function (): void {
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Test</title></head><body>
<div id="add-project-modal" data-testid="add-project-form">
<label><input type="checkbox" name="active" checked> Активный</label>
<label>Тег <input type="text" name="tag" id="tag-input"></label>
<fieldset>
<legend>Источник данных</legend>
<label><input type="checkbox" name="platform[]" value="B1" checked> B1</label>
<label><input type="checkbox" name="platform[]" value="B2" checked> B2</label>
<label><input type="checkbox" name="platform[]" value="B3" checked> B3</label>
</fieldset>
<label>Название проекта <input type="text" name="name" id="name-input" required></label>
<label>Источники сбора <select name="signal_type" id="signal-type"><option>Сайты</option><option>Звонки</option><option>СМС</option></select></label>
<fieldset>
<legend>Регион</legend>
<label><input type="radio" name="region_mode" value="include" checked> Включить</label>
<label><input type="radio" name="region_mode" value="exclude"> Исключить</label>
<input type="text" name="regions_filter" placeholder="Фильтр по регионам">
</fieldset>
<label>Список сайтов <textarea name="domains" id="domains-input"></textarea></label>
<label>Лимит в день <input type="number" name="limit" id="limit-input" value="10"></label>
<fieldset>
<legend>Дни получения номеров</legend>
<label><input type="checkbox" name="workdays[]" value="1" checked> Пн.</label>
<label><input type="checkbox" name="workdays[]" value="2" checked> Вт.</label>
<label><input type="checkbox" name="workdays[]" value="3" checked> Ср.</label>
<label><input type="checkbox" name="workdays[]" value="4" checked> Чт.</label>
<label><input type="checkbox" name="workdays[]" value="5" checked> Пт.</label>
<label><input type="checkbox" name="workdays[]" value="6" checked> Сб.</label>
<label><input type="checkbox" name="workdays[]" value="7" checked> Вс.</label>
</fieldset>
<button type="button" id="save-btn">Сохранить</button>
<button type="button" id="cancel-btn">Отмена</button>
</div>
<table id="projects-table"><tbody></tbody></table>
<script>
document.getElementById('save-btn').addEventListener('click', function() {
var tbody = document.querySelector('#projects-table tbody');
var row = document.createElement('tr');
row.dataset.id = String(Date.now());
var tdId = document.createElement('td');
tdId.textContent = row.dataset.id;
var tdName = document.createElement('td');
tdName.textContent = document.getElementById('name-input').value;
row.appendChild(tdId);
row.appendChild(tdName);
tbody.appendChild(row);
document.getElementById('add-project-modal').style.display = 'none';
});
</script>
</body></html>
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
// Minimal Vitest config for tools/*.test.mjs (Node environment, no Vue/DOM).
// Separate from vitest.config.ts which targets tests/Frontend/**/*.ts.
// Run from repo root: node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['../tools/*.test.mjs'],
exclude: ['../tools/ruflo-*.test.mjs', '../tools/subagent-prompt-prefix.test.mjs'],
},
});
+68
View File
@@ -1420,3 +1420,71 @@ JTBD
тулчейне
пинами
evals
# parallel-sessions-coordination spec (2026-05-18)
коммитит
инвокейшн
парсимый
парсится
ревьюить
инвокацией
# ЭТАЛОН проекта (2026-05-18) — Russian IT vocabulary
волатильный
волатильную
волатильно
волатильны
незакоммиченное
бандл
# План «Сделки drawer + редактирование источника» (2026-05-18)
табах
отревизован
ребаланс
квирком
тулинг
лоадит
CCS
промпта
# Компакция «мозга» findings 2/3/6/7 (2026-05-18)
пин
пинуют
стабу
клаузы
коммичу
# Brain governance design (2026-05-19) — router-only + observer + 4 контролёра
слойного
слойный
рецидивирующие
зарегламентировать
версионный
стейлнес
апдейты
разруливают
брейн
DWC
нодов
креды
Апи
имплементациями
алёрт
инжектят
инжектим
фикстурный
роута
# Brain dashboard design spec (2026-05-19)
визуализирующий
анимируются
неподсвеченными
полл
инференс
вендорено
# Brain dashboard implementation plan (2026-05-19)
visualises
AGD
agg
+28 -2
View File
@@ -1,11 +1,37 @@
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать две записи в обратном хронологическом порядке (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.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.23, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.25, консолидированная — разворачивает БД с нуля).
**История записей:**
## v8.25 — 2026-05-19 — supplier_manual_sync_queue (Tier 3 резерва канала миграции проектов)
**+1 таблица** SaaS-level (без tenant_id / RLS, как `supplier_csv_reconcile_log`):
- `supplier_manual_sync_queue` — очередь яруса 3 резерва канала миграции проектов
(spec `docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md` §4.5).
- **+3 CHECK:** `chk_smsq_platform` (B1/B2/B3), `chk_smsq_operation` (create/update),
`chk_smsq_status` (pending/resolved/cancelled).
- **+2 индекса:** `idx_smsq_status_created`, `idx_smsq_project`.
- **+2 FK:** `project_id → projects ON DELETE CASCADE`;
`resolved_by_user_id → users ON DELETE SET NULL`.
Метрики после: 64 базовые таблицы (62 regular + 2 partitioned parents),
12 партиций, 121 индекс, 40 RLS-политик, 5 функций, 13 триггеров.
Миграция: `2026_05_19_120000_create_supplier_manual_sync_queue.php` (idempotent
guard через `to_regclass`).
## v8.24 — 2026-05-18 — supplier_leads.vid → nullable
`ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL`. Резервный CSV-канал
(Путь 2): отчёт поставщика «Запрос номеров» не содержит vid → CSV-recovered лиды
имеют vid=NULL. UNIQUE-индекс idx_supplier_leads_vid_unique сохранён (в PostgreSQL
NULL ≠ NULL — множественные NULL не конфликтуют). Миграция:
2026_05_18_140000_supplier_leads_vid_nullable.php. RLS не затронут.
## v8.23 — 2026-05-17 — Редизайн «Сделки» (воронка статусов 14 → 5)
**Изменения:**
+35 -3
View File
@@ -1,7 +1,8 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.23 (17.05.2026 — Редизайн «Сделки»: seed lead_statuses 14→5 (new/viewed/in_progress/won/lost))
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 119 индексов / 40 RLS-политик / 5 функций / 13 триггеров
-- Версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 121 индекс / 40 RLS-политик / 5 функций / 13 триггеров
-- Базовая версия: 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 лимитов)
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
@@ -1128,6 +1129,37 @@ CREATE INDEX supplier_csv_reconcile_log_status_index
ON supplier_csv_reconcile_log(status)
WHERE status IN ('drift_alert','failed');
-- -----------------------------------------------------------------------------
-- supplier_manual_sync_queue — Tier 3 очередь резерва канала миграции проектов (v8.25)
-- -----------------------------------------------------------------------------
-- SaaS-level (не tenant-scoped, без RLS, как supplier_csv_reconcile_log).
-- FailoverProjectChannel записывает строку при провале ярусов 1-2: оператор
-- админ-экрана вносит проект вручную в crm.bp-gr.ru и помечает row resolved.
-- Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_manual_sync_queue (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
platform VARCHAR(8) NOT NULL,
operation VARCHAR(16) NOT NULL,
external_id VARCHAR(64),
payload_snapshot JSONB NOT NULL,
failure_reason VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')),
CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')),
CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled'))
);
CREATE INDEX idx_smsq_status_created
ON supplier_manual_sync_queue (status, created_at DESC);
CREATE INDEX idx_smsq_project
ON supplier_manual_sync_queue (project_id);
-- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser.
@@ -1910,7 +1942,7 @@ CREATE TABLE supplier_leads (
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
platform VARCHAR(4) NOT NULL,
raw_payload JSONB NOT NULL,
vid BIGINT NOT NULL,
vid BIGINT, -- nullable: NULL у CSV-recovered лидов (Путь 2)
phone VARCHAR(20) NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source VARCHAR(16) NOT NULL DEFAULT 'webhook',
+106 -4
View File
@@ -1,7 +1,17 @@
# Plugin Stack Rules — Superpowers + Frontend Design (v3.12)
# Plugin Stack Rules — Superpowers + Frontend Design (v3.17)
**Дата:** 17.05.2026
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3).
**Дата:** 19.05.2026
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16).
**v3.17** — observer schema v2 sync (ADR-011 amend): R16.1 +предложение про `schema_version` / `decision_provenance` / `environment` / `task_size` / `prompt_signal` + расширенные события (`hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`) + `observer_error` маркер; R16.4 +cross-ref на factor-analysis spec и plan. R0R15 без изменений. Routing-gate / C5 controller / `/brain-retro` analyzer — нормативно в Pravila §16.7/§16.8 + ADR-011 §5; PSR_v1 фиксирует evidence-сбор (R16), не enforcement. Связано: ADR-011, Pravila v1.32 (§16 amend), spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`.
**v3.16** — Brain evidence loop: новое R16 «Brain evidence loop» (R16.1 observer scope — Stop-хук пишет episodes-YYYY-MM.jsonl, 5 обязательных полей incl. `primary_rationale`; R16.2 plugin stack-conscious events — `routing_decision` / `skill_invoked` с `node_id` при использовании R6/R6.1/R15, факторная матрица 5 осей для `/brain-retro`; R16.3 не override — R0–R15 определяют выбор, R16 только фиксирует историю; R16.4 cross-refs ADR-011 / Pravila §16 / spec+plan+procedure). R0R15 без изменений. Связано: ADR-011 `docs/adr/ADR-011-brain-governance.md`, Pravila §16, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`.
**v3.15** — Компакция «мозга» (SYSTEM-аудит 18.05.2026, finding 3 — структурный дрейф счётчиков): R10.1 +note «счётчики и нумерация позиций тулчейна — канон [Tooling Прил. Н §0](Tooling_v8_3.md), anchor "КАНОН СЧЁТЧИКОВ"»; реестр R10.1 ссылается per-row на Tooling #NN, агрегатные числа не дублирует. Содержательных изменений R0–R15: 0. Связано: Tooling Прил.Н v2.16 (§0 +«КАНОН СЧЁТЧИКОВ»), CLAUDE.md v2.17 (§3.3 компакция), Pravila v1.30 (§13.2 пин, §14 dormant-метка); план `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`.
**v3.14** — Off-phase routing: **R15 новое правило** «Off-phase routing» — закрывает Rec5 SYSTEM-аудита 18.05.2026 «PSR_v1 R-аппарат UI-перекошен (R1-R9 / R11-R14 — UI; off-phase 30 узлов регулировались только R10.1 + меткой "вне R6/R14")». R15.1 — R6.0/R6.1/R14 не применяются к off-phase (codifies существующую практику); R15.2 — routing-таблица 30 узлов вынесена в `docs/routing-off-phase.md` (single home + 12 канонических связок Rec4); R15.3 — приоритет специфичности при коллизии узлов; R15.4 — Pravila §12/§14/§15 перевешивают R15; R15.5 — live-override (заказчик называет узел напрямую). UI-аппарат R0–R14 — без изменений. Финальная формула расширена. ruflo isolation 18.05 (Pravila §14.9) добавляет «ruflo dormant — не маршрутизировать». Связано: `docs/routing-off-phase.md` v1.0, snapshot `docs/discovery/2026-05-18-system-audit-brain.md` Rec5, Pravila v1.29 / Tooling v2.15 / CLAUDE.md v2.16 (pending sync).
**v3.13** — Anthropic dev-tooling: R10.1 Блок 1 +5 строк таблицы — **skill-creator** (#56) / **plugin-dev** (#57) / **hookify** (#58) / **claude-code-setup** (#59) / **context7** (#60) — 5 Anthropic-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (L1-паттерн). +note (v3.13). Новые 13-я (**authoring-tooling** — #56-#58) и 14-я (**dev-support** — #59-#60) off-phase подкатегории — не UI → вне R6.0/R6.1/R14. **hookify HK1** — hard-rule pre-check на коллизию с economy/skill-discipline хуками; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Содержательных изменений R0–R14: 0. ADR-010. Связано: Tooling v2.14, Pravila v1.28, CLAUDE.md v2.15; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
**v3.12** — discovery-interview: R10.1 Блок 1 +note (v3.12) — **discovery-interview** (Tooling #55, self-authored project-скил `.claude/skills/discovery-interview/`, как process-modeling/process-analysis; режимы FEATURE + SYSTEM). Новая 12-я off-phase подкатегория **discovery-tooling** (§4.30) — не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.13, Pravila v1.26, CLAUDE.md v2.13; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
@@ -404,6 +414,8 @@ Stack — **головной**. Все плагины вне stack'а — **ин
Реестр разбит на три блока **по типу источника** (v1.5+) — раньше всё было одним списком, что путало «отключи в settings.json» с «не вызывай /команду». Каждый блок имеет свою механику включения и отмены.
**Счётчики (finding 3, v3.15):** числа позиций тулчейна и off-phase подкатегорий — канон [Tooling Прил. Н §0](Tooling_v8_3.md) (anchor «КАНОН СЧЁТЧИКОВ»). Реестр ниже ссылается per-row на Tooling #NN; агрегатные счётчики PSR_v1 не дублирует — это закрывает класс «дрейф счётчиков» (SYSTEM-аудит 18.05.2026).
#### Блок 1: `enabledPlugins` через marketplace (включаются в `~/.claude/settings.json`)
| Плагин | Marketplace | Роль | Когда инвокировать |
@@ -418,6 +430,11 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **product-management** *(6 команд `/write-spec`, `/roadmap-update` и др.)* | `anthropics/knowledge-work-plugins` (plugin `product-management@knowledge-work-plugins`, Anthropic Verified) | product-strategy церемонии (problem→spec, roadmap, stakeholder updates, research synthesis, competitive analysis, metrics review). Категория: **project-management** (Tooling #42). 0 хуков | при product-strategy work: написание спеки, обновление роадмапа, анализ конкурентов. Не UI → вне R6.0/R6.1/R14 |
| **Design plugin** *(Design Critique / Accessibility Audit / UX Writing / Research Synthesis)* | `anthropics/knowledge-work-plugins` (Anthropic Verified) | дизайн-критика и UX — ревью макетов, дизайн-уровневый a11y-аудит, UX-копирайт, research synthesis. Категория: **design-tooling** (Tooling #46, вне UI-пула) | при дизайн-критике макета, UX-анализе, написании микрокопирайта — pre-code (ADR-006). Не подменяет FD #30 (генерация) и `requesting-code-review`. Не UI → вне R6.0/R6.1/R14 |
| **operations** *(9 skills: `process-doc` / `process-optimization` / `change-request` / `capacity-plan` / `compliance-tracking` / `risk-assessment` / `runbook` / `status-report` / `vendor-review`)* | `anthropics/knowledge-work-plugins` (plugin `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified) | бизнес-процессы — документирование процесса, оптимизация, change-management, capacity-планирование. Категория: **business-process** (Tooling #51, вне UI-пула). 0 lifecycle-хуков | при работе с бизнес-процессом — документирование/оптимизация/change-request/capacity. Не UI → вне R6.0/R6.1/R14 |
| **skill-creator** *(1 skill)* | `anthropics/claude-plugins-official` (Anthropic Verified) | конструктор скилов — создание standalone-скилов с нуля, модификация, performance-eval/benchmark, оптимизация `description` под триггеринг. Категория: **authoring-tooling** (Tooling #56, вне UI-пула) | при создании нового **standalone** проектного скила. SC1 — граница с plugin-dev:skill-development (скилы внутри плагина); SC2 — вендоренные/self-authored скилы правятся прямым Edit, не через skill-creator (риск потери провенанса). Не UI → вне R6.0/R6.1/R14 |
| **plugin-dev** *(8 skills + агенты `agent-creator` / `plugin-validator` / `skill-reviewer`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | конструктор Claude-плагинов — структура / агенты / скилы / команды / хуки / MCP-интеграция / settings. Категория: **authoring-tooling** (Tooling #57) | при разработке собственного marketplace-плагина. PD1 — не для модификации вендоренного/self-authored (SC2); PD3 — `plugin-dev:hook-development` генерирует хук → применяется правило HK1. Не UI → вне R6.0/R6.1/R14 |
| **hookify** *(skills `/hookify` / `/configure` / `/list` / `/help` + `writing-rules` + агент `conversation-analyzer`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | генератор хуков из анализа транскриптов диалога / явных инструкций. Категория: **authoring-tooling** (Tooling #58) | **только по явному `/hookify`**, не проактивно (HK2). **HK1 hard-rule:** перед генерацией хука — обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json`; перезапись 6-компонентной economy/skill-discipline архитектуры (economy-mode / skill-marker / skill-check / state-guard / postcompact / verifier) **запрещена**; при коллизии — остановка, ручное согласование. HK3 — закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Не UI → вне R6.0/R6.1/R14 |
| **claude-code-setup** *(skill `claude-automation-recommender`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | рекомендатель Claude Code automations — анализ кодовой базы + советы (хуки / суб-агенты / скилы / плагины / MCP). Read-only. Категория: **dev-support** (Tooling #59, вне UI-пула) | при запросе на оптимизацию Claude Code setup. CCS1 — рекомендации фильтруются R0 stack-gate + R10.1; ничего не устанавливается без явного согласования заказчика. Не UI → вне R6.0/R6.1/R14 |
| **context7** *(MCP-tools `query-docs` / `resolve-library-id`)* | `anthropics/claude-plugins-official` (Anthropic Verified) — плагин в `enabledPlugins`, не `.mcp.json`-сервер | актуальная документация библиотек / фреймворков / SDK — отдаёт upstream-доки, обходит cutoff training data. Категория: **dev-support** (Tooling #60) | **первый выбор** для документации **известной библиотеки** (Laravel / Vue / Vuetify / Pest / React / …). CTX1 — WebFetch для конкретного URL, WebSearch — поиск без знания библиотеки. Не UI → вне R6.0/R6.1/R14 |
**Блок 1 — note (v3.3):** **mermaid-skill** (Tooling #37, генератор C4/architecture-диаграмм) — вендоренный сторонний скил в `.claude/skills/mermaid/` (`WH-2099/mermaid-skill`, MIT), **не** через marketplace и **не** в `enabledPlugins`. Пассивная утилита (генерация Mermaid-исходника), не решатель — формально вне типологии трёх блоков; регистрируется здесь для полноты. Категория **architecture-tooling**, вне R6/R14.
@@ -431,6 +448,8 @@ Stack — **головной**. Все плагины вне stack'а — **ин
**Блок 1 — note (v3.12):** **discovery-interview** (Tooling #55) — self-authored project-скил в `.claude/skills/discovery-interview/`, **не** вендоренный сторонний и **не** через marketplace; написан проектом (паттерн project-скилов `audit-portal`/`regression`/`process-modeling`/`process-analysis`). **Линтуется** lefthook'ом (cspell+markdownlint), **не** в `cspell.json` `ignorePaths` / `.markdownlintignore` (LINT1). Категория **discovery-tooling** (12-я off-phase подкатегория), вне R6.0/R6.1/R14.
**Блок 1 — note (v3.13):** 5 Anthropic dev-плагинов — **skill-creator** (#56) / **plugin-dev** (#57) / **hookify** (#58) / **claude-code-setup** (#59) / **context7** (#60) — marketplace-плагины из `anthropics/claude-plugins-official`, включены в `~/.claude/settings.json` `enabledPlugins` user-level. Формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин включён без формализации» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Две новые off-phase подкатегории: **authoring-tooling** (13-я — #56-#58, создание Claude-артефактов) + **dev-support** (14-я — #59-#60, поддержка/документация Claude-разработки), не UI → вне R6.0/R6.1/R14. **hookify** несёт hard-rule HK1 (pre-check на коллизию с existing хуками). `context7` — плагин из marketplace (не `.mcp.json`-сервер Блока 3), хотя предоставляет MCP-tools. ADR-010, Tooling §4.31–§4.35.
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
@@ -758,9 +777,86 @@ Pipeline активируется при одновременном выполн
---
## Правило 15 — Off-phase routing
Закрывает Rec5 SYSTEM-аудита 18.05.2026: R-аппарат R0–R14 регулирует почти исключительно UI-фичи (stack-gate R0, классификация R1, фазы R2, фильтр R6, источники UI R11, паттерны решений R12, decision matrix R13, UI-pipeline R14). Off-phase множество (30 узлов #31-#60 + ruflo + infrastructure) регулировалось одним R10.1 + меткой «не UI → вне R6.0/R6.1/R14», без явной матрицы «задача → узел». R15 — собственный слой регламента для off-phase.
### 15.1. Off-phase узлы вне UI-фильтров
R6.0 / R6.1 / R14 pipeline **не применяются** к off-phase узлам. Причина: эти узлы не производят UI-код / визуал бренда — Trail of Bits сканирует security, deptrac анализирует слои зависимостей, openapi-mcp интроспектирует REST API, sentry читает production errors. Применять стек-фильтр к их выводу — категорийная ошибка. Codifies существующую практику (PSR_v1 v3.3–v3.13 каждая интеграция помечала off-phase как «не UI → вне R6/R14»; теперь это явно правило).
**R15 — пост-R1 слой.** Off-phase routing срабатывает **после** классификации задачи Правилом 1, как выбор инструмента внутри назначенной ветки, а не как отдельная шестая ветка R1. Задача «сделай security-аудит diff» классифицируется R1 как процессная → внутри stack работает Superpowers → если требуется off-phase инструмент (Trail of Bits #39), его выбор регулирует R15-таблица. Финальная формула это отражает: «→ если задача off-phase: Правило 15». R15 не конкурирует с R0/R1 за gate — он работает внутри их рамок.
### 15.2. Routing-таблица — внешний документ
Полная таблица «задача → off-phase узел» вынесена в [`docs/routing-off-phase.md`](routing-off-phase.md) v1.0+. Там же — 12 канонических связок 2+ узлов (L1–L12, закрывает Rec4 SYSTEM-аудита: brainstorming-chain, security-слой, project-management-связка, runtime-debug, ML-trio и т.д.) + список anti-pattern связок.
PSR_v1 не дублирует 30-строчную таблицу — single home в routing-off-phase.md. При коллизии содержимого побеждает routing-off-phase.md (он SoT по off-phase routing); R15.1/R15.3R15.5 этого правила — мета-слой.
### 15.3. Приоритет специфичности при коллизии узлов
Если задача попадает под 2+ off-phase узлов:
1. **Более специфичный узел** перевешивает менее специфичный (например задача «процессное узкое место из кода Laravel» → `process-analysis` #53 специфичнее общего `operations` #51).
2. **ADR-границы** имеют приоритет над интуицией: пары узлов, где границы зафиксированы в ADR (DI1DI6 в ADR-009 для discovery-interview ↔ process-analysis; OPS1OPS5 в ADR-008 для operations ↔ process-modeling; UI1UI3 в ADR-006 для Universal Icons; TB1 для Trail of Bits ↔ Semgrep) — следуем ADR.
3. **DEFERRED-узлы** (mcp_figma #44 / Jupyter MCP #50 / n8n-mcp #54) — пропускать; эскалация заказчику если задача требует их.
4. **Изолированные узлы** (ruflo на 18.05.2026 — Pravila §14.9 dormant) — не маршрутизировать; queen-триггер сейчас не работает.
### 15.4. Hard-rules перевешивают R15
Pravila §12 (Superpowers инвокация первой), §14 (queen-роутинг — сейчас dormant), §15 (параллельные сессии + субагенты git Sonnet/Opus only) — explicit hard-rules. При коллизии с R15 побеждают они. Например запрос с триггером `queen` (когда §14 не dormant) маршрутизируется через ruflo Queen независимо от R15-таблицы; git-коммит-субагент идёт через Sonnet/Opus независимо от того, в каком off-phase узле задача.
### 15.5. Live-override
Заказчик может явно назвать узел в промпте («через `process-modeling`», «возьми `mermaid`», «`adr-kit` сделай»). В этом случае R15-таблица **не применяется** — выполнить именно названный узел. Если выбор кажется неоптимальным — кратко отметить (одна строка) и продолжить.
### 15.6. Гранулярные особенности категорий
- **debug-runtime** (#34 sentry, #35 redis) — READ-ONLY обязательно. Никаких DEL/SET/FLUSH из Claude.
- **UI-пул** (#31 UPM, #32 21st) — здесь R15 не применяется; R14 pipeline ведёт (это UI-задачи по природе).
- **infrastructure** (#33 claude-md-management) — единственный канал для правок CLAUDE.md (Pravila §5 п.10 + R10.1 Блок 1).
- **authoring-tooling** (#56-#58) — политика триггеров: skill-creator ≥3 повторений workflow → новый скил; hookify повторяющаяся ошибка → новый хук (с pre-check HK1); plugin-dev — для расширений plugin-grain.
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support** — следуют routing-off-phase.md.
### 15.7. Тип правила и enforcement
R15 — обычное правило (не hard-rule). Pravila §9 «Отступления» применяется при необходимости с явным указанием. Нарушение R15 (использование «не того» off-phase узла) — фиксируется в feedback memory, не trigger'ит hard-rule violations.
---
## Правило 16 — Brain evidence loop
**Status**: introduced PSR_v1 v3.16 (2026-05-19) per ADR-011.
### 16.1. Observer scope
Observer Stop-хук (`tools/observer-stop-hook.mjs`) пишет evidence в `docs/observer/episodes-YYYY-MM.jsonl` каждую сессию. Поля: `task_id` / `timestamps` / `path_type` / `outcome` / `primary_rationale` + optional `events[]` (per spec v1.1 §5.2.1).
Схема v2 (2026-05-19, ADR-011 amend): эпизод несёт `schema_version`, `decision_provenance` (autonomous / user_directed_method + контрфактуал), `environment` (`economy_level` / `model` / `post_compaction` / `session_turn` / `parallel_session`), `task_size`, `task_ref`, `prompt_signal`; события расширены `hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`. При внутреннем отказе хука — минимальный `observer_error` маркер вместо тихого пропуска. Spec — `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`.
### 16.2. Plugin stack-conscious events
Когда в сессии используется UI-фильтр стека (R6/R6.1) или off-phase узел (R15), observer записывает событие `routing_decision` или `skill_invoked` с `node_id` (ссылка на Tooling Прил. Н §4.NN). Это позволяет `/brain-retro` проагрегировать «какие R6/R15 решения чаще всего применялись» через факторную матрицу (5 осей: triggers_matched / candidates_dropped_because / boundaries_applied / hard_floor.rules / task_classification).
### 16.3. Не override
R16 — evidence-сбор, не правило выбора. R0–R15 продолжают определять выбор узлов; R16 фиксирует историю и enables факторный анализ.
### 16.4. Cross-refs
- ADR-011 `docs/adr/ADR-011-brain-governance.md`
- Pravila §16 (brain governance hard-rule tier-§13)
- spec: `docs/superpowers/specs/2026-05-19-brain-governance-design.md`
- spec (factor-analysis): `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
- plan: `docs/superpowers/plans/2026-05-19-brain-governance.md`
- plan (factor-analysis): `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`
- procedure: `docs/router-procedure.md`
---
## Финальная формула
> **Любая задача → Правило 0 (gate, stack-головной) → Правило 1 (классификация по типу) → Правило 9 (решение, ≤2 итерации) → Правило 13 (decision matrix по уверенности) → Правило 2 (фаза UI-фичи) → исполнение по Правилам 3, 4, 6 → если нужен внешний UI-генератор: Правило 14 pipeline (UPM на фазах 1/2, 21st на фазе 5) → завершение по Правилу 7 → ревью по Правилу 5. Источники истины — Правило 11 (UI/UX). Паттерны решений — Правило 12. Координация с не-stack плагинами — Правило 10. Тай-брейкеры — Правило 8.**
> **Любая задача → Правило 0 (gate, stack-головной) → Правило 1 (классификация по типу) → Правило 9 (решение, ≤2 итерации) → Правило 13 (decision matrix по уверенности) → Правило 2 (фаза UI-фичи) → исполнение по Правилам 3, 4, 6 → если нужен внешний UI-генератор: Правило 14 pipeline (UPM на фазах 1/2, 21st на фазе 5) → если задача off-phase (security / архитектура / процесс / discovery / ML / debug / интеграция / authoring / docs-tooling): Правило 15 (routing-off-phase.md + ADR-границы) → завершение по Правилу 7 → ревью по Правилу 5. Источники истины — Правило 11 (UI/UX). Паттерны решений — Правило 12. Координация с не-stack плагинами — Правило 10. Тай-брейкеры — Правило 8.**
---
@@ -790,6 +886,12 @@ Pipeline активируется при одновременном выполн
## История версий
- **v3.17 (2026-05-19)** — observer schema v2 sync (ADR-011 amend): R16.1 +предложение про `schema_version` / `decision_provenance` / `environment` / `task_size` / `prompt_signal` + расширенные события (`hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`) + `observer_error` маркер; R16.4 +cross-ref на factor-analysis spec и plan. R0R15 без изменений. Routing-gate / C5 controller / `/brain-retro` analyzer — нормативно в Pravila §16.7/§16.8 + ADR-011 §5; PSR_v1 фиксирует evidence-сбор (R16), не enforcement. Связано: ADR-011, Pravila v1.32 (§16 amend), CLAUDE.md v2.19, spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`. Per spec v1.0 §7.
- **v3.16 (2026-05-19)** — Brain evidence loop: новое R16 «Brain evidence loop» (R16.1 observer scope — Stop-хук `tools/observer-stop-hook.mjs` пишет `docs/observer/episodes-YYYY-MM.jsonl`, 5 обязательных полей: `task_id` / `timestamps` / `path_type` / `outcome` / `primary_rationale` + optional `events[]` per spec v1.1 §5.2.1; R16.2 plugin stack-conscious events — при использовании R6/R6.1 или R15 off-phase observer пишет `routing_decision` / `skill_invoked` с `node_id`, факторная матрица 5 осей для `/brain-retro`: triggers_matched / candidates_dropped_because / boundaries_applied / hard_floor.rules / task_classification; R16.3 не override — R0–R15 определяют выбор узлов, R16 только фиксирует историю; R16.4 cross-refs). R0R15 без изменений. Связано: ADR-011 `docs/adr/ADR-011-brain-governance.md`, Pravila §16, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`, procedure `docs/router-procedure.md`. Per spec v1.1 §5.2.1 amendment.
- **v3.14 (2026-05-18)** — Off-phase routing: новое R15 «Off-phase routing» (R15.1 off-phase узлы вне UI-фильтров R6.0/R6.1/R14 — codifies существующую практику; R15.2 routing-таблица в `docs/routing-off-phase.md` v1.0+ как single home; R15.3 приоритет специфичности + ADR-границы (DI1-DI6 / OPS1-OPS5 / UI1-UI3 / TB1) при коллизии; R15.4 Pravila §12/§14/§15 перевешивают R15; R15.5 live-override заказчика; R15.6 гранулярные категории; R15.7 обычное правило, не hard-rule). Финальная формула расширена шагом «→ Правило 15 (routing-off-phase.md + ADR-границы) для off-phase». Свойства свода — добавлено R15 в полноту и непротиворечивость. UI-аппарат R0-R14 — без изменений. Слот R15 был свободен после удаления motion-системы в v2.0; теперь занят off-phase routing. Связано: `docs/routing-off-phase.md` v1.0 (новый файл, 30 off-phase узлов + 12 канонических связок Rec4), Pravila v1.29 / Tooling v2.15 / CLAUDE.md v2.16 (pending sync). Snapshot — `docs/discovery/2026-05-18-system-audit-brain.md` Rec5. Через manual Edit. **In-place 18.05 вечер (аудит дисциплины R15):** R15.1 +абзац «R15 — пост-R1 слой» (off-phase routing срабатывает после классификации R1, как выбор инструмента внутри ветки, не отдельная шестая ветка R1 — M2-находка аудита). Содержательных изменений R-аппарата 0; routing-off-phase.md синхронно → v1.1 (note про UI-пул #31/#32 — делегирующие ссылки на R14, не R15-routed; +строка «диагностика конверсии» → process-analysis #53).
- **v3.8 (2026-05-17)** — A4 design-tooling: R10.1 Блок 1 +Design plugin (`anthropics/claude-plugins-official`, Anthropic Verified) — дизайн-критика и UX, новая 8-я off-phase подкатегория design-tooling; Блок 3 +Universal Icons MCP (`npx -y mcp-universal-icons`, MIT) + Figma MCP (remote `https://mcp.figma.com/mcp`, DEFERRED). Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.8, Pravila v1.22, CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`.
- **v3.7 (2026-05-17)** — A6-расширение deptrac: R10.1 Блок 1 +note «Блок 1 — note (v3.7)» — **deptrac** (`deptrac/deptrac` v4.6.1, BSD-3, Composer dev-dependency — **не** marketplace-плагин и **не** в `enabledPlugins`, регистрируется нотой как mermaid-skill/CCPM; врезан lefthook pre-commit job 10). Категория **architecture-tooling** (Tooling #43, раздел A6 карты) — 4-й инструмент подкатегории, не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.7, Pravila v1.21, CLAUDE.md v2.7. План `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`.
+162 -3
View File
@@ -1,10 +1,22 @@
# Правила работы Claude в проекте «Лидерра»
**Версия:** v1.26 (18.05.2026)
**Дата:** 18.05.2026
**Версия:** v1.33 (19.05.2026)
**Дата:** 19.05.2026
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
**Что изменилось в v1.33 относительно v1.32:** observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` | `user_directed_method` | `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из вариантов, предложенных Claude в предыдущем ходе — например `1`, `в делаем`, `делай 2`). §16.7 +абзац: routing-gate НЕ блокирует `user_chose_from_options` (выбор из choice-space, сформулированного самим Claude — не навязанный извне метод). Детектор — `tools/observer-choice-detector.mjs` (детерминированный, тег не требуется). Spec §11 `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` v1.1, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`. Связано: CLAUDE.md v2.20.
**Что изменилось в v1.32 относительно v1.31:** observer factor-analysis extension (ADR-011 amend): §16.2 +абзац «Схема эпизода v2» (`schema_version: 2`, `decision_provenance`, `environment`, `task_size`, `task_ref`, `prompt_signal`; `outcome` `unknown` при записи; виды событий расширены `hook_fired`/`interrupt`/`retry`/`time_burn`/`parse_gap`); §16.3 4→5 контролёров (+C5 observer-coverage-checker, warn-only); §16.7 (новое) routing-тег-дисциплина — Stop-хук `decision: block` при навязанном методе без тега, `stop_hook_active` guard против петли; §16.8 (новое) самодисциплина наблюдателя (`observer_error` маркер вместо тихого пропуска, `parse_gap` событие, C5 контролёр); §16.6 +cross-ref на factor-analysis spec. Spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`. Связано: PSR_v1 v3.17, CLAUDE.md v2.19.
**Что изменилось в v1.31 относительно v1.30:** +§16 «Регламент «мозга» (brain governance)» — router-only архитектура (§16.1), observer Stop-event (§16.2), 4 контролёра C1-C4 (§16.3), поведенческое правило «не использован ≠ проблема» (§16.4), явная метка «не override-floor §9» (§16.5), cross-refs (§16.6). Уровень рекомендации §13 — НЕ explicit hard-rule вне §9. ADR-011 enforcement через `adr-judge` lefthook job. Связано: ADR-011, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`.
**Что изменилось в v1.29 относительно v1.28:** +§14.9 «Текущий статус: изолирован (18.05.2026, dormant)» — заказчик распорядился изолировать ruflo от активного потока без удаления артефактов (ход Rec2 SYSTEM-аудита `docs/discovery/2026-05-18-system-audit-brain.md`, маршрут «изолируй, не удаляй»). Live-связи ruflo с Claude-потоком отключены: оба `tools/ruflo-*-hook.mjs` сняты из `.claude/settings.json` UserPromptSubmit; `ruflo` MCP-server удалён из `.mcp.json`; PM2 `ruflo-daemon` остановлен + dump.pm2 = `[]`; Windows Task Scheduler `PM2-ruflo-daemon` оставлен (идемпотентен после пустого save). Артефакты сохранены: npm-пакет, файлы хуков `tools/ruflo-*-hook.mjs`, memory `mem_ruflo`, документация (этот §14, Tooling §4.10, CLAUDE.md §3.5). Queen-триггер §14.1 сейчас **dormant** — хук-инжектор не подаёт директиву; промпт с `queen`/`королева` выполняется напрямую. Откат §14 как нормативного текста заказчик не запрашивал — только изоляции рантайма. План реактивации — memory `feedback_ruflo_isolated.md`. Связано: Tooling v2.15. Архитектурных изменений в §§1–13 + §§14.1-14.8: 0.
**Что изменилось в v1.28 относительно v1.27:** §13.2 +абзац «Off-phase authoring-tooling + dev-support» — формализованы 5 Anthropic dev-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (#56 skill-creator, #57 plugin-dev, #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup, #60 context7 — четырнадцатая off-phase подкатегория dev-support). L1-паттерн «плагин включён без формализации» (повтор UPM/21st 10.05, Sentry/Redis 13.05). hookify несёт hard-rule HK1 — pre-check на коллизию с economy/skill-discipline хуками. Границы — ADR-010. Связано: Tooling v2.14 / PSR_v1 v3.13 / CLAUDE.md v2.15. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
**Что изменилось в v1.27 относительно v1.26:** +§15 hard-rule «Параллельные сессии» (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync, 15.3 cross-refs). §15 третье hard-rule после §12 и §14. Список «нормативка» — 8 позиций. Спек — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`.
**Что изменилось в v1.26 относительно v1.25:** §13.2 +абзац «Off-phase discovery-tooling» — формализован скил `discovery-interview` (Tooling #55; self-authored project-скил `.claude/skills/discovery-interview/`, режимы FEATURE+SYSTEM) как двенадцатая off-phase подкатегория; как проектный скил регистрируется в §13.2, не §12.2. Границы — ADR-009 (DI1–DI6, разрез по слою-источнику с process-analysis #53). Связано: Tooling v2.13 / PSR_v1 v3.12 / CLAUDE.md v2.13. План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
**Что изменилось в v1.25 относительно v1.24:** §13.2 +абзац «Off-phase business-process» — формализованы инструменты раздела C10 карты «Бизнес-процессы (общее)» (#51 operations — marketplace-плагин 9 скилов; #52 process-modeling, #53 process-analysis — self-authored project-скилы; #54 n8n-mcp — DEFERRED, у портала нет n8n) как одиннадцатая off-phase подкатегория. Границы — ADR-008. Связано: Tooling v2.12 / PSR_v1 v3.11 / CLAUDE.md v2.12. План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`.
@@ -585,6 +597,13 @@ P0 = блокер старта спринта или регуляторного
| **v1.24** | **17.05.2026** | A11 ml-ai-tooling: §13.2 +абзац «Off-phase ml-ai-tooling» — формализованы инструменты раздела A11 карты «ML / AI-разработка» (#48 promptfoo — npm devDependency, CLI-eval LLM-промптов; #49 Data Scientist skill — вендоренный сторонний скил; #50 Jupyter MCP — DEFERRED, требует Python ML-окружения) как десятая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. promptfoo делает платные LLM-вызовы — только вручную/CI, никогда в хук (ML1). Границы — ADR-007. Связано: Tooling v2.10 / PSR_v1 v3.10 / CLAUDE.md v2.10. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн). План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.25** | **17.05.2026** | C10 business-process: §13.2 +абзац «Off-phase business-process» — формализованы инструменты раздела C10 карты «Бизнес-процессы (общее)» (#51 operations — marketplace-плагин 9 скилов; #52 process-modeling — self-authored BPMN-скил; #53 process-analysis — self-authored discovery-скил; #54 n8n-mcp — DEFERRED, workflow-движок, у портала нет n8n) как одиннадцатая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. Границы — ADR-008. Связано: Tooling v2.12 / PSR_v1 v3.11 / CLAUDE.md v2.12. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24). План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.26** | **18.05.2026** | discovery-interview: §13.2 +абзац «Off-phase discovery-tooling» — формализован скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/`, режимы FEATURE+SYSTEM — интервью-discovery до проектирования) как двенадцатая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. Как проектный скил регистрируется в §13.2, **не** в §12.2 (карта Superpowers-скилов); триггер-eval 20/20. Границы — ADR-009 (DI1DI6). Связано: Tooling v2.13 / PSR_v1 v3.12 / CLAUDE.md v2.13. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24/v1.25). План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.27** | **18.05.2026** | Параллельные сессии: координация. +§15 hard-rule (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync, 15.3 cross-refs). §15 третье hard-rule после §12 и §14; список «нормативка» — 8 позиций. Лечит два класса инцидентов параллельных-сессий: (A) субагенты теряются между worktree (Sprint 6 прецедент), (B) нормативка/MEMORY дрейфует (Tooling v2.11 collision 17.05.2026). Спек — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`, план — `docs/superpowers/plans/2026-05-18-parallel-sessions-coordination.md`. |
| **v1.28** | **18.05.2026** | Anthropic dev-tooling: §13.2 +абзац «Off-phase authoring-tooling + dev-support» — формализованы 5 Anthropic-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (#56 skill-creator / #57 plugin-dev / #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup / #60 context7 — четырнадцатая off-phase подкатегория dev-support); не UI → вне R6.0/R6.1/R14. L1-паттерн «плагин включён без формализации» (повтор UPM/21st 10.05, Sentry/Redis 13.05). hookify несёт hard-rule HK1 — pre-check на коллизию с economy/skill-discipline хуками; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Границы — ADR-010 (SC1SC3 / PD1PD3 / HK1HK3 / CCS1 / CTX1CTX2). Связано: Tooling v2.14 / PSR_v1 v3.13 / CLAUDE.md v2.15. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24/v1.25/v1.26). **NB:** перенумеровано v1.27→v1.28 — v1.27 параллельно занят parallel-sessions §15 (origin/main `781a59c`); ветка `feat/anthropic-dev-tooling` ребейзнута на §15. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.29** | **18.05.2026** | ruflo isolation (Rec2 SYSTEM-аудита 18.05): +§14.9 «Текущий статус: изолирован, dormant». Заказчик распорядился отрезать ruflo от активного потока без удаления артефактов. Live-связи отключены: `tools/ruflo-recall-hook.mjs` + `tools/ruflo-queen-hook.mjs` сняты из `.claude/settings.json` UserPromptSubmit; `ruflo` MCP-server удалён из `.mcp.json`; PM2 `ruflo-daemon` остановлен (`pm2 stop` + `delete` + `save --force`, `~/.pm2/dump.pm2` = `[]`); Task Scheduler `PM2-ruflo-daemon` оставлен (идемпотентен — после пустого save resurrect восстанавливает пустое состояние). Артефакты сохранены: npm-пакет `ruflo`, файлы хуков `tools/ruflo-*-hook.mjs`, memory `mem_ruflo`, документация. Queen-триггер §14.1 сейчас **dormant** — хук-инжектор не подаёт директиву; промпт с `queen`/`королева` выполняется напрямую. Откат §14 заказчик не запрашивал, только изоляции рантайма. Связано: Tooling v2.15, CLAUDE.md v2.16 (pending sync), memory `feedback_ruflo_isolated.md`. Snapshot — `docs/discovery/2026-05-18-system-audit-brain.md` Rec2. Через прямой Edit (нормативка) + Bash (pm2/runtime) + Edit `.claude/settings.json` + Edit `.mcp.json`. Архитектурных изменений в §§1–14.8: 0. |
| **v1.30** | **18.05.2026** | Компакция «мозга» (SYSTEM-аудит findings 2/3/6/7, интервью с заказчиком). **§14 (finding 6):** заголовок +метка «СТАТУС: dormant с 18.05.2026 (§14.9)»; §14.1 +врезка о dormant-статусе перед нормативным текстом — раньше §14.5 объявлял §14 живым hard-rule, а §14.9 dormant-статус был виден только в конце параграфа; теперь читателю §14 виден сразу. **§13.2 (finding 3):** +note «счётчики off-phase подкатегорий/инструментов — канон [Tooling Прил. Н §0](Tooling_v8_3.md)»; ординалы в абзацах §13.2 объявлены описательными. Связано: CLAUDE.md v2.17 (§3.3 компакция + счётчики-пины + ruflo-стаб), Tooling Прил.Н v2.16 (§0 +«КАНОН СЧЁТЧИКОВ»), PSR_v1 v3.15 (R10.1 пин). План `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`. Через прямой Edit. Архитектурных изменений в §§1–14 (кроме §14 заголовок/§14.1 врезка + §13.2 note): 0. |
| **v1.31** | **19.05.2026** | Brain governance: +§16 «Регламент «мозга»» (router-only архитектура §16.1 + observer Stop-event §16.2 + 4 контролёра C1-C4 §16.3 + поведенческое правило «не использован ≠ проблема» §16.4 + не override-floor §9 §16.5 + cross-refs §16.6). Уровень рекомендации §13 — НЕ explicit hard-rule вне §9. Тремя hard-rules вне §9 остаются §12 / §14 (dormant) / §15. ADR-011 enforcement через `adr-judge` lefthook job (секция `## Enforcement` обязательна). Связано: ADR-011 `docs/adr/ADR-011-brain-governance.md`, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`, procedure `docs/router-procedure.md`, memory `feedback_brain_unused_tools_not_problem.md` + `project_brain_governance_design.md`. Архитектурных изменений в §§1–15: 0. |
| **v1.32** | **19.05.2026** | Observer factor-analysis extension (ADR-011 amend): §16.2 +абзац «Схема эпизода v2» (`schema_version: 2`, `decision_provenance`, `environment`, `task_size`, `task_ref`, `prompt_signal`; `outcome` `unknown` при записи; виды событий +`hook_fired`/`interrupt`/`retry`/`time_burn`/`parse_gap`); §16.3 4→5 контролёров (+C5 observer-coverage-checker, warn-only); §16.7 (новое) routing-тег-дисциплина — Stop-хук `decision: block` при навязанном методе без тега, `stop_hook_active` guard; §16.8 (новое) самодисциплина наблюдателя (`observer_error` маркер, `parse_gap` событие, C5). Spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`. Связано: PSR_v1 v3.17, CLAUDE.md v2.19. Архитектурных изменений в §§1–15: 0. |
| **v1.33** | **19.05.2026** | Observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` \| `user_directed_method` \| `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из вариантов, предложенных Claude в предыдущем ходе). §16.7 +абзац «Граница `user_chose_from_options`»: routing-gate НЕ блокирует этот kind — выбор из choice-space, сформулированного самим Claude, не навязанный извне метод; routing-тег не обязателен (детектор `tools/observer-choice-detector.mjs` детерминированный). Spec §11 `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` v1.1, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`. Связано: CLAUDE.md v2.20. Архитектурных изменений в §§1–15: 0. |
---
@@ -717,6 +736,8 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
**Инфраструктурные плагины (вне расширенного UI-пула, v1.9+):** `claude-md-management` (skills `claude-md-improver` + `revise-claude-md`, marketplace `anthropics/claude-plugins-official`) — единственный интерфейс правок CLAUDE.md (CLAUDE.md §5 п.10). Категория **инфраструктурная**, не UI — поэтому не попадает под §13 (расширенный UI-пул) и не проходит R6.0/R6.1 фильтр / R14 pipeline. Регулируется PSR_v1 R10.1 блок 1 (`enabledPlugins`-плагины) как off-pool tool. Аналогичные инфраструктурные категории — built-in skills Claude Code (`review`, `security-review`, `init`, `simplify`, `update-config`, `keybindings-help`, `fewer-permission-prompts`, `loop`, `schedule`, `claude-api`) — активируются по явному `/имя` от пользователя; PSR_v1 R10.1 блок 2.
**Счётчики off-phase подкатегорий и инструментов** (ординалы «пятая… четырнадцатая подкатегория», номера #NN) в абзацах ниже — описательные. Канон числовых счётчиков тулчейна — [Tooling Прил. Н §0](Tooling_v8_3.md) (anchor «КАНОН СЧЁТЧИКОВ»); при расхождении приоритет — Tooling §0 (finding 3 SYSTEM-аудита 18.05.2026 — устранение дрейфа счётчиков).
**Off-phase MCP debug-runtime (отдельная категория, введена v1.13 Pravila, 13.05.2026 day +1):** `@sentry/mcp-server@0.33.0+` (Tooling #34, server `sentry` в `.mcp.json`) — отладка production errors в self-hosted Sentry (Yandex Cloud per CLAUDE.md §2; pending Б-1 ООО registration); `@modelcontextprotocol/server-redis@2025.4.25` (Tooling #35, server `redis` в `.mcp.json`; deprecated Anthropic source; Memurai PONG verified Task 4) — отладка Redis/Memurai runtime (очереди, кэш, Pest --parallel races per quirk 72/77). **Категория отдельная** от UI-пула (§13.2 paired-stack + UPM + 21st) и от infrastructure (claude-md-management §13.2 paragraph выше) — **не trigger'ит R6.0/R6.1 stack-фильтры** (READ-ONLY, не модифицируют code/UI/CLAUDE.md) и **не входит в R14 pipeline** UI-генераторов. Регулируется PSR_v1 R10.1 Блок 3 (`.mcp.json`-серверы) как debug-runtime off-phase tool. READ-ONLY usage обязателен — никаких mutation операций (DEL/FLUSHDB/SET/LPUSH для Redis; write actions для Sentry). Установлены retrospective на feat/claude-automation `6f7e7d7` (sentry) + `bd4ec48` (redis), merged через PR #3 (`cc5f63b`). PSR_v1 cross-ref: **v3.6+**, R10.1 Блок 3.
**Off-phase architecture-tooling (отдельная категория, v1.17, 17.05.2026; +deptrac v1.21):** четыре инструмента раздела A6 карты «Архитектура систем» — `adr-kit` (Tooling #36, marketplace `rvdbreemen/adr-kit`; ADR-решения в `docs/adr/`, `adr-judge` врезан в lefthook pre-commit job 9 декларативно, без `--llm`), `mermaid-skill` (Tooling #37, вендоренный сторонний скил `.claude/skills/mermaid/`; C4/architecture-диаграммы), `architecture-patterns` (Tooling #38, marketplace `secondsky/claude-skills`; knowledge-only справочник паттернов), `deptrac` (Tooling #43, Composer dev-dependency `deptrac/deptrac` v4.6.1 BSD-3; архитектурный fitness-гейт направления зависимостей / границ слоёв — врезан в lefthook pre-commit job 10, конфиг `app/deptrac.yaml` 13 слоёв, чистый PHP без вызовов LLM). **Категория отдельная** от UI-пула (UPM/21st), infrastructure (claude-md-management) и debug-runtime (Sentry/Redis) — не UI, **не trigger'ит R6.0/R6.1 stack-фильтры и не входит в R14 pipeline**. Регулируется PSR_v1 R10.1 Блок 1 (adr-kit, architecture-patterns) + Блок 1 notes (mermaid-skill — вендоренный скил, deptrac — composer dev-dep — оба вне типологии трёх блоков). Установлены 17.05.2026 (adr-kit/mermaid/architecture-patterns — ветка `feat/a6-architecture-tooling`, план `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`; deptrac — план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`).
@@ -735,6 +756,8 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
**Off-phase discovery-tooling (v1.26, 18.05.2026):** скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/` — как `audit-portal`/`regression`/`process-modeling`/`process-analysis`) — структурированное интервью-discovery до проектирования: режим FEATURE (JTBD-интервью заказчика — вскрывает проблему, отдаёт discovery-brief в `brainstorming`), режим SYSTEM (интервью-ориентация по мета-слою проекта — карта/CLAUDE.md/MEMORY/Открытые_вопросы/Tooling/git log). **Двенадцатая** off-phase подкатегория. Не UI → вне R6.0/R6.1/R14 PSR_v1. Как **проектный** скил (не Superpowers-скил) регистрируется здесь в §13.2, **не** в §12.2 (карта Superpowers-скилов) — триггерится штатным механизмом using-superpowers по `description` (триггер-eval 20/20). Дубль с `process-analysis` #53 исключён разрезом по слою-источнику; границы — ADR-009 (DI1–DI6). Регулируется PSR_v1 R10.1 Блок 1 note (self-authored project-скил). Установлен 18.05.2026 на ветке `worktree-discovery-interview`; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
**Off-phase authoring-tooling + dev-support (v1.28, 18.05.2026):** 5 Anthropic dev-плагинов из marketplace `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level — формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин фактически включён без формализации в правилах» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Подкатегория **authoring-tooling** (тринадцатая, создание Claude-артефактов): #56 `skill-creator` (Tooling §4.31; конструктор standalone-скилов), #57 `plugin-dev` (§4.32; конструктор marketplace-плагинов — 8 sub-skills + 3 агента), #58 `hookify` (§4.33; генератор хуков). Подкатегория **dev-support** (четырнадцатая, поддержка/документация Claude-разработки): #59 `claude-code-setup` (§4.34; рекомендатель Claude Code automations, read-only), #60 `context7` (§4.35; актуальная документация библиотек). Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. **hookify** — особое правило: вызов только по явному `/hookify`, перед генерацией хука обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json` (перезапись 6-компонентной economy/skill-discipline архитектуры запрещена — конфликт-аудит HK1; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`). Границы D2–D5 — ADR-010. Регулируется PSR_v1 R10.1 Блок 1. Установлены 18.05.2026 на ветке `feat/anthropic-dev-tooling`; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
### 13.3. Скоуп
| Тип задачи | Кто отвечает |
@@ -817,12 +840,14 @@ Hard-link идёт через цепочку: R14 нарушено → R10.4 «
§13.10 — **второй hard-link** §13 (после §13.9). Mid-tier — между декларативными §§13.1–13.8 и hard-rule §12.
## 14. Ruflo Queen routing — hard rule (триггер queen/королева)
## 14. Ruflo Queen routing — hard rule (триггер queen/королева) — СТАТУС: dormant с 18.05.2026 (§14.9)
Введено 15.05.2026 на явное требование заказчика: «зафиксируй жёсткое правило, что когда я пишу queen или королева ты запускаешь через ruflo, и так же подправь правила чтобы чаще отправлял задачи через руфло». Дизайн — через `superpowers:brainstorming` (spec `docs/superpowers/specs/2026-05-15-ruflo-queen-trigger-and-delegation-design.md`).
### 14.1. Принцип
**(СТАТУС: правило сейчас dormant — ruflo изолирован 18.05.2026, см. §14.9; промпт с `queen`/`королева` исполняется напрямую, директива не инжектится. Нормативный текст §14.1–§14.8 ниже вступает в силу при реактивации ruflo.)**
Если промпт заказчика содержит триггер-слово `queen` (англ.) или `королева` (рус., в любой падежной форме) — задача **безусловно** маршрутизируется через ruflo Queen. Это explicit hard-rule (§14.5). Claude не оспаривает маршрут, не предлагает прямой путь, не ссылается на тривиальность задачи.
### 14.2. Механизм и cost-gate
@@ -858,6 +883,140 @@ Hard-link идёт через цепочку: R14 нарушено → R10.4 «
Откат §14 — только явным запросом заказчика «откати §14». При сбое `hive-mind spawn` (ruflo — alpha-софт) Claude сообщает о сбое и выполняет задачу напрямую как фоллбэк — это не нарушение §14 (правило требует попытки маршрута, а не работающей alpha-инфраструктуры).
### 14.9. Текущий статус: изолирован (18.05.2026, dormant)
Заказчик распорядился изолировать ruflo от активного потока, не удаляя артефакты (ход Rec2 SYSTEM-аудита 18.05.2026, маршрут «изолируй, не удаляй»). Live-связи ruflo с Claude-потоком отключены: оба `tools/ruflo-*-hook.mjs` сняты из `.claude/settings.json` UserPromptSubmit; `ruflo` MCP-server удалён из `.mcp.json`; PM2 `ruflo-daemon` остановлен (`pm2 stop` + `delete` + `save --force`, `~/.pm2/dump.pm2` = `[]`); Windows Task Scheduler `PM2-ruflo-daemon` оставлен — после пустого save идемпотентен, resurrect восстанавливает пустое состояние. Артефакты сохранены: npm-пакет `ruflo`, файлы `tools/ruflo-*-hook.mjs`, memory `mem_ruflo`, документация (Tooling §4.10, CLAUDE.md §3.5, этот §14).
**Следствие §14.1:** queen-триггер сейчас **dormant** — хук-инжектор отключён, директива в промпт не подаётся; промпт с `queen`/`королева` выполняется напрямую (как без триггера). При возобновлении подключения § 14.1 автоматически восстанавливает hard-rule статус — отката §14 как нормативного текста заказчик не запрашивал, только изоляции рантайма.
**Реактивация:** восстановить блок `UserPromptSubmit` в `.claude/settings.json` (2 хука) + `"ruflo": {...}` entry в `.mcp.json` + `pm2 start <ecosystem-config> && pm2 save --force`. Полный план реактивации — memory `feedback_ruflo_isolated.md` и `project_ruflo_integration.md`.
---
## 15. Параллельные сессии — hard rule (субагенты + git, нормативка + pre-flight sync)
Действует с 18.05.2026. **Hard rule**: §9 «Отступления» к §15 не применяется (как §12 и §14).
### 15.1 Субагенты + git
Git-коммит-задачи субагенту (любой `Task`-инвокейшн, чей prompt содержит `git commit`, `git push`, `git stage`, `git checkout`, `git switch`, `git merge`, `git rebase`, либо где явно ожидается коммит в результате) — **только модель Sonnet или Opus**, никогда Haiku. Контроллер, делегирующий git-операцию Haiku-субагенту — нарушение §15.1, фиксируется в feedback того же уровня, что §12.
Исключение — read-only git-операции (`git log`, `git status`, `git diff`, `git rev-parse`, `git branch --show-current`, `git worktree list`) — разрешены любой модели.
Прецедент-источник: Sprint 6 (17.05.2026) — Haiku-субагенты угнали ветку параллельной сессии, устранено через `git reflog` + `reset`. Корневая причина — отсутствие верификации HEAD/branch после Task-инвокации. Verify-протокол — `.claude/skills/subagent-driven-development/references/git-safety-checklist.md`.
### 15.2 Нормативные правки + pre-flight sync
Любая правка файлов из списка «нормативка» (см. ниже) выполняется **только** на актуальной базе `origin/main`. Pre-flight обязателен:
```bash
git fetch origin && git log HEAD..origin/main --oneline
```
Если есть untracked commits на `origin/main`, ребейз/merge **до начала правки**, не после.
Параллельная нормативная правка на устаревшей базе — нарушение §15.2. Признак нарушения: коммит правит файл, чья последняя версия на `origin/main` новее, чем версия в parent коммите правки.
**Список «нормативка» — 8 позиций:**
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` (и все `memory/*.md`)
6. `docs/Открытые_вопросы_v8_3.md`
7. `docs/adr/*.md` (glob — collision на ADR-NNN номере = тот же класс, что version-bump нормативки)
8. `db/schema.sql` (параллельные миграции из разных сессий = реальный риск; запись в `db/CHANGELOG_schema.md` сама не защищает от version-base дрейфа)
Расширение списка — отдельная правка §15.2, не «по ощущениям».
Дополнительно: при параллельных активных сессиях контроллер обязан добавить запись в `docs/sessions/CURRENT.md` до первой нормативной правки (claim) — формат и жизненный цикл записи описаны в `docs/sessions/README.md`. Конфликт-резолюция (file overlap / section overlap / version-claim collision) — там же.
### 15.3 Cross-refs в других файлах
- **CLAUDE.md §1 priority chain** — §15 рядом с §12 и §14 как hard-rule (см. footer-абзац после цепочки).
- **PSR_v1** — не правится: §15 не про координацию плагинов, а про координацию сессий.
- **Tooling** — не правится.
---
## 16. Регламент «мозга» (brain governance)
**Hard-rule статус**: рекомендация уровня §13 (transitive через ADR-011 enforcement); НЕ override-floor §9. См. §16.5.
### 16.1. Router-only архитектура
Маршрутизация «задача → узел/узлы» исполняется ровно одной процедурой — [`docs/router-procedure.md`](../router-procedure.md). Никакого каталога «проверенных цепочек» нет; каждая задача — свежая сборка. Подробности — spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md` §4.
### 16.2. Observer (scope B)
В Stop-event сессии Claude инвокирует хук `tools/observer-stop-hook.mjs`, который записывает одну JSONL-строку в `docs/observer/episodes-YYYY-MM.jsonl`. Дополнительные MD-заметки — `docs/observer/notes/YYYY-MM-DD-<slug>.md`.
Запись ОБЯЗАНА содержать 5 полей: `task_id` / `timestamps` / `path_type` / `outcome` / `primary_rationale` (structured object с 7 sub-fields per spec §5.2.1). Структурированные события (`routing_decision` / `hook_fired` / `chain_divergence` / `skill_invoked` / `error` / `confusion_marker` / `time_burn`) — опционально в массиве `events[]`.
**ПДн-фильтр** через regex (phone `+7XXXXXXXXXX`, email `***@***`, токены gitleaks-style) — обязателен перед write.
**Граница**: observer **только пишет**, не правит нормативку. Решения принимаются вручную заказчиком через `/brain-retro` skill.
**Схема эпизода v2 (2026-05-19, factor-analysis extension):** эпизод несёт `schema_version: 2` и поля для факторного анализа — `decision_provenance` (кто выбрал узел), `environment` (`economy_level` / `model` / `post_compaction` / `session_turn` / `parallel_session`), `task_size`, `task_ref`, `prompt_signal`; `outcome` при записи — `unknown` (уточняется `/brain-retro` по сентименту следующей реплики). Виды событий расширены: `hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`. При внутреннем отказе хука пишется минимальный маркер `observer_error` вместо тихого пропуска. Spec — `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`.
`decision_provenance.kind``autonomous` | `user_directed_method` | `user_chose_from_options` (phase 1.1, spec §11). `autonomous` — дефолт. `user_directed_method` — заказчик навязал метод извне (routing-тег). `user_chose_from_options` — collaborative-choice: заказчик выбрал один из вариантов, предложенных Claude в предыдущем ходе (детектор `tools/observer-choice-detector.mjs` — детерминированный, тег не нужен). Для `user_chose_from_options` контрфактуал (`claude_would_have_chosen`) — рекомендованная Claude опция (первая из предложенных).
### 16.3. 5 контролёров
| # | Имя | Что закрывает | Реализация |
|---|---|---|---|
| C1 | L1-watcher | settings.json ↔ Tooling drift | lefthook + GitHub Actions weekly |
| C2 | Cross-ref consistency | version drift нормативных файлов | lefthook, regex |
| C3 | Observer-of-observer | observer evidence-loop устаревает | counter + lefthook warn, 54-week self-prune |
| C4 | STATUS.md | приборная панель | post-commit regen `docs/observer/STATUS.md` |
| C5 | Observer-coverage-checker | пропуски наблюдателя + целостность регистрации | lefthook warn-only + STATUS.md |
Все 5 — механические, 0 LLM-вызовов в hot path.
### 16.4. Поведенческое правило «не использован ≠ проблема»
Узел «мозга», не задействованный на реальной задаче, **не** считается проблемой и **не** подлежит автоматической пометке. Это — capability-readiness, осознанная стратегия заказчика. См. `memory/feedback_brain_unused_tools_not_problem.md`.
**Исключение**: deprecated upstream-пакеты или физически сломанные инструменты (отдельная категория, `npm audit` / `composer outdated`).
### 16.5. Не override-floor §9
§16 — рекомендация tier-уровня §13, НЕ explicit hard-rule вне §9. Тремя hard-rules вне §9 остаются §12 (Superpowers), §14 (Ruflo Queen — dormant), §15 (параллельные сессии).
ADR-011 enforcement через `adr-judge` lefthook job гарантирует существование секции `## Enforcement` в самом ADR.
### 16.6. Cross-refs
- ADR-011 `docs/adr/ADR-011-brain-governance.md`
- spec: `docs/superpowers/specs/2026-05-19-brain-governance-design.md`
- spec (factor-analysis): `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
- plan: `docs/superpowers/plans/2026-05-19-brain-governance.md`
- plan (factor-analysis): `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`
- plan (factor-analysis phase 1.1): `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`
- procedure: `docs/router-procedure.md`
- routing-table: `docs/routing-off-phase.md`
- evidence: `docs/observer/`
- memory: `feedback_brain_unused_tools_not_problem.md`, `project_brain_governance_design.md`
### 16.7. Routing-тег-дисциплина
Когда заказчик навязал конкретный метод/узел (директива `запусти X` / `используй X` / `через X` / `/команда`), Claude ОБЯЗАН в том же ходе эмитить routing-тег — одну строку-HTML-комментарий:
`<!-- routing: provenance=user_directed_method node=<выбранный> counterfactual=<узел, который Claude выбрал бы автономно> -->`
Enforcement — механический, не поведенческая просьба: `tools/observer-stop-hook.mjs` содержит routing-gate (`routingGateDecision` + `detectMethodDirected`). Детектор видит навязанный метод, тега нет → Stop-хук возвращает `decision: block`, и ход не завершается без тега. Это хук, а не tier-§13-правило — обойти рационализацией нельзя. Гейт срабатывает не более одного раза за ход (`stop_hook_active` guard против петли).
**Граница `user_chose_from_options` (phase 1.1):** routing-gate НЕ блокирует ход, классифицированный как `user_chose_from_options` — заказчик выбрал из вариантов, которые Claude сам же и предложил (collaborative-choice, не навязанный извне метод). Routing-тег для этого случая не обязателен: детектор `observer-choice-detector.mjs` восстанавливает провенанс детерминированно из транскрипта. Тег Claude может эмитить добровольно (для прозрачности), но Stop-хук его не требует.
### 16.8. Самодисциплина наблюдателя
Наблюдатель фиксирует каждый Stop без молчаливых пропусков:
- Внутренний отказ хука → строка-маркер `observer_error` в JSONL (не тихий `exit 0` без записи).
- Доля непарсибельных строк транскрипта выше порога → событие `parse_gap`.
- Контролёр **C5 observer-coverage-checker** (lefthook, warn-only) сверяет покрытие (git-активность без эпизодов) и целостность регистрации (Stop-хук в `.claude/settings.json`, `post-commit` установлен); расхождение — флаг в `docs/observer/STATUS.md`.
---
## Что сделано после утверждения
+523 -6
View File
File diff suppressed because one or more lines are too long
+77
View File
@@ -0,0 +1,77 @@
# ADR-010: Anthropic dev-tooling formalization
- **Status:** Accepted
- **Date:** 2026-05-18
- **Deciders:** Дмитрий
## Context
Пять Anthropic-плагинов включены в `~/.claude/settings.json` `enabledPlugins`
user-level, но не имеют номера в реестре Tooling §3.3 / PSR_v1 R10.1:
`skill-creator`, `plugin-dev`, `hookify`, `claude-code-setup`, `context7`. Все пять
из marketplace `anthropics/claude-plugins-official`.
Это повторение L1-паттерна «плагин фактически включён без формализации в правилах»:
зафиксирован 2026-05-10 (UPM #31 / 21st #32 — обнаружены только когда заказчик
спросил про конфликты), повторился 2026-05-13 (Sentry #34 / Redis #35
формализованы retrospective в v1.92). Любое использование неформализованного
плагина — байпас PSR_v1 R0.2/R10. Карта `docs/automation-graph.html` имеет
соответствующие 5 узлов (iter7 audit-actualization 16.05.2026), но без номеров и
без edge к governing-правилу; узел `hookify_plugin` несёт незакрытый 🔴-конфликт
`hookify_plugin ↔ hk_pre_claude` (плагин hookify может перезаписать существующие
хуки в `settings.json`).
Аудит «мозга» (discovery-interview SYSTEM-режим, 2026-05-18) вскрыл долг; заказчик
выбрал формализовать все 5, предварительно закрыв риски.
## Decision
Пять плагинов формализуются как позиции #56#60 реестра Tooling в **двух новых
off-phase подкатегориях** (семантика разная — одна категория запутала бы правила):
- **authoring-tooling** — создание Claude-артефактов: #56 skill-creator,
#57 plugin-dev, #58 hookify.
- **dev-support** — поддержка/документация Claude-разработки: #59 claude-code-setup,
#60 context7.
Граничные правила (locked):
1. **hookify (#58)** — вызов только по явному `/hookify`, не проактивно. Перед
генерацией хука — обязательный pre-check на коллизию с уже-зарегистрированными
хуками в `~/.claude/settings.json`; перезапись 6-компонентной economy/
skill-discipline архитектуры запрещена. Это закрывает 🔴-конфликт
`hookify_plugin ↔ hk_pre_claude` (🔴 → 🟢).
2. **skill-creator (#56) ↔ plugin-dev:skill-development (#57)** — skill-creator для
standalone проектных скилов; plugin-dev:skill-development — для скилов внутри
разрабатываемого marketplace-плагина. Вендоренные и self-authored скилы
модифицируются прямым Edit, не через skill-creator.
3. **context7 (#60) ↔ WebFetch ↔ WebSearch** — context7 первый выбор для
документации известной библиотеки; WebFetch — конкретный URL; WebSearch — поиск
без URL.
4. **claude-code-setup (#59)** — read-only анализатор; рекомендации фильтруются
через R0/R10.1, ничего не устанавливается без явного согласования.
Обе подкатегории — не UI → вне фильтров PSR_v1 R6.0/R6.1 и R14 pipeline; регулируются
R10.1 Блок 1 как infrastructure (по образцу claude-md-management #33).
ADR обязателен (не retrospective-без-ADR как Sentry/Redis #34/#35): здесь 5 позиций
и 2 новые подкатегории — decision-grain выше порога.
## Consequences
- Положительно: L1-долг для 5 Anthropic-плагинов закрыт — использование больше не
байпас R0.2/R10; 🔴-конфликт hookify закрыт правилом (🔴 → 🟢, классификация карты
🔴1/⚫3/🟢7 → 🔴0/⚫3/🟢8); карта получает edge к governing-правилу для 5 узлов.
- Отрицательно: реестр Tooling растёт 55 → 60; число off-phase подкатегорий 12 → 14.
- Риск: эти 5 плагинов включены user-level — влияют на все проекты машины;
формализация в Лидерра-нормативке другие проекты не ломает (они её не читают) —
это не риск, а ограничение области действия.
- Defer: изменение `enabledPlugins` (выключение плагинов) — отвергнуто заказчиком в
пользу формализации; не выполняется.
## Enforcement
None — формализация декларативная (реестр + границы в R10.1 / Pravila §13.2).
hookify pre-check на коллизию хуков — поведенческое правило, проверяется code review,
не автоматическим гейтом. Границы #56#60 зафиксированы в Tooling §4.31–§4.32 и
PSR_v1 R10.1 Блок 1.
+102
View File
@@ -0,0 +1,102 @@
---
id: ADR-011
title: Brain governance — router-only + observer + 4 mechanical controllers
status: Accepted
date: 2026-05-19
related:
- docs/superpowers/specs/2026-05-19-brain-governance-design.md
- docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md
- docs/discovery/2026-05-18-system-audit-brain.md
- ADR-010 (HK1 hard-rule, hook collision pre-check)
---
# ADR-011: Brain governance — router-only + observer + 4 mechanical controllers
## Status
Accepted (2026-05-19). **Amended 2026-05-19** — observer factor-analysis extension: episode schema v2, two-sided enforcement (routing-gate + C5 controller). See Decision §5.
## Context
The Лидерра «brain» (60 formal positions + 20 ruflo plugins per Tooling Прил. Н §0) accreted faster than it was regulated. SYSTEM-аудит 18.05.2026 (`docs/discovery/2026-05-18-system-audit-brain.md`) closed Rec1Rec5; intervention session 19.05.2026 went deeper to design ongoing governance.
Three recurring problems were identified:
1. **L1-pattern**: plugin enabled in `~/.claude/settings.json` user-level without formalization in Tooling Прил. Н. Occurred 3× in 8 days (UPM/21st 10.05; Sentry/Redis 13.05; Anthropic dev-tooling 18.05).
2. **Version drift** between 8 normative files. Tooling v2.11 collision 17.05.2026 — two parallel sessions consumed the same version number.
3. **Speculative regulation ahead of usage**. Initial recommendation «prune unused» rejected by owner — capability-readiness is an explicit strategy.
## Decision
### 1. Router-only
The brain has a single routing source of truth: the existing registry in [Tooling Прил. Н](../Tooling_v8_3.md) §4.X (extended with 9 obligatory attributes per spec §4.1) + the procedure in [`docs/router-procedure.md`](../router-procedure.md).
There is **no cache of «verified chains»**. There is **no 3-layer update mechanism**. There is **no forced-choice gate**. Every task is a fresh router-derived path.
Canonical chains L1L12 in [`docs/routing-off-phase.md`](../routing-off-phase.md) remain as general-shape recommendations, not history-based records.
### 2. Observer (scope B, full package from day 1)
A passive Stop-event hook appends one JSONL line per session to `docs/observer/episodes-YYYY-MM.jsonl` and optionally a MD note in `docs/observer/notes/`. **Observer only writes; never intervenes.** PII-filter (gitleaks-like regex) is mandatory pre-write.
**Each episode has 5 mandatory fields** including a structured `primary_rationale` (7 sub-fields per spec §5.2.1: `step` / `node_chosen` / `triggers_matched` / `candidates_considered` / `boundaries_applied` / `hard_floor` / `task_classification`). Each individual router decision is also recorded as a `routing_decision` event in `events[]` (one per node-choice for chains). This enables **factor analysis** through `/brain-retro` — answers «which factors most often resolve conflicts between nodes X and Y» rather than just «node X used N times».
A `/brain-retro` skill aggregates evidence once per sprint and proposes regulatory candidates; the owner accepts or rejects manually.
### 3. 5 mechanical controllers
All 5 are mechanical (regex/diff/JSON math). 0 LLM calls in hot path.
- **C1 L1-watcher** — lefthook job + weekly cron. Detects plugins in `settings.json` not formalized in Tooling Прил. Н.
- **C2 Cross-ref consistency** — lefthook job, regex-style (adr-judge analog). Detects version drift between normative files.
- **C3 Observer-of-observer** — counter + lefthook warn. Self-prune through **54 weeks** without reads.
- **C4 STATUS dashboard**`docs/observer/STATUS.md`, regenerated per-commit.
- **C5 Observer-coverage-checker** — lefthook warn-only job. Flags observer coverage gaps (git activity but 0 episodes) and registration-integrity breaks (Stop-hook missing from `settings.json`, `post-commit` not installed). Surfaced in STATUS.md.
### 4. Behavioral rule «unused ≠ problem»
The capability-readiness strategy is explicit. A node never used on a real task is **not** a problem and **not** an auto-removal candidate. Used-count is informational, never an alert. This rule overrides the analytical instinct to «prune unused».
Exception: deprecated upstream packages or physically broken tools (separate category — `npm audit` / `composer outdated`).
### 5. Observer factor-analysis extension (v2)
The observer episode is extended to `schema_version: 2` so a real factor analysis becomes possible: `decision_provenance` (autonomous vs user-dictated method, with a counterfactual), `environment` factors, `task_size`, `prompt_signal`, and an honest `outcome` of `unknown` at write time. Four layers — schema v2, deterministic capture + a routing-tag, two-sided enforcement (Stop-hook routing-gate + C5 self-discipline controller), `/brain-retro` analysis. The routing-gate makes provenance reliable: when the user dictates a method and the routing-tag is missing, the Stop-hook returns `decision: block`. Spec: `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`.
## Consequences
### Positive
- Speculative regulation eliminated structurally — no chain catalog can drift.
- Evidence-loop active from day 1 — owner has data for monthly/quarterly review.
- 3 recurring problem classes (L1-pattern, version drift, evidence consumption) closed mechanically with 0 LLM cost.
- Capability-readiness preserved — installed-but-unused tools are not flagged.
### Negative / risks
- 4 new lefthook jobs add ~12s to pre-commit.
- Observer JSONL grows ~50200KB/month; archival after 12 months is a manual task.
- C3 54-week threshold is long — if observer infra is broken silently, detection waits up to a year. Mitigator: C4 STATUS.md shows weekly read-counter.
### Neutral
- The decision is reversible at low cost: removing controllers = `lefthook.yml` revert; removing observer = unregister Stop-hook + archive `docs/observer/`.
## Enforcement
- C1 / C2 / C3 lefthook jobs fail-fast on commit when invariants break.
- C4 STATUS.md regeneration on post-commit (informational; not a gate).
- Observer routing-gate runs inside `observer-stop-hook.mjs` (`decision: block` when a method is dictated without a routing-tag); C5 observer-coverage-checker is a warn-only lefthook job.
- ADR-011 itself is enforced by **adr-judge** (lefthook job 9) — this section's existence is verified per-commit (regex `^## Enforcement$`).
## References
- spec: `docs/superpowers/specs/2026-05-19-brain-governance-design.md`
- spec (extension): `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
- plan: `docs/superpowers/plans/2026-05-19-brain-governance.md`
- plan (extension): `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`
- ADR-010 (HK1 pre-check hard-rule)
- Pravila §12 / §14 / §15 (hard-floor for router procedure step 1)
- PSR_v1 R15 (off-phase routing extends to brain governance)
- memory: `feedback_brain_unused_tools_not_problem.md`, `project_brain_governance_design.md`
+104 -84
View File
@@ -201,7 +201,7 @@
<div class="cat-item" data-filter-key="group:mcp"><div class="cat-dot" style="background:#cb4b16"></div>MCP-серверы</div>
<div class="cat-item" data-filter-key="group:lefthook"><div class="cat-dot" style="background:#dc322f"></div>Lefthook jobs</div>
<div class="cat-item" data-filter-key="group:memory"><div class="cat-dot" style="background:#586e75"></div>Memory files</div>
<div class="cat-item" data-filter-key="group:ruflo"><div class="cat-dot" style="background:#ff8800"></div>🌊 ruflo (оркестратор)</div>
<div class="cat-item" data-filter-key="group:ruflo"><div class="cat-dot" style="background:#555555; border:1px dashed #888888"></div>🔇 ruflo (изолирован 18.05)</div>
<div class="cat-item" data-filter-key="conflict:RED"><div class="cat-dot" style="background:#ff5f57; border:1px dashed #ff5f57"></div>🔴 Не закрыт правилом</div>
<div class="cat-item" data-filter-key="conflict:BLACK"><div class="cat-dot" style="background:#888888; border:1px dashed #888888"></div>⚫ Возник на практике</div>
<div class="cat-item" data-filter-key="conflict:GREEN"><div class="cat-dot" style="background:#859900; border:1px dashed #859900"></div>🟢 Закрыт правилом</div>
@@ -228,10 +228,10 @@ function pos(ring, angleDeg) {
const NODES = [
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
{ id: 'pravila', label: 'Pravila v1.24', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.10', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.10', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.10', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
{ id: 'pravila', label: 'Pravila v1.29', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.16', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.14', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.15', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
@@ -305,7 +305,7 @@ const NODES = [
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'hooks', size: 20, ring: 4, ...pos(4, 165) },
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'ruflo', size: 20, ring: 4, ...pos(4, 165) },
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
@@ -509,10 +509,8 @@ const EDGES = [
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
E('psr_v1', 'skill_creator', 'R10.1:\nвнешний инструмент'),
E('psr_v1', 'claude_setup', 'R10.1:\nвнешний инструмент'),
E('psr_v1', 'plugin_dev', 'R10.1:\nвнешний инструмент'),
E('psr_v1', 'context7', 'R10.1:\nвнешний инструмент'),
// 4 ребра psr_v1skill_creator/claude_setup/plugin_dev/context7 — перенесены
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
@@ -568,13 +566,20 @@ const EDGES = [
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
// ══════════════════════════════════════════════════
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
// ══════════════════════════════════════════════════
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
CONFLICT('hookify_plugin', 'hk_pre_claude', 'hookify может перезаписать существующий хук', 'RED'),
CONFLICT('mcp_pw', 'sk_parallel', 'Browser is already in use (квирк #2)', 'BLACK'),
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.14): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
CONFLICT('mcp_pw', 'sk_parallel', 'Профиль Playwright MCP хэшируется per-cwd (квирк #95) → worktrees получают разные mcp-chrome-{hash}, не конфликтуют. Same-dir parallel — редкий случай (две Claude-сессии в одной dir), регулируется Pravila §15.2 claim в docs/sessions/CURRENT.md', 'GREEN'),
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
@@ -616,7 +621,7 @@ const CATEGORY_LABELS = {
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
ruflo: 'ruflo (оркестратор)'
ruflo: 'ruflo (изолирован)'
};
function nd(desc, when, limits, reportsTo, manages, together, conflicts) {
@@ -658,7 +663,7 @@ const NODE_DETAILS = {
'Править можно только через скил `/claude-md-management:claude-md-improver` или `:revise-claude-md` (правило §5 п.10). Прямые Edit/Write блокируются хуком предупреждения.',
[{ name: 'Pravila', cond: 'всегда подчинён (уровень 2a)' }],
[
{ name: 'Tooling v2.10', cond: 'ссылается как на реестр инструментов' },
{ name: 'Tooling v2.15', cond: 'ссылается как на реестр инструментов' },
{ name: 'плагин claude-md-management', cond: 'правило §5 п.10 — единственный канал правок' }
],
[
@@ -681,7 +686,7 @@ const NODE_DETAILS = {
[{ name: 'CLAUDE.md', desc: 'CLAUDE.md §5 п.10 требует править только через скил claude-md-management, а PSR_v1 это ограничение не повторяет — риск прямых Edit', type: 'GREEN' }]
),
tooling: nd(
'Реестр 70 позиций — 50 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
'Реестр 80 позиций — 60 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
'При выборе инструмента для фазы (нулевая документация / первая backend / вторая frontend / третья перед запуском в боевую среду), при добавлении нового инструмента, при обновлении версий.',
'При прямом конфликте с CLAUDE.md побеждает CLAUDE.md (оперативная карта уровня 2a). Любая правка требует синхронизации с CLAUDE.md §3.',
[
@@ -737,12 +742,12 @@ const NODE_DETAILS = {
hookify_plugin: nd(
'Плагин создания хуков — анализирует разговоры и предлагает новые автоматизации в виде хуков.',
'При запросе «давай повесим хук на это поведение» или после серии повторяющихся ошибок — анализ через агента conversation-analyzer.',
'Правило PSR_v1 R10.1. Новые хуки могут конфликтовать с существующими (см. конфликты ниже) — обязательная проверка файла настроек до создания.',
[{ name: 'PSR_v1', cond: 'R10.1: формализован' }],
'PSR_v1 R10.1 блок 1 #58 (authoring-tooling). HK1 hard-rule: только по явному /hookify, не проактивно; перед генерацией хука — обязательный pre-check на коллизию с зарегистрированными хуками settings.json; перезапись 6-компонентной economy/skill-discipline архитектуры запрещена. ADR-010.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #58: authoring-tooling, HK1 pre-check (ADR-010)' }],
[{ name: 'агент hookify:conversation-analyzer', cond: 'запускает анализ разговоров' }],
[{ name: 'агент hookify:conversation-analyzer', cond: 'плагин и агент работают в паре' }],
[
{ name: 'хук pre-claude-warn', desc: 'плагин hookify создаёт новые хуки PreToolUse на лету — может перезаписать или конкурировать с этим хуком', type: 'RED' }
{ name: 'хук pre-claude-warn', desc: 'Закрыто правилом HK1 (ADR-010): hookify — только по явному /hookify, перед генерацией хука обязательный pre-check на коллизию с существующими хуками settings.json; перезапись 6-компонентной economy/skill-discipline архитектуры запрещена', type: 'GREEN' }
]
),
@@ -995,7 +1000,7 @@ const NODE_DETAILS = {
[{ name: 'плагин Superpowers', cond: 'содержит' }],
[],
[{ name: 'скил worktree', cond: 'parallel-work использует worktree для изоляции' }],
[{ name: 'MCP-сервер playwright', desc: 'Браузер уже занят (Browser is already in use) при одновременном запуске нескольких сессий через worktree', type: 'BLACK' }]
[{ name: 'MCP-сервер playwright', desc: 'Профили per-cwd hash (квирк #95) → worktrees получают разные mcp-chrome-{hash} директории, не конфликтуют. Same-dir parallel — редкий runtime, регулируется Pravila §15.2 claim', type: 'GREEN' }]
),
sk_worktree: nd(
'Создаёт изолированную копию репозитория (worktree) для рискованной или параллельной работы.',
@@ -1226,11 +1231,11 @@ const NODE_DETAILS = {
mcp_pw: nd(
'Управляет браузером — снимает скриншоты, кликает, заполняет формы для smoke- и a11y-тестов.',
'При визуальной проверке прототипов (фаза 0), при a11y smoke (axe-core), при UI integration smoke.',
'Не для боевых пользователей. На сессию один общий браузер — при parallel-work возможны столкновения (см. квирк #2 в memory).',
'Не для боевых пользователей. Профиль persistent кэшируется per-cwd hash (квирк #95 в memory) → разные worktrees получают разные mcp-chrome-{hash} директории и не конфликтуют. Конфликт остаётся только при same-dir parallel (две Claude-сессии в одной dir одновременно вызывают browser).',
[{ name: 'CLAUDE.md §3.1 #2', cond: 'активен с фазы 0' }],
[],
[{ name: 'SessionStart хук', cond: 'используется для визуальной проверки прототипов' }],
[{ name: 'parallel-work скил', desc: 'Один shared browser на сессию — конкуренция при параллельной работе через worktrees (memory квирк #2)', type: 'BLACK' }]
[{ name: 'parallel-work скил', desc: 'Профили per-cwd hash → worktrees не конфликтуют (квирк #95). Same-dir parallel регулируется Pravila §15.2 claim в CURRENT.md', type: 'GREEN' }]
),
mcp_gh: nd(
'GitHub API — читает/создаёт PR, issues, коммиты, ветки в репозитории CoralMinister/lidpotok.',
@@ -1599,7 +1604,7 @@ const NODE_DETAILS = {
'Плагин Anthropic для создания новых скилов — eval-driven подход: датасеты сценариев, train/test split, бенчмарк-цикл.',
'При формализации повторяющегося процесса в скил с проверяемым выводом (генерация кода, преобразование файлов).',
'Включён в настройках (~/.claude/settings.json). Для discipline-скилов (TDD-типа) предпочтительнее скил writing-skills плагина Superpowers — у них разные философии.',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #56: authoring-tooling (ADR-010)' }],
[],
[{ name: 'скил writing-skills', cond: 'обе создают скилы — skill-creator eval-driven, writing-skills через TDD' }]
),
@@ -1607,7 +1612,7 @@ const NODE_DETAILS = {
'Плагин Anthropic — рекомендатель автоматизаций (claude-automation-recommender): анализирует репозиторий и советует, какие MCP-серверы, скилы, хуки, суб-агентов добавить.',
'При настройке/ревизии автоматизации проекта — «чего не хватает в тулчейне».',
'Включён в настройках (~/.claude/settings.json). Рекомендации — совещательные, решение за заказчиком.',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #59: dev-support — рекомендации фильтруются R0/R10.1 (CCS1, ADR-010)' }],
[],
[]
),
@@ -1615,7 +1620,7 @@ const NODE_DETAILS = {
'Плагин Anthropic для разработки плагинов Claude Code — 7 скилов (структура плагина, разработка скилов / агентов / хуков / команд, интеграция MCP, настройки).',
'При создании или правке плагина и его компонентов.',
'Включён в настройках. Содержит 3 агента, уже представленные на карте (agent-creator / plugin-validator / skill-reviewer).',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #57: authoring-tooling — только для marketplace-плагинов, не для вендоренного/self-authored (PD1, ADR-010)' }],
[
{ name: 'агент plugin-dev:agent-creator', cond: 'входит в плагин' },
{ name: 'агент plugin-dev:plugin-validator', cond: 'входит в плагин' },
@@ -1627,7 +1632,7 @@ const NODE_DETAILS = {
'Плагин Anthropic — актуальная документация библиотек / фреймворков / API через MCP-инструменты query-docs и resolve-library-id.',
'При вопросах по библиотеке / фреймворку / SDK / CLI — синтаксис API, конфигурация, миграция версий. Предпочтительнее веб-поиска для документации библиотек.',
'Включён в настройках. Не для рефакторинга / отладки бизнес-логики / ревью — только документация.',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #60: dev-support — первый выбор для документации библиотек; WebFetch/WebSearch как fallback (CTX1, ADR-010)' }],
[],
[]
),
@@ -1842,7 +1847,7 @@ const EDGE_DETAILS = {
// ── КОНФЛИКТЫ (8 рёбер; 3 из них имеют ту же пару from/to, что и обычные — здесь объединены под одним ключом) ─
'sk_rls->ag_rls': { type: 'конфликт', when: 'граница задана: скил — по таблице, агент — по diff/ветке/PR', transfers: 'coverage', mandatory: 'опционально', rule: 'секции «Граница…» в SKILL.md + rls-reviewer.md (spec 2026-05-16)' },
'hookify_plugin->hk_pre_claude': { type: 'конфликт', when: 'hookify plugin генерирует hook — двойное owner-ship vs settings.json', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента (plugin vs settings.json)' },
'mcp_pw->sk_parallel': { type: 'конфликт', when: 'Playwright и parallel-agents оба требуют изоляцию', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента (изоляция worktree vs MCP)' },
'mcp_pw->sk_parallel': { type: 'конфликт', when: 'Playwright и parallel-agents оба требуют изоляцию', transfers: 'coverage', mandatory: 'опционально', rule: 'GREEN: квирк #95 — профили per-cwd hash → worktrees не конфликтуют; same-dir parallel под Pravila §15.2 claim' },
'ag_pest->mcp_redis': { type: 'конфликт', when: 'Pest --parallel race на Redis cache (quirk 72/77)', transfers: 'coverage', mandatory: 'опционально', rule: 'CLAUDE.md §3.3 #35 (Redis MCP) — race остаётся вне регламента' },
'psr_v1->claude_md': { type: 'конфликт', when: 'PSR_v1 уровень 3 vs CLAUDE.md 2a — приоритет CLAUDE.md', transfers: 'контроль', mandatory: 'hard-block', rule: 'CLAUDE.md §1 (priority chain)' },
'upm->fd_plugin': { type: 'конфликт', when: 'UPM и FD оба претендуют на UI-решения', transfers: 'coverage', mandatory: 'hard-block', rule: 'PSR_v1 R14.5 (не параллельно)' },
@@ -1869,30 +1874,40 @@ const EDGE_DETAILS = {
};
// ════════════════════════════════════════════════════
// SECTION 3.6: NODE META (iter6 — даты, использование, дубли)
// SECTION 3.6: NODE META (iter6 → iter8 — даты, использование, дубли)
// ════════════════════════════════════════════════════
// Данные — фактический снимок: даты из git/changelog/mtime, счётчик uses —
// из разбора транскриптов сессий Claude Code за окно META_WINDOW.
// Методика и воспроизводимость — план iter6, Приложение А.
const META_SNAPSHOT = '16.05.2026'; // дата генерации значений
const META_WINDOW = '0916.05.2026'; // окно подсчёта использования (7 дней)
//
// iter8 (18.05.2026): окно расширено 0916.05 → 0918.05 (10 дней).
// Узлы интеграционных волн 17-18.05 (A6 / D3 / C9 / A4 / A3 / A11 / C10 / discovery /
// ADT) получают baseline 1 = факт интеграции (коммит + plan/spec/ADR + Tooling §4).
// Реальные вызовы (за пределами интеграций) не подсчитаны — транскрипты Claude Code
// не доступны как источник в репо. mcp_figma — uses=0, usesSrc='DEFERRED'.
// null сохраняется только для принципиально неизмеримых: правила, superpowers,
// hookify_plugin, ruflo_daemon, ruflo_memory, фоновые economy/skill-discipline
// хуки (hk_self_check / skill_marker / skill_check / state_guard / postcompact /
// verifier / ruflo_queen) и старые mem_* без активных Read-вызовов в окне.
const META_SNAPSHOT = '18.05.2026'; // дата генерации значений
const META_WINDOW = '0918.05.2026'; // окно подсчёта использования (10 дней)
// uses: number — измеримый узел (0 = реально простаивал); null — измерить нельзя
// (узел-правило / плагин-обёртка / автономный демон / пассивное хранилище) → «нет данных».
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | '—'
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | 'интеграция' | 'DEFERRED' | '—'
const NODE_META = {
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
pravila: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
pravila: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
// ── ПЛАГИНЫ (5) ──
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
fd_plugin: { since: '10.05.2026', changed: '—', uses: 1, usesSrc: 'скил' },
upm: { since: '10.05.2026', changed: '—', uses: 0, usesSrc: 'скил' },
claude_md_mgmt: { since: '10.05.2026', changed: '—', uses: 15, usesSrc: 'скил' },
hookify_plugin: { since: '—', changed: '', uses: null, usesSrc: '—' },
hookify_plugin: { since: '—', changed: '18.05.2026', uses: null, usesSrc: '—' },
// ── СКИЛЫ SUPERPOWERS (14) — связка подключена 09.05.2026 ──
sk_brainstorm: { since: '09.05.2026', changed: '—', uses: 44, usesSrc: 'скил' },
@@ -1973,36 +1988,40 @@ const NODE_META = {
mem_github: { since: '07.05.2026', changed: '15.05.2026', uses: 33, usesSrc: 'memory-чтение' },
// ── RUFLO ОРКЕСТРАТОР (9) — все внедрены big-bang'ом 15.05.2026 ──
ruflo_queen: { since: '15.05.2026', changed: '16.05.2026', uses: 0, usesSrc: 'инспекция' },
ruflo_plugins: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
ruflo_workers: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
ruflo_agents_catalog: { since: '15.05.2026', changed: '', uses: 0, usesSrc: 'инспекция',
// 🔇 ИЗОЛИРОВАН 18.05.2026 (Rec2 SYSTEM-аудита): hooks сняты из settings.json,
// MCP удалён из .mcp.json, PM2 daemon stopped+saved-empty. См. Pravila §14.9 /
// Tooling §4.10 / memory feedback_ruflo_isolated.md. uses=0 — реальные вызовы 0.
ruflo_queen: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true },
ruflo_plugins: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true },
ruflo_workers: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true },
ruflo_agents_catalog: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true,
dupNote: '100 определений агентов дублируют реестр агентов; каталог буквально содержит 2 проектных агента' },
ruflo_commands: { since: '15.05.2026', changed: '', uses: 0, usesSrc: 'инспекция',
ruflo_commands: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true,
dupNote: '88 slash-команд дублируют роль скилов — именованные вызываемые процедуры; команды инертны' },
ruflo_daemon: { since: '15.05.2026', changed: '', uses: null, usesSrc: '—' },
ruflo_memory: { since: '15.05.2026', changed: '', uses: null, usesSrc: '—',
dupNote: 'дублирует роль 16 memory-файлов проекта — постоянная память между сессиями; уже ⚫-конфликт с project_state' },
ruflo_mcp: { since: '15.05.2026', changed: '', uses: 36, usesSrc: 'MCP' },
ruflo_recall_hook: { since: '15.05.2026', changed: '', uses: 220, usesSrc: 'хук' },
ruflo_daemon: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'pm2 stopped+deleted', isolated: true },
ruflo_memory: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'не читается', isolated: true,
dupNote: 'дублирует роль 16 memory-файлов проекта — постоянная память между сессиями; ⚫-конфликт с project_state снят изоляцией' },
ruflo_mcp: { since: '15.05.2026', changed: '18.05.2026', uses: 36, usesSrc: 'MCP (был активен 15-17.05; снят 18.05)', isolated: true },
ruflo_recall_hook: { since: '15.05.2026', changed: '18.05.2026', uses: 220, usesSrc: 'хук (был активен 15-17.05; снят 18.05)', isolated: true },
// ── MEMORY +1 (артефакт ruflo big-bang) ──
mem_ruflo: { since: '15.05.2026', changed: '16.05.2026', uses: 18, usesSrc: 'memory-чтение' },
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — узлы добавлены по полному аудиту карты ──
// uses новых узлов по транскриптам не измерялись (null = нет данных).
skill_creator: { since: '11.05.2026', changed: '', uses: null, usesSrc: '' },
claude_setup: { since: '11.05.2026', changed: '', uses: null, usesSrc: '' },
plugin_dev: { since: '—', changed: '', uses: null, usesSrc: '' },
context7: { since: '—', changed: '', uses: null, usesSrc: '' },
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 + iter8 18.05.2026 ──
// ADT (18.05): baseline 1 = факт формализации в Tooling §4.31–4.35 + интеграционный коммит 515acb6.
skill_creator: { since: '11.05.2026', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
claude_setup: { since: '11.05.2026', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
plugin_dev: { since: '—', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
context7: { since: '—', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
// Фоновые economy/skill-discipline хуки — измерение требует доступа к user-level логам, не репо.
hk_self_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_skill_marker: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_skill_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_state_guard: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_postcompact: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_verifier: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_ruflo_queen: { since: '15.05.2026', changed: '', uses: null, usesSrc: '—' },
sk_regression: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_ruflo_queen: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'снят 18.05', isolated: true }, // 🔇 ИЗОЛИРОВАН (см. ruflo блок выше)
sk_regression: { since: '15.05.2026', changed: '—', uses: 2, usesSrc: 'скил' }, // verification в Sprint 1-6
mem_audit_b: { since: '08.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_audit_c: { since: '07.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_suppliercrm: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
@@ -2012,43 +2031,44 @@ const NODE_META = {
mem_sprint2: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_sprint3: { since: '16.05.2026', changed: '—', uses: null, usesSrc: '—' },
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 ──
adr_kit: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
arch_patterns: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
mermaid_skill: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
deptrac: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 (iter8: baseline 1 = факт интеграции) ──
adr_kit: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
arch_patterns: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
mermaid_skill: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
deptrac: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
// ── D3 AUDIT-SECURITY 17.05.2026 ──
tob_skills: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
sec_guidance: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'хук' },
sk_security_review: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
sk_audit_portal: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
// ── D3 AUDIT-SECURITY 17.05.2026 (iter8: baseline 1) ──
tob_skills: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
sec_guidance: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'хук' },
sk_security_review: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
sk_audit_portal: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
// ── C9 PROJECT-MANAGEMENT-TOOLING 17.05.2026 ──
ccpm: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
product_mgmt: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
// ── C9 PROJECT-MANAGEMENT-TOOLING 17.05.2026 (iter8: baseline 1) ──
ccpm: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
product_mgmt: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
// ── A4 DESIGN-TOOLING 17.05.2026 ──
mcp_figma: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
mcp_icons: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'MCP' },
design_plugin:{ since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
// ── A4 DESIGN-TOOLING 17.05.2026 (iter8: baseline 1, mcp_figma=0 DEFERRED) ──
mcp_figma: { since: '17.05.2026', changed: '—', uses: 0, usesSrc: 'DEFERRED' },
mcp_icons: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'MCP' },
design_plugin:{ since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
// ── A3 INTEGRATION-TOOLING (17.05.2026) ──
ag_apidocs: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
mcp_openapi: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '' },
// ── A3 INTEGRATION-TOOLING (17.05.2026, iter8: baseline 1) ──
ag_apidocs: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
mcp_openapi: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
// ── A11 ML-AI-TOOLING (17.05.2026) ──
claude_api: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
promptfoo: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'CLI' },
data_scientist: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
// ── A11 ML-AI-TOOLING (17.05.2026, iter8: baseline 1) ──
claude_api: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
promptfoo: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'CLI' },
data_scientist: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
// ── C10 BUSINESS-PROCESS (17.05.2026) ──
ops_plugin: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
process_modeling: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
process_analysis: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
// ── C10 BUSINESS-PROCESS (17.05.2026, iter8: baseline 1) ──
ops_plugin: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
process_modeling: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
process_analysis: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
// ── DISCOVERY-TOOLING (18.05.2026) ──
discovery_interview: { since: '18.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
// ── DISCOVERY-TOOLING (18.05.2026, iter8: factual в сессии) ──
// snapshot 2026-05-18-system-audit-brain.md (утро) + это интервью (вечер) + последующие вызовы
discovery_interview: { since: '18.05.2026', changed: '—', uses: 3, usesSrc: 'скил, factual' },
};
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
@@ -2255,7 +2275,7 @@ const GROUPS = {
mcp: { color: { background: '#2d1200', border: '#cb4b16', highlight: { border: '#ff6b30', background: '#3d1900' } }, font: { color: '#fdf6e3', size: 11 } },
lefthook: { color: { background: '#2d0000', border: '#dc322f', highlight: { border: '#ff5f5c', background: '#3d0000' } }, font: { color: '#fdf6e3', size: 10 } },
memory: { color: { background: '#112233', border: '#586e75', highlight: { border: '#839496', background: '#1a2f40' } }, font: { color: '#eee8d5', size: 10 } },
ruflo: { color: { background: '#332100', border: '#ff8800', highlight: { border: '#ffaa33', background: '#4d3300' } }, font: { color: '#fdf6e3', size: 12, bold: true } },
ruflo: { color: { background: '#262626', border: '#555555', highlight: { border: '#777777', background: '#333333' } }, font: { color: '#8a8a8a', size: 12, bold: true }, shapeProperties: { borderDashes: [4, 4] } },
};
const nodesDS = new vis.DataSet(NODES);
@@ -0,0 +1,159 @@
# SYSTEM-аудит «мозга» — 18.05.2026
Результат режима SYSTEM скила `discovery-interview`. Синтез-ориентация по состоянию
системы автоматизации Лидерры («мозг» = карта `docs/automation-graph.html` + тулчейн).
## Запрос ориентации
Scope: **весь мозг, 125 узлов**. Заказчик попросил проверить и оптимизировать работу
узлов по пяти осям: (1) здоровье новых узлов, (2) устранение конфликтов,
(3) корректность выбора узла под задачу (routing), (4) связки 2+ узлов для синергии,
(5) пересмотр правил/запретов ради эффективности — качества и скорости.
## Состояние
Карта `docs/automation-graph.html`: **125 узлов / 135 рёбер**, конфликты **🔴0 / ⚫2 / 🟢9**
(11 конфликтных рёбер). Тулчейн — **60 формализованных позиций** (29 phase-active +
30 off-phase + 1 historic). Последняя интеграция — #5660 Anthropic dev-tooling (push
`515acb6`, 18.05).
> **UPDATE 18.05.2026 вечер:** ⚫1 `mcp_pw ↔ sk_parallel` понижен до 🟢 после
> верификации квирка #95 — профиль Playwright MCP хэшируется per-cwd → worktrees
> получают разные `mcp-chrome-{hash}` директории, не конфликтуют. README playwright-mcp
> прямо: конфликт — только для клиентов «sharing the same workspace». Same-dir parallel
> регулируется Pravila §15.2 claim в `docs/sessions/CURRENT.md`. Эффект: ⚫3 → ⚫2,
> 🟢8 → 🟢9. Оба оставшихся ⚫ — ruflo (после изоляции 18.05 dormant).
### Ось 1 — здоровье новых узлов
С iter7 (16.05, 83 узла) мозг вырос на ~42 узла серией интеграций A6→D3→C9→A4→A3→A11→
C10→anthropic-dev-tooling. Каждая интеграция проходила конфликт-аудит → **0 новых
структурных конфликтов**, узлы интегрированы чисто. Паспорт NODE_META (since / changed /
section) синхронизирован интеграциями — покрывает все 125 узлов, **не gap**.
Реальные gap'ы:
- **Теплокарта `uses` застыла.** `META_SNAPSHOT = 16.05.2026`, `META_WINDOW = 0916.05.2026`.
~30 узлов волны 17–18.05 в этом окне физически не существовали → их `uses` = null/0
не от неиспользования, а от того, что окно их старше. Режим карты «🔥 По использованию»
на самом свежем слое вводит в заблуждение. 51 из 125 узлов имеют `uses: null`.
- **Хвост «формализован, но не отработан».** process-modeling, process-analysis,
discovery-interview, operations, ccpm, product-management, promptfoo, data-scientist —
формализованы, но фактическое число вызовов неизвестно (теплокарта их не видит).
mcp_figma — узел в статусе DEFERRED. Мозг накапливает декларированную, но не
проверенную в бою ёмкость.
### Ось 2 — конфликты
🔴0 структурных — все закрыты правилами. 2 ⚫ (после downgrade 18.05 вечер):
1. ~~`mcp_pw ↔ sk_parallel`~~**🟢 закрыт**: квирк #95 (профили per-cwd hash → worktrees
не конфликтуют) + Pravila §15.2 claim для same-dir parallel. Текст nd() в карте
ссылался на «квирк #2», но memory[#2] — это taskkill, не Playwright; реальный источник
— квирк #95 (опровергает hypothesis shared-browser).
2. `ruflo_memory ↔ mem_state` — два хранилища памяти не синхронизированы; ruflo-память
почти пуста (0 записей + 2 HNSW-призрака #1122). **После изоляции 18.05 — dormant.**
3. `ruflo_daemon ↔ ag_pest` — daemon worker-jitter усиливает Pest-квирки 73/77.
**После изоляции 18.05 — dormant** (daemon stopped, dump.pm2=[]).
**Системное наблюдение: оба оставшихся ⚫ — ruflo, оба dormant.** Реальное runtime-трение
— ноль. ruflo сохранён как артефакт, queen-триггер dormant, артефакты можно реактивировать
по плану в `feedback_ruflo_isolated.md`.
### Ось 3 — корректность routing (задача→узел)
Управляется: CLAUDE.md §3 (карта по фазам/задачам, 60 строк), PSR_v1 R1/R9/R13
(классификация + decision matrix), per-integration конфликт-аудиты с границами
(DI16, OPS15, TB1, AK1… — закреплены в ADR-003..010).
Сильно: каждая интеграция авторила границы явно — routing-дисциплина высокая,
дрейф ловится конфликт-аудитом.
Слабость: **PSR_v1 R13 decision-matrix покрывает только UI/код-задачи.** 30 off-phase
инструментов (#31–60 — половина тулчейна) живут в R10.1 как плоский 3-блочный реестр с
прозаическим «когда инвокировать», без матрицы. Выбор между process-modeling /
process-analysis / operations / discovery-interview / brainstorming для «процессной»
задачи = чтение 5 прозаических описаний. Routing-знание рассыпано по CLAUDE.md §3 +
R10.1 + ADR + конфликт-коды — единого «задача X → узел Y» для off-phase нет.
### Ось 4 — синергия (связки 2+ узлов)
Карта кодирует синергию в NODE_DETAILS (поле «С кем работает одновременно») и
NODE_SECTION_SECONDARY (кросс-реф reuse-инструментов).
Рабочие цепочки: brainstorming→writing-plans→subagent-driven-development (канон эпика);
discovery-interview FEATURE→brainstorming (хэндофф brief); process-modeling↔process-analysis
(as-is↔to-be); mermaid рендерит для operations/adr-kit/process-modeling.
Недоиспользуемые связки: discovery-interview SYSTEM + audit-portal (ориентация→вердикт);
openapi-mcp + api-docs agent + Boost (интеграционная разработка); systematic-debugging +
redis/sentry MCP (рантайм-баги).
Gap: синергия размазана по 125 полям «together», сводного «рекомендованные связки» нет —
а заказчик явно его просит.
### Ось 5 — правила/запреты (эффективность)
PSR_v1 — на момент утреннего среза 15 правил R0–R14 (R15-слот пуст после v2.0). История
v1.0→v3.13 — свод рос реактивно, закрывая трения по мере обнаружения. (Rec5 закрытие —
R15 «Off-phase routing» введён v3.14 на свободный слот; см. UPDATE ниже.)
- **Перекос в UI.** R1R9, R11–R14 — почти целиком routing UI-фич (Superpowers vs
Frontend Design, фазы R2, UI-генераторы UPM/21st). Off-phase тулинг (30 инструментов)
регулируется только R10.1 + меткой «вне R6/R14». UI-аппарат огромен, off-phase-аппарат
тонкий — при том что off-phase множество выросло 3→30.
- **Запрет-разрастание.** CLAUDE.md §5 — 12 пунктов (§5 п.12 — tombstone «Резерв снят»);
Pravila — §12/§14/§15 hard-rules + 15 нумерованных правил; PSR_v1 R0.6 — 10 hard-стопов.
- **Скорость.** Gate-аппарат R0→R1→R9→R13→R2 спроектирован под UI-фичу, но текущая
работа в основном off-phase / документация / тулинг. Режим «экономия» частично лечит,
но мозг по-прежнему фронт-лоадит UI-feature gate на каждую задачу.
> **UPDATE 18.05.2026 вечер (аудит дисциплины R15):** PSR_v1 R15 «Off-phase routing»
> (введён v3.14, Rec5) проверен против R0/R6/R10/R14 — содержательных противоречий
> нет: R15.1 codifies «off-phase вне UI-фильтров», R15.6 разграничивает UI-пул,
> R15.4 — hard-rules перевешивают. routing-off-phase.md прогнан на 7 задачах
> (5 прямых + 2 граничных) — 7/7 routed cleanly, ADR-границы работают. 3 minor-находки
> исправлены: M1 — note про UI-пул #31/#32 как делегирующие строки (routing-off-phase.md
> v1.1); M2 — R15.1 +абзац «R15 — пост-R1 слой» (PSR_v1 in-place); M3 — +строка
> «диагностика конверсии» → process-analysis #53. Перекос UI-аппарата (R1–R14) над
> off-phase остаётся структурным, но R15 — корректный противовес; дальнейшее
> выравнивание — отдельная задача, не блокер.
## Что открыто
- **iter8 не сделан** — теплокарта NODE_META не пересобиралась с 16.05 (2 интеграционные
волны спустя).
- **ruflo не отревизован** — keep/trim-решение по advisory-подсистеме не принято;
2 из 3 живых конфликтов и jitter-вред Pest висят.
- **Off-phase routing** — нет decision-аида для 30 инструментов #3160.
- **Связки** — нет сводной карты-панели «рекомендованные комбо».
- **Ребаланс PSR_v1** — off-phase множество удесятерилось без своего раздела правил.
- **WISHLIST карты:** W1 (K7-spike — починка embeddings ruflo, статус `next`),
W2W4 (мост claude-mem→ReasoningBank + ремонтник, `blocked` на W1) — встроенный
backlog развития мозга, не двигался.
## Источники
- Карта — `docs/automation-graph.html` (NODE_SECTION стр. 2135, NODE_META стр. 1883,
WISHLIST стр. 2230).
- Правила — `docs/Plugin_stack_rules_v1.md` v3.13 (R0R14), `CLAUDE.md` v2.15 §3/§5,
`docs/Tooling_v8_3.md` Прил. Н v2.14, Pravila §12/§14/§15.
- Память — `project_automation_map.md`, `project_anthropic_dev_tooling.md`,
`feedback_plugin_paired_stack.md`.
- ADR — `docs/adr/003..010` (границы интеграций).
- git log — origin/main `515acb6` (anthropic-dev-tooling, 18.05).
## Следующий шаг
Пять рекомендаций, отвечающих на пять осей запроса (приоритет сверху вниз):
1. **iter8 — пересборка теплокарты NODE_META** (ось 1). Новое окно `META_WINDOW`,
включить волну 17–18.05; иначе режим «🔥 По использованию» врёт.
2. **Ревизия ruflo — keep/trim** (оси 2+5). Решение заказчика: оставить advisory как
есть / урезать демон (снять jitter-вред Pest) / отключить. 2 из 3 ⚫-конфликтов уйдут.
3. **Off-phase routing-матрица** (оси 3+5). Decision-матрица R13-стиля на 30 инструментов
#31–60 либо компактный routing-аид в CLAUDE.md §3.
4. **Панель «Связки» на карте** (ось 4). Сводные рекомендованные комбо узлов отдельным
режимом легенды.
5. **Ребаланс PSR_v1** (ось 5). Off-phase множеству — свой раздел-матрица; рассмотреть
облегчение UI-gate для не-UI задач.
+5
View File
@@ -0,0 +1,5 @@
{
"last_read_at": "2026-05-19T00:00:00+03:00",
"read_count_last_period": 0,
"period_start": "2026-05-19T00:00:00+03:00"
}
+46
View File
@@ -0,0 +1,46 @@
# Observer infrastructure
Passive evidence-loop for the Лидерра «brain» per ADR-011.
## Files
- `episodes-YYYY-MM.jsonl` — append-only JSONL, one line per Stop-event. Schema **v2** (`schema_version: 2`): the 5 mandatory fields + `decision_provenance` (who chose the node), `environment` (economy_level / model / post_compaction / session_turn / parallel_session), `task_size`, `task_ref`, `prompt_signal`, and an `outcome` that is `unknown` at write time (refined by `/brain-retro`). On an internal hook failure a minimal `observer_error` marker line is written instead of a silent skip. Written by `tools/observer-stop-hook.mjs` via `tools/observer-transcript-parser.mjs`.
- `notes/YYYY-MM-DD-<slug>.md` — optional MD notes for sessions with qualitative history.
- `STATUS.md` — auto-generated dashboard. Regenerated per-commit by `tools/status-md-generator.mjs`.
- `.read-counter.json` — C3 observer-of-observer counter. Updated on Read of observer files.
## Lifecycle
1. **Write**: every Stop-event appends one JSONL line, parsed from the session transcript (Stop-hook).
2. **Aggregate**: `/brain-retro` skill reads JSONL each sprint, proposes regulatory candidates.
3. **Surface**: `STATUS.md` shows controllers + monthly stats.
4. **Self-prune**: C3 warns if 54 weeks pass without any read of observer files.
## Routing-tag discipline
When the user dictates a specific method/node (e.g. «запусти discovery-interview»), Claude must emit one line in its response:
```
<!-- routing: provenance=user_directed_method node=<chosen> counterfactual=<node Claude would have chosen autonomously> -->
```
The Stop-hook routing-gate (`tools/observer-routing-detector.mjs` + `routingGateDecision`) detects a dictated method; if the tag is missing it returns `decision: block`, so the turn cannot end without the tag. The gate fires at most once per turn (`stop_hook_active` guard). This makes `decision_provenance` reliable — factor analysis can separate a router error from a user-dictated one.
## Privacy
PII filter (phone numbers, emails, tokens) is applied **before** every write — see `tools/observer-pii-filter.mjs`. gitleaks pre-push also scans observer files as part of full-history sweep.
## Don't
- Don't edit `episodes-*.jsonl` manually — it's append-only.
- Don't write outside `docs/observer/notes/` for hand-curated notes.
- Don't change `.read-counter.json` manually — it's maintained by hooks.
## HK1 pre-check (Pravila ADR-010) — verified 2026-05-19
Before registering `tools/observer-stop-hook.mjs` on Stop event (Task B5), verified collision against 6-component economy/skill-discipline architecture:
- **User-level** `~/.claude/settings.json` already has Stop hook: **agent-type** Sonnet-4.6 economy compliance verifier (analyzes transcript for claim-without-evidence violations).
- **Project-level** `.claude/settings.json` — Stop slot empty.
**Result**: no overwrite. observer-stop-hook will be added as **command-type entry in project-level Stop array**. Project + user scopes are independent slots in Claude Code 2.x — both run on the same Stop event without conflict. The agent verifier (user scope) and the JSONL appender (project scope) have non-overlapping responsibilities.
+20
View File
@@ -0,0 +1,20 @@
# Brain Status (auto-generated)
Last updated: 2026-05-19T11:22:16.708Z
| Контролёр | Состояние | Детали |
|---|---|---|
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
| 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 | ✅ | 23 episode(s), 982 recent commit(s) · Stop-hook + post-commit OK |
## Метрики (информационные, не алерты)
- Observer evidence: 23 episodes this month, 0 observer_error markers, 0 PII matches before filter
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Алерт-индикаторы
✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
+16
View File
@@ -0,0 +1,16 @@
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T05:18:16.342Z","ended_at":"2026-05-19T06:05:55.439Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"refactor"},"events":[{"kind":"tool_summary","counts":{"TodoWrite":2,"AskUserQuestion":5}}]}
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:07:06.499Z","ended_at":"2026-05-19T06:08:21.424Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[]}
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:10:13.713Z","ended_at":"2026-05-19T06:16:11.406Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Write":1,"Bash":2,"Edit":3,"TodoWrite":1}},{"kind":"error","message":"tool_result reported is_error"}]}
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:20:40.404Z","ended_at":"2026-05-19T06:23:08.962Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":2,"Read":1,"Edit":2}}]}
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:32:15.034Z","ended_at":"2026-05-19T06:57:02.675Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"bugfix"},"events":[{"kind":"tool_summary","counts":{"Read":17,"ToolSearch":1,"Glob":5,"TodoWrite":4,"Grep":14,"Write":1}}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:06:30.059Z","ended_at":"2026-05-19T08:10:43.437Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":82,"parallel_session":true},"task_size":{"tool_calls":12,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"bugfix"},"events":[{"kind":"tool_summary","counts":{"Edit":5,"Read":1,"Bash":4,"TodoWrite":2}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Bash":8,"PostToolUse:Bash":4,"PreToolUse:TodoWrite":2,"PostToolUse:TodoWrite":2},"errors":0},{"kind":"retry"}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:10:44.073Z","ended_at":"2026-05-19T08:13:14.644Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":83,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:13:37.924Z","ended_at":"2026-05-19T08:15:57.442Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":84,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":2,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Read":2,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Read":2,"PostToolUse:Read":2,"PreToolUse:Edit":3,"PostToolUse:Edit":3},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:21:19.146Z","ended_at":"2026-05-19T08:25:57.307Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":86,"parallel_session":false},"task_size":{"tool_calls":1,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"refactor"},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":1}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":1,"PostToolUse:AskUserQuestion":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:25:58.145Z","ended_at":"2026-05-19T08:28:19.676Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"brainstorming"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":87,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:29:06.419Z","ended_at":"2026-05-19T08:30:06.086Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":88,"parallel_session":false},"task_size":{"tool_calls":2,"files_touched":1,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Edit":1}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Edit":1,"PostToolUse:Edit":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:34:18.924Z","ended_at":"2026-05-19T08:40:38.461Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":132,"parallel_session":true},"task_size":{"tool_calls":2,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":2}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":2,"PostToolUse:AskUserQuestion":2},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:43:39.664Z","ended_at":"2026-05-19T08:46:16.416Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"approval","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":133,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\superpowers\\specs\\2026-05-19-observer-factor-analysis-design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Read":1,"Edit":4,"Grep":1}},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Grep":1,"PostToolUse:Grep":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T09:21:50.135Z","ended_at":"2026-05-19T09:27:09.498Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":139,"parallel_session":true},"task_size":{"tool_calls":11,"files_touched":3,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\observer\\episodes-2026-05.jsonl","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[{"kind":"tool_summary","counts":{"Bash":3,"Read":4,"Edit":4}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Bash":6,"PostToolUse:Bash":2,"PreToolUse:Read":4,"PostToolUse:Read":3,"PreToolUse:Edit":8,"PostToolUse:Edit":4},"errors":0},{"kind":"retry"},{"kind":"retry"}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:11:19.381Z","ended_at":"2026-05-19T10:12:06.880Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":140,"parallel_session":true},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"question"},"events":[{"kind":"hook_fired","counts":{"Stop":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:13:02.977Z","ended_at":"2026-05-19T10:24:02.234Z"},"path_type":"regulated","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":91,"parallel_session":true},"task_size":{"tool_calls":19,"files_touched":4,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\553717ec-bf55-43dc-8b9c-b9812711023a.jsonl","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.test.mjs","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.mjs","c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"superpowers:systematic-debugging","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":true,"rules":["Pravila §12"]},"task_classification":"other"},"events":[{"kind":"skill_invoked","skill":"superpowers:systematic-debugging"},{"kind":"skill_invoked","skill":"claude-md-management:claude-md-improver"},{"kind":"tool_summary","counts":{"Skill":2,"Grep":2,"Read":5,"Bash":7,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Skill":2,"PostToolUse:Skill":2,"PreToolUse:Grep":2,"PostToolUse:Grep":2,"PreToolUse:Read":5,"PostToolUse:Read":5,"PreToolUse:Bash":14,"PostToolUse:Bash":7,"PreToolUse:Edit":6,"PostToolUse:Edit":3},"errors":0}]}
View File
+72
View File
@@ -0,0 +1,72 @@
# Router procedure v1.0
**Status:** active (introduced 2026-05-19, spec dd5bded, ADR-011)
**Owner:** Claude Code automatic at session start.
## Purpose
Single source of truth for «task → node(s)» routing. Replaces implicit routing scattered across Pravila §12/§14/§15, PSR_v1 R0R15, Tooling §3, and routing-off-phase.md by explicitly listing the procedure executed every turn.
## Inputs
1. Active task from user prompt.
2. Node registry: [`docs/Tooling_v8_3.md`](Tooling_v8_3.md) Прил. Н §4.X (9 obligatory attributes per row — see §4.1 spec).
3. Off-phase routing table: [`docs/routing-off-phase.md`](routing-off-phase.md).
4. Hard-floor rules: [Pravila §12 / §14 / §15](Pravila_raboty_Claude_v1_1.md).
5. ADR boundaries: `docs/adr/*.md`.
## Procedure (5 steps, executed per turn)
### Step 1 — Hard-floor check (Pravila §12 / §14 / §15)
- Does the prompt contain `queen` / `королева`? → Pravila §14 (currently dormant per §14.9).
- Is the task in Pravila §12.2 map (TDD / debug / brainstorm / writing-plans / verification / discovery / migration / commit / review / UI-feature / arch-decision / refactor / parallel-sessions / worktree / writing-skills)? → invoke skill FIRST (hard-rule).
- Will any of the 8 normative files be edited? → §15.2 pre-flight sync MANDATORY.
- Subagent + git tasks? → §15.1 Sonnet/Opus only (NEVER Haiku).
If any hard-floor rule applies and is skipped — this is a violation, regardless of subsequent steps.
### Step 2 — Classification
- Phase-active (0/1/2/3) or off-phase?
- Type: TDD / debug / brainstorm / writing-plan / verification / discovery / migration / commit / review / UI-feature / arch-decision / refactor / docs / sync / other.
- Identify task triggers (keywords, file types touched, output requested).
### Step 3 — Trigger-based node selection
- Scan Tooling Прил. Н §4.X for rows whose `triggers` attribute matches the classified task.
- If ≥2 nodes match — apply ADR boundaries (the `boundaries` attribute points to the relevant ADR-NNN).
- If conflict remains — apply PSR_v1 R15.3 (specificity priority).
### Step 4 — Canonical chain check (if applicable)
- If the matched node-set corresponds to one of L1L12 chains in `routing-off-phase.md` §4 — invoke the chain.
- If no chain matches — execute as ad-hoc combination. Observer will record `path_type: improvised`.
### Step 5 — Execution
- Invoke skill (if §12 applies) or apply selected node(s) by trigger.
- Document boundary decisions in inline comments OR in the observer log.
## What this procedure does NOT consult
- **No cache of «verified chains»** (history-based records). Such a cache was explicitly rejected in brainstorming turn 8 (2026-05-19).
- **No `last-used-on-real-task` attribute** on registry rows. Unused-status is not used to choose or skip a node (capability-readiness — see `memory/feedback_brain_unused_tools_not_problem.md`).
- **No forced-choice gate**. Nodes that don't match triggers are silently skipped.
## When this procedure is consulted
Every turn — implicitly by Claude at session start, explicitly when routing is ambiguous.
## Relationship to other documents
- Pravila §12/§14/§15 — hard-floor; this procedure step 1 enforces them.
- PSR_v1 R0R14 — UI-stack apparatus; consulted in step 3 when task touches UI.
- PSR_v1 R15 — off-phase routing extension; consulted in step 3 for off-phase nodes.
- Tooling §3 / §4.X — node registry; the input to step 3.
- routing-off-phase.md — chains L1L12; consulted in step 4.
## Changelog
- **v1.0 (2026-05-19)** — initial fixation. Replaces implicit-scattered routing. ADR-011.
+129
View File
@@ -0,0 +1,129 @@
# Routing-аид: задача → off-phase узел
> **Назначение.** Quick-reference: триггер задачи → какой off-phase узел тулчейна
> взять (Tooling §4.11–§4.35). Закрывает пробел SYSTEM-аудита 18.05.2026 (Rec3):
> 30 off-phase инструментов регулировались плоским 3-блочным реестром PSR_v1 R10.1
> без матрицы «задача → узел».
>
> **Scope.** Только off-phase (#31#60 + ruflo, infrastructure). Активные фазовые
> инструменты (#1#29 + #30 Frontend Design) — карта CLAUDE.md §3.1/§3.2/§3.4.
> Superpowers-skills и hard-rules — Pravila §12.2 (не дублируется здесь).
>
> **Источник истины.** Tooling §4.X (детальное описание каждого узла), Pravila §13.2
> (категоризация off-phase), PSR_v1 R10.1 (3-блочный реестр ролей).
>
> **Версия.** 1.1 (18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
> конверсии» → process-analysis #53 (M3); +note про UI-пул #31/#32 как делегирующие
> строки, не R15-routed (M1). v1.0 — Rec3 SYSTEM-аудита). Триггеры — формулировки
> заказчика или явные ключевые слова в промпте.
---
## Таблица routing
| Триггер задачи | Узел | # | Категория | Гейт |
|---|---|---|---|---|
| Архитектурное решение, ADR, обоснование выбора | **adr-kit** | #36 | architecture-tooling | `adr-judge` в lefthook job 9 |
| C4 / контекст / контейнер / компонент-диаграмма | **mermaid-skill** | #37 | architecture-tooling | вендорен; рендера не нужно |
| Справка по архитектурному паттерну (Clean/Hex/DDD/CQRS…) | **architecture-patterns** | #38 | architecture-tooling | knowledge-only |
| Контроль направления зависимостей / границ слоёв `App\` | **deptrac** | #43 | architecture-tooling | lefthook pre-commit job 10 |
| Security-аудит diff/PR, supply-chain риск, вариант-анализ | **Trail of Bits Skills** (8 плагинов) | #39 | audit-security | on-demand кампания |
| Inline-предупреждения уязвимостей при правке кода | **Security Guidance** (PreToolUse-хук) | #40 | audit-security | блокирующий `sys.exit 2` |
| SAST-сканер всего кода | **Semgrep MCP** | #25 (фаза 3) | — | npm run sast |
| Полный security-review текущей ветки | `/security-review` (slash-команда) | — | audit-security | customized FP-фильтр |
| Полный портальный аудит | **audit-portal** (project-скил) | — | audit-security | 14-фазный |
| PRD → эпик → GitHub-issues → параллельные агенты → код | **CCPM** (vendored skill) | #41 | project-management | `.claude/prds/` + `.claude/epics/` |
| PRD / roadmap-update / metrics-review / sprint-planning | **product-management** (Anthropic-плагин) | #42 | project-management | 9 slash-команд |
| GitHub-issues операции (просмотр/создание) | **GitHub MCP** | #3 (фаза 0) | — | через `mcp__github__*` |
| Извлечь дизайн-токены из Figma | **Figma MCP** | #44 | design-tooling | **DEFERRED** — нет Figma-аккаунта |
| Вставить SVG-иконку из 10 коллекций (не Lucide) | **Universal Icons MCP** | #45 | design-tooling | ADR-006 D4: Lucide через `lucide-vue-next` |
| Дизайн-критика / UX-копи / a11y-уровня дизайна / research synthesis | **Design plugin** | #46 | design-tooling | pre-code; Pa11y остаётся технический SoT |
| Introspection OpenAPI/REST API чужой/своей | **openapi-mcp-server** | #47 | integration-tooling | READ-ONLY |
| Генерация OpenAPI-спеки своего API | **api-docs agent** (claude-flow) | — | integration-tooling | без Tooling-номера |
| Eval LLM-промпта / red-team / регрессия на промпт | **promptfoo** (npm CLI) | #48 | ml-ai-tooling | вручную/CI, **никогда в хук** (ML1) |
| Классический ML-воркфлоу: алгоритм / feature eng / оценка | **Data Scientist skill** | #49 | ml-ai-tooling | knowledge-only |
| Исполняемый ML-ноутбук с обучением | **Jupyter MCP** | #50 | ml-ai-tooling | **DEFERRED** — нет Python ML-окружения |
| Документировать/оптимизировать/change-management бизнес-процесс | **operations** (9 скилов) | #51 | business-process | Mermaid-рендер делегирует #37 |
| BPMN 2.0 to-be модель процесса, RACI, state-машина | **process-modeling** (project-скил) | #52 | business-process | как process-discovery from-head |
| As-is discovery процесса из кода Laravel + audit-логов, узкие места, KPI | **process-analysis** (project-скил) | #53 | business-process | from-code; ≠ discovery-interview (from-head) |
| Диагностика просадки метрики/конверсии (почему падает B2, где теряем в воронке) | **process-analysis** (project-скил) | #53 | business-process | from-code + audit-данные; discovery-interview SKIP-кейс |
| n8n workflow-движок | **n8n-mcp** | #54 | business-process | **DEFERRED** — n8n не в стеке |
| Интервью-discovery перед фичей (FEATURE) / ориентация по проекту (SYSTEM) | **discovery-interview** (project-скил) | #55 | discovery-tooling | разрез по слою-источнику с #53 (ADR-009) |
| Brainstorm: проблема не очерчена, нужно вскрыть | `superpowers:brainstorming` | — | (Superpowers, §12.2) | не off-phase, но связан |
| Создать новый скил из ≥3 повторений workflow | **skill-creator** | #56 | authoring-tooling | политика триггеров ADR-010 |
| Создать новый Claude Code plugin | **plugin-dev** | #57 | authoring-tooling | knowledge for plugin authoring |
| Создать хук на повторяющуюся ошибку | **hookify** | #58 | authoring-tooling | **HK1 pre-check** на коллизию economy/skill-discipline |
| Подсказки настроек Claude Code для проекта | **claude-code-setup** | #59 | dev-support | recommender |
| Текущая документация библиотеки/SDK/CLI | **context7** | #60 | dev-support | вместо WebSearch для библиотек |
| Отладка production runtime errors через self-hosted Sentry | **Sentry MCP** | #34 | debug-runtime | READ-ONLY, pending Б-1 deployment |
| Отладка Redis/Memurai очередей / кэша / Pest-квирков 73/77 | **Redis MCP** | #35 | debug-runtime | READ-ONLY обязательно |
| Правки `CLAUDE.md` | **claude-md-management** | #33 | infrastructure | §5 п.10 — единственный канал |
| UI-резерв (50+ стилей / 161 палитра / 99 UX-гайдлайнов / 25 чартов) | **UI UX Pro Max** | #31 | UI-пул † | PSR_v1 R14.3 pipeline; R6.0+R6.1 фильтр |
| UI стартовый шаблон / иконка-логотип бренда | **21st Magic MCP** | #32 | UI-пул † | PSR_v1 R14.4 pipeline; R6.0+R6.1 фильтр |
| Оркестрация роя / queen / королева | **ruflo** | — | orchestration | **ИЗОЛИРОВАН 18.05.2026** (Pravila §14.9 dormant) |
> **† UI-пул (#31 UPM / #32 21st) — делегирующие строки.** R15.6 явно: к UI-пулу R15
> не применяется — это UI-задачи по природе, их ведёт R14 pipeline. Строки выше —
> не off-phase routing, а указатель «иди в R14», включены для полноты единого
> reference. Фактический регламент UPM/21st — PSR_v1 R14.3/R14.4, не эта таблица.
---
## Канонические связки 2+ узлов
> Закрывает Rec4 SYSTEM-аудита 18.05.2026 («сводного списка рекомендованных комбо
> нет — синергия размазана по 125 полям `together` в NODE_DETAILS карты»).
> Здесь — 12 цепочек, где совместная работа узлов даёт эффект сильнее суммы. UI-рендер
> панели «🔗 Связки» на карте — отдельный future iter; данные ниже — основа для него.
| # | Цепочка | Зачем |
|---|---|---|
| L1 | `discovery-interview` (FEATURE) → `brainstorming``writing-plans``subagent-driven-development` | Полный цикл от боли заказчика до атомарных коммитов. Передаёт discovery-brief в brainstorming без re-asking; план потом исполняется параллельными субагентами. |
| L2 | `discovery-interview` (SYSTEM) + `audit-portal` | Ориентация по состоянию (где мы) + вердикт здоровья портала. Snapshot 18.05 — пример L2 в действии. |
| L3 | `process-analysis` (#53) ↔ `process-modeling` (#52) | As-is из кода ↔ to-be BPMN. Парная работа: #53 вскрывает узкое место, #52 моделирует целевую схему. Разрез по слою-источнику — ADR-009. |
| L4 | `mermaid-skill` (#37) ← `adr-kit` (#36) / `process-modeling` (#52) / `operations` (#51) | Mermaid рендерит C4 / BPMN / process-doc для трёх потребителей. Single source of truth для диаграмм. |
| L5 | `adr-kit` (#36) + `architecture-patterns` (#38) + `deptrac` (#43) | Архитектурный треугольник: решение → паттерн-обоснование → fitness-контроль (статический enforcement через lefthook job 10). |
| L6 | `Trail of Bits` (#39) + `Semgrep MCP` (#25) + `Security Guidance` (#40) + `/security-review` | Слоистый security: блокирующий PreToolUse-хук на правке → inline SAST → on-demand глубокий аудит → review всего бренча. TB1 / SG1 — границы. |
| L7 | `openapi-mcp-server` (#47) + `api-docs agent` + `Boost MCP` (#10) | Интеграционная разработка: спека-introspection + генератор спеки + Laravel API. Раздел A3 карты. |
| L8 | `systematic-debugging` + `Sentry MCP` (#34) + `Redis MCP` (#35) | Runtime-баги: ≥3 гипотезы → факты из production (Sentry) + factual из очередей/кэша (Redis). Snapshot указал как недоиспользуемую связку. |
| L9 | `CCPM` (#41) + `product-management` (#42) + `GitHub MCP` (#3) | PRD → эпики → GitHub issues. CCPM делает трассируемость; product-management — продуктовые церемонии; GitHub MCP — issue/PR-операции. |
| L10 | `promptfoo` (#48) + `Data Scientist skill` (#49) + `claude-api skill` (Sonnet 4.6 SDK) | LLM-фича: eval LLM-промптов + ML-воркфлоу + Anthropic SDK. ML1 — promptfoo только вручную/CI. |
| L11 | `skill-creator` (#56) + `hookify` (#58) + `plugin-dev` (#57) | Расширение Claude-инфраструктуры: ≥3 повторений workflow → новый скил / ошибка повторяется → новый хук (HK1 pre-check) / задача требует плагина → plugin-dev. |
| L12 | `claude-md-management` (#33) + `revise-claude-md` skill | Захват session-learnings → CLAUDE.md update. Единственный канал §5 п.10. |
**Anti-pattern связок** (не комбинировать в одной задаче):
- **UPM (#31) ↔ Frontend Design (#30) ↔ 21st (#32)** — PSR_v1 R14.5 запрещает параллельное использование UI-генераторов (один pipeline, один решатель).
- **ruflo Queen ↔ subagent-driven-development напрямую** — ruflo сейчас изолирован (Pravila §14.9 dormant). При реактивации связь снова работоспособна.
- **`mcp_figma` (DEFERRED) → Frontend Design code-gen** — ADR-006 FM1: extract-only для Figma; code-gen дублировал бы FD.
- **`Data Scientist skill` (#49) → решатель в коде** — knowledge-only, не пишет код за вас (R10.2 PSR_v1).
---
## Дисциплина выбора
1. **Не подменяй фазовые инструменты off-phase.** Если задача попадает под фазу
0/1/2/3 (Tooling §2–§5) — берём фазовый узел. Off-phase — резерв и специализация.
2. **DEFERRED-узлы (#44 Figma / #50 Jupyter / #54 n8n)** — не использовать без явного
возобновления (нет аккаунта / нет Python ML / нет n8n в стеке). Запрос «через Figma»
при текущем состоянии = эскалация заказчику.
3. **Изолированные узлы (ruflo на 18.05.2026)** — не маршрутизировать. Запрос
с `queen`/`королева` сейчас выполняется напрямую (§14 dormant). При запросе
реактивации — план в memory `feedback_ruflo_isolated.md`.
4. **UI-пул (#31 UPM / #32 21st)** — только через R14 pipeline. Никогда не решатель,
всегда материал; обязательны R6.0 фильтр + R6.1 hard-override Forest + FD адаптация.
5. **Hard-rules (Pravila §12 / §14 / §15)** перевешивают этот routing-аид при коллизии.
6. **Граничные случаи между похожими узлами** — кросс-ссылки в Tooling §4.X (например
#53 process-analysis ↔ #55 discovery-interview через слой-источник ADR-009;
#39 ToB ↔ #25 Semgrep MCP через scope TB1).
---
## Связано
- [docs/Tooling_v8_3.md](Tooling_v8_3.md) §4.11–§4.35 — детальные описания узлов.
- [docs/Pravila_raboty_Claude_v1_1.md](Pravila_raboty_Claude_v1_1.md) §13.2 — категоризация off-phase.
- [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) R10.1 — 3-блочный реестр ролей; R14 — UI-пул pipeline.
- `CLAUDE.md` §3.3 — оперативная карта с командами установки.
- `docs/discovery/2026-05-18-system-audit-brain.md` Rec3 — источник решения.
- `docs/adr/*.md` — границы между категориями (ADR-003/004/005/006/007/008/009/010).

Some files were not shown because too many files have changed in this diff Show More