Compare commits

...

80 Commits

Author SHA1 Message Date
Дмитрий 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
130 changed files with 22527 additions and 517 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
+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` напрямую.
+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.
+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/
+17 -2
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",
@@ -42,6 +43,20 @@
"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."
},
"universal-icons": {
"command": "npx",
"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."
}
}
}
+47 -7
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).'}';
}
}
@@ -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'],
];
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProjectRequest extends FormRequest
{
@@ -20,8 +19,10 @@ class UpdateProjectRequest extends FormRequest
return [
'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'],
@@ -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(),
@@ -207,7 +207,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 +219,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();
+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',
@@ -114,6 +114,13 @@ class ProjectService
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
/**
* LEGACY (Plan 6): обновляет только bitmask `region_mask` федеральных округов.
* После Plan 6 источник истины региональной фильтрации `regions` INT[];
* outbound SyncSupplierProjectsJob читает `regions[]`, НЕ `region_mask`. Значит
* этот bulk-action на реальную фильтрацию у поставщика не влияет. Субъект-уровневый
* bulk-edit `regions[]` запланирован в Plan 6.5 (spec §13 out of scope C9).
*/
private function bulkUpdateRegions($query, array $payload): array
{
$add = (int) ($payload['add'] ?? 0);
@@ -191,6 +198,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);
+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'));
}
}
};
+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]
+229 -7
View File
@@ -66,6 +66,96 @@ parameters:
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 +168,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
@@ -159,7 +285,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
-
@@ -765,13 +891,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: 32
path: tests/Feature/DealIndexTest.php
-
@@ -783,7 +909,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 30
count: 36
path: tests/Feature/DealIndexTest.php
-
@@ -801,7 +927,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 21
count: 24
path: tests/Feature/DealIndexTest.php
-
@@ -972,6 +1098,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 +1140,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 +1182,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 +1224,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 +1272,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 +1431,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: 8
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
@@ -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"
@@ -20,7 +20,7 @@
*/
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent, MOCK_EVENTS } from '../../composables/mockDealEvents';
import { type DealEvent } from '../../composables/mockDealEvents';
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
import * as dealsApi from '../../api/deals';
import * as remindersApi from '../../api/reminders';
@@ -56,8 +56,8 @@ function formatCost(cost: number): string {
}
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
// показываем реальные events. На fail / без tenant_id — fallback на MOCK_EVENTS.
const events = ref<DealEvent[]>([...MOCK_EVENTS]);
// показываем реальные events. На fail / без tenant_id — events пуст + eventsFetchError.
const events = ref<DealEvent[]>([]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
@@ -116,7 +116,7 @@ function formatReminderTime(iso: string | null): string {
async function loadEvents() {
if (!props.deal || !props.tenantId) {
events.value = [...MOCK_EVENTS];
events.value = [];
commentDraft.value = '';
return;
}
@@ -128,7 +128,7 @@ async function loadEvents() {
commentDraft.value = res.deal.comment ?? '';
} catch {
eventsFetchError.value = true;
events.value = [...MOCK_EVENTS];
events.value = [];
commentDraft.value = '';
} finally {
eventsLoading.value = false;
@@ -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">
@@ -3,7 +3,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import axios from 'axios';
import type { Project } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS } from '../../constants/regions';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [] }>();
@@ -11,8 +11,7 @@ const emit = defineEmits<{ close: []; saved: [] }>();
interface FormState {
name: string;
daily_limit_target: number;
region_mask: number;
region_mode: 'include' | 'exclude';
regions: number[];
delivery_days_mask: number;
sms_senders: string[];
sms_keyword: string;
@@ -21,48 +20,31 @@ interface FormState {
const form = reactive<FormState>({
name: '',
daily_limit_target: 50,
region_mask: 0,
region_mode: 'include',
regions: [],
delivery_days_mask: 127,
sms_senders: [],
sms_keyword: '',
});
const selectedRegions = ref<number[]>([]);
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
function maskToCodes(mask: number): number[] {
const codes: number[] = [];
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
return codes;
}
function reseedFromProject(p: Project | null): void {
if (!p) return;
form.name = p.name;
form.daily_limit_target = p.daily_limit_target;
form.region_mask = p.region_mask ?? 0;
form.region_mode = (p.region_mode ?? 'include') as 'include' | 'exclude';
form.regions = Array.isArray(p.regions) ? [...p.regions] : [];
form.delivery_days_mask = p.delivery_days_mask ?? 127;
form.sms_senders = p.sms_senders ?? [];
form.sms_keyword = p.sms_keyword ?? '';
selectedRegions.value = maskToCodes(form.region_mask);
}
reseedFromProject(props.project);
watch(() => props.project?.id, () => {
reseedFromProject(props.project);
});
watch(selectedRegions, (codes) => {
if (codes.length === 0) {
form.region_mask = 0;
form.region_mode = 'include';
} else {
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
form.region_mode = 'exclude';
}
});
watch(
() => props.project?.id,
() => {
reseedFromProject(props.project);
},
);
const saving = ref(false);
const errors = reactive<Record<string, string[]>>({});
@@ -76,7 +58,9 @@ async function onPause(): Promise<void> {
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm('Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).');
const ok = window.confirm(
'Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).',
);
if (!ok) return;
await store.archive(props.project.id);
emit('close');
@@ -90,8 +74,7 @@ async function onSave(): Promise<void> {
const payload: Record<string, unknown> = {
name: form.name,
daily_limit_target: form.daily_limit_target,
region_mask: form.region_mask,
region_mode: form.region_mode,
regions: form.regions,
delivery_days_mask: form.delivery_days_mask,
};
if (props.project.signal_type === 'sms') {
@@ -122,7 +105,7 @@ const activeDays = computed<boolean[]>(() => {
});
function toggleDay(i: number): void {
form.delivery_days_mask ^= (1 << i);
form.delivery_days_mask ^= 1 << i;
}
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
@@ -159,7 +142,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
<div class="pdd-field">
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
<v-autocomplete
v-model="selectedRegions"
v-model="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
@@ -169,7 +152,15 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
density="comfortable"
hide-details
data-testid="pdd-regions"
/>
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
<div class="pdd-field">
@@ -197,13 +188,12 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">🗄 Удалить</button>
</div>
<div class="pdd-foot-right">
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">Отмена</button>
<button
class="pdd-btn pdd-btn-primary"
data-testid="pdd-save"
:disabled="saving"
@click="onSave"
>Сохранить</button>
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">
Отмена
</button>
<button class="pdd-btn pdd-btn-primary" data-testid="pdd-save" :disabled="saving" @click="onSave">
Сохранить
</button>
</div>
</footer>
</div>
@@ -212,34 +202,123 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
<style scoped>
.project-details-drawer {
position: fixed; top: 0; right: 0; bottom: 0;
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 480px;
background: var(--liderra-surface, #ffffff);
border-left: 1px solid var(--liderra-line, #e6e2d6);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
transform: translateX(100%);
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
display: flex; flex-direction: column;
display: flex;
flex-direction: column;
z-index: 5;
}
.project-details-drawer.open { transform: translateX(0); }
.pdd-content { display: flex; flex-direction: column; height: 100%; }
.pdd-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--liderra-line, #e6e2d6); }
.pdd-title { font-weight: 600; font-size: 16px; }
.pdd-close { background: none; border: 0; cursor: pointer; font-size: 18px; padding: 4px; }
.pdd-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
.pdd-field { display: flex; flex-direction: column; gap: 4px; }
.pdd-label { font-size: 12px; color: #6b6f72; }
.pdd-input { padding: 8px 10px; border: 1px solid var(--liderra-line, #e6e2d6); border-radius: 6px; font: inherit; }
.pdd-days { display: flex; gap: 4px; }
.pdd-day { padding: 6px 10px; border: 1px solid var(--liderra-line, #e6e2d6); background: #ffffff; border-radius: 4px; cursor: pointer; font: inherit; }
.pdd-day.active { background: #0f6e56; color: #ffffff; border-color: #0f6e56; }
.pdd-foot { display: flex; justify-content: space-between; padding: 12px 20px; border-top: 1px solid var(--liderra-line, #e6e2d6); }
.pdd-foot-left, .pdd-foot-right { display: flex; gap: 8px; }
.pdd-btn { padding: 6px 14px; border: 0; border-radius: 6px; cursor: pointer; font: inherit; }
.pdd-btn-text { background: transparent; color: #081319; }
.pdd-btn-primary { background: #0f6e56; color: #ffffff; }
.pdd-btn-warning { background: #f59e0b; color: #ffffff; }
.pdd-btn-error { background: #dc2626; color: #ffffff; }
.pdd-error { color: #dc2626; font-size: 12px; margin-top: 4px; }
.project-details-drawer.open {
transform: translateX(0);
}
.pdd-content {
display: flex;
flex-direction: column;
height: 100%;
}
.pdd-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--liderra-line, #e6e2d6);
}
.pdd-title {
font-weight: 600;
font-size: 16px;
}
.pdd-close {
background: none;
border: 0;
cursor: pointer;
font-size: 18px;
padding: 4px;
}
.pdd-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
flex: 1;
overflow-y: auto;
}
.pdd-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.pdd-label {
font-size: 12px;
color: #6b6f72;
}
.pdd-input {
padding: 8px 10px;
border: 1px solid var(--liderra-line, #e6e2d6);
border-radius: 6px;
font: inherit;
}
.pdd-days {
display: flex;
gap: 4px;
}
.pdd-day {
padding: 6px 10px;
border: 1px solid var(--liderra-line, #e6e2d6);
background: #ffffff;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.pdd-day.active {
background: #0f6e56;
color: #ffffff;
border-color: #0f6e56;
}
.pdd-foot {
display: flex;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid var(--liderra-line, #e6e2d6);
}
.pdd-foot-left,
.pdd-foot-right {
display: flex;
gap: 8px;
}
.pdd-btn {
padding: 6px 14px;
border: 0;
border-radius: 6px;
cursor: pointer;
font: inherit;
}
.pdd-btn-text {
background: transparent;
color: #081319;
}
.pdd-btn-primary {
background: #0f6e56;
color: #ffffff;
}
.pdd-btn-warning {
background: #f59e0b;
color: #ffffff;
}
.pdd-btn-error {
background: #dc2626;
color: #ffffff;
}
.pdd-error {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
}
</style>
+3 -2
View File
@@ -1,4 +1,5 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { POLLING_INTERVAL_MS } from '../constants/polling';
/**
* Polling-composable для авто-обновления view-данных.
@@ -15,14 +16,14 @@ import { onBeforeUnmount, onMounted } from 'vue';
* Cleanup на onBeforeUnmount: clearInterval + removeEventListener.
*/
export interface PollingOptions {
/** Период polling в миллисекундах. По умолчанию 30_000. */
/** Период polling в миллисекундах. По умолчанию POLLING_INTERVAL_MS (30 с). */
intervalMs?: number;
/** Если false — composable не стартует interval (для disable-флага). */
enabled?: boolean;
}
export function usePolling(loader: () => void | Promise<void>, options: PollingOptions = {}): void {
const intervalMs = options.intervalMs ?? 30_000;
const intervalMs = options.intervalMs ?? POLLING_INTERVAL_MS;
const enabled = options.enabled ?? true;
if (!enabled) return;
+11
View File
@@ -0,0 +1,11 @@
/**
* Интервалы polling-обновления view-данных — единый источник «магических»
* чисел для usePolling. До приезда SSE/WebSocket в production это покрывает
* «real-time»-паттерн (см. composables/usePolling.ts).
*/
/** Базовый интервал авто-обновления для большинства view-данных (30 с). */
export const POLLING_INTERVAL_MS = 30_000;
/** Интервал для менее срочных счётчиков (напоминания в сайдбаре). */
export const POLLING_REMINDERS_INTERVAL_MS = 60_000;
+114 -37
View File
@@ -1,42 +1,119 @@
export interface Region {
code: number;
name: string;
code: number; // 1..89, sequential по конституционному порядку (Art. 65)
name: string; // официальное название субъекта
federalDistrict: number; // 1..8 (см. FEDERAL_DISTRICT_NAMES)
}
// MVP: 31 региона (коды 1..31) ограничены 32-bit region_mask из Plan 5 Task 9.
// Sentinel code:0 = «Вся РФ» (включает все регионы, эквивалент пустой маски).
// Имена — официальные субъекты РФ по конституционному порядку нумерации.
// Конституционный порядок (ст. 65 Конституции РФ, ред. 2022):
// 24 республики (1..24) → 9 краёв (25..33) → 48 областей (34..81) →
// 3 города фед.знач. (82..84) → 1 АО Еврейская (85) → 4 АО (86..89).
// Sentinel code:0 = "Вся РФ" (UI hint, в БД хранится как regions=[]).
export const REGIONS: Region[] = [
{ code: 0, name: 'Вся РФ' },
{ code: 1, name: 'Республика Адыгея' },
{ code: 2, name: 'Республика Башкортостан' },
{ code: 3, name: 'Республика Бурятия' },
{ code: 4, name: 'Республика Алтай' },
{ code: 5, name: 'Республика Дагестан' },
{ code: 6, name: 'Республика Ингушетия' },
{ code: 7, name: 'Кабардино-Балкарская Республика' },
{ code: 8, name: 'Республика Калмыкия' },
{ code: 9, name: 'Карачаево-Черкесская Республика' },
{ code: 10, name: 'Республика Карелия' },
{ code: 11, name: 'Республика Коми' },
{ code: 12, name: 'Республика Марий Эл' },
{ code: 13, name: 'Республика Мордовия' },
{ code: 14, name: 'Республика Саха (Якутия)' },
{ code: 15, name: 'Республика Северная Осетия — Алания' },
{ code: 16, name: 'Республика Татарстан' },
{ code: 17, name: 'Республика Тыва' },
{ code: 18, name: 'Удмуртская Республика' },
{ code: 19, name: 'Республика Хакасия' },
{ code: 20, name: 'Чеченская Республика' },
{ code: 21, name: 'Чувашская Республика' },
{ code: 22, name: 'Алтайский край' },
{ code: 23, name: 'Краснодарский край' },
{ code: 24, name: 'Красноярский край' },
{ code: 25, name: 'Приморский край' },
{ code: 26, name: 'Ставропольский край' },
{ code: 27, name: 'Хабаровский край' },
{ code: 28, name: 'Амурская область' },
{ code: 29, name: 'Архангельская область' },
{ code: 30, name: 'Астраханская область' },
{ code: 31, name: 'Белгородская область' },
{ code: 0, name: 'Вся РФ', federalDistrict: 0 },
// 24 республики
{ code: 1, name: 'Республика Адыгея', federalDistrict: 3 },
{ code: 2, name: 'Республика Алтай', federalDistrict: 7 },
{ code: 3, name: 'Республика Башкортостан', federalDistrict: 5 },
{ code: 4, name: 'Республика Бурятия', federalDistrict: 8 },
{ code: 5, name: 'Республика Дагестан', federalDistrict: 4 },
{ code: 6, name: 'Донецкая Народная Республика', federalDistrict: 3 },
{ code: 7, name: 'Республика Ингушетия', federalDistrict: 4 },
{ code: 8, name: 'Кабардино-Балкарская Республика', federalDistrict: 4 },
{ code: 9, name: 'Республика Калмыкия', federalDistrict: 3 },
{ code: 10, name: 'Карачаево-Черкесская Республика', federalDistrict: 4 },
{ code: 11, name: 'Республика Карелия', federalDistrict: 2 },
{ code: 12, name: 'Республика Коми', federalDistrict: 2 },
{ code: 13, name: 'Республика Крым', federalDistrict: 3 },
{ code: 14, name: 'Луганская Народная Республика', federalDistrict: 3 },
{ code: 15, name: 'Республика Марий Эл', federalDistrict: 5 },
{ code: 16, name: 'Республика Мордовия', federalDistrict: 5 },
{ code: 17, name: 'Республика Саха (Якутия)', federalDistrict: 8 },
{ code: 18, name: 'Республика Северная Осетия — Алания', federalDistrict: 4 },
{ code: 19, name: 'Республика Татарстан', federalDistrict: 5 },
{ code: 20, name: 'Республика Тыва', federalDistrict: 7 },
{ code: 21, name: 'Удмуртская Республика', federalDistrict: 5 },
{ code: 22, name: 'Республика Хакасия', federalDistrict: 7 },
{ code: 23, name: 'Чеченская Республика', federalDistrict: 4 },
{ code: 24, name: 'Чувашская Республика', federalDistrict: 5 },
// 9 краёв
{ code: 25, name: 'Алтайский край', federalDistrict: 7 },
{ code: 26, name: 'Забайкальский край', federalDistrict: 8 },
{ code: 27, name: 'Камчатский край', federalDistrict: 8 },
{ code: 28, name: 'Краснодарский край', federalDistrict: 3 },
{ code: 29, name: 'Красноярский край', federalDistrict: 7 },
{ code: 30, name: 'Пермский край', federalDistrict: 5 },
{ code: 31, name: 'Приморский край', federalDistrict: 8 },
{ code: 32, name: 'Ставропольский край', federalDistrict: 4 },
{ code: 33, name: 'Хабаровский край', federalDistrict: 8 },
// 48 областей
{ code: 34, name: 'Амурская область', federalDistrict: 8 },
{ code: 35, name: 'Архангельская область', federalDistrict: 2 },
{ code: 36, name: 'Астраханская область', federalDistrict: 3 },
{ code: 37, name: 'Белгородская область', federalDistrict: 1 },
{ code: 38, name: 'Брянская область', federalDistrict: 1 },
{ code: 39, name: 'Владимирская область', federalDistrict: 1 },
{ code: 40, name: 'Волгоградская область', federalDistrict: 3 },
{ code: 41, name: 'Вологодская область', federalDistrict: 2 },
{ code: 42, name: 'Воронежская область', federalDistrict: 1 },
{ code: 43, name: 'Запорожская область', federalDistrict: 3 },
{ code: 44, name: 'Ивановская область', federalDistrict: 1 },
{ code: 45, name: 'Иркутская область', federalDistrict: 7 },
{ code: 46, name: 'Калининградская область', federalDistrict: 2 },
{ code: 47, name: 'Калужская область', federalDistrict: 1 },
{ code: 48, name: 'Кемеровская область', federalDistrict: 7 },
{ code: 49, name: 'Кировская область', federalDistrict: 5 },
{ code: 50, name: 'Костромская область', federalDistrict: 1 },
{ code: 51, name: 'Курганская область', federalDistrict: 6 },
{ code: 52, name: 'Курская область', federalDistrict: 1 },
{ code: 53, name: 'Ленинградская область', federalDistrict: 2 },
{ code: 54, name: 'Липецкая область', federalDistrict: 1 },
{ code: 55, name: 'Магаданская область', federalDistrict: 8 },
{ code: 56, name: 'Московская область', federalDistrict: 1 },
{ code: 57, name: 'Мурманская область', federalDistrict: 2 },
{ code: 58, name: 'Нижегородская область', federalDistrict: 5 },
{ code: 59, name: 'Новгородская область', federalDistrict: 2 },
{ code: 60, name: 'Новосибирская область', federalDistrict: 7 },
{ code: 61, name: 'Омская область', federalDistrict: 7 },
{ code: 62, name: 'Оренбургская область', federalDistrict: 5 },
{ code: 63, name: 'Орловская область', federalDistrict: 1 },
{ code: 64, name: 'Пензенская область', federalDistrict: 5 },
{ code: 65, name: 'Псковская область', federalDistrict: 2 },
{ code: 66, name: 'Ростовская область', federalDistrict: 3 },
{ code: 67, name: 'Рязанская область', federalDistrict: 1 },
{ code: 68, name: 'Самарская область', federalDistrict: 5 },
{ code: 69, name: 'Саратовская область', federalDistrict: 5 },
{ code: 70, name: 'Сахалинская область', federalDistrict: 8 },
{ code: 71, name: 'Свердловская область', federalDistrict: 6 },
{ code: 72, name: 'Смоленская область', federalDistrict: 1 },
{ code: 73, name: 'Тамбовская область', federalDistrict: 1 },
{ code: 74, name: 'Тверская область', federalDistrict: 1 },
{ code: 75, name: 'Томская область', federalDistrict: 7 },
{ code: 76, name: 'Тульская область', federalDistrict: 1 },
{ code: 77, name: 'Тюменская область', federalDistrict: 6 },
{ code: 78, name: 'Ульяновская область', federalDistrict: 5 },
{ code: 79, name: 'Херсонская область', federalDistrict: 3 },
{ code: 80, name: 'Челябинская область', federalDistrict: 6 },
{ code: 81, name: 'Ярославская область', federalDistrict: 1 },
// 3 города федерального значения
{ code: 82, name: 'Москва', federalDistrict: 1 },
{ code: 83, name: 'Санкт-Петербург', federalDistrict: 2 },
{ code: 84, name: 'Севастополь', federalDistrict: 3 },
// 1 автономная область
{ code: 85, name: 'Еврейская автономная область', federalDistrict: 8 },
// 4 автономных округа
{ code: 86, name: 'Ненецкий автономный округ', federalDistrict: 2 },
{ code: 87, name: 'Ханты-Мансийский автономный округ — Югра', federalDistrict: 6 },
{ code: 88, name: 'Чукотский автономный округ', federalDistrict: 8 },
{ code: 89, name: 'Ямало-Ненецкий автономный округ', federalDistrict: 6 },
];
export const FEDERAL_DISTRICT_NAMES: Record<number, string> = {
1: 'Центральный',
2: 'Северо-Западный',
3: 'Южный',
4: 'Северо-Кавказский',
5: 'Приволжский',
6: 'Уральский',
7: 'Сибирский',
8: 'Дальневосточный',
};
+16
View File
@@ -38,6 +38,9 @@ const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
/** DEV-режим: показываем баннер о застабленном auth-gate админки (B6). */
const isDevEnv = import.meta.env.DEV;
const userInitials = computed(() => {
const u = auth.user;
if (!u) return 'АО';
@@ -131,6 +134,19 @@ const currentPageTitle = computed(() => {
</v-app-bar>
<v-main class="admin-main">
<v-alert
v-if="isDevEnv"
type="warning"
variant="tonal"
density="compact"
class="ma-4"
data-testid="dev-auth-gap-banner"
>
DEV-режим: доступ к админке открыт без SSO-проверки middleware
<code>EnsureSaasAdmin</code> в dev пропускает все запросы. В production
требуется вход через Yandex 360 + роль <code>super_admin</code> (Б-1);
неавторизованные запросы получают 503.
</v-alert>
<ImpersonationBanner />
<RouterView />
</v-main>
+3 -2
View File
@@ -15,6 +15,7 @@ import { useAuthStore } from '../stores/auth';
import { useNotificationsStore } from '../stores/notifications';
import { useRemindersStore } from '../stores/reminders';
import { usePolling } from '../composables/usePolling';
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import DevIndexBadge from '../components/DevIndexBadge.vue';
@@ -57,8 +58,8 @@ onMounted(() => {
void loadNotifications();
void loadReminderCounts();
});
usePolling(loadNotifications, { intervalMs: 30_000, enabled: true });
usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
usePolling(loadReminderCounts, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
</script>
<template>
+1
View File
@@ -16,6 +16,7 @@ export interface Project {
archived_at: string | null;
region_mask?: number;
region_mode?: string;
regions?: number[]; // Plan 6 — subject codes 1..89; пустой массив = вся РФ
delivery_days_mask?: number;
sync_status: 'ok' | 'pending' | 'failed';
last_synced_at?: string | null;
+7 -8
View File
@@ -3,7 +3,7 @@
* Список сделок центральный экран CRM. Используется менеджерами ежедневно.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_deals.html.
* MVP: page-head + chiprow со срезами + поиск + v-data-table с mock'ами.
* MVP: page-head + chiprow со срезами + поиск + v-data-table (данные из API).
*
* Не входит в этот коммит (отдельные TODO):
* - Drawer с деталями сделки при клике на строку (правая панель v-navigation-drawer right).
@@ -15,7 +15,7 @@
*/
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
import { DEALS_TABS, type MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { usePolling } from '../composables/usePolling';
// Sprint 2 Phase B / O-perf-06: lazy-imports для тяжёлых компонентов, гейтящихся
@@ -107,10 +107,9 @@ const selected = ref<number[]>([]);
const filterProjects = ref<string[]>([]);
const filterManagers = ref<string[]>([]);
// Локальная reactive-копия. При наличии auth.user.tenant_id fetch через
// API (см. onMounted ниже); на network/500 fallback на MOCK_DEALS чтобы UI
// оставался работоспособным (полезно для dev и Vitest jsdom-среды).
const dealsState = reactive<MockDeal[]>(MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
// Локальная reactive-копия. Наполняется через API (loadDeals/onMounted);
// до загрузки и при ошибке пуст.
const dealsState = reactive<MockDeal[]>([]);
const loading = ref(false);
const fetchError = ref(false);
@@ -130,7 +129,7 @@ async function loadDeals() {
const mapped = deals.map((d) => mapApiDeal(d));
dealsState.splice(0, dealsState.length, ...mapped);
} catch {
fetchError.value = true; // оставляем MOCK_DEALS как fallback
fetchError.value = true; // state остаётся пустым показываем error-alert
} finally {
loading.value = false;
}
@@ -720,7 +719,7 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
class="mt-3"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
Не удалось загрузить сделки. Попробуйте обновить.
</v-alert>
<!-- Task 15: wrapper с .ld-hover-lift + .ld-stagger-row для quiet-luxury motion
+6 -7
View File
@@ -20,7 +20,7 @@
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { LEAD_STATUSES } from '../composables/leadStatuses';
import { MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
import { type MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { usePolling } from '../composables/usePolling';
import * as dealsApi from '../api/deals';
@@ -44,11 +44,10 @@ interface DraggableChangeEvent {
}
// Reactive Record<slug, MockDeal[]> отдельный массив для каждой колонки
// (vuedraggable v-model требует независимые arrays). Deep-clone объектов
// сделок чтобы не мутировать MOCK_DEALS const при DnD.
// (vuedraggable v-model требует независимые arrays).
const dealsByStatus = reactive<Record<string, MockDeal[]>>(
LEAD_STATUSES.reduce<Record<string, MockDeal[]>>((acc, s) => {
acc[s.slug] = MOCK_DEALS.filter((d) => d.statusSlug === s.slug).map((d) => ({ ...d }));
acc[s.slug] = [];
return acc;
}, {}),
);
@@ -108,7 +107,7 @@ function onOpenDeal(id: number) {
}
}
const totalDeals = ref(MOCK_DEALS.length);
const totalDeals = ref(0);
const fetchError = ref(false);
const newDealOpen = ref(false);
@@ -139,7 +138,7 @@ async function loadDeals() {
}
totalDeals.value = total;
} catch {
fetchError.value = true; // оставляем MOCK_DEALS как fallback
fetchError.value = true; // state остаётся пустым показываем error-alert
}
}
@@ -205,7 +204,7 @@ defineExpose({
class="mt-3"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
Не удалось загрузить сделки. Попробуйте обновить.
</v-alert>
<div class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
-27
View File
@@ -167,33 +167,6 @@ onUnmounted(() => store.stopPolling());
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* Workaround: MDI-шрифт не подключён в проекте (Диз-4),
`<i class="mdi-close-circle">` рендерится пустым. Подменяем глиф на Unicode ``
и показываем только когда поле имеет значение (Vuetify ставит `.v-field--dirty`). */
.projects-view :deep(.v-field__clearable) {
position: relative;
}
.projects-view :deep(.v-field__clearable .v-icon) {
color: transparent;
width: 20px;
height: 20px;
}
.projects-view :deep(.v-field--dirty .v-field__clearable)::after {
content: '✕';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(1, 32, 25, 0.55);
font-size: 14px;
font-family: 'Inter', system-ui, sans-serif;
pointer-events: none;
transition: color 150ms ease;
}
.projects-view :deep(.v-field--dirty .v-field__clearable:hover)::after {
color: var(--liderra-noir, #012019);
}
.toolbar-check {
display: inline-flex;
align-items: center;
+2 -1
View File
@@ -12,6 +12,7 @@ import { computed, onMounted, ref } from 'vue';
import { cancelReportJob, createReportJob, deleteReportJob, listReportJobs, retryReportJob } from '../api/reports';
import { extractErrorMessage, extractValidationErrors } from '../api/client';
import { usePolling } from '../composables/usePolling';
import { POLLING_INTERVAL_MS } from '../constants/polling';
import { type ReportFormat, type ReportJob, type ReportType } from '../composables/mockReports';
import { mapApiReportJob, uiTypeToApi } from '../composables/reportsMapper';
import ReportRequestForm from '../components/reports/ReportRequestForm.vue';
@@ -59,7 +60,7 @@ onMounted(() => {
void loadJobs();
});
usePolling(loadJobs, { intervalMs: 30_000 });
usePolling(loadJobs, { intervalMs: POLLING_INTERVAL_MS });
async function submitForm(): Promise<void> {
submitting.value = true;
+8 -39
View File
@@ -1,16 +1,20 @@
<script setup lang="ts">
/**
* Settings настройки тенанта/пользователя. 8 вкладок (по v8.5 §13 + ТЗ §14).
* Settings настройки тенанта/пользователя. 4 рабочие вкладки.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html.
* Полностью реализованы (с UI-разводкой): Профиль, Безопасность, API и Webhook,
* Уведомления (матрица 8×3 по schema v8.7 §4 users.notification_preferences).
* Placeholder-заглушки: Проекты, Команда, Интеграции, Тихие часы.
*
* Аудит D6/D7 (Sprint 3E, 2026-05-16): placeholder-вкладки Проекты/Команда/
* Интеграции/Тихие часы убраны UI не должен обещать «в разработке».
* «Проекты» дублировали /projects; «Команда» и «Тихие часы» (ТЗ §17.8)
* требуют schema+backend (отдельные эпики); «Интеграции» внешне-блокированы (Б-1).
* Вкладки вернутся при реальной реализации соответствующих модулей.
*/
import { computed, ref } from 'vue';
import { ref } from 'vue';
import ApiTab from './settings/ApiTab.vue';
import NotificationsTab from './settings/NotificationsTab.vue';
import PlaceholderTab from './settings/PlaceholderTab.vue';
import ProfileTab from './settings/ProfileTab.vue';
import SecurityTab from './settings/SecurityTab.vue';
@@ -23,41 +27,11 @@ interface Tab {
const tabs: Tab[] = [
{ id: 'profile', label: 'Профиль', icon: 'mdi-account-outline' },
{ id: 'security', label: 'Безопасность', icon: 'mdi-shield-lock-outline' },
{ id: 'projects', label: 'Проекты', icon: 'mdi-folder-outline' },
{ id: 'team', label: 'Команда', icon: 'mdi-account-group-outline' },
{ id: 'api', label: 'API и Webhook', icon: 'mdi-api' },
{ id: 'integrations', label: 'Интеграции', icon: 'mdi-puzzle-outline' },
{ id: 'hours', label: 'Тихие часы', icon: 'mdi-clock-outline' },
{ id: 'notifications', label: 'Уведомления', icon: 'mdi-bell-outline' },
];
const activeTab = ref('profile');
const placeholderProps = computed(() => {
const map: Record<string, { title: string; description: string }> = {
projects: {
title: 'Проекты',
description:
'Управление проектами тенанта (макс. 10 на тарифе «Команда»). Для каждого проекта — поставщик ГЦК, цена за лид, активные UTM-кампании.',
},
team: {
title: 'Команда',
description:
'Менеджеры тенанта (макс. 4 + расширение). Назначение прав, автораспределение, ограничение доступа к проектам.',
},
integrations: {
title: 'Интеграции',
description:
'Подключение Telegram-бота для нотификаций, экспорт в 1С 8.3, JivoSite helpdesk, Yandex 360 SSO.',
},
hours: {
title: 'Тихие часы',
description:
'Расписание, в которое не приходят SMS/звонки автонапоминаний (например, 22:00-08:00 + выходные).',
},
};
return map[activeTab.value];
});
</script>
<template>
@@ -91,11 +65,6 @@ const placeholderProps = computed(() => {
<SecurityTab v-else-if="activeTab === 'security'" />
<ApiTab v-else-if="activeTab === 'api'" />
<NotificationsTab v-else-if="activeTab === 'notifications'" />
<PlaceholderTab
v-else-if="placeholderProps"
:title="placeholderProps.title"
:description="placeholderProps.description"
/>
</v-card>
</v-col>
</v-row>
@@ -5,10 +5,8 @@
* Сводный биллинг по всем тенантам: выручка, MRR, retention, refunds.
* Источник данных: aggregate balance_transactions / invoices / tariff_subscriptions.
*
* MVP только display-вьюха с mock-данными. Backend `/api/admin/billing/*`
* подключается отдельным коммитом.
* Данные грузятся с backend GET /api/admin/billing.
*/
import { ADMIN_BILLING_SUMMARY as MOCK_SUMMARY, ADMIN_BILLING_TENANTS } from '../../composables/mockAdmin';
import { computed, onMounted, reactive, ref } from 'vue';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
@@ -17,11 +15,6 @@ import { extractErrorMessage } from '../../api/client';
const search = ref('');
/**
* Reactive-копия initial = MOCK для UI без backend'а; replace на API на mount.
* View работает в обоих режимах: row может быть из mock (узкие enum-types)
* или из API (открытые string-типы).
*/
type BillingRow = {
id: number;
name: string;
@@ -34,25 +27,13 @@ type BillingRow = {
status: string;
};
const rowsState = reactive<BillingRow[]>(
ADMIN_BILLING_TENANTS.map((r) => ({
id: r.id,
name: r.name,
inn: r.inn,
tariff: r.tariff,
balance_rub: r.balance_rub,
monthly_topups_rub: r.monthly_topups_rub,
monthly_charges_rub: r.monthly_charges_rub,
mrr_rub: r.mrr_rub,
status: r.status,
})),
);
const rowsState = reactive<BillingRow[]>([]);
const summary = reactive({
total_mrr_rub: MOCK_SUMMARY.total_mrr_rub,
monthly_revenue_rub: MOCK_SUMMARY.monthly_revenue_rub,
overdue_count: MOCK_SUMMARY.overdue_count,
refunds_count_30d: MOCK_SUMMARY.refunds_count_30d,
total_mrr_rub: 0,
monthly_revenue_rub: 0,
overdue_count: 0,
refunds_count_30d: 0,
});
const loading = ref(false);
@@ -255,7 +236,7 @@ function tariffLabel(t: string): string {
class="mb-4"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
Не удалось загрузить биллинг. Попробуйте обновить.
</v-alert>
<!-- Stats row -->
@@ -6,10 +6,8 @@
* Категории: PDN-breach, service_outage, security, billing, data_loss.
* При PDN-breach обязательное уведомление РКН за 24 ч (152-ФЗ).
*
* MVP display + фильтр по статусу/severity. Backend `/api/admin/incidents`
* подключается отдельным коммитом.
* Display + фильтр по статусу/severity. Данные с backend GET /api/admin/incidents.
*/
import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { usePolling } from '../../composables/usePolling';
@@ -73,32 +71,12 @@ function categoryLabel(c: string): string {
return categoryMap[c] ?? c;
}
// Reactive initial = MOCK; replace на API на mount.
const rowsState = reactive<IncidentRow[]>(
ADMIN_INCIDENTS.map((r) => ({
id: r.id,
incident_id: r.incident_id,
title: r.title,
severity: r.severity,
category: r.category,
status: r.status,
detected_at: r.detected_at,
affected_tenants: r.affected_tenants,
rkn_notified: r.rkn_notified,
rkn_deadline_at: r.rkn_deadline_at,
})),
);
// Reactive наполняется через loadIncidents (API).
const rowsState = reactive<IncidentRow[]>([]);
const stats = reactive({ open: 0, investigating: 0, rkn_pending: 0 });
const loading = ref(false);
const fetchError = ref(false);
// Initial stats из mock (UI consistency без backend'а).
stats.open = rowsState.filter((r) => r.status === 'open').length;
stats.investigating = rowsState.filter((r) => r.status === 'investigating').length;
stats.rkn_pending = rowsState.filter(
(r) => (r.category === 'pdn_breach' || r.category === 'data_breach') && !r.rkn_notified,
).length;
async function loadIncidents() {
loading.value = true;
fetchError.value = false;
@@ -176,7 +154,7 @@ function formatDate(iso: string): string {
class="mb-4"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
Не удалось загрузить инциденты. Попробуйте обновить.
</v-alert>
<v-row class="mb-4" data-testid="incidents-stats">
@@ -5,10 +5,8 @@
* Глобальные настройки SaaS-уровня (system_settings по schema v8.7 §10):
* лимиты квот, тарифные планы, фичефлаги, fallback supplier_id.
*
* MVP display + read-only edit-режим. Backend `/api/admin/system-settings`
* + edit-flow подключаются отдельным коммитом.
* Display + edit-режим. Данные с backend GET /api/admin/system-settings.
*/
import { ADMIN_SYSTEM_SETTINGS } from '../../composables/mockAdmin';
import type { AdminSystemSetting } from '../../composables/mockAdmin';
import * as adminApi from '../../api/admin';
import type { SystemSetting as ApiSystemSetting } from '../../api/admin';
@@ -21,13 +19,10 @@ const loading = ref(false);
const fetchError = ref<string | null>(null);
/**
* Settings-state. Инициируется mock-данными (fallback если backend недоступен),
* на mount replace через `adminApi.listSystemSettings()`.
*
* Type-narrowing: AdminSystemSetting (mock) vs ApiSystemSetting различаются
* только origin (mock vs БД), shape совместим оба `{key, value, type, ...}`.
* Settings-state. Наполняется на mount через `adminApi.listSystemSettings()`.
* До загрузки и при ошибке пустой; ошибка показывается через fetchError-banner.
*/
const settingsState = reactive<AdminSystemSetting[]>([...ADMIN_SYSTEM_SETTINGS]);
const settingsState = reactive<AdminSystemSetting[]>([]);
async function loadSettings() {
loading.value = true;
@@ -37,8 +32,8 @@ async function loadSettings() {
// Replace всё содержимое сохранив reactive-ref.
settingsState.splice(0, settingsState.length, ...(fromApi as unknown as AdminSystemSetting[]));
} catch (err) {
// На fail оставляем mock (не очищаем UI). Показываем error-banner.
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить настройки с сервера. Показаны mock-данные.');
// На fail settingsState пустой, показываем error-banner.
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить настройки с сервера. Попробуйте обновить.');
} finally {
loading.value = false;
}
@@ -168,6 +163,7 @@ defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated,
size="small"
density="comfortable"
prepend-icon="mdi-pencil"
:aria-label="`Изменить настройку ${setting.key}`"
:data-testid="`edit-${setting.key}-btn`"
@click="openEdit(setting)"
>
@@ -15,7 +15,7 @@
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { MOCK_STATS, MOCK_TENANTS, type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
import { type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
@@ -29,8 +29,8 @@ import TenantsTable from '../../components/admin/tenants/TenantsTable.vue';
const router = useRouter();
const tenantsState = reactive<AdminTenant[]>(MOCK_TENANTS.map((t) => ({ ...t })));
const stats = reactive({ ...MOCK_STATS });
const tenantsState = reactive<AdminTenant[]>([]);
const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0, monthlyRevenueRub: 0 });
const loading = ref(false);
const fetchError = ref(false);
@@ -123,7 +123,7 @@ const filteredTenants = computed<AdminTenant[]>(() => {
class="mt-3"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
Не удалось загрузить тенантов. Попробуйте обновить.
</v-alert>
<TenantsFilters
+20 -3
View File
@@ -86,11 +86,22 @@ async function handleSubmit() {
placeholder="Минимум 8 символов"
variant="outlined"
density="comfortable"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
required
:error-messages="errors.password"
@click:append-inner="showPassword = !showPassword"
/>
>
<template #append-inner>
<v-icon
class="password-toggle"
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
role="button"
tabindex="0"
@click="showPassword = !showPassword"
@keydown.enter.prevent="showPassword = !showPassword"
@keydown.space.prevent="showPassword = !showPassword"
/>
</template>
</v-text-field>
<div class="d-flex justify-end mb-2">
<RouterLink to="/forgot" class="text-body-2 text-primary"> Забыли пароль? </RouterLink>
@@ -141,4 +152,10 @@ async function handleSubmit() {
.yandex-sso-wrap {
width: 100%;
}
.password-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
border-radius: 2px;
}
</style>
+20 -3
View File
@@ -102,11 +102,22 @@ async function handleSubmit() {
placeholder="Минимум 8 символов"
variant="outlined"
density="comfortable"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
required
:error-messages="errors.password"
@click:append-inner="showPassword = !showPassword"
/>
>
<template #append-inner>
<v-icon
class="password-toggle"
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
role="button"
tabindex="0"
@click="showPassword = !showPassword"
@keydown.enter.prevent="showPassword = !showPassword"
@keydown.space.prevent="showPassword = !showPassword"
/>
</template>
</v-text-field>
<div v-if="password" class="strength-block mb-2">
<v-progress-linear
@@ -184,4 +195,10 @@ async function handleSubmit() {
gap: 4px;
margin-bottom: 8px;
}
.password-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
border-radius: 2px;
}
</style>
@@ -112,11 +112,22 @@ async function handleSubmit() {
placeholder="Минимум 10 символов"
variant="outlined"
density="comfortable"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
required
:error-messages="errors.password"
@click:append-inner="showPassword = !showPassword"
/>
>
<template #append-inner>
<v-icon
class="password-toggle"
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
role="button"
tabindex="0"
@click="showPassword = !showPassword"
@keydown.enter.prevent="showPassword = !showPassword"
@keydown.space.prevent="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-text-field
v-model="passwordConfirmation"
@@ -165,4 +176,10 @@ async function handleSubmit() {
flex-direction: column;
gap: 8px;
}
.password-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
border-radius: 2px;
}
</style>
@@ -76,12 +76,34 @@
:error-messages="errors.daily_limit_target"
/>
<v-autocomplete
v-model="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Регионы (пусто = вся РФ)"
multiple
chips
clearable
density="comfortable"
class="ld-input-quiet"
data-testid="regions-autocomplete"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
<v-alert
v-if="generalError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
class="mt-3"
closable
@click:close="generalError = null"
>
@@ -114,9 +136,12 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
const props = defineProps<{
modelValue: boolean;
mode?: 'create' | 'edit';
@@ -124,9 +149,8 @@ const props = defineProps<{
}>();
const emit = defineEmits(['update:modelValue', 'saved']);
// region_mask=255 = все 8 ФО (schema default, см. db/schema.sql §projects).
// PDD regions UI отключён до закрытия Plan 6 конфликт с 8-битной ФО-маской
// в PhonePrefixService.php (1 phone prefix 1 ФО, не субъект).
// Plan 6: regions = subject codes (1..89) backend dual-writes region_mask/region_mode.
// Пустой массив = вся РФ.
const form = reactive({
name: '',
signal_type: 'site' as 'site' | 'call' | 'sms',
@@ -134,8 +158,7 @@ const form = reactive({
sms_senders: [] as string[],
sms_keyword: '',
daily_limit_target: 50,
region_mask: 255,
region_mode: 'include' as 'include' | 'exclude',
regions: [] as number[],
delivery_days_mask: 127,
});
const errors = reactive<Record<string, string[]>>({});
@@ -159,6 +182,7 @@ watch(
if (open) generalError.value = null;
if (open && props.mode === 'edit' && props.project) {
Object.assign(form, props.project);
form.regions = Array.isArray(props.project.regions) ? [...props.project.regions] : [];
const days: number[] = [];
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
selectedDays.value = days;
@@ -170,8 +194,7 @@ watch(
sms_senders: [],
sms_keyword: '',
daily_limit_target: 50,
region_mask: 255,
region_mode: 'include',
regions: [],
delivery_days_mask: 127,
});
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
@@ -1,26 +0,0 @@
<script setup lang="ts">
/**
* Универсальный placeholder для ещё-не-реализованных вкладок Settings.
* Используется для вкладок: Проекты, Команда, Интеграции, Тихие часы.
*
* При реализации каждой вкладки заменяется на отдельный component.
*/
defineProps<{ title: string; description: string }>();
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-3">{{ title }}</h2>
<v-alert type="info" variant="tonal" density="compact" class="mb-4">
<strong>В разработке.</strong> Этот раздел реализуется в следующих коммитах.
</v-alert>
<p class="text-body-2 text-medium-emphasis">{{ description }}</p>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
</style>
@@ -59,11 +59,12 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.21 has correct metrics — 63 base tables, 118 indexes, 40 RLS policies', function () {
it('schema.sql v8.22 has correct metrics — 63 base tables, 119 indexes, 40 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.21.
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.22.
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
$schema = file_get_contents($schemaPath);
@@ -76,7 +77,7 @@ it('schema.sql v8.21 has correct metrics — 63 base tables, 118 indexes, 40 RLS
expect($baseTables)->toBe(63);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(118);
expect($createIndexes)->toBe(119); // v8.22 (Plan 6/C9): +1 GIN idx_projects_regions
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(40);
@@ -19,8 +19,7 @@ it('creates a site project with valid payload', function () {
'signal_type' => 'site',
'signal_identifier' => 'okna-spb.ru',
'daily_limit_target' => 50,
'region_mask' => 0,
'region_mode' => 'include',
'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -36,7 +35,7 @@ it('rejects invalid site domain', function () {
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'not a domain',
'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 50, 'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -50,7 +49,7 @@ it('creates a call project with valid 11-digit phone', function () {
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Натяжные', 'signal_type' => 'call', 'signal_identifier' => '79161234567',
'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 30, 'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -63,7 +62,7 @@ it('rejects call signal_identifier not starting with 7', function () {
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '89991234567',
'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 30, 'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -77,7 +76,7 @@ it('creates sms project with senders + keyword', function () {
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Ипотека', 'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'], 'sms_keyword' => 'ипотека',
'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 100, 'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -93,7 +92,7 @@ it('rejects sms project without sms_senders', function () {
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'X', 'signal_type' => 'sms',
'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 100, 'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -108,7 +107,7 @@ it('rejects when tenant exceeds max_projects limit', function () {
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru',
'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 10, 'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -123,7 +122,7 @@ it('forces tenant_id from auth user (not from payload)', function () {
$this->actingAs($userA)->postJson('/api/projects', [
'tenant_id' => $tenantB->id, // попытка инъекции
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru',
'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 10, 'regions' => [],
'delivery_days_mask' => 127,
]);
@@ -137,10 +136,67 @@ it('rejects site domain with consecutive dots', function () {
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'okna..spb.ru',
'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include',
'daily_limit_target' => 50, 'regions' => [],
'delivery_days_mask' => 127,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['signal_identifier']);
});
// Plan 6 — subject-level regions[] support.
it('creates project with subject-level regions array', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Regions Test Project',
'signal_type' => 'site',
'signal_identifier' => 'regions-test.example',
'daily_limit_target' => 50,
'delivery_days_mask' => 127,
'regions' => [82, 83], // Москва + СПб
]);
$response->assertStatus(201);
$response->assertJsonPath('data.regions', [82, 83]);
$created = Project::where('name', 'Regions Test Project')->firstOrFail();
expect($created->regions)->toBe([82, 83]);
});
it('dual-writes region_mask=255 + region_mode=include for backward-compat', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Dual Write Test',
'signal_type' => 'site',
'signal_identifier' => 'dualwrite.example',
'daily_limit_target' => 50,
'delivery_days_mask' => 127,
'regions' => [77],
]);
$response->assertStatus(201);
$created = Project::where('name', 'Dual Write Test')->firstOrFail();
expect($created->region_mask)->toBe(255);
expect($created->region_mode)->toBe('include');
});
it('rejects regions code out of 1..89 range with 422', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Invalid Code Test',
'signal_type' => 'site',
'signal_identifier' => 'invalid.example',
'daily_limit_target' => 50,
'delivery_days_mask' => 127,
'regions' => [90, 100],
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['regions.0', 'regions.1']);
});
@@ -78,14 +78,50 @@ it('cross-tenant update returns 404', function () {
])->assertStatus(404);
});
it('updates region_mask and delivery_days_mask', function () {
it('updates delivery_days_mask (region_mask now read-only — see regions[] tests below)', function () {
// Plan 6: region_mask/region_mode больше не клиент-controllable через UpdateProjectRequest
// (validation rules удалены, ProjectService::create dual-writes 255/include).
// Источник истины для региональной фильтрации — projects.regions INT[] (Plan 6).
// Этот тест адаптирован: проверяет, что delivery_days_mask остаётся writeable через PATCH.
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'region_mask' => 78, 'region_mode' => 'exclude', 'delivery_days_mask' => 31,
'delivery_days_mask' => 31,
])->assertOk();
expect($project->fresh()->region_mask)->toBe(78);
expect($project->fresh()->delivery_days_mask)->toBe(31);
});
// Plan 6 — subject-level regions[] support.
it('updates regions array via PATCH', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => []]);
$response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'regions' => [82],
]);
$response->assertStatus(200);
$response->assertJsonPath('data.regions', [82]);
expect($project->fresh()->regions)->toBe([82]);
});
it('preserves regions when PATCH omits the field (sometimes rule)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'regions' => [82, 83],
]);
$response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'name' => 'Renamed Project',
]);
$response->assertStatus(200);
expect($project->fresh()->regions)->toBe([82, 83]);
});
@@ -11,6 +11,7 @@ use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
@@ -309,6 +310,36 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
Http::assertNothingSent();
});
test('passes regions directly to allocator without bitmask conversion', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'regions' => [82, 83],
'region_mask' => 255,
]);
$job = new SyncSupplierProjectsJob;
$projects = Project::where('tenant_id', $tenant->id)->get();
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
expect($adapted->first()->regions)->toBe([82, 83]);
});
test('passes empty array to allocator when project has regions=[]', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'regions' => [],
'region_mask' => 255,
]);
$job = new SyncSupplierProjectsJob;
$projects = Project::where('tenant_id', $tenant->id)->get();
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
expect($adapted->first()->regions)->toBe([]);
});
test('sticky auth error throws and sends critical alert email', function (): void {
Mail::fake();
Bus::fake([RefreshSupplierSessionJob::class]);
@@ -346,3 +377,38 @@ test('sticky auth error throws and sends critical alert email', function (): voi
return $mail->alertType === 'sticky_auth';
});
});
test('outbound: copies project regions[] into supplier_project current_regions via full handle()', function (): void {
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'regions-flow.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'regions-flow.example.com',
'supplier_b1_project_id' => $sp->id,
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [82, 83],
'region_mask' => 255,
'region_mode' => 'include',
]);
Http::fake([
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 556], 200),
]);
(new SyncSupplierProjectsJob)->handle();
$sp->refresh();
expect($sp->current_regions)->toBe([82, 83])
->and($sp->supplier_external_id)->toBe('556');
});
+43 -3
View File
@@ -1,8 +1,46 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminBillingView from '../../resources/js/views/admin/AdminBillingView.vue';
import { ADMIN_BILLING_TENANTS, ADMIN_BILLING_SUMMARY } from '../../resources/js/composables/mockAdmin';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
listAdminBilling: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(adminApi.listAdminBilling).mockResolvedValue({
tenants: ADMIN_BILLING_TENANTS.map((r) => ({
id: r.id,
subdomain: `tenant${r.id}`,
organization_name: r.name,
contact_email: `t${r.id}@test.io`,
status: r.status === 'overdue' ? 'active' : r.status,
balance_rub: String(r.balance_rub),
tariff_id: 1,
tariff_name: { start: 'Старт', basic: 'Базовый', pro: 'Команда', enterprise: 'Enterprise' }[r.tariff] ?? r.tariff,
mrr_rub: String(r.mrr_rub),
monthly_topups_rub: String(r.monthly_topups_rub),
monthly_charges_rub: String(r.monthly_charges_rub),
last_payment_at: r.last_payment_at,
chargeback_unrecovered_rub: r.status === 'overdue' ? '1.00' : '0.00',
})),
summary: {
total_mrr_rub: String(ADMIN_BILLING_SUMMARY.total_mrr_rub),
monthly_revenue_rub: String(ADMIN_BILLING_SUMMARY.monthly_revenue_rub),
overdue_count: ADMIN_BILLING_SUMMARY.overdue_count,
refunds_count_30d: ADMIN_BILLING_SUMMARY.refunds_count_30d,
},
});
});
const mountView = async () => {
const router = createRouter({
@@ -11,9 +49,11 @@ const mountView = async () => {
});
await router.push('/admin/billing');
await router.isReady();
return mount(AdminBillingView, {
const wrapper = mount(AdminBillingView, {
global: { plugins: [createVuetify(), router] },
});
await flushPromises();
return wrapper;
};
describe('AdminBillingView.vue', () => {
@@ -93,7 +93,7 @@ describe('AdminBillingView ↔ GET /api/admin/billing integration', () => {
expect(vm.summary.refunds_count_30d).toBe(3);
});
it('reject → fetchError=true + alert виден + MOCK fallback остаётся', async () => {
it('reject → fetchError=true + alert виден + rowsState пустой', async () => {
vi.mocked(adminApi.listAdminBilling).mockRejectedValueOnce(new Error('500'));
const wrapper = mountView();
@@ -101,7 +101,7 @@ describe('AdminBillingView ↔ GET /api/admin/billing integration', () => {
const vm = wrapper.vm as unknown as { fetchError: boolean; rowsState: unknown[] };
expect(vm.fetchError).toBe(true);
expect(vm.rowsState.length).toBeGreaterThan(0);
expect(vm.rowsState.length).toBe(0);
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
+50 -4
View File
@@ -1,8 +1,52 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminIncidentsView from '../../resources/js/views/admin/AdminIncidentsView.vue';
import { ADMIN_INCIDENTS } from '../../resources/js/composables/mockAdmin';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
listAdminIncidents: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(adminApi.listAdminIncidents).mockResolvedValue({
incidents: ADMIN_INCIDENTS.map((r) => ({
id: r.id,
incident_id: r.incident_id,
type: r.category as string,
severity: r.severity,
summary: r.title,
started_at: r.detected_at,
detected_at: r.detected_at,
resolved_at: null,
status: (r.status === 'closed' ? 'resolved' : r.status) as 'open' | 'investigating' | 'resolved',
affected_tenants_count: r.affected_tenants,
affected_users_count: null,
rkn_notified: r.rkn_notified,
rkn_notified_at: null,
rkn_deadline_at: r.rkn_deadline_at,
})),
total: ADMIN_INCIDENTS.length,
limit: 100,
offset: 0,
summary: {
open: ADMIN_INCIDENTS.filter((r) => r.status === 'open').length,
investigating: ADMIN_INCIDENTS.filter((r) => r.status === 'investigating').length,
rkn_pending: ADMIN_INCIDENTS.filter(
(r) => ['pdn_breach', 'data_breach'].includes(r.category) && !r.rkn_notified,
).length,
total_unresolved: ADMIN_INCIDENTS.filter((r) => r.status !== 'resolved' && r.status !== 'closed').length,
},
});
});
const mountView = async () => {
const router = createRouter({
@@ -14,7 +58,9 @@ const mountView = async () => {
});
await router.push('/admin/incidents');
await router.isReady();
return { wrapper: mount(AdminIncidentsView, { global: { plugins: [createVuetify(), router] } }), router };
const wrapper = mount(AdminIncidentsView, { global: { plugins: [createVuetify(), router] } });
await flushPromises();
return { wrapper, router };
};
describe('AdminIncidentsView.vue', () => {
@@ -57,7 +103,7 @@ describe('AdminIncidentsView.vue', () => {
it('клик по строке инцидента вызывает router.push на admin-incident-detail', async () => {
const { wrapper, router } = await mountView();
const pushSpy = vi.spyOn(router, 'push');
// get first row — mock data has id from ADMIN_INCIDENTS[0]
// get first row — populated via API mock
const vm = wrapper.vm as unknown as { rowsState: Array<{ id: number }> };
const firstId = vm.rowsState[0].id;
const row = wrapper.find(`[data-testid="incident-row-${firstId}"]`);
@@ -97,7 +97,7 @@ describe('AdminIncidentsView ↔ GET /api/admin/incidents integration', () => {
expect(vm.stats.rkn_pending).toBe(1);
});
it('reject → fetchError=true + alert виден + MOCK fallback', async () => {
it('reject → fetchError=true + alert виден + rowsState пустой', async () => {
vi.mocked(adminApi.listAdminIncidents).mockRejectedValueOnce(new Error('500'));
const wrapper = mountView();
@@ -105,7 +105,7 @@ describe('AdminIncidentsView ↔ GET /api/admin/incidents integration', () => {
const vm = wrapper.vm as unknown as { fetchError: boolean; rowsState: unknown[] };
expect(vm.fetchError).toBe(true);
expect(vm.rowsState.length).toBeGreaterThan(0);
expect(vm.rowsState.length).toBe(0);
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
+16
View File
@@ -60,6 +60,10 @@ const mountAdminLayout = async (path = '/admin/tenants', user: AuthUser | null =
};
describe('AdminLayout.vue', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('монтируется без ошибок', async () => {
const { wrapper } = await mountAdminLayout();
expect(wrapper.exists()).toBe(true);
@@ -214,4 +218,16 @@ describe('AdminLayout.vue', () => {
const nav = wrapper.find('[aria-label="Админ навигация"]');
expect(nav.exists()).toBe(true);
});
it('B6: показывает DEV-баннер auth-gap в dev-режиме', async () => {
vi.stubEnv('DEV', true);
const { wrapper } = await mountAdminLayout();
expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(true);
});
it('B6: скрывает DEV-баннер в production-режиме', async () => {
vi.stubEnv('DEV', false);
const { wrapper } = await mountAdminLayout();
expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(false);
});
});
+16 -4
View File
@@ -26,12 +26,14 @@ const mountView = async () => {
});
await router.push('/admin/system');
await router.isReady();
return mount(AdminSystemView, {
const wrapper = mount(AdminSystemView, {
global: {
plugins: [createVuetify(), router],
stubs: { SystemSettingEditDialog: true },
},
});
await flushPromises();
return wrapper;
};
describe('AdminSystemView.vue', () => {
@@ -120,15 +122,15 @@ describe('AdminSystemView.vue', () => {
expect(adminApi.listSystemSettings).toHaveBeenCalledTimes(1);
});
it('при сетевой ошибке показывает warning-banner + сохраняет mock-данные', async () => {
it('при сетевой ошибке показывает warning-banner + settingsState пустой', async () => {
vi.mocked(adminApi.listSystemSettings).mockRejectedValueOnce(new Error('Network down'));
const wrapper = await mountView();
await flushPromises();
const banner = wrapper.find('[data-testid="fetch-error-alert"]');
expect(banner.exists()).toBe(true);
// Mock-настройки остались (fallback)
// Пустой при ошибке — без mock-fallback
const rows = wrapper.findAll('[data-testid="setting-row"]');
expect(rows.length).toBe(7);
expect(rows.length).toBe(0);
});
it('onSettingUpdated обновляет value и updated_at в settingsState', async () => {
@@ -147,4 +149,14 @@ describe('AdminSystemView.vue', () => {
expect(row?.value).toBe('7');
expect(row?.updated_at).toBe('2026-05-09T11:30:00');
});
it('G9: edit-кнопки имеют aria-label с ключом настройки', async () => {
const wrapper = await mountView();
const editBtns = wrapper.findAll('[data-testid^="edit-"]');
expect(editBtns.length).toBeGreaterThan(0);
for (const btn of editBtns) {
const label = btn.attributes('aria-label') ?? '';
expect(label).toMatch(/^Изменить настройку .+/);
}
});
});
+91 -34
View File
@@ -1,12 +1,33 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminTenantsView from '../../resources/js/views/admin/AdminTenantsView.vue';
import { MOCK_STATS, MOCK_TENANTS } from '../../resources/js/composables/mockTenants';
import { MOCK_STATS, MOCK_TENANTS, type AdminTenant } from '../../resources/js/composables/mockTenants';
// Мокаем api/admin: listAdminTenants возвращает пустой ответ —
// smoke-тесты затем seed'ят tenantsState/stats напрямую через vm (defineExpose).
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
listAdminTenants: vi.fn().mockResolvedValue({
tenants: [],
total: 0,
limit: 100,
offset: 0,
stats: { total: 0, active: 0, trial: 0, overdue: 0 },
}),
};
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('AdminTenantsView.vue', () => {
const factory = () => {
/** Монтирует view, ждёт mount-цикл, затем seed'ит state фикстурами. */
const factory = async () => {
// useRouter() в AdminTenantsView требует router-context в тестах.
const router = createRouter({
history: createMemoryHistory(),
@@ -15,22 +36,34 @@ describe('AdminTenantsView.vue', () => {
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
],
});
return mount(AdminTenantsView, {
await router.push('/admin/tenants');
await router.isReady();
const wrapper = mount(AdminTenantsView, {
global: {
plugins: [createVuetify(), router],
// ImpersonationDialog stubим — внутри использует api/admin axios.
stubs: { ImpersonationDialog: true },
},
});
await flushPromises();
// Seed state напрямую через defineExpose — имитирует успешную загрузку с теми же фикстурами.
const vm = wrapper.vm as unknown as {
tenantsState: AdminTenant[];
stats: typeof MOCK_STATS;
};
vm.tenantsState.splice(0, vm.tenantsState.length, ...MOCK_TENANTS.map((t) => ({ ...t })));
Object.assign(vm.stats, MOCK_STATS);
await wrapper.vm.$nextTick();
return wrapper;
};
it('монтируется и содержит заголовок «Тенанты»', () => {
const wrapper = factory();
it('монтируется и содержит заголовок «Тенанты»', async () => {
const wrapper = await factory();
expect(wrapper.find('h1').text()).toBe('Тенанты');
});
it('показывает 5 stats: всего/активны/trial/просрочка/выручка', () => {
const wrapper = factory();
it('показывает 5 stats: всего/активны/trial/просрочка/выручка', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain(`${MOCK_STATS.total}`); // 142
expect(text).toContain('всего');
@@ -45,22 +78,22 @@ describe('AdminTenantsView.vue', () => {
expect(text).toMatch(/1\s+248\s+600\s*₽/);
});
it('таблица содержит 7 колонок (Тенант/Статус/Тариф/Баланс/Желаем×факт/MRR/Активность)', () => {
const wrapper = factory();
it('таблица содержит 7 колонок (Тенант/Статус/Тариф/Баланс/Желаем×факт/MRR/Активность)', async () => {
const wrapper = await factory();
const headers = wrapper.findAll('thead th').map((h) => h.text());
['Тенант', 'Статус', 'Тариф', 'Баланс', 'Желаем×факт', 'MRR', 'Активность'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
});
it('рендерит все 7 mock-tenants', () => {
const wrapper = factory();
it('рендерит все 7 mock-tenants', async () => {
const wrapper = await factory();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(MOCK_TENANTS.length);
});
it('первая строка — Окна Москва ООО + ИНН + Активен + Команда', () => {
const wrapper = factory();
it('первая строка — Окна Москва ООО + ИНН + Активен + Команда', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Окна Москва ООО');
expect(text).toContain('ИНН 7724444444');
@@ -68,37 +101,37 @@ describe('AdminTenantsView.vue', () => {
expect(text).toContain('Команда');
});
it('overdue-тенант (Двери Премиум) показывает «Просрочка 3 дня» + отрицательный баланс', () => {
const wrapper = factory();
it('overdue-тенант (Двери Премиум) показывает «Просрочка 3 дня» + отрицательный баланс', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Двери Премиум');
expect(text).toContain('Просрочка 3 дня');
expect(text).toMatch(/1\s+200/); // -1200 без 0 ₽
});
it('trial-тенант (Ремонт под ключ) показывает «Trial · 4 дня» + MRR=—', () => {
const wrapper = factory();
it('trial-тенант (Ремонт под ключ) показывает «Trial · 4 дня» + MRR=—', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Ремонт под ключ');
expect(text).toContain('Trial · 4 дня');
});
it('suspended-тенант (Оконные системы РФ) показывает «Приостановлен»', () => {
const wrapper = factory();
it('suspended-тенант (Оконные системы РФ) показывает «Приостановлен»', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Оконные системы РФ');
expect(text).toContain('Приостановлен');
});
it('содержит search-input с placeholder «ИНН, юр. лицо, email админа…»', () => {
const wrapper = factory();
it('содержит search-input с placeholder «ИНН, юр. лицо, email админа…»', async () => {
const wrapper = await factory();
const input = wrapper.find('input[type="text"]');
expect(input.exists()).toBe(true);
expect(input.attributes('placeholder')).toContain('ИНН');
});
it('фильтр по search оставляет только matching-tenants', async () => {
const wrapper = factory();
const wrapper = await factory();
const input = wrapper.find('input[type="text"]');
await input.setValue('Натяжные');
await wrapper.vm.$nextTick();
@@ -107,15 +140,15 @@ describe('AdminTenantsView.vue', () => {
expect(rows[0].text()).toContain('Натяжные потолки СПб');
});
it('содержит Экспорт-кнопку и фильтры Статус/Тариф', () => {
const wrapper = factory();
it('содержит Экспорт-кнопку и фильтры Статус/Тариф', async () => {
const wrapper = await factory();
expect(wrapper.text()).toContain('Экспорт');
expect(wrapper.find('[data-testid="filter-statuses"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="filter-tariffs"]').exists()).toBe(true);
});
it('фильтр по статусу «overdue» оставляет только просроченных', async () => {
const wrapper = factory();
const wrapper = await factory();
const vm = wrapper.vm as unknown as { filterStatuses: string[] };
vm.filterStatuses = ['overdue'];
await wrapper.vm.$nextTick();
@@ -125,7 +158,7 @@ describe('AdminTenantsView.vue', () => {
});
it('фильтр по тарифу «Pro» оставляет 1 row', async () => {
const wrapper = factory();
const wrapper = await factory();
const vm = wrapper.vm as unknown as { filterTariffs: string[] };
vm.filterTariffs = ['Pro'];
await wrapper.vm.$nextTick();
@@ -135,7 +168,7 @@ describe('AdminTenantsView.vue', () => {
});
it('clearFilters сбрасывает оба фильтра + кнопка «Сбросить» появляется только когда фильтры активны', async () => {
const wrapper = factory();
const wrapper = await factory();
const vm = wrapper.vm as unknown as {
filterStatuses: string[];
filterTariffs: string[];
@@ -152,8 +185,8 @@ describe('AdminTenantsView.vue', () => {
expect(vm.filterTariffs).toEqual([]);
});
it('каждая строка имеет impersonate-кнопку (mdi-account-switch) с уникальным data-testid', () => {
const wrapper = factory();
it('каждая строка имеет impersonate-кнопку (mdi-account-switch) с уникальным data-testid', async () => {
const wrapper = await factory();
// Все 7 mock-tenants должны иметь кнопку
MOCK_TENANTS.forEach((t) => {
const btn = wrapper.find(`[data-testid="impersonate-btn-${t.id}"]`);
@@ -161,8 +194,8 @@ describe('AdminTenantsView.vue', () => {
});
});
it('impersonate-кнопка disabled для suspended-тенанта (Оконные системы РФ id=105)', () => {
const wrapper = factory();
it('impersonate-кнопка disabled для suspended-тенанта (Оконные системы РФ id=105)', async () => {
const wrapper = await factory();
const suspendedBtn = wrapper.find('[data-testid="impersonate-btn-105"]');
expect(suspendedBtn.exists()).toBe(true);
// v-btn disabled-state — атрибут disabled на DOM-элементе
@@ -170,7 +203,7 @@ describe('AdminTenantsView.vue', () => {
});
it('click на impersonate-кнопке открывает ImpersonationDialog с правильным tenant', async () => {
const wrapper = factory();
const wrapper = await factory();
// До click — диалог закрыт (modelValue=false)
const dialogStub = wrapper.findComponent({ name: 'ImpersonationDialog' });
expect(dialogStub.exists()).toBe(true);
@@ -186,4 +219,28 @@ describe('AdminTenantsView.vue', () => {
expect(dialogStub.props('tenant')).toMatchObject({ id: 42, name: 'Окна Москва ООО' });
expect(dialogStub.props('requestedBy')).toBe(1);
});
it('API reject → tenantsState пустой + fetch-error-alert виден', async () => {
const adminApi = await import('../../resources/js/api/admin');
vi.mocked(adminApi.listAdminTenants).mockRejectedValueOnce(new Error('Network error'));
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/tenants', name: 'admin-tenants', component: AdminTenantsView },
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
],
});
await router.push('/admin/tenants');
await router.isReady();
const wrapper = mount(AdminTenantsView, {
global: { plugins: [createVuetify(), router], stubs: { ImpersonationDialog: true } },
});
await flushPromises();
const vm = wrapper.vm as unknown as { fetchError: boolean; tenantsState: unknown[] };
expect(vm.fetchError).toBe(true);
expect(vm.tenantsState.length).toBe(0);
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
});
@@ -103,7 +103,7 @@ describe('AdminTenantsView ↔ GET /api/admin/tenants integration', () => {
expect(vm.stats.trial).toBe(1);
});
it('reject → fetchError=true + alert виден + MOCK_TENANTS остаётся', async () => {
it('reject → fetchError=true + alert виден + tenantsState пустой', async () => {
vi.mocked(adminApi.listAdminTenants).mockRejectedValueOnce(new Error('500'));
const wrapper = await mountView();
@@ -111,7 +111,7 @@ describe('AdminTenantsView ↔ GET /api/admin/tenants integration', () => {
const vm = wrapper.vm as unknown as { fetchError: boolean; tenantsState: unknown[] };
expect(vm.fetchError).toBe(true);
expect(vm.tenantsState.length).toBeGreaterThan(0); // mock-fallback
expect(vm.tenantsState.length).toBe(0); // пустой при ошибке, не mock-fallback
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
+2 -3
View File
@@ -4,7 +4,6 @@ import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import { MOCK_EVENTS } from '../../resources/js/composables/mockDealEvents';
beforeEach(() => {
setActivePinia(createPinia());
@@ -77,10 +76,10 @@ describe('DealDetailDrawer.vue', () => {
expect(text).toMatch(/1\s+850\s*₽/); // sampleDeal.cost = 1850
});
it('рендерит timeline с MOCK_EVENTS (6 событий)', () => {
it('рендерит timeline без событий (без tenantId events пуст — I3)', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(MOCK_EVENTS.length);
expect(items).toHaveLength(0);
});
it('emit-ит update:open=false при close-кнопке', async () => {
@@ -4,7 +4,6 @@ import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import { MOCK_EVENTS } from '../../resources/js/composables/mockDealEvents';
import type { GetDealResponse, ApiDealEvent } from '../../resources/js/api/deals';
vi.mock('../../resources/js/api/deals', async (importOriginal) => {
@@ -49,13 +48,13 @@ function makeApiEvent(overrides: Partial<ApiDealEvent> = {}): ApiDealEvent {
}
describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
it('БЕЗ tenantId — getDeal не вызывается, показываются MOCK_EVENTS', async () => {
it('БЕЗ tenantId — getDeal не вызывается, events пуст (I3)', async () => {
const wrapper = factory({ open: true });
await flushPromises();
expect(dealsApi.getDeal).not.toHaveBeenCalled();
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(MOCK_EVENTS.length);
expect(items).toHaveLength(0);
});
it('С tenantId — getDeal вызывается, events заменяются на API', async () => {
@@ -97,7 +96,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
expect(wrapper.text()).toContain('new → paid');
});
it('getDeal reject → eventsFetchError=true, alert виден, MOCK_EVENTS как fallback', async () => {
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
vi.mocked(dealsApi.getDeal).mockRejectedValueOnce(new Error('500'));
const wrapper = factory({ open: true, tenantId: 1 });
@@ -106,9 +105,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
const vm = wrapper.vm as unknown as { eventsFetchError: boolean };
expect(vm.eventsFetchError).toBe(true);
expect(wrapper.find('[data-testid="events-fetch-error-alert"]').exists()).toBe(true);
// Fallback на MOCK_EVENTS.
// I3: нет mock-fallback — events пуст.
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(MOCK_EVENTS.length);
expect(items).toHaveLength(0);
});
it('open=false → getDeal не вызывается', async () => {
@@ -7,6 +7,7 @@ import DealsView from '../../resources/js/views/DealsView.vue';
import KanbanView from '../../resources/js/views/KanbanView.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { ApiDeal } from '../../resources/js/api/deals';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
vi.mock('../../resources/js/api/deals', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/deals')>();
@@ -86,14 +87,13 @@ const mountKanbanView = () =>
});
describe('DealsView ↔ GET /api/deals integration', () => {
it('БЕЗ auth.user.tenant_id — listDeals не вызывается, fallback на MOCK_DEALS', async () => {
it('БЕЗ auth.user.tenant_id — listDeals не вызывается, dealsState пустой', async () => {
setupAuth(null);
const wrapper = await mountDealsView();
await flushPromises();
expect(dealsApi.listDeals).not.toHaveBeenCalled();
// MOCK_DEALS содержит 12 элементов — fallback виден.
const vm = wrapper.vm as unknown as { dealsState: { id: number }[] };
expect(vm.dealsState.length).toBeGreaterThan(0);
expect(vm.dealsState.length).toBe(0);
});
it('С auth.user.tenant_id — listDeals вызывается с tenantId + replace dealsState', async () => {
@@ -120,7 +120,7 @@ describe('DealsView ↔ GET /api/deals integration', () => {
expect(vm.dealsState.find((d) => d.id === 200)?.name).toBe('Из API #1');
});
it('listDeals reject → fetchError=true, alert виден, MOCK_DEALS остаётся как fallback', async () => {
it('listDeals reject → fetchError=true, alert виден, dealsState пустой', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockRejectedValueOnce(new Error('network'));
@@ -129,7 +129,7 @@ describe('DealsView ↔ GET /api/deals integration', () => {
const vm = wrapper.vm as unknown as { fetchError: boolean; dealsState: unknown[] };
expect(vm.fetchError).toBe(true);
expect(vm.dealsState.length).toBeGreaterThan(0);
expect(vm.dealsState.length).toBe(0);
// Alert виден.
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
@@ -280,6 +280,9 @@ describe('DealsView ↔ GET /api/deals integration', () => {
applyBulkStatus: (slug: string) => Promise<void>;
dealsState: { id: number; statusSlug: string }[];
};
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
await flushPromises();
vm.selected = [1];
await flushPromises();
await vm.applyBulkStatus('paid');
@@ -441,6 +444,9 @@ describe('DealsView ↔ GET /api/deals integration', () => {
applyBulkDelete: () => Promise<void>;
dealsState: { id: number }[];
};
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
await flushPromises();
const before = vm.dealsState.length;
vm.selected = [1, 2];
await flushPromises();
@@ -540,6 +546,9 @@ describe('DealsView ↔ GET /api/deals integration', () => {
dealsState: { id: number }[];
lastDeletedSnapshot: { id: number }[];
};
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
await flushPromises();
const sample = vm.dealsState[0];
vm.selected = [sample.id];
@@ -651,7 +660,7 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
expect(vm.fetchError).toBe(false);
});
it('listDeals reject → fetchError=true, MOCK_DEALS остаётся в колонках', async () => {
it('listDeals reject → fetchError=true, колонки пусты', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockRejectedValueOnce(new Error('500'));
@@ -663,9 +672,8 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
fetchError: boolean;
};
expect(vm.fetchError).toBe(true);
// Хотя бы одна колонка с mock-сделками заполнена (изначальный state).
const filledColumns = Object.values(vm.dealsByStatus).filter((arr) => arr.length > 0);
expect(filledColumns.length).toBeGreaterThan(0);
expect(filledColumns.length).toBe(0);
});
it('reload-btn вызывает listDeals второй раз', async () => {
+76 -4
View File
@@ -4,13 +4,19 @@ import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import DealsView from '../../resources/js/views/DealsView.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import { MOCK_DEALS, type MockDeal } from '../../resources/js/composables/mockDeals';
import * as dealsApi from '../../resources/js/api/deals';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
// Smoke-тесты DealsView с mock-данными.
/** Засевает dealsState фикстурой MOCK_DEALS (имитирует успешный API-ответ). */
function seedDealsState(wrapper: ReturnType<typeof mount>) {
const vm = wrapper.vm as unknown as { dealsState: MockDeal[] };
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
}
const mountDeals = async () => {
setActivePinia(createPinia());
const router = createRouter({
@@ -25,12 +31,16 @@ const mountDeals = async () => {
// layout-injection от v-app. В Vitest vite-plugin-vuetify auto-import не
// работает, layout-context недоступен. Stub'им сам Drawer (тестируется
// отдельно в DealDetailDrawer.spec.ts).
return mount(DealsView, {
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
await flushPromises();
seedDealsState(wrapper);
await flushPromises();
return wrapper;
};
/** Audit C8/F3: монтирует DealsView по произвольному пути (с query-параметрами). */
@@ -42,12 +52,16 @@ const mountDealsViewAt = async (path: string) => {
});
await router.push(path);
await router.isReady();
return mount(DealsView, {
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
await flushPromises();
seedDealsState(wrapper);
await flushPromises();
return wrapper;
};
describe('DealsView.vue', () => {
@@ -295,7 +309,36 @@ describe('DealsView.vue', () => {
// Audit C8/F3: deep-link /deals?openId=
it('route.query.openId открывает drawer соответствующей сделки', async () => {
const openId = MOCK_DEALS[0].id;
const wrapper = await mountDealsViewAt(`/deals?openId=${openId}`);
// Мокаем API чтобы loadDeals заполнил state до вызова openDealFromQuery в onMounted.
vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({
deals: MOCK_DEALS.map((d) => ({
id: d.id,
name: d.name,
phone: d.phone,
status: d.statusSlug,
project_name: d.project,
manager_name: d.manager.name,
cost: d.cost,
created_at: new Date(Date.now() - d.receivedMinutesAgo * 60000).toISOString(),
deleted_at: null,
})),
total: MOCK_DEALS.length,
} as never);
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push(`/deals?openId=${openId}`);
await router.isReady();
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
await flushPromises();
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
expect(vm.drawerOpen).toBe(true);
@@ -372,4 +415,33 @@ test('C3: exportAllFiltered на пустом списке показывает
expect(vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
});
// I3 regression: API reject → dealsState пустой + fetchError=true (нет mock-fallback)
// Faithful-паттерн: auth + mock ДО mount, onMounted сам вызывает loadDeals.
test('I3: loadDeals reject оставляет dealsState пустым и выставляет fetchError', async () => {
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push('/deals');
await router.isReady();
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
await flushPromises();
const vm = wrapper.vm as unknown as {
dealsState: MockDeal[];
fetchError: boolean;
};
expect(vm.dealsState.length).toBe(0);
expect(vm.fetchError).toBe(true);
});
afterEach(() => vi.restoreAllMocks());
+27 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
@@ -52,6 +52,10 @@ describe('ImpersonationDialog.vue', () => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('не рендерит content когда modelValue=false', () => {
const wrapper = factory({ modelValue: false, tenant: sampleTenant });
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
@@ -198,4 +202,26 @@ describe('ImpersonationDialog.vue', () => {
expect(events).toBeDefined();
expect(events?.[0]).toEqual([false]);
});
it('I4 — dev-code-banner НЕ рендерится когда import.meta.env.DEV=false (prod)', async () => {
vi.stubEnv('DEV', false);
vi.mocked(adminApi.impersonationInit).mockResolvedValue({
token_id: 42,
expires_at: '2026-05-09T12:00:00Z',
sent_to_email: 'admin@okna-moscow.ru',
_dev_plain_code: '123456',
});
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
await wrapper.find('[data-testid="reason-input"] textarea').setValue(
'Тикет SUP-12453: клиент сообщил, что в карточке сделки не сохраняется коммент.',
);
await wrapper.find('[data-testid="submit-init-btn"]').trigger('click');
await flushPromises();
// step verify достигнут — devPlainCode='123456' есть, но DEV=false → баннер не рендерится
expect(wrapper.text()).toContain('Код отправлен на email клиента');
expect(wrapper.find('[data-testid="dev-code-banner"]').exists()).toBe(false);
});
});
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createMemoryHistory, createRouter } from 'vue-router';
import KanbanView from '../../resources/js/views/KanbanView.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
// KanbanView содержит DealDetailDrawer (VNavigationDrawer) — требует
// injected layout от v-app, недоступной в Vitest. Stub'им как в KanbanView.spec.ts.
@@ -28,6 +29,12 @@ describe('KanbanView — redesigned', () => {
it('card containers have ld-hover-lift class', async () => {
const w = setup();
await flushPromises();
// Засеваем dealsByStatus (после I3 init пустой — карточек нет)
const vm = w.vm as unknown as { dealsByStatus: Record<string, unknown[]> };
for (const d of MOCK_DEALS) {
(vm.dealsByStatus[d.statusSlug] ??= []).push({ ...d, manager: { ...d.manager } });
}
await flushPromises();
expect(w.html()).toMatch(/ld-hover-lift/);
});
+38 -1
View File
@@ -1,11 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import KanbanView from '../../resources/js/views/KanbanView.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import * as dealsApi from '../../resources/js/api/deals';
import { LEAD_STATUSES } from '../../resources/js/composables/leadStatuses';
import { MOCK_DEALS, type MockDeal } from '../../resources/js/composables/mockDeals';
describe('KanbanView.vue', () => {
// KanbanView содержит DealDetailDrawer (v-navigation-drawer), который требует
@@ -97,6 +98,15 @@ describe('KanbanView.vue', () => {
it('обновляет statusSlug сделки при drop в новую колонку (event.added)', async () => {
const wrapper = factory();
// Засеваем dealsByStatus фикстурой MOCK_DEALS (init теперь пустой).
const vm = wrapper.vm as unknown as { dealsByStatus: Record<string, MockDeal[]> };
for (const deal of MOCK_DEALS) {
if (vm.dealsByStatus[deal.statusSlug]) {
vm.dealsByStatus[deal.statusSlug].push({ ...deal });
}
}
await wrapper.vm.$nextTick();
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
// Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке.
const newCol = cols[0]; // new — sortOrder=1
@@ -114,6 +124,33 @@ describe('KanbanView.vue', () => {
});
});
// I3 regression: API reject → dealsByStatus пустые + fetchError=true (нет mock-fallback)
// Faithful-паттерн: auth + mock ДО mount, onMounted сам вызывает loadDeals.
describe('KanbanView I3 regression', () => {
it('loadDeals reject оставляет dealsByStatus пустыми и выставляет fetchError', async () => {
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as never;
const wrapper = mount(KanbanView, {
global: {
plugins: [createVuetify()],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
await flushPromises();
const vm = wrapper.vm as unknown as {
dealsByStatus: Record<string, MockDeal[]>;
fetchError: boolean;
};
expect(vm.fetchError).toBe(true);
// Все колонки пусты — нет mock-fallback
const allDeals = Object.values(vm.dealsByStatus).flat();
expect(allDeals.length).toBe(0);
});
});
describe('KanbanView DnD persist (Sprint 1 C4)', () => {
beforeEach(() => {
vi.clearAllMocks();
+13
View File
@@ -87,4 +87,17 @@ describe('LoginView.vue', () => {
expect(ssoBtn).toBeDefined();
expect(ssoBtn!.classes()).toContain('v-btn--disabled');
});
it('A9: переключатель видимости пароля имеет accessible-name и работает', async () => {
const wrapper = await mountLoginView();
const toggle = wrapper.find('[aria-label="Показать пароль"]');
expect(toggle.exists()).toBe(true);
expect(toggle.attributes('role')).toBe('button');
await toggle.trigger('click');
expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
// keyboard activation (Enter) — toggle back
await wrapper.find('[aria-label="Скрыть пароль"]').trigger('keydown', { key: 'Enter' });
expect(wrapper.find('[aria-label="Показать пароль"]').exists()).toBe(true);
});
});
+11
View File
@@ -288,6 +288,17 @@ describe('NewDealDialog.vue', () => {
expect(closeEmits === undefined || !closeEmits.some((e) => e[0] === false)).toBe(true);
});
it('I3: без tenantId — projectOptions и managerOptions пусты (нет mock-fallback)', async () => {
const wrapper = factory({ modelValue: true }); // нет tenantId
await flushPromises();
const vm = wrapper.vm as unknown as {
projectOptions: string[];
managerOptions: unknown[];
};
expect(vm.projectOptions).toHaveLength(0);
expect(vm.managerOptions).toHaveLength(0);
});
it('C6: при провале loadLookups показывает degradation-alert', async () => {
vi.spyOn(dealsApi, 'listProjects').mockRejectedValue(new Error('network'));
vi.spyOn(dealsApi, 'listManagers').mockRejectedValue(new Error('network'));
@@ -6,6 +6,16 @@ import axios from 'axios';
vi.mock('axios');
vi.mock('../../resources/js/api/client', () => ({
apiClient: {
post: vi.fn().mockResolvedValue({ data: {} }),
patch: vi.fn().mockResolvedValue({ data: {} }),
},
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
}));
import { apiClient } from '../../resources/js/api/client';
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
import type { Project } from '../../resources/js/stores/projectsStore';
@@ -74,4 +84,24 @@ describe('NewProjectDialog', () => {
it.skip('emits saved event after successful POST', async () => {
// TODO: см. предыдущий skip — те же причины.
});
it('renders regions autocomplete with 89 selectable subjects (excluding "Вся РФ" sentinel)', async () => {
const wrapper = factory();
await flushPromises();
const autocomplete = wrapper.findComponent({ name: 'VAutocomplete' });
expect(autocomplete.exists()).toBe(true);
expect(autocomplete.props('items')).toHaveLength(89);
expect((autocomplete.props('items') as Array<{ code: number }>).every((r) => r.code !== 0)).toBe(true);
});
it('sends regions array in POST payload', async () => {
const wrapper = factory();
await flushPromises();
const autocomplete = wrapper.findComponent({ name: 'VAutocomplete' });
autocomplete.vm.$emit('update:model-value', [82, 83]);
await flushPromises();
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
await flushPromises();
expect(apiClient.post).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ regions: [82, 83] }));
});
});
+18 -19
View File
@@ -24,6 +24,7 @@ const sampleProject: Project = {
archived_at: null,
region_mask: 0,
region_mode: 'include',
regions: [],
delivery_days_mask: 31, // Mon-Fri
sync_status: 'pending',
};
@@ -50,7 +51,7 @@ describe('ProjectDetailsDrawer', () => {
// Days mask 31 = bits 0..4 = Mon..Fri (5 days active)
const dayBtns = wrapper.findAll('button[data-testid^="pdd-day-"]');
expect(dayBtns.length).toBe(7);
const activeBtns = dayBtns.filter(b => b.classes().includes('active'));
const activeBtns = dayBtns.filter((b) => b.classes().includes('active'));
expect(activeBtns.length).toBe(5);
});
@@ -126,8 +127,12 @@ describe('ProjectDetailsDrawer', () => {
});
it('Pause button calls store.toggleActive', async () => {
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: { ...sampleProject, is_active: false } } });
(axios.get as unknown as ReturnType<typeof vi.fn> | undefined)?.mockResolvedValue?.({ data: { data: [], meta: { total: 0 } } });
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { data: { ...sampleProject, is_active: false } },
});
(axios.get as unknown as ReturnType<typeof vi.fn> | undefined)?.mockResolvedValue?.({
data: { data: [], meta: { total: 0 } },
});
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
const store = useProjectsStore();
const spy = vi.spyOn(store, 'toggleActive').mockResolvedValueOnce(undefined);
@@ -174,33 +179,30 @@ describe('ProjectDetailsDrawer', () => {
vi.unstubAllGlobals();
});
it('renders region chips when project has non-zero region_mask', async () => {
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
it('renders region chips for project.regions = [1, 2]', async () => {
const withRegions: Project = { ...sampleProject, regions: [1, 2] };
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
await wrapper.vm.$nextTick();
const text = wrapper.text();
expect(text).toContain('Адыгея');
expect(text).toContain('Башкортостан');
expect(text).toContain('Адыгея'); // code 1
expect(text).toContain('Алтай'); // code 2 (Республика Алтай)
});
it('selecting regions encodes mask + sets mode=exclude on save', async () => {
it('selecting regions adds to regions array (no bitmask conversion)', async () => {
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
await autocomplete.vm.$emit('update:model-value', [3, 5]);
await autocomplete.vm.$emit('update:model-value', [82, 83]);
await wrapper.vm.$nextTick();
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(axios.patch).toHaveBeenCalledWith(
'/api/projects/42',
expect.objectContaining({ region_mask: 40, region_mode: 'exclude' }),
);
expect(axios.patch).toHaveBeenCalledWith('/api/projects/42', expect.objectContaining({ regions: [82, 83] }));
});
it('clearing all regions resets mask=0 + mode=include on save', async () => {
it('clearing all regions sets regions=[] on save', async () => {
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
const withRegions: Project = { ...sampleProject, regions: [82, 83] };
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
await autocomplete.vm.$emit('update:model-value', []);
@@ -208,9 +210,6 @@ describe('ProjectDetailsDrawer', () => {
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(axios.patch).toHaveBeenCalledWith(
'/api/projects/42',
expect.objectContaining({ region_mask: 0, region_mode: 'include' }),
);
expect(axios.patch).toHaveBeenCalledWith('/api/projects/42', expect.objectContaining({ regions: [] }));
});
});
+13
View File
@@ -53,4 +53,17 @@ describe('RegisterView.vue', () => {
const links = wrapper.findAll('a').map((a) => a.text());
expect(links.some((t) => t.includes('Войдите'))).toBe(true);
});
it('A9: переключатель видимости пароля имеет accessible-name и работает', async () => {
const wrapper = await mountRegister();
const toggle = wrapper.find('[aria-label="Показать пароль"]');
expect(toggle.exists()).toBe(true);
expect(toggle.attributes('role')).toBe('button');
await toggle.trigger('click');
expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
// keyboard activation (Enter) — toggle back
await wrapper.find('[aria-label="Скрыть пароль"]').trigger('keydown', { key: 'Enter' });
expect(wrapper.find('[aria-label="Показать пароль"]').exists()).toBe(true);
});
});
@@ -112,4 +112,17 @@ describe('ResetPasswordView.vue', () => {
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Пароли не совпадают');
});
it('A9: переключатель видимости пароля имеет accessible-name и работает', async () => {
const wrapper = await mountReset();
const toggle = wrapper.find('[aria-label="Показать пароль"]');
expect(toggle.exists()).toBe(true);
expect(toggle.attributes('role')).toBe('button');
await toggle.trigger('click');
expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
// keyboard activation (Enter) — toggle back
await wrapper.find('[aria-label="Скрыть пароль"]').trigger('keydown', { key: 'Enter' });
expect(wrapper.find('[aria-label="Показать пароль"]').exists()).toBe(true);
});
});
+11 -24
View File
@@ -15,28 +15,26 @@ describe('SettingsView.vue', () => {
expect(wrapper.find('h1').text()).toBe('Настройки');
});
it('содержит ровно 8 nav-tabs', () => {
it('содержит ровно 4 nav-tabs (placeholder-вкладки убраны, audit D6/D7)', () => {
const wrapper = factory();
const items = wrapper.findAll('.tabs-rail .v-list-item');
expect(items.length).toBe(8);
expect(items.length).toBe(4);
});
it('содержит все 8 названий вкладок', () => {
it('содержит все 4 названия рабочих вкладок', () => {
const wrapper = factory();
const text = wrapper.text();
const labels = [
'Профиль',
'Безопасность',
'Проекты',
'Команда',
'API и Webhook',
'Интеграции',
'Тихие часы',
'Уведомления',
];
const labels = ['Профиль', 'Безопасность', 'API и Webhook', 'Уведомления'];
labels.forEach((l) => expect(text).toContain(l));
});
it('не содержит placeholder-вкладок и текста «В разработке»', () => {
const wrapper = factory();
const railText = wrapper.find('.tabs-rail').text();
['Команда', 'Интеграции', 'Тихие часы'].forEach((l) => expect(railText).not.toContain(l));
expect(wrapper.text()).not.toContain('В разработке');
});
it('по умолчанию показывает вкладку «Профиль»', () => {
const wrapper = factory();
const text = wrapper.text();
@@ -46,17 +44,6 @@ describe('SettingsView.vue', () => {
expect(text).toContain('Тайм-зона');
});
it('placeholder-вкладки показывают «В разработке»', async () => {
const wrapper = factory();
// Кликаем по «Проекты» — placeholder-вкладка.
const items = wrapper.findAll('.tabs-rail .v-list-item');
const projectsItem = items.find((i) => i.text().includes('Проекты'));
expect(projectsItem).toBeDefined();
await projectsItem!.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('В разработке');
});
it('переключение на «Уведомления» показывает матрицу 8×3', async () => {
const wrapper = factory();
const items = wrapper.findAll('.tabs-rail .v-list-item');
+64
View File
@@ -1,6 +1,9 @@
# Глоссарий проекта Лидерра
# Формат: одно слово на строке. Кириллица в нижнем регистре.
# A4 design-tooling integration (v2.8 / v3.8 / v1.22)
iconify
# Бренд и термины проекта
лидерра
liderra
@@ -1321,3 +1324,64 @@ mmdc
inertiajs
Sev
вендоренный
вендорен
# D3 audit-risk tooling integration (Прил. Н #39-40)
unvetted
mcpmarket
behaviour
triada
trailofbits
hackathon
субсет
# A11 ML/AI tooling integration — brainstorming spec + plan (2026-05-17)
CCPM
REU
promptfoo
promptfooconfig
datalayer
scikit
XGBoost
Jupyter
pandas
alirezarezvani
Anthropic
RAG
venv
Helicone
Langfuse
sickn
antigravity
sqlite
воркфлоу
эксцепшн
# SG #40 Security Guidance correction (2026-05-17)
резолва
шим
characterisation
Arclio
Cowork
PRD
automazeio
prds
Vivek
# deptrac architecture-fitness integration (2026-05-17)
deptrac
qossmic
mermaidjs
graphviz
# A3 integration-tooling design spec + plan (2026-05-17)
аудировал
JVM
хендлеров
ivo
redocly
ivotoby
ребейз
ребейзнута
ребейзом
+3 -1
View File
@@ -23,7 +23,9 @@
"package-lock.json",
"*.svg",
"**/*.sql",
".claude/skills/mermaid/**"
".claude/skills/mermaid/**",
".claude/skills/ccpm/**",
".claude/skills/data-scientist/**"
],
"ignoreRegExpList": [
"Email",
+27 -2
View File
@@ -1,11 +1,36 @@
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать записей в обратном хронологическом порядке (v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать одну запись в обратном хронологическом порядке (v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.21, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.22, консолидированная — разворачивает БД с нуля).
**История записей:**
## v8.22 — 2026-05-17 — Plan 6 (C9 — Subject-level regions)
**Изменения:**
- `projects` +1 колонка: `regions INT[] NOT NULL DEFAULT '{}'`
- `projects` +1 GIN-индекс: `idx_projects_regions`
- `projects` +1 COMMENT ON COLUMN на `regions`
**Не изменено (deprecated, удаление в Plan 6.5):**
- `projects.region_mask` (помечен inline-комментарием DEPRECATED)
- `projects.region_mode`
- CHECK `chk_projects_region_mask_range`
**Семантика:**
- `regions=[]` → «вся РФ» (паритет с legacy `region_mask=255 + region_mode='include'`)
- `regions=[82,83]` → проект принимает лиды только из Москвы (82) и Санкт-Петербурга (83)
**Schema baseline после v8.22:** 64 базовых таблиц / 12 партиций / **119 индексов** (+1 GIN) / 40 RLS / 5 функций / 13 триггеров.
**Применение:** инкрементальная миграция `2026_05_17_100000_plan6_regions_subject_level.php` (`ALTER TABLE projects ADD COLUMN regions` + `CREATE INDEX ... USING GIN`, guard'ы `hasColumn` / `IF NOT EXISTS`).
**Связано:** docs/superpowers/specs/2026-05-14-plan-6-regions-subject-level-design.md
## v8.21 — 2026-05-16 — Sprint 4 (историческая миграция лидов §6)
- **+1 таблица** `import_unknown_statuses` (tenant-level маппинг неизвестных статусов CSV; RLS `tenant_isolation`; UNIQUE `(tenant_id, status_ru)`; partial index `idx_import_unknown_statuses_unresolved`).
+13 -3
View File
@@ -1,7 +1,7 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.21 (16.05.2026 — Sprint 4: import_unknown_statuses + import_log enrichment (+5 колонок))
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 118 индексов / 40 RLS-политик / 5 функций / 13 триггеров
-- Версия: v8.22 (17.05.2026 — Plan 6 (C9): projects.regions INT[] subject-level filtering + GIN-индекс idx_projects_regions)
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 119 индексов / 40 RLS-политик / 5 функций / 13 триггеров
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
@@ -809,13 +809,18 @@ CREATE TABLE projects (
supplier_b3_project_id BIGINT,
effective_limit_calculated_at TIMESTAMPTZ,
-- РАСШИРЕНИЕ v8.2: регионы и дни (партия 10.3 секции 6, 7, 11)
region_mask INT NOT NULL DEFAULT 255,
region_mask INT NOT NULL DEFAULT 255, -- DEPRECATED Plan 6.5: см. regions INT[]
-- битмаска 8 ФО РФ: бит 1=Центральный, 2=Северо-Западный, 4=Южный,
-- 8=Северо-Кавказский, 16=Приволжский, 32=Уральский, 64=Сибирский,
-- 128=Дальневосточный. 255 = все 8 округов.
region_mode VARCHAR(10) NOT NULL DEFAULT 'include'
CHECK (region_mode IN ('include','exclude')),
-- 'include' = принимать только из выбранных, 'exclude' = принимать кроме выбранных
-- v8.20 (Plan 6): Subject-level regions array. 89 codes из resources/js/constants/regions.ts.
-- Пустой массив = «вся РФ» (паритет с legacy region_mask=255 + region_mode='include').
-- region_mask/region_mode остаются для legacy reader'ов (PhonePrefixService, LeadRouter),
-- DEPRECATED — удаляются в Plan 6.5 после переключения читателей.
regions INT[] NOT NULL DEFAULT '{}'::INT[],
delivery_days_mask INT NOT NULL DEFAULT 127,
-- битмаска дней недели: бит 1=Пн, 2=Вт, 4=Ср, 8=Чт, 16=Пт, 32=Сб, 64=Вс.
-- 127 = все 7 дней (паритет с формой создания нового проекта в оригинале).
@@ -863,6 +868,8 @@ CREATE INDEX idx_projects_tag ON projects(tag);
-- РАСШИРЕНИЕ v8.12: composite index для lookup по signal-полям (resolveSignalSource)
CREATE INDEX idx_projects_tenant_signal
ON projects(tenant_id, signal_type, signal_identifier);
-- v8.20 (Plan 6): GIN-индекс для outbound regions queries.
CREATE INDEX idx_projects_regions ON projects USING GIN (regions);
COMMENT ON COLUMN projects.daily_limit_target IS
'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на '
@@ -874,6 +881,9 @@ COMMENT ON COLUMN projects.effective_daily_limit_today IS
'MIN(daily_limit_target, FLOOR(balance / lead_cost)). Пересчитывается cron '
'limits:recalc в 00:00 МСК и при изменении баланса. NULL = не считалось.';
COMMENT ON COLUMN projects.regions IS
'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.22).';
-- -----------------------------------------------------------------------------
-- supplier_projects — SaaS-level агрегатные проекты у поставщиков (v8.13, Plan 1/5 Task 2)
+38 -1
View File
@@ -1,8 +1,20 @@
# Plugin Stack Rules — Superpowers + Frontend Design (v3.3)
# Plugin Stack Rules — Superpowers + Frontend Design (v3.10)
**Дата:** 17.05.2026
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3).
**v3.10** — A11 ml-ai-tooling: R10.1 Блок 3 +1 строка **Jupyter MCP** (DEFERRED — требует Python ML-окружения; ml-ai-tooling, off-phase, раздел A11 карты) + Блок 1 note (v3.10) — **promptfoo** (npm devDependency `promptfoo`, CLI-eval LLM-промптов) + **Data Scientist skill** (вендоренный сторонний скил `.claude/skills/data-scientist/`). Десятая off-phase подкатегория ml-ai-tooling. Не UI → вне R6/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.10, Pravila v1.24, CLAUDE.md v2.10; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
**v3.9** — A3 integration-tooling: R10.1 Блок 3 +1 строка **openapi-mcp-server** (категория integration-tooling, off-phase, раздел A3 карты, stdio MCP, server `openapi` в `.mcp.json`, Tooling §4.22 #47). Не UI → вне R6/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.9, Pravila v1.23, CLAUDE.md v2.9; план `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`.
**v3.7** — A6-расширение deptrac: R10.1 Блок 1 +note «Блок 1 — note (v3.7)» — **deptrac** (`deptrac/deptrac` v4.6.1, Composer dev-dependency, **не** marketplace-плагин и **не** в `enabledPlugins` — регистрируется нотой, как mermaid-skill/CCPM). Категория **architecture-tooling** (Tooling #43, раздел A6 карты) — 4-й инструмент подкатегории; не UI → вне R6.0/R6.1/R14. deptrac врезан как lefthook pre-commit job 10. Содержательных изменений R0–R14: 0. Связано: Tooling v2.7, Pravila v1.21, CLAUDE.md v2.7; план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`.
**v3.6** — C9 project-management: R10.1 Блок 1 (`enabledPlugins`) +2 строки — **CCPM** (`automazeio/ccpm`, вендорен в `.claude/skills/ccpm/`) + **product-management** (`anthropics/knowledge-work-plugins`, Anthropic Verified). Блок 1 +note про **CCPM** (вендоренный скил, аналог mermaid-skill). Новая категория **project-management** (Tooling #41-42, раздел C9 карты) — не UI → вне R6.0/R6.1/R14, как architecture-tooling/audit-security. Содержательных изменений R0–R14: 0. Связано: Tooling v2.6, Pravila v1.20, CLAUDE.md v2.6; план `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`.
**v3.5** — фактическая правка R10.1 Блок 1 строки **security-guidance**: это **блокирующий** PreToolUse-хук (`sys.exit(2)`, одноразовый speed-bump per «файл+правило» за сессию, retry проходит), не warn-only. Содержательных изменений R0–R14: 0. Связано: Tooling v2.5, Pravila v1.19, CLAUDE.md v2.5; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`.
**v3.4** — D3 audit-security: R10.1 Блок 1 (`enabledPlugins`) +2 строки — **Trail of Bits Skills** (`trailofbits/skills`, субсет 8 плагинов) + **security-guidance** (`anthropics/claude-plugins-official`). Новая категория **audit-security** (Tooling #39-40, раздел D3 карты) — не UI → вне R6.0/R6.1/R14, как debug-runtime/infrastructure/architecture-tooling. Содержательных изменений R0–R9/R11–R14: 0. Связано: Tooling v2.4, Pravila v1.18, CLAUDE.md v2.4.
**v3.3** — A6 architecture-tooling: R10.1 Блок 1 (`enabledPlugins`) +2 строки — **adr-kit** (`rvdbreemen/adr-kit`) + **architecture-patterns** (`secondsky/claude-skills`); Блок 1 +note про **mermaid-skill** (вендоренный сторонний скил). Новая категория **architecture-tooling** (Tooling #36-38, раздел A6 карты) — не UI → вне R6.0/R6.1/R14, как debug-runtime/infrastructure. Содержательных изменений R0–R9/R11–R14: 0. Связано: Tooling v2.3, Pravila v1.17, CLAUDE.md v2.3.
**v3.2** — реколлаж R0: sub-policy → top-of-stack gate (ruflo не entry-point по факту рантайма: 0 задач, рой idle). R0 title восстановлен, уровень −1 убран из R0.1 таблицы, R0.2 абзац перед gate-диаграммой возвращён к stack-gate формулировке. Связано: Pravila v1.16, CLAUDE.md v2.2, Tooling v2.2.
@@ -396,9 +408,20 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **claude-md-management** *(skills `claude-md-improver` + `revise-claude-md`)* | `anthropics/claude-plugins-official` | инфраструктурный плагин для CLAUDE.md edits | **обязательно** при любом изменении CLAUDE.md (выполнение CLAUDE.md §5 п.10). Не альтернатива stack'у, а инструмент внутри stack-фазы «реализация». Категория: **инфраструктурная** (вне UI-пула Pravila §13) |
| **adr-kit** *(8 skills + агент `adr-generator`)* | `rvdbreemen/adr-kit` | Architecture Decision Records — `/adr-kit:adr` авторинг, `/adr-kit:lint` проверка, `adr-judge` enforcement. Категория: **architecture-tooling** (Tooling #36, вне UI-пула) | при авторинге/ревизии архитектурного решения в `docs/adr/`. `adr-judge` врезан в lefthook job 9 (декларативно, без `--llm`). Не UI → вне R6.0/R6.1/R14 |
| **architecture-patterns** *(1 skill)* | `secondsky/claude-skills` | справочник архитектурных паттернов (Clean / Hexagonal / layered / DDD). Категория: **architecture-tooling** (Tooling #38). Knowledge-only, не решатель | при проектировании/рефакторинге подсистемы — справка по паттернам. Не источник истины (R11), как UPM |
| **Trail of Bits Skills** *(субсет 8 плагинов)* | `trailofbits/skills` (marketplace `trailofbits`) | аудит безопасности — security-аудит diff, supply-chain риск зависимостей, поиск вариантов уязвимостей. Категория: **audit-security** (Tooling #39, вне UI-пула). CC-BY-SA-4.0, marketplace-плагин (не вендорен) | при глубокой аудит-кампании раздела D3 «Аудит и управление рисками». Не UI → вне R6.0/R6.1/R14 |
| **security-guidance** *(1 PreToolUse-хук, блокирующий)* | `anthropics/claude-plugins-official` | inline-предупреждения уязвимостей при правке кода — **блокирующий** хук (`sys.exit 2`, одноразовый speed-bump per «файл+правило» за сессию, retry проходит), 8 контентных правил + 1 path-правило. Категория: **audit-security** (Tooling #40) | автоматически — PreToolUse-хук на Write/Edit/MultiEdit. Не решатель, не UI → вне R6.0/R6.1/R14 |
| **CCPM** *(vendored standalone skill, `/pm` flow, 14 bash-скриптов)* | `automazeio/ccpm` (вендорен в `.claude/skills/ccpm/`) | PRD→эпик→GitHub-issue→код с полной трассируемостью. GitHub-issue-backed модель (ADR-004). PRD/epic store в `.claude/prds/`/`.claude/epics/`. Категория: **project-management** (Tooling #41, вне UI-пула). Bus-factor mitigation — вендорен (community-проект). 0 хуков | при авторинге PRD/epic и создании GitHub-issue из CCPM flow. Не UI → вне R6.0/R6.1/R14 |
| **product-management** *(6 команд `/write-spec`, `/roadmap-update` и др.)* | `anthropics/knowledge-work-plugins` (plugin `product-management@knowledge-work-plugins`, Anthropic Verified) | product-strategy церемонии (problem→spec, roadmap, stakeholder updates, research synthesis, competitive analysis, metrics review). Категория: **project-management** (Tooling #42). 0 хуков | при product-strategy work: написание спеки, обновление роадмапа, анализ конкурентов. Не UI → вне R6.0/R6.1/R14 |
| **Design plugin** *(Design Critique / Accessibility Audit / UX Writing / Research Synthesis)* | `anthropics/knowledge-work-plugins` (Anthropic Verified) | дизайн-критика и UX — ревью макетов, дизайн-уровневый a11y-аудит, UX-копирайт, research synthesis. Категория: **design-tooling** (Tooling #46, вне UI-пула) | при дизайн-критике макета, UX-анализе, написании микрокопирайта — pre-code (ADR-006). Не подменяет FD #30 (генерация) и `requesting-code-review`. Не UI → вне R6.0/R6.1/R14 |
**Блок 1 — note (v3.3):** **mermaid-skill** (Tooling #37, генератор C4/architecture-диаграмм) — вендоренный сторонний скил в `.claude/skills/mermaid/` (`WH-2099/mermaid-skill`, MIT), **не** через marketplace и **не** в `enabledPlugins`. Пассивная утилита (генерация Mermaid-исходника), не решатель — формально вне типологии трёх блоков; регистрируется здесь для полноты. Категория **architecture-tooling**, вне R6/R14.
**Блок 1 — note (v3.6):** **CCPM** (Tooling #41) — аналогично mermaid-skill: вендоренный сторонний скил в `.claude/skills/ccpm/` (`automazeio/ccpm`, MIT), **не** через marketplace и **не** в `enabledPlugins`. Активируется через `/pm` в Claude Code. Категория **project-management**, вне R6/R14.
**Блок 1 — note (v3.7):** **deptrac** (Tooling #43, архитектурный fitness-гейт) — Composer dev-dependency (`deptrac/deptrac` v4.6.1, BSD-3), **не** marketplace-плагин и **не** в `enabledPlugins`; врезан как lefthook pre-commit job 10 (`deptrac analyse` на staged `app/**/*.php`). CLI-инструмент статического анализа направления зависимостей между слоями — не решатель; формально вне типологии трёх блоков, регистрируется здесь для полноты. Категория **architecture-tooling** (как adr-kit/architecture-patterns), вне R6.0/R6.1/R14.
**Блок 1 — note (v3.10):** **promptfoo** (Tooling #48, ml-ai-tooling) — npm devDependency (`promptfoo`, MIT) в корневом `package.json`, **не** marketplace-плагин и **не** в `enabledPlugins`; CLI-инструмент eval LLM-промптов, запуск `npx promptfoo` вручную/CI (платные LLM-вызовы — никогда в хук, ML1). **Data Scientist skill** (Tooling #49, ml-ai-tooling) — аналогично mermaid-skill/CCPM: вендоренный сторонний скил в `.claude/skills/data-scientist/` (`sickn33/antigravity-awesome-skills`, код MIT / контент CC BY 4.0), **не** через marketplace. Оба формально вне типологии трёх блоков, регистрируются здесь для полноты. Категория **ml-ai-tooling** (раздел A11 карты), вне R6.0/R6.1/R14.
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
@@ -428,6 +451,10 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **github** | `.mcp.json` (HTTP `api.githubcopilot.com/mcp`, требует `GITHUB_TOKEN`) | официальный hosted GitHub MCP — issues, PRs, search-code, list-commits и т.д. | при работе с GitHub-объектами (PR, issues, branches) |
| **sentry** *(`@sentry/mcp-server@0.33.0+`, official; tools `mcp__sentry__*`)* | `.mcp.json` (env `SENTRY_URL` + `SENTRY_AUTH_TOKEN` через PowerShell User scope) | **debug-runtime MCP** — production error investigation в self-hosted Sentry (Yandex Cloud per CLAUDE.md §2). Категория **debug-runtime** (v2.1+) — отдельная от UI-пула (UPM/21st) и инфраструктурного (claude-md-management). Tooling #34. Pending Sentry instance deployment (Б-1) | при investigation runtime error / post-incident debug. READ-ONLY scope (`org:read`/`project:read`/`event:read`). Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов |
| **redis** *(`@modelcontextprotocol/server-redis@2025.4.25`, deprecated Anthropic source)* | `.mcp.json` (`redis://localhost:6379` к Memurai Windows service) | **debug-runtime MCP** — Redis/Memurai inspection (очереди, кэш, Pest --parallel race conditions per quirk 72/77). Категория **debug-runtime** (v2.1+). Tooling #35. Memurai PONG verified Task 4. Migration plan на community alternative (e.g., `@easy-mcps/redis-mcp-server@1.0.8`) post-MVP | при debug Redis runtime. **READ-ONLY usage обязателен** — никаких DEL/FLUSHDB/SET/LPUSH из Claude. Manual mutations — через `memurai-cli` напрямую заказчиком. Cosmetic deprecation warning в stderr |
| **Universal Icons MCP** *(`universal-icons` сервер, tools `search_icons`/`get_icon`/`health_check`)* | `.mcp.json` (`npx -y mcp-universal-icons`, MIT) | поиск/вставка SVG-иконок — 10 коллекций включая Lucide (брендовый icon-set). Категория: **design-tooling** (Tooling #45) | при поиске иконки для Vue-компонента. НЕ запрашивать jsx/Tailwind-формат (R6.0). Материал, не решатель (R10.2). Вне R14 pipeline |
| **Figma MCP** *(remote `https://mcp.figma.com/mcp`)***DEFERRED** | `.mcp.json` (HTTP-транспорт, OAuth) — не установлен, precondition: Figma-аккаунт | извлечение дизайн-токенов/variables из Figma-источника (`get_variable_defs`). **Extract-only** (ADR-006) — code-gen не используется. Категория: **design-tooling** (Tooling #44) | DEFERRED (FM2 — у проекта нет Figma-файла). При появлении Figma-аккаунта. Extract-only — FD #30 остаётся UI-решателем. Вне R6.0/R6.1/R14 |
| **openapi-mcp-server** *(`openapi` сервер, tools `mcp__openapi__*`)* | `.mcp.json` (stdio MCP, env `OPENAPI_SPEC_URL` или локальный файл) | **integration-tooling MCP** — OpenAPI/Swagger-спецификации интеграций (inspect, introspect внешних API). Категория: **integration-tooling** (Tooling §4.22 #47). Раздел A3 карты «Программирование — интеграции (API, вебхуки)». Off-phase | при работе с внешними API-интеграциями (introspection спецификаций). **READ-ONLY introspection** — не мутировать внешние API из Claude. Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов. Вне R6/R14 |
| **Jupyter MCP** *(`jupyter` сервер)***DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: Python ML-окружение | **ml-ai-tooling MCP** — исполняемые ноутбуки (классический ML: обучение моделей). Категория: **ml-ai-tooling** (Tooling §4.25 #50). Раздел A11 карты «ML / AI-разработка». Off-phase | DEFERRED — на native-Windows машине нет Python ML-рантайма и нет модели для обучения. Зарегистрирован как pending-слот (как Figma MCP); устанавливается отдельной severable-задачей при появлении конкретной модели. Вне R6/R14 |
**Отмена:** через удаление из `~/.claude.json` или `.mcp.json`. Live-override через `/команду` для MCP не предусмотрен — MCP-серверы не имеют slash-интерфейса.
@@ -753,6 +780,16 @@ Pipeline активируется при одновременном выполн
## История версий
- **v3.8 (2026-05-17)** — A4 design-tooling: R10.1 Блок 1 +Design plugin (`anthropics/claude-plugins-official`, Anthropic Verified) — дизайн-критика и UX, новая 8-я off-phase подкатегория design-tooling; Блок 3 +Universal Icons MCP (`npx -y mcp-universal-icons`, MIT) + Figma MCP (remote `https://mcp.figma.com/mcp`, DEFERRED). Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.8, Pravila v1.22, CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`.
- **v3.7 (2026-05-17)** — A6-расширение deptrac: R10.1 Блок 1 +note «Блок 1 — note (v3.7)» — **deptrac** (`deptrac/deptrac` v4.6.1, BSD-3, Composer dev-dependency — **не** marketplace-плагин и **не** в `enabledPlugins`, регистрируется нотой как mermaid-skill/CCPM; врезан lefthook pre-commit job 10). Категория **architecture-tooling** (Tooling #43, раздел A6 карты) — 4-й инструмент подкатегории, не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.7, Pravila v1.21, CLAUDE.md v2.7. План `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`.
- **v3.6 (2026-05-17)** — C9 project-management: R10.1 Блок 1 (`enabledPlugins`) +2 строки (**CCPM** `automazeio/ccpm` вендорен в `.claude/skills/ccpm/`, **product-management** `anthropics/knowledge-work-plugins` Anthropic Verified) + Блок 1 note про CCPM (vendored скил, аналог mermaid-skill). Новая категория **project-management** (Tooling #41-42, раздел C9 карты «Управление проектами») — 7-я off-phase подкатегория, не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.6, Pravila v1.20, CLAUDE.md v2.6. План `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`.
- **v3.5 (2026-05-17)** — фактическая правка R10.1 Блок 1 (security-guidance): хук **блокирующий** (`sys.exit(2)`, одноразовый speed-bump per «файл+правило» за сессию), не warn-only. Содержательных изменений R0–R14: 0. Связано: Tooling v2.5, Pravila v1.19, CLAUDE.md v2.5. План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`.
- **v3.4 (2026-05-17)** — D3 audit-security: R10.1 Блок 1 (`enabledPlugins`) +2 строки (Trail of Bits Skills #39, security-guidance #40) — новая 6-я off-phase подкатегория audit-security. Связано: Tooling v2.4, Pravila v1.18, CLAUDE.md v2.4. План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`.
- **v3.3 (2026-05-17)** — A6 architecture-tooling: R10.1 Блок 1 (`enabledPlugins`) +2 строки — **adr-kit** (`rvdbreemen/adr-kit`, 8 skills + агент `adr-generator`; `adr-judge` врезан в lefthook pre-commit job 9 декларативно, без `--llm` → 0 вызовов Claude API) + **architecture-patterns** (`secondsky/claude-skills`, knowledge-only справочник паттернов). Блок 1 +note про **mermaid-skill** (вендоренный сторонний скил `.claude/skills/mermaid/`, генератор C4-диаграмм — пассивная утилита вне типологии 3 блоков). Новая категория **architecture-tooling** (Tooling #36-38, раздел A6 карты «Архитектура систем») — не UI → вне R6.0/R6.1/R14 pipeline, как debug-runtime и infrastructure. Содержательных изменений R0–R9/R11–R14: 0. Связано: Tooling v2.2→v2.3, Pravila v1.16→v1.17, CLAUDE.md v2.2→v2.3. Через manual Edit. План `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`.
- **v3.2 (2026-05-16)** — реколлаж R0: sub-policy → top-of-stack gate (ruflo не entry-point по факту рантайма: 0 задач, рой idle). **Изменено:** R0 title → «Stack-gate: paired-stack delegation pattern»; R0.1 таблица — удалена строка уровня −1 (ruflo entry-point), строка уровня 3 (PSR_v1) → «— (PSR_v1 — сам stack-документ, вопрос неприменим)»; R0.1 преамбула — убраны формулировки sub-policy-под-ruflo, stack снова головной над уровнями 4–6; R0.2 абзац перед диаграммой — возвращён к stack-gate формулировке; шапка cross-refs: CLAUDE.md v2.0+ → v2.2+, Pravila v1.15+ → v1.16+, Tooling v2.0+ → v2.2+. ASCII-диаграмма (STACK GATE) и R0.5 не тронуты. **R0.6 п.11 удалён** (ruflo autonomous-routing hard-stop — висячая ссылка на ruflo как маршрутизатор задач; противоречит реколлажу: ruflo не entry-point, рой idle, 0 задач). Связано: Pravila v1.16 / CLAUDE.md v2.2 / Tooling v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`.
+35 -4
View File
@@ -1,10 +1,24 @@
# Правила работы Claude в проекте «Лидерра»
**Версия:** v1.17 (17.05.2026)
**Версия:** v1.24 (17.05.2026)
**Дата:** 17.05.2026
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
**Что изменилось в v1.24 относительно v1.23:** §13.2 +абзац «Off-phase ml-ai-tooling» — формализованы инструменты раздела A11 карты «ML / AI-разработка» (#48 promptfoo, #49 Data Scientist skill, #50 Jupyter MCP DEFERRED) как десятая off-phase подкатегория; promptfoo делает платные LLM-вызовы — только вручную/CI, никогда в хук (ML1). Границы — ADR-007. Связано: Tooling v2.10 / PSR_v1 v3.10 / CLAUDE.md v2.10. План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
**Что изменилось в v1.23 относительно v1.22:** §13.2 +абзац «Off-phase integration-tooling» — формализованы инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» (#47 openapi-mcp-server, api-docs agent) как девятая off-phase подкатегория; READ-ONLY introspection. Связано: Tooling v2.9 / PSR_v1 v3.9 / CLAUDE.md v2.9. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`.
**Что изменилось в v1.22 относительно v1.21:** §13.2 +абзац «Off-phase design-tooling» — формализованы 3 инструмента раздела A4 карты «Дизайн (UI/UX, графика, бренд)» (#44 Figma MCP DEFERRED, #45 Universal Icons MCP, #46 Design plugin) как восьмая off-phase подкатегория; §13.2 PSR_v1 cross-ref синхронизирован → v3.8+. Связано: Tooling v2.8 / PSR_v1 v3.8 / CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`.
**Что изменилось в v1.21 относительно v1.20:** §13.2 абзац «Off-phase architecture-tooling» расширен — формализован 4-й инструмент раздела A6 карты «Архитектура систем» (#43 deptrac, Composer dev-dependency `deptrac/deptrac` v4.6.1; архитектурный fitness-гейт направления зависимостей / границ слоёв, врезан в lefthook pre-commit job 10). Категория architecture-tooling без изменений (та же пятая off-phase). Связано: Tooling v2.7 / PSR_v1 v3.7 / CLAUDE.md v2.7; план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`.
**Что изменилось в v1.20 относительно v1.19:** §13.2 +абзац «Off-phase project-management» — формализованы 2 инструмента раздела C9 карты «Управление проектами» (#41 CCPM, #42 product-management) как седьмая off-phase категория; §13.2 PSR_v1 cross-ref v3.5+ → v3.6+. Связано: Tooling v2.6 / PSR_v1 v3.6 / CLAUDE.md v2.6; план `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`.
**Что изменилось в v1.19 относительно v1.18:** §13.2 абзац «Off-phase audit-security» — фактическая правка характеристики #40 Security Guidance: это **блокирующий** PreToolUse-хук (`sys.exit 2`, одноразовый speed-bump per «файл+правило» за сессию), не warn-only. §13.2 PSR_v1 cross-ref v3.4+ → v3.5+. Связано: Tooling v2.5 / PSR_v1 v3.5 / CLAUDE.md v2.5; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`.
**Что изменилось в v1.18 относительно v1.17:** §13.2 +абзац «Off-phase audit-security» — формализованы 2 инструмента раздела D3 карты «Аудит и управление рисками» (#39 Trail of Bits Skills, #40 Security Guidance) как шестая off-phase категория; §13.2 PSR_v1 cross-ref v3.3+ → v3.4+. Связано: Tooling v2.4 / PSR_v1 v3.4 / CLAUDE.md v2.4; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`.
**Что изменилось в v1.17 относительно v1.16:** §13.2 +абзац «Off-phase architecture-tooling» — формализованы 3 инструмента раздела A6 карты «Архитектура систем» (#36 adr-kit, #37 mermaid-skill, #38 architecture-patterns) как пятая off-phase категория; §13.2 PSR_v1 cross-ref v3.2+ → v3.3+. Связано: Tooling v2.3 / PSR_v1 v3.3 / CLAUDE.md v2.3; план `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`.
**Краткое резюме v1.16:** реколлаж ruflo к фактическому рантайму: §12 sub-policy → hard-rule (title + абзацы), §12.4 первый буллет → «§9 не применяется», §0 priority note убран ruflo уровень −1 (цепочка начинается с §12 explicit hard-rule), §14.6 cross-ref убран «ruflo — уровень −1» → «ruflo как инструмент (хук + MCP), не уровень иерархии», §13.9/§13.10 PSR_v1 cross-refs «v3.0+, R0 → sub-policy» → «v3.2+, R0 — top-of-stack gate». Связано: CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`.
@@ -558,6 +572,13 @@ P0 = блокер старта спринта или регуляторного
| **v1.15** | **15.05.2026** | Новый §14 «Ruflo Queen routing — hard rule»: триггер queen/королева → безусловный route через ruflo Queen (`hive-mind spawn --claude`), enforcement-хук `tools/ruflo-queen-hook.mjs`. §13.6 tier-таблица +строка §14 (explicit hard-rule). §0 priority chain +§14 +note. §14.3 — проактивное предложение ruflo-spawn на нетривиальных задачах. Связано: spec/plan 2026-05-15-ruflo-queen-trigger-and-delegation, CLAUDE.md v2.1, PSR_v1 v3.1, Tooling v2.1. Через `superpowers:brainstorming``writing-plans``subagent-driven-development`. |
| **v1.16** | **16.05.2026** | Реколлаж ruflo — приведение декларации к фактическому рантайму: §12 Superpowers переведён из sub-policy обратно в explicit hard-rule; §0 priority note и §14.6 cross-ref — убраны упоминания ruflo как «уровня 1»; §11.5/§13.2/§13.9/§13.10 cross-refs на PSR_v1 v3.2. Связано: CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`. |
| **v1.17** | **17.05.2026** | A6 architecture-tooling: §13.2 +абзац «Off-phase architecture-tooling» — формализованы 3 инструмента раздела A6 карты «Архитектура систем» (#36 adr-kit, #37 mermaid-skill, #38 architecture-patterns) как пятая off-phase категория, отдельная от UI-пула / infrastructure / debug-runtime / orchestration; не UI → вне R6.0/R6.1/R14. §13.2 PSR_v1 cross-ref v3.2+ → v3.3+. Связано: Tooling v2.2→v2.3 (§4.11-4.13 + §0 счётчик 35→38), PSR_v1 v3.2→v3.3 (R10.1 Блок 1 +2 строки + note), CLAUDE.md v2.2→v2.3 (§3.3 +#36-38). Через manual Edit (Pravila/PSR_v1/Tooling) + `/claude-md-management:claude-md-improver` (CLAUDE.md per §5 п.10). План `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.18** | **17.05.2026** | D3 audit-security: §13.2 +абзац «Off-phase audit-security» — формализованы 2 инструмента раздела D3 карты «Аудит и управление рисками» (#39 Trail of Bits Skills, #40 Security Guidance) как шестая off-phase категория; §13.2 PSR_v1 cross-ref v3.3+ → v3.4+. Связано: Tooling v2.4 / PSR_v1 v3.4 / CLAUDE.md v2.4. План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. |
| **v1.19** | **17.05.2026** | Фактическая правка §13.2 абзаца «Off-phase audit-security»: #40 Security Guidance — **блокирующий** PreToolUse-хук (`sys.exit 2`, одноразовый speed-bump per «файл+правило» за сессию), не warn-only; §13.2 PSR_v1 cross-ref v3.4+ → v3.5+. Связано: Tooling v2.5 / PSR_v1 v3.5 / CLAUDE.md v2.5. План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. |
| **v1.20** | **17.05.2026** | C9 project-management: §13.2 +абзац «Off-phase project-management» — формализованы 2 инструмента раздела C9 карты «Управление проектами» (#41 CCPM, #42 product-management) как седьмая off-phase категория, отдельная от UI-пула / infrastructure / debug-runtime / orchestration / architecture-tooling / audit-security; не UI → вне R6.0/R6.1/R14. §13.2 PSR_v1 cross-ref v3.5+ → v3.6+. Связано: Tooling v2.6 (§4.16-4.17 + §0 счётчик 40→42), PSR_v1 v3.6 (R10.1 Блок 1 +2 строки + note), CLAUDE.md v2.6 (§3.3 +#41-42). Через manual Edit (Pravila/PSR_v1/Tooling) + `/claude-md-management:claude-md-improver` (CLAUDE.md per §5 п.10). План `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.21** | **17.05.2026** | A6-расширение deptrac: §13.2 абзац «Off-phase architecture-tooling» расширен — формализован 4-й инструмент раздела A6 (#43 deptrac, Composer dev-dependency `deptrac/deptrac` v4.6.1 BSD-3; архитектурный fitness-гейт направления зависимостей / границ слоёв, врезан в lefthook pre-commit job 10, конфиг `app/deptrac.yaml` 13 слоёв, первый прогон 0 нарушений → baseline не нужен, red-green доказан). Категория architecture-tooling без изменений. Связано: Tooling v2.6→v2.7 (§4.18 + §0 счётчик 42→43), PSR_v1 v3.6→v3.7 (R10.1 Блок 1 note), CLAUDE.md v2.6→v2.7 (§3.3 +#43). Через manual Edit (Pravila/PSR_v1/Tooling) + `/claude-md-management:claude-md-improver` (CLAUDE.md per §5 п.10). План `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.22** | **17.05.2026** | A4 design-tooling: §13.2 +абзац «Off-phase design-tooling» — формализованы 3 инструмента раздела A4 карты «Дизайн (UI/UX, графика, бренд)» (#44 Figma MCP / #45 Universal Icons MCP / #46 Design plugin) как восьмая off-phase подкатегория, отдельная от UI-пула / infrastructure / debug-runtime / orchestration / architecture-tooling / audit-security / project-management; не UI → вне R6.0/R6.1/R14. §13.2 PSR_v1 cross-ref v3.3+ → v3.8+ (текст застрял на v3.3+ — changelog v1.18-v1.20 заявлял bump'ы, но §13.2 не обновлялся; теперь синхронизирован). Связано: Tooling v2.8 / PSR_v1 v3.8 / CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`. |
| **v1.23** | **17.05.2026** | A3 integration-tooling: §13.2 +абзац «Off-phase integration-tooling» — формализованы инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» (#47 `openapi-mcp-server`, Tooling §4.22; `api-docs` agent, claude-flow, без Tooling-номера) как девятая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. READ-ONLY introspection. Регулируются PSR_v1 R10.1 Блок 3. Связано: Tooling v2.9 / PSR_v1 v3.9 / CLAUDE.md v2.9. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.24** | **17.05.2026** | A11 ml-ai-tooling: §13.2 +абзац «Off-phase ml-ai-tooling» — формализованы инструменты раздела A11 карты «ML / AI-разработка» (#48 promptfoo — npm devDependency, CLI-eval LLM-промптов; #49 Data Scientist skill — вендоренный сторонний скил; #50 Jupyter MCP — DEFERRED, требует Python ML-окружения) как десятая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. promptfoo делает платные LLM-вызовы — только вручную/CI, никогда в хук (ML1). Границы — ADR-007. Связано: Tooling v2.10 / PSR_v1 v3.10 / CLAUDE.md v2.10. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн). План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
---
@@ -673,7 +694,7 @@ P0 = блокер старта спринта или регуляторного
### 13.2. Парность со Superpowers + расширенный пул UI-инструментов (v1.8)
Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный stack одного приоритетного уровня**. Оба плагина подключены к gate stack'а одновременно, между ними нет иерархии. Координация — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) **v3.3+ (R0 — top-of-stack gate; ruflo big-bang 15.05.2026 + реколлаж 16.05.2026; полный детализированный реестр правил в PSR_v1)**.
Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный stack одного приоритетного уровня**. Оба плагина подключены к gate stack'а одновременно, между ними нет иерархии. Координация — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) **v3.8+ (R0 — top-of-stack gate; ruflo big-bang 15.05.2026 + реколлаж 16.05.2026; полный детализированный реестр правил в PSR_v1)**.
**Расширенный пул UI-инструментов (v1.8)** добавляет к paired-stack ядру два внешних плагина в роли **инструментов** (R10.1 PSR_v1, не решателей):
@@ -690,9 +711,19 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
**Инфраструктурные плагины (вне расширенного UI-пула, v1.9+):** `claude-md-management` (skills `claude-md-improver` + `revise-claude-md`, marketplace `anthropics/claude-plugins-official`) — единственный интерфейс правок CLAUDE.md (CLAUDE.md §5 п.10). Категория **инфраструктурная**, не UI — поэтому не попадает под §13 (расширенный UI-пул) и не проходит R6.0/R6.1 фильтр / R14 pipeline. Регулируется PSR_v1 R10.1 блок 1 (`enabledPlugins`-плагины) как off-pool tool. Аналогичные инфраструктурные категории — built-in skills Claude Code (`review`, `security-review`, `init`, `simplify`, `update-config`, `keybindings-help`, `fewer-permission-prompts`, `loop`, `schedule`, `claude-api`) — активируются по явному `/имя` от пользователя; PSR_v1 R10.1 блок 2.
**Off-phase MCP debug-runtime (отдельная категория, введена v1.13 Pravila, 13.05.2026 day +1):** `@sentry/mcp-server@0.33.0+` (Tooling #34, server `sentry` в `.mcp.json`) — отладка production errors в self-hosted Sentry (Yandex Cloud per CLAUDE.md §2; pending Б-1 ООО registration); `@modelcontextprotocol/server-redis@2025.4.25` (Tooling #35, server `redis` в `.mcp.json`; deprecated Anthropic source; Memurai PONG verified Task 4) — отладка Redis/Memurai runtime (очереди, кэш, Pest --parallel races per quirk 72/77). **Категория отдельная** от UI-пула (§13.2 paired-stack + UPM + 21st) и от infrastructure (claude-md-management §13.2 paragraph выше) — **не trigger'ит R6.0/R6.1 stack-фильтры** (READ-ONLY, не модифицируют code/UI/CLAUDE.md) и **не входит в R14 pipeline** UI-генераторов. Регулируется PSR_v1 R10.1 Блок 3 (`.mcp.json`-серверы) как debug-runtime off-phase tool. READ-ONLY usage обязателен — никаких mutation операций (DEL/FLUSHDB/SET/LPUSH для Redis; write actions для Sentry). Установлены retrospective на feat/claude-automation `6f7e7d7` (sentry) + `bd4ec48` (redis), merged через PR #3 (`cc5f63b`).
**Off-phase MCP debug-runtime (отдельная категория, введена v1.13 Pravila, 13.05.2026 day +1):** `@sentry/mcp-server@0.33.0+` (Tooling #34, server `sentry` в `.mcp.json`) — отладка production errors в self-hosted Sentry (Yandex Cloud per CLAUDE.md §2; pending Б-1 ООО registration); `@modelcontextprotocol/server-redis@2025.4.25` (Tooling #35, server `redis` в `.mcp.json`; deprecated Anthropic source; Memurai PONG verified Task 4) — отладка Redis/Memurai runtime (очереди, кэш, Pest --parallel races per quirk 72/77). **Категория отдельная** от UI-пула (§13.2 paired-stack + UPM + 21st) и от infrastructure (claude-md-management §13.2 paragraph выше) — **не trigger'ит R6.0/R6.1 stack-фильтры** (READ-ONLY, не модифицируют code/UI/CLAUDE.md) и **не входит в R14 pipeline** UI-генераторов. Регулируется PSR_v1 R10.1 Блок 3 (`.mcp.json`-серверы) как debug-runtime off-phase tool. READ-ONLY usage обязателен — никаких mutation операций (DEL/FLUSHDB/SET/LPUSH для Redis; write actions для Sentry). Установлены retrospective на feat/claude-automation `6f7e7d7` (sentry) + `bd4ec48` (redis), merged через PR #3 (`cc5f63b`). PSR_v1 cross-ref: **v3.6+**, R10.1 Блок 3.
**Off-phase architecture-tooling (отдельная категория, v1.17, 17.05.2026):** три инструмента раздела A6 карты «Архитектура систем» — `adr-kit` (Tooling #36, marketplace `rvdbreemen/adr-kit`; ADR-решения в `docs/adr/`, `adr-judge` врезан в lefthook pre-commit job 9 декларативно, без `--llm`), `mermaid-skill` (Tooling #37, вендоренный сторонний скил `.claude/skills/mermaid/`; C4/architecture-диаграммы), `architecture-patterns` (Tooling #38, marketplace `secondsky/claude-skills`; knowledge-only справочник паттернов). **Категория отдельная** от UI-пула (UPM/21st), infrastructure (claude-md-management) и debug-runtime (Sentry/Redis) — не UI, **не trigger'ит R6.0/R6.1 stack-фильтры и не входит в R14 pipeline**. Регулируется PSR_v1 R10.1 Блок 1 (adr-kit, architecture-patterns) + Блок 1 note (mermaid-skill — вендоренный скил вне типологии трёх блоков). Установлены 17.05.2026 на ветке `feat/a6-architecture-tooling`; план `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`.
**Off-phase architecture-tooling (отдельная категория, v1.17, 17.05.2026; +deptrac v1.21):** четыре инструмента раздела A6 карты «Архитектура систем» — `adr-kit` (Tooling #36, marketplace `rvdbreemen/adr-kit`; ADR-решения в `docs/adr/`, `adr-judge` врезан в lefthook pre-commit job 9 декларативно, без `--llm`), `mermaid-skill` (Tooling #37, вендоренный сторонний скил `.claude/skills/mermaid/`; C4/architecture-диаграммы), `architecture-patterns` (Tooling #38, marketplace `secondsky/claude-skills`; knowledge-only справочник паттернов), `deptrac` (Tooling #43, Composer dev-dependency `deptrac/deptrac` v4.6.1 BSD-3; архитектурный fitness-гейт направления зависимостей / границ слоёв — врезан в lefthook pre-commit job 10, конфиг `app/deptrac.yaml` 13 слоёв, чистый PHP без вызовов LLM). **Категория отдельная** от UI-пула (UPM/21st), infrastructure (claude-md-management) и debug-runtime (Sentry/Redis) — не UI, **не trigger'ит R6.0/R6.1 stack-фильтры и не входит в R14 pipeline**. Регулируется PSR_v1 R10.1 Блок 1 (adr-kit, architecture-patterns) + Блок 1 notes (mermaid-skill — вендоренный скил, deptrac — composer dev-dep — оба вне типологии трёх блоков). Установлены 17.05.2026 (adr-kit/mermaid/architecture-patterns — ветка `feat/a6-architecture-tooling`, план `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`; deptrac — план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`).
**Off-phase audit-security (отдельная категория, v1.18, 17.05.2026):** инструменты раздела D3 карты «Аудит и управление рисками» — `Trail of Bits Skills` (Tooling #39, marketplace `trailofbits/skills`; курированный субсет 8 audit-плагинов — security-аудит diff, supply-chain риск зависимостей; CC-BY-SA-4.0, marketplace-плагин не вендорен), `Security Guidance` (Tooling #40, marketplace `anthropics/claude-plugins-official`; один **блокирующий** PreToolUse-хук — inline-предупреждения уязвимостей, `sys.exit 2`, одноразовый speed-bump per «файл+правило» за сессию). Дополнительно `/security-review` (Anthropic built-in, customized в `.claude/commands/security-review.md` с проектным FP-фильтром RLS/ПДн/economy-хуки). **Категория отдельная** от UI-пула, infrastructure, debug-runtime, orchestration и architecture-tooling — не UI, **не trigger'ит R6.0/R6.1 stack-фильтры и не входит в R14 pipeline**. Регулируется PSR_v1 R10.1 Блок 1. Установлены 17.05.2026 на ветке `feat/d3-audit-risk-tooling`; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`.
**Off-phase project-management (отдельная категория, v1.20, 17.05.2026):** инструменты раздела C9 карты «Управление проектами» — `CCPM` (Tooling #41, вендоренный standalone-скил в `.claude/skills/ccpm/`, `automazeio/ccpm` MIT; PRD→эпик→GitHub-issue→код с трассируемостью через `/pm` flow; GitHub-issue-backed модель ADR-004; bus-factor — community-проект, mitigation — вендоринг), `product-management` (Tooling #42, marketplace `anthropics/knowledge-work-plugins`, Anthropic Verified; product-strategy церемонии: `/write-spec`, `/roadmap-update`, `/stakeholder-update`, `/synthesize-research`, `/competitive-brief`, `/metrics-review`). GitHub MCP (Tooling #3) reuse с `projects` toolset для GitHub Projects v2 (не новый слот). **Категория отдельная** от UI-пула, infrastructure, debug-runtime, orchestration, architecture-tooling и audit-security — не UI, **не trigger'ит R6.0/R6.1 stack-фильтры и не входит в R14 pipeline**. Регулируется PSR_v1 R10.1 Блок 1. Установлены 17.05.2026 на ветке `feat/c9-project-management-tooling`; план `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`.
**Off-phase design-tooling (A4):** #44 Figma MCP (extract-only, DEFERRED — у проекта нет Figma-аккаунта), #45 Universal Icons MCP, #46 Design plugin — раздел A4 карты «Дизайн (UI/UX, графика, бренд)». Восьмая off-phase подкатегория. Не UI-решатели → вне расширенного UI-пула, вне R6.0/R6.1/R14 PSR_v1. Границы — ADR-006 (Figma extract-only; Design plugin a11y дизайн-уровня — Pa11y остаётся техническим SoT; Design Critique pre-code). Регулируются PSR_v1 R10.1 (Блок 1 — Design plugin; Блок 3 — Figma MCP / Universal Icons MCP).
**Off-phase integration-tooling (A3):** Инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» — #47 `openapi-mcp-server` (Tooling §4.22; введён A3-интеграцией 17.05.2026) и `api-docs` agent (claude-flow, узел карты A3 без отдельного Tooling-номера). Off-phase, не UI → вне R6/R14 PSR_v1. READ-ONLY introspection. Регулируются PSR_v1 R10.1 Блок 3.
**Off-phase ml-ai-tooling (A11, v1.24, 17.05.2026):** Инструменты раздела A11 карты «ML / AI-разработка» — #48 `promptfoo` (Tooling §4.23; npm devDependency, CLI-eval LLM-промптов, MIT), #49 `Data Scientist skill` (Tooling §4.24; вендоренный сторонний скил в `.claude/skills/data-scientist/`, классический ML-воркфлоу; код MIT / контент CC BY 4.0), #50 `Jupyter MCP` (Tooling §4.25; **DEFERRED** — требует Python ML-окружения, на native-Windows машине не ставится; зарегистрирован как pending-слот, как Figma MCP #44). Плюс reuse-слой — claude-api skill (PSR_v1 R10.1 Блок 2), context7 MCP, Sentry MCP — без новых номеров. Десятая off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. promptfoo делает платные LLM-вызовы — запуск только вручную/CI, никогда в хук (конфликт-аудит ML1). Границы — ADR-007. Регулируются PSR_v1 R10.1 (Блок 1 — promptfoo dev-dep + Data Scientist skill вендорен; Блок 3 — Jupyter MCP). Установлены 17.05.2026 на ветке `worktree-a11-ml-ai-tooling`; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
### 13.3. Скоуп

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