Compare commits

...

293 Commits

Author SHA1 Message Date
Дмитрий 7df4786499 docs(discovery): brief переделки миграции проектов + распределения лидов
Зафиксированы решения discovery-интервью 2026-05-20: два режима экспорта
проектов (онлайн + пакетный 18:00 МСК), один save с тремя флагами B1+B2+B3,
tag=регион, и новый алгоритм распределения лидов (cap=3 рандом из недобравших,
заказ = max(наиб_лимит, ceil(Σ/3)); группировка отменена). Реализация не начата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:58:57 +03:00
Дмитрий 162fe010fe feat(map): iter9 — brain governance subsystem (+9 nodes, +12 edges, +1 GREEN)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:12:24 +03:00
Дмитрий 426983ffaa docs(map): iter9 implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:59:29 +03:00
Дмитрий 87c5eb6323 docs(map): spec self-review fix — edges 13->12
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:50:18 +03:00
Дмитрий cb864b18a5 docs(map): iter9 brain-governance design spec
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:49:32 +03:00
Дмитрий 4b4c8d94b9 docs(etalon): refresh snapshot after supplier-migration-followup epic (HEAD 8f5a399→dd0a9ff, demo re-seed, failover live-smoke) 2026-05-20 04:10:09 +03:00
Дмитрий dd0a9ffea6 docs(observer): sync spec §6 with as-built factor-analyzer
§6 drifted from the implemented brain-retro analyzer after Phase 1.2/1.3:
- factor matrix now lists 9 axes (session_turn + parallel_session were
  captured in the episode schema §3 but missing from the §6 matrix);
- outcome inference documents 'blocked' (error events > retry events) and
  notes 'failure' as deferred to the phase-2 agent-judge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:17:17 +03:00
Дмитрий 353b1599b6 fix(observer): brain-retro analyzer — blocked outcome + v1 filter + factors
P0.1b: inferOutcome emits 'blocked' when a turn had more error than retry
events (an unrecovered tool failure) — previously the enum value was dead.

P0.1c: 'failure' documented as deferred to the phase-2 agent-judge. It is a
judgment (work wrong AND never corrected), not deterministically recoverable
from a transcript; a wrong-then-corrected turn surfaces as 'rework'.

P1.1: analyze() drops v1 episodes (no schema_version 2) — they lack
environment/prompt_signal/decision_provenance and polluted the factor
matrix. Reports v1SkippedCount.

P2.1: session_turn (bucketed early/mid/late) and parallel_session added to
FACTOR_FNS — closes the schema↔matrix mismatch (both were captured in the
episode but absent from the factor axes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:44 +03:00
Дмитрий 97388cf840 fix(observer): transcript-parser accuracy — session_turn + correction signal
P0.2: count session_turn from the last compaction. The transcript file
accumulates duplicated context-rebuild snapshots (quirk #101), so counting
real prompts from i=0 inflated it and made it non-monotonic. Now counts
"real prompts since the last compaction" — monotonic by construction.

P0.1a: widen the correction prompt_signal regex (не работает / сломал /
опять / откати / revert / still not / wrong / ...). The old regex was too
narrow, so rework outcomes were invisible to the factor analysis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:29 +03:00
Дмитрий 8f5a399a25 docs(discovery): 3-tier failover live-smoke 2026-05-19 — all tiers green, 156/156 Supplier suite 2026-05-19 17:31:15 +03:00
Дмитрий efd3e73aa2 fix(supplier): manage-project.js — drop wrong status-switch click + recon live-smoke
Task 4 live-smoke выявил: единственный .el-switch формы — include/exclude
регионов (regions_reverse), НЕ статус active/paused. Старый код кликал его
по dto.active → ошибочно ставил regions_reverse. Статус — дефолт портала
(active), UI-switch для него нет → switch-блок удалён.

recon-doc 2026-05-19-rt-project-form-locators.md: +секция Live-smoke
(domain-формат валидируется, multi-source save = N проектов, switch = regions,
type/tab re-render); row 6 исправлен.
2026-05-19 17:31:15 +03:00
Дмитрий 0f1b604554 fix(supplier): manage-project.js robustness — conditional type/tab clicks + diag dump
Найдено при Task 4 live-smoke form-канала:
- type-select и вкладка «Список» кликались безусловно → re-click уже-активного
  значения ремоунтит Element UI tab-pane (textarea детачится). Теперь кликаем
  только при реальной смене значения + waitForTimeout после смены типа.
- defensive: проверка непустого textarea после fill content.
- diag: на status!=OK дамп фактически отправленного rt-project-save body в stderr.
2026-05-19 17:31:14 +03:00
Дмитрий 48d7303963 fix(supplier): manage-project.js — text-only platform locator + exact endsWith URL match (reviewer Critical+Important) 2026-05-19 17:31:13 +03:00
Дмитрий b9e72e6231 feat(supplier): rewrite manage-project.js for Element UI + intercept rt-project-save response for external_id
- fillForm rewritten to label-for locators (.el-form-item:has([for="..."])) from recon 2026-05-19
- createOp: external_id from page.waitForResponse('rt-project-save') body, not DOM
- updateOp: same save endpoint intercept; row found by data-id or text
- listOp: Vuex state strategy 1, DOM scrape strategy 2, empty array fallback
- Known gaps (JSDoc + stderr warnings): workdays not in add-project form (portal default);
  regions require id->name mapping (skipped in Tier-2 MVP, logged to stderr)
- Test: HTTP fixture server serves rt-form-element-ui.html + handles /admin/visit/rt-project-save
- Fixture: .v-dialog--active wrapper + 10 .el-form-item (label[for=...]) + type-select popup in body

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:31:13 +03:00
Дмитрий 80c5f6289a docs(discovery): rt-project form locators recon (Element UI + Vuetify dialog, 10 fields) 2026-05-19 17:31:12 +03:00
Дмитрий 895975482d test(supplier): cover FailoverProjectChannel tier-3 escalation + transient bypass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:31:11 +03:00
Дмитрий e81cd8ed2c test(supplier): lock HTTP-200-without-Content-Type contract (no login-detect false-positive) 2026-05-19 17:31:11 +03:00
Дмитрий bff5faf02b feat(supplier): detect HTTP-200 HTML login page → force refresh+retry (defense-in-depth) 2026-05-19 17:30:54 +03:00
Дмитрий 8df5a3fe00 docs(supplier): plan for migration follow-up — HTTP-200 login detect + form rewrite + 3-tier smoke 2026-05-19 17:29:52 +03:00
Дмитрий 83295a25f3 fix(brain): redirect / to /docs/observer/dashboard.html (browser-smoke fix)
Browser smoke (Playwright) revealed that rewriting path internally without
changing the response URL left the browser's base URL as /, breaking
relative <script src="dashboard.js"> and ../automation-graph-data.js
references. 302 redirect makes the browser settle on /docs/observer/,
which resolves the relative paths correctly. All 4 views verified clean
(0 console errors). Screenshots: brain-dashboard-{map,replay,feed,aggregate}-view.png.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:52 +03:00
Дмитрий 0fad4305d4 feat(brain): Forest polish + observer README entry for the dashboard 2026-05-19 16:23:52 +03:00
Дмитрий 2f60910b09 feat(brain): conflict three-layer panel (design / friction / correlation) +3 tests 2026-05-19 16:23:51 +03:00
Дмитрий f48d5115ce feat(brain): Агрегат view — metric tiles + node heat overlay 2026-05-19 16:23:51 +03:00
Дмитрий 774763c21c feat(brain): aggregator — node heat, distributions, redirect rate (+4 tests) 2026-05-19 16:23:50 +03:00
Дмитрий c1b690edd3 feat(brain): Лента auto-poll with pause (5s interval, view-driven) 2026-05-19 16:23:50 +03:00
Дмитрий e34b11aca5 feat(brain): Лента view — groupBySession + grouped feed UI 2026-05-19 16:23:49 +03:00
Дмитрий b4f4f441b5 feat(brain): Разбор view UI — list + filters + trajectory highlight 2026-05-19 16:23:49 +03:00
Дмитрий 475e233c2a feat(brain): filterEpisodes + 3 tests (Task 7 logic; UI deferred)
Worktree has no app/node_modules — vitest not run here; final regression
deferred to main-checkout post parallel-session release. Logic is a 7-line
pure filter; tests cover empty filter, classification, errors-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:48 +03:00
Дмитрий 3e289479f0 feat(brain): Карта view — plain topology + design conflicts list 2026-05-19 16:23:48 +03:00
Дмитрий 0cee520f0d feat(brain): dashboard shell + graph banner + view switching 2026-05-19 16:23:47 +03:00
Дмитрий c3392bef13 feat(brain): node attribution — episode signals to graph nodes 2026-05-19 16:23:46 +03:00
Дмитрий 7fed5bc18b feat(brain): episode JSONL parser + v1/v2 normalizer 2026-05-19 16:23:46 +03:00
Дмитрий 43028228c8 refactor(brain): extract automation-graph topology to a shared data file 2026-05-19 16:23:45 +03:00
Дмитрий f1092772fb feat(brain): static server + /api/episodes for the dashboard 2026-05-19 16:23:45 +03:00
Дмитрий 702c2ff7b5 fix(brain): correct vitest command in plan — run from app/
The config's include `../tools/*.test.mjs` resolves relative to its
own dir (app/), not cwd. Baseline verified 2026-05-19 from app/:
11 files, 169 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:44 +03:00
Дмитрий b75f9e3d21 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 16:23:44 +03:00
Дмитрий 2e26edbb3a 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 16:23:43 +03:00
Дмитрий 643e1a5dcf fix(supplier): refresh-session.js — устранена гонка Promise.all/click
Логин-страница уже в состоянии networkidle → waitForLoadState резолвился
мгновенно (до пост-логин редиректа), скрипт хватал PHPSESSID
неаутентифицированной логин-страницы. CSV-сверка 11:00 (19.05) упала
"load-reports returned non-array response" — портал отдал HTTP 200
+ HTML логин-страницы вместо JSON-массива отчётов.

После клика submit:
- waitForFunction опрашивает исчезновение #loginform-username из DOM
  (переживает навигацию);
- guard exit 1, если форма осталась — отклонённый логин больше не
  маскируется под «успех» (exit 0).

Verified: 2× RefreshSupplierSessionJob → валидная сессия (load-reports
JSON-массив из 39 отчётов); CsvReconcileJob id=7 status=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:26:39 +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
Дмитрий b40f2c8ffb feat(map): discovery_interview node — discovery-tooling, E5 section
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:35:36 +03:00
Дмитрий 63337b418d docs(discovery): process-analysis — reciprocal SKIP boundary to discovery-interview
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:28:34 +03:00
Дмитрий 2ebc776cc9 docs(discovery): register discovery-tooling — Tooling/PSR/Pravila/CLAUDE.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:37:16 +03:00
Дмитрий a0691e8857 docs(discovery): ADR-009 — discovery-interview tooling decision
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:24:51 +03:00
Дмитрий 50fc188f01 feat(discovery): add docs/discovery — README + brief/snapshot templates
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:23:42 +03:00
Дмитрий 14f92d5147 feat(discovery): add discovery-interview skill — FEATURE + SYSTEM modes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:22:08 +03:00
Дмитрий 802cda1b34 docs(discovery): brainstorming spec + integration plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 05:28:58 +03:00
Дмитрий 33d9c43450 docs(c10): fix lint debt in brainstorming spec (MD032 + optimise→optimize)
Spec committed pre-lefthook (cd56efb) — never lint-checked. MD032
blank-around-lists + British→US spelling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:44:15 +03:00
Дмитрий afcff10892 feat(map): C10 nodes — closes section «Бизнес-процессы (общее)»
3 new nodes (ops_plugin, process_modeling, process_analysis) → NODE_SECTION
C10; 5 reuse cross-refs (mermaid/architecture-patterns/CCPM/product-management/
writing-plans) → NODE_SECTION_SECONDARY; 3 governing edges; 3 nd() + Паспорт
entries. Map 121→124 nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:44:15 +03:00
Дмитрий 1a49d7b127 docs(c10): register business-process category — Tooling/PSR/Pravila/CLAUDE.md
C10 #51 operations + #52 process-modeling + #53 process-analysis +
Tooling Прил.Н v2.11 (§4.26-4.29, §0 50→54), PSR_v1 v3.11 (R10.1),
Pravila v1.25 (§13.2), CLAUDE.md v2.11. CLAUDE.md via direct Edit —
worktree-constraint exception to §5 п.10 (A11 v1.24 precedent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:44:15 +03:00
Дмитрий a816c2413b feat(c10): bootstrap docs/process — README + worked example + ADR-008
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:52 +03:00
Дмитрий b22b76f96e feat(c10): add self-authored process-analysis skill (discovery/bottleneck)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:52 +03:00
Дмитрий ea5e475f32 feat(c10): add self-authored process-modeling skill (BPMN/process maps) 2026-05-18 04:33:52 +03:00
Дмитрий 626baa65ec docs(c10): plan correction — operations is 9 skills, not /ops:* commands
Task 2 install revealed operations@knowledge-work-plugins v1.2.0 ships
9 skills (process-doc, process-optimization, change-request, …) and 0
lifecycle hooks — not /ops:* slash-commands. OPS4 resolved on install;
+OPS5 (boundary vs the 2 self-authored skills); skill "Границы" sharpened.
cspell-words += RACI/DMN/czlonkowski.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:51 +03:00
Дмитрий bcba3a153c docs(c10): implementation plan — C10 business-process tooling integration
9-task plan: install operations plugin, author process-modeling +
process-analysis skills, bootstrap docs/process/ + ADR-008, normative
sync (#51-54), map closure (3 nodes + 5 cross-refs). n8n-mcp DEFERRED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:12 +03:00
Дмитрий 3e389365d5 docs(c10): brainstorming spec — C10 business-process tooling integration
Design doc for populating the empty C10 «Бизнес-процессы (общее)» map
section. Approach 3 (hybrid + vendoring): operations plugin + 2
self-authored vendored skills (process-modeling, process-analysis) +
5 reuse cross-refs; n8n-mcp DEFERRED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:12 +03:00
Дмитрий e29f38280e chore(deals): post-review cleanup — refresh stale §6.4 docs + mapper count assertion 2026-05-18 03:42:41 +03:00
Дмитрий 0f4f7161c8 feat(deals): Kanban — 5-column funnel (comment + test sync) 2026-05-18 03:42:41 +03:00
Дмитрий b4138bbc82 feat(deals): sweep 14->5 funnel slugs — controllers, mocks, stories, tests 2026-05-18 03:42:41 +03:00
Дмитрий 80c1cfd9e4 feat(deals): useStatusPill — add viewed/lost funnel slugs 2026-05-18 03:42:41 +03:00
Дмитрий 37518e6aa2 feat(deals): leadStatuses composable — 5-status funnel snapshot 2026-05-18 03:42:41 +03:00
Дмитрий a2b6293566 feat(deals): StatusRuToSlugMapper — remap supplier RU statuses to 5-slug funnel 2026-05-18 03:42:41 +03:00
Дмитрий 77cc535ab2 feat(deals): migration — remap deals.status + drop obsolete lead_statuses (14->5) 2026-05-18 03:42:41 +03:00
Дмитрий 5e73e0cf0f feat(deals): schema — lead_statuses funnel 14->5 (new/viewed/in_progress/won/lost) 2026-05-18 03:42:41 +03:00
Дмитрий 90be402106 test(deals): make 'one loadDeals' regression test non-vacuous (exercise page!=1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 03:42:41 +03:00
Дмитрий e9ae43a81b test(deals): drop obsolete ids-based export tests from DealCreateTest (superseded by DealExportTest)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 03:42:40 +03:00
Дмитрий 78333da3d5 test(deals): rewrite DealsView spec for redesign; drop DealsViewRedesign spec + DEALS_TABS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:42:40 +03:00
Дмитрий fc7d34a131 fix(deals): DealsView — single reload per filter change, clear search debounce on unmount 2026-05-18 03:42:40 +03:00
Дмитрий efc6dbeb0a feat(deals): DealsView — lead-registry redesign (export panel, per-page, master-detail panel) 2026-05-18 03:42:40 +03:00
Дмитрий d78a72c286 refactor(deals): A9 review nits — drop duplicate spec, single Pinia, accurate comment 2026-05-18 03:42:40 +03:00
Дмитрий ba12fecc5c refactor(deals): extract DealDetailBody; DealDetailDrawer = overlay/inline wrapper 2026-05-18 03:42:40 +03:00
Дмитрий 74cc4408c7 feat(deals): DealsBulkBar — status-change only (drop export/delete/trash) 2026-05-18 03:42:40 +03:00
Дмитрий ccf194ed8a feat(deals): DealsTable — lead-registry columns (Телефон/Источник/Город/Статус/Напоминание/Комментарий/Поставлен) 2026-05-18 03:42:40 +03:00
Дмитрий a2bfeafcea feat(deals): DealsFilters — phone search + Status/Project/City selects 2026-05-18 03:42:40 +03:00
Дмитрий f98a3bf109 feat(deals): DealExportController -- export by delivery-date range, lead-registry columns 2026-05-18 03:42:40 +03:00
Дмитрий 3981fdcbf3 fix(deals): DealController@index — 422 on malformed received_from/received_to date params 2026-05-18 03:42:40 +03:00
Дмитрий 5234e46d92 feat(deals): DealController@index — received_at date-range filter + comment/city/signal_type/next_reminder_at 2026-05-18 03:42:40 +03:00
Дмитрий a3167d5783 feat(deals): mapApiDeal maps city/comment/signalType/receivedAt/nextReminderAt 2026-05-18 03:42:40 +03:00
Дмитрий 7bcfbf6bd4 feat(deals): api/deals — ApiDeal +4 fields, date-range list params, exportDealsByRange 2026-05-18 03:42:40 +03:00
Дмитрий ad2c8f1704 feat(deals): extend MockDeal with city/comment/signalType/receivedAt/nextReminderAt 2026-05-18 03:42:40 +03:00
Дмитрий 55a34af986 feat(deals): redesign groundwork — spec, plan, mockups + sidebar nav cleanup
Deals page redesign: design spec + implementation plan (Phase A page redesign,
Phase B 14->5 status funnel) + v8 HTML mockups (variants comparison + final).
AppSidebar: remove Импорт данных / Отчёты nav links (routes stay reachable by
direct URL); AppLayout.spec updated to 6 nav items. stylelint --fix on mockups;
cspell-words += deals-redesign terms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:42:39 +03:00
Дмитрий 54451d2ea6 feat(projects): RegionsBulkDialog — subject-level regions (89 RF subjects) #1426
Bulk regions dialog reworked from federal-district bitmask to subject/region
selection, consistent with ProjectDetailsDrawer/NewProjectDialog. Full-stack:
add_regions/remove_regions on projects.regions INT[], BulkProjectActionRequest
split validation, ProjectService model-instance update. federal-districts.ts
removed (zero consumers). +menuRepositionFix util for v-autocomplete menu.
phpstan-baseline: bump actingAs ignore count 14->15 (new validation test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:41:46 +03:00
Дмитрий 9cf0f0c0c7 docs(adr): ADR-006 Decision-4 — Universal Icons icon-path boundary
Конфликт-аудит карты (docs/automation-graph.html) выявил
нерегламентированную границу: Universal Icons MCP #45 отдаёт raw SVG,
проектная конвенция (CTO-19) — lucide-vue-next + Vuetify IconSet.
ADR-006 регулировал #45 только против 21st logo_search.

- ADR-006: +Decision item 4 + Consequences bullet + Status Amended-строка
  (Lucide-иконки канонически через lucide-vue-next/Vuetify IconSet;
  raw-SVG MCP — только не-Lucide коллекции).
- CLAUDE.md v2.10 -> v2.11: §3.3 #45 +нота, §0 cross-ref Tooling v2.11, §9 +запись.
- Tooling Прил.Н v2.10 -> v2.11: §4.20 +UI3.

Pravila §13.2 / PSR_v1 — не затронуты (assess: §13.2 делегирует к ADR-006,
PSR_v1 R10.1 — role-registry). Счётчики инструментов без изменений (50).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:19:12 +03:00
Дмитрий de66b8b316 docs(map): refresh rule-node versions v1.24/v2.10/v3.10/v2.10 + tooling count (post-A11)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:59:13 +03:00
Дмитрий 008c8a3ad0 feat(map): A11 nodes — closes section «ML / AI-разработка»
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:42:18 +03:00
Дмитрий 18603f6881 docs(a11): register ml-ai-tooling category — promptfoo/Data Scientist skill/Jupyter MCP #48-50 (NUM1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:34:13 +03:00
Дмитрий d7aa5efe30 feat(a11): bootstrap docs/ml — README + promptfoo example + ADR-007
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:17:20 +03:00
Дмитрий 21f5047640 feat(a11): vendor Data Scientist skill into .claude/skills + lint-ignore (ML3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:28 +03:00
Дмитрий a539b08499 feat(a11): add promptfoo as devDependency for LLM prompt eval (ML1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:12:11 +03:00
Дмитрий 05706ef429 @
docs(a3): cspell-words.txt +ребейз-family

ребейз/ребейзнута/ребейзом — слова из CLAUDE.md §6/§9 и spec §7
(описание ребейза feat/a3 на origin/main). Единственные реально новые
термины A3-нормативки по cspell-прогону добавленных строк.

Task 10 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 16:06:34 +03:00
Дмитрий 35b48c1b0c @
docs(a3): CLAUDE.md v2.9 — register #47 openapi-mcp-server (A3 integration-tooling)

§3 title 46→47; §3.3 +строка #47 openapi-mcp-server; §1 row 2b 46→47;
§3.3 footer 46→47 + integration-tooling 9-я off-phase подкатегория
(17 off-phase); §0 cross-refs Pravila v1.23 / PSR_v1 v3.9 / Tooling v2.9;
§6 +абзац A3; §9 +запись. Шапка v2.8→v2.9.

Через /claude-md-management:claude-md-improver (§5 п.10).
Task 9 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 16:00:52 +03:00
Дмитрий 046c8b6efa @
docs(a3): Pravila v1.23 — §13.2 +Off-phase integration-tooling

§13.2 +абзац «Off-phase integration-tooling»: #47 openapi-mcp-server
(Tooling §4.22) + api-docs agent (узел карты A3 без Tooling-номера).
Не UI → вне R6/R14. Регулируются PSR_v1 R10.1 Блок 3. v1.22→v1.23.

Task 8 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:54:52 +03:00
Дмитрий fc5f58a992 @
docs(a3): PSR_v1 v3.9 — R10.1 Блок 3 +openapi-mcp (integration-tooling)

R10.1 Блок 3 (MCP-серверы) +1 строка openapi-mcp-server — категория
integration-tooling, off-phase, раздел A3. Не UI → вне R6/R14.
Tooling §4.22 #47. Версия v3.8→v3.9.

Task 7 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:50:02 +03:00
Дмитрий b51d5fb31d @
docs(a3): Tooling Прил. Н v2.9 — register #47 openapi-mcp-server (§4.22)

§4.22 — openapi-mcp-server (@ivotoby/openapi-mcp-server v1.14.0, MIT),
9-я off-phase подкатегория integration-tooling. §0 счётчик 46→47
(17 off-phase, 67 total). Парный узел карты — api-docs agent (без
Tooling-номера). Статус: verified.

Task 6 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:47:07 +03:00
Дмитрий 10b19df1c4 @
feat(map): A3 nodes — api-docs agent + openapi MCP

2 новых узла раздела A3 «Программирование — интеграции»: ag_apidocs
(api-docs agent, claude-flow) + mcp_openapi (openapi MCP, #47). NODES /
NODE_SECTION / NODE_DETAILS nd() / NODE_TIMELINE / EDGES (3 ребра).
pos()-углы 4/175 + 5/5 после Grep-проверки коллизий. Счётчик 116→118.

Task 4 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:39:30 +03:00
Дмитрий df4532d2fd @
feat(map): NODE_SECTION_SECONDARY layer — cross-ref nodes into A3

Аддитивный слой NODE_SECTION_SECONDARY (NODE_SECTION 1:1 не трогается):
кросс-реф mcp_boost/context7/ag_pest/mcp_semgrep/mcp_sentry в раздел A3.
SECTION_NODES build + Паспорт «Раздел» (формат «A1 (+A3)») обновлены.

Task 3 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:34:41 +03:00
Дмитрий d85b9391cc @
docs(a3): re-baseline spec+plan onto origin/main 1313d89

feat/a3 ребейзнута на актуальный origin/main (был форк от D3-эры).
C9/deptrac/A4 уже влиты → openapi-mcp #41→#47, Tooling §4.16→§4.22,
integration-tooling 7-я→9-я off-phase подкатегория. Версии:
Tooling v2.8→v2.9, PSR_v1 v3.8→v3.9, Pravila v1.22→v1.23, CLAUDE.md
v2.8→v2.9. Карта 116→118 узлов. Stale line-anchors → Grep-by-symbol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:32:03 +03:00
Дмитрий 2018959fdc @
feat(a3): register openapi-mcp-server in .mcp.json

openapi MCP server (@ivotoby/openapi-mcp-server v1.14.0, MIT, stdio) —
отдаёт docs/api/openapi.yaml как MCP-ресурс/тулы. Smoke verified
(npx --help, native-Windows OK). Конфиг — env-vars API_BASE_URL +
OPENAPI_SPEC_PATH (README stdio-форма).

Task 2 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:20:43 +03:00
Дмитрий ff3979d527 @
docs(a3): OpenAPI skeleton for /api/deals — A3 smoke artifact

Стартовый OpenAPI 3.1 скелет для группы /api/deals* (8 эндпоинтов)
как smoke-доказательство api-docs-тулинга. Redocly lint — valid (exit 0,
2 warning о неполноте, ожидаемо для скелета). Не полная спека API.

Task 1 плана A3 integration-tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:19:19 +03:00
Дмитрий 756a8838d6 @
docs(a3): A3 integration-tooling implementation plan

10 задач: api-docs smoke → openapi-mcp install → карта (NODE_SECTION_SECONDARY
слой + 2 узла) → нормативка (Tooling/PSR_v1/Pravila/CLAUDE.md) → регрессия+память.

Точные якоря карты: NODE_SECTION (110 узлов), SECTION_NODES build (1973-1977),
ld-section (2082-2083), форматы ag_pest/mcp_boost. Риск кросс-веточной
нумерации с A11/C9 — Task 10 Step 2.

cspell-words.txt +redocly/ivotoby. Через superpowers:writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:19:19 +03:00
Дмитрий a319e4f98a @
docs(a3): A3 integration-tooling design spec

Дизайн интеграции раздела A3 «Программирование — интеграции (API, вебхуки)»
карты automation-graph.html — параллельно A6/D3.

- 2 новых узла: api-docs agent (claude-flow, 0-install) + openapi-mcp-server
  (npm/stdio MCP, Tooling #41 §4.16)
- 5 кросс-реф узлов через новый аддитивный слой NODE_SECTION_SECONDARY
  (context7/Boost/Pest/Semgrep/Sentry — NODE_SECTION 1:1 не ломается)
- Нормативка: Tooling v2.5, PSR_v1 v3.5, Pravila v1.19, CLAUDE.md v2.5
- Риск кросс-веточной нумерации с A11/C9 зафиксирован

cspell-words.txt +4 валидных термина (аудировал/JVM/хендлеров/ivo).
Через superpowers:brainstorming (2 развилки сняты с заказчиком).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-17 15:19:19 +03:00
Дмитрий 1313d89525 docs(a4): add A4 design-tooling integration plan
The 8-task plan executed for the A4 epic, with the post-flight Plan Correction block (FM2 defer, #44-46 numbering, ADR-006, knowledge-work-plugins marketplace, /plugin unavailable in VSCode-extension env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:07:26 +03:00
Дмитрий bcce4d9986 feat(a4): register Universal Icons MCP #45 in .mcp.json
Off-phase A4 design-tooling. Smoke verified post-reload: health_check OK, get_icon home/lucide returns a valid framework-neutral SVG. Comment ADR ref corrected ADR-004 -> ADR-006.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:07:25 +03:00
Дмитрий a718bb951f fix(a4): correct #46 Design plugin marketplace -> knowledge-work-plugins
The 'design' plugin lives in anthropics/knowledge-work-plugins (same marketplace as #42 product-management), not claude-plugins-official (which carries only frontend-design). Verified post-reload against the marketplace manifest. Pre-push fixup of 621498a's own error - v2.8/v3.8/v2.8 unchanged. Tooling 4.21 also completes the capability list (+Design System Management, +Dev Handoff).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:07:15 +03:00
Дмитрий 621498acc9 docs(a4): register #44-46 design-tooling — Tooling v2.8 / PSR_v1 v3.8 / Pravila v1.22 / CLAUDE.md v2.8 2026-05-17 13:03:58 +03:00
Дмитрий cafa8dfe2d fix(map): mcp_figma/mcp_icons → R10.1 блок 3 (MCP-серверы, не блок 1) 2026-05-17 12:40:49 +03:00
Дмитрий 8d9183c3ac feat(map): add mcp_figma/mcp_icons/design_plugin nodes — closes section A4 (3→6) 2026-05-17 12:35:00 +03:00
Дмитрий 0cea2cc320 docs(adr): ADR-006 — A4 design-tooling boundaries (FM1/DP1/DP2) 2026-05-17 12:29:09 +03:00
Дмитрий 9b63e27825 feat(map): deptrac node — extends section A6 to 4 nodes
automation-graph.html — new `deptrac` node (architecture-tooling),
NODE_SECTION → A6 (раздел «Архитектура систем» 3→4 узла), edge
psr_v1→deptrac, NODE_DETAILS + NODE_META entries. Smoke-tested:
113 nodes / 118 edges, 0 JS errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:32:37 +03:00
Дмитрий 0c98524357 docs(deptrac): register #43 deptrac architecture-tooling in 4 normative files
Tooling Прил. Н v2.6→v2.7 (§4.18 new, §0 counter 42→43, off-phase
+12→+13; footer v2.6 row restored — pre-existing C9 gap); PSR_v1
v3.6→v3.7 (R10.1 Блок 1 note — deptrac is a Composer dev-dep, not a
marketplace plugin, like mermaid-skill/CCPM); Pravila v1.20→v1.21
(§13.2 architecture-tooling para +deptrac); CLAUDE.md v2.6→v2.7
(§3 title, §1 row 2b, §3.3 +#43 row, §6 +para, §9 +entry, §0
cross-refs) via /claude-md-management:claude-md-improver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:28:08 +03:00
Дмитрий 431117087f docs(arch): code-derived C4 component-layer diagram from deptrac (gap 4)
docs/architecture/c4-component-layers.md — the Level-3 layer
dependency graph generated by `deptrac analyse --formatter=mermaidjs`
(code-derived, drift-proof). Closes the A6 «C4 drift» gap at the
component level. README diagram index + regenerate note updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:15:34 +03:00
Дмитрий 5deff727a4 ci(deptrac): wire deptrac as lefthook pre-commit job 10
Job 10 runs `deptrac analyse` (root: app/) when staged app/**/*.php
changes — the layer-dependency gate. Red-green verified: a
Model→Service dependency is flagged (DependsOnDisallowedLayer,
exit 1); a clean tree exits 0. app/.gitignore += /.deptrac.cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:13:27 +03:00
Дмитрий 554b59359c feat(deptrac): layer model + ruleset config + ADR-005
app/deptrac.yaml — 13 layers (Controller/Service/Model/Job/…),
conservative ruleset enforcing inward/upward-violating directions.
First `deptrac analyse`: 0 violations / 481 allowed / 977 uncovered
— the codebase already conforms, so no baseline file is needed.
ADR-005 records the decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:09:24 +03:00
Дмитрий 507c4d869a docs(plan): deptrac architecture-fitness integration plan
9-task plan closing the 4 open A6 architecture-fitness gaps
(conformance, layer-direction, C4 drift, active design) via
deptrac as a lefthook job-10 layer-dependency gate. + cspell vocab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:07:09 +03:00
Дмитрий f9bedb6aad build(deptrac): add deptrac 4.6.1 as a composer dev-dependency (DT1)
deptrac/deptrac ^4.6 + 5 transitive deps (symfony/config,
dependency-injection, var-exporter; phpdocumentor/graphviz;
jetbrains/phpstorm-stubs). Primary DT1 path — composer dev-dep;
no PHAR fallback needed (resolver clean, 0 security advisories).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:04:45 +03:00
Дмитрий 88eac07116 merge: origin/main (automation-graph C9 map + project-management tooling) в C9-интеграцию 2026-05-17 10:13:02 +03:00
Дмитрий b1e903f31a fix(projects): C9 code-review findings — ProjectResource отдаёт regions[] + покрытие
C1: ProjectResource не возвращал regions → edit-диалог/drawer затирали
    сохранённые регионы при сохранении. +поле в toArray().
C2: +integration-тест outbound regions[] через полный SyncSupplierProjectsJob::handle().
I1: расскип NewProjectDialog payload-теста (regions в POST).
I2: assert data.regions в ProjectsStore/UpdateTest (ловит C1 на backend-уровне).
I4: docblock — bulkUpdateRegions legacy (region_mask, не влияет на outbound до Plan 6.5).
M1: CHANGELOG v8.22 — исправлен неверный пример регионов (Москва=82).

Регрессия: Pest 905/902/3sk/0, Vitest 104f/884/3sk/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:05:32 +03:00
Дмитрий ec6ebc57e0 merge: C9 — Plan 6 регионы субъект-уровня в портал
# Conflicts:
#	app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php
#	db/CHANGELOG_schema.md
#	db/schema.sql
2026-05-17 09:30:21 +03:00
Дмитрий 3b7023809f feat(map): C9 nodes ccpm + product_mgmt — closes section «Управление проектами» 2026-05-17 09:10:44 +03:00
Дмитрий d733ad0a2f docs(c9): CLAUDE.md v2.6 — register C9 project-management tooling (#41-42) 2026-05-17 09:10:44 +03:00
Дмитрий 2cf7471687 docs(c9): register CCPM + product-management #41-42 (project-management category) 2026-05-17 09:10:44 +03:00
Дмитрий 6b4e7441c9 feat(c9): bootstrap docs/projects + CCPM store + ADR-004 2026-05-17 09:10:44 +03:00
Дмитрий a7b207e689 feat(c9): vendor CCPM skill into .claude/skills + lint-ignore (CP1) 2026-05-17 09:10:44 +03:00
Дмитрий 6b2da83851 docs(plan): C9 project-management tooling integration plan 2026-05-17 09:10:44 +03:00
Дмитрий cc3f2e5b13 feat(c9): enable GitHub MCP projects toolset for Projects v2 (GH1) 2026-05-17 09:10:44 +03:00
Дмитрий fad1c895a1 merge: Sprint 3E (D6/D7 — убрать placeholder-вкладки SettingsView) в портал 2026-05-17 09:03:21 +03:00
Дмитрий 1c217fae43 chore(cleanup): снять устаревший MDI clearable-workaround (CTO-19 tail) — Sprint 6 I5 2026-05-17 08:18:44 +03:00
Дмитрий 6230c0fa61 fix(a11y): aria-label с ключом на edit-кнопках AdminSystem — Sprint 6 G9 2026-05-17 08:18:44 +03:00
Дмитрий 7a537105e3 docs(polling): семантический doc-комментарий вместо списка call-site'ов — Sprint 6 F4 review-fixup 2026-05-17 08:18:44 +03:00
Дмитрий 8a7314d198 refactor(polling): вынести интервалы в constants/polling.ts — Sprint 6 F4 2026-05-17 08:18:44 +03:00
Дмитрий e41844a13b test(admin): явный stubEnv DEV=true в dev-баннер тесте — Sprint 6 B6 review-fixup 2026-05-17 08:18:43 +03:00
Дмитрий 11baaefe21 feat(admin): DEV-only баннер о застабленном auth-gate — Sprint 6 B6 2026-05-17 08:18:43 +03:00
Дмитрий 97a27fdfbf fix(a11y): focus-visible ring + keyboard-activation тест на eye-toggle — Sprint 6 A9 review-fixup 2026-05-17 08:18:43 +03:00
Дмитрий d41471c818 fix(a11y): accessible eye-toggle на полях пароля — Sprint 6 A9 2026-05-17 08:18:43 +03:00
Дмитрий 3360e6f023 docs(sprint6): implementation plan — P3 polish + cleanup tail 2026-05-17 08:18:43 +03:00
Дмитрий 7d84959c15 docs(d3): mark stale warn-only claim in D3 plan as corrected (v2.5)
The D3 plan still describes Security Guidance #40 as warn-only (the pre-correction belief). Plan body kept as a historical snapshot; added a one-line NB pointing to the v2.5 correction (Tooling §4.15 / ADR-003 / CLAUDE.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:36:26 +03:00
Дмитрий ded07d3a6b docs(d3): correct Security Guidance #40 — blocking hook, not warn-only
SG #40 was characterised across all D3 docs as warn-only / does not block. Verified end-to-end: security_reminder_hook.py does sys.exit(2) — a BLOCKING PreToolUse hook (one-time speed-bump per file+rule per session, the retry passes).

SG2: on this Windows host the bundled hooks.json hardcodes python3, absent from PATH — the hook never spawned (inert). Fixed with a python3.exe shim in the Python install dir (env-only, not in repo).

Normative sync: Tooling v2.5, PSR_v1 v3.5, Pravila v1.19, CLAUDE.md v2.5; ADR-003 amended; automation-graph sec_guidance nd(). Tool counts unchanged (40 positions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:29:42 +03:00
Дмитрий 608f4b2231 docs(a11): implementation plan — ML/AI tooling integration (A11)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:29:42 +03:00
Дмитрий 6a64a98fbf docs(a11): brainstorming spec — ML/AI tooling integration (A11)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:29:42 +03:00
Дмитрий f29b1b7e50 docs(5d): план Sprint 5D — cleanup mock fallback (I3/I4) 2026-05-17 07:13:51 +03:00
Дмитрий 0d2c64aa8c test(deals): T1 fixup — DealsListIntegration/KanbanRedesign под I3 (убран MOCK_DEALS-fallback) 2026-05-17 07:13:51 +03:00
Дмитрий 256acf8781 fix(admin): I4 — devPlainCode-баннер за import.meta.env.DEV 2026-05-17 07:13:51 +03:00
Дмитрий a0b1cfdcae fix(admin): I3 — убрать mock fallback в System/Tenants 2026-05-17 07:13:50 +03:00
Дмитрий 2b04bbd4f8 fix(admin): I3 — убрать mockAdmin fallback в Billing/Incidents 2026-05-17 07:13:50 +03:00
Дмитрий 888b7563cd fix(deals): I3 — убрать mock-fallback в NewDealDialog/DealDetailDrawer 2026-05-17 07:13:50 +03:00
Дмитрий 3a58090db9 test(deals): T1 review-fixup — I3-тесты через onMounted-путь 2026-05-17 07:13:50 +03:00
Дмитрий 23579dd9be fix(deals): I3 — убрать MOCK_DEALS fallback в DealsView/KanbanView 2026-05-17 07:13:50 +03:00
Дмитрий 7c12b7419c feat(map): D3 nodes — closes section «Аудит и управление рисками» 2026-05-17 06:15:30 +03:00
Дмитрий f05bb4dde2 docs(audit): CLAUDE.md v2.4 — register #39-40 audit-security (D3) 2026-05-17 06:15:30 +03:00
Дмитрий 703f101c11 docs(audit): register Trail of Bits + Security Guidance #39-40 (D3 audit-security) 2026-05-17 06:15:30 +03:00
Дмитрий 30eec9fb7d feat(audit): distill 14-phase portal audit into audit-portal skill (D3) 2026-05-17 06:15:29 +03:00
Дмитрий 83a831c46d docs(audit): toolchain attack-surface procedure + audit/ home (D3 #5) 2026-05-17 06:15:29 +03:00
Дмитрий b72780c54e feat(adr): ADR-003 — D3 audit & risk-management tooling decision 2026-05-17 06:15:29 +03:00
Дмитрий 8c9a91be1c feat(audit): customize /security-review with project FP-filter (D3 #2) 2026-05-17 06:15:29 +03:00
Дмитрий f892c94feb docs(plan): D3 audit & risk-management tooling integration plan 2026-05-17 06:15:29 +03:00
Дмитрий 7b04e7e752 feat(settings): D6/D7 — убрать placeholder-вкладки SettingsView
Audit findings D6/D7 (Sprint 3E): убраны 4 placeholder-вкладки
(Проекты/Команда/Интеграции/Тихие часы) из SettingsView — UI не должен
обещать неработающий функционал. Удалён PlaceholderTab.vue. Остались
4 рабочие вкладки: Профиль, Безопасность, API и Webhook, Уведомления.
Тесты: 8/8 SettingsView.spec.ts ✓, Vitest 100f/838/3sk/0 ✓.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:25:42 +03:00
Дмитрий 822e5346d8 docs(plan): Sprint 3E — Settings placeholder-tabs (D6/D7) 2026-05-16 14:21:56 +03:00
Дмитрий 4bdb996c6c feat(ui): subject-level regions autocomplete in NewProjectDialog + PDD (Plan 6 Task 5)
- projectsStore: Project.regions?: number[] interface field
- NewProjectDialog: replace interim placeholder с v-autocomplete (89
  subjects + federal district subtitle); form drops region_mask/region_mode
  (backend dual-writes)
- ProjectDetailsDrawer: replace maskToCodes/encode-watch с direct
  form.regions binding; same v-autocomplete component
- Vitest: +2 NewProjectDialog tests (count=89, POST payload includes regions[]);
  refactor 3 existing PDD region tests на regions[] model

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 05:54:05 +03:00
Дмитрий 830e7fc3d7 feat(supplier): outbound adapter direct-copy regions[] (Plan 6 Task 4)
SyncSupplierProjectsJob::adaptProjectsForAllocator no longer converts
8-bit region_mask via bitmaskToList. Instead direct-copies projects.regions[]
(89-code subject array) into supplier_projects.current_regions / DTO.

region_mask still dual-written for PhonePrefixService backward-compat (Plan 6.5
cleanup will switch readers and drop dual-write).

+2 Pest tests verifying direct copy + empty-array semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 05:43:49 +03:00
Дмитрий c1ecefafc0 feat(projects): backend support for subject-level regions array (Plan 6 Task 3)
- Project model: +regions in fillable + cast via PostgresIntArray
  (custom Eloquent cast for PG INT[] — Laravel stock 'array' uses JSON
  which Postgres rejects on native INT[] columns)
- StoreProjectRequest / UpdateProjectRequest: drop region_mask/mode rules,
  add regions array validation (1..89 each, present/sometimes)
- ProjectService::create: dual-write — regions источник истины + legacy
  region_mask=255 + region_mode='include' для PhonePrefixService/LeadRouter
  compatibility (Plan 6.5 cleanup will remove dual-write)
- +5 Pest tests covering create/update/dual-write/validation rejection
- Drive-by: SchemaDeltaTest indexes pin 117 → 118 (Plan 6 v8.20 carryover
  from Task 1; should ideally have landed in Task 1 commit c487641)
- phpstan-baseline: +3 entries for Project::$regions until next ide-helper
  regen; existing Pest actingAs counts bumped 9→12 / 6→8 for new tests

Verified: Pest --parallel 747/744/3sk/0/0 (5 new tests pass +
SchemaDeltaTest now green), phpstan 0 errors, pint clean, gitleaks 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 05:39:43 +03:00
Дмитрий f467409baf chore(regions): expand REGIONS constant 31 → 89 + add federal district mapping
89 субъектов РФ по конституционному порядку (ст. 65, ред. 2022).
Adds federalDistrict field for UI group-by + FEDERAL_DISTRICT_NAMES map.
Sentinel code:0 "Вся РФ" сохранён для UI hint; в БД = regions=[].
Plan 6 (см. docs/superpowers/specs/2026-05-14-plan-6-regions-subject-level-design.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 05:01:12 +03:00
Дмитрий c4876410ea db(schema): v8.20 — add projects.regions INT[] for subject-level filtering
Adds INT[] column + GIN index to support 89-code regions (Plan 6).
region_mask/region_mode kept for backward-compat (DEPRECATED, removal in Plan 6.5).
Empty array semantically equivalent to legacy region_mask=255 (all of Russia).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 04:52:19 +03:00
343 changed files with 60359 additions and 4337 deletions
+239
View File
@@ -0,0 +1,239 @@
---
allowed-tools: Bash(git diff:*), Bash(git status:*), Bash(git log:*), Bash(git show:*), Bash(git remote show:*), Read, Glob, Grep, LS, Task
description: Complete a security review of the pending changes on the current branch
---
You are a senior security engineer conducting a focused security review of the changes on this branch.
GIT STATUS:
```
!`git status`
```
FILES MODIFIED:
```
!`git diff --name-only origin/HEAD...`
```
COMMITS:
```
!`git log --no-decorate origin/HEAD...`
```
DIFF CONTENT:
```
!`git diff --merge-base origin/HEAD`
```
Review the complete diff above. This contains all code changes in the PR.
OBJECTIVE:
Perform a security-focused code review to identify HIGH-CONFIDENCE security vulnerabilities that could have real exploitation potential. This is not a general code review - focus ONLY on security implications newly added by this PR. Do not comment on existing security concerns.
CRITICAL INSTRUCTIONS:
1. MINIMIZE FALSE POSITIVES: Only flag issues where you're >80% confident of actual exploitability
2. AVOID NOISE: Skip theoretical issues, style concerns, or low-impact findings
3. FOCUS ON IMPACT: Prioritize vulnerabilities that could lead to unauthorized access, data breaches, or system compromise
4. EXCLUSIONS: Do NOT report the following issue types:
- Denial of Service (DOS) vulnerabilities, even if they allow service disruption
- Secrets or sensitive data stored on disk (these are handled by other processes)
- Rate limiting or resource exhaustion issues
SECURITY CATEGORIES TO EXAMINE:
**Input Validation Vulnerabilities:**
- SQL injection via unsanitized user input
- Command injection in system calls or subprocesses
- XXE injection in XML parsing
- Template injection in templating engines
- NoSQL injection in database queries
- Path traversal in file operations
**Authentication & Authorization Issues:**
- Authentication bypass logic
- Privilege escalation paths
- Session management flaws
- JWT token vulnerabilities
- Authorization logic bypasses
**Crypto & Secrets Management:**
- Hardcoded API keys, passwords, or tokens
- Weak cryptographic algorithms or implementations
- Improper key storage or management
- Cryptographic randomness issues
- Certificate validation bypasses
**Injection & Code Execution:**
- Remote code execution via deseralization
- Pickle injection in Python
- YAML deserialization vulnerabilities
- Eval injection in dynamic code execution
- XSS vulnerabilities in web applications (reflected, stored, DOM-based)
**Data Exposure:**
- Sensitive data logging or storage
- PII handling violations
- API endpoint data leakage
- Debug information exposure
Additional notes:
- Even if something is only exploitable from the local network, it can still be a HIGH severity issue
ANALYSIS METHODOLOGY:
Phase 1 - Repository Context Research (Use file search tools):
- Identify existing security frameworks and libraries in use
- Look for established secure coding patterns in the codebase
- Examine existing sanitization and validation patterns
- Understand the project's security model and threat model
Phase 2 - Comparative Analysis:
- Compare new code changes against existing security patterns
- Identify deviations from established secure practices
- Look for inconsistent security implementations
- Flag code that introduces new attack surfaces
Phase 3 - Vulnerability Assessment:
- Examine each modified file for security implications
- Trace data flow from user inputs to sensitive operations
- Look for privilege boundaries being crossed unsafely
- Identify injection points and unsafe deserialization
REQUIRED OUTPUT FORMAT:
You MUST output your findings in markdown. The markdown output should contain the file, line number, severity, category (e.g. `sql_injection` or `xss`), description, exploit scenario, and fix recommendation.
For example:
# Vuln 1: XSS: `foo.py:42`
- Severity: High
- Description: User input from `username` parameter is directly interpolated into HTML without escaping, allowing reflected XSS attacks
- Exploit Scenario: Attacker crafts URL like `/bar?q=<script>alert(document.cookie)</script>` to execute JavaScript in victim's browser, enabling session hijacking or data theft
- Recommendation: Use Flask's escape() function or Jinja2 templates with auto-escaping enabled for all user inputs rendered in HTML
SEVERITY GUIDELINES:
- **HIGH**: Directly exploitable vulnerabilities leading to RCE, data breach, or authentication bypass
- **MEDIUM**: Vulnerabilities requiring specific conditions but with significant impact
- **LOW**: Defense-in-depth issues or lower-impact vulnerabilities
CONFIDENCE SCORING:
- 0.9-1.0: Certain exploit path identified, tested if possible
- 0.8-0.9: Clear vulnerability pattern with known exploitation methods
- 0.7-0.8: Suspicious pattern requiring specific conditions to exploit
- Below 0.7: Don't report (too speculative)
FINAL REMINDER:
Focus on HIGH and MEDIUM findings only. Better to miss some theoretical issues than flood the report with false positives. Each finding should be something a security engineer would confidently raise in a PR review.
FALSE POSITIVE FILTERING:
> You do not need to run commands to reproduce the vulnerability, just read the code to determine if it is a real vulnerability. Do not use the bash tool or write to any files.
>
> HARD EXCLUSIONS - Automatically exclude findings matching these patterns:
>
> 1. Denial of Service (DOS) vulnerabilities or resource exhaustion attacks.
> 2. Secrets or credentials stored on disk if they are otherwise secured.
> 3. Rate limiting concerns or service overload scenarios.
> 4. Memory consumption or CPU exhaustion issues.
> 5. Lack of input validation on non-security-critical fields without proven security impact.
> 6. Input sanitization concerns for GitHub Action workflows unless they are clearly triggerable via untrusted input.
> 7. A lack of hardening measures. Code is not expected to implement all security best practices, only flag concrete vulnerabilities.
> 8. Race conditions or timing attacks that are theoretical rather than practical issues. Only report a race condition if it is concretely problematic.
> 9. Vulnerabilities related to outdated third-party libraries. These are managed separately and should not be reported here.
> 10. Memory safety issues such as buffer overflows or use-after-free-vulnerabilities are impossible in rust. Do not report memory safety issues in rust or any other memory safe languages.
> 11. Files that are only unit tests or only used as part of running tests.
> 12. Log spoofing concerns. Outputting un-sanitized user input to logs is not a vulnerability.
> 13. SSRF vulnerabilities that only control the path. SSRF is only a concern if it can control the host or protocol.
> 14. Including user-controlled content in AI system prompts is not a vulnerability.
> 15. Regex injection. Injecting untrusted content into a regex is not a vulnerability.
> 16. Regex DOS concerns.
> 17. Insecure documentation. Do not report any findings in documentation files such as markdown files.
> 18. A lack of audit logs is not a vulnerability.
>
> PRECEDENTS -
>
> 1. Logging high value secrets in plaintext is a vulnerability. Logging URLs is assumed to be safe.
> 2. UUIDs can be assumed to be unguessable and do not need to be validated.
> 3. Environment variables and CLI flags are trusted values. Attackers are generally not able to modify them in a secure environment. Any attack that relies on controlling an environment variable is invalid.
> 4. Resource management issues such as memory or file descriptor leaks are not valid.
> 5. Subtle or low impact web vulnerabilities such as tabnabbing, XS-Leaks, prototype pollution, and open redirects should not be reported unless they are extremely high confidence.
> 6. React and Angular are generally secure against XSS. These frameworks do not need to sanitize or escape user input unless it is using dangerouslySetInnerHTML, bypassSecurityTrustHtml, or similar methods. Do not report XSS vulnerabilities in React or Angular components or tsx files unless they are using unsafe methods.
> 7. Most vulnerabilities in github action workflows are not exploitable in practice. Before validating a github action workflow vulnerability ensure it is concrete and has a very specific attack path.
> 8. A lack of permission checking or authentication in client-side JS/TS code is not a vulnerability. Client-side code is not trusted and does not need to implement these checks, they are handled on the server-side. The same applies to all flows that send untrusted data to the backend, the backend is responsible for validating and sanitizing all inputs.
> 9. Only include MEDIUM findings if they are obvious and concrete issues.
> 10. Most vulnerabilities in ipython notebooks (*.ipynb files) are not exploitable in practice. Before validating a notebook vulnerability ensure it is concrete and has a very specific attack path where untrusted input can trigger the vulnerability.
> 11. Logging non-PII data is not a vulnerability even if the data may be sensitive. Only report logging vulnerabilities if they expose sensitive information such as secrets, passwords, or personally identifiable information (PII).
> 12. Command injection vulnerabilities in shell scripts are generally not exploitable in practice since shell scripts generally do not run with untrusted user input. Only report command injection vulnerabilities in shell scripts if they are concrete and have a very specific attack path for untrusted input.
>
> SIGNAL QUALITY CRITERIA - For remaining findings, assess:
>
> 1. Is there a concrete, exploitable vulnerability with a clear attack path?
> 2. Does this represent a real security risk vs theoretical best practice?
> 3. Are there specific code locations and reproduction steps?
> 4. Would this finding be actionable for a security team?
>
> For each finding, assign a confidence score from 1-10:
>
> - 1-3: Low confidence, likely false positive or noise
> - 4-6: Medium confidence, needs investigation
> - 7-10: High confidence, likely true vulnerability
PROJECT FALSE-POSITIVE GUIDANCE (Лидерра):
> This section is project-specific (Лидерра CRM — Laravel 13 + Vue 3 multi-tenant SaaS).
> Apply it alongside the HARD EXCLUSIONS and PRECEDENTS above when filtering findings.
>
> EXPECTED — treat as NOT a finding:
>
> 1. Missing application-layer tenant checks where the table has PostgreSQL Row-Level
> Security. Tenant isolation is enforced at the DB layer (`SET LOCAL
> app.current_tenant_id` via the `SetTenantContext` middleware; 5 DB roles; 39 RLS
> policies — see `docs/adr/ADR-002-multitenancy-postgres-rls.md`). DO still flag
> queued jobs or code running as the `crm_supplier_worker` role (which is BYPASSRLS)
> that read/write tenant-scoped tables WITHOUT an explicit `where('tenant_id', ...)`.
> 2. The `tools/*.mjs` economy / ruflo hook scripts using `child_process.spawnSync`
> or `process.env`. These are intentional local CLI hooks, not user-facing or
> network-reachable code paths.
> 3. Hardcoded-secret findings already covered by gitleaks (pre-commit + pre-push).
> Do NOT re-report unless a NEW hardcoded credential is introduced by this diff.
> 4. Test factories / seeders (`*Factory.php`, `*Seeder.php`) using `Faker` or
> predictable values — test-only, per HARD EXCLUSION 11.
>
> PRIORITISE for this project:
>
> 1. HMAC / signature verification gaps on inbound webhooks (supplier lead intake).
> 2. Signed-URL generation and validation (report file downloads, e.g. the reports
> `/api/reports/jobs/{id}/file` endpoint).
> 3. `auth:sanctum` + tenant middleware coverage on `/api/*` routes — a missing guard
> is a cross-tenant data-leak vector (cf. the J1 / CTO-18 fix).
> 4. Personal-data (ПДн) handling under 152-ФЗ — exposure of subject data in
> responses, logs, or exports.
> 5. Mass-assignment on Eloquent models (`$fillable` / `$guarded` gaps) reachable
> from a request.
START ANALYSIS:
Begin your analysis now. Do this in 3 steps:
1. Use a sub-task to identify vulnerabilities. Use the repository exploration tools to understand the codebase context, then analyze the PR changes for security implications. In the prompt for this sub-task, include all of the above.
2. Then for each vulnerability identified by the above sub-task, create a new sub-task to filter out false-positives. Launch these sub-tasks as parallel sub-tasks. In the prompt for these sub-tasks, include everything in the "FALSE POSITIVE FILTERING" instructions (including the "PROJECT FALSE-POSITIVE GUIDANCE (Лидерра)" block).
3. Filter out any vulnerabilities where the sub-task reported a confidence less than 8.
Your final reply must contain the markdown report and nothing else.
+1
View File
@@ -0,0 +1 @@
# CCPM epic/task store — see docs/projects/README.md
+1
View File
@@ -0,0 +1 @@
# CCPM PRD store — see docs/projects/README.md
+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
}
]
}
]
}
}
+69
View File
@@ -0,0 +1,69 @@
---
name: audit-portal
description: Запускать при полном аудите портала Лидерры — периодической сквозной проверке качества и безопасности (статанализ, тесты, схема БД, security, UI-smoke, a11y, coverage, bundle, pre-prod). Триггеры — «провести аудит портала», «полный аудит», «portal audit», подготовка к pre-prod или релизу.
---
# Audit Portal — 14-фазный аудит портала
## Когда использовать
Периодический сквозной аудит всего портала Лидерры. Прецеденты — аудиты #1
(2026-05-12), #2 (2026-05-13), #3 (2026-05-14). НЕ для точечной проверки одного
файла или фичи — для этого прямой инструмент (`/regression`, `/security-review`,
Pest).
## 14 фаз
Фазы последовательны; фаза 2 — 4 параллельных субагента. Каждая фаза пишет
находки в `docs/superpowers/audits/<дата>-portal-full-audit-findings.md`, секция
`## Phase N`. BLOCKED-пункты — в `<дата>-portal-full-audit-blocked.md`.
| # | Фаза | Инструмент |
|---|---|---|
| 1 | Pre-flight — ветка/HEAD, delta-коммиты, `composer`/`npm install`, skeleton-файлы аудита | git, composer, npm |
| 2 | Статанализ — ×4 параллельных субагента | A backend: pint+stan+composer audit · B frontend: eslint+vue-tsc+prettier+knip · C docs: markdownlint+cspell+lychee · D SQL: squawk+pgFormatter |
| 3 | Тестовые своды | Pest --parallel + sequential, Vitest, Histoire build, Vite build |
| 4 | Целостность схемы — root tables, RLS-политики (инвариант 39), 5 user-функций поимённо, orphan-FK, header drift | Laravel Boost MCP (`database-query`) |
| 5 | Security — перечислить CI-workflows ПЕРВЫМ, gitleaks delta + полная история + no-git | gitleaks, `ls .github/workflows/`, `/security-review` + Trail of Bits плагины |
| 6 | UI-smoke — обход 24 маршрутов: рендер, 0 JS-ошибок, иконки | Playwright MCP |
| 7 | Кросс-док целостность — версии нормативки, schema-маркер, `routes/web.php`, `.mcp.json` | Read, Grep, Select-String |
| 8 | A11y — Pa11y на 4 guest-URL + axe-core на auth-views | Pa11y, axe-core через Playwright |
| 9 | Coverage — Vitest --coverage, сверка с baseline | `@vitest/coverage-v8` |
| 10 | Bundle — Vite build + анализ чанков vs baseline | `parse-bundle-analyze.mjs` |
| 11 | Pre-prod + TODO-sweep — schedule, RUNBOOK, `.env.example` diff, Sentry SDK, TODO/FIXME | `artisan schedule:list`, `composer show`, Select-String |
| 12 | Категоризация + fix-loop — rollup P0P3; P0/P1 чинятся через TDD (failing test → fix → `test:parallel`) | Pest, Vitest, git |
| 13 | Финальная регрессия | Pest --parallel, Vitest, Vite build, gitleaks, lychee |
| 14 | Report + memory + push | Write, `git push` (pre-push: gitleaks-full-history + lychee) |
Нумерация — Audit #3 (самый свежий). Audit #2 использовал Phase 0–14 с иным
порядком a11y / coverage / bundle; при расхождении — версия выше.
## Рубрика серьёзности
- **P0** — блокирует production / data corruption / security incident.
- **P1** — нарушение функциональности / failing test / type error / a11y violation.
- **P2** — warning / style / dead code / stale doc.
- **P3** — cosmetic / nice-to-have.
Fix-eligibility: `[FIX-NOW]` — P0/P1, ≤30 мин, atomic-коммит на находку;
`[FIX-DEFER]` — P2/P3, только запись в findings, без кода; `[BLOCKED]` — нужно
явное «закрываем» от заказчика → `blocked.md` (категории Q.HARD / Q.PRODUCT /
Q.DEFER / Q.INFO).
## Методология
- Каждая фаза завершается `git commit` находок. После каждых 3 коммитов —
self-review §8 (метрики схемы, версии нормативки).
- Регрессия в фазе 12/13 → `systematic-debugging` (≥3 гипотезы) → rollback или
forward-fix → перепрогон фазы.
- Hard-stop'ы decision-tree: не менять `db/schema.sql`, не закрывать
Б-/CTO-/Ю-/Диз-/DO-/OPEN- без явного «закрываем», не ставить пакеты, не
править корневой `CLAUDE.md` напрямую, не делать force-push.
- BLOCKED-находка, требующая решения владельца → в реестр `Открытые_вопросы`
через скил `q-item-add`.
## Не использовать когда
- Нужна одна проверка (тест / lint / security одного диффа) — прямой инструмент
или `/regression quick`.
- Точечный security-review диффа ветки — `/security-review` напрямую.
+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)
+87
View File
@@ -0,0 +1,87 @@
---
name: ccpm
description: "CCPM - spec-driven project management: PRD → Epic → GitHub Issues → parallel agents → shipped code. Use this skill for anything in the software delivery lifecycle: writing a PRD ('write a PRD for X', 'let's plan X', 'scope this out'), parsing a PRD into an epic, decomposing an epic into tasks, syncing to GitHub ('sync the X epic', 'push tasks to github'), starting work on an issue ('start working on issue N', 'let's work on issue N'), analyzing parallel work streams, running standups ('standup', 'run the standup'), checking status ('what's next', 'what's blocked', 'what are we working on'), closing issues, or merging an epic. Use ccpm any time the user is talking about shipping a feature, managing work, or tracking progress — even if they don't say 'ccpm' or 'PRD'. Do NOT use for: debugging code, writing tests, reviewing PRs, or raw GitHub issue/PR operations with no delivery context."
---
# CCPM - Claude Code Project Manager
A spec-driven development workflow: PRD → Epic → GitHub Issues → Parallel Agents → Shipped Code.
## Core Philosophy
Requirements live in files, not heads. Every feature starts as a PRD, becomes a technical epic, decomposes into GitHub issues, and gets executed by parallel agents with full traceability.
## File Conventions
Before doing anything, read `references/conventions.md` for path standards, frontmatter schemas, and GitHub operation rules. These apply to all phases.
## The Five Phases
### 1. Plan — Capture requirements
**When**: User wants to define a new feature, product requirement, or scope of work.
**Read**: `references/plan.md`
**Covers**: Writing PRDs through guided brainstorming, converting PRDs to technical epics.
### 2. Structure — Break it down
**When**: An epic exists and needs to be decomposed into concrete tasks.
**Read**: `references/structure.md`
**Covers**: Epic decomposition into numbered task files with dependencies and parallelization.
### 3. Sync — Push to GitHub
**When**: Local epic/tasks need to become GitHub issues, progress needs to be posted as comments, or a bug is found and needs a linked issue created.
**Read**: `references/sync.md`
**Covers**: Epic sync (epic + tasks → GitHub issues), issue sync (progress comments), closing issues/epics, bug reporting against completed issues.
### 4. Execute — Start building
**When**: User wants to start working on one or more GitHub issues with parallel agents.
**Read**: `references/execute.md`
**Covers**: Issue analysis (parallel work stream identification), launching parallel agents, coordinating worktrees.
### 5. Track — Know where things stand
**When**: User asks for status, standup report, what's blocked, what's next, or needs to validate state.
**Read**: `references/track.md`
**Covers**: Status, standup, search, in-progress, next priority, blocked items, validation.
---
## Script-First Rule
For deterministic operations — anything that reads and reports without needing reasoning — always run the bash script directly rather than doing the work manually:
| What the user wants | Script to run |
|---|---|
| Project status | `bash references/scripts/status.sh` |
| Standup report | `bash references/scripts/standup.sh` |
| List all epics | `bash references/scripts/epic-list.sh` |
| Show epic details | `bash references/scripts/epic-show.sh <name>` |
| Epic status | `bash references/scripts/epic-status.sh <name>` |
| List PRDs | `bash references/scripts/prd-list.sh` |
| PRD status | `bash references/scripts/prd-status.sh` |
| Search issues/tasks | `bash references/scripts/search.sh <query>` |
| What's in progress | `bash references/scripts/in-progress.sh` |
| What's next | `bash references/scripts/next.sh` |
| What's blocked | `bash references/scripts/blocked.sh` |
| Validate project state | `bash references/scripts/validate.sh` |
Use the LLM for work that requires reasoning: writing PRDs, analyzing parallelism, launching agents, synthesizing updates.
---
## Quick Reference
```
Plan a feature: "I want to build X" or "create a PRD for X"
Parse to epic: "turn the X PRD into an epic"
Decompose: "break down the X epic into tasks"
Sync to GitHub: "push the X epic to GitHub"
Start an issue: "start working on issue 42"
Check status: "what's our status" / "standup"
What's next: "what should I work on next"
Merge epic: "merge the X epic"
Report a bug: "found a bug in issue 42" / "testing issue 42 revealed X"
```
@@ -0,0 +1,178 @@
# Conventions — File Formats, Paths & Rules
Read this before doing any file operations across all phases.
---
## Directory Structure
```
.claude/
├── prds/
│ └── <feature-name>.md # Product requirement documents
├── epics/
│ ├── <feature-name>/
│ │ ├── epic.md # Technical epic
│ │ ├── <N>.md # Task files (named by GitHub issue number after sync)
│ │ ├── <N>-analysis.md # Parallel work stream analysis
│ │ ├── github-mapping.md # Issue number → URL mapping
│ │ ├── execution-status.md # Active agents tracker
│ │ └── updates/
│ │ └── <issue_N>/
│ │ ├── stream-A.md # Per-agent progress
│ │ ├── progress.md # Overall issue progress
│ │ └── execution.md # Execution state
│ └── archived/
│ └── <feature-name>/ # Completed epics
└── context/ # Project context docs (separate system)
```
---
## Frontmatter Schemas
### PRD (.claude/prds/<name>.md)
```yaml
---
name: <feature-name> # kebab-case, matches filename
description: <one-liner> # used in lists and summaries
status: backlog | active | completed
created: <ISO 8601> # date -u +"%Y-%m-%dT%H:%M:%SZ"
---
```
### Epic (.claude/epics/<name>/epic.md)
```yaml
---
name: <feature-name>
status: backlog | in-progress | completed
created: <ISO 8601>
updated: <ISO 8601>
progress: 0% # recalculated when tasks close
prd: .claude/prds/<name>.md
github: https://github.com/<owner>/<repo>/issues/<N> # set on sync
---
```
### Task (.claude/epics/<name>/<N>.md)
```yaml
---
name: <Task Title>
status: open | in-progress | closed
created: <ISO 8601>
updated: <ISO 8601>
github: https://github.com/<owner>/<repo>/issues/<N> # set on sync
depends_on: [] # issue numbers this must wait for
parallel: true # can run concurrently with non-conflicting tasks
conflicts_with: [] # issue numbers that touch the same files
---
```
### Progress (.claude/epics/<name>/updates/<N>/progress.md)
```yaml
---
issue: <N>
started: <ISO 8601>
last_sync: <ISO 8601>
completion: 0%
---
```
---
## Datetime Rule
Always get real current datetime from the system — never use placeholder text:
```bash
date -u +"%Y-%m-%dT%H:%M:%SZ"
```
---
## Frontmatter Update Pattern
When updating a single frontmatter field in an existing file:
```bash
sed -i.bak "/^<field>:/c\\<field>: <value>" <file>
rm <file>.bak
```
When stripping frontmatter to get body content for GitHub:
```bash
sed '1,/^---$/d; 1,/^---$/d' <file> > /tmp/body.md
```
---
## GitHub Operations
### Repository Safety Check (run before any write operation)
```bash
remote_url=$(git remote get-url origin 2>/dev/null || echo "")
if [[ "$remote_url" == *"automazeio/ccpm"* ]]; then
echo "❌ Cannot write to the CCPM template repository."
echo "Update remote: git remote set-url origin https://github.com/YOUR/REPO.git"
exit 1
fi
REPO=$(echo "$remote_url" | sed 's|.*github.com[:/]||' | sed 's|\.git$||')
```
### Authentication
Don't pre-check authentication. Run the `gh` command and handle failure:
```bash
gh <command> || echo "❌ GitHub CLI failed. Run: gh auth login"
```
### Getting Issue Numbers
```bash
# From a task file's github field:
grep 'github:' <file> | grep -oE '[0-9]+$'
```
---
## Git / Worktree Conventions
- One branch per epic: `epic/<name>`
- Worktrees live at `../epic-<name>/` (sibling to project root)
- Always start branches from an up-to-date main:
```bash
git checkout main && git pull origin main
git worktree add ../epic-<name> -b epic/<name>
```
- Commit format inside epics: `Issue #<N>: <description>`
- Never use `--force` in any git operation
---
## Naming Conventions
- Feature names: kebab-case, lowercase, letters/numbers/hyphens, starts with a letter
- Task files before sync: `001.md`, `002.md`, ... (sequential)
- Task files after sync: renamed to GitHub issue number (e.g., `1234.md`)
- Labels applied on sync: `epic`, `epic:<name>`, `feature` (for epics); `task`, `epic:<name>` (for tasks)
---
## Epic Progress Calculation
```bash
total=$(ls .claude/epics/<name>/[0-9]*.md 2>/dev/null | wc -l)
closed=$(grep -l '^status: closed' .claude/epics/<name>/[0-9]*.md 2>/dev/null | wc -l)
progress=$((closed * 100 / total))
```
Update epic frontmatter when any task closes.
+223
View File
@@ -0,0 +1,223 @@
# Execute — Start Building with Parallel Agents
This phase covers analyzing GitHub issues for parallel work streams and launching agents to execute them.
---
## Issue Analysis
**Trigger**: User wants to understand how to parallelize work on an issue before starting.
### Preflight
- Find the local task file: check `.claude/epics/*/<N>.md` first, then search for `github:.*issues/<N>` in frontmatter.
- If not found: "❌ No local task for issue #<N>. Run a sync first."
### Process
Get issue details: `gh issue view <N> --json title,body,labels`
Read the local task file fully. Identify independent work streams by asking:
- Which files will be created/modified?
- Which changes can happen simultaneously without conflict?
- What are the dependencies between changes?
**Common stream patterns:**
- Database Layer: schema, migrations, models
- Service Layer: business logic, data access
- API Layer: endpoints, validation, middleware
- UI Layer: components, pages, styles
- Test Layer: unit tests, integration tests
Create `.claude/epics/<epic_name>/<N>-analysis.md`:
```markdown
---
issue: <N>
title: <title>
analyzed: <run: date -u +"%Y-%m-%dT%H:%M:%SZ">
estimated_hours: <total>
parallelization_factor: <1.0-5.0>
---
# Parallel Work Analysis: Issue #<N>
## Overview
## Parallel Streams
### Stream A: <Name>
**Scope**:
**Files**:
**Can Start**: immediately
**Estimated Hours**:
**Dependencies**: none
### Stream B: <Name>
**Scope**:
**Files**:
**Can Start**: after Stream A
**Dependencies**: Stream A
## Coordination Points
### Shared Files
### Sequential Requirements
## Conflict Risk Assessment
## Parallelization Strategy
## Expected Timeline
- With parallel execution: <max_stream_hours>h wall time
- Without: <sum_all_hours>h
- Efficiency gain: <pct>%
```
**Output**: "✅ Analysis complete for issue #<N> — N parallel streams identified. Ready to start? Say: start issue <N>"
---
## Starting an Issue
**Trigger**: User wants to begin work on a specific GitHub issue.
### Preflight
1. Verify issue exists and is open: `gh issue view <N> --json state,title,labels,body`
2. Find local task file (as above).
3. Check for analysis file: `.claude/epics/*/<N>-analysis.md` — if missing, run analysis first (or do both in sequence: analyze then start).
4. Verify epic worktree exists: `git worktree list | grep "epic-<name>"` — if not: "❌ No worktree. Sync the epic first."
### Process
**Step 1 — Read the analysis**, identify which streams can start immediately vs. which have dependencies.
**Step 2 — Create progress tracking:**
```bash
mkdir -p .claude/epics/<epic>/updates/<N>
current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
```
Create `.claude/epics/<epic>/updates/<N>/stream-<X>.md` for each stream:
```markdown
---
issue: <N>
stream: <stream_name>
started: <datetime>
status: in_progress
---
## Scope
## Progress
- Starting implementation
```
**Step 3 — Launch parallel agents** for each stream that can start immediately:
```yaml
Task:
description: "Issue #<N> Stream <X>"
subagent_type: "general-purpose"
prompt: |
You are working on Issue #<N> in the epic worktree at: ../epic-<name>/
Your stream: <stream_name>
Your scope — files to modify: <file_patterns>
Work to complete: <stream_description>
Instructions:
1. Read full task from: .claude/epics/<epic>/<N>.md
2. Read analysis from: .claude/epics/<epic>/<N>-analysis.md
3. Work ONLY in your assigned files
4. Commit frequently: "Issue #<N>: <specific change>"
5. Update progress in: .claude/epics/<epic>/updates/<N>/stream-<X>.md
6. If you need to touch files outside your scope, note it in your progress file and wait
7. Never use --force on git operations
Complete your stream's work and mark status: completed when done.
```
Streams with unmet dependencies are queued — launch them as their dependencies complete.
**Step 4 — Assign on GitHub:**
```bash
gh issue edit <N> --add-assignee @me --add-label "in-progress"
```
**Step 5 — Create execution status file** at `.claude/epics/<epic>/updates/<N>/execution.md`:
```markdown
## Active Streams
- Stream A: <name> — Started <time>
- Stream B: <name> — Started <time>
## Queued
- Stream C: <name> — Waiting on Stream A
## Completed
(none yet)
```
**Output:**
```
✅ Started work on issue #<N>
Launched N agents:
Stream A: <name> ✓ Started
Stream B: <name> ✓ Started
Stream C: <name> ⏸ Waiting (depends on A)
Monitor: check progress in .claude/epics/<epic>/updates/<N>/
Sync updates: "sync issue <N>"
```
---
## Starting a Full Epic
**Trigger**: User wants to launch parallel agents across all ready issues in an epic at once.
### Preflight
- Verify `.claude/epics/<name>/epic.md` exists and has a `github:` field (i.e., it's been synced).
- Check for uncommitted changes: `git status --porcelain` — block if dirty.
- Verify epic branch exists: `git branch -a | grep "epic/<name>"`
### Process
**Step 1 — Read all task files** in `.claude/epics/<name>/`. Parse frontmatter for `status`, `depends_on`, `parallel`.
**Step 2 — Categorize tasks:**
- Ready: status=open, no unmet depends_on
- Blocked: has unmet depends_on
- In Progress: already has an execution file
- Complete: status=closed
**Step 3 — Analyze any ready tasks** that don't have an analysis file yet (run issue analysis inline).
**Step 4 — Launch agents** for all ready tasks following the same per-issue agent launch pattern above.
**Step 5 — Create/update** `.claude/epics/<name>/execution-status.md` with all active agents and queued issues.
**Step 6 — As agents complete**, check if blocked issues are now unblocked and launch those agents.
---
## Agent Coordination Rules
When multiple agents work in the same worktree simultaneously:
- Each agent works only on files in its assigned stream scope.
- Agents commit frequently with `Issue #<N>: <description>` format.
- Before modifying a shared file, check `git status <file>` — if another agent has it modified, wait and pull first.
- Agents sync via commits: `git pull --rebase origin epic/<name>` before starting new file work.
- Conflicts are never auto-resolved — agents report them and pause.
- No `--force` flags ever.
Shared files that commonly need coordination (types, config, package.json) should be handled by one designated stream; others pull after that commit.
+111
View File
@@ -0,0 +1,111 @@
# Plan — Capture Requirements
This phase turns an idea into a structured PRD, then converts the PRD into a technical epic ready for decomposition.
---
## Writing a PRD
**Trigger**: User wants to plan a new feature, product requirement, or area of work.
### Preflight
- Check if `.claude/prds/<name>.md` already exists — if so, confirm overwrite before proceeding.
- Ensure `.claude/prds/` directory exists; create it if not.
- Feature name must be kebab-case (lowercase, letters/numbers/hyphens, starts with a letter). If not: "❌ Feature name must be kebab-case. Example: user-auth, payment-v2"
### Process
Conduct a genuine brainstorming session before writing anything. Ask the user:
- What problem does this solve?
- Who are the users affected?
- What does success look like?
- What's explicitly out of scope?
- What are the constraints (tech, time, resources)?
Then write `.claude/prds/<name>.md` with this frontmatter and structure:
```markdown
---
name: <feature-name>
description: <one-line summary>
status: backlog
created: <run: date -u +"%Y-%m-%dT%H:%M:%SZ">
---
# PRD: <feature-name>
## Executive Summary
## Problem Statement
## User Stories
## Functional Requirements
## Non-Functional Requirements
## Success Criteria
## Constraints & Assumptions
## Out of Scope
## Dependencies
```
**Quality gates before saving:**
- No placeholder text in any section
- User stories include acceptance criteria
- Success criteria are measurable
- Out of scope is explicitly listed
**After creation**: Confirm "✅ PRD created: `.claude/prds/<name>.md`" and suggest: "Ready to create technical epic? Say: parse the <name> PRD"
---
## Parsing a PRD into a Technical Epic
**Trigger**: User wants to convert an existing PRD into a technical implementation plan.
### Preflight
- Verify `.claude/prds/<name>.md` exists with valid frontmatter (name, description, status, created).
- Check if `.claude/epics/<name>/epic.md` already exists — confirm overwrite if so.
### Process
Read the PRD fully, then produce `.claude/epics/<name>/epic.md`:
```markdown
---
name: <feature-name>
status: backlog
created: <run: date -u +"%Y-%m-%dT%H:%M:%SZ">
progress: 0%
prd: .claude/prds/<name>.md
github: (will be set on sync)
---
# Epic: <feature-name>
## Overview
## Architecture Decisions
## Technical Approach
### Frontend Components
### Backend Services
### Infrastructure
## Implementation Strategy
## Task Breakdown Preview
## Dependencies
## Success Criteria (Technical)
## Estimated Effort
```
**Key constraints:**
- Aim for ≤10 tasks total — prefer simplicity over completeness.
- Look for ways to leverage existing functionality before creating new code.
- Identify parallelization opportunities in the task breakdown preview.
**After creation**: Confirm "✅ Epic created: `.claude/epics/<name>/epic.md`" and suggest: "Ready to decompose into tasks? Say: decompose the <name> epic"
---
## Editing a PRD or Epic
Read the file first, make targeted edits preserving all frontmatter. Update the `updated` frontmatter field with current datetime.
@@ -0,0 +1,67 @@
#!/bin/bash
echo "Getting tasks..."
echo ""
echo ""
echo "🚫 Blocked Tasks"
echo "================"
echo ""
found=0
for epic_dir in .claude/epics/*/; do
[ -d "$epic_dir" ] || continue
epic_name=$(basename "$epic_dir")
for task_file in "$epic_dir"/[0-9]*.md; do
[ -f "$task_file" ] || continue
# Check if task is open
status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//')
if [ "$status" != "open" ] && [ -n "$status" ]; then
continue
fi
# Check for dependencies
deps_line=$(grep "^depends_on:" "$task_file" | head -1)
if [ -n "$deps_line" ]; then
deps=$(echo "$deps_line" | sed 's/^depends_on: *//' | sed 's/^\[//' | sed 's/\]$//' | sed 's/,/ /g' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
[ -z "$deps" ] && deps=""
else
deps=""
fi
if [ -n "$deps" ] && [ "$deps" != "depends_on:" ]; then
task_name=$(grep "^name:" "$task_file" | head -1 | sed 's/^name: *//')
task_num=$(basename "$task_file" .md)
echo "⏸️ Task #$task_num - $task_name"
echo " Epic: $epic_name"
echo " Blocked by: [$deps]"
# Check status of dependencies
open_deps=""
for dep in $deps; do
dep_file="$epic_dir$dep.md"
if [ -f "$dep_file" ]; then
dep_status=$(grep "^status:" "$dep_file" | head -1 | sed 's/^status: *//')
[ "$dep_status" = "open" ] && open_deps="$open_deps #$dep"
fi
done
[ -n "$open_deps" ] && echo " Waiting for:$open_deps"
echo ""
((found++))
fi
done
done
if [ $found -eq 0 ]; then
echo "No blocked tasks found!"
echo ""
echo "💡 All tasks with dependencies are either completed or in progress."
else
echo "📊 Total blocked: $found tasks"
fi
exit 0
@@ -0,0 +1,94 @@
#!/bin/bash
echo "Getting epics..."
echo ""
echo ""
[ ! -d ".claude/epics" ] && echo "📁 No epics directory found. Create your first epic with: /pm:prd-parse <feature-name>" && exit 0
[ -z "$(ls -d .claude/epics/*/ 2>/dev/null)" ] && echo "📁 No epics found. Create your first epic with: /pm:prd-parse <feature-name>" && exit 0
echo "📚 Project Epics"
echo "================"
echo ""
# Initialize arrays to store epics by status
planning_epics=""
in_progress_epics=""
completed_epics=""
# Process all epics
for dir in .claude/epics/*/; do
[ -d "$dir" ] || continue
[ -f "$dir/epic.md" ] || continue
# Extract metadata
n=$(grep "^name:" "$dir/epic.md" | head -1 | sed 's/^name: *//')
s=$(grep "^status:" "$dir/epic.md" | head -1 | sed 's/^status: *//' | tr '[:upper:]' '[:lower:]')
p=$(grep "^progress:" "$dir/epic.md" | head -1 | sed 's/^progress: *//')
g=$(grep "^github:" "$dir/epic.md" | head -1 | sed 's/^github: *//')
# Defaults
[ -z "$n" ] && n=$(basename "$dir")
[ -z "$p" ] && p="0%"
# Count tasks
t=$(ls "$dir"/[0-9]*.md 2>/dev/null | wc -l)
# Format output with GitHub issue number if available
if [ -n "$g" ]; then
i=$(echo "$g" | grep -o '/[0-9]*$' | tr -d '/')
entry=" 📋 ${dir}epic.md (#$i) - $p complete ($t tasks)"
else
entry=" 📋 ${dir}epic.md - $p complete ($t tasks)"
fi
# Categorize by status (handle various status values)
case "$s" in
planning|draft|"")
planning_epics="${planning_epics}${entry}\n"
;;
in-progress|in_progress|active|started)
in_progress_epics="${in_progress_epics}${entry}\n"
;;
completed|complete|done|closed|finished)
completed_epics="${completed_epics}${entry}\n"
;;
*)
# Default to planning for unknown statuses
planning_epics="${planning_epics}${entry}\n"
;;
esac
done
# Display categorized epics
echo "📝 Planning:"
if [ -n "$planning_epics" ]; then
echo -e "$planning_epics" | sed '/^$/d'
else
echo " (none)"
fi
echo ""
echo "🚀 In Progress:"
if [ -n "$in_progress_epics" ]; then
echo -e "$in_progress_epics" | sed '/^$/d'
else
echo " (none)"
fi
echo ""
echo "✅ Completed:"
if [ -n "$completed_epics" ]; then
echo -e "$completed_epics" | sed '/^$/d'
else
echo " (none)"
fi
# Summary
echo ""
echo "📊 Summary"
total=$(ls -d .claude/epics/*/ 2>/dev/null | wc -l)
tasks=$(find .claude/epics -name "[0-9]*.md" 2>/dev/null | wc -l)
echo " Total epics: $total"
echo " Total tasks: $tasks"
exit 0
@@ -0,0 +1,91 @@
#!/bin/bash
epic_name="$1"
if [ -z "$epic_name" ]; then
echo "❌ Please provide an epic name"
echo "Usage: /pm:epic-show <epic-name>"
exit 1
fi
echo "Getting epic..."
echo ""
echo ""
epic_dir=".claude/epics/$epic_name"
epic_file="$epic_dir/epic.md"
if [ ! -f "$epic_file" ]; then
echo "❌ Epic not found: $epic_name"
echo ""
echo "Available epics:"
for dir in .claude/epics/*/; do
[ -d "$dir" ] && echo "$(basename "$dir")"
done
exit 1
fi
# Display epic details
echo "📚 Epic: $epic_name"
echo "================================"
echo ""
# Extract metadata
status=$(grep "^status:" "$epic_file" | head -1 | sed 's/^status: *//')
progress=$(grep "^progress:" "$epic_file" | head -1 | sed 's/^progress: *//')
github=$(grep "^github:" "$epic_file" | head -1 | sed 's/^github: *//')
created=$(grep "^created:" "$epic_file" | head -1 | sed 's/^created: *//')
echo "📊 Metadata:"
echo " Status: ${status:-planning}"
echo " Progress: ${progress:-0%}"
[ -n "$github" ] && echo " GitHub: $github"
echo " Created: ${created:-unknown}"
echo ""
# Show tasks
echo "📝 Tasks:"
task_count=0
open_count=0
closed_count=0
for task_file in "$epic_dir"/[0-9]*.md; do
[ -f "$task_file" ] || continue
task_num=$(basename "$task_file" .md)
task_name=$(grep "^name:" "$task_file" | head -1 | sed 's/^name: *//')
task_status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//')
parallel=$(grep "^parallel:" "$task_file" | head -1 | sed 's/^parallel: *//')
if [ "$task_status" = "closed" ] || [ "$task_status" = "completed" ]; then
echo " ✅ #$task_num - $task_name"
((closed_count++))
else
echo " ⬜ #$task_num - $task_name"
[ "$parallel" = "true" ] && echo -n " (parallel)"
((open_count++))
fi
((task_count++))
done
if [ $task_count -eq 0 ]; then
echo " No tasks created yet"
echo " Run: /pm:epic-decompose $epic_name"
fi
echo ""
echo "📈 Statistics:"
echo " Total tasks: $task_count"
echo " Open: $open_count"
echo " Closed: $closed_count"
[ $task_count -gt 0 ] && echo " Completion: $((closed_count * 100 / task_count))%"
# Next actions
echo ""
echo "💡 Actions:"
[ $task_count -eq 0 ] && echo " • Decompose into tasks: /pm:epic-decompose $epic_name"
[ -z "$github" ] && [ $task_count -gt 0 ] && echo " • Sync to GitHub: /pm:epic-sync $epic_name"
[ -n "$github" ] && [ "$status" != "completed" ] && echo " • Start work: /pm:epic-start $epic_name"
exit 0
@@ -0,0 +1,90 @@
#!/bin/bash
echo "Getting status..."
echo ""
echo ""
epic_name="$1"
if [ -z "$epic_name" ]; then
echo "❌ Please specify an epic name"
echo "Usage: /pm:epic-status <epic-name>"
echo ""
echo "Available epics:"
for dir in .claude/epics/*/; do
[ -d "$dir" ] && echo "$(basename "$dir")"
done
exit 1
else
# Show status for specific epic
epic_dir=".claude/epics/$epic_name"
epic_file="$epic_dir/epic.md"
if [ ! -f "$epic_file" ]; then
echo "❌ Epic not found: $epic_name"
echo ""
echo "Available epics:"
for dir in .claude/epics/*/; do
[ -d "$dir" ] && echo "$(basename "$dir")"
done
exit 1
fi
echo "📚 Epic Status: $epic_name"
echo "================================"
echo ""
# Extract metadata
status=$(grep "^status:" "$epic_file" | head -1 | sed 's/^status: *//')
progress=$(grep "^progress:" "$epic_file" | head -1 | sed 's/^progress: *//')
github=$(grep "^github:" "$epic_file" | head -1 | sed 's/^github: *//')
# Count tasks
total=0
open=0
closed=0
blocked=0
# Use find to safely iterate over task files
for task_file in "$epic_dir"/[0-9]*.md; do
[ -f "$task_file" ] || continue
((total++))
task_status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//')
deps=$(grep "^depends_on:" "$task_file" | head -1 | sed 's/^depends_on: *\[//' | sed 's/\]//')
if [ "$task_status" = "closed" ] || [ "$task_status" = "completed" ]; then
((closed++))
elif [ -n "$deps" ] && [ "$deps" != "depends_on:" ]; then
((blocked++))
else
((open++))
fi
done
# Display progress bar
if [ $total -gt 0 ]; then
percent=$((closed * 100 / total))
filled=$((percent * 20 / 100))
empty=$((20 - filled))
echo -n "Progress: ["
[ $filled -gt 0 ] && printf '%0.s█' $(seq 1 $filled)
[ $empty -gt 0 ] && printf '%0.s░' $(seq 1 $empty)
echo "] $percent%"
else
echo "Progress: No tasks created"
fi
echo ""
echo "📊 Breakdown:"
echo " Total tasks: $total"
echo " ✅ Completed: $closed"
echo " 🔄 Available: $open"
echo " ⏸️ Blocked: $blocked"
[ -n "$github" ] && echo ""
[ -n "$github" ] && echo "🔗 GitHub: $github"
fi
exit 0
@@ -0,0 +1,71 @@
#!/bin/bash
echo "Helping..."
echo ""
echo ""
echo "📚 Claude Code PM - Project Management System"
echo "============================================="
echo ""
echo "🎯 Quick Start Workflow"
echo " 1. /pm:prd-new <name> - Create a new PRD"
echo " 2. /pm:prd-parse <name> - Convert PRD to epic"
echo " 3. /pm:epic-decompose <name> - Break into tasks"
echo " 4. /pm:epic-sync <name> - Push to GitHub"
echo " 5. /pm:epic-start <name> - Start parallel execution"
echo ""
echo "📄 PRD Commands"
echo " /pm:prd-new <name> - Launch brainstorming for new product requirement"
echo " /pm:prd-parse <name> - Convert PRD to implementation epic"
echo " /pm:prd-list - List all PRDs"
echo " /pm:prd-edit <name> - Edit existing PRD"
echo " /pm:prd-status - Show PRD implementation status"
echo ""
echo "📚 Epic Commands"
echo " /pm:epic-decompose <name> - Break epic into task files"
echo " /pm:epic-sync <name> - Push epic and tasks to GitHub"
echo " /pm:epic-oneshot <name> - Decompose and sync in one command"
echo " /pm:epic-list - List all epics"
echo " /pm:epic-show <name> - Display epic and its tasks"
echo " /pm:epic-status [name] - Show epic progress"
echo " /pm:epic-close <name> - Mark epic as complete"
echo " /pm:epic-edit <name> - Edit epic details"
echo " /pm:epic-refresh <name> - Update epic progress from tasks"
echo " /pm:epic-start <name> - Launch parallel agent execution"
echo ""
echo "📝 Issue Commands"
echo " /pm:issue-show <num> - Display issue and sub-issues"
echo " /pm:issue-status <num> - Check issue status"
echo " /pm:issue-start <num> - Begin work with specialized agent"
echo " /pm:issue-sync <num> - Push updates to GitHub"
echo " /pm:issue-close <num> - Mark issue as complete"
echo " /pm:issue-reopen <num> - Reopen closed issue"
echo " /pm:issue-edit <num> - Edit issue details"
echo " /pm:issue-analyze <num> - Analyze for parallel work streams"
echo ""
echo "🔄 Workflow Commands"
echo " /pm:next - Show next priority tasks"
echo " /pm:status - Overall project dashboard"
echo " /pm:standup - Daily standup report"
echo " /pm:blocked - Show blocked tasks"
echo " /pm:in-progress - List work in progress"
echo ""
echo "🔗 Sync Commands"
echo " /pm:sync - Full bidirectional sync with GitHub"
echo " /pm:import <issue> - Import existing GitHub issues"
echo ""
echo "🔧 Maintenance Commands"
echo " /pm:validate - Check system integrity"
echo " /pm:clean - Archive completed work"
echo " /pm:search <query> - Search across all content"
echo ""
echo "⚙️ Setup Commands"
echo " /pm:init - Install dependencies and configure GitHub"
echo " /pm:help - Show this help message"
echo ""
echo "💡 Tips"
echo " • Use /pm:next to find available work"
echo " • Run /pm:status for quick overview"
echo " • Epic workflow: prd-new → prd-parse → epic-decompose → epic-sync"
echo " • View README.md for complete documentation"
exit 0
@@ -0,0 +1,74 @@
#!/bin/bash
echo "Getting status..."
echo ""
echo ""
echo "🔄 In Progress Work"
echo "==================="
echo ""
# Check for active work in updates directories
found=0
if [ -d ".claude/epics" ]; then
for updates_dir in .claude/epics/*/updates/*/; do
[ -d "$updates_dir" ] || continue
issue_num=$(basename "$updates_dir")
epic_name=$(basename $(dirname $(dirname "$updates_dir")))
if [ -f "$updates_dir/progress.md" ]; then
completion=$(grep "^completion:" "$updates_dir/progress.md" | head -1 | sed 's/^completion: *//')
[ -z "$completion" ] && completion="0%"
# Get task name from the task file
task_file=".claude/epics/$epic_name/$issue_num.md"
if [ -f "$task_file" ]; then
task_name=$(grep "^name:" "$task_file" | head -1 | sed 's/^name: *//')
else
task_name="Unknown task"
fi
echo "📝 Issue #$issue_num - $task_name"
echo " Epic: $epic_name"
echo " Progress: $completion complete"
# Check for recent updates
if [ -f "$updates_dir/progress.md" ]; then
last_update=$(grep "^last_sync:" "$updates_dir/progress.md" | head -1 | sed 's/^last_sync: *//')
[ -n "$last_update" ] && echo " Last update: $last_update"
fi
echo ""
((found++))
fi
done
fi
# Also check for in-progress epics
echo "📚 Active Epics:"
for epic_dir in .claude/epics/*/; do
[ -d "$epic_dir" ] || continue
[ -f "$epic_dir/epic.md" ] || continue
status=$(grep "^status:" "$epic_dir/epic.md" | head -1 | sed 's/^status: *//')
if [ "$status" = "in-progress" ] || [ "$status" = "active" ]; then
epic_name=$(grep "^name:" "$epic_dir/epic.md" | head -1 | sed 's/^name: *//')
progress=$(grep "^progress:" "$epic_dir/epic.md" | head -1 | sed 's/^progress: *//')
[ -z "$epic_name" ] && epic_name=$(basename "$epic_dir")
[ -z "$progress" ] && progress="0%"
echo "$epic_name - $progress complete"
fi
done
echo ""
if [ $found -eq 0 ]; then
echo "No active work items found."
echo ""
echo "💡 Start work with: /pm:next"
else
echo "📊 Total active items: $found"
fi
exit 0
@@ -0,0 +1,192 @@
#!/bin/bash
echo "Initializing..."
echo ""
echo ""
echo " ██████╗ ██████╗██████╗ ███╗ ███╗"
echo "██╔════╝██╔════╝██╔══██╗████╗ ████║"
echo "██║ ██║ ██████╔╝██╔████╔██║"
echo "╚██████╗╚██████╗██║ ██║ ╚═╝ ██║"
echo " ╚═════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝"
echo "┌─────────────────────────────────┐"
echo "│ Claude Code Project Management │"
echo "│ by https://x.com/aroussi │"
echo "└─────────────────────────────────┘"
echo "https://github.com/automazeio/ccpm"
echo ""
echo ""
echo "🚀 Initializing Claude Code PM System"
echo "======================================"
echo ""
# Check for required tools
echo "🔍 Checking dependencies..."
# Check gh CLI
if command -v gh &> /dev/null; then
echo " ✅ GitHub CLI (gh) installed"
else
echo " ❌ GitHub CLI (gh) not found"
echo ""
echo " Installing gh..."
if command -v brew &> /dev/null; then
brew install gh
elif command -v apt-get &> /dev/null; then
sudo apt-get update && sudo apt-get install gh
else
echo " Please install GitHub CLI manually: https://cli.github.com/"
exit 1
fi
fi
# Check gh auth status
echo ""
echo "🔐 Checking GitHub authentication..."
if gh auth status &> /dev/null; then
echo " ✅ GitHub authenticated"
else
echo " ⚠️ GitHub not authenticated"
echo " Running: gh auth login"
gh auth login
fi
# Check for gh-sub-issue extension
echo ""
echo "📦 Checking gh extensions..."
if gh extension list | grep -q "yahsan2/gh-sub-issue"; then
echo " ✅ gh-sub-issue extension installed"
else
echo " 📥 Installing gh-sub-issue extension..."
gh extension install yahsan2/gh-sub-issue
fi
# Create directory structure
echo ""
echo "📁 Creating directory structure..."
mkdir -p .claude/prds
mkdir -p .claude/epics
mkdir -p .claude/rules
mkdir -p .claude/agents
mkdir -p .claude/scripts/pm
echo " ✅ Directories created"
# Copy scripts if in main repo
if [ -d "scripts/pm" ] && [ ! "$(pwd)" = *"/.claude"* ]; then
echo ""
echo "📝 Copying PM scripts..."
cp -r scripts/pm/* .claude/scripts/pm/
chmod +x .claude/scripts/pm/*.sh
echo " ✅ Scripts copied and made executable"
fi
# Check for git
echo ""
echo "🔗 Checking Git configuration..."
if git rev-parse --git-dir > /dev/null 2>&1; then
echo " ✅ Git repository detected"
# Check remote
if git remote -v | grep -q origin; then
remote_url=$(git remote get-url origin)
echo " ✅ Remote configured: $remote_url"
# Check if remote is the CCPM template repository
if [[ "$remote_url" == *"automazeio/ccpm"* ]] || [[ "$remote_url" == *"automazeio/ccpm.git"* ]]; then
echo ""
echo " ⚠️ WARNING: Your remote origin points to the CCPM template repository!"
echo " This means any issues you create will go to the template repo, not your project."
echo ""
echo " To fix this:"
echo " 1. Fork the repository or create your own on GitHub"
echo " 2. Update your remote:"
echo " git remote set-url origin https://github.com/YOUR_USERNAME/YOUR_REPO.git"
echo ""
else
# Create GitHub labels if this is a GitHub repository
if gh repo view &> /dev/null; then
echo ""
echo "🏷️ Creating GitHub labels..."
# Create base labels with improved error handling
epic_created=false
task_created=false
if gh label create "epic" --color "0E8A16" --description "Epic issue containing multiple related tasks" --force 2>/dev/null; then
epic_created=true
elif gh label list 2>/dev/null | grep -q "^epic"; then
epic_created=true # Label already exists
fi
if gh label create "task" --color "1D76DB" --description "Individual task within an epic" --force 2>/dev/null; then
task_created=true
elif gh label list 2>/dev/null | grep -q "^task"; then
task_created=true # Label already exists
fi
# Report results
if $epic_created && $task_created; then
echo " ✅ GitHub labels created (epic, task)"
elif $epic_created || $task_created; then
echo " ⚠️ Some GitHub labels created (epic: $epic_created, task: $task_created)"
else
echo " ❌ Could not create GitHub labels (check repository permissions)"
fi
else
echo " ️ Not a GitHub repository - skipping label creation"
fi
fi
else
echo " ⚠️ No remote configured"
echo " Add with: git remote add origin <url>"
fi
else
echo " ⚠️ Not a git repository"
echo " Initialize with: git init"
fi
# Create CLAUDE.md if it doesn't exist
if [ ! -f "CLAUDE.md" ]; then
echo ""
echo "📄 Creating CLAUDE.md..."
cat > CLAUDE.md << 'EOF'
# CLAUDE.md
> Think carefully and implement the most concise solution that changes as little code as possible.
## Project-Specific Instructions
Add your project-specific instructions here.
## Testing
Always run tests before committing:
- `npm test` or equivalent for your stack
## Code Style
Follow existing patterns in the codebase.
EOF
echo " ✅ CLAUDE.md created"
fi
# Summary
echo ""
echo "✅ Initialization Complete!"
echo "=========================="
echo ""
echo "📊 System Status:"
gh --version | head -1
echo " Extensions: $(gh extension list | wc -l) installed"
echo " Auth: $(gh auth status 2>&1 | grep -o 'Logged in to [^ ]*' || echo 'Not authenticated')"
echo ""
echo "🎯 Next Steps:"
echo " 1. Create your first PRD: /pm:prd-new <feature-name>"
echo " 2. View help: /pm:help"
echo " 3. Check status: /pm:status"
echo ""
echo "📚 Documentation: README.md"
exit 0
@@ -0,0 +1,61 @@
#!/bin/bash
echo "Getting status..."
echo ""
echo ""
echo "📋 Next Available Tasks"
echo "======================="
echo ""
# Find tasks that are open and have no dependencies or whose dependencies are closed
found=0
for epic_dir in .claude/epics/*/; do
[ -d "$epic_dir" ] || continue
epic_name=$(basename "$epic_dir")
for task_file in "$epic_dir"/[0-9]*.md; do
[ -f "$task_file" ] || continue
# Check if task is open
status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//')
if [ "$status" != "open" ] && [ -n "$status" ]; then
continue
fi
# Check dependencies
deps_line=$(grep "^depends_on:" "$task_file" | head -1)
if [ -n "$deps_line" ]; then
deps=$(echo "$deps_line" | sed 's/^depends_on: *//' | sed 's/^\[//' | sed 's/\]$//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
[ -z "$deps" ] && deps=""
else
deps=""
fi
# If no dependencies or empty, task is available
if [ -z "$deps" ] || [ "$deps" = "depends_on:" ]; then
task_name=$(grep "^name:" "$task_file" | head -1 | sed 's/^name: *//')
task_num=$(basename "$task_file" .md)
parallel=$(grep "^parallel:" "$task_file" | head -1 | sed 's/^parallel: *//')
echo "✅ Ready: #$task_num - $task_name"
echo " Epic: $epic_name"
[ "$parallel" = "true" ] && echo " 🔄 Can run in parallel"
echo ""
((found++))
fi
done
done
if [ $found -eq 0 ]; then
echo "No available tasks found."
echo ""
echo "💡 Suggestions:"
echo " • Check blocked tasks: /pm:blocked"
echo " • View all tasks: /pm:epic-list"
fi
echo ""
echo "📊 Summary: $found tasks ready to start"
exit 0
@@ -0,0 +1,89 @@
# !/bin/bash
# Check if PRD directory exists
if [ ! -d ".claude/prds" ]; then
echo "📁 No PRD directory found. Create your first PRD with: /pm:prd-new <feature-name>"
exit 0
fi
# Check for PRD files
if ! ls .claude/prds/*.md >/dev/null 2>&1; then
echo "📁 No PRDs found. Create your first PRD with: /pm:prd-new <feature-name>"
exit 0
fi
# Initialize counters
backlog_count=0
in_progress_count=0
implemented_count=0
total_count=0
echo "Getting PRDs..."
echo ""
echo ""
echo "📋 PRD List"
echo "==========="
echo ""
# Display by status groups
echo "🔍 Backlog PRDs:"
for file in .claude/prds/*.md; do
[ -f "$file" ] || continue
status=$(grep "^status:" "$file" | head -1 | sed 's/^status: *//')
if [ "$status" = "backlog" ] || [ "$status" = "draft" ] || [ -z "$status" ]; then
name=$(grep "^name:" "$file" | head -1 | sed 's/^name: *//')
desc=$(grep "^description:" "$file" | head -1 | sed 's/^description: *//')
[ -z "$name" ] && name=$(basename "$file" .md)
[ -z "$desc" ] && desc="No description"
# echo " 📋 $name - $desc"
echo " 📋 $file - $desc"
((backlog_count++))
fi
((total_count++))
done
[ $backlog_count -eq 0 ] && echo " (none)"
echo ""
echo "🔄 In-Progress PRDs:"
for file in .claude/prds/*.md; do
[ -f "$file" ] || continue
status=$(grep "^status:" "$file" | head -1 | sed 's/^status: *//')
if [ "$status" = "in-progress" ] || [ "$status" = "active" ]; then
name=$(grep "^name:" "$file" | head -1 | sed 's/^name: *//')
desc=$(grep "^description:" "$file" | head -1 | sed 's/^description: *//')
[ -z "$name" ] && name=$(basename "$file" .md)
[ -z "$desc" ] && desc="No description"
# echo " 📋 $name - $desc"
echo " 📋 $file - $desc"
((in_progress_count++))
fi
done
[ $in_progress_count -eq 0 ] && echo " (none)"
echo ""
echo "✅ Implemented PRDs:"
for file in .claude/prds/*.md; do
[ -f "$file" ] || continue
status=$(grep "^status:" "$file" | head -1 | sed 's/^status: *//')
if [ "$status" = "implemented" ] || [ "$status" = "completed" ] || [ "$status" = "done" ]; then
name=$(grep "^name:" "$file" | head -1 | sed 's/^name: *//')
desc=$(grep "^description:" "$file" | head -1 | sed 's/^description: *//')
[ -z "$name" ] && name=$(basename "$file" .md)
[ -z "$desc" ] && desc="No description"
# echo " 📋 $name - $desc"
echo " 📋 $file - $desc"
((implemented_count++))
fi
done
[ $implemented_count -eq 0 ] && echo " (none)"
# Display summary
echo ""
echo "📊 PRD Summary"
echo " Total PRDs: $total_count"
echo " Backlog: $backlog_count"
echo " In-Progress: $in_progress_count"
echo " Implemented: $implemented_count"
exit 0
@@ -0,0 +1,63 @@
#!/bin/bash
echo "📄 PRD Status Report"
echo "===================="
echo ""
if [ ! -d ".claude/prds" ]; then
echo "No PRD directory found."
exit 0
fi
total=$(ls .claude/prds/*.md 2>/dev/null | wc -l)
[ $total -eq 0 ] && echo "No PRDs found." && exit 0
# Count by status
backlog=0
in_progress=0
implemented=0
for file in .claude/prds/*.md; do
[ -f "$file" ] || continue
status=$(grep "^status:" "$file" | head -1 | sed 's/^status: *//')
case "$status" in
backlog|draft|"") ((backlog++)) ;;
in-progress|active) ((in_progress++)) ;;
implemented|completed|done) ((implemented++)) ;;
*) ((backlog++)) ;;
esac
done
echo "Getting status..."
echo ""
echo ""
# Display chart
echo "📊 Distribution:"
echo "================"
echo ""
echo " Backlog: $(printf '%-3d' $backlog) [$(printf '%0.s█' $(seq 1 $((backlog*20/total))))]"
echo " In Progress: $(printf '%-3d' $in_progress) [$(printf '%0.s█' $(seq 1 $((in_progress*20/total))))]"
echo " Implemented: $(printf '%-3d' $implemented) [$(printf '%0.s█' $(seq 1 $((implemented*20/total))))]"
echo ""
echo " Total PRDs: $total"
# Recent activity
echo ""
echo "📅 Recent PRDs (last 5 modified):"
ls -t .claude/prds/*.md 2>/dev/null | head -5 | while read file; do
name=$(grep "^name:" "$file" | head -1 | sed 's/^name: *//')
[ -z "$name" ] && name=$(basename "$file" .md)
echo "$name"
done
# Suggestions
echo ""
echo "💡 Next Actions:"
[ $backlog -gt 0 ] && echo " • Parse backlog PRDs to epics: /pm:prd-parse <name>"
[ $in_progress -gt 0 ] && echo " • Check progress on active PRDs: /pm:epic-status <name>"
[ $total -eq 0 ] && echo " • Create your first PRD: /pm:prd-new <name>"
exit 0
@@ -0,0 +1,71 @@
#!/bin/bash
query="$1"
if [ -z "$query" ]; then
echo "❌ Please provide a search query"
echo "Usage: /pm:search <query>"
exit 1
fi
echo "Searching for '$query'..."
echo ""
echo ""
echo "🔍 Search results for: '$query'"
echo "================================"
echo ""
# Search in PRDs
if [ -d ".claude/prds" ]; then
echo "📄 PRDs:"
results=$(grep -l -i "$query" .claude/prds/*.md 2>/dev/null)
if [ -n "$results" ]; then
for file in $results; do
name=$(basename "$file" .md)
matches=$(grep -c -i "$query" "$file")
echo "$name ($matches matches)"
done
else
echo " No matches"
fi
echo ""
fi
# Search in Epics
if [ -d ".claude/epics" ]; then
echo "📚 Epics:"
results=$(find .claude/epics -name "epic.md" -exec grep -l -i "$query" {} \; 2>/dev/null)
if [ -n "$results" ]; then
for file in $results; do
epic_name=$(basename $(dirname "$file"))
matches=$(grep -c -i "$query" "$file")
echo "$epic_name ($matches matches)"
done
else
echo " No matches"
fi
echo ""
fi
# Search in Tasks
if [ -d ".claude/epics" ]; then
echo "📝 Tasks:"
results=$(find .claude/epics -name "[0-9]*.md" -exec grep -l -i "$query" {} \; 2>/dev/null | head -10)
if [ -n "$results" ]; then
for file in $results; do
epic_name=$(basename $(dirname "$file"))
task_num=$(basename "$file" .md)
echo " • Task #$task_num in $epic_name"
done
else
echo " No matches"
fi
fi
# Summary
total=$(find .claude -name "*.md" -exec grep -l -i "$query" {} \; 2>/dev/null | wc -l)
echo ""
echo "📊 Total files with matches: $total"
exit 0
@@ -0,0 +1,86 @@
#!/bin/bash
echo "📅 Daily Standup - $(date '+%Y-%m-%d')"
echo "================================"
echo ""
today=$(date '+%Y-%m-%d')
echo "Getting status..."
echo ""
echo ""
echo "📝 Today's Activity:"
echo "===================="
echo ""
# Find files modified today
recent_files=$(find .claude -name "*.md" -mtime -1 2>/dev/null)
if [ -n "$recent_files" ]; then
# Count by type
prd_count=$(echo "$recent_files" | grep -c "/prds/" 2>/dev/null | tr -d '[:space:]')
epic_count=$(echo "$recent_files" | grep -c "/epic.md" 2>/dev/null | tr -d '[:space:]')
task_count=$(echo "$recent_files" | grep -c "/[0-9]*.md" 2>/dev/null | tr -d '[:space:]')
update_count=$(echo "$recent_files" | grep -c "/updates/" 2>/dev/null | tr -d '[:space:]')
prd_count=${prd_count:-0}; epic_count=${epic_count:-0}; task_count=${task_count:-0}; update_count=${update_count:-0}
[ "$prd_count" -gt 0 ] && echo " • Modified $prd_count PRD(s)"
[ "$epic_count" -gt 0 ] && echo " • Updated $epic_count epic(s)"
[ "$task_count" -gt 0 ] && echo " • Worked on $task_count task(s)"
[ "$update_count" -gt 0 ] && echo " • Posted $update_count progress update(s)"
else
echo " No activity recorded today"
fi
echo ""
echo "🔄 Currently In Progress:"
# Show active work items
for updates_dir in .claude/epics/*/updates/*/; do
[ -d "$updates_dir" ] || continue
if [ -f "$updates_dir/progress.md" ]; then
issue_num=$(basename "$updates_dir")
epic_name=$(basename $(dirname $(dirname "$updates_dir")))
completion=$(grep "^completion:" "$updates_dir/progress.md" | head -1 | sed 's/^completion: *//')
echo " • Issue #$issue_num ($epic_name) - ${completion:-0%} complete"
fi
done
echo ""
echo "⏭️ Next Available Tasks:"
# Show top 3 available tasks
count=0
for epic_dir in .claude/epics/*/; do
[ -d "$epic_dir" ] || continue
for task_file in "$epic_dir"/[0-9]*.md; do
[ -f "$task_file" ] || continue
status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//')
if [ "$status" != "open" ] && [ -n "$status" ]; then
continue
fi
deps_line=$(grep "^depends_on:" "$task_file" | head -1)
if [ -n "$deps_line" ]; then
deps=$(echo "$deps_line" | sed 's/^depends_on: *//' | sed 's/^\[//' | sed 's/\]$//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
[ -z "$deps" ] && deps=""
else
deps=""
fi
if [ -z "$deps" ] || [ "$deps" = "depends_on:" ]; then
task_name=$(grep "^name:" "$task_file" | head -1 | sed 's/^name: *//')
task_num=$(basename "$task_file" .md)
echo " • #$task_num - $task_name"
((count++))
[ $count -ge 3 ] && break 2
fi
done
done
echo ""
echo "📊 Quick Stats:"
total_tasks=$(find .claude/epics -name "[0-9]*.md" 2>/dev/null | wc -l)
open_tasks=$(find .claude/epics -name "[0-9]*.md" -exec grep -l "^status: *open" {} \; 2>/dev/null | wc -l)
closed_tasks=$(find .claude/epics -name "[0-9]*.md" -exec grep -l "^status: *closed" {} \; 2>/dev/null | wc -l)
echo " Tasks: $open_tasks open, $closed_tasks closed, $total_tasks total"
exit 0
@@ -0,0 +1,42 @@
#!/bin/bash
echo "Getting status..."
echo ""
echo ""
echo "📊 Project Status"
echo "================"
echo ""
echo "📄 PRDs:"
if [ -d ".claude/prds" ]; then
total=$(ls .claude/prds/*.md 2>/dev/null | wc -l)
echo " Total: $total"
else
echo " No PRDs found"
fi
echo ""
echo "📚 Epics:"
if [ -d ".claude/epics" ]; then
total=$(ls -d .claude/epics/*/ 2>/dev/null | grep -v '/archived/$' | wc -l)
echo " Total: $total"
else
echo " No epics found"
fi
echo ""
echo "📝 Tasks:"
if [ -d ".claude/epics" ]; then
total=$(find .claude/epics -path "*/archived/*" -prune -o -name "[0-9]*.md" -print 2>/dev/null | wc -l)
open=$(find .claude/epics -path "*/archived/*" -prune -o -name "[0-9]*.md" -print 2>/dev/null | xargs grep -l "^status: *open" 2>/dev/null | wc -l)
closed=$(find .claude/epics -path "*/archived/*" -prune -o -name "[0-9]*.md" -print 2>/dev/null | xargs grep -l "^status: *closed" 2>/dev/null | wc -l)
echo " Open: $open"
echo " Closed: $closed"
echo " Total: $total"
else
echo " No tasks found"
fi
exit 0
@@ -0,0 +1,96 @@
#!/bin/bash
echo "Validating PM System..."
echo ""
echo ""
echo "🔍 Validating PM System"
echo "======================="
echo ""
errors=0
warnings=0
# Check directory structure
echo "📁 Directory Structure:"
[ -d ".claude" ] && echo " ✅ .claude directory exists" || { echo " ❌ .claude directory missing"; ((errors++)); }
[ -d ".claude/prds" ] && echo " ✅ PRDs directory exists" || echo " ⚠️ PRDs directory missing"
[ -d ".claude/epics" ] && echo " ✅ Epics directory exists" || echo " ⚠️ Epics directory missing"
[ -d ".claude/rules" ] && echo " ✅ Rules directory exists" || echo " ⚠️ Rules directory missing"
echo ""
# Check for orphaned files
echo "🗂️ Data Integrity:"
# Check epics have epic.md files
for epic_dir in .claude/epics/*/; do
[ -d "$epic_dir" ] || continue
if [ ! -f "$epic_dir/epic.md" ]; then
echo " ⚠️ Missing epic.md in $(basename "$epic_dir")"
((warnings++))
fi
done
# Check for tasks without epics
orphaned=$(find .claude -name "[0-9]*.md" -not -path ".claude/epics/*/*" 2>/dev/null | wc -l)
[ $orphaned -gt 0 ] && echo " ⚠️ Found $orphaned orphaned task files" && ((warnings++))
# Check for broken references
echo ""
echo "🔗 Reference Check:"
for task_file in .claude/epics/*/[0-9]*.md; do
[ -f "$task_file" ] || continue
deps_line=$(grep "^depends_on:" "$task_file" | head -1)
if [ -n "$deps_line" ]; then
deps=$(echo "$deps_line" | sed 's/^depends_on: *//' | sed 's/^\[//' | sed 's/\]$//' | sed 's/,/ /g' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
[ -z "$deps" ] && deps=""
else
deps=""
fi
if [ -n "$deps" ] && [ "$deps" != "depends_on:" ]; then
epic_dir=$(dirname "$task_file")
for dep in $deps; do
if [ ! -f "$epic_dir/$dep.md" ]; then
echo " ⚠️ Task $(basename "$task_file" .md) references missing task: $dep"
((warnings++))
fi
done
fi
done
if [ $warnings -eq 0 ] && [ $errors -eq 0 ]; then
echo " ✅ All references valid"
fi
# Check frontmatter
echo ""
echo "📝 Frontmatter Validation:"
invalid=0
for file in $(find .claude -name "*.md" -path "*/epics/*" -o -path "*/prds/*" 2>/dev/null); do
if ! grep -q "^---" "$file"; then
echo " ⚠️ Missing frontmatter: $(basename "$file")"
((invalid++))
fi
done
[ $invalid -eq 0 ] && echo " ✅ All files have frontmatter"
# Summary
echo ""
echo "📊 Validation Summary:"
echo " Errors: $errors"
echo " Warnings: $warnings"
echo " Invalid files: $invalid"
if [ $errors -eq 0 ] && [ $warnings -eq 0 ] && [ $invalid -eq 0 ]; then
echo ""
echo "✅ System is healthy!"
else
echo ""
echo "💡 Run /pm:clean to fix some issues automatically"
fi
exit 0
+111
View File
@@ -0,0 +1,111 @@
# Structure — Break Down an Epic
This phase converts a technical epic into concrete, numbered task files with dependency and parallelization metadata.
---
## Epic Decomposition
**Trigger**: User wants to break an epic into actionable tasks.
### Preflight
- Verify `.claude/epics/<name>/epic.md` exists with valid frontmatter.
- If numbered task files (001.md, 002.md...) already exist in the epic directory, list them and confirm deletion before recreating.
- If epic status is "completed", warn the user before proceeding.
### Process
Read the epic fully. Analyze for parallelism — which pieces of work can happen simultaneously without file conflicts?
**Task types to consider:**
- Setup: environment, scaffolding, dependencies
- Data: models, schemas, migrations
- API: endpoints, services, integration
- UI: components, pages, styling
- Tests: unit, integration, e2e
- Docs: README, API docs, changelogs
**Parallelization strategy by epic size:**
- Small (<5 tasks): create sequentially
- Medium (510 tasks): batch into 23 groups, spawn parallel Task agents
- Large (>10 tasks): analyze dependencies first, launch parallel agents (max 5 concurrent), create dependent tasks after prerequisites
For parallel creation, use the Task tool:
```yaml
Task:
description: "Create task files batch N"
subagent_type: "general-purpose"
prompt: |
Create task files for epic: <name>
Tasks to create: [list 3-4 tasks]
Save to: .claude/epics/<name>/001.md, 002.md, etc.
Follow the task file format exactly.
Return: list of files created.
```
### Task File Format
```markdown
---
name: <Task Title>
status: open
created: <run: date -u +"%Y-%m-%dT%H:%M:%SZ">
updated: <same as created>
github: (will be set on sync)
depends_on: []
parallel: true
conflicts_with: []
---
# Task: <Task Title>
## Description
## Acceptance Criteria
- [ ]
## Technical Details
## Dependencies
## Effort Estimate
- Size: XS/S/M/L/XL
- Hours: N
## Definition of Done
- [ ] Code implemented
- [ ] Tests written and passing
- [ ] Code reviewed
```
**Numbering**: sequential 001.md, 002.md, etc. Tasks are renamed to GitHub issue numbers after sync — do not hard-code dependencies by filename, use the `depends_on` array.
### After Creating All Tasks
Append a summary to the epic file:
```markdown
## Tasks Created
- [ ] 001.md - <Title> (parallel: true/false)
- [ ] 002.md - <Title> (parallel: true/false)
Total tasks: N
Parallel tasks: N
Sequential tasks: N
Estimated total effort: N hours
```
**After completion**: Confirm "✅ Created N tasks for epic: <name>" and suggest: "Ready to push to GitHub? Say: sync the <name> epic"
---
## Dependency Rules
- `depends_on` lists task numbers that must complete before this task can start.
- `parallel: true` means the task can run concurrently with others it doesn't conflict with.
- `conflicts_with` lists tasks that touch the same files — these cannot run in parallel.
- Circular dependencies are an error — check before finalizing.
+315
View File
@@ -0,0 +1,315 @@
# Sync — Push to GitHub & Track Progress
This phase covers pushing local epics/tasks to GitHub as issues, syncing progress as comments, and closing issues when work is done.
---
## Repository Safety Check
**Always run this before any GitHub write operation:**
```bash
remote_url=$(git remote get-url origin 2>/dev/null || echo "")
if [[ "$remote_url" == *"automazeio/ccpm"* ]]; then
echo "❌ Cannot sync to the CCPM template repository."
echo "Update remote: git remote set-url origin https://github.com/YOUR/REPO.git"
exit 1
fi
REPO=$(echo "$remote_url" | sed 's|.*github.com[:/]||' | sed 's|\.git$||')
```
---
## Epic Sync — Push Epic + Tasks to GitHub
**Trigger**: User wants to push a local epic and its tasks to GitHub as issues.
### Preflight
- Verify `.claude/epics/<name>/epic.md` exists.
- Verify numbered task files exist — if none: "❌ No tasks to sync. Decompose the epic first."
### Process
**Step 1 — Create epic issue:**
Strip frontmatter from epic.md, then:
```bash
sed '1,/^---$/d; 1,/^---$/d' .claude/epics/<name>/epic.md > /tmp/epic-body.md
epic_number=$(gh issue create \
--repo "$REPO" \
--title "Epic: <name>" \
--body-file /tmp/epic-body.md \
--label "epic,epic:<name>,feature" \
--json number -q .number)
```
**Step 2 — Create task sub-issues:**
Check if `gh-sub-issue` extension is available:
```bash
if gh extension list | grep -q "yahsan2/gh-sub-issue"; then
use_subissues=true
fi
```
For <5 tasks: create sequentially.
For ≥5 tasks: use parallel Task agents (3-4 tasks per batch).
Per task:
```bash
sed '1,/^---$/d; 1,/^---$/d' <task_file> > /tmp/task-body.md
task_number=$(gh issue create \
--repo "$REPO" \
--title "<task_name>" \
--body-file /tmp/task-body.md \
--label "task,epic:<name>" \
--json number -q .number)
# or with sub-issues:
# gh sub-issue create --parent $epic_number ...
```
**Step 3 — Rename task files and update references:**
After all issues are created, rename `001.md``<issue_number>.md` and update all `depends_on`/`conflicts_with` arrays to use real issue numbers (not sequential numbers).
```bash
# Build old→new mapping, then for each task file:
sed -i.bak "s/\b001\b/<new_num_1>/g" <file> # repeat for each mapping
mv 001.md <new_num>.md
```
**Step 4 — Update frontmatter:**
```bash
current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Update github: and updated: fields in epic.md and each task file
github_url="https://github.com/$REPO/issues/<number>"
sed -i.bak "/^github:/c\\github: $github_url" <file>
sed -i.bak "/^updated:/c\\updated: $current_date" <file>
rm <file>.bak
```
**Step 5 — Create worktree for the epic:**
```bash
git checkout main && git pull origin main
git worktree add ../epic-<name> -b epic/<name>
```
**Step 6 — Create github-mapping.md:**
```markdown
# GitHub Issue Mapping
Epic: #<N> - https://github.com/<repo>/issues/<N>
Tasks:
- #<N>: <title> - https://github.com/<repo>/issues/<N>
Synced: <datetime>
```
**Output:**
```
✅ Synced epic <name> to GitHub
Epic: #<N>
Tasks: N sub-issues
Worktree: ../epic-<name>
Next: "start working on issue <N>" or "start the <name> epic"
```
---
## Issue Sync — Post Progress to GitHub
**Trigger**: User wants to sync local development progress to a GitHub issue as a comment.
### Preflight
- Verify issue exists: `gh issue view <N> --json state`
- Check `.claude/epics/*/updates/<N>/` exists with a `progress.md` file.
- Check `last_sync` in progress.md — if synced <5 minutes ago, confirm before proceeding.
### Process
Gather updates from `.claude/epics/<epic>/updates/<N>/` (progress.md, notes.md, commits.md).
Format and post a comment:
```bash
gh issue comment <N> --body-file /tmp/update-comment.md
```
Comment format:
```markdown
## 🔄 Progress Update - <date>
### ✅ Completed Work
### 🔄 In Progress
### 📝 Technical Notes
### 📊 Acceptance Criteria Status
### 🚀 Next Steps
### ⚠️ Blockers
---
*Progress: N% | Synced at <timestamp>*
```
After posting: update `last_sync` in progress.md frontmatter, update `updated` in the task file.
Add sync marker to local files to prevent duplicate comments:
```markdown
<!-- SYNCED: <datetime> -->
```
---
## Closing an Issue
**Trigger**: User marks a task complete.
### Process
1. Find the local task file (`.claude/epics/*/<N>.md`).
2. Update frontmatter: `status: closed`, `updated: <now>`.
3. Post completion comment:
```bash
echo "✅ Task completed — all acceptance criteria met." | gh issue comment <N> --body-file -
gh issue close <N>
```
1. Check off the task in the epic issue body:
```bash
gh issue view <epic_N> --json body -q .body > /tmp/epic-body.md
sed -i "s/- \[ \] #<N>/- [x] #<N>/" /tmp/epic-body.md
gh issue edit <epic_N> --body-file /tmp/epic-body.md
```
1. Recalculate and update epic progress: `progress = closed_tasks / total_tasks * 100`
---
## Merging an Epic
**Trigger**: User wants to merge a completed epic back to main.
### Preflight
- Verify worktree `../epic-<name>` exists.
- Check for uncommitted changes in the worktree — block if dirty.
- Warn if any task issues are still open.
### Process
```bash
# From worktree: run project tests if detectable
cd ../epic-<name>
# detect and run: npm test / pytest / cargo test / go test / etc.
# From main repo:
git checkout main && git pull origin main
git merge epic/<name> --no-ff -m "Merge epic: <name>"
git push origin main
# Cleanup
git worktree remove ../epic-<name>
git branch -d epic/<name>
git push origin --delete epic/<name>
# Archive
mkdir -p .claude/epics/archived/
mv .claude/epics/<name> .claude/epics/archived/
# Close GitHub issues
epic_issue=$(grep 'github:' .claude/epics/archived/<name>/epic.md | grep -oE '[0-9]+$')
gh issue close $epic_issue -c "Epic completed and merged to main"
```
Update epic.md frontmatter: `status: completed`.
---
## Reporting a Bug Against a Completed Issue
**Trigger**: User finds a bug while testing a completed or in-progress issue — e.g. "found a bug in issue 42", "email validation is broken, came up while testing issue 42".
The workflow should stay automated: create a linked bug task without losing context from the original issue.
### Process
**Step 1 — Read the original issue for context:**
```bash
gh issue view <original_N> --json title,body,labels
```
Also read the local task file if it exists: `.claude/epics/*/<original_N>.md`
**Step 2 — Create a local bug task file:**
```markdown
---
name: Bug: <short description>
status: open
created: <run: date -u +"%Y-%m-%dT%H:%M:%SZ">
updated: <same>
github: (will be set on sync)
depends_on: []
parallel: false
conflicts_with: []
bug_for: <original_N>
---
# Bug: <short description>
## Context
Found while working on / testing issue #<original_N>: <original title>
## Description
<what's broken>
## Steps to Reproduce
<steps>
## Expected vs Actual
- Expected:
- Actual:
## Acceptance Criteria
- [ ] Bug is fixed
- [ ] Original issue #<original_N> behaviour is unaffected
## Effort Estimate
- Size: XS/S
```
Save to `.claude/epics/<same_epic_as_original>/bug-<original_N>-<slug>.md`
**Step 3 — Create a linked GitHub issue:**
```bash
gh issue create \
--repo "$REPO" \
--title "Bug: <short description>" \
--body "$(cat /tmp/bug-body.md)" \
--label "bug,epic:<epic_name>" \
--json number -q .number
```
The issue body should open with `Fixes / follow-up to #<original_N>` so GitHub auto-links them.
**Step 4 — Update the local file** with the GitHub issue number and rename to `<new_N>.md`.
**Output:**
```
✅ Bug issue created: #<new_N> — "Bug: <short description>"
Linked to: #<original_N>
Epic: <epic_name>
Start fixing it: "start working on issue <new_N>"
```
+165
View File
@@ -0,0 +1,165 @@
# Track — Know Where Things Stand
Tracking operations use bash scripts directly for speed and consistency. The LLM is not needed for these — just run the script and present the output.
---
## Script-First Rule
All tracking operations have a corresponding bash script. Run the script; do not reconstruct the output manually.
Scripts live in `references/scripts/` relative to this skill, but need to run from the **project root** (where `.claude/` lives). Run them as:
```bash
bash <skill_path>/references/scripts/<script>.sh [args]
```
Or if ccpm is installed project-locally:
```bash
bash ccpm/scripts/pm/<script>.sh [args]
```
---
## Project Status
**Trigger**: "what's our status", "project status", "overview"
```bash
bash references/scripts/status.sh
```
Shows: active epics, open issues count, recent activity.
---
## Standup Report
**Trigger**: "standup", "daily standup", "what did we do", "morning update"
```bash
bash references/scripts/standup.sh
```
Shows: what was completed yesterday, what's in progress today, any blockers.
---
## List Epics
**Trigger**: "list epics", "show epics", "what epics do we have"
```bash
bash references/scripts/epic-list.sh
```
---
## Show Epic Details
**Trigger**: "show the <name> epic", "epic details for <name>"
```bash
bash references/scripts/epic-show.sh <name>
```
---
## Epic Status
**Trigger**: "status of the <name> epic", "how far along is <name>"
```bash
bash references/scripts/epic-status.sh <name>
```
Shows: task completion breakdown, active agents, blocking issues.
---
## List PRDs
**Trigger**: "list PRDs", "what PRDs do we have", "show backlog"
```bash
bash references/scripts/prd-list.sh
```
---
## PRD Status
**Trigger**: "PRD status", "which PRDs are parsed", "what's in backlog"
```bash
bash references/scripts/prd-status.sh
```
---
## Search
**Trigger**: "search for <query>", "find issues about <topic>", "look for <term>"
```bash
bash references/scripts/search.sh "<query>"
```
Searches local task files, PRDs, and epics for the query term.
---
## What's In Progress
**Trigger**: "what's in progress", "what are we working on", "active work"
```bash
bash references/scripts/in-progress.sh
```
---
## What's Next
**Trigger**: "what should I work on next", "what's next", "next priority"
```bash
bash references/scripts/next.sh
```
Shows highest-priority open tasks with no blocking dependencies.
---
## What's Blocked
**Trigger**: "what's blocked", "any blockers", "what can't we move on"
```bash
bash references/scripts/blocked.sh
```
---
## Validate Project State
**Trigger**: "validate", "check project state", "is everything consistent"
```bash
bash references/scripts/validate.sh
```
Checks: frontmatter consistency, orphaned files, missing GitHub links, dependency integrity.
---
## When Scripts Fail
If a script fails or the output needs interpretation (e.g., an error in the output, or the user asks "what does this mean"), then step in to explain. But always run the script first — don't guess at what status/standup output would look like.
If `.claude/` directory doesn't exist at all, the project hasn't been initialized. Direct the user to run:
```bash
bash references/scripts/init.sh
```
+224
View File
@@ -0,0 +1,224 @@
---
name: data-scientist
description: Expert data scientist for advanced analytics, machine learning, and statistical modeling. Handles complex data analysis, predictive modeling, and business intelligence.
---
## Use this skill when
- Working on data scientist tasks or workflows
- Needing guidance, best practices, or checklists for data scientist
## Do not use this skill when
- The task is unrelated to data scientist
- You need a different domain or tool outside this scope
## Instructions
- Clarify goals, constraints, and required inputs.
- Apply relevant best practices and validate outcomes.
- Provide actionable steps and verification.
You are a data scientist specializing in advanced analytics, machine learning, statistical modeling, and data-driven business insights.
## Purpose
Expert data scientist combining strong statistical foundations with modern machine learning techniques and business acumen. Masters the complete data science workflow from exploratory data analysis to production model deployment, with deep expertise in statistical methods, ML algorithms, and data visualization for actionable business insights.
## Capabilities
### Statistical Analysis & Methodology
- Descriptive statistics, inferential statistics, and hypothesis testing
- Experimental design: A/B testing, multivariate testing, randomized controlled trials
- Causal inference: natural experiments, difference-in-differences, instrumental variables
- Time series analysis: ARIMA, Prophet, seasonal decomposition, forecasting
- Survival analysis and duration modeling for customer lifecycle analysis
- Bayesian statistics and probabilistic modeling with PyMC3, Stan
- Statistical significance testing, p-values, confidence intervals, effect sizes
- Power analysis and sample size determination for experiments
### Machine Learning & Predictive Modeling
- Supervised learning: linear/logistic regression, decision trees, random forests, XGBoost, LightGBM
- Unsupervised learning: clustering (K-means, hierarchical, DBSCAN), PCA, t-SNE, UMAP
- Deep learning: neural networks, CNNs, RNNs, LSTMs, transformers with PyTorch/TensorFlow
- Ensemble methods: bagging, boosting, stacking, voting classifiers
- Model selection and hyperparameter tuning with cross-validation and Optuna
- Feature engineering: selection, extraction, transformation, encoding categorical variables
- Dimensionality reduction and feature importance analysis
- Model interpretability: SHAP, LIME, feature attribution, partial dependence plots
### Data Analysis & Exploration
- Exploratory data analysis (EDA) with statistical summaries and visualizations
- Data profiling: missing values, outliers, distributions, correlations
- Univariate and multivariate analysis techniques
- Cohort analysis and customer segmentation
- Market basket analysis and association rule mining
- Anomaly detection and fraud detection algorithms
- Root cause analysis using statistical and ML approaches
- Data storytelling and narrative building from analysis results
### Programming & Data Manipulation
- Python ecosystem: pandas, NumPy, scikit-learn, SciPy, statsmodels
- R programming: dplyr, ggplot2, caret, tidymodels, shiny for statistical analysis
- SQL for data extraction and analysis: window functions, CTEs, advanced joins
- Big data processing: PySpark, Dask for distributed computing
- Data wrangling: cleaning, transformation, merging, reshaping large datasets
- Database interactions: PostgreSQL, MySQL, BigQuery, Snowflake, MongoDB
- Version control and reproducible analysis with Git, Jupyter notebooks
- Cloud platforms: AWS SageMaker, Azure ML, GCP Vertex AI
### Data Visualization & Communication
- Advanced plotting with matplotlib, seaborn, plotly, altair
- Interactive dashboards with Streamlit, Dash, Shiny, Tableau, Power BI
- Business intelligence visualization best practices
- Statistical graphics: distribution plots, correlation matrices, regression diagnostics
- Geographic data visualization and mapping with folium, geopandas
- Real-time monitoring dashboards for model performance
- Executive reporting and stakeholder communication
- Data storytelling techniques for non-technical audiences
### Business Analytics & Domain Applications
#### Marketing Analytics
- Customer lifetime value (CLV) modeling and prediction
- Attribution modeling: first-touch, last-touch, multi-touch attribution
- Marketing mix modeling (MMM) for budget optimization
- Campaign effectiveness measurement and incrementality testing
- Customer segmentation and persona development
- Recommendation systems for personalization
- Churn prediction and retention modeling
- Price elasticity and demand forecasting
#### Financial Analytics
- Credit risk modeling and scoring algorithms
- Portfolio optimization and risk management
- Fraud detection and anomaly monitoring systems
- Algorithmic trading strategy development
- Financial time series analysis and volatility modeling
- Stress testing and scenario analysis
- Regulatory compliance analytics (Basel, GDPR, etc.)
- Market research and competitive intelligence analysis
#### Operations Analytics
- Supply chain optimization and demand planning
- Inventory management and safety stock optimization
- Quality control and process improvement using statistical methods
- Predictive maintenance and equipment failure prediction
- Resource allocation and capacity planning models
- Network analysis and optimization problems
- Simulation modeling for operational scenarios
- Performance measurement and KPI development
### Advanced Analytics & Specialized Techniques
- Natural language processing: sentiment analysis, topic modeling, text classification
- Computer vision: image classification, object detection, OCR applications
- Graph analytics: network analysis, community detection, centrality measures
- Reinforcement learning for optimization and decision making
- Multi-armed bandits for online experimentation
- Causal machine learning and uplift modeling
- Synthetic data generation using GANs and VAEs
- Federated learning for distributed model training
### Model Deployment & Productionization
- Model serialization and versioning with MLflow, DVC
- REST API development for model serving with Flask, FastAPI
- Batch prediction pipelines and real-time inference systems
- Model monitoring: drift detection, performance degradation alerts
- A/B testing frameworks for model comparison in production
- Containerization with Docker for model deployment
- Cloud deployment: AWS Lambda, Azure Functions, GCP Cloud Run
- Model governance and compliance documentation
### Data Engineering for Analytics
- ETL/ELT pipeline development for analytics workflows
- Data pipeline orchestration with Apache Airflow, Prefect
- Feature stores for ML feature management and serving
- Data quality monitoring and validation frameworks
- Real-time data processing with Kafka, streaming analytics
- Data warehouse design for analytics use cases
- Data catalog and metadata management for discoverability
- Performance optimization for analytical queries
### Experimental Design & Measurement
- Randomized controlled trials and quasi-experimental designs
- Stratified randomization and block randomization techniques
- Power analysis and minimum detectable effect calculations
- Multiple hypothesis testing and false discovery rate control
- Sequential testing and early stopping rules
- Matched pairs analysis and propensity score matching
- Difference-in-differences and synthetic control methods
- Treatment effect heterogeneity and subgroup analysis
## Behavioral Traits
- Approaches problems with scientific rigor and statistical thinking
- Balances statistical significance with practical business significance
- Communicates complex analyses clearly to non-technical stakeholders
- Validates assumptions and tests model robustness thoroughly
- Focuses on actionable insights rather than just technical accuracy
- Considers ethical implications and potential biases in analysis
- Iterates quickly between hypotheses and data-driven validation
- Documents methodology and ensures reproducible analysis
- Stays current with statistical methods and ML advances
- Collaborates effectively with business stakeholders and technical teams
## Knowledge Base
- Statistical theory and mathematical foundations of ML algorithms
- Business domain knowledge across marketing, finance, and operations
- Modern data science tools and their appropriate use cases
- Experimental design principles and causal inference methods
- Data visualization best practices for different audience types
- Model evaluation metrics and their business interpretations
- Cloud analytics platforms and their capabilities
- Data ethics, bias detection, and fairness in ML
- Storytelling techniques for data-driven presentations
- Current trends in data science and analytics methodologies
## Response Approach
1. **Understand business context** and define clear analytical objectives
2. **Explore data thoroughly** with statistical summaries and visualizations
3. **Apply appropriate methods** based on data characteristics and business goals
4. **Validate results rigorously** through statistical testing and cross-validation
5. **Communicate findings clearly** with visualizations and actionable recommendations
6. **Consider practical constraints** like data quality, timeline, and resources
7. **Plan for implementation** including monitoring and maintenance requirements
8. **Document methodology** for reproducibility and knowledge sharing
## Example Interactions
- "Analyze customer churn patterns and build a predictive model to identify at-risk customers"
- "Design and analyze A/B test results for a new website feature with proper statistical testing"
- "Perform market basket analysis to identify cross-selling opportunities in retail data"
- "Build a demand forecasting model using time series analysis for inventory planning"
- "Analyze the causal impact of marketing campaigns on customer acquisition"
- "Create customer segmentation using clustering techniques and business metrics"
- "Develop a recommendation system for e-commerce product suggestions"
- "Investigate anomalies in financial transactions and build fraud detection models"
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
---
> **Provenance (A11 «ML / AI-разработка»):** vendored into Лидерра 2026-05-17 from
> [`sickn33/antigravity-awesome-skills`](https://github.com/sickn33/antigravity-awesome-skills)
> `skills/data-scientist`. Skill content is licensed **CC BY 4.0**; repository
> tooling is MIT. Aggregator frontmatter (`risk`/`source`/`date_added`) dropped on
> vendor. See `docs/ml/README.md` for the A11 toolset and boundaries.
+142
View File
@@ -0,0 +1,142 @@
---
name: discovery-interview
description: Структурированное интервью-discovery ПЕРЕД проектированием. Два режима. FEATURE — заказчик описывает проблему, боль или цель без готового решения («менеджеры жалуются на…», «сделки теряются», «хочу чтобы…»): JTBD-интервью вскрывает проблему до решения и отдаёт discovery-brief в brainstorming. SYSTEM — запрос ориентации по проекту («сориентируй», «где мы сейчас», «что в тулчейне / на карте», «catch-up по…»): синтез по мета-слою (карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log). SKIP — чёткий директив на реализацию («интегрируй X», «закрой находку Y», «поправь Z»): это не discovery. SKIP — анализ бизнес-процесса из кода или диагностика просадки измеримой метрики/конверсии («как устроен процесс X», «process discovery», «где узкое место», «почему просела конверсия»): это skill process-analysis. Используй при «discovery interview», «проведи discovery», «сориентируй по проекту» и при расплывчатом проблемном запросе, даже если слово «discovery» не названо.
---
# Discovery Interview
Структурированное интервью, которое вскрывает **проблему** прежде, чем кто-либо
начнёт проектировать решение. Два режима — FEATURE (интервью заказчика перед
фичей) и SYSTEM (интервью-ориентация по состоянию проекта).
Зачем скил существует: запрос вида «менеджеры жалуются на X» или «хочу, чтобы Y» —
это симптом, не задача. Уйдёшь сразу в дизайн — спроектируешь решение не той
проблемы. Discovery interview удерживает разговор в проблемном поле ровно столько,
сколько нужно, чтобы понять *настоящую* потребность, и только потом передаёт
эстафету проектированию.
## Когда какой режим
| Запрос | Действие |
|---|---|
| Заказчик описал проблему / боль / цель без решения | режим **FEATURE** |
| Заказчик просит сориентировать по проекту | режим **SYSTEM** |
| Заказчик дал чёткий директив («сделай X», «интегрируй Y») | скил не нужен — работай напрямую |
| Вопрос про устройство бизнес-процесса из кода | скил `process-analysis`, не этот |
## Несущий принцип — три слоя-источника
Этот скил соседствует со скилом `process-analysis` (раздел C10 карты). Чтобы не
дублировать его, способности разведены по **слою данных**, с которым работают:
| Способность | Слой-источник | Метод |
|---|---|---|
| `process-analysis` | app-код — `routes/`, `app/Jobs`, `audit_*` | реконструкция бизнес-процесса из кода |
| discovery-interview **FEATURE** | голова заказчика | интервью человека |
| discovery-interview **SYSTEM** | мета-слой — карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log | интервью + синтез |
Правило разведения: если ответ добывается **чтением кода** — это `process-analysis`.
Если ответ лежит в голове заказчика или в управляющих документах — это
discovery-interview.
## Режим FEATURE
### Триггер
Заказчик описывает проблему, боль, раздражение или цель — но НЕ готовое решение.
Признаки: «менеджеры жалуются…», «X теряется», «неудобно делать Y», «хочу, чтобы…»,
«было бы хорошо, если…».
### SKIP
Не запускай FEATURE, если запрос — чёткий директив на реализацию: «интегрируй X»,
«закрой находку Y», «поправь Z», «добавь endpoint». Проблема уже понята заказчиком,
discovery только затормозит. Работай напрямую — или через `brainstorming`, если
дизайн решения нетривиален.
Не запускай FEATURE и если запрос — **диагностика просадки измеримой метрики или
конверсии** («почему падает конверсия B2», «где теряем в воронке», «почему лиды не
доходят до оплаты»). Ответ там добывается анализом кода и audit-данных — это скил
`process-analysis`. FEATURE — про UX-боль и желаемые возможности, не про диагностику
чисел.
### Процесс
1. **Один вопрос за раз.** Не вываливай список — это интервью, не анкета. Ответ на
первый вопрос определяет второй.
2. **Спрашивай про прошлое поведение, не про гипотетику.** «Расскажи, как ты делал
это в последний раз» сильнее, чем «как бы ты хотел». Люди плохо предсказывают
своё поведение и точно помнят прошлое.
3. **Копай до корня — «5 почему».** Первая названная проблема обычно симптом.
4. **Не задавай наводящих вопросов.** «Тебе мешает отсутствие фильтра?» подсказывает
ответ. Спроси открыто: «что именно замедляет тебя на этом экране?».
5. **Поняв проблему — собери discovery-brief и остановись.** Не проектируй решение —
это работа `brainstorming`.
Банк вопросов по шагам JTBD — `references/jtbd-questions.md`.
### Артефакт — discovery-brief
Проблема · JTBD (какую работу заказчик «нанимает» решение сделать) · Текущий обходной
путь · Цена боли (время / деньги / частота) · Сигнал успеха (как поймём, что закрыто)
· Ограничения. Шаблон — `docs/discovery/templates/discovery-brief.md`.
### Хэндофф
discovery-brief — это вход для `brainstorming`. Передай brief как готовую проблемную
секцию: `brainstorming` берёт её и переходит к решению — он **не перезадаёт** уже
выясненные вопросы. discovery-interview отвечает за «что за проблема», brainstorming —
за «что построим». Отдельным файлом FEATURE-brief не сохраняется — он вливается в
спеку brainstorming.
## Режим SYSTEM
### Триггер
Заказчик просит сориентировать его по состоянию проекта: «сориентируй», «где мы
сейчас», «что у нас по X», «что в тулчейне / на карте», «catch-up».
### SKIP
Не запускай SYSTEM, если вопрос про устройство **бизнес-процесса** («как устроен
процесс сделок», «process discovery», «где узкое место в воронке») — это скил
`process-analysis`, он читает код. SYSTEM отвечает на «где мы в проекте», не «как
работает процесс X».
### Процесс
1. **Короткое уточнение scope** — что именно ориентировать? Весь проект, конкретный
раздел, тулчейн, открытые вопросы? Без scope ответ будет рыхлым.
2. **Синтез по мета-слою:** карта `docs/automation-graph.html`, `CLAUDE.md`, MEMORY,
`docs/Открытые_вопросы_*.md`, `docs/Tooling_*.md`, `git log`.
3. **Запрет:** не читай `app/`-код для реконструкции процессов — это исключительный
метод `process-analysis`. SYSTEM работает только с мета-слоем.
4. **Выдай синтез**, а не пересказ документа целиком — ответ на запрос ориентации с
пинами на источники.
### Артефакт — system-snapshot
Если ориентация существенная — сохрани `docs/discovery/YYYY-MM-DD-<тема>.md` по
шаблону `docs/discovery/templates/system-snapshot.md`. Мелкий устный ответ файла не
требует.
## JTBD-дисциплина (общая для обоих режимов)
- **Один вопрос за раз** — интервью, не анкета.
- **Прошлое, не гипотетика** — «когда это случилось в последний раз?».
- **«5 почему»** — корень, не симптом.
- **Не наводи** — открытые вопросы, без подсказанного ответа.
- **Слушай, не защищай** — если заказчик критикует существующее, не оправдывай его,
копай дальше.
## Границы
- **`brainstorming`** — проектирование решения. discovery-interview вскрывает проблему
и передаёт brief; brainstorming проектирует. Не дублируй его вопросы.
- **`process-analysis`** (раздел C10) — анализ as-is бизнес-процесса из кода и
диагностика метрик/конверсии. Если ответ требует чтения `routes/` / `app/Jobs` /
`audit_*` или расчёта метрик процесса — это `process-analysis`, не этот скил.
- **`audit-portal`** — качественный вердикт о здоровье портала. SYSTEM даёт
ориентацию («где мы»), не вердикт («здорово ли»).
- **Интервью конечных пользователей Лидерры** — вне этого скила (defer post-Б-1; для
методологии user research — `design:user-research`).
@@ -0,0 +1,26 @@
{
"skill_name": "discovery-interview",
"note": "Триггер-eval: should_trigger=true → должен вызваться discovery-interview; false → должен сработать другой инструмент (expected_skill). Особое внимание — near-miss к process-analysis (C10).",
"evals": [
{ "id": 1, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "менеджеры жалуются что не видят, какие сделки сегодня надо обзвонить — каждое утро роются в фильтрах вручную" },
{ "id": 2, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "у меня ощущение что лиды из B2 проседают по конверсии, но не пойму почему — хочу разобраться" },
{ "id": 3, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "хочу чтобы поставщики сами видели свой баланс, а то постоянно пишут в поддержку спрашивают" },
{ "id": 4, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "проведи discovery interview по идее напоминаний — я пока сам не уверен что именно нужно" },
{ "id": 5, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "не нравится как сейчас сделана выгрузка отчётов, неудобно, давай покопаем что не так" },
{ "id": 6, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "клиенты часто отваливаются на этапе оплаты, надо понять что там за проблема" },
{ "id": 7, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "сориентируй меня — где мы сейчас по проекту, что закрыто что нет" },
{ "id": 8, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что у нас вообще в тулчейне по безопасности, я запутался" },
{ "id": 9, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "вернулся после недели отсутствия, сделай catch-up что произошло по проекту" },
{ "id": 10, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что там на карте в разделе биллинга, какие узлы" },
{ "id": 11, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "как устроен процесс обработки сделки от создания до закрытия — пройди по коду" },
{ "id": 12, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "где узкое место в воронке лидов, какой шаг тормозит" },
{ "id": 13, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "сделай process discovery по джобам импорта лидов" },
{ "id": 14, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "посчитай метрики процесса: cycle time по статусам сделок" },
{ "id": 15, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "интегрируй openapi-mcp-server в .mcp.json" },
{ "id": 16, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "закрой находку аудита G7 по AdminBillingController" },
{ "id": 17, "should_trigger": false, "expected_skill": "systematic-debugging", "prompt": "поправь падающий тест RlsSmokeTest, он валится на teardown" },
{ "id": 18, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "добавь endpoint POST /api/deals/{id}/archive" },
{ "id": 19, "should_trigger": false, "expected_skill": "write-spec / brainstorming", "prompt": "напиши спеку для фичи мультивалютного биллинга" },
{ "id": 20, "should_trigger": false, "expected_skill": "audit-portal", "prompt": "проведи полный аудит портала перед релизом" }
]
}
@@ -0,0 +1,45 @@
# Банк вопросов JTBD — режим FEATURE
Вопросы для discovery-интервью. Задавать **по одному**, адаптируя формулировку под
контекст. Все вопросы — про прошлое поведение, без подсказанного ответа.
## 1. Вскрыть проблему
- Расскажи, что произошло в последний раз, когда [ситуация]?
- Что именно тебя в этом раздражало или замедляло?
- Как часто это случается?
## 2. Текущий обходной путь
- Как ты решаешь это сейчас?
- Что делаешь, когда [проблема] происходит?
- Кто ещё это делает и как?
## 3. Цена боли
- Сколько времени это съедает за неделю?
- Что случается, если не сделать это вовремя?
- Были случаи, когда из-за этого что-то сорвалось?
## 4. JTBD — какую работу «нанимают» решение сделать
- Если бы это работало идеально — что бы ты перестал делать руками?
- Какого результата ты на самом деле добиваешься?
## 5. Сигнал успеха
- Как ты поймёшь, что проблема закрыта?
- Что должно стать видимо иначе?
## 6. Ограничения
- Что нельзя ломать или менять?
- Есть ли срок?
## Антипаттерны
- **Наводящий вопрос** («тебе мешает отсутствие X?») — подсказывает ответ; заказчик
согласится из вежливости.
- **Гипотетика** («как бы ты хотел?») — люди плохо предсказывают своё поведение.
- **Список вопросов разом** — это анкета, не интервью; теряется ветвление по ответам.
- **Принять первый ответ за корень** — копай «5 почему» до настоящей причины.
+68
View File
@@ -0,0 +1,68 @@
---
name: process-analysis
description: Анализ и оптимизация существующего бизнес-процесса — process discovery (реконструкция as-is процесса из кода Laravel и audit-логов), поиск узких мест, трассировка требование→процесс, метрики и KPI процесса. Триггеры — «проанализируй процесс», «где узкое место», «process discovery», «как устроен процесс X», «метрики процесса», «оптимизируй процесс». Раздел C10 карты «Бизнес-процессы (общее)».
---
# Process Analysis
Разбирает **существующий** бизнес-процесс: восстанавливает фактическую модель,
находит узкие места, считает метрики. Парный скил к `process-modeling` — тот
проектирует to-be, этот вскрывает as-is.
## Четыре режима
### 1. Process discovery — реконструкция as-is
Восстановить фактический процесс из артефактов кода (карта источников —
`references/discovery.md`): маршруты + контроллеры (точки входа), джобы/события
(асинхронные шаги), enum статусов + переходы (state-машина), audit-таблицы
(фактические следы), cron/scheduler (периодические шаги). Итог — модель,
которую можно передать `process-modeling` для отрисовки.
### 2. Bottleneck — поиск узких мест
Паттерны: ручной шаг между авто-шагами; шаг с ожиданием внешней системы; точка
сериализации (advisory-lock, `lockForUpdate`); N+1 внутри шага; ретраи/таймауты;
шаг с наибольшей долей исключений.
Граница: это **процессные** узкие места. Runtime/код-производительность —
`perf-analyzer` / скил `analysis:bottleneck-detect` (PA1).
### 3. Трассировка требование→процесс
Связать пункт ТЗ / `Открытые_вопросы` → шаги процесса → код (file:line) →
тесты. Выявить шаги без требования (скрытая логика) и требования без
реализации.
### 4. Метрики процесса
Определить KPI: throughput, cycle time, конверсия между статусами, доля
исключений, объём ручного труда. Числа берутся из БД через `Boost`, не
выдумываются.
Граница: продуктовые метрики — плагин `product-management` (`/metrics-review`).
## Рабочий процесс
1. Определить режим (1-4) по запросу.
2. Собрать факты из кода / БД / логов — никаких допущений без пинов (file:line).
3. Выдать находки: модель / список узких мест / матрицу трассировки / таблицу
метрик.
4. Рекомендации направить в `process-modeling` (to-be) или в задачи. Этот скил
код не правит.
## Границы
- **Проектирование to-be модели** — скил `process-modeling`.
- **Runtime / код-производительность**`perf-analyzer`,
`analysis:bottleneck-detect` (PA1).
- **Продуктовые метрики** — плагин `product-management`.
- **Документ / change-request процесса** — плагин `operations`.
- **Интервью заказчика про будущую фичу / ориентация по проекту** — скил
`discovery-interview`. Тот вскрывает проблему до решения через интервью человека
(режим FEATURE) и синтезирует мета-слой проекта (режим SYSTEM); этот скил — про
вскрытие as-is процесса из app-кода. «process discovery», «как устроен процесс X»,
«где узкое место» — сюда; «проведи discovery interview», «сориентируй по проекту» —
в `discovery-interview`.
- **Генерик-методология оптимизации процесса** — скил `process-optimization`
плагина `operations`. Этот скил — про code-grounded discovery конкретного
процесса Лидерры (вскрытие as-is), не про общую методологию и не про
проектирование to-be.
@@ -0,0 +1,32 @@
# Process discovery — карта источников as-is процесса в Лидерре
Где в коде Лидерры лежат факты о фактическом бизнес-процессе.
## Источники
| Артефакт процесса | Где искать |
|---|---|
| Точки входа процесса | `app/routes/*.php` + `app/app/Http/Controllers/**` |
| Синхронные шаги | методы контроллеров + `app/app/Services/**` |
| Асинхронные шаги | `app/app/Jobs/**`, `app/app/Events/**` + listeners |
| State-машина | enum/константы статусов + `db/schema.sql` (воронка — 14 статусов) |
| Фактические следы выполнения | `audit_*` таблицы, `audit_chain_hash` (событийный лог) |
| Периодические шаги | `app/app/Console/**` + scheduler (`partitions:create-months` и пр.) |
| Бизнес-правила в шагах | `calc_lead_score` (SQL), `PricingTierResolver`, `LedgerService` |
## Метод
1. От **точки входа** (route → controller) пройти по вызовам до терминального
состояния.
2. Каждый `dispatch()` / событие — асинхронная ветка; проследить listener/job.
3. Переход статуса = ребро state-машины; собрать все переходы в автомат.
4. Свериться с **audit-логом**: фактический порядок событий в `audit_*` может
расходиться с «проектным» — расхождение само по себе находка.
5. Зафиксировать каждый шаг пином `file:line`; без пина — это допущение, не факт.
## Антипаттерны при discovery
- Принять «happy path» за весь процесс — исключения (catch, failed jobs,
таймауты) тоже шаги.
- Пропустить cron-шаги — они не видны из route-графа.
- Доверять имени метода вместо его тела.
+56
View File
@@ -0,0 +1,56 @@
---
name: process-modeling
description: Моделирование бизнес-процесса — BPMN 2.0 (пулы, дорожки, задачи, гейтвеи, события), карты процессов, customer-journey / value-stream, RACI-матрицы, state-машины. Триггеры — «смоделируй процесс», «нарисуй BPMN», «карта процесса», «swimlane / дорожки», «customer journey», «RACI», проектирование state-машины (воронка сделок, цепочка джобов). Раздел C10 карты «Бизнес-процессы (общее)».
---
# Process Modeling
Превращает словесное описание бизнес-процесса в формальную модель. Скил даёт
**нотацию и методологию** — рендер диаграмм делегируется скилу `mermaid`
(process-modeling не рендерит сам — конфликт-граница OPS1/BPMN1: mermaid
остаётся рендер-SoT).
## Когда какой артефакт
| Нужно | Артефакт |
|---|---|
| Кто-что-в-каком-порядке делает, с ветвлениями | BPMN 2.0 / swimlane |
| Сквозной поток end-to-end крупными блоками | Карта процесса (flowchart) |
| Опыт клиента/лида по этапам + точки боли | Customer-journey map |
| Поток создания ценности + потери и ожидания | Value-stream map |
| Распределение ответственности по шагам | RACI-матрица |
| Конечный автомат (статусы + переходы) | State-диаграмма |
## Рабочий процесс
1. **Собрать процесс** — уточнить: триггер (что запускает), участники (роли),
шаги по порядку, ветвления и условия, итог, исключения. Неясное — один
вопрос за раз.
2. **Выбрать артефакт** по таблице выше.
3. **Построить модель** в нотации (BPMN — см. `references/bpmn.md`).
4. **Отрендерить** — передать исходник скилу `mermaid`.
5. **Свериться** — модель не должна противоречить ТЗ / `db/schema.sql` /
`Открытые_вопросы`. Процесс вне ТЗ И не в реестре открытых вопросов —
hard-стоп (Pravila §7): не моделировать молча, поднять вопрос.
## BPMN 2.0 — ядро
Полная нотация и маппинг на mermaid — `references/bpmn.md`. Кратко:
- **Pool** — организация/система; **Lane** — роль внутри pool.
- **Task** — атомарное действие; **Sub-process** — свёрнутый под-поток.
- **Gateway** — ветвление: exclusive (XOR — один путь), parallel (AND — все
пути), inclusive (OR — один и более).
- **Event** — start / intermediate / end; типы: timer, message, error.
- **Sequence flow** — порядок внутри pool; **Message flow** — между pool'ами.
## Границы
- **Рендер диаграмм** — скил `mermaid` (C10 OPS1/BPMN1). Этот скил исходник не
рисует — отдаёт его mermaid.
- **DDD-границы доменных процессов** — скил `architecture-patterns` (bounded
context = граница бизнес-процесса).
- **Документ процесса, change-request, оптимизация** — плагин `operations`
(скилы `process-doc`, `change-request`, `process-optimization`).
- **Анализ as-is процесса** (discovery, узкие места) — скил `process-analysis`.
- Этот скил — про проектирование **to-be модели**, не про вскрытие as-is.
@@ -0,0 +1,56 @@
# BPMN 2.0 — справочник нотации и рендер в mermaid
mermaid не имеет нативного BPMN-рендера. BPMN-модель выражается через mermaid
`flowchart` (swimlane через `subgraph` = дорожки) или `stateDiagram-v2`.
## Элементы BPMN → mermaid
| BPMN | Смысл | mermaid-выражение |
|---|---|---|
| Pool / Lane | организация / роль | `subgraph Роль ... end` |
| Task | действие | прямоугольник `id[Текст]` |
| Sub-process | свёрнутый поток | `id[[Текст]]` |
| Start event | старт | `id((Старт))` |
| End event | конец | `id((Конец))` |
| Exclusive gateway (XOR) | один путь | ромб `id{Условие?}` + подписи на рёбрах |
| Parallel gateway (AND) | все пути | ромб `id{И}` с несколькими исходящими |
| Sequence flow | порядок | `-->` |
| Message flow | между pool | `-.->` |
## Шаблон swimlane
```mermaid
flowchart TD
subgraph Менеджер
A((Старт)) --> B[Принять лид]
B --> C{Лид валиден?}
end
subgraph Система
C -->|да| D[Создать сделку]
C -->|нет| E((Отклонён))
D --> F((Сделка создана))
end
```
## State-машина
Для конечных автоматов (воронка сделок — 14 статусов из `db/schema.sql`)
использовать `stateDiagram-v2`:
```mermaid
stateDiagram-v2
[*] --> new
new --> in_progress
in_progress --> won
in_progress --> lost
won --> [*]
lost --> [*]
```
Статус-слаги — из `db/schema.sql` (источник истины воронки), не выдумывать.
## Правила
- Один gateway — один вопрос; каждое исходящее ребро подписано условием.
- Каждый путь оканчивается end-событием (нет «висящих» задач).
- Исключения (timer/error) моделировать явно, не прятать в «happy path».
@@ -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']
});
+2 -1
View File
@@ -185,5 +185,6 @@ ruflo-mcp-stderr.log
.claude/agents/templates/
.claude/agents/testing/
.claude/agents/v3/
.claude/commands/
.claude/commands/*
!.claude/commands/security-review.md
.claude/helpers/
+2
View File
@@ -3,3 +3,5 @@ node_modules/
bin/
CLAUDE.md
.claude/skills/mermaid/
.claude/skills/ccpm/
.claude/skills/data-scientist/
+16 -5
View File
@@ -10,9 +10,10 @@
"type": "http",
"url": "https://api.githubcopilot.com/mcp",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}"
"Authorization": "Bearer ${GITHUB_TOKEN}",
"X-MCP-Toolsets": "actions,code_security,context,dependabot,discussions,gists,issues,notifications,orgs,projects,pull_requests,repos,secret_protection,security_advisories,stargazers,users"
},
"comment": "Фаза 0 #3 — официальный hosted GitHub MCP (https://github.com/github/github-mcp-server). Требует env GITHUB_TOKEN с PAT (scopes: repo, read:org, не давать admin/delete). Раньше использовали deprecated @modelcontextprotocol/server-github — заменён 06.05.2026."
"comment": "Фаза 0 #3 — официальный hosted GitHub MCP (https://github.com/github/github-mcp-server). Требует env GITHUB_TOKEN с PAT (scopes: repo, read:org, не давать admin/delete). Раньше использовали deprecated @modelcontextprotocol/server-github — заменён 06.05.2026. X-MCP-Toolsets явно перечисляет toolset'ы, включая `projects` (GitHub Projects v2 — доски/спринты/milestones) для раздела C9 «Управление проектами» — план docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md (GH1). Заголовок заменяет default-набор: список явный, чтобы не сузить поверхность."
},
"laravel-boost": {
"command": "php",
@@ -38,10 +39,20 @@
"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": {
"_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", "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."
"args": ["-y", "mcp-universal-icons"],
"comment": "Off-phase A4 design-tooling #45 — Universal Icons MCP (npm mcp-universal-icons, awssat, MIT). Поиск/вставка SVG-иконок из 10 коллекций, включая Lucide (проектный icon-set, CTO-19). Tools: search_icons / get_icon / health_check. SVG framework-neutral по умолчанию — НЕ запрашивать jsx/Tailwind-формат (PSR_v1 R6.0). Формализация — Tooling §4.20. ADR-006 граница UI2: иконки UI; бренд-логотипы — за 21st logo_search. План docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md."
},
"openapi": {
"command": "npx",
"args": ["-y", "@ivotoby/openapi-mcp-server"],
"env": {
"API_BASE_URL": "http://localhost",
"OPENAPI_SPEC_PATH": "./docs/api/openapi.yaml"
},
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
}
}
}
+105 -27
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -6,6 +6,7 @@
.env.production
.phpactor.json
.phpunit.result.cache
/.deptrac.cache
/.codex
/.cursor/
/.idea
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/**
* Eloquent cast for PostgreSQL native INT[] columns.
*
* Laravel stock 'array' cast uses json_encode/json_decode and sends `[1,2,3]`
* (JSON), which Postgres rejects on INT[] columns (expects `{1,2,3}` array
* literal). This cast:
*
* - get(): parses Postgres array literal `{1,2,3}` (or empty `{}`) into PHP
* int array.
* - set(): serializes PHP array `[1,2,3]` into Postgres literal `{1,2,3}`.
*
* Used for projects.regions INT[] (Plan 6).
*
* @implements CastsAttributes<list<int>, list<int>|null>
*/
class PostgresIntArray implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
* @return list<int>
*/
public function get(Model $model, string $key, mixed $value, array $attributes): array
{
if ($value === null || $value === '' || $value === '{}') {
return [];
}
// PG returns literal like "{1,2,3}".
if (is_string($value)) {
$trimmed = trim($value, '{}');
if ($trimmed === '') {
return [];
}
return array_map('intval', explode(',', $trimmed));
}
// Defensive: if driver already gave array.
if (is_array($value)) {
return array_values(array_map('intval', $value));
}
return [];
}
/**
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
// Defensive: interface phpdoc says list<int>|null, but $value is mixed at PHP level;
// protect against runtime misuse (e.g., string passed mistakenly).
// @phpstan-ignore function.alreadyNarrowedType
if (! is_array($value)) {
throw new \InvalidArgumentException(
"PostgresIntArray cast expects array for key '{$key}', got ".gettype($value)
);
}
if ($value === []) {
return '{}';
}
$ints = array_map('intval', $value);
return '{'.implode(',', $ints).'}';
}
}
@@ -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]);
}
}
@@ -63,10 +63,10 @@ class DashboardController extends Controller
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
// --- conversion: % статуса 'paid' в окне ---
$curPaid = (clone $base())->where('status', 'paid')
// --- conversion: % статуса 'won' в окне ---
$curPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$windowStart, $now])->count();
$prevPaid = (clone $base())->where('status', 'paid')
$prevPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$prevStart, $windowStart])->count();
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
@@ -13,6 +13,7 @@ use App\Models\User;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
@@ -55,6 +56,11 @@ class DealController extends Controller
{
$tenantId = (int) $request->user()->tenant_id;
$request->validate([
'received_from' => 'nullable|date',
'received_to' => 'nullable|date',
]);
$statuses = (array) $request->query('status_in', []);
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
@@ -64,6 +70,8 @@ class DealController extends Controller
$onlyDeleted = $request->boolean('only_deleted');
$countOnly = $request->boolean('count_only');
$cursorRaw = (string) $request->query('cursor', '');
$receivedFrom = trim((string) $request->query('received_from', ''));
$receivedTo = trim((string) $request->query('received_to', ''));
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
// При передаче cursor — keyset через PG row constructor (received_at, id) < (?, ?),
@@ -81,7 +89,7 @@ class DealController extends Controller
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
}
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly, $receivedFrom, $receivedTo) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
@@ -92,8 +100,16 @@ class DealController extends Controller
// withTrashed() обходит global scope SoftDeletes; явный
// whereNotNull('deleted_at') фильтрует только удалённые.
$query = Deal::query()
->select('deals.*')
->addSelect(['next_reminder_at' => DB::table('reminders')
->select('remind_at')
->whereColumn('reminders.deal_id', 'deals.id')
->whereNull('reminders.completed_at')
->orderBy('remind_at')
->limit(1),
])
->where('tenant_id', $tenantId)
->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']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
@@ -115,6 +131,13 @@ class DealController extends Controller
->orWhere('contact_name', 'ilike', $like);
});
}
if ($receivedFrom !== '') {
$query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
}
if ($receivedTo !== '') {
// received_to включительно — до конца дня (+1 день, строгое <).
$query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
}
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
@@ -187,6 +210,15 @@ class DealController extends Controller
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
: null,
'received_at' => $d->received_at?->toIso8601String(),
'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,
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
@@ -219,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) {
@@ -261,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,
@@ -403,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,
],
]);
}
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Style;
@@ -16,44 +17,45 @@ use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Export сделок в CSV / XLSX через OpenSpout streaming.
* Экспорт сделок в CSV / XLSX через OpenSpout streaming.
*
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
* Редизайн «Сделки» (2026-05-17, Task A5): экспорт по ДИАПАЗОНУ ДАТ поставки
* (received_at), не по списку id. Окно задаётся received_from/received_to;
* оба опциональны (пусто = весь период). Колонки соответствуют таблице
* страницы (без чекбокса и без «Напоминание» экспорт = дамп лидов).
*
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe).
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
* полный объект .xlsx в памяти (для 10K сделок 100+ MB). OpenSpout пишет
* O-perf-05: streaming устраняет memory pressure. OpenSpout пишет
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
* по сделкам пик памяти O(1) от размера экспорта.
*
* API контракт сохранён:
* POST /api/deals/export {ids[], format?: csv|xlsx}
* Headers Content-Type / Content-Disposition без изменений.
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
* XLSX: bold-header + auto-size columns.
*
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe). Чужие id
* отфильтрует where(tenant_id) defense-in-depth.
*/
class DealExportController extends Controller
{
/** Заголовки таблицы — общие для CSV и XLSX. */
private const HEADERS = ['ID', мя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
/** Заголовки — общие для CSV и XLSX. */
private const HEADERS = ['Телефон', сточник', 'Город', 'Статус', 'Комментарий', 'Поставлен'];
/** signal_type → русская метка для колонки «Источник». */
private const SIGNAL_LABELS = ['call' => 'Звонки', 'site' => 'Сайт', 'sms' => 'СМС'];
public function export(Request $request): StreamedResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1|max:10000',
'ids.*' => 'integer|min:1',
'received_from' => 'nullable|date',
'received_to' => 'nullable|date',
'format' => 'nullable|string|in:csv,xlsx',
]);
$tenantId = (int) $request->user()->tenant_id;
$format = $validated['format'] ?? 'csv';
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
$from = isset($validated['received_from']) && $validated['received_from'] !== ''
? Carbon::parse($validated['received_from'])->startOfDay() : null;
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
$headers = $format === 'xlsx'
? [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -64,14 +66,16 @@ class DealExportController extends Controller
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
];
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
return new StreamedResponse(function () use ($tenantId, $format, $from, $to) {
// RLS-контекст должен быть установлен внутри транзакции на момент
// фактического SELECT. StreamedResponse callback вызывается уже
// после Laravel-response pipeline'а, поэтому открываем транзакцию
// прямо здесь.
DB::transaction(function () use ($validated, $tenantId, $format) {
DB::transaction(function () use ($tenantId, $format, $from, $to) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$statusNames = DB::table('lead_statuses')->pluck('name_ru', 'slug');
$writer = $this->openWriter($format);
$writer->openToFile('php://output');
@@ -81,32 +85,41 @@ class DealExportController extends Controller
if ($format === 'xlsx') {
/** @var XlsxWriter $writer */
$writer->getCurrentSheet()->setName('Сделки');
$headerStyle = (new Style)->withFontBold(true);
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, $headerStyle));
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, (new Style)->withFontBold(true)));
} else {
$writer->addRow(Row::fromValues(self::HEADERS));
}
// chunkById(500) — keyset-friendly; в нашем DealsView это
// редкий тяжёлый action, экспортировать могут до 10K id.
Deal::query()
$query = Deal::query()
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->orderBy('id')
->chunkById(500, function ($deals) use ($writer) {
foreach ($deals as $deal) {
/** @var Deal $deal */
$writer->addRow(Row::fromValues([
$deal->id,
(string) ($deal->contact_name ?? ''),
(string) $deal->phone,
(string) $deal->status,
$deal->project_id,
$deal->manager_id ?? '',
$deal->received_at->toDateTimeString(),
]));
}
});
->with('project:id,name,signal_type')
->orderByDesc('received_at');
if ($from !== null) {
$query->where('received_at', '>=', $from);
}
if ($to !== null) {
$query->where('received_at', '<', $to);
}
// chunkById(500) — keyset-friendly; deals.id — BIGSERIAL (unique),
// корректно для чанкинга даже при партиционированной PK (id, received_at).
$query->chunkById(500, function ($deals) use ($writer, $statusNames) {
foreach ($deals as $deal) {
/** @var Deal $deal */
$signal = $deal->project?->signal_type;
$source = trim(($deal->project?->name ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
$writer->addRow(Row::fromValues([
(string) $deal->phone,
$source,
(string) ($deal->city ?? ''),
(string) ($statusNames[$deal->status] ?? $deal->status),
(string) ($deal->comment ?? ''),
$deal->received_at?->toDateTimeString() ?? '',
]));
}
}, 'id');
$writer->close();
});
@@ -120,12 +133,10 @@ class DealExportController extends Controller
}
// CSV: ;-разделитель + UTF-8 BOM (Excel-friendly RU-локаль).
$options = new CsvOptions(
return new CsvWriter(new CsvOptions(
FIELD_DELIMITER: ';',
FIELD_ENCLOSURE: '"',
SHOULD_ADD_BOM: true,
);
return new CsvWriter($options);
));
}
}
@@ -32,10 +32,17 @@ class BulkProjectActionRequest extends FormRequest
'scope.filter.search' => ['nullable', 'string', 'max:255'],
];
if ($action === 'update_regions' || $action === 'update_days') {
$maxMask = $action === 'update_regions' ? 255 : 127;
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
if ($action === 'update_regions') {
// Plan 6.5: субъект-уровневые коды 1..89 (см. resources/js/constants/regions.ts).
$rules['add_regions'] = ['nullable', 'array'];
$rules['add_regions.*'] = ['integer', 'between:1,89'];
$rules['remove_regions'] = ['nullable', 'array'];
$rules['remove_regions.*'] = ['integer', 'between:1,89'];
}
if ($action === 'update_days') {
$rules['add'] = ['nullable', 'integer', 'min:0', 'max:127'];
$rules['remove'] = ['nullable', 'integer', 'min:0', 'max:127'];
}
if ($action === 'update_limit') {
@@ -22,8 +22,11 @@ class StoreProjectRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
'region_mask' => ['required', 'integer', 'min:0'],
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
// Empty array = "вся РФ" (паритет с legacy region_mask=255 + region_mode='include').
// present = поле должно быть в payload (даже если []), enforces explicit choice.
'regions' => ['present', 'array'],
'regions.*' => ['integer', 'between:1,89'],
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
];
+24 -4
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProjectRequest extends FormRequest
{
@@ -17,15 +17,35 @@ 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'],
'region_mask' => ['sometimes', 'integer', 'min:0'],
'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])],
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
// sometimes = поле omit-able (preserves prior DB value), массив + each 1..89.
'regions' => ['sometimes', 'array'],
'regions.*' => ['integer', 'between:1,89'],
'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'],
'sms_senders' => ['sometimes', 'array', 'min:1'],
'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;
}
}
@@ -31,6 +31,7 @@ class ProjectResource extends JsonResource
'archived_at' => $project->archived_at?->toIso8601String(),
'region_mask' => $this->region_mask,
'region_mode' => $this->region_mode,
'regions' => $this->regions,
'delivery_days_mask' => $this->delivery_days_mask,
'sync_status' => $this->aggregateSyncStatus(),
'last_synced_at' => $this->aggregateLastSyncedAt(),
+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,
@@ -207,7 +229,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
* Маппинг:
* daily_limit daily_limit_target
* workdays биты delivery_days_mask (bit 0=Пн, , bit 6=Вс) ISO 1..7
* regions биты region_mask (bit 0=Центральный, , bit 7=Дальневосточный) 1..8
* regions projects.regions INT[] (subject codes 1..89) direct copy
*
* @param EloquentCollection<int, Project> $projects
* @return Collection<int, stdClass>
@@ -219,12 +241,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
$obj->daily_limit = (int) $p->daily_limit_target;
$obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);
// region_mask=255 (все 8 ФО, default) — catch-all семантика → пустой массив
// у supplier ("без региональных ограничений"). Иначе — список выставленных битов.
$regionMask = (int) $p->region_mask;
$obj->regions = $regionMask === 255
? []
: $this->bitmaskToList($regionMask, 8);
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
// Empty array = "вся РФ" (паритет с supplier API semantics).
// Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService,
// outbound к supplier использует только regions[]. Cleanup в Plan 6.5.
$obj->regions = array_values((array) $p->regions);
return $obj;
})->values();
+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'.
+8
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Casts\PostgresIntArray;
use Carbon\CarbonInterface;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder;
@@ -45,6 +46,9 @@ class Project extends Model
'effective_limit_calculated_at',
'region_mask',
'region_mode',
// Plan 6 (schema v8.20): Subject-level regions array (89 codes из resources/js/constants/regions.ts).
// Источник истины с Plan 6+; region_mask/region_mode — DEPRECATED (Plan 6.5 cleanup).
'regions',
'delivery_days_mask',
'assignment_strategy',
'ttfr_target_minutes',
@@ -69,6 +73,10 @@ class Project extends Model
'daily_limit_target' => 'integer',
'effective_daily_limit_today' => 'integer',
'region_mask' => 'integer',
// Plan 6: Subject-level regions array (89 codes). Используется кастомный
// PostgresIntArray cast — Laravel stock 'array' посылает JSON `[1,2,3]`,
// что Postgres отвергает на INT[] (ожидает literal `{1,2,3}`).
'regions' => PostgresIntArray::class,
'delivery_days_mask' => 'integer',
'ttfr_target_minutes' => 'integer',
'effective_limit_calculated_at' => 'datetime',
@@ -0,0 +1,58 @@
<?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;
use Illuminate\Support\Carbon;
/**
* Очередь яруса 3 резерва канала миграции проектов.
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
*
* @property int $id
* @property int $project_id
* @property string $platform
* @property string $operation
* @property string|null $external_id
* @property array<string, mixed> $payload_snapshot
* @property string $failure_reason
* @property string $status
* @property int|null $resolved_by_user_id
* @property Carbon|null $created_at
* @property Carbon|null $resolved_at
*/
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),
),
);
}
/**
@@ -105,7 +105,7 @@ final class HistoricalImportService
}
/**
* Маппит статус: каноническая таблица §6.4 tenant-override fallback 'new'.
* Маппит статус: StatusRuToSlugMapper tenant-override fallback 'new'.
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
*
* @param array<string, string> $overrides
@@ -5,29 +5,36 @@ declare(strict_types=1);
namespace App\Services\Import;
/**
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
* Маппинг русских названий статусов (старые 14 названий поставщика + новые 5)
* в slug 5-статусной воронки (редизайн 2026-05-17).
*
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
*/
class StatusRuToSlugMapper
{
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
/** @var array<string, string> Русские названия → 5 slug'ов воронки (редизайн 2026-05-17). */
private const STATUS_RU_TO_SLUG = [
'Новые' => 'new',
// Новые названия 5-статусной воронки.
'Новая сделка' => 'new',
'Просмотрено' => 'viewed',
'Проработан' => 'worked',
'База' => 'base',
'Недозвон' => 'missed',
'Переговоры' => 'negotiations',
'Ожидаем оплаты' => 'waiting_payment',
артнерка' => 'partnership',
'Оплачено' => 'paid',
'Закрыто и не реализовано' => 'closed',
'Тест драйв' => 'test_drive',
'Горячий' => 'hot',
'На замену' => 'replacement',
'Конечный недозвон' => 'final_missed',
'В работе' => 'in_progress',
'Сделка' => 'won',
'Не реализовано' => 'lost',
// Старые 14 названий поставщика → новые slug'и (исторический CSV-импорт).
'Новые' => 'new',
роработан' => 'in_progress',
'База' => 'in_progress',
'Недозвон' => 'in_progress',
'Переговоры' => 'in_progress',
'Ожидаем оплаты' => 'in_progress',
'Партнерка' => 'in_progress',
'Оплачено' => 'won',
'Закрыто и не реализовано' => 'lost',
'Тест драйв' => 'in_progress',
'Горячий' => 'in_progress',
'На замену' => 'in_progress',
'Конечный недозвон' => 'in_progress',
];
/**
@@ -39,7 +46,8 @@ class StatusRuToSlugMapper
}
/**
* Полная каноническая таблица для UI wizard'а (показать варианты).
* Полная таблица соответствия: русское название slug 5-статусной воронки
* (18 ключей старые и новые названия схлопываются в 5 slug'ов).
*
* @return array<string, string>
*/
+43 -8
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);
@@ -114,15 +118,41 @@ class ProjectService
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
/**
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
*
* Для каждого проекта: regions := unique(regions add_regions) \ remove_regions,
* отсортировано по возрастанию. `regions[]` источник истины региональной
* фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его).
* Legacy `region_mask` здесь не трогается как и в одиночном PATCH
* /api/projects/{id}; его удаление Plan 6.5 cleanup.
*
* NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных
* субъектов это осознанное действие оператора bulk-диалога.
*
* Обновление идёт через model-инстанс (не query-builder mass update): каст
* PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а
* mass update каст не применяет. count BULK_MAX (500) допустимо.
*/
private function bulkUpdateRegions($query, array $payload): array
{
$add = (int) ($payload['add'] ?? 0);
$remove = (int) ($payload['remove'] ?? 0);
$add = array_map('intval', $payload['add_regions'] ?? []);
$remove = array_map('intval', $payload['remove_regions'] ?? []);
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0255)
$updated = $query->update([
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
]);
if ($add === [] && $remove === []) {
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
$projects = (clone $query)->get(['id', 'regions']);
$updated = 0;
foreach ($projects as $project) {
$next = array_values(array_unique([...($project->regions ?? []), ...$add]));
$next = array_values(array_diff($next, $remove));
sort($next);
$project->update(['regions' => $next]);
$updated++;
}
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
@@ -191,6 +221,11 @@ class ProjectService
$data['tenant_id'] = $tenant->id;
$data['is_active'] = true;
$data['regions'] = $data['regions'] ?? [];
// Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
$data['region_mask'] = 255;
$data['region_mode'] = 'include';
$project = Project::create($data);
SyncSupplierProjectJob::dispatch($project->id);
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
* managers_summary агрегат сделок по менеджерам за период (audit F1).
*
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
* в строку «Не назначен». «Оплачено» = status='won' (won-статус воронки, как
* в DashboardController). Конверсия = won / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
@@ -48,7 +48,7 @@ class ManagersSummaryProvider implements ReportDataProvider
"deals.manager_id,
users.first_name, users.last_name, users.email,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
COUNT(*) FILTER (WHERE deals.status = 'won') AS paid"
)
->get();
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
* sources_summary агрегат сделок по источнику (utm_source) за период (audit F1).
*
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
* paid / total * 100, округление до 0.1.
* строку «Прямые / без метки». «Оплачено» = status='won'. Конверсия =
* won / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id паттерн DealsExportProvider.
@@ -45,7 +45,7 @@ class SourcesSummaryProvider implements ReportDataProvider
->selectRaw(
"utm_source,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
COUNT(*) FILTER (WHERE status = 'won') AS paid"
)
->get();
@@ -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);
}
@@ -211,9 +320,43 @@ class SupplierPortalClient
);
}
// Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей
// сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим
// refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username.
if ($this->isHtmlLoginPage($response)) {
if ($isRetry) {
throw new SupplierAuthException(
"Portal returned login page after refresh on {$path}",
httpStatus: $response->status(),
responseBody: $response->body(),
);
}
try {
dispatch_sync(app(RefreshSupplierSessionJob::class));
} catch (\Throwable $e) {
throw new SupplierAuthException(
"Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}",
httpStatus: $response->status(),
previous: $e,
);
}
return $this->request($method, $path, $body, isRetry: true, asJson: $asJson);
}
return $response;
}
private function isHtmlLoginPage(Response $response): bool
{
$contentType = $response->header('Content-Type');
if (! str_starts_with(mb_strtolower($contentType), 'text/html')) {
return false;
}
return preg_match('~loginform-(username|password)~i', $response->body()) === 1;
}
/**
* @return array{phpsessid: string, csrf: string, refreshed_at?: string}
*/
@@ -244,23 +387,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,
];
}
}
+1
View File
@@ -17,6 +17,7 @@
},
"require-dev": {
"barryvdh/laravel-ide-helper": "*",
"deptrac/deptrac": "^4.6",
"fakerphp/faker": "^1.23",
"infection/infection": "^0.32.7",
"larastan/larastan": "*",
+427 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f6418ddc96f575de868a519b516c26d8",
"content-hash": "b859d747b77450b0917b3a7ae30284aa",
"packages": [
{
"name": "brick/math",
@@ -7279,6 +7279,91 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
{
"name": "deptrac/deptrac",
"version": "4.6.1",
"source": {
"type": "git",
"url": "https://github.com/deptrac/deptrac.git",
"reference": "6ff20dec210f119a4ddebdf8e28603689f34eb67"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/deptrac/deptrac/zipball/6ff20dec210f119a4ddebdf8e28603689f34eb67",
"reference": "6ff20dec210f119a4ddebdf8e28603689f34eb67",
"shasum": ""
},
"require": {
"composer/xdebug-handler": "^3.0",
"jetbrains/phpstorm-stubs": "2024.3 || 2025.3 || 2026.1",
"nikic/php-parser": "^5",
"php": "^8.2",
"phpdocumentor/graphviz": "^2.1",
"phpdocumentor/type-resolver": "^1.9.0 || ^2.0.0",
"phpstan/phpdoc-parser": "^1.5.0 || ^2.1.0",
"phpstan/phpstan": "^2.0",
"psr/container": "^2.0",
"psr/event-dispatcher": "^1.0",
"symfony/config": "^6.4 || ^7.4 || ^8.0",
"symfony/console": "^6.4 || ^7.4 || ^8.0",
"symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0",
"symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0",
"symfony/event-dispatcher-contracts": "^3.4",
"symfony/filesystem": "^6.4 || ^7.4 || ^8.0",
"symfony/finder": "^6.4 || ^7.4 || ^8.0",
"symfony/yaml": "^6.4 || ^7.4 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8",
"ergebnis/composer-normalize": "^2.45",
"ext-libxml": "*",
"symfony/stopwatch": "^6.4 || ^7.4 || ^8.0"
},
"suggest": {
"ext-dom": "For using the JUnit output formatter"
},
"bin": [
"deptrac"
],
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": false,
"forward-command": true,
"target-directory": "tools"
}
},
"autoload": {
"psr-4": {
"Deptrac\\Deptrac\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tim Glabisch"
},
{
"name": "Simon Mönch"
},
{
"name": "Denis Brumann"
}
],
"description": "Deptrac is a static code analysis tool that helps to enforce rules for dependencies between software layers.",
"keywords": [
"dev",
"static analysis"
],
"support": {
"issues": "https://github.com/deptrac/deptrac/issues",
"source": "https://github.com/deptrac/deptrac/tree/4.6.1"
},
"time": "2026-05-13T08:23:06+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
@@ -8042,6 +8127,50 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "jetbrains/phpstorm-stubs",
"version": "v2026.1",
"source": {
"type": "git",
"url": "https://github.com/JetBrains/phpstorm-stubs",
"reference": "2cdd054c4109dfb76667c9198bf9427606354243"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/2cdd054c4109dfb76667c9198bf9427606354243",
"reference": "2cdd054c4109dfb76667c9198bf9427606354243",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
"phpdocumentor/reflection-docblock": "^5.6",
"phpunit/phpunit": "^12.3"
},
"type": "library",
"autoload": {
"files": [
"PhpStormStubsMap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "PHP runtime & extensions header files for PhpStorm",
"homepage": "https://www.jetbrains.com/phpstorm",
"keywords": [
"autocomplete",
"code",
"inference",
"inspection",
"jetbrains",
"phpstorm",
"stubs",
"type"
],
"time": "2026-02-19T20:12:01+00:00"
},
{
"name": "justinrainbow/json-schema",
"version": "6.8.2",
@@ -9674,6 +9803,59 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "phpdocumentor/graphviz",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/GraphViz.git",
"reference": "115999dc7f31f2392645aa825a94a6b165e1cedf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/GraphViz/zipball/115999dc7f31f2392645aa825a94a6b165e1cedf",
"reference": "115999dc7f31f2392645aa825a94a6b165e1cedf",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"ext-simplexml": "*",
"mockery/mockery": "^1.2",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^8.2 || ^9.2",
"psalm/phar": "^4.15"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\GraphViz\\": "src/phpDocumentor/GraphViz",
"phpDocumentor\\GraphViz\\PHPStan\\": "./src/phpDocumentor/PHPStan"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"description": "Wrapper for Graphviz",
"support": {
"issues": "https://github.com/phpDocumentor/GraphViz/issues",
"source": "https://github.com/phpDocumentor/GraphViz/tree/2.1.0"
},
"time": "2021-12-13T19:03:21+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -12674,6 +12856,169 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/config",
"version": "v7.4.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/config.git",
"reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57",
"reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/filesystem": "^7.1|^8.0",
"symfony/polyfill-ctype": "~1.8"
},
"conflict": {
"symfony/finder": "<6.4",
"symfony/service-contracts": "<2.5"
},
"require-dev": {
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Config\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/config/tree/v7.4.10"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-03T14:20:49+00:00"
},
{
"name": "symfony/dependency-injection",
"version": "v7.4.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/dependency-injection.git",
"reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d",
"reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/container": "^1.1|^2.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/service-contracts": "^3.6",
"symfony/var-exporter": "^6.4.20|^7.2.5|^8.0"
},
"conflict": {
"ext-psr": "<1.1|>=2",
"symfony/config": "<6.4",
"symfony/finder": "<6.4",
"symfony/yaml": "<6.4"
},
"provide": {
"psr/container-implementation": "1.1|2.0",
"symfony/service-implementation": "1.1|2.0|3.0"
},
"require-dev": {
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\DependencyInjection\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.10"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-06T11:55:30+00:00"
},
{
"name": "symfony/filesystem",
"version": "v7.4.9",
@@ -12744,6 +13089,87 @@
],
"time": "2026-04-18T13:18:21+00:00"
},
{
"name": "symfony/var-exporter",
"version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-exporter.git",
"reference": "22e03a49c95ef054a43601cd159b222bfab1c701"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/22e03a49c95ef054a43601cd159b222bfab1c701",
"reference": "22e03a49c95ef054a43601cd159b222bfab1c701",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3"
},
"require-dev": {
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/serializer": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\VarExporter\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows exporting any serializable PHP data structure to plain PHP code",
"homepage": "https://symfony.com",
"keywords": [
"clone",
"construct",
"export",
"hydrate",
"instantiate",
"lazy-loading",
"proxy",
"serialize"
],
"support": {
"source": "https://github.com/symfony/var-exporter/tree/v7.4.9"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-18T13:18:21+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.4.10",
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Plan 6 (C9) subject-level regions.
*
* +1 колонка projects.regions INT[] (1..89 коды субъектов РФ; пустой массив = вся РФ).
* +1 GIN-индекс idx_projects_regions для outbound regions queries.
* region_mask/region_mode остаются (dual-write) удаление в Plan 6.5.
*
* Guard'ы: migrate:fresh грузит schema.sql v8.22 (где delta уже есть) до миграций,
* поэтому каждый кусок применяется только при отсутствии (как Sprint 4 миграция).
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('projects', 'regions')) {
DB::statement("ALTER TABLE projects ADD COLUMN regions INT[] NOT NULL DEFAULT '{}'::INT[]");
}
DB::statement('CREATE INDEX IF NOT EXISTS idx_projects_regions ON projects USING GIN (regions)');
DB::statement(
'COMMENT ON COLUMN projects.regions IS '
."'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.22).'"
);
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS idx_projects_regions');
if (Schema::hasColumn('projects', 'regions')) {
Schema::table('projects', fn ($table) => $table->dropColumn('regions'));
}
}
};
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Воронка статусов 14 5 (редизайн «Сделки» 2026-05-17).
*
* Новые 5: new / viewed / in_progress / won / lost. Slug'и `new` и `viewed`
* сохраняются (RouteSupplierLeadJob / DealController@store default'ят 'new').
* Ремап старых 14 5 в deals.status и import_unknown_statuses.mapped_to_slug
* перед DELETE устаревших lead_statuses (FK-safe). tenant_status_overrides
* со старыми slug'ами удаляются (кастомные ярлыки схлопнутых статусов
* обсолетны + исключает PK-коллизию при ремапе).
*
* На migrate:fresh schema.sql уже сеет 5 UPDATE/DELETE здесь no-op.
* down() необратима (схлопывание lossy).
*
* Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md §3.
*/
return new class extends Migration
{
/** Старый slug → новый. new/viewed не меняются (отсутствуют в карте). */
private const REMAP = [
'worked' => 'in_progress', 'base' => 'in_progress', 'missed' => 'in_progress',
'negotiations' => 'in_progress', 'waiting_payment' => 'in_progress',
'partnership' => 'in_progress', 'test_drive' => 'in_progress', 'hot' => 'in_progress',
'replacement' => 'in_progress', 'final_missed' => 'in_progress',
'paid' => 'won', 'closed' => 'lost',
];
private const KEEP = ['new', 'viewed', 'in_progress', 'won', 'lost'];
public function up(): void
{
DB::transaction(function () {
// 1) Новые slug'и обязаны существовать до ремапа FK-ссылок.
DB::table('lead_statuses')->upsert([
['slug' => 'new', 'name_ru' => 'Новая сделка', 'is_system' => true, 'sort_order' => 1, 'color_hex' => '#3B82F6'],
['slug' => 'viewed', 'name_ru' => 'Просмотрено', 'is_system' => true, 'sort_order' => 2, 'color_hex' => '#8B5CF6'],
['slug' => 'in_progress', 'name_ru' => 'В работе', 'is_system' => true, 'sort_order' => 3, 'color_hex' => '#06B6D4'],
['slug' => 'won', 'name_ru' => 'Сделка', 'is_system' => true, 'sort_order' => 4, 'color_hex' => '#10B981'],
['slug' => 'lost', 'name_ru' => 'Не реализовано', 'is_system' => true, 'sort_order' => 5, 'color_hex' => '#6B7280'],
], ['slug'], ['name_ru', 'is_system', 'sort_order', 'color_hex']);
// 2) Ремап ссылок на старые slug'и.
foreach (self::REMAP as $old => $new) {
DB::table('deals')->where('status', $old)->update(['status' => $new]);
DB::table('import_unknown_statuses')->where('mapped_to_slug', $old)->update(['mapped_to_slug' => $new]);
}
// 3) Обсолетные кастомные ярлыки статусов — удалить (FK на lead_statuses).
DB::table('tenant_status_overrides')->whereNotIn('status_slug', self::KEEP)->delete();
// 4) Удалить устаревшие статусы (все FK-ссылки перенаправлены).
DB::table('lead_statuses')->whereNotIn('slug', self::KEEP)->delete();
});
}
public function down(): void
{
throw new RuntimeException('Воронка 14→5 необратима (схлопывание статусов lossy).');
}
};
@@ -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');
}
};
+46
View File
@@ -0,0 +1,46 @@
deptrac:
paths:
- ./app
layers:
- name: Controller
collectors: [{ type: directory, value: app/Http/Controllers/.* }]
- name: Request
collectors: [{ type: directory, value: app/Http/Requests/.* }]
- name: Resource
collectors: [{ type: directory, value: app/Http/Resources/.* }]
- name: Middleware
collectors: [{ type: directory, value: app/Http/Middleware/.* }]
- name: Service
collectors: [{ type: directory, value: app/Services/.* }]
- name: Job
collectors: [{ type: directory, value: app/Jobs/.* }]
- name: Console
collectors: [{ type: directory, value: app/Console/.* }]
- name: Repository
collectors: [{ type: directory, value: app/Repositories/.* }]
- name: Model
collectors: [{ type: directory, value: app/Models/.* }]
- name: Mail
collectors: [{ type: directory, value: app/Mail/.* }]
- name: Rule
collectors: [{ type: directory, value: app/Rules/.* }]
- name: Exception
collectors: [{ type: directory, value: app/Exceptions/.* }]
- name: Provider
collectors: [{ type: directory, value: app/Providers/.* }]
ruleset:
# Conservative ruleset — enforces only the architecturally-wrong directions
# (inward/upward deps). Whatever current code violates is captured by the
# baseline (deptrac.baseline.yaml); this gate then catches only NEW drift.
Controller: [Service, Request, Resource, Model, Job, Mail, Repository, Rule, Exception]
Middleware: [Service, Model, Exception]
Service: [Service, Model, Repository, Job, Mail, Rule, Exception]
Job: [Service, Model, Repository, Mail, Exception]
Console: [Service, Model, Repository, Job, Mail, Exception]
Repository: [Model, Exception]
Request: [Rule, Model]
Resource: [Model]
Rule: [Model]
Mail: [Model]
Model: []
Provider: [Controller, Service, Job, Console, Repository, Model, Mail, Middleware, Request, Resource, Rule, Exception]
+359 -11
View File
@@ -54,18 +54,132 @@ parameters:
count: 1
path: app/Http/Controllers/Api/AdminTenantsController.php
-
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$next_reminder_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/DealController.php
-
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 5
path: app/Http/Controllers/Api/DealController.php
-
message: '#^Expression on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.expr
count: 1
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Cannot call method toIso8601String\(\) on null\.$#'
identifier: method.nonObject
count: 1
path: app/Http/Controllers/Api/ImpersonationController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$error_message\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$filename\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$finished_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_added\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_skipped\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_total\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_updated\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$started_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$tenant_id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$unknown_statuses_count\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$occurrences\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$status_ru\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\<int,App\\Models\\ImportUnknownStatus\>\:\:map\(\) contains unresolvable type\.$#'
identifier: argument.unresolvableType
count: 1
path: app/Http/Controllers/Api/ImportController.php
-
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -78,12 +192,48 @@ parameters:
count: 1
path: app/Http/Middleware/SetTenantContext.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$file_path\.$#'
identifier: property.notFound
count: 3
path: app/Jobs/ImportLeadsJob.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$user_id\.$#'
identifier: property.notFound
count: 3
path: app/Jobs/ImportLeadsJob.php
-
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 2
path: app/Mail/NewLeadNotification.php
-
message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperImportLog\.$#'
identifier: class.notFound
count: 1
path: app/Models/ImportLog.php
-
message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperImportUnknownStatus\.$#'
identifier: class.notFound
count: 1
path: app/Models/ImportUnknownStatus.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#'
identifier: property.notFound
count: 1
path: app/Services/Import/HistoricalImportService.php
-
message: '#^Call to function is_array\(\) with array\<mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
@@ -102,6 +252,12 @@ parameters:
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Call to function is_array\(\) with array\<string, mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -159,7 +315,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 6
count: 9
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
-
@@ -168,6 +324,18 @@ parameters:
count: 1
path: tests/Feature/Admin/AdminPricingTiersControllerTest.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 Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -180,6 +348,24 @@ parameters:
count: 3
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Admin/SupplierManualQueueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Admin/SupplierManualQueueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Admin/SupplierManualQueueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -285,7 +471,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 14
count: 15
path: tests/Feature/Api/ProjectBulkActionsTest.php
-
@@ -711,7 +897,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 25
count: 10
path: tests/Feature/DealCreateTest.php
-
@@ -756,6 +942,42 @@ parameters:
count: 2
path: tests/Feature/DealDestroyTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/DealExportTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 6
path: tests/Feature/DealExportTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealExportTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealExportTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/DealExportTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/DealExportTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#'
identifier: property.notFound
@@ -765,13 +987,13 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#'
identifier: property.notFound
count: 7
count: 10
path: tests/Feature/DealIndexTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 26
count: 38
path: tests/Feature/DealIndexTest.php
-
@@ -783,7 +1005,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 30
count: 41
path: tests/Feature/DealIndexTest.php
-
@@ -801,7 +1023,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 21
count: 29
path: tests/Feature/DealIndexTest.php
-
@@ -873,7 +1095,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 13
count: 20
path: tests/Feature/DealShowTest.php
-
@@ -891,7 +1113,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 7
count: 10
path: tests/Feature/DealShowTest.php
-
@@ -972,6 +1194,12 @@ parameters:
count: 9
path: tests/Feature/DealUpdateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/DemoSeederTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
@@ -1008,6 +1236,18 @@ parameters:
count: 17
path: tests/Feature/ImpersonationTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$mapped_to_slug\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$occurrences\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$service\.$#'
identifier: property.notFound
@@ -1038,6 +1278,18 @@ parameters:
count: 3
path: tests/Feature/Import/ImportCompletedNotificationTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$mapped_to_slug\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$resolved_at\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -1068,6 +1320,42 @@ parameters:
count: 5
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$error_message\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$finished_at\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_added\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_skipped\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$unknown_statuses_count\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -1080,6 +1368,36 @@ parameters:
count: 4
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$entity_type\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$mapping_config\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$status_ru\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -1209,13 +1527,13 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 9
count: 12
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 6
count: 12
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
@@ -1464,6 +1782,36 @@ parameters:
count: 1
path: tests/Feature/Supplier/AutoPauseFlowTest.php
-
message: '#^Access to an undefined property App\\Services\\Supplier\\PlaywrightBridge\:\:\$lastArgs\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/Supplier/Channel/FormProjectChannelTest.php
-
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/CsvReconcileJobTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Parameter \#1 \$tier1 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
identifier: argument.type
count: 2
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Parameter \#2 \$tier2 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
identifier: argument.type
count: 2
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>RT Project Form Fixture — Element UI + Vuetify dialog</title>
<style>
/* Minimal stubs so Playwright class-based locators work */
.el-form-item { margin-bottom: 12px; }
.el-form-item__label { display: inline-block; min-width: 140px; }
.el-form-item__content { display: inline-block; }
.el-input__inner { border: 1px solid #cccccc; padding: 4px 8px; }
.el-checkbox { cursor: pointer; margin-right: 8px; }
.el-checkbox__input.is-checked .el-checkbox__inner { background: #409eff; }
.el-checkbox__inner { display: inline-block; width: 14px; height: 14px; border: 1px solid #cccccc; }
.el-switch { cursor: pointer; }
.el-switch.is-checked .el-switch__core { background: #409eff; }
.el-switch__core { display: inline-block; width: 40px; height: 20px; border-radius: 10px; background: #cccccc; }
.el-select-dropdown { position: absolute; background: #ffffff; border: 1px solid #cccccc; z-index: 9999; min-width: 120px; }
.el-select-dropdown__item { padding: 6px 12px; cursor: pointer; }
.el-select-dropdown__item:hover { background: #f5f7fa; }
.el-button { padding: 6px 16px; cursor: pointer; border: 1px solid #cccccc; background: #ffffff; }
.el-input-number .el-input__inner { width: 80px; }
</style>
</head><body>
<!-- Vuetify dialog wrapper — required by manage-project.js locator ".v-dialog--active button:has-text(...)" -->
<div class="v-dialog v-dialog--active v-dialog--persistent" style="padding:16px;">
<form class="el-form el-form--label-left">
<!-- 1. Tag -->
<div class="el-form-item">
<label class="el-form-item__label" for="tag">Тег</label>
<div class="el-form-item__content">
<div class="el-input">
<input type="text" class="el-input__inner" id="tag-fixture">
</div>
</div>
</div>
<!-- 2. Источник данных (B1/B2/B3 checkboxes) — label for="srcrt" -->
<div class="el-form-item">
<label class="el-form-item__label" for="srcrt">Источник данных</label>
<div class="el-form-item__content" id="srcrt-container">
<label class="el-checkbox is-checked" data-platform="B1">
<span class="el-checkbox__input is-checked">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original" checked>
</span>
<span class="el-checkbox__label">B1</span>
</label>
<label class="el-checkbox is-checked" data-platform="B2">
<span class="el-checkbox__input is-checked">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original" checked>
</span>
<span class="el-checkbox__label">B2</span>
</label>
<label class="el-checkbox is-checked" data-platform="B3">
<span class="el-checkbox__input is-checked">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original" checked>
</span>
<span class="el-checkbox__label">B3</span>
</label>
</div>
</div>
<!-- 3. Name — label for="name" -->
<div class="el-form-item">
<label class="el-form-item__label" for="name">Название проекта</label>
<div class="el-form-item__content">
<div class="el-input">
<input type="text" class="el-input__inner" id="name-fixture">
</div>
</div>
</div>
<!-- 4. Type select — label for="type" -->
<div class="el-form-item">
<label class="el-form-item__label" for="type">Источники сбора</label>
<div class="el-form-item__content">
<div class="el-select" id="type-select-container">
<!-- readonly input that shows selected value; clicking it opens dropdown popup in body -->
<div class="el-input">
<input type="text" class="el-input__inner" id="type-select-input" readonly
value="Сайты" placeholder="Выберите" data-current-value="Сайты">
<span class="el-input__suffix"><span class="el-select__caret"></span></span>
</div>
</div>
</div>
</div>
<!-- 5. Slider «Период» — no label-for, no DTO field, leave default -->
<div class="el-form-item">
<label class="el-form-item__label">Период</label>
<div class="el-form-item__content">
<div class="el-slider" aria-valuemin="0" aria-valuemax="24" aria-valuetext="10-18">
<span style="font-size:12px;color:#999999">10-18 (default)</span>
</div>
</div>
</div>
<!-- 6. Switch «Включить» — no label-for; identified by .el-switch in form-item -->
<div class="el-form-item" id="switch-form-item">
<label class="el-form-item__label">Статус</label>
<div class="el-form-item__content">
<div class="el-switch" id="active-switch">
<input type="checkbox" class="el-switch__input" id="active-switch-input">
<span class="el-switch__core"></span>
<span>Включить</span>
</div>
</div>
</div>
<!-- 7. Regions — label for="regions", el-select multiple -->
<div class="el-form-item">
<label class="el-form-item__label" for="regions">Регион</label>
<div class="el-form-item__content">
<div class="el-select el-select--multiple">
<input type="text" class="el-input__inner" id="regions-input" placeholder="Выберите регионы">
</div>
</div>
</div>
<!-- 8. limit_off — no label-for, no DTO field -->
<div class="el-form-item">
<label class="el-form-item__label" for="limit_off">Разделять по проектам</label>
<div class="el-form-item__content">
<label class="el-checkbox">
<span class="el-checkbox__input">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original">
</span>
<span class="el-checkbox__label">Да</span>
</label>
</div>
</div>
<!-- 9. Content (uniqueKey / domains) — label for="content", el-tabs -->
<div class="el-form-item">
<label class="el-form-item__label" for="content">Список сайтов</label>
<div class="el-form-item__content">
<div class="el-tabs">
<div class="el-tabs__header">
<div class="el-tabs__item is-active" data-tab="list">Список</div>
<div class="el-tabs__item" data-tab="file">Файл</div>
</div>
<div class="el-tabs__content">
<textarea class="el-textarea__inner" id="content-textarea" rows="4" style="width:100%"></textarea>
</div>
</div>
</div>
</div>
<!-- 10. Limit — label for="limit", el-input-number -->
<div class="el-form-item">
<label class="el-form-item__label" for="limit">Лимит в день</label>
<div class="el-form-item__content">
<div class="el-input-number">
<span class="el-input-number__decrease">-</span>
<div class="el-input">
<input type="text" class="el-input__inner" id="limit-input" value="10">
</div>
<span class="el-input-number__increase">+</span>
</div>
</div>
</div>
</form><!-- end .el-form -->
<!-- Save/Cancel buttons OUTSIDE form, INSIDE .v-dialog--active -->
<div style="margin-top:16px;">
<button type="button" class="el-button" id="save-btn">Сохранить</button>
<button type="button" class="el-button" id="cancel-btn">Отмена</button>
</div>
</div><!-- end .v-dialog--active -->
<script>
(function() {
'use strict';
// ---- Checkbox toggle behaviour ----
// Click on .el-checkbox toggles .is-checked on itself and .el-checkbox__input child
document.querySelectorAll('#srcrt-container .el-checkbox').forEach(function(cb) {
cb.addEventListener('click', function(e) {
e.preventDefault();
var isChecked = cb.classList.contains('is-checked');
cb.classList.toggle('is-checked', !isChecked);
var cbInput = cb.querySelector('.el-checkbox__input');
if (cbInput) cbInput.classList.toggle('is-checked', !isChecked);
var rawInput = cb.querySelector('input.el-checkbox__original');
if (rawInput) rawInput.checked = !isChecked;
});
});
// ---- Switch toggle behaviour ----
var switchEl = document.getElementById('active-switch');
if (switchEl) {
switchEl.addEventListener('click', function(e) {
e.preventDefault();
var isChecked = switchEl.classList.contains('is-checked');
switchEl.classList.toggle('is-checked', !isChecked);
var inp = document.getElementById('active-switch-input');
if (inp) inp.checked = !isChecked;
});
}
// ---- Type select popup ----
// When input#type-select-input is clicked, create a dropdown in body
var typeInput = document.getElementById('type-select-input');
var typeOptions = ['Сайты', 'Звонки', 'СМС', 'Ретро сайты', 'Ретро звонки'];
function removeDropdown() {
var existing = document.querySelector('body > .el-select-dropdown');
if (existing) existing.remove();
}
if (typeInput) {
typeInput.addEventListener('click', function(e) {
e.stopPropagation();
removeDropdown();
var dropdown = document.createElement('div');
dropdown.className = 'el-select-dropdown el-popper';
dropdown.style.position = 'absolute';
dropdown.style.left = '20px';
dropdown.style.top = '200px';
var ul = document.createElement('ul');
ul.className = 'el-scrollbar__view el-select-dropdown__list';
typeOptions.forEach(function(opt) {
var li = document.createElement('li');
li.className = 'el-select-dropdown__item';
li.textContent = opt;
li.addEventListener('click', function(e2) {
e2.stopPropagation();
typeInput.value = opt;
typeInput.setAttribute('data-current-value', opt);
removeDropdown();
});
ul.appendChild(li);
});
dropdown.appendChild(ul);
document.body.appendChild(dropdown);
});
}
// Close dropdown on outside click
document.addEventListener('click', function() {
removeDropdown();
});
// ---- Save button: POST to /admin/visit/rt-project-save on the same origin ----
// NOTE: NO fetch mock here — the HTTP server (manage-project.test.js) handles
// this route and returns {status:"OK",id:"99001"}. Playwright's waitForResponse
// intercepts real network requests, not mocked fetch.
document.getElementById('save-btn').addEventListener('click', function() {
var payload = {
tag: document.getElementById('tag-fixture') ? document.getElementById('tag-fixture').value : '',
name: document.getElementById('name-fixture') ? document.getElementById('name-fixture').value : '',
type: typeInput ? typeInput.getAttribute('data-current-value') : 'Сайты',
limit: document.getElementById('limit-input') ? document.getElementById('limit-input').value : '10',
};
fetch('/admin/visit/rt-project-save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
});
})();
</script>
</body></html>
+405
View File
@@ -0,0 +1,405 @@
#!/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.
*
* KNOWN GAPS (Tier-2 MVP, зафиксированы по recon 2026-05-19):
* - workdays: поле add-project форм НЕ содержит чекбоксы дней недели (только slider «Период»
* часы 0-24). DTO.workdays игнорируется; портал применяет дефолт (все 7 дней).
* Для точной настройки workdays используйте Tier-1 (AJAX).
* - regions: форма требует имена регионов, DTO несёт int[] id. Mapping idname не реализован.
* Tier-2 всегда передаёт пустой массив регионов (нет фильтрации). Регионы должны быть
* настроены вручную или через Tier-1.
*/
const { chromium } = require('playwright');
const TIMEOUT_MS = 90_000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Возвращает локатор form-item по значению атрибута for= у label.
* Стратегия: .el-form-item:has(.el-form-item__label[for="<attrFor>"])
*/
function fieldByFor(page, attrFor) {
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
}
// ---------------------------------------------------------------------------
// Login
// ---------------------------------------------------------------------------
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]'),
]);
}
// ---------------------------------------------------------------------------
// fillForm — Element UI label-for локаторы (recon 2026-05-19)
// ---------------------------------------------------------------------------
async function fillForm(page, dto) {
// NOTE: статус active/paused НЕ выставляется через форму. Единственный
// .el-switch на форме — это include/exclude регионов («Включить/Исключить»,
// recon 2026-05-19 row 6), НЕ статус проекта. Статус задаётся дефолтом
// портала (active). dto.active игнорируется в Tier-2; switch не трогаем
// (regions skip — см. ниже). Verified live 2026-05-19.
// --- 1. Tag ---
if (dto.tag !== undefined && dto.tag !== null) {
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(String(dto.tag));
}
// --- 2. Platforms (srcrt) — B1/B2/B3 checkboxes ---
// Initial: все три checked. Нужно включить только те, что в dto.platforms, остальные выключить.
const platformContainer = fieldByFor(page, 'srcrt');
for (const p of ['B1', 'B2', 'B3']) {
const wanted = (dto.platforms || []).includes(p);
// Identification — по `.el-checkbox__label` textContent (per recon-doc
// 2026-05-19-rt-project-form-locators.md row 2: реальный портал НЕ имеет
// `data-platform`-атрибута, inputs без `name`). Whitespace-tolerant `^\s*B1\s*$`.
const cb = platformContainer.locator('.el-checkbox').filter({
has: page.locator('.el-checkbox__label', { hasText: new RegExp(`^\\s*${p}\\s*$`) }),
}).first();
const cbClass = await cb.getAttribute('class').catch(() => '');
const isChecked = (cbClass || '').includes('is-checked');
if (!!isChecked !== wanted) {
await cb.click();
}
}
// --- 3. Name (label for="name") ---
// В реальном портале dto.name заполняется в поле «Название проекта»,
// а dto.uniqueKey (список сайтов/номеров) — в textarea «content».
// manage-project.js получает dto.name напрямую.
if (dto.name !== undefined) {
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(String(dto.name));
}
// --- 4. Type select (label for="type") ---
// El-select readonly input. Клик открывает popup в body > .el-select-dropdown.
const signalTypeMap = { site: 'Сайты', call: 'Звонки', sms: 'СМС' };
const signalLabel = signalTypeMap[dto.signal_type];
if (!signalLabel) {
throw new Error(
`Unsupported signal_type "${dto.signal_type}". Supported: site, call, sms. ` +
'"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.',
);
}
// Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит
// content tab-pane (Сайты/Звонки/СМС — разные поля сбора) → если сразу
// после type-select заполнять content, fill попадёт в detached textarea
// (Vue ещё не закончил ре-рендер) → rt-project-save уходит с пустым
// `content` → портал «Введите домены». Verified live 2026-05-19.
const typeInput = fieldByFor(page, 'type').locator('.el-select input.el-input__inner');
const currentType = (await typeInput.inputValue().catch(() => '')).trim();
if (currentType !== signalLabel) {
await typeInput.click();
// Dropdown рендерится снаружи формы в body — ждём его появления
const dropdownOption = page.locator('.el-select-dropdown__item', {
hasText: new RegExp(`^${signalLabel}$`),
});
await dropdownOption.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
await dropdownOption.click();
// Ждём, пока Vue завершит ре-рендер content tab-pane после смены типа.
await page.waitForTimeout(1000);
}
// --- 7. Regions (label for="regions") — SKIP, gap зафиксирован в JSDoc ---
// DTO несёт int[] id; форма требует имена. Mapping не реализован для MVP.
if (dto.regions && dto.regions.length > 0) {
process.stderr.write(
JSON.stringify({
warning: 'regions skipped in Tier-2 form channel: DTO carries int[] ids but form requires region names. ' +
'Region filtering will not be applied. Configure regions manually or use Tier-1.',
regions_received: dto.regions,
}) + '\n',
);
}
// --- 9. Content — список сайтов/номеров/отправителей (label for="content") ---
// Вкладка «Список» (default active). dto.domains — массив строк или dto.uniqueKey — строка.
const contentLines = dto.domains && dto.domains.length
? dto.domains.join('\n')
: dto.uniqueKey
? String(dto.uniqueKey)
: null;
if (contentLines) {
const contentField = fieldByFor(page, 'content');
// Вкладка «Список» — default active. Кликаем ТОЛЬКО если она НЕ активна:
// клик по вкладке Element UI ремоунтит tab-pane → textarea детачится,
// и последующий .fill() гонится с ре-рендером (домены теряются →
// rt-project-save уходит с пустым `content` → портал «Введите домены»).
// Verified live 2026-05-19: re-click активной вкладки ломал save.
const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }).first();
if ((await listTab.count()) > 0) {
const tabClass = (await listTab.getAttribute('class')) || '';
if (!tabClass.includes('is-active')) {
await listTab.click();
await contentField.locator('textarea.el-textarea__inner')
.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
}
}
const contentTa = contentField.locator('textarea.el-textarea__inner');
await contentTa.fill(contentLines);
// Defensive: убедиться, что значение действительно осело в textarea
// (если поле детачнулось ре-рендером — fill уйдёт в пустоту).
const filledValue = await contentTa.inputValue();
if (filledValue.trim() === '') {
throw new Error(
'Content textarea empty after fill — likely tab/type re-render race; domains lost',
);
}
}
// --- 10. Limit (label for="limit") ---
if (dto.limit !== undefined) {
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
}
// NOTE: workdays — gap зафиксирован в JSDoc. Форма add-project не содержит
// чекбоксы дней недели. dto.workdays игнорируется.
if (dto.workdays && dto.workdays.length !== 7) {
process.stderr.write(
JSON.stringify({
warning: 'workdays ignored in Tier-2 form channel: add-project form has no workdays field. ' +
'Portal will apply default (all 7 days). Configure workdays manually or use Tier-1.',
workdays_received: dto.workdays,
}) + '\n',
);
}
}
// ---------------------------------------------------------------------------
// createOp
// ---------------------------------------------------------------------------
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 },
);
// Кнопка «Добавить проект» — recon: label [title="Добавить проект"]
await page.locator('button:has-text("Добавить проект")').click();
// Ждём появления формы — label for="name" внутри .el-form
await page.locator('.el-form-item__label[for="name"]').waitFor({
state: 'visible',
timeout: TIMEOUT_MS,
});
}
await fillForm(page, args.dto);
// Кликаем «Сохранить» + перехватываем ответ rt-project-save
const [saveResponse] = await Promise.all([
page.waitForResponse(
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
{ timeout: TIMEOUT_MS },
),
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
]);
const body = await saveResponse.json();
if (body.status !== 'OK') {
// DIAG: дамп фактически отправленного тела — для расследования "Введите домены"
const sentBody = saveResponse.request().postData();
process.stderr.write(JSON.stringify({ diag_sent_body: sentBody }) + '\n');
throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`);
}
const externalId = String(body.id ?? '');
if (!externalId) {
throw new Error('Portal returned status=OK but empty id');
}
return { external_id: externalId };
}
// ---------------------------------------------------------------------------
// updateOp
// ---------------------------------------------------------------------------
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 },
);
}
// Найти строку таблицы по externalId и кликнуть кнопку редактирования.
// Реальная таблица портала — Vuetify data-table; строки по data-id или текстовому совпадению.
// Стратегия 1: строка с атрибутом data-id
const rowLocator = page.locator(`tr[data-id="${args.externalId}"], [data-id="${args.externalId}"]`);
const rowCount = await rowLocator.count();
if (rowCount > 0) {
await rowLocator.first().locator('button').first().click();
} else {
// Стратегия 2: найти строку содержащую текст externalId и кликнуть edit-кнопку
await page.locator(`tr:has-text("${args.externalId}")`).first().locator('button').first().click();
}
// Дождаться формы
await page.locator('.el-form-item__label[for="name"]').waitFor({
state: 'visible',
timeout: TIMEOUT_MS,
});
await fillForm(page, args.dto);
// Перехватываем ответ rt-project-save при update (тот же endpoint)
const [saveResponse] = await Promise.all([
page.waitForResponse(
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
{ timeout: TIMEOUT_MS },
),
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
]);
const body = await saveResponse.json();
if (body.status !== 'OK') {
throw new Error(`Portal rejected update: ${body.message || 'unknown error'}`);
}
return { ok: true };
}
// ---------------------------------------------------------------------------
// listOp
// ---------------------------------------------------------------------------
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 },
);
}
// Стратегия 1: Vuex state (если доступен)
const projects = await page.evaluate(() => {
try {
if (window.app && window.app.$store && window.app.$store.state) {
const st = window.app.$store.state;
const list = st.projects || st.rtProjects || st.visitProjects || null;
if (Array.isArray(list)) {
return list.map((p) => ({
id: parseInt(p.id, 10),
name: p.name || p.title || null,
platform: p.platform || null,
signal_type: p.type || p.signal_type || null,
unique_key: p.content || p.unique_key || null,
}));
}
}
} catch (_) { /* Vuex недоступен */ }
return null;
});
if (projects !== null) {
return { projects };
}
// Стратегия 2: DOM-скрейп таблицы
// Реальная таблица портала: строки tr с data-id или стандартные td
const rows = await page.locator('table tbody tr[data-id], .v-data-table tbody tr[data-id]').evaluateAll(
(nodes) => nodes.map((n) => ({
id: parseInt(n.dataset.id || '0', 10),
name: n.querySelector('td:nth-child(2)')
? n.querySelector('td:nth-child(2)').textContent.trim()
: null,
})),
);
if (rows.length > 0) {
return { projects: rows };
}
// Стратегия 3: фикстура / пустая страница — возвращаем пустой массив
return { projects: [] };
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
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);
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Фикстурный тест manage-project.js против локального HTTP-сервера с Element UI фикстурой.
*
* Почему HTTP, не file://: manage-project.js перехватывает ответ page.waitForResponse()
* с URL endsWith('/admin/visit/rt-project-save'). Браузер не шлёт network-запросы при
* file://-origin fetch из-за CORS/same-origin ограничений в Chromium.
*
* Runner: встроенный node:test (Node 18+). Запуск: `node --test manage-project.test.js`.
*/
const { test } = require('node:test');
const assert = require('node:assert');
const { execFile } = require('node:child_process');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures', 'rt-form-element-ui.html');
/** Запустить ephemeral HTTP-сервер, отдающий фикстуру и обрабатывающий mock-эндпоинты. */
function startFixtureServer() {
return new Promise((resolve) => {
const html = fs.readFileSync(FIXTURE_PATH, 'utf8');
const server = http.createServer((req, res) => {
// Mock rt-project-save — Playwright перехватывает реальный сетевой запрос
if (req.url && req.url.includes('rt-project-save') && req.method === 'POST') {
// Consume request body (important — don't hang connection)
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const payload = JSON.stringify({ status: 'OK', message: '', result: null, id: '99001' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(payload);
});
return;
}
// Default: serve fixture HTML
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
});
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
/** Спавнить manage-project.js, подать JSON на stdin, вернуть {code, stdout, stderr}. */
function runScript(input) {
return new Promise((resolve, reject) => {
const child = execFile(
'node',
[SCRIPT],
{ timeout: 90_000 },
(err, stdout, stderr) => {
if (err && err.killed) return reject(new Error('Process killed / timed out'));
// err.code — exit code; treat as expected (tests assert on code)
resolve({
code: err ? err.code : 0,
stdout: stdout.toString(),
stderr: stderr.toString(),
});
},
);
child.stdin.write(JSON.stringify(input));
child.stdin.end();
});
}
// ---------------------------------------------------------------------------
// Test 1 — createProject через Element UI фикстуру → external_id из mock-response
// ---------------------------------------------------------------------------
test('createProject fills Element UI form and returns external_id from intercept response', async () => {
const server = await startFixtureServer();
try {
const { port } = server.address();
const url = `http://127.0.0.1:${port}`;
const result = await runScript({
operation: 'create',
url,
skipLogin: true,
dto: {
tag: '_lidpotok',
name: 'example.com',
platforms: ['B1'],
signal_type: 'site',
limit: 5,
workdays: [1, 2, 3, 4, 5],
domains: ['example.com'],
region_mode: 'include',
regions: [],
active: true,
},
});
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
let out;
try {
out = JSON.parse(result.stdout);
} catch (e) {
assert.fail(`stdout is not valid JSON: ${result.stdout}\nstderr: ${result.stderr}`);
}
assert.strictEqual(out.external_id, '99001', `expected external_id "99001", got ${JSON.stringify(out)}`);
} finally {
server.close();
}
});
// ---------------------------------------------------------------------------
// Test 2 — listProjects в skipLogin-режиме возвращает массив projects
// ---------------------------------------------------------------------------
test('listProjects returns array (skipLogin mode, fixture page)', async () => {
const server = await startFixtureServer();
try {
const { port } = server.address();
const url = `http://127.0.0.1:${port}`;
const result = await runScript({
operation: 'list',
url,
skipLogin: true,
});
// listOp в skipLogin-режиме не навигирует на /admin/visit/rt — просто открывает url.
// Фикстура не содержит Vuex и таблицы с проектами → возвращает {projects: []}.
assert.strictEqual(result.code, 0, `Expected exit 0. stderr: ${result.stderr}`);
let out;
try {
out = JSON.parse(result.stdout);
} catch (e) {
assert.fail(`stdout is not valid JSON: ${result.stdout}`);
}
assert.ok(Array.isArray(out.projects), `expected projects array, got: ${JSON.stringify(out)}`);
} finally {
server.close();
}
});
+26 -7
View File
@@ -27,17 +27,36 @@ 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);
await page.fill(passwordSelector, args.password);
await Promise.all([
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
page.click(submitSelector),
]);
// Сабмит + ОЖИДАНИЕ пост-логин перехода.
// Старый Promise.all([waitForLoadState('networkidle'), click]) — гонка:
// логин-страница уже в состоянии networkidle, поэтому waitForLoadState
// резолвился мгновенно (ДО редиректа), и скрипт хватал PHPSESSID
// неаутентифицированной логин-страницы. Ждём, пока логин-форма исчезнет
// из DOM — waitForFunction опрашивает и переживает навигацию.
await page.click(submitSelector);
await page
.waitForFunction(
(sel) => !document.querySelector(sel),
loginSelector,
{ timeout: TIMEOUT_MS },
)
.catch(() => { /* форма осталась — логин отклонён, ловится guard'ом ниже */ });
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {});
// Verify: логин-форма всё ещё на странице → вход НЕ удался. Не возвращаем
// мусорную (неаутентифицированную) сессию как «успех» (exit 0).
if ((await page.locator(loginSelector).count()) > 0) {
process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' }));
process.exit(1);
}
let csrf = null;
try {
+32
View File
@@ -130,6 +130,26 @@ export async function exportDealsXlsx(payload: Omit<ExportDealsPayload, 'format'
return data;
}
export interface ExportDealsByRangePayload {
tenant_id: number;
received_from?: string;
received_to?: string;
format: 'csv' | 'xlsx';
}
/**
* Экспорт сделок по диапазону дат поставки. format='xlsx' Blob, 'csv' строка.
*/
export async function exportDealsByRange(payload: ExportDealsByRangePayload): Promise<Blob | string> {
await ensureCsrfCookie();
if (payload.format === 'xlsx') {
const { data } = await apiClient.post<Blob>('/api/deals/export', payload, { responseType: 'blob' });
return data;
}
const { data } = await apiClient.post<string>('/api/deals/export', payload, { responseType: 'text' });
return data;
}
export interface ApiDeal {
id: number;
tenant_id: number;
@@ -142,6 +162,13 @@ export interface ApiDeal {
manager_name: string | null;
manager_initials: string | null;
received_at: string | null;
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;
}
export interface ApiDealEvent {
@@ -175,6 +202,9 @@ export interface ListDealsParams {
projectId?: number;
managerId?: number;
search?: string;
/** Диапазон дат поставки (received_at). ISO-дата 'YYYY-MM-DD'. */
receivedFrom?: string;
receivedTo?: string;
limit?: number;
offset?: number;
/** «Корзина» — вернуть ТОЛЬКО soft-deleted сделки. */
@@ -196,6 +226,8 @@ export async function listDeals(params: ListDealsParams): Promise<ListDealsRespo
project_id: params.projectId,
manager_id: params.managerId,
search: params.search,
received_from: params.receivedFrom,
received_to: params.receivedTo,
limit: params.limit,
offset: params.offset,
only_deleted: params.onlyDeleted ? 'true' : undefined,
@@ -14,6 +14,7 @@
import { computed, onMounted, ref } from 'vue';
import { impersonationActive, type ImpersonationActiveSession } from '../../api/admin';
import { usePolling } from '../../composables/usePolling';
import { POLLING_INTERVAL_MS } from '../../constants/polling';
const sessions = ref<ImpersonationActiveSession[]>([]);
@@ -37,7 +38,7 @@ const label = computed(() => {
});
onMounted(load);
usePolling(load, { intervalMs: 30_000 });
usePolling(load, { intervalMs: POLLING_INTERVAL_MS });
defineExpose({ sessions, load });
</script>
@@ -5,7 +5,7 @@
* 3-step state-machine:
* 1. 'reason' textarea для основания (30 chars) POST /api/admin/impersonation/init.
* 2. 'verify' показ email клиента + ввод 6-значного кода /api/admin/impersonation/verify.
* На dev показывается _dev_plain_code (на prod исчезнет после MailService).
* На dev показывается _dev_plain_code (за import.meta.env.DEV; на prod баннер не рендерится).
* 3. 'active' chip «Сессия активна», кнопка «Завершить» /api/admin/impersonation/end.
*
* NB: на MVP saas-admin auth не реализован, requested_by передаётся параметром
@@ -49,6 +49,10 @@ const expiresAt = ref<string | null>(null);
const devPlainCode = ref<string | null>(null);
const usedAtIso = ref<string | null>(null);
// I4: явный frontend DEV-gate. import.meta.env.DEV статически заменяется Vite
// в prod-сборке = false, баннер с плейн-кодом tree-shake'ится.
const isDevEnv = import.meta.env.DEV;
const reasonLength = computed(() => reason.value.trim().length);
const reasonRemaining = computed(() => Math.max(0, 30 - reasonLength.value));
const reasonValid = computed(() => reasonLength.value >= 30);
@@ -216,7 +220,7 @@ function close() {
data-testid="code-input"
/>
<v-alert
v-if="devPlainCode"
v-if="isDevEnv && devPlainCode"
type="success"
variant="tonal"
density="compact"
@@ -24,11 +24,11 @@ import FunnelChart from './FunnelChart.vue';
</v-app>
</Variant>
<Variant title="концентрация на 'Оплачено'">
<Variant title="концентрация на 'Сделка'">
<v-app>
<v-main class="story-pane">
<v-container>
<FunnelChart :counts="{ paid: 100, new: 5, viewed: 5, worked: 5 }" />
<FunnelChart :counts="{ won: 100, new: 5, viewed: 5, in_progress: 5 }" />
</v-container>
</v-main>
</v-app>
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Воронка распределения лидов по 14 статусам.
* Воронка распределения лидов по 5 статусам воронки.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
* с #funnel-title (segmented bar + funnel-list).
@@ -13,7 +13,7 @@
* Рендер:
* 1. Segmented horizontal bar каждый сегмент пропорционален count'у статуса
* и закрашен colorHex из lead_statuses.
* 2. funnel-list 14 строк с цветным dot + name + count, отсортированы по
* 2. funnel-list 5 строк с цветным dot + name + count, отсортированы по
* убыванию count'а (как в handoff).
*/
import { computed } from 'vue';
@@ -26,23 +26,14 @@ interface Props {
// Default counts инлайнятся в withDefaults Vue SFC compiler требует чтобы
// factory-функция в withDefaults не реферировала модуль-уровневые const'ы
// (checkInvalidScopeReference). Mock-распределение ~247 лидов по 14 статусам.
// (checkInvalidScopeReference). Mock-распределение ~190 лидов по 5 статусам.
const props = withDefaults(defineProps<Props>(), {
counts: () => ({
new: 18,
viewed: 14,
worked: 22,
base: 9,
missed: 16,
negotiations: 11,
waiting_payment: 7,
partnership: 4,
paid: 45,
closed: 3,
test_drive: 38,
hot: 5,
replacement: 5,
final_missed: 39,
new: 24,
viewed: 18,
in_progress: 96,
won: 41,
lost: 11,
}),
title: 'Воронка',
});
@@ -0,0 +1,374 @@
<script setup lang="ts">
/**
* Тело панели деталей сделки (hero + параметры + комментарий + напоминания +
* timeline). Извлечено из DealDetailDrawer (редизайн 2026-05-17) общее тело
* для overlay-дровера (Канбан) и inline-панели master-detail («Сделки»).
*
* Backend: GET /api/deals/{id}, PATCH /api/deals/{id}, GET /api/deals/{id}/events.
*/
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';
import { useLeadStatusesStore } from '../../stores/leadStatuses';
import DealDetailHero from './DealDetailHero.vue';
import DealDetailTimeline from './DealDetailTimeline.vue';
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
const leadStatusesStore = useLeadStatusesStore();
const props = defineProps<{
deal: MockDeal | null;
tenantId?: number;
}>();
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;
return leadStatusesStore.findBySlug(props.deal.statusSlug);
});
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);
const commentDraft = ref<string>('');
const commentSaving = ref(false);
const commentSaveError = ref(false);
const commentToastOpen = ref(false);
const commentToastText = ref('');
const reminders = ref<ApiReminder[]>([]);
const remindersLoading = ref(false);
const reminderDialogOpen = ref(false);
async function loadReminders() {
if (!props.deal || !props.tenantId) {
reminders.value = [];
return;
}
remindersLoading.value = true;
try {
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
reminders.value = res.items;
} catch {
reminders.value = [];
} finally {
remindersLoading.value = false;
}
}
async function completeReminderInDrawer(id: number) {
try {
await remindersApi.completeReminder(id);
reminders.value = reminders.value.filter((r) => r.id !== id);
} catch {
/* silent */
}
}
function onReminderSaved() {
void loadReminders();
}
function formatReminderTime(iso: string | null): string {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
const min = Math.round(Math.abs(ms) / 60_000);
const future = ms > 0;
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
const hr = Math.round(min / 60);
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
const days = Math.round(hr / 24);
return future ? `через ${days} д` : `${days} д назад`;
}
async function loadEvents() {
if (!props.deal || !props.tenantId) {
events.value = [];
commentDraft.value = '';
return;
}
eventsLoading.value = true;
eventsFetchError.value = false;
try {
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
events.value = res.events.map((e) => mapApiDealEvent(e));
commentDraft.value = res.deal.comment ?? '';
} catch {
eventsFetchError.value = true;
events.value = [];
commentDraft.value = '';
} finally {
eventsLoading.value = false;
}
}
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;
commentSaveError.value = false;
try {
await dealsApi.updateDeal(props.deal.id, {
tenant_id: props.tenantId,
comment: commentDraft.value || null,
});
commentToastText.value = 'Комментарий сохранён.';
commentToastOpen.value = true;
await loadEvents();
} catch {
commentSaveError.value = true;
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
commentToastOpen.value = true;
} finally {
commentSaving.value = false;
}
}
// Загрузка при появлении/смене сделки. Компонент смонтирован всегда тело (<div v-if="deal">) рендерится только при deal != null.
watch(
() => [props.deal?.id, props.tenantId] as const,
() => {
if (props.deal) {
loadEvents();
void loadReminders();
}
},
{ immediate: true },
);
defineExpose({
events, eventsLoading, eventsFetchError, loadEvents,
commentDraft, commentSaving, commentSaveError, commentToastOpen, commentToastText, saveComment,
});
</script>
<template>
<div v-if="deal" class="drawer-content">
<DealDetailHero
:deal="deal"
:status="status"
:all-statuses="leadStatusesStore.statuses"
@close="emit('close')"
@change-status="onStatusChange"
/>
<v-divider />
<section class="section pa-5">
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
<dl class="params">
<div class="param">
<dt class="text-caption text-medium-emphasis">Проект</dt>
<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">{{ projectTypeLabel }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Источник</dt>
<dd class="text-body-2">{{ projectSourceLabel }}</dd>
</div>
</dl>
</section>
<v-divider />
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
<v-textarea
v-model="commentDraft"
placeholder="Заметка менеджера…"
variant="outlined"
density="comfortable"
auto-grow
rows="3"
hide-details
counter="5000"
data-testid="comment-textarea"
/>
<div class="d-flex ga-2 mt-2 justify-end">
<v-btn
:loading="commentSaving"
color="primary"
size="small"
prepend-icon="mdi-content-save-outline"
data-testid="save-comment-btn"
@click="saveComment"
>
Сохранить
</v-btn>
</div>
</section>
<v-divider v-if="tenantId" />
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
<v-btn
size="x-small"
variant="text"
prepend-icon="mdi-plus"
data-testid="add-reminder-btn"
@click="reminderDialogOpen = true"
>
Создать
</v-btn>
</div>
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
Нет активных напоминаний.
</div>
<ul v-else class="reminders-list">
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
<v-btn
icon="mdi-check-circle-outline"
size="x-small"
variant="text"
density="comfortable"
:data-testid="`drawer-complete-${r.id}`"
@click="completeReminderInDrawer(r.id)"
/>
<div class="reminder-body">
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
<div class="reminder-meta text-caption text-medium-emphasis">
{{ formatReminderTime(r.remind_at) }}
</div>
</div>
</li>
</ul>
</section>
<v-divider v-if="tenantId && deal" />
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
<v-snackbar
v-model="commentToastOpen"
:timeout="3000"
:color="commentSaveError ? 'warning' : undefined"
data-testid="comment-toast"
location="bottom right"
>
{{ commentToastText }}
</v-snackbar>
<ReminderDialog
v-if="tenantId && deal"
v-model="reminderDialogOpen"
:deal-id="deal.id"
@saved="onReminderSaved"
/>
</div>
</template>
<style scoped>
.drawer-content {
display: flex;
flex-direction: column;
}
.section-title {
font-weight: 600;
color: #081319;
}
.params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 12px;
margin: 0;
}
.param dt {
font-size: 11px;
margin-bottom: 2px;
}
.param dd {
margin: 0;
color: #081319;
}
.param .link {
color: #0f6e56;
cursor: pointer;
}
.param .link:hover {
text-decoration: underline;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.reminders-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.reminder-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid #e8e3d6;
border-radius: 6px;
background: #fdfaf3;
}
.reminder-body {
flex: 1;
min-width: 0;
}
.reminder-text {
font-size: 13px;
line-height: 1.4;
color: #081319;
}
.reminder-meta {
margin-top: 2px;
}
</style>
@@ -7,7 +7,7 @@ const open1 = ref(true);
const open2 = ref(true);
const dealNew = MOCK_DEALS.find((d) => d.statusSlug === 'new')!;
const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
const dealWon = MOCK_DEALS.find((d) => d.statusSlug === 'won')!;
</script>
<template>
@@ -20,10 +20,10 @@ const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
</v-app>
</Variant>
<Variant title="paid status">
<Variant title="won status">
<v-app>
<v-main class="story-main">
<DealDetailDrawer v-model:open="open2" :deal="dealPaid" />
<DealDetailDrawer v-model:open="open2" :deal="dealWon" />
</v-main>
</v-app>
</Variant>
@@ -1,310 +1,62 @@
<script setup lang="ts">
/**
* Правая панель с деталями сделки. Открывается при click на строку в DealsView
* или на карточку в KanbanView.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_deal_card.html.
* MVP: hero (имя + телефон + статус-chip + close), параметры (Проект/Стоимость/
* Источник/Email), Activity timeline (5-7 событий).
*
* Не входит в этот коммит:
* - Редактирование параметров (input-fields + save).
* - Смена статуса через dropdown (на Канбане через DnD).
* - Tag management, manager assignment, reminders, comment/templates
* отдельные секции, отдельные коммиты.
*
* Backend:
* - GET /api/deals/{id} full detail with events.
* - PATCH /api/deals/{id} частичное обновление полей.
* - GET /api/deals/{id}/events `activity_log` фильтр по deal_id.
* Обёртка панели деталей сделки. `inline=false` (по умолчанию) overlay
* v-navigation-drawer (Канбан). `inline=true` боковая панель master-detail
* для страницы «Сделки» (список сжимается, панель встаёт рядом, не перекрывает).
* Тело общий DealDetailBody.vue.
*/
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { computed } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent, MOCK_EVENTS } from '../../composables/mockDealEvents';
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
import * as dealsApi from '../../api/deals';
import * as remindersApi from '../../api/reminders';
import type { ApiReminder } from '../../api/reminders';
import { useLeadStatusesStore } from '../../stores/leadStatuses';
import DealDetailHero from './DealDetailHero.vue';
import DealDetailTimeline from './DealDetailTimeline.vue';
// Sprint 2 Phase B / O-perf-06: ReminderDialog гейтится через v-model chunk-split.
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
import DealDetailBody from './DealDetailBody.vue';
const leadStatusesStore = useLeadStatusesStore();
const props = withDefaults(
defineProps<{
open: boolean;
deal: MockDeal | null;
tenantId?: number;
inline?: boolean;
}>(),
{ inline: false },
);
const props = defineProps<{
open: boolean;
deal: MockDeal | null;
tenantId?: number;
const emit = defineEmits<{
'update:open': [value: boolean];
'status-changed': [slug: string];
}>();
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
const drawerOpen = computed({
get: () => props.open,
set: (v) => emit('update:open', v),
});
const status = computed(() => {
if (!props.deal) return null;
return leadStatusesStore.findBySlug(props.deal.statusSlug);
});
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
function close() {
emit('update:open', false);
}
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
// показываем реальные events. На fail / без tenant_id fallback на MOCK_EVENTS.
const events = ref<DealEvent[]>([...MOCK_EVENTS]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
// Comment editor редактирование текущего комментария сделки.
const commentDraft = ref<string>('');
const commentSaving = ref(false);
const commentSaveError = ref(false);
const commentToastOpen = ref(false);
const commentToastText = ref('');
// Reminders на сделку отдельная секция с inline-create + список.
const reminders = ref<ApiReminder[]>([]);
const remindersLoading = ref(false);
const reminderDialogOpen = ref(false);
async function loadReminders() {
if (!props.deal || !props.tenantId) {
reminders.value = [];
return;
}
remindersLoading.value = true;
try {
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
reminders.value = res.items;
} catch {
reminders.value = [];
} finally {
remindersLoading.value = false;
}
}
async function completeReminderInDrawer(id: number) {
try {
await remindersApi.completeReminder(id);
reminders.value = reminders.value.filter((r) => r.id !== id);
} catch {
/* silent */
}
}
function onReminderSaved() {
void loadReminders();
}
function formatReminderTime(iso: string | null): string {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
const min = Math.round(Math.abs(ms) / 60_000);
const future = ms > 0;
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
const hr = Math.round(min / 60);
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
const days = Math.round(hr / 24);
return future ? `через ${days} д` : `${days} д назад`;
}
async function loadEvents() {
if (!props.deal || !props.tenantId) {
events.value = [...MOCK_EVENTS];
commentDraft.value = '';
return;
}
eventsLoading.value = true;
eventsFetchError.value = false;
try {
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
events.value = res.events.map((e) => mapApiDealEvent(e));
commentDraft.value = res.deal.comment ?? '';
} catch {
eventsFetchError.value = true;
events.value = [...MOCK_EVENTS];
commentDraft.value = '';
} finally {
eventsLoading.value = false;
}
}
async function saveComment() {
if (!props.deal || !props.tenantId) return;
commentSaving.value = true;
commentSaveError.value = false;
try {
await dealsApi.updateDeal(props.deal.id, {
tenant_id: props.tenantId,
comment: commentDraft.value || null,
});
commentToastText.value = 'Комментарий сохранён.';
commentToastOpen.value = true;
// Reload events чтобы показать новый deal.commented в timeline.
await loadEvents();
} catch {
commentSaveError.value = true;
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
commentToastOpen.value = true;
} finally {
commentSaving.value = false;
}
}
// Fetch при открытии drawer'а или смене сделки.
watch(
() => [props.open, props.deal?.id, props.tenantId] as const,
([open]) => {
if (open) {
loadEvents();
void loadReminders();
}
},
{ immediate: true },
);
defineExpose({
events,
eventsLoading,
eventsFetchError,
loadEvents,
commentDraft,
commentSaving,
commentSaveError,
commentToastOpen,
commentToastText,
saveComment,
});
</script>
<template>
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
<div v-if="deal" class="drawer-content">
<DealDetailHero :deal="deal" :status="status" @close="drawerOpen = false" />
<v-divider />
<section class="section pa-5">
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
<dl class="params">
<div class="param">
<dt class="text-caption text-medium-emphasis">Проект</dt>
<dd class="text-body-2">{{ 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>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Источник</dt>
<dd class="text-body-2 link">Я.Директ landing-1</dd>
</div>
</dl>
</section>
<v-divider />
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
<v-textarea
v-model="commentDraft"
placeholder="Заметка менеджера…"
variant="outlined"
density="comfortable"
auto-grow
rows="3"
hide-details
counter="5000"
data-testid="comment-textarea"
/>
<div class="d-flex ga-2 mt-2 justify-end">
<v-btn
:loading="commentSaving"
color="primary"
size="small"
prepend-icon="mdi-content-save-outline"
data-testid="save-comment-btn"
@click="saveComment"
>
Сохранить
</v-btn>
</div>
</section>
<v-divider v-if="tenantId" />
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
<v-btn
size="x-small"
variant="text"
prepend-icon="mdi-plus"
data-testid="add-reminder-btn"
@click="reminderDialogOpen = true"
>
Создать
</v-btn>
</div>
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
Нет активных напоминаний.
</div>
<ul v-else class="reminders-list">
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
<v-btn
icon="mdi-check-circle-outline"
size="x-small"
variant="text"
density="comfortable"
:data-testid="`drawer-complete-${r.id}`"
@click="completeReminderInDrawer(r.id)"
/>
<div class="reminder-body">
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
<div class="reminder-meta text-caption text-medium-emphasis">
{{ formatReminderTime(r.remind_at) }}
</div>
</div>
</li>
</ul>
</section>
<v-divider v-if="tenantId && deal" />
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
<v-snackbar
v-model="commentToastOpen"
:timeout="3000"
:color="commentSaveError ? 'warning' : undefined"
data-testid="comment-toast"
location="bottom right"
>
{{ commentToastText }}
</v-snackbar>
<ReminderDialog
v-if="tenantId && deal"
v-model="reminderDialogOpen"
:deal-id="deal.id"
@saved="onReminderSaved"
/>
</div>
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@close="close"
@status-changed="(s: string) => emit('status-changed', s)"
/>
</aside>
<v-navigation-drawer
v-else
v-model="drawerOpen"
location="right"
temporary
:width="480"
class="deal-drawer"
>
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@close="close"
@status-changed="(s: string) => emit('status-changed', s)"
/>
</v-navigation-drawer>
</template>
@@ -312,75 +64,16 @@ defineExpose({
.deal-drawer {
background: #fff;
}
.drawer-content {
display: flex;
flex-direction: column;
}
.section-title {
font-weight: 600;
color: #081319;
}
.params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 12px;
margin: 0;
}
.param dt {
font-size: 11px;
margin-bottom: 2px;
}
.param dd {
margin: 0;
color: #081319;
}
.param .link {
color: #0f6e56;
cursor: pointer;
}
.param .link:hover {
text-decoration: underline;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.reminders-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.reminder-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
.deal-detail-inline {
flex: 0 0 400px;
width: 400px;
background: #fff;
border: 1px solid #e8e3d6;
border-radius: 6px;
background: #fdfaf3;
}
.reminder-body {
flex: 1;
min-width: 0;
}
.reminder-text {
font-size: 13px;
line-height: 1.4;
color: #081319;
}
.reminder-meta {
margin-top: 2px;
border-radius: 8px;
overflow-y: auto;
align-self: flex-start;
max-height: calc(100vh - 160px);
position: sticky;
top: 16px;
}
</style>
@@ -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>
@@ -1,20 +1,13 @@
<script setup lang="ts">
/**
* Sticky-bar bulk-actions для выбранных сделок (Sprint 3 Phase C).
*
* Показывается когда selectedCount > 0. В trash-mode только кнопка
* «Восстановить»; в обычном режиме Сменить статус (menu со списком),
* Экспорт, Удалить.
*
* Контракт: stateless presentation родитель держит `selected`, `statusMenuOpen`,
* `leadStatuses`, передаёт через props и слушает emit'ы.
* Sticky-bar массовой смены статуса для выбранных сделок (редизайн 2026-05-17).
* Только смена статуса корзина/экспорт убраны (экспорт панель по датам).
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
defineProps<{
selectedCount: number;
trashMode: boolean;
statusMenuOpen: boolean;
leadStatuses: LeadStatus[];
}>();
@@ -22,9 +15,6 @@ defineProps<{
defineEmits<{
'update:statusMenuOpen': [value: boolean];
'apply-status': [slug: MockDeal['statusSlug']];
'apply-export': [];
'request-delete': [];
'apply-restore-trash': [];
'clear-selected': [];
}>();
</script>
@@ -39,73 +29,38 @@ defineEmits<{
data-testid="bulk-bar"
>
<div class="bulk-bar-inner">
<span class="bulk-count">
Выбрано <span class="num">{{ selectedCount }}</span>
</span>
<span class="bulk-count">Выбрано <span class="num">{{ selectedCount }}</span></span>
<v-spacer />
<!-- В trash-mode только Восстановить; в обычном режиме полный набор. -->
<v-btn
v-if="trashMode"
variant="tonal"
color="success"
size="small"
prepend-icon="mdi-restore"
data-testid="bulk-restore-trash-btn"
@click="$emit('apply-restore-trash')"
<v-menu
:model-value="statusMenuOpen"
:close-on-content-click="false"
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
>
Восстановить
</v-btn>
<template v-if="!trashMode">
<v-menu
:model-value="statusMenuOpen"
:close-on-content-click="false"
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
variant="tonal"
size="small"
prepend-icon="mdi-tag-arrow-right"
data-testid="bulk-status-btn"
>
Сменить статус
</v-btn>
</template>
<v-list density="compact" max-height="320" min-width="240">
<v-list-item
v-for="s in leadStatuses"
:key="s.slug"
:data-testid="`bulk-status-item-${s.slug}`"
@click="$emit('apply-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>
<v-btn
variant="tonal"
size="small"
prepend-icon="mdi-download"
data-testid="bulk-export-btn"
@click="$emit('apply-export')"
>
Экспорт
</v-btn>
<v-btn
variant="tonal"
color="error"
size="small"
prepend-icon="mdi-trash-can-outline"
data-testid="bulk-delete-btn"
@click="$emit('request-delete')"
>
Удалить
</v-btn>
</template>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
variant="tonal"
size="small"
prepend-icon="mdi-tag-arrow-right"
data-testid="bulk-status-btn"
>
Сменить статус
</v-btn>
</template>
<v-list density="compact" max-height="320" min-width="240">
<v-list-item
v-for="s in leadStatuses"
:key="s.slug"
:data-testid="`bulk-status-item-${s.slug}`"
@click="$emit('apply-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>
<v-btn
icon="mdi-close"
variant="text"
@@ -123,7 +78,6 @@ defineEmits<{
font-feature-settings: 'tnum';
font-weight: 500;
}
.status-dot {
display: inline-block;
width: 6px;
@@ -131,7 +85,6 @@ defineEmits<{
border-radius: 50%;
margin-right: 6px;
}
.bulk-bar {
position: sticky;
top: 0;
@@ -1,123 +1,114 @@
<script setup lang="ts">
/**
* Filter-bar для DealsView (Sprint 3 Phase C):
* - btn-toggle с DEALS_TABS (active/all/...) + chip-counts
* - search input (имя/телефон/проект)
* - multi-select Проект и Менеджер
* - кнопка «Сбросить фильтры» (если хоть один из multi-select заполнен)
*
* Состояние держится в родителе через v-model:* (двунаправленные связки).
* Фильтр-бар реестра «Сделки»: поиск по телефону + 3 select'а (Статус, Проект,
* Город). Состояние держит родитель через v-model:*. Город пока без данных
* (источник §4 спеки не определён): select disabled при пустом availableCities.
*/
import { DEALS_TABS } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
defineProps<{
activeTab: (typeof DEALS_TABS)[number]['id'];
searchQuery: string;
filterProjects: string[];
filterManagers: string[];
availableProjects: string[];
availableManagers: { name: string; initials: string }[];
counts: Record<string, number>;
const props = defineProps<{
searchPhone: string;
filterStatus: string | null;
filterProject: number | null;
filterCity: string | null;
leadStatuses: LeadStatus[];
availableProjects: { id: number; name: string }[];
availableCities: string[];
}>();
defineEmits<{
'update:activeTab': [value: (typeof DEALS_TABS)[number]['id']];
'update:searchQuery': [value: string];
'update:filterProjects': [value: string[]];
'update:filterManagers': [value: string[]];
'update:searchPhone': [value: string];
'update:filterStatus': [value: string | null];
'update:filterProject': [value: number | null];
'update:filterCity': [value: string | null];
'clear-filters': [];
}>();
const hasActiveFilter = () =>
props.filterStatus !== null || props.filterProject !== null || props.filterCity !== null;
</script>
<template>
<div class="filter-bar mt-4">
<v-btn-toggle
:model-value="activeTab"
mandatory
color="primary"
density="comfortable"
variant="outlined"
@update:model-value="(v: (typeof DEALS_TABS)[number]['id']) => $emit('update:activeTab', v)"
>
<v-btn v-for="tab in DEALS_TABS" :key="tab.id" :value="tab.id" size="small">
{{ tab.label }}
<v-chip size="x-small" class="ml-2 chip-count" variant="tonal">
{{ counts[tab.id] }}
</v-chip>
</v-btn>
</v-btn-toggle>
<div class="deals-filters">
<v-text-field
:model-value="searchQuery"
placeholder="Поиск: имя, телефон, проект…"
:model-value="searchPhone"
placeholder="Поиск по телефону…"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
class="search-input ml-4"
@update:model-value="(v: string) => $emit('update:searchQuery', v ?? '')"
class="filters-search"
data-testid="filter-search-phone"
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
/>
<v-select
:model-value="filterProjects"
:model-value="filterStatus"
:items="leadStatuses"
item-title="nameRu"
item-value="slug"
label="Статус"
variant="outlined"
density="compact"
hide-details
clearable
class="filters-select"
data-testid="filter-status"
@update:model-value="(v: string | null) => $emit('update:filterStatus', v ?? null)"
/>
<v-select
:model-value="filterProject"
:items="availableProjects"
multiple
chips
closable-chips
clearable
item-title="name"
item-value="id"
label="Проект"
variant="outlined"
density="compact"
hide-details
label="Проект"
style="min-width: 180px; max-width: 260px"
data-testid="filter-projects"
@update:model-value="(v: string[]) => $emit('update:filterProjects', v ?? [])"
clearable
class="filters-select"
data-testid="filter-project"
@update:model-value="(v: number | null) => $emit('update:filterProject', v ?? null)"
/>
<v-select
:model-value="filterManagers"
:items="availableManagers"
item-title="name"
item-value="name"
multiple
chips
closable-chips
clearable
:model-value="filterCity"
:items="availableCities"
label="Город"
variant="outlined"
density="compact"
hide-details
label="Менеджер"
style="min-width: 180px; max-width: 260px"
data-testid="filter-managers"
@update:model-value="(v: string[]) => $emit('update:filterManagers', v ?? [])"
clearable
:disabled="availableCities.length === 0"
class="filters-select"
data-testid="filter-city"
@update:model-value="(v: string | null) => $emit('update:filterCity', v ?? null)"
/>
<v-btn
v-if="filterProjects.length > 0 || filterManagers.length > 0"
v-if="hasActiveFilter()"
variant="text"
size="small"
prepend-icon="mdi-filter-off"
data-testid="clear-filters-btn"
@click="$emit('clear-filters')"
>
Сбросить фильтры
Сбросить
</v-btn>
</div>
</template>
<style scoped>
.filter-bar {
.deals-filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-input {
flex: 1 1 320px;
max-width: 360px;
.filters-search {
flex: 1 1 240px;
max-width: 320px;
}
.chip-count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
.filters-select {
min-width: 170px;
max-width: 220px;
}
</style>
@@ -1,32 +1,22 @@
<script setup lang="ts">
/**
* Таблица сделок (Sprint 3 Phase C extraction из DealsView).
*
* Логически замкнутый блок: v-data-table со всеми типизированными слотами
* (Vuetify 3.12 VDataTableSlots, Sprint 2 Phase B / O-stack-05).
*
* Контракт:
* props:
* - deals: MockDeal[] отфильтрованный список (computed в родителе).
* - selectedIds: number[] v-model:selected (двунаправленно).
* - statusBySlug: Map<string, LeadStatus> для status-chip color/label.
* emits:
* - update:selectedIds sync v-model selected с родителем.
* - row-click(deal) раскрыть drawer.
* Таблица реестра лидов «Сделки» (редизайн 2026-05-17).
* Колонки: чекбокс · Телефон · Источник · Город · Статус · Напоминание ·
* Комментарий · Поставлен. Напоминание/Комментарий read-only.
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import { stripChannelPrefix } from '../../composables/projectName';
import StatusPill from '../ui/StatusPill.vue';
withDefaults(
const props = withDefaults(
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
// Task 15: row height from density toggle (44 comfortable / 36 compact).
rowHeight?: number;
activeDealId?: number | null;
}>(),
{ rowHeight: 44 },
{ activeDealId: null },
);
const emit = defineEmits<{
@@ -34,18 +24,22 @@ const emit = defineEmits<{
'row-click': [deal: MockDeal];
}>();
function onSelectedUpdate(value: number[]) {
emit('update:selectedIds', value);
const SIGNAL_LABELS: Record<string, string> = { call: 'Звонки', site: 'Сайт', sms: 'СМС' };
function signalLabel(t: MockDeal['signalType']): string {
return t ? (SIGNAL_LABELS[t] ?? '') : '';
}
function formatRelative(minutes: number): string {
if (minutes < 60) return `${minutes} мин назад`;
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
return `${Math.floor(minutes / (60 * 24))} д назад`;
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
}).format(d);
}
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + '';
function rowProps(deal: MockDeal): Record<string, unknown> {
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
}
</script>
@@ -55,72 +49,61 @@ function formatCost(cost: number): string {
:model-value="selectedIds"
:items="deals"
:headers="[
{ title: 'Лид', key: 'name', sortable: true },
{ title: 'Телефон', key: 'phone', sortable: true },
{ title: 'Источник', key: 'project', sortable: false },
{ title: 'Город', key: 'city', sortable: false },
{ title: 'Статус', key: 'statusSlug', sortable: false },
{ title: 'Проект', key: 'project', sortable: false },
{ title: 'Менеджер', key: 'manager', sortable: false },
{ title: 'Стоимость', key: 'cost', align: 'end', sortable: true },
{ title: 'Время', key: 'receivedMinutesAgo', align: 'end', sortable: true },
{ title: 'Напоминание', key: 'nextReminderAt', sortable: true },
{ title: 'Комментарий', key: 'comment', sortable: false },
{ title: 'Поставлен', key: 'receivedAt', align: 'end', sortable: true },
]"
show-select
item-value="id"
items-per-page="-1"
hide-default-footer
hover
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
@update:model-value="onSelectedUpdate"
:row-props="(p: { item: MockDeal }) => rowProps(p.item)"
@update:model-value="(v: number[]) => emit('update:selectedIds', v)"
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
<!--
Vuetify 3.12 типизированные слоты VDataTable (Sprint 2 Phase B / O-stack-05).
`:items="deals"` (MockDeal[]) Vuetify через VDataTableSlots<ItemType<T>>
выводит `item` как `MockDeal` автоматически. Дополнительная inline-аннотация
`{ item }: { item: MockDeal }` фиксирует этот контракт явно IDE и vue-tsc
проверяют доступ к полям статически.
-->
<template #[`item.name`]="{ item }: { item: MockDeal }">
<div class="cell-deal">
<v-avatar size="32" color="primary" class="mr-3">
<span class="text-caption font-weight-medium">{{
item.name
.split(' ')
.map((p: string) => p[0])
.join('')
.slice(0, 2)
}}</span>
</v-avatar>
<div>
<div class="deal-name">{{ item.name }}</div>
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
</div>
<template #[`item.phone`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ item.phone }}</span>
</template>
<template #[`item.project`]="{ item }: { item: MockDeal }">
<div class="cell-source">
<span class="source-project">{{ stripChannelPrefix(item.project) }}</span>
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
signalLabel(item.signalType)
}}</span>
</div>
</template>
<template #[`item.city`]="{ item }: { item: MockDeal }">
<span :class="{ 'text-medium-emphasis': !item.city }">{{ item.city || '—' }}</span>
</template>
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
<StatusPill
:slug="item.statusSlug"
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
/>
</template>
<template #[`item.manager`]="{ item }: { item: MockDeal }">
<div class="cell-manager">
<v-avatar size="22" color="secondary" class="mr-2">
<span class="text-caption">{{ item.manager.initials }}</span>
</v-avatar>
{{ item.manager.name }}
</div>
<template #[`item.nextReminderAt`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s" :class="{ 'text-medium-emphasis': !item.nextReminderAt }">{{
formatDateTime(item.nextReminderAt)
}}</span>
</template>
<template #[`item.cost`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
<template #[`item.comment`]="{ item }: { item: MockDeal }">
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
item.comment || '—'
}}</span>
</template>
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s">{{ formatDateTime(item.receivedAt) }}</span>
</template>
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
@@ -135,8 +118,8 @@ function formatCost(cost: number): string {
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).name}»`"
@update:model-value="(v: boolean | null) => toggleSelect(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
@update:model-value="() => toggleSelect(internalItem)"
/>
</template>
</v-data-table>
@@ -151,34 +134,32 @@ function formatCost(cost: number): string {
.deals-table-card {
background: #fff;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.cell-deal {
.cell-source {
display: flex;
align-items: center;
padding: 6px 0;
flex-direction: column;
line-height: 1.3;
}
.deal-name {
.source-project {
font-weight: 500;
color: #081319;
}
.cell-manager {
display: flex;
align-items: center;
.source-signal {
font-size: 11px;
color: #6b6356;
}
.status-dot {
.cell-comment {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
:deep(.deals-row-active) {
background: rgba(15, 110, 86, 0.07);
}
</style>
@@ -14,15 +14,16 @@ import * as dealsApi from '../../api/deals';
import { extractErrorMessage } from '../../api/client';
import { ref, watch } from 'vue';
import { LEAD_STATUSES } from '../../composables/leadStatuses';
import { MOCK_MANAGERS, MOCK_PROJECTS, type MockDeal, type MockManager } from '../../composables/mockDeals';
import { type MockDeal, type MockManager } from '../../composables/mockDeals';
/**
* Управление source для проектов и менеджеров. Если tenantId передан, загружаем
* с backend через GET /api/projects, /api/managers. На fail (network)
* fallback на MOCK_PROJECTS/MOCK_MANAGERS (UI всё равно работоспособен).
* Списки проектов и менеджеров грузятся с backend через GET /api/projects,
* /api/managers при открытии диалога (если передан tenantId). На fail
* списки пустые + degradation-alert (lookupsFailed), создание блокируется
* до повторной успешной загрузки.
*/
const projectOptions = ref<string[]>([...MOCK_PROJECTS]);
const managerOptions = ref<MockManager[]>([...MOCK_MANAGERS]);
const projectOptions = ref<string[]>([]);
const managerOptions = ref<MockManager[]>([]);
// Map name backend-id, нужен только когда manager_id отправляется на backend.
const managerIdByName = ref<Map<string, number>>(new Map());
@@ -77,7 +78,7 @@ const errors = ref<Record<string, string>>({});
const submitError = ref<string | null>(null);
const busy = ref(false);
// Audit C6: loadLookups упал показываем degradation-alert (списки = mock).
// Audit C6: loadLookups упал показываем degradation-alert (списки пусты).
const lookupsFailed = ref(false);
// Регенерируем ID на каждое создание для local-mode. На API backend SERIAL.
@@ -175,7 +176,7 @@ async function submit() {
}
}
defineExpose({ lookupsFailed });
defineExpose({ lookupsFailed, projectOptions, managerOptions });
function close() {
dialogOpen.value = false;
@@ -205,8 +206,7 @@ function close() {
class="mb-3"
data-testid="lookups-error-alert"
>
Не удалось загрузить списки проектов и менеджеров показаны примерные значения. Проверьте выбор
перед сохранением.
Не удалось загрузить списки проектов и менеджеров попробуйте позже.
</v-alert>
<v-row dense>
<v-col cols="12" md="6">

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