]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
[ERP-43] Сообщение напоминание, всплывающее для открытия и закрытия смены.
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 10 Feb 2026 19:03:25 +0000 (22:03 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 10 Feb 2026 19:03:25 +0000 (22:03 +0300)
26 files changed:
.claude-plugin/marketplace.json [new file with mode: 0644]
.claude-plugin/plugin.json [new file with mode: 0644]
.claude/skills/jira-comment/SKILL.md [new file with mode: 0755]
.claude/skills/jira-debate/SKILL.md [new file with mode: 0755]
.claude/skills/jira-fetch/SKILL.md [new file with mode: 0755]
.claude/skills/jira-fetch/config/state/ERP-43.json [new file with mode: 0644]
.claude/skills/jira-focus-detect/SKILL.md [new file with mode: 0755]
.claude/skills/jira-implement/SKILL.md [new file with mode: 0755]
.claude/skills/jira-interview/SKILL.md [new file with mode: 0755]
.claude/skills/jira-plan/SKILL.md [new file with mode: 0755]
.claude/skills/jira-prd/SKILL.md [new file with mode: 0755]
.claude/skills/jira-report/SKILL.md [new file with mode: 0755]
.claude/skills/jira-skill-recommend/SKILL.md [new file with mode: 0755]
.claude/skills/jira-spec/SKILL.md [new file with mode: 0755]
.claude/skills/jira-sync/SKILL.md [new file with mode: 0755]
.claude/skills/jira-workflow/SKILL.md [new file with mode: 0755]
erp24/config/db-test.php [new file with mode: 0644]
erp24/controllers/ShiftReminderController.php
erp24/docker/nginx/default.conf [new file with mode: 0644]
erp24/docker/php/Dockerfile [new file with mode: 0644]
erp24/docs/services/ShiftReminderService.md [new file with mode: 0644]
erp24/models/ShiftReminderShown.php
erp24/tests/functional/ShiftReminderControllerCest.php [new file with mode: 0644]
erp24/tests/unit/models/ShiftReminderShownTest.php [new file with mode: 0644]
erp24/tests/unit/services/ShiftReminderServiceTest.php [new file with mode: 0644]
erp24/web/js/shift-reminder.js

diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644 (file)
index 0000000..fb961dc
--- /dev/null
@@ -0,0 +1,17 @@
+{
+  "name": "jira-workflow-plugin",
+  "owner": {
+    "name": "Developer"
+  },
+  "metadata": {
+    "description": "Automated Jira workflow with Official Atlassian MCP: fetch → interview → PRD → debate → spec → debate → plan → debate → implement. OAuth auth, 6 AI models, persistent config, Russian language.",
+    "version": "2.2.0"
+  },
+  "plugins": [
+    {
+      "name": "jira-workflow-plugin",
+      "description": "Automated Jira workflow with Official Atlassian MCP: fetch → interview → PRD → debate → spec → debate → plan → debate → implement. OAuth auth, 6 AI models, persistent config, Russian language.",
+      "source": "./"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644 (file)
index 0000000..c51dd08
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "name": "jira-workflow-plugin",
+  "description": "Automated Jira workflow with Official Atlassian MCP: fetch → interview → PRD → debate → spec → debate → plan → debate → implement. OAuth auth, 6 AI models, persistent config, Russian language.",
+  "version": "2.2.0",
+  "author": {
+    "name": "Developer"
+  }
+}
\ No newline at end of file
diff --git a/.claude/skills/jira-comment/SKILL.md b/.claude/skills/jira-comment/SKILL.md
new file mode 100755 (executable)
index 0000000..602341f
--- /dev/null
@@ -0,0 +1,485 @@
+---
+name: jira-comment
+description: Добавление структурированных комментариев к задачам Jira на каждом этапе workflow
+triggers:
+  - jira comment
+  - добавить комментарий jira
+  - post jira comment
+---
+
+# Jira Comment Skill
+
+Добавляет структурированные комментарии к задаче на каждом этапе workflow.
+
+## Входные параметры
+
+- `issue_key` — ключ задачи (например, PROJ-123)
+- `stage` — этап workflow: `fetch` | `interview` | `prd` | `debate` | `spec` | `plan` | `implement` | `complete`
+- `context` — данные контекста для шаблона
+
+## Шаблоны комментариев
+
+### После fetch
+
+```markdown
+🚀 **Начата обработка задачи**
+
+**Workflow:** {workflow_type}
+**Режим доступа:** {jira_access_mode}
+**Этапы:** {stages_list}
+
+Следующий этап: Интервью
+```
+
+### После interview
+
+```markdown
+📝 **Интервью завершено**
+
+**Вопросов задано:** {questions_count}
+**Требований извлечено:** {requirements_count}
+
+**Основные требования:**
+{requirements_summary}
+
+📎 Артефакт: `interview.md` (прикреплён)
+```
+
+### После PRD
+
+```markdown
+📋 **PRD создан**
+
+**Разделы:**
+- Executive Summary
+- Problem Statement
+- Goals & Success Metrics
+- Requirements (Functional/Non-functional)
+- User Stories
+- Acceptance Criteria
+
+📎 Артефакт: `prd.md` (прикреплён)
+
+Следующий этап: Дебаты PRD
+```
+
+### После debate
+
+```markdown
+🗣️ **Дебаты завершены** ({document_type})
+
+**Персоны:** {personas_list}
+**Раундов:** {total_rounds}
+**Моделей:** {models_count}
+
+**Улучшения:**
+{improvements_list}
+
+📎 Артефакт: `debate-log.md` (обновлён)
+```
+
+### После spec
+
+```markdown
+📐 **Техническая спецификация создана**
+
+**Архитектура:** {architecture_summary}
+**API endpoints:** {api_count}
+**DB changes:** {db_changes}
+
+📎 Артефакт: `spec.md` (прикреплён)
+```
+
+### После plan
+
+```markdown
+📊 **План реализации создан**
+
+**Подзадач создано:** {subtasks_count}
+
+**Подзадачи:**
+{subtask_links}
+
+**Зависимости:**
+{dependency_graph}
+
+📎 Артефакт: `plan.md` (прикреплён)
+```
+
+### После implement (финальный)
+
+```markdown
+✅ **Реализация завершена**
+
+**Статус:** Выполнено
+**Подзадач выполнено:** {completed}/{total}
+
+**Созданные файлы:**
+{files_created}
+
+**Изменённые файлы:**
+{files_modified}
+
+**Тесты:** {tests_passed} passed, {tests_failed} failed
+
+---
+Workflow завершён успешно.
+```
+
+## API доступа к Jira
+
+### Official Atlassian MCP (основной режим)
+
+**ВАЖНО:** Используй Official Atlassian Remote MCP Server с OAuth авторизацией.
+
+Claude автоматически видит доступные MCP tools от сервера `atlassian`.
+Ищи tool для добавления комментариев, например:
+- `atlassian_jira_add_comment`
+- `add_comment`
+- или аналогичный
+
+**Формат вызова:**
+```
+atlassian_jira_add_comment(
+    issue_key="PROJ-123",
+    body="Текст комментария"
+)
+```
+
+**Примечание:** Atlassian MCP обычно принимает plain text или markdown.
+Сервер сам обрабатывает форматирование.
+
+### HTTP fallback (REST API v3)
+
+Используется только если Atlassian MCP недоступен.
+Требует env переменные: ATLASSIAN_HOST, ATLASSIAN_EMAIL, ATLASSIAN_TOKEN.
+При HTTP режиме требуется ADF формат в body.
+
+### curl команда для HTTP fallback
+
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{
+    "body": {
+      "type": "doc",
+      "version": 1,
+      "content": [
+        {
+          "type": "paragraph",
+          "content": [{"type": "text", "text": "MESSAGE"}]
+        }
+      ]
+    }
+  }' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${issue_key}/comment"
+```
+
+## ADF (Atlassian Document Format)
+
+Для HTTP fallback комментарии форматируются в ADF:
+
+### Структура документа
+
+```json
+{
+  "body": {
+    "type": "doc",
+    "version": 1,
+    "content": [...]
+  }
+}
+```
+
+### Heading
+
+```json
+{
+  "type": "heading",
+  "attrs": {"level": 3},
+  "content": [{"type": "text", "text": "Заголовок"}]
+}
+```
+
+### Paragraph с форматированием
+
+```json
+{
+  "type": "paragraph",
+  "content": [
+    {"type": "text", "text": "Обычный текст "},
+    {"type": "text", "marks": [{"type": "strong"}], "text": "жирный"},
+    {"type": "text", "text": " и "},
+    {"type": "text", "marks": [{"type": "em"}], "text": "курсив"}
+  ]
+}
+```
+
+### Bullet list
+
+```json
+{
+  "type": "bulletList",
+  "content": [
+    {
+      "type": "listItem",
+      "content": [
+        {"type": "paragraph", "content": [{"type": "text", "text": "Пункт 1"}]}
+      ]
+    },
+    {
+      "type": "listItem",
+      "content": [
+        {"type": "paragraph", "content": [{"type": "text", "text": "Пункт 2"}]}
+      ]
+    }
+  ]
+}
+```
+
+### Code block
+
+```json
+{
+  "type": "codeBlock",
+  "attrs": {"language": "bash"},
+  "content": [{"type": "text", "text": "echo hello"}]
+}
+```
+
+### Emoji
+
+```json
+{
+  "type": "emoji",
+  "attrs": {"shortName": ":rocket:"}
+}
+```
+
+## Конвертация Markdown в ADF
+
+```python
+def markdown_to_adf(markdown: str) -> dict:
+    """
+    Конвертирует markdown в Atlassian Document Format.
+    """
+    content = []
+    lines = markdown.split('\n')
+    i = 0
+
+    while i < len(lines):
+        line = lines[i]
+
+        # Heading
+        if line.startswith('# '):
+            content.append({
+                "type": "heading",
+                "attrs": {"level": 1},
+                "content": [{"type": "text", "text": line[2:]}]
+            })
+        elif line.startswith('## '):
+            content.append({
+                "type": "heading",
+                "attrs": {"level": 2},
+                "content": [{"type": "text", "text": line[3:]}]
+            })
+        elif line.startswith('### '):
+            content.append({
+                "type": "heading",
+                "attrs": {"level": 3},
+                "content": [{"type": "text", "text": line[4:]}]
+            })
+
+        # Bullet list
+        elif line.startswith('- ') or line.startswith('* '):
+            items = []
+            while i < len(lines) and (lines[i].startswith('- ') or lines[i].startswith('* ')):
+                item_text = lines[i][2:]
+                items.append({
+                    "type": "listItem",
+                    "content": [{
+                        "type": "paragraph",
+                        "content": parse_inline_formatting(item_text)
+                    }]
+                })
+                i += 1
+            content.append({"type": "bulletList", "content": items})
+            continue
+
+        # Code block
+        elif line.startswith('```'):
+            lang = line[3:] or None
+            code_lines = []
+            i += 1
+            while i < len(lines) and not lines[i].startswith('```'):
+                code_lines.append(lines[i])
+                i += 1
+            block = {
+                "type": "codeBlock",
+                "content": [{"type": "text", "text": '\n'.join(code_lines)}]
+            }
+            if lang:
+                block["attrs"] = {"language": lang}
+            content.append(block)
+
+        # Horizontal rule
+        elif line == '---':
+            content.append({"type": "rule"})
+
+        # Paragraph
+        elif line.strip():
+            content.append({
+                "type": "paragraph",
+                "content": parse_inline_formatting(line)
+            })
+
+        i += 1
+
+    return {"type": "doc", "version": 1, "content": content}
+
+
+def parse_inline_formatting(text: str) -> list:
+    """Парсит inline форматирование (bold, italic, code)."""
+    import re
+
+    content = []
+    # Simplified: just return plain text
+    # Full implementation would handle **bold**, *italic*, `code`
+
+    # Handle emoji shortcuts
+    emoji_map = {
+        "🚀": ":rocket:",
+        "📝": ":memo:",
+        "📋": ":clipboard:",
+        "🗣️": ":speaking_head:",
+        "📐": ":triangular_ruler:",
+        "📊": ":bar_chart:",
+        "✅": ":white_check_mark:",
+        "📎": ":paperclip:"
+    }
+
+    for emoji, shortname in emoji_map.items():
+        if emoji in text:
+            parts = text.split(emoji)
+            for j, part in enumerate(parts):
+                if part:
+                    content.append({"type": "text", "text": part})
+                if j < len(parts) - 1:
+                    content.append({
+                        "type": "emoji",
+                        "attrs": {"shortName": shortname}
+                    })
+            return content
+
+    # Default: plain text
+    return [{"type": "text", "text": text}]
+```
+
+## Основной процесс
+
+### Алгоритм добавления комментария
+
+1. **Сформируй текст комментария** из шаблона для этапа (см. Шаблоны выше)
+2. **Определи режим доступа** (MCP или HTTP) из состояния workflow
+3. **Отправь комментарий:**
+
+#### При MCP режиме:
+```
+Вызови MCP tool: jira_add_comment
+  issue_key: "{issue_key}"
+  comment: "{plain_text_message}"
+```
+
+**ВАЖНО:** Передавай plain text с markdown разметкой, НЕ ADF объект!
+
+#### При HTTP режиме:
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{
+    "body": {ADF_OBJECT}
+  }' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/{issue_key}/comment"
+```
+
+### Алгоритм прикрепления артефакта
+
+**Всегда используй HTTP** (MCP не поддерживает attachments):
+
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "X-Atlassian-Token: no-check" \
+  -F "file=@{file_path}" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/{issue_key}/attachments"
+```
+
+## Вывод
+
+```
+[comment] Добавляю комментарий к PROJ-123 (этап: debate)...
+         Режим: REST API
+         Статус: ✓ Добавлен
+```
+
+## Интеграция с workflow
+
+Вызывается автоматически после каждого этапа:
+
+```python
+# В jira-workflow после завершения этапа
+invoke_skill("jira-comment",
+    issue_key=issue_key,
+    stage=current_stage,
+    context=stage_context
+)
+```
+
+## Прикрепление артефактов
+
+**ВАЖНО:** MCP-сервер jira-mcp НЕ поддерживает прикрепление файлов!
+Всегда используй HTTP fallback для attachments.
+
+### curl команда для прикрепления файла
+
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "X-Atlassian-Token: no-check" \
+  -F "file=@docs/jira/PROJ-123/prd.md" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/attachments"
+```
+
+### Проверка успешности
+
+Успешный ответ: HTTP 200, JSON с информацией о файле.
+Ошибка: HTTP 4xx/5xx с описанием проблемы.
+
+## Создание подзадач
+
+```python
+def create_subtask(parent_key: str, summary: str, description: str) -> str:
+    """Создаёт подзадачу и возвращает её ключ."""
+    payload = {
+        "fields": {
+            "project": {"key": parent_key.split('-')[0]},
+            "parent": {"key": parent_key},
+            "summary": summary,
+            "description": markdown_to_adf(description),
+            "issuetype": {"name": "Sub-task"}
+        }
+    }
+
+    response = requests.post(
+        f"https://{host}/rest/api/3/issue",
+        auth=(email, token),
+        headers={"Content-Type": "application/json"},
+        json=payload
+    )
+
+    if response.status_code == 201:
+        return response.json()["key"]
+    return None
+```
diff --git a/.claude/skills/jira-debate/SKILL.md b/.claude/skills/jira-debate/SKILL.md
new file mode 100755 (executable)
index 0000000..518613b
--- /dev/null
@@ -0,0 +1,720 @@
+---
+name: jira-debate
+description: Дебаты документов (PRD, Spec, Plan) с AI-моделями через OpenRouter до достижения консенсуса
+triggers:
+  - jira debate
+  - adversarial spec
+  - spec debate
+  - prd debate
+  - plan debate
+hooks:
+  pre: model-availability
+---
+
+# Jira Debate Skill
+
+Проводит adversarial дебаты документов с 7 AI-моделями до достижения консенсуса.
+
+## Входные параметры
+
+- `document_type` — тип документа: `prd` | `spec` | `plan`
+- `document_path` — путь к файлу документа
+- `issue_key` — ключ задачи
+- `personas` — список персон для фокусированных дебатов (опционально)
+- `auto_detect_focus` — автоопределение персон (default: true)
+
+## Режимы дебатов
+
+### Стандартный режим (без персон)
+
+Все модели критикуют документ одновременно по общим критериям.
+Используется когда `personas` пустой или `auto_detect_focus: false`.
+
+### Режим с персонами (фокусированные дебаты)
+
+Дебаты проходят итерациями по персонам. Каждая итерация фокусирует все модели
+на конкретной области (frontend, backend, security и т.д.).
+
+```
+Iteration 1: Backend-архитектор
+  - Все модели критикуют с фокусом на backend
+  - 1-3 раунда до консенсуса по backend-аспектам
+
+Iteration 2: Frontend-архитектор
+  - Все модели критикуют с фокусом на frontend
+  - 1-3 раунда до консенсуса по frontend-аспектам
+
+Iteration 3: Инженер безопасности
+  - Все модели критикуют с фокусом на security
+  - 1-3 раунда до консенсуса по security-аспектам
+```
+
+### Определение персон
+
+```python
+# В начале дебатов
+if auto_detect_focus and not personas:
+    # Вызвать jira-focus-detect
+    focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data)
+    personas = focus_result["detected_personas"]
+
+    if focus_result["confidence"] == "low":
+        # Запросить подтверждение у пользователя
+        personas = confirm_personas_with_user(personas)
+```
+
+### Загрузка конфигурации персоны
+
+```python
+def load_persona_config(persona_id: str) -> dict:
+    with open("config/debate-personas.json") as f:
+        config = json.load(f)
+    return config["personas"][persona_id]
+```
+
+### Построение промпта с фокусом персоны
+
+```python
+def build_persona_prompt(base_prompt: str, persona: dict, document_type: str) -> str:
+    review_criteria = persona["review_criteria"][document_type]
+    focus_areas = persona["focus_areas"]
+
+    persona_context = f"""
+## Persona Focus: {persona["name"]}
+
+You are reviewing this document from the perspective of a {persona["name"]}.
+
+### Focus Areas
+{chr(10).join(f"- {area}" for area in focus_areas)}
+
+### Specific Review Questions for {document_type.upper()}
+{chr(10).join(f"- {q}" for q in review_criteria)}
+
+{persona["prompt_suffix"]}
+
+---
+
+"""
+    return persona_context + base_prompt
+```
+
+### Итерация по персонам
+
+```python
+async def debate_with_personas(
+    document: str,
+    personas: list,
+    document_type: str,
+    active_models: list
+) -> tuple[str, list]:
+    """
+    Проводит дебаты с итерациями по персонам.
+
+    Returns:
+        tuple: (updated_document, all_improvements)
+    """
+    all_improvements = []
+    debate_config = load_debate_config()
+
+    for persona_id in personas:
+        persona = load_persona_config(persona_id)
+        rounds_config = debate_config["rounds_per_persona"][document_type]
+
+        print(f"[debate:{document_type}] Итерация: {persona['name_ru']}")
+
+        round_num = 0
+        consensus = False
+
+        while not consensus and round_num < rounds_config["max"]:
+            round_num += 1
+            print(f"  Раунд {round_num}/{rounds_config['max']} ({persona['name_ru']})...")
+
+            # Выполнить раунд с фокусом персоны
+            results = await execute_round_with_persona(
+                document=document,
+                persona=persona,
+                document_type=document_type,
+                active_models=active_models
+            )
+
+            # Проверить консенсус по фокусу персоны
+            consensus = check_persona_consensus(results, persona)
+
+            if not consensus:
+                document = update_document_with_feedback(document, results)
+                all_improvements.extend(results["improvements"])
+
+        print(f"  {persona['name_ru']}: консенсус за {round_num} раунд(ов)")
+
+    return document, all_improvements
+```
+
+### Вывод прогресса с персонами
+
+```
+[debate:spec] Фокусированные дебаты (3 персоны)
+
+[debate:spec] Итерация 1/3: Backend-архитектор
+  Раунд 1/3 (Backend-архитектор)...
+    GPT-5.2: MAJOR (API design: missing pagination)
+    DeepSeek v3.2: CRITICAL (DB schema: no indexes)
+    Gemini 3 Pro: APPROVED
+    ...
+  -> Обновляю спецификацию
+
+  Раунд 2/3 (Backend-архитектор)...
+    GPT-5.2: APPROVED
+    DeepSeek v3.2: APPROVED
+    ...
+  Backend-архитектор: консенсус за 2 раунда
+
+[debate:spec] Итерация 2/3: Frontend-архитектор
+  Раунд 1/3 (Frontend-архитектор)...
+    GPT-5.2: MINOR (component structure suggestion)
+    ...
+  Frontend-архитектор: консенсус за 1 раунд
+
+[debate:spec] Итерация 3/3: Инженер безопасности
+  Раунд 1/3 (Инженер безопасности)...
+    GPT-5.2: MAJOR (missing CSRF protection)
+    ...
+  -> Обновляю спецификацию
+
+  Раунд 2/3 (Инженер безопасности)...
+    ...
+  Инженер безопасности: консенсус за 2 раунда
+
+[debate:spec] Все итерации завершены
+  Улучшений внесено: 7
+  Документ обновлён: docs/jira/PROJ-123/spec.md
+```
+
+## Модели-критики
+
+| # | Модель | Роль | Фокус |
+|---|--------|------|-------|
+| 1 | **GPT-5.2** | Lead Technical Critic | High reasoning, техническая корректность |
+| 2 | **DeepSeek v3.2** | Architecture Critic | Архитектура, паттерны |
+| 3 | **Grok 4.1 Fast** | Edge Case Critic | Граничные случаи |
+| 4 | **Gemini 3 Pro** | Scalability Critic | Масштабируемость |
+| 5 | **Perplexity Sonar Pro** | Deep Research Critic | Best practices |
+| 6 | **GLM 4.7** | Alternative Perspective Critic | Альтернативы |
+| 7 | **MiMo-V2-Flash** | Rapid Validation Critic | Санити-чек |
+
+## Конфигурация раундов по типу документа
+
+| Тип документа | Мин. раундов | Макс. раундов | Фокус |
+|---------------|-------------|---------------|-------|
+| **prd** | 2 | 5 | Бизнес-требования, полнота, критерии приёмки |
+| **spec** | 2 | 10 | Техническая корректность, архитектура, безопасность |
+| **plan** | 1 | 3 | Декомпозиция, зависимости, риски |
+
+Консенсус:
+- 70% моделей APPROVED
+- Нет CRITICAL issues
+- Макс. 2 MINOR на модель
+
+## Процесс
+
+### 1. Pre-hook: Проверка доступности моделей
+
+Hook `model-availability` автоматически:
+1. Проверяет `OPENROUTER_API_KEY`
+2. Пингует каждую модель
+3. Для недоступных required моделей -> интервью для замены
+4. Возвращает список активных моделей
+
+### 2. Подготовка контекста для моделей
+
+Промпт адаптируется в зависимости от `document_type`:
+
+#### Для PRD (`document_type: "prd"`)
+
+```markdown
+## Context
+
+You are a {role} reviewing a Product Requirements Document (PRD).
+
+### Your Focus
+{focus_description}
+
+### PRD-Specific Review Criteria
+- Completeness of functional and non-functional requirements
+- Business value and problem statement clarity
+- Acceptance criteria specificity and testability
+- Scope definition (in-scope vs out-of-scope)
+- Risk assessment and mitigation
+- User stories clarity
+- MoSCoW prioritization correctness
+
+### Severity Levels
+- CRITICAL: Missing critical requirement or fundamentally wrong assumption
+- MAJOR: Incomplete requirement or unclear acceptance criteria
+- MINOR: Minor improvement to clarity or structure
+- SUGGESTION: Optional enhancement
+- APPROVED: PRD is acceptable
+
+### Task
+
+Review the following PRD and provide feedback.
+
+For each issue found:
+1. State the severity level
+2. Quote the problematic section
+3. Explain the issue
+4. Suggest a fix
+
+If the PRD is acceptable, respond with "APPROVED" and brief justification.
+
+---
+
+## PRD
+
+{document_content}
+
+---
+
+Provide your review:
+```
+
+#### Для Spec (`document_type: "spec"`)
+
+```markdown
+## Context
+
+You are a {role} reviewing a technical specification.
+
+### Your Focus
+{focus_description}
+
+### Spec-Specific Review Criteria
+- Architecture correctness and patterns
+- API design (RESTfulness, schemas, error handling)
+- Database schema (normalization, indexes, migrations)
+- Security considerations (auth, validation, injection)
+- Performance and scalability
+- Testing strategy completeness
+- Code examples accuracy
+
+### Severity Levels
+- CRITICAL: Blocks implementation, must be fixed
+- MAJOR: Important to fix before implementation
+- MINOR: Recommendation, can be deferred
+- SUGGESTION: Optional improvement
+- APPROVED: Specification is acceptable
+
+### Task
+
+Review the following technical specification and provide feedback.
+
+For each issue found:
+1. State the severity level
+2. Quote the problematic section
+3. Explain the issue
+4. Suggest a fix
+
+If the specification is acceptable, respond with "APPROVED" and brief justification.
+
+---
+
+## Specification
+
+{document_content}
+
+---
+
+## PRD Reference
+
+{prd_summary}
+
+---
+
+Provide your review:
+```
+
+#### Для Plan (`document_type: "plan"`)
+
+```markdown
+## Context
+
+You are a {role} reviewing an implementation plan.
+
+### Your Focus
+{focus_description}
+
+### Plan-Specific Review Criteria
+- Task decomposition granularity (2-8 hours per task)
+- Dependency graph correctness (no circular deps)
+- Risk coverage and mitigation strategies
+- Execution order feasibility
+- Acceptance criteria for each subtask
+- Coverage of all spec requirements
+- Testing strategy per phase
+
+### Severity Levels
+- CRITICAL: Circular dependency or missing critical task
+- MAJOR: Wrong dependency order or insufficient decomposition
+- MINOR: Minor improvement to task description
+- SUGGESTION: Optional optimization
+- APPROVED: Plan is acceptable
+
+### Task
+
+Review the following implementation plan and provide feedback.
+
+For each issue found:
+1. State the severity level
+2. Quote the problematic section
+3. Explain the issue
+4. Suggest a fix
+
+If the plan is acceptable, respond with "APPROVED" and brief justification.
+
+---
+
+## Implementation Plan
+
+{document_content}
+
+---
+
+## Specification Reference
+
+{spec_summary}
+
+---
+
+Provide your review:
+```
+
+### 3. Выполнение раунда
+
+Для каждого раунда:
+
+```python
+async def execute_round(round_number: int, document_content: str, active_models: list, document_type: str):
+    results = {}
+
+    # Параллельные запросы ко всем моделям
+    for model in active_models:
+        response = await call_openrouter(
+            model_id=model["id"],
+            prompt=build_prompt(model["role"], model["focus"], document_content, document_type),
+            temperature=model["temperature"]
+        )
+        results[model["display_name"]] = parse_review(response)
+
+    return results
+```
+
+### 4. OpenRouter API вызов
+
+```python
+async def call_openrouter(model_id: str, prompt: str, temperature: float):
+    headers = {
+        "Authorization": f"Bearer {OPENROUTER_API_KEY}",
+        "Content-Type": "application/json",
+        "HTTP-Referer": "https://jira-workflow-plugin",
+        "X-Title": "Jira Workflow Debate"
+    }
+
+    payload = {
+        "model": model_id,
+        "messages": [
+            {"role": "system", "content": "You are a technical specification reviewer."},
+            {"role": "user", "content": prompt}
+        ],
+        "temperature": temperature,
+        "max_tokens": 2000
+    }
+
+    response = await httpx.post(
+        "https://openrouter.ai/api/v1/chat/completions",
+        headers=headers,
+        json=payload,
+        timeout=60.0
+    )
+
+    return response.json()["choices"][0]["message"]["content"]
+```
+
+### 5. Парсинг результатов
+
+```python
+def parse_review(response: str) -> dict:
+    """
+    Парсит ответ модели и извлекает:
+    - severity: CRITICAL/MAJOR/MINOR/SUGGESTION/APPROVED
+    - issues: list of {section, problem, suggestion}
+    """
+
+    if "APPROVED" in response.upper() and "CRITICAL" not in response.upper():
+        return {"severity": "APPROVED", "issues": [], "summary": response}
+
+    issues = []
+    # Parse structured feedback...
+
+    max_severity = determine_max_severity(issues)
+
+    return {
+        "severity": max_severity,
+        "issues": issues,
+        "raw_response": response
+    }
+```
+
+### 6. Агрегация и обновление документа
+
+После каждого раунда:
+
+```python
+def aggregate_feedback(round_results: dict) -> dict:
+    critical_issues = []
+    major_issues = []
+    minor_issues = []
+    suggestions = []
+
+    for model, result in round_results.items():
+        for issue in result["issues"]:
+            if issue["severity"] == "CRITICAL":
+                critical_issues.append({**issue, "source": model})
+            elif issue["severity"] == "MAJOR":
+                major_issues.append({**issue, "source": model})
+            # ...
+
+    return {
+        "critical": critical_issues,
+        "major": major_issues,
+        "minor": minor_issues,
+        "suggestions": suggestions,
+        "approved_count": count_approved(round_results),
+        "total_models": len(round_results)
+    }
+```
+
+### 7. Проверка консенсуса
+
+```python
+def check_consensus(aggregated: dict, config: dict) -> bool:
+    approved_ratio = aggregated["approved_count"] / aggregated["total_models"]
+
+    if approved_ratio < config["approved_threshold"]:
+        return False
+
+    if len(aggregated["critical"]) > 0:
+        return False
+
+    if config["no_critical_issues"] and len(aggregated["major"]) > 0:
+        return False
+
+    return True
+```
+
+### 8. Обновление документа между раундами
+
+Если консенсус не достигнут:
+
+```python
+def update_document_with_feedback(document_content: str, feedback: dict, document_type: str) -> str:
+    """
+    Обновляет документ на основе CRITICAL и MAJOR issues.
+    MINOR и SUGGESTION учитываются, но не блокируют.
+    """
+
+    changes = []
+
+    for issue in feedback["critical"] + feedback["major"]:
+        # Применяем исправление
+        change = apply_fix(document_content, issue)
+        changes.append(change)
+
+    # Добавляем changelog
+    document_content = add_changelog_entry(document_content, changes)
+
+    return document_content
+```
+
+### 9. Вывод прогресса
+
+```
+[debate:{document_type}] Проверяю доступность моделей...
+         GPT-5.2: доступна
+         DeepSeek v3.2: доступна
+         Grok 4.1 Fast: доступна
+         Gemini 3 Pro: доступна
+         Perplexity Sonar Pro: недоступна (пропущена)
+         GLM 4.7: доступна
+         MiMo-V2-Flash: доступна
+
+[debate:{document_type}] Раунд 1/{max_rounds}...
+         Отправляю документ 6 моделям...
+
+         GPT-5.2: MAJOR
+           -> "Не описан retry при ошибках API"
+         DeepSeek v3.2: CRITICAL
+           -> "Circular dependency в архитектуре"
+         Grok 4.1 Fast: MINOR
+           -> "Edge case: пустой результат"
+         Gemini 3 Pro: MAJOR
+           -> "Bottleneck при batch > 100"
+         GLM 4.7: APPROVED
+         MiMo-V2-Flash: APPROVED
+
+         Консенсус: 2/6 APPROVED (нужно 70%)
+         Critical: 1, Major: 2, Minor: 1
+
+         -> Обновляю документ...
+
+[debate:{document_type}] Раунд 2/{max_rounds}...
+         ...
+
+[debate:{document_type}] Раунд 3/{max_rounds}...
+
+         GPT-5.2: APPROVED
+         DeepSeek v3.2: APPROVED
+         Grok 4.1 Fast: APPROVED
+         Gemini 3 Pro: APPROVED (1 minor)
+         GLM 4.7: APPROVED
+         MiMo-V2-Flash: APPROVED
+
+         Консенсус достигнут: 6/6 APPROVED
+         Сохранено: docs/jira/PROJ-123/debate-log.md
+```
+
+### 10. Сохранение debate-log.md
+
+```markdown
+# Debate Log: {issue_key} ({document_type})
+
+## Summary
+- **Document:** {document_type} ({document_path})
+- **Rounds:** 3
+- **Models:** GPT-5.2, DeepSeek v3.2, Grok 4.1 Fast, Gemini 3 Pro, GLM 4.7, MiMo-V2-Flash
+- **Status:** CONSENSUS_REACHED
+- **Duration:** 15 minutes
+
+## Round 1
+
+### GPT-5.2 (Lead Technical Critic)
+**Severity:** MAJOR
+**Issue:**
+> Section "Error Handling" does not describe retry behavior for API timeouts.
+
+**Suggestion:**
+> Add retry policy with exponential backoff (3 attempts, 2s/4s/8s delays).
+
+### DeepSeek v3.2 (Architecture Critic)
+**Severity:** CRITICAL
+**Issue:**
+> The proposed architecture creates a circular dependency between ServiceA and ServiceB.
+
+**Suggestion:**
+> Introduce a mediator interface or event-based communication.
+
+...
+
+## Round 2
+
+### Changes Applied
+1. Added "Retry Policy" section (addressing GPT-5.2 feedback)
+2. Refactored architecture: introduced EventBus (addressing DeepSeek v3.2)
+3. Added pagination for batch operations (addressing Gemini 3 Pro)
+
+### Model Responses
+...
+
+## Round 3 (Final)
+
+### Voting Results
+| Model | Verdict | Notes |
+|-------|---------|-------|
+| GPT-5.2 | APPROVED | |
+| DeepSeek v3.2 | APPROVED | |
+| Grok 4.1 Fast | APPROVED | |
+| Gemini 3 Pro | APPROVED | 1 minor: "Consider adding diagram" |
+| GLM 4.7 | APPROVED | |
+| MiMo-V2-Flash | APPROVED | |
+
+**Consensus reached: 6/6 models approved**
+
+## Improvements Made
+1. Error handling with retry policy
+2. Architecture refactoring (EventBus pattern)
+3. Batch operation pagination
+4. Additional edge case handling
+
+## Remaining Minor Issues (deferred)
+- Add architecture diagram
+- Consider caching strategy for read-heavy operations
+```
+
+### 11. Прикрепление лога дебатов к Jira
+
+После завершения debate:
+
+```python
+# Прикрепить debate-log к задаче
+attach_artifact(issue_key, f"docs/jira/{issue_key}/debate-log.md")
+
+# Добавить комментарий
+jira_add_comment(issue_key, f"""
+🗣 Дебаты завершены ({document_type})
+
+Раундов: {rounds_count}
+Моделей: {models_count}
+Статус: Консенсус достигнут
+
+Улучшения внесены:
+{improvements_list}
+
+Лог дебатов: docs/jira/{issue_key}/debate-log.md
+""")
+```
+
+### 12. Обновление состояния
+
+```json
+{
+  "completed_stages": ["fetch", "interview", "prd", "debate_prd", "spec", "debate_spec"],
+  "current_stage": "plan",
+  "debate": {
+    "prd": {
+      "rounds": 2,
+      "consensus": true
+    },
+    "spec": {
+      "rounds": 3,
+      "models_participated": 6,
+      "consensus": true,
+      "improvements_made": 4
+    }
+  },
+  "artifacts": {
+    "debate_log": "docs/jira/PROJ-123/debate-log.md",
+    "spec_final": "docs/jira/PROJ-123/spec.md"
+  }
+}
+```
+
+## Обработка ошибок
+
+### Модель не отвечает
+- Retry 3 раза с exponential backoff
+- Если всё равно не отвечает -> пометить как unavailable
+- Если required модель -> интервью для замены
+
+### Превышен max_rounds
+- Сохранить текущее состояние
+- Уведомить пользователя о нерешённых issues
+- Предложить: продолжить вручную или принять как есть
+
+### Rate limit OpenRouter
+- Exponential backoff
+- Распределить запросы во времени
+
+## Выходные данные
+
+Передаёт в следующий этап:
+- Финальный документ (прошедший debate)
+- debate-log.md с историей
+- Список improvements для включения в следующие этапы
diff --git a/.claude/skills/jira-fetch/SKILL.md b/.claude/skills/jira-fetch/SKILL.md
new file mode 100755 (executable)
index 0000000..cb3a7fe
--- /dev/null
@@ -0,0 +1,278 @@
+---
+name: jira-fetch
+description: Получение задач из Jira по ключу, JQL или интерактивный выбор
+triggers:
+  - jira fetch
+  - получить задачу jira
+---
+
+# Jira Fetch Skill
+
+Получает данные задачи из Jira для последующей обработки в workflow.
+
+## Входные параметры
+
+- `issue_key` - ключ задачи (PROJ-123) или список через запятую
+- `jql` - JQL-запрос для поиска задач
+- `select` - интерактивный режим выбора
+
+## Процесс
+
+### 1. Использование Official Atlassian MCP (основной режим)
+
+**ВАЖНО:** Плагин использует Official Atlassian Remote MCP Server с OAuth авторизацией.
+
+При первом использовании MCP автоматически запросит OAuth авторизацию через браузер.
+
+**Доступ к Jira через Atlassian MCP:**
+
+Claude автоматически видит доступные MCP tools от сервера `atlassian`.
+Используй tools с префиксом `atlassian_jira_*` для работы с Jira:
+
+- Получение issue: ищи tool вроде `atlassian_jira_get_issue` или `get_issue`
+- Поиск: ищи tool вроде `atlassian_jira_search` или `search_issues`
+- Комментарии: ищи tool вроде `atlassian_jira_add_comment`
+- Создание: ищи tool вроде `atlassian_jira_create_issue`
+
+**Проверка авторизации:**
+```
+Вызови tool: atlassian_jira_get_myself (или аналогичный)
+Если успешно -> MCP авторизован
+Если ошибка auth -> предложить OAuth через AskUserQuestion
+```
+
+### 2. HTTP Fallback (резервный режим)
+
+Используется только если Atlassian MCP недоступен И заданы env переменные:
+- ATLASSIAN_HOST
+- ATLASSIAN_EMAIL
+- ATLASSIAN_TOKEN
+
+### 2. Определение режима работы
+
+```
+Если указан issue_key:
+  -> Режим: single или batch (если несколько ключей)
+Если указан jql:
+  -> Режим: jql_search
+Если указан select:
+  -> Режим: interactive
+```
+
+### 3. Получение данных
+
+#### Single/Batch режим
+
+**MCP:**
+```
+jira_get_issue(issue_key="PROJ-123")
+```
+
+**HTTP fallback (через Bash curl):**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -H "Content-Type: application/json" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123?expand=transitions"
+```
+
+Извлеки:
+- `key` - ключ задачи
+- `fields.summary` - заголовок
+- `fields.description` - описание
+- `fields.issuetype.name` - тип (Story, Bug, Task, Epic)
+- `fields.priority.name` - приоритет
+- `fields.assignee.displayName` - исполнитель
+- `fields.reporter.displayName` - автор
+- `fields.status.name` - статус
+- `fields.labels` - метки
+- `fields.components` - компоненты
+- `fields.fixVersions` - версии
+- `fields.subtasks` - подзадачи
+- `fields.issuelinks` - связанные задачи
+- `fields.attachment` - вложения
+- `fields.comment.comments` - комментарии
+
+#### JQL режим
+
+**MCP:**
+```
+jira_search(jql="project=PROJ AND sprint=current", max_results=50)
+```
+
+**HTTP fallback:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -H "Content-Type: application/json" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/search?jql=project%3DPROJ%20AND%20sprint%3Dcurrent&maxResults=50"
+```
+
+#### Interactive режим
+
+1. Выполни JQL для задач текущего пользователя:
+
+   **MCP:**
+   ```
+   jira_search(jql="(assignee = currentUser() OR reporter = currentUser()) AND status != Done ORDER BY updated DESC", max_results=20)
+   ```
+
+   **HTTP fallback:**
+   ```bash
+   curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+     -H "Content-Type: application/json" \
+     "https://${ATLASSIAN_HOST}/rest/api/3/search?jql=(assignee%3DcurrentUser()%20OR%20reporter%3DcurrentUser())%20AND%20status!%3DDone%20ORDER%20BY%20updated%20DESC&maxResults=20"
+   ```
+
+2. Покажи список через AskUserQuestion:
+   ```
+   Выберите задачи для обработки:
+   A) PROJ-123: Добавить фильтр (Story, назначена мне)
+   B) PROJ-124: Исправить баг (Bug, создана мной)
+   C) PROJ-125: Рефакторинг API (Task, назначена мне)
+   D) Показать все задачи спринта
+   E) Указать свой ключ/JQL
+   ```
+
+3. Если выбрано "Показать все задачи спринта":
+   **MCP:**
+   ```
+   jira_search(jql="sprint in openSprints() AND status != Done", max_results=20)
+   ```
+
+   **HTTP fallback:**
+   ```bash
+   curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+     -H "Content-Type: application/json" \
+     "https://${ATLASSIAN_HOST}/rest/api/3/search?jql=sprint%20in%20openSprints()%20AND%20status!%3DDone&maxResults=20"
+   ```
+
+4. Получи полные данные для выбранных задач
+
+### 4. Определение workflow типа
+
+На основе `issuetype.name` определи workflow из `config/workflow-types.json`:
+
+```
+Story -> full
+Task -> full
+Bug -> quick
+Epic -> decompose
+Sub-task -> quick
+```
+
+### 5. Сохранение состояния
+
+Создай файл состояния `config/state/{issue_key}.json`:
+
+```json
+{
+  "issue_key": "PROJ-123",
+  "workflow": "full",
+  "current_stage": "fetch",
+  "completed_stages": ["fetch"],
+  "jira_access_mode": "mcp",
+  "jira_data": {
+    "summary": "...",
+    "description": "...",
+    "type": "Story",
+    "priority": "High"
+  },
+  "started_at": "2025-01-26T10:00:00Z"
+}
+```
+
+### 6. Вывод
+
+```
+[fetch] Загружено: PROJ-123 (режим: mcp)
+        Type: Story | Priority: High | Sprint: Sprint 23
+        Workflow: full (fetch -> interview -> prd -> debate(prd) -> spec -> debate(spec) -> plan -> debate(plan) -> implement)
+
+        Summary: Добавить экспорт отчётов в PDF
+        Description: [первые 200 символов...]
+
+        Linked: PROJ-100 (blocks), PROJ-110 (relates to)
+        Subtasks: нет
+        Comments: 3
+```
+
+## HTTP Fallback: Общие операции
+
+### Добавление комментария
+
+**MCP (plain text, НЕ ADF!):**
+```
+jira_add_comment(
+    issue_key="PROJ-123",
+    comment="Текст комментария с **markdown** разметкой"
+)
+```
+
+**ВАЖНО:** При MCP режиме передавай plain text! MCP сервер сам форматирует.
+
+**HTTP fallback (требует ADF):**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "MESSAGE"}]}]}}' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/comment"
+```
+
+### Создание подзадачи
+
+**MCP:**
+```
+jira_create_issue(project, issue_type="Sub-task", parent, summary, description)
+```
+
+**HTTP fallback:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"fields": {"project": {"key": "PROJ"}, "issuetype": {"name": "Sub-task"}, "parent": {"key": "PROJ-123"}, "summary": "..."}}' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue"
+```
+
+### Прикрепление файла
+
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "X-Atlassian-Token: no-check" \
+  -F "file=@docs/jira/PROJ-123/prd.md" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/attachments"
+```
+
+### Переход статуса (transition)
+
+**HTTP fallback (получить transitions):**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -H "Content-Type: application/json" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/transitions"
+```
+
+**HTTP fallback (выполнить transition):**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"transition": {"id": "TRANSITION_ID"}}' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/transitions"
+```
+
+## Обработка ошибок
+
+- `Issue not found` -> Сообщить пользователю, предложить проверить ключ
+- `Authentication failed` -> Проверить ATLASSIAN_HOST, ATLASSIAN_EMAIL, ATLASSIAN_TOKEN
+- `Rate limit` -> Retry с exponential backoff (3 попытки)
+- `MCP unavailable` -> Автоматический переход на HTTP fallback
+
+## Выходные данные
+
+Передаёт в следующий этап (interview):
+- Полные данные задачи из Jira
+- Определённый тип workflow
+- Режим доступа к Jira (mcp/http)
+- Путь к файлу состояния
diff --git a/.claude/skills/jira-fetch/config/state/ERP-43.json b/.claude/skills/jira-fetch/config/state/ERP-43.json
new file mode 100644 (file)
index 0000000..381ed2d
--- /dev/null
@@ -0,0 +1,24 @@
+{
+  "issue_key": "ERP-43",
+  "workflow": "full",
+  "current_stage": "fetch",
+  "completed_stages": [
+    "fetch"
+  ],
+  "jira_access_mode": "http",
+  "jira_data": {
+    "id": "11786",
+    "key": "ERP-43",
+    "summary": "Создание уведомлений о закрытии смены в ERP",
+    "description": null,
+    "type": "Feature",
+    "status": "В работе",
+    "priority": "Medium",
+    "assignee": "Филиппов Алексей",
+    "reporter": "Филиппов Алексей",
+    "labels": [],
+    "components": [],
+    "subtasks": []
+  },
+  "started_at": "2026-02-10T15:57:03.681840Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/jira-focus-detect/SKILL.md b/.claude/skills/jira-focus-detect/SKILL.md
new file mode 100755 (executable)
index 0000000..b301ec8
--- /dev/null
@@ -0,0 +1,366 @@
+---
+name: jira-focus-detect
+description: Автоматическое определение фокусов и персон для дебатов на основе содержимого задачи
+triggers:
+  - detect focus
+  - определить фокус
+  - analyze task focus
+---
+
+# Jira Focus Detect Skill
+
+Анализирует задачу и определяет релевантные персоны для фокусированных дебатов.
+
+## Входные параметры
+
+- `issue_data` — данные задачи из jira-fetch (summary, description, components, labels)
+- `codebase_context` — опционально, контекст кодовой базы (затрагиваемые файлы)
+
+## Процесс определения
+
+### 1. Загрузка конфигурации
+
+```python
+def load_focus_config():
+    """Загружает конфигурацию персон и детекции."""
+    config_path = "config/debate-personas.json"
+    with open(config_path) as f:
+        config = json.load(f)
+    return config["focus_detection"], config["personas"]
+```
+
+### 2. Анализ текста задачи
+
+Сканирует summary, description и acceptance criteria на ключевые слова.
+
+```python
+def detect_focus_from_text(text: str, keywords: dict) -> dict:
+    """
+    Определяет фокусы по ключевым словам в тексте.
+
+    Returns:
+        dict: {persona_id: score}
+    """
+    text_lower = text.lower()
+    scores = {}
+    matched_keywords = {}
+
+    for persona, kw_list in keywords.items():
+        matches = [kw for kw in kw_list if kw in text_lower]
+        if matches:
+            scores[persona] = len(matches)
+            matched_keywords[persona] = matches
+
+    return scores, matched_keywords
+```
+
+### 3. Анализ компонентов Jira
+
+```python
+def detect_focus_from_components(components: list) -> dict:
+    """
+    Определяет фокусы по компонентам задачи в Jira.
+    """
+    component_mapping = {
+        # Frontend
+        "Frontend": "frontend",
+        "UI": "frontend",
+        "Web": "frontend",
+        "Mobile": "frontend",
+
+        # Backend
+        "Backend": "backend",
+        "API": "backend",
+        "Database": "backend",
+        "Server": "backend",
+
+        # DevOps
+        "Infrastructure": "devops",
+        "DevOps": "devops",
+        "CI/CD": "devops",
+        "Deployment": "devops",
+
+        # Security
+        "Security": "security",
+        "Auth": "security",
+        "Authentication": "security",
+
+        # QA
+        "QA": "qa",
+        "Testing": "qa",
+        "Quality": "qa"
+    }
+
+    scores = {}
+    for comp in components:
+        comp_name = comp.get("name", comp) if isinstance(comp, dict) else comp
+        for pattern, persona in component_mapping.items():
+            if pattern.lower() in comp_name.lower():
+                scores[persona] = scores.get(persona, 0) + 2
+                break
+
+    return scores
+```
+
+### 4. Анализ labels
+
+```python
+def detect_focus_from_labels(labels: list) -> dict:
+    """
+    Определяет фокусы по labels задачи.
+    """
+    label_mapping = {
+        "frontend": "frontend",
+        "backend": "backend",
+        "api": "backend",
+        "database": "backend",
+        "devops": "devops",
+        "infrastructure": "devops",
+        "security": "security",
+        "auth": "security",
+        "testing": "qa",
+        "e2e": "qa",
+        "performance": "backend"
+    }
+
+    scores = {}
+    for label in labels:
+        label_lower = label.lower()
+        for pattern, persona in label_mapping.items():
+            if pattern in label_lower:
+                scores[persona] = scores.get(persona, 0) + 3
+                break
+
+    return scores
+```
+
+### 5. Анализ затрагиваемых файлов (опционально)
+
+```python
+def detect_focus_from_files(files: list, patterns: dict) -> dict:
+    """
+    Определяет фокусы по паттернам файлов.
+
+    Args:
+        files: список путей к файлам
+        patterns: component_patterns из конфига
+    """
+    from fnmatch import fnmatch
+
+    scores = {}
+    for persona, file_patterns in patterns.items():
+        for pattern in file_patterns:
+            matches = [f for f in files if fnmatch(f, pattern) or pattern in f]
+            if matches:
+                scores[persona] = scores.get(persona, 0) + len(matches)
+
+    return scores
+```
+
+### 6. Объединение результатов
+
+```python
+def combine_scores(*score_dicts, weights=None) -> list:
+    """
+    Объединяет scores из разных источников.
+
+    Args:
+        score_dicts: кортежи (scores_dict, source_name)
+        weights: веса для источников
+
+    Returns:
+        list: топ-3 персоны, отсортированные по score
+    """
+    default_weights = {
+        "text": 1.0,
+        "components": 2.0,
+        "labels": 3.0,
+        "files": 1.5
+    }
+    weights = weights or default_weights
+
+    combined = {}
+    for scores, source in score_dicts:
+        weight = weights.get(source, 1.0)
+        for persona, score in scores.items():
+            combined[persona] = combined.get(persona, 0) + (score * weight)
+
+    # Сортировка и топ-3
+    sorted_personas = sorted(
+        combined.items(),
+        key=lambda x: x[1],
+        reverse=True
+    )
+
+    return [p[0] for p in sorted_personas[:3] if p[1] > 0]
+```
+
+## Основной процесс
+
+```python
+def detect_task_focus(issue_data: dict, files: list = None) -> dict:
+    """
+    Основная функция определения фокусов задачи.
+
+    Args:
+        issue_data: данные задачи из Jira
+        files: опциональный список затрагиваемых файлов
+
+    Returns:
+        dict: {
+            "detected_personas": ["backend", "frontend", ...],
+            "detection_scores": {...},
+            "matched_keywords": {...},
+            "confidence": "high" | "medium" | "low"
+        }
+    """
+    detection_config, personas = load_focus_config()
+    keywords = detection_config["keywords"]
+    patterns = detection_config["component_patterns"]
+
+    # Собираем текст для анализа
+    text = " ".join([
+        issue_data.get("summary", ""),
+        issue_data.get("description", ""),
+        " ".join(issue_data.get("acceptance_criteria", []))
+    ])
+
+    # Анализ из разных источников
+    text_scores, matched = detect_focus_from_text(text, keywords)
+    component_scores = detect_focus_from_components(
+        issue_data.get("components", [])
+    )
+    label_scores = detect_focus_from_labels(
+        issue_data.get("labels", [])
+    )
+
+    score_sources = [
+        (text_scores, "text"),
+        (component_scores, "components"),
+        (label_scores, "labels")
+    ]
+
+    # Добавляем файлы если есть
+    if files:
+        file_scores = detect_focus_from_files(files, patterns)
+        score_sources.append((file_scores, "files"))
+
+    # Объединяем
+    detected = combine_scores(*score_sources)
+
+    # Определяем confidence
+    total_score = sum(
+        sum(s.values()) for s, _ in score_sources
+    )
+    confidence = "high" if total_score > 10 else "medium" if total_score > 5 else "low"
+
+    # Fallback если ничего не найдено
+    if not detected:
+        detected = ["backend"]  # default fallback
+        confidence = "low"
+
+    return {
+        "detected_personas": detected,
+        "detection_scores": dict(
+            sorted(
+                {k: v for d, _ in score_sources for k, v in d.items()}.items(),
+                key=lambda x: x[1],
+                reverse=True
+            )
+        ),
+        "matched_keywords": matched,
+        "confidence": confidence
+    }
+```
+
+## Вывод
+
+```
+[focus-detect] Анализирую задачу PROJ-123...
+
+Источники:
+  • Текст задачи: api, database, endpoint, authentication
+  • Компоненты Jira: Backend, API
+  • Labels: backend, security
+
+Обнаруженные фокусы:
+  1. backend (score: 12) — найдено: api, database, endpoint, service
+  2. security (score: 6) — найдено: authentication, token
+  3. frontend (score: 2) — найдено: form, page
+
+Confidence: high
+
+Рекомендуемые персоны для дебатов:
+  • Backend-архитектор (primary)
+  • Инженер безопасности
+
+Подтвердить? [Y/n/edit]
+```
+
+## Интерактивный режим
+
+Если `debate_config.allow_user_override: true`:
+
+```
+Выберите персоны для дебатов:
+
+  [x] 1) Backend-архитектор (рекомендован)
+  [x] 2) Инженер безопасности (рекомендован)
+  [ ] 3) Frontend-архитектор
+  [ ] 4) DevOps-инженер
+  [ ] 5) QA-инженер
+
+Введите номера через пробел или Enter для рекомендованных: _
+```
+
+## Выходные данные
+
+```json
+{
+  "detected_personas": ["backend", "security"],
+  "detection_scores": {
+    "backend": 12,
+    "security": 6,
+    "frontend": 2,
+    "devops": 0,
+    "qa": 1
+  },
+  "matched_keywords": {
+    "backend": ["api", "database", "endpoint", "service"],
+    "security": ["authentication", "token"]
+  },
+  "confidence": "high",
+  "user_confirmed": true,
+  "user_modified": false
+}
+```
+
+## Интеграция с workflow
+
+Вызывается из `jira-debate` перед началом дебатов:
+
+```python
+# В jira-debate
+if auto_detect_focus and not personas:
+    focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data)
+    personas = focus_result["detected_personas"]
+
+    if focus_result["confidence"] == "low":
+        # Запросить подтверждение у пользователя
+        personas = confirm_personas_with_user(personas)
+```
+
+## Сохранение в state
+
+Результат сохраняется в `config/state/{ISSUE-KEY}.json`:
+
+```json
+{
+  "focus_detection": {
+    "detected_at": "2026-01-28T12:00:00Z",
+    "personas": ["backend", "security"],
+    "confidence": "high",
+    "scores": {...}
+  }
+}
+```
diff --git a/.claude/skills/jira-implement/SKILL.md b/.claude/skills/jira-implement/SKILL.md
new file mode 100755 (executable)
index 0000000..9c6950c
--- /dev/null
@@ -0,0 +1,385 @@
+---
+name: jira-implement
+description: Реализация кода по плану с автоматическим обновлением статусов
+triggers:
+  - jira implement
+  - начать реализацию
+  - implement plan
+---
+
+# Jira Implement Skill
+
+Выполняет реализацию кода по плану, отслеживая прогресс через TaskUpdate и синхронизируя с Jira.
+
+## Входные данные
+
+- plan.md с декомпозицией
+- Локальные tasks (созданные jira-plan)
+- Subtasks в Jira
+
+## Процесс
+
+### 1. Загрузка плана и задач
+
+```python
+def load_implementation_context(issue_key: str) -> dict:
+    # Загрузить plan
+    plan = read_file(f"docs/jira/{issue_key}/plan.md")
+
+    # Загрузить state
+    state = read_state_file(f"config/state/{issue_key}.json")
+
+    # Получить список tasks
+    tasks = TaskList()
+
+    # Отфильтровать tasks для этого issue
+    issue_tasks = [
+        t for t in tasks
+        if t.metadata.get("jira_subtask", "").startswith(issue_key)
+    ]
+
+    return {
+        "plan": plan,
+        "state": state,
+        "tasks": issue_tasks
+    }
+```
+
+### 2. Определение следующей задачи
+
+```python
+def get_next_task(tasks: list) -> Optional[dict]:
+    """
+    Найти следующую задачу для выполнения:
+    1. Статус "pending"
+    2. Нет blockedBy или все blockers completed
+    """
+    for task in tasks:
+        if task["status"] != "pending":
+            continue
+
+        blocked_by = task.get("blockedBy", [])
+        if not blocked_by:
+            return task
+
+        # Проверить что все blockers completed
+        all_blockers_done = all(
+            get_task(blocker_id)["status"] == "completed"
+            for blocker_id in blocked_by
+        )
+
+        if all_blockers_done:
+            return task
+
+    return None
+```
+
+### 3. Выполнение задачи
+
+Для каждой задачи из плана:
+
+```python
+async def execute_task(task: dict, context: dict):
+    task_id = task["id"]
+    jira_subtask = task["metadata"]["jira_subtask"]
+
+    # 1. Пометить как in_progress
+    TaskUpdate(taskId=task_id, status="in_progress")
+
+    # 2. Sync с Jira
+    await jira_transition_issue(jira_subtask, "In Progress")
+    await jira_add_comment(jira_subtask, "🚀 Started implementation")
+
+    # 3. Извлечь детали из плана
+    subtask_details = extract_subtask_from_plan(context["plan"], jira_subtask)
+
+    # 4. Выполнить реализацию
+    result = await implement_subtask(subtask_details)
+
+    # 5. Пометить как completed
+    TaskUpdate(taskId=task_id, status="completed")
+
+    # 6. Sync с Jira
+    await jira_transition_issue(jira_subtask, "Done")
+    await jira_add_comment(jira_subtask, f"✅ Completed\n\nChanges:\n{result['summary']}")
+
+    return result
+```
+
+### 4. Реализация по типу subtask
+
+#### Database Migration
+
+```python
+async def implement_db_migration(details: dict):
+    """
+    1. Создать файл миграции
+    2. Применить миграцию
+    3. Проверить результат
+    """
+
+    # Создать миграцию
+    migration_content = generate_migration(details["schema"])
+    Write(
+        file_path=f"db/migrations/versions/{timestamp}_{details['name']}.py",
+        content=migration_content
+    )
+
+    # Применить
+    Bash(command="alembic upgrade head")
+
+    # Проверить
+    Bash(command="alembic current")
+
+    return {"type": "migration", "file": migration_file}
+```
+
+#### Models
+
+```python
+async def implement_models(details: dict):
+    """
+    1. Создать SQLAlchemy модель
+    2. Создать Pydantic схемы
+    3. Обновить __init__.py
+    """
+
+    # Модель
+    model_content = generate_model(details["model"])
+    Write(
+        file_path=f"src/models/{details['model_name']}.py",
+        content=model_content
+    )
+
+    # Схемы
+    schema_content = generate_schemas(details["model"])
+    Write(
+        file_path=f"src/schemas/{details['model_name']}.py",
+        content=schema_content
+    )
+
+    # Обновить экспорты
+    Edit(
+        file_path="src/models/__init__.py",
+        old_string="# models",
+        new_string=f"# models\nfrom .{details['model_name']} import {details['class_name']}"
+    )
+
+    return {"type": "models", "files": [model_file, schema_file]}
+```
+
+#### Service
+
+```python
+async def implement_service(details: dict):
+    """
+    1. Создать сервис с методами из spec
+    2. Включить improvements из debate (retry policy и т.д.)
+    """
+
+    service_content = generate_service(
+        details["service"],
+        debate_improvements=details.get("debate_improvements", [])
+    )
+
+    Write(
+        file_path=f"src/services/{details['service_name']}.py",
+        content=service_content
+    )
+
+    return {"type": "service", "file": service_file}
+```
+
+#### API Endpoint
+
+```python
+async def implement_api(details: dict):
+    """
+    1. Создать route с валидацией
+    2. Добавить Swagger docs
+    3. Включить debate improvements (pagination и т.д.)
+    """
+
+    api_content = generate_api_endpoint(
+        details["endpoint"],
+        debate_improvements=details.get("debate_improvements", [])
+    )
+
+    Write(
+        file_path=f"src/api/{details['resource']}.py",
+        content=api_content
+    )
+
+    return {"type": "api", "file": api_file}
+```
+
+#### Tests
+
+```python
+async def implement_tests(details: dict):
+    """
+    1. Unit тесты для сервиса
+    2. Integration тесты для API
+    3. Edge cases из interview
+    """
+
+    # Unit tests
+    unit_content = generate_unit_tests(details["service"])
+    Write(
+        file_path=f"tests/unit/test_{details['service_name']}.py",
+        content=unit_content
+    )
+
+    # Integration tests
+    integration_content = generate_integration_tests(details["api"])
+    Write(
+        file_path=f"tests/integration/test_{details['resource']}_api.py",
+        content=integration_content
+    )
+
+    # Запустить тесты
+    Bash(command=f"pytest tests/ -v")
+
+    return {"type": "tests", "files": [unit_file, integration_file]}
+```
+
+### 5. Обработка ошибок
+
+```python
+async def handle_implementation_error(task: dict, error: Exception):
+    """
+    При ошибке:
+    1. НЕ помечать task как completed
+    2. Добавить комментарий в Jira
+    3. Сохранить состояние для resume
+    """
+
+    jira_subtask = task["metadata"]["jira_subtask"]
+
+    await jira_add_comment(jira_subtask, f"""
+⚠️ Implementation error
+
+```
+{str(error)}
+```
+
+Task remains in progress. Manual intervention may be required.
+""")
+
+    # Сохранить состояние
+    save_state({
+        "current_task": task["id"],
+        "error": str(error),
+        "can_resume": True
+    })
+
+    raise ImplementationError(f"Failed to implement {jira_subtask}: {error}")
+```
+
+### 6. Вывод прогресса
+
+```
+[implement] Начинаю реализацию PROJ-123...
+
+    План: 5 subtasks
+
+    [1/5] PROJ-123-1: Database Migration
+          ↻ Starting...
+          ✓ Migration created
+          ✓ Migration applied
+          ✓ Completed
+
+    [2/5] PROJ-123-2: SQLAlchemy Models
+          ↻ Starting...
+          ✓ Model created
+          ✓ Schemas created
+          ✓ Exports updated
+          ✓ Completed
+
+    [3/5] PROJ-123-3: Service Layer
+          ↻ Starting...
+          ✓ Service created (with retry policy from debate)
+          ✓ Completed
+
+    [4/5] PROJ-123-4: API Endpoint
+          ↻ Starting...
+          ✓ Endpoint created (with pagination from debate)
+          ✓ Swagger docs added
+          ✓ Completed
+
+    [5/5] PROJ-123-5: Tests
+          ↻ Starting...
+          ✓ Unit tests: 12 passed
+          ✓ Integration tests: 4 passed
+          ✓ Completed
+
+    ═══════════════════════════════════════
+    ✅ Implementation complete!
+
+    Files created: 8
+    Files modified: 3
+    Tests: 16 passed
+
+    All subtasks synced with Jira.
+    Parent issue PROJ-123 ready for review.
+```
+
+### 7. Финализация
+
+```python
+async def finalize_implementation(issue_key: str, results: list):
+    # Обновить состояние
+    update_state(issue_key, {
+        "completed_stages": [..., "implement"],
+        "current_stage": "completed",
+        "implementation": {
+            "subtasks_completed": len(results),
+            "files_created": count_created(results),
+            "files_modified": count_modified(results),
+            "tests_passed": count_tests(results)
+        }
+    })
+
+    # Финальный комментарий в Jira
+    await jira_add_comment(issue_key, f"""
+🎉 Implementation completed!
+
+**Summary:**
+- Subtasks: {len(results)} completed
+- Files created: {count_created(results)}
+- Files modified: {count_modified(results)}
+- Tests: {count_tests(results)} passed
+
+**Artifacts:**
+- PRD: docs/jira/{issue_key}/prd.md
+- Spec: docs/jira/{issue_key}/spec.md
+- Debate: docs/jira/{issue_key}/debate-log.md
+- Plan: docs/jira/{issue_key}/plan.md
+
+Ready for code review.
+""")
+```
+
+## Выходные данные
+
+```json
+{
+  "issue_key": "PROJ-123",
+  "status": "completed",
+  "subtasks_completed": 5,
+  "files": {
+    "created": ["migration.py", "model.py", "schema.py", "service.py", "api.py", "tests.py"],
+    "modified": ["__init__.py", "routes.py"]
+  },
+  "tests": {
+    "unit": 12,
+    "integration": 4,
+    "passed": 16,
+    "failed": 0
+  },
+  "debate_improvements_applied": [
+    "retry_policy",
+    "pagination"
+  ]
+}
+```
diff --git a/.claude/skills/jira-interview/SKILL.md b/.claude/skills/jira-interview/SKILL.md
new file mode 100755 (executable)
index 0000000..34ff35c
--- /dev/null
@@ -0,0 +1,224 @@
+---
+name: jira-interview
+description: Интервью для уточнения требований задачи (adversarial-spec style)
+triggers:
+  - jira interview
+  - уточнить требования
+---
+
+# Jira Interview Skill
+
+Проводит структурированное интервью для уточнения требований перед генерацией документации.
+
+## Входные данные
+
+- Данные задачи из jira-fetch
+- Тип workflow (определяет min_questions)
+
+## Количество вопросов
+
+```
+full: min 5 вопросов
+quick: min 2 вопроса
+decompose: min 7 вопросов
+```
+
+## Процесс
+
+### 1. Анализ входных данных
+
+Проанализируй из Jira:
+- `description` - основное описание
+- `comments` - комментарии (часто содержат уточнения)
+- `attachments` - вложения (скриншоты, документы)
+- `issuelinks` - связанные задачи (контекст)
+
+### 2. Определение пробелов
+
+Проверь наличие:
+
+| Аспект | Проверка |
+|--------|----------|
+| Acceptance Criteria | Есть ли чёткие критерии готовности? |
+| Scope | Определены ли границы задачи? |
+| Edge Cases | Описаны ли граничные случаи? |
+| Dependencies | Указаны ли зависимости? |
+| Non-functional | Есть ли требования к производительности/безопасности? |
+| UI/UX | Для фронтенд-задач: есть ли макеты? |
+| Data | Какие данные нужны? Откуда? |
+| Error Handling | Как обрабатывать ошибки? |
+
+### 3. Генерация вопросов
+
+Используй AskUserQuestion tool. **Один вопрос за раз!**
+
+#### Категории вопросов
+
+**Scope (границы):**
+```
+Что НЕ входит в эту задачу?
+A) Только базовая функциональность X
+B) X + интеграция с Y
+C) Полная реализация X, Y, Z
+D) Другое
+```
+
+**Edge Cases (граничные случаи):**
+```
+Что делать, если [условие]?
+A) Показать ошибку пользователю
+B) Использовать значение по умолчанию
+C) Пропустить операцию
+D) Другое
+```
+
+**Dependencies (зависимости):**
+```
+От каких компонентов/сервисов зависит эта задача?
+A) Только внутренние API
+B) Внешние сервисы (указать)
+C) База данных (новые таблицы/поля)
+D) Другое
+```
+
+**Acceptance Criteria (критерии готовности):**
+```
+Как проверить, что задача выполнена?
+A) Unit-тесты покрывают основные сценарии
+B) E2E тест на happy path
+C) Ручное тестирование по чеклисту
+D) Другое
+```
+
+**Non-functional (нефункциональные):**
+```
+Есть ли требования к производительности?
+A) Нет особых требований
+B) Время ответа < 200ms
+C) Поддержка 1000+ одновременных запросов
+D) Другое
+```
+
+**Data (данные):**
+```
+Какие данные нужны для этой функции?
+A) Существующие данные из БД
+B) Новые поля в существующих таблицах
+C) Новые таблицы/коллекции
+D) Внешние источники данных
+```
+
+**Error Handling (обработка ошибок):**
+```
+Как обрабатывать ошибки при [операция]?
+A) Показать сообщение пользователю
+B) Retry автоматически
+C) Логировать и продолжить
+D) Откатить транзакцию
+```
+
+### 4. Адаптивные вопросы
+
+На основе предыдущих ответов генерируй follow-up вопросы:
+
+```
+Если ответ на "Scope" = C (полная реализация):
+  -> Добавь вопросы о приоритизации компонентов
+  -> Уточни порядок реализации
+
+Если ответ на "Dependencies" = B (внешние сервисы):
+  -> Спроси о fallback при недоступности сервиса
+  -> Уточни SLA внешнего сервиса
+```
+
+### 5. Сохранение результатов
+
+После завершения интервью создай `docs/jira/{issue_key}/interview.md`:
+
+```markdown
+# Interview: PROJ-123
+
+**Дата:** 2025-01-26
+**Задача:** Добавить экспорт отчётов в PDF
+
+## Вопросы и ответы
+
+### Q1: Scope
+**Вопрос:** Что НЕ входит в эту задачу?
+**Ответ:** B) X + интеграция с Y
+**Комментарий пользователя:** Интеграция с Y нужна для...
+
+### Q2: Edge Cases
+**Вопрос:** Что делать при пустом отчёте?
+**Ответ:** A) Показать сообщение "Нет данных"
+
+### Q3: Dependencies
+...
+
+## Extracted Requirements
+
+На основе интервью определены требования:
+
+### Функциональные
+- FR-1: Экспорт текущего отчёта в PDF
+- FR-2: Batch-экспорт нескольких отчётов
+- FR-3: Интеграция с сервисом Y
+
+### Нефункциональные
+- NFR-1: Время генерации PDF < 5 секунд
+- NFR-2: Поддержка файлов до 50MB
+
+### Ограничения
+- Не включает: экспорт в другие форматы (Excel, CSV)
+- Зависит от: ReportService, PDFGenerator
+
+### Acceptance Criteria
+- [ ] Unit-тесты покрывают основные сценарии
+- [ ] E2E тест на batch-экспорт 10 отчётов
+```
+
+### 6. Обновление состояния
+
+Обнови `config/state/{issue_key}.json`:
+
+```json
+{
+  "completed_stages": ["fetch", "interview"],
+  "current_stage": "prd",
+  "interview": {
+    "questions_asked": 5,
+    "artifact": "docs/jira/PROJ-123/interview.md"
+  }
+}
+```
+
+### 7. Синхронизация с Jira (комментарий на русском)
+
+Добавь summary интервью в комментарий Jira:
+
+```
+jira_add_comment(issue_key, """
+📋 Интервью завершено ({N} вопросов)
+
+Ключевые решения:
+- Границы: базовая функциональность + интеграция с Y
+- Производительность: время генерации < 5 сек
+- Обработка ошибок: показ сообщения пользователю
+
+Полное интервью: docs/jira/PROJ-123/interview.md
+""")
+```
+
+## Пропуск интервью
+
+Если указан флаг `--no-interview`:
+1. Пропустить этап
+2. Использовать description и comments из Jira как есть
+3. Логировать предупреждение о возможных пробелах
+
+## Выходные данные
+
+Передаёт в следующий этап (prd):
+- Файл interview.md с Q&A
+- Extracted requirements
+- Обновлённое состояние
diff --git a/.claude/skills/jira-plan/SKILL.md b/.claude/skills/jira-plan/SKILL.md
new file mode 100755 (executable)
index 0000000..566dadc
--- /dev/null
@@ -0,0 +1,375 @@
+---
+name: jira-plan
+description: Декомпозиция задачи на subtasks и создание плана реализации
+triggers:
+  - jira plan
+  - создать план
+  - decompose task
+---
+
+# Jira Plan Skill
+
+Декомпозирует задачу на subtasks, создаёт план реализации и синхронизирует с Jira.
+
+## Входные данные
+
+- Финальная спецификация (после debate)
+- debate-log.md с improvements
+- Конфигурация workflow
+
+## Процесс
+
+### 1. Анализ спецификации
+
+Извлеки из spec.md:
+- API changes -> subtasks для каждого endpoint
+- Database changes -> subtask для миграций
+- Service changes -> subtasks для каждого сервиса
+- Tests -> subtask для тестирования
+
+### 2. Анализ debate improvements
+
+Из debate-log.md извлеки improvements, которые нужно включить в план:
+
+```python
+def extract_debate_improvements(debate_log: str) -> list:
+    """
+    Извлекает improvements из debate:
+    - Added retry policy
+    - Refactored architecture
+    - Added pagination
+    """
+    # Parse "Improvements Made" section
+    return improvements
+```
+
+### 3. Генерация subtasks
+
+Правила декомпозиции:
+
+```yaml
+rules:
+  - name: "One concern per subtask"
+    description: "Каждый subtask решает одну задачу"
+
+  - name: "Testable outcome"
+    description: "Результат subtask можно проверить"
+
+  - name: "Estimated size"
+    description: "Subtask занимает 2-8 часов работы"
+
+  - name: "Clear dependencies"
+    description: "Зависимости между subtasks явные"
+```
+
+### 4. Структура плана
+
+Создай `docs/jira/{issue_key}/plan.md`:
+
+```markdown
+# Implementation Plan: {issue_key}
+
+**Spec:** docs/jira/{issue_key}/spec.md
+**Debate:** docs/jira/{issue_key}/debate-log.md
+**Date:** {current_date}
+
+---
+
+## Overview
+
+{Краткое описание что будет реализовано}
+
+Total subtasks: {N}
+Estimated effort: {hours} hours
+
+## Dependencies Graph
+
+```
+[PROJ-123-1: DB Migration]
+        |
+[PROJ-123-2: Models] --> [PROJ-123-4: API Endpoint]
+        |                         |
+[PROJ-123-3: Service] <----------+
+        |
+[PROJ-123-5: Tests]
+```
+
+## Subtasks
+
+### PROJ-123-1: Database Migration
+
+**Description:**
+Создать миграцию для новой таблицы `{table_name}`.
+
+**Acceptance Criteria:**
+- [ ] Миграция создана в `db/migrations/versions/`
+- [ ] Миграция применяется без ошибок
+- [ ] Rollback работает корректно
+
+**Files to modify:**
+- `db/migrations/versions/{timestamp}_{name}.py` (create)
+
+**Blocked by:** None
+**Blocks:** PROJ-123-2, PROJ-123-3
+
+---
+
+### PROJ-123-2: SQLAlchemy Models
+
+**Description:**
+Создать модель `{ModelName}` и соответствующие DTO.
+
+**Acceptance Criteria:**
+- [ ] Модель создана в `models/`
+- [ ] DTO созданы в `schemas/`
+- [ ] Связи с другими моделями настроены
+
+**Files to modify:**
+- `src/models/{model_name}.py` (create)
+- `src/schemas/{model_name}.py` (create)
+- `src/models/__init__.py` (modify)
+
+**Blocked by:** PROJ-123-1
+**Blocks:** PROJ-123-3, PROJ-123-4
+
+---
+
+### PROJ-123-3: Service Layer
+
+**Description:**
+Реализовать `{ServiceName}Service` с бизнес-логикой.
+
+**Acceptance Criteria:**
+- [ ] Сервис создан в `services/`
+- [ ] CRUD операции реализованы
+- [ ] Error handling добавлен (включая retry policy из debate)
+
+**Files to modify:**
+- `src/services/{service_name}.py` (create)
+- `src/services/__init__.py` (modify)
+
+**Blocked by:** PROJ-123-2
+**Blocks:** PROJ-123-4
+
+**Debate improvements included:**
+- Retry policy with exponential backoff (GPT-5.2)
+
+---
+
+### PROJ-123-4: API Endpoint
+
+**Description:**
+Создать REST endpoint `{method} /api/v1/{resource}`.
+
+**Acceptance Criteria:**
+- [ ] Endpoint создан в `api/`
+- [ ] Валидация входных данных
+- [ ] Swagger документация
+- [ ] Pagination добавлена (из debate)
+
+**Files to modify:**
+- `src/api/{resource}.py` (create or modify)
+- `src/api/__init__.py` (modify)
+
+**Blocked by:** PROJ-123-3
+**Blocks:** PROJ-123-5
+
+**Debate improvements included:**
+- Pagination for batch operations (Gemini 3 Pro)
+
+---
+
+### PROJ-123-5: Tests
+
+**Description:**
+Написать unit и integration тесты.
+
+**Acceptance Criteria:**
+- [ ] Unit тесты для сервиса (>80% coverage)
+- [ ] Integration тесты для API
+- [ ] Edge cases из interview покрыты
+
+**Files to modify:**
+- `tests/unit/test_{service_name}.py` (create)
+- `tests/integration/test_{resource}_api.py` (create)
+
+**Blocked by:** PROJ-123-4
+**Blocks:** None
+
+---
+
+## Execution Order
+
+1. **Phase 1: Foundation**
+   - PROJ-123-1: Database Migration
+
+2. **Phase 2: Core**
+   - PROJ-123-2: Models
+   - PROJ-123-3: Service (after models)
+
+3. **Phase 3: Interface**
+   - PROJ-123-4: API Endpoint
+
+4. **Phase 4: Quality**
+   - PROJ-123-5: Tests
+
+## Risk Mitigation
+
+| Risk | Mitigation |
+|------|------------|
+| {risk_from_spec} | {mitigation} |
+
+## Notes
+
+- {any_additional_notes}
+```
+
+### 5. Создание subtasks в Jira
+
+**MCP:**
+```python
+async def create_jira_subtasks(parent_key: str, subtasks: list):
+    created = []
+
+    for subtask in subtasks:
+        result = await jira_create_issue(
+            project=extract_project(parent_key),
+            issue_type="Sub-task",
+            parent=parent_key,
+            summary=subtask["summary"],
+            description=format_subtask_description(subtask),
+            labels=["auto-generated", "jira-workflow"]
+        )
+        created.append(result["key"])
+
+    return created
+```
+
+**HTTP fallback:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"fields": {"project": {"key": "PROJ"}, "issuetype": {"name": "Sub-task"}, "parent": {"key": "PROJ-123"}, "summary": "...", "description": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "..."}]}]}, "labels": ["auto-generated", "jira-workflow"]}}' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue"
+```
+
+### 6. Создание TaskCreate для текущей сессии
+
+Параллельно создай задачи через TaskCreate:
+
+```python
+for subtask in subtasks:
+    TaskCreate(
+        subject=f"{subtask['key']}: {subtask['summary']}",
+        description=subtask["description"],
+        activeForm=f"Реализую {subtask['summary']}",
+        metadata={
+            "jira_subtask": subtask["key"],
+            "blocked_by": subtask["blocked_by"],
+            "blocks": subtask["blocks"]
+        }
+    )
+```
+
+### 7. Установка зависимостей
+
+```python
+# После создания всех задач, установи зависимости
+for subtask in subtasks:
+    if subtask["blocked_by"]:
+        TaskUpdate(
+            taskId=subtask["task_id"],
+            addBlockedBy=[find_task_id(dep) for dep in subtask["blocked_by"]]
+        )
+```
+
+### 8. Обновление состояния
+
+```json
+{
+  "completed_stages": ["fetch", "interview", "prd", "debate_prd", "spec", "debate_spec", "plan"],
+  "current_stage": "debate_plan",
+  "plan": {
+    "subtasks_count": 5,
+    "jira_subtasks": ["PROJ-123-1", "PROJ-123-2", "PROJ-123-3", "PROJ-123-4", "PROJ-123-5"],
+    "local_tasks": [1, 2, 3, 4, 5]
+  },
+  "artifacts": {
+    "plan": "docs/jira/PROJ-123/plan.md"
+  }
+}
+```
+
+### 9. Прикрепление плана к задаче Jira
+
+```python
+# Прикрепить план как вложение
+attach_artifact(issue_key, f"docs/jira/{issue_key}/plan.md")
+```
+
+**HTTP fallback:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "X-Atlassian-Token: no-check" \
+  -F "file=@docs/jira/${ISSUE_KEY}/plan.md" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}/attachments"
+```
+
+### 10. Синхронизация с Jira (комментарий на русском)
+
+Добавь комментарий к родительской задаче:
+
+```python
+jira_add_comment(parent_key, f"""
+📋 План реализации создан
+
+Подзадачи:
+{format_subtasks_list(created_subtasks)}
+
+Документ плана: docs/jira/{parent_key}/plan.md
+
+Фазы:
+1. Фундамент (миграции БД)
+2. Ядро (модели, сервисы)
+3. Интерфейс (API)
+4. Качество (тесты)
+
+Готово к реализации.
+""")
+```
+
+### 11. Вывод
+
+```
+[plan] Создаю план реализации...
+
+       Анализ спецификации:
+       - API changes: 2 endpoints
+       - Database: 1 new table
+       - Services: 1 new service
+       - Debate improvements: 2
+
+       Декомпозиция на subtasks:
+       PROJ-123-1: Database Migration
+       PROJ-123-2: SQLAlchemy Models
+       PROJ-123-3: Service Layer (+ retry policy)
+       PROJ-123-4: API Endpoint (+ pagination)
+       PROJ-123-5: Tests
+
+       Subtasks созданы в Jira
+       Tasks созданы локально (TaskCreate)
+       Зависимости установлены
+       План сохранён: docs/jira/PROJ-123/plan.md
+       План прикреплён к задаче в Jira
+
+       -> Переход к этапу: debate (документ: Plan)
+```
+
+## Выходные данные
+
+Передаёт в следующий этап (debate Plan):
+- plan.md с декомпозицией
+- Созданные subtasks в Jira
+- Локальные tasks с зависимостями
diff --git a/.claude/skills/jira-prd/SKILL.md b/.claude/skills/jira-prd/SKILL.md
new file mode 100755 (executable)
index 0000000..3b9687e
--- /dev/null
@@ -0,0 +1,267 @@
+---
+name: jira-prd
+description: Генерация Product Requirements Document на основе данных Jira и интервью
+triggers:
+  - jira prd
+  - создать prd
+---
+
+# Jira PRD Skill
+
+Генерирует Product Requirements Document на основе данных из Jira и результатов интервью.
+
+## Входные данные
+
+- Данные задачи из Jira (jira-fetch)
+- Результаты интервью (jira-interview)
+- Тип workflow
+
+## Когда создаётся PRD
+
+```
+full workflow: ДА
+quick workflow: НЕТ (пропускается)
+decompose workflow: ДА
+```
+
+## Процесс
+
+### 1. Сбор контекста
+
+Прочитай:
+- `config/state/{issue_key}.json` - состояние workflow
+- `docs/jira/{issue_key}/interview.md` - результаты интервью
+- Связанные задачи из Jira (если есть)
+
+### 2. Структура PRD
+
+Создай `docs/jira/{issue_key}/prd.md`:
+
+```markdown
+# PRD: {summary из Jira}
+
+**Issue:** {issue_key}
+**Type:** {issue_type}
+**Priority:** {priority}
+**Author:** {reporter}
+**Date:** {current_date}
+**Status:** Draft
+
+---
+
+## 1. Executive Summary
+
+{Краткое описание задачи в 2-3 предложениях}
+
+## 2. Problem Statement
+
+### 2.1 Background
+{Контекст и предыстория задачи}
+
+### 2.2 Problem
+{Какую проблему решаем}
+
+### 2.3 Impact
+{Влияние проблемы на бизнес/пользователей}
+
+## 3. Goals & Success Metrics
+
+### 3.1 Goals
+- **Primary:** {основная цель}
+- **Secondary:** {дополнительные цели}
+
+### 3.2 Success Metrics
+| Metric | Current | Target |
+|--------|---------|--------|
+| {metric_name} | {current_value} | {target_value} |
+
+### 3.3 Non-Goals
+{Что явно НЕ является целью этой задачи}
+
+## 4. Requirements
+
+### 4.1 Functional Requirements
+
+| ID | Requirement | Priority | Source |
+|----|-------------|----------|--------|
+| FR-1 | {requirement} | Must Have | Interview Q1 |
+| FR-2 | {requirement} | Should Have | Jira Description |
+| FR-3 | {requirement} | Nice to Have | Interview Q3 |
+
+### 4.2 Non-Functional Requirements
+
+| ID | Category | Requirement |
+|----|----------|-------------|
+| NFR-1 | Performance | {requirement} |
+| NFR-2 | Security | {requirement} |
+| NFR-3 | Scalability | {requirement} |
+
+### 4.3 Constraints
+- {constraint_1}
+- {constraint_2}
+
+### 4.4 Assumptions
+- {assumption_1}
+- {assumption_2}
+
+## 5. User Stories
+
+### 5.1 Primary User Story
+```
+As a {user_type}
+I want to {action}
+So that {benefit}
+```
+
+### 5.2 Additional User Stories
+{Дополнительные user stories если применимо}
+
+## 6. Scope
+
+### 6.1 In Scope
+- {item_1}
+- {item_2}
+
+### 6.2 Out of Scope
+- {item_1} - {reason}
+- {item_2} - {reason}
+
+### 6.3 Future Considerations
+- {item_1} - planned for {version/sprint}
+
+## 7. Dependencies
+
+### 7.1 Internal Dependencies
+| Component | Type | Status |
+|-----------|------|--------|
+| {component} | Required | Available |
+
+### 7.2 External Dependencies
+| Service | Type | SLA |
+|---------|------|-----|
+| {service} | Required | 99.9% |
+
+### 7.3 Blockers
+{Текущие блокеры если есть}
+
+## 8. Risks
+
+| Risk | Probability | Impact | Mitigation |
+|------|-------------|--------|------------|
+| {risk} | Medium | High | {mitigation} |
+
+## 9. Timeline
+
+| Milestone | Target Date | Notes |
+|-----------|-------------|-------|
+| Spec Review | {date} | |
+| Implementation | {date} | |
+| Testing | {date} | |
+| Release | {date} | |
+
+## 10. Acceptance Criteria
+
+- [ ] {criterion_1}
+- [ ] {criterion_2}
+- [ ] {criterion_3}
+
+---
+
+## Appendix
+
+### A. Related Issues
+- {PROJ-XXX}: {summary} (blocks/relates to)
+
+### B. Interview Summary
+{Ссылка на interview.md}
+
+### C. Glossary
+| Term | Definition |
+|------|------------|
+| {term} | {definition} |
+
+---
+
+**Changelog:**
+| Version | Date | Author | Changes |
+|---------|------|--------|---------|
+| 0.1 | {date} | Claude | Initial draft |
+```
+
+### 3. Правила генерации
+
+1. **Используй только фактические данные** из Jira и интервью
+2. **Не придумывай** requirements, которых нет в источниках
+3. **Отмечай неясности** как TBD (To Be Determined)
+4. **Приоритизация** по MoSCoW: Must Have, Should Have, Could Have, Won't Have
+5. **Трассируемость** - указывай источник каждого requirement
+
+### 4. Обновление состояния
+
+```json
+{
+  "completed_stages": ["fetch", "interview", "prd"],
+  "current_stage": "debate_prd",
+  "artifacts": {
+    "interview": "docs/jira/PROJ-123/interview.md",
+    "prd": "docs/jira/PROJ-123/prd.md"
+  }
+}
+```
+
+### 5. Прикрепление PRD к задаче Jira
+
+```python
+# Прикрепить PRD как вложение
+attach_artifact(issue_key, "docs/jira/{issue_key}/prd.md")
+```
+
+**HTTP fallback:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "X-Atlassian-Token: no-check" \
+  -F "file=@docs/jira/${ISSUE_KEY}/prd.md" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}/attachments"
+```
+
+### 6. Синхронизация с Jira (комментарий на русском)
+
+Добавь комментарий:
+
+```
+jira_add_comment(issue_key, """
+📄 PRD создан
+
+Документ: docs/jira/PROJ-123/prd.md
+
+Ключевые требования:
+- FR-1: {summary}
+- FR-2: {summary}
+- NFR-1: {summary}
+
+Готово к созданию технической спецификации.
+""")
+```
+
+### 7. Вывод
+
+```
+[prd] PRD сгенерирован
+      Сохранено: docs/jira/PROJ-123/prd.md
+      Прикреплён к задаче в Jira
+      Ссылка добавлена в Jira
+
+      Requirements summary:
+      - Functional: 5 (3 Must Have, 2 Should Have)
+      - Non-functional: 3
+      - Out of scope: 2 items
+
+      -> Переход к этапу: debate (документ: PRD)
+```
+
+## Выходные данные
+
+Передаёт в следующий этап (debate PRD):
+- Файл prd.md
+- Структурированные requirements для спецификации
diff --git a/.claude/skills/jira-report/SKILL.md b/.claude/skills/jira-report/SKILL.md
new file mode 100755 (executable)
index 0000000..d9f0656
--- /dev/null
@@ -0,0 +1,409 @@
+---
+name: jira-report
+description: Генерация финального отчёта о проделанной работе перед имплементацией
+triggers:
+  - jira report
+  - отчёт jira
+  - workflow report
+  - generate report
+---
+
+# Jira Report Skill
+
+Генерирует итоговый отчёт перед началом имплементации.
+
+## Входные параметры
+
+- `issue_key` — ключ задачи
+- `state` — текущее состояние workflow из `config/state/{issue_key}.json`
+
+## Выходной файл
+
+`docs/jira/{ISSUE-KEY}/report.md`
+
+## Процесс генерации
+
+### 1. Загрузка данных
+
+```python
+def load_report_data(issue_key: str) -> dict:
+    """Собирает все данные для отчёта."""
+
+    # Загрузить state
+    state_path = f"config/state/{issue_key}.json"
+    with open(state_path) as f:
+        state = json.load(f)
+
+    # Загрузить артефакты
+    artifacts_dir = f"docs/jira/{issue_key}"
+    artifacts = {}
+
+    for artifact in ["interview.md", "prd.md", "spec.md", "debate-log.md", "plan.md"]:
+        path = f"{artifacts_dir}/{artifact}"
+        if os.path.exists(path):
+            with open(path) as f:
+                artifacts[artifact] = f.read()
+
+    return {
+        "state": state,
+        "artifacts": artifacts,
+        "issue_key": issue_key
+    }
+```
+
+### 2. Извлечение метрик
+
+```python
+def extract_metrics(data: dict) -> dict:
+    """Извлекает метрики из артефактов и состояния."""
+
+    state = data["state"]
+    artifacts = data["artifacts"]
+
+    # Метрики дебатов
+    debate_metrics = {
+        "prd": extract_debate_metrics(state.get("debate", {}).get("prd", {})),
+        "spec": extract_debate_metrics(state.get("debate", {}).get("spec", {})),
+        "plan": extract_debate_metrics(state.get("debate", {}).get("plan", {}))
+    }
+
+    # Подсчёт подзадач из plan.md
+    subtasks = parse_subtasks_from_plan(artifacts.get("plan.md", ""))
+
+    # Извлечение архитектуры из spec.md
+    architecture = extract_architecture_summary(artifacts.get("spec.md", ""))
+
+    return {
+        "debates": debate_metrics,
+        "subtasks": subtasks,
+        "architecture": architecture,
+        "total_improvements": sum(
+            d.get("improvements", 0) for d in debate_metrics.values()
+        ),
+        "total_rounds": sum(
+            d.get("rounds", 0) for d in debate_metrics.values()
+        )
+    }
+```
+
+### 3. Генерация отчёта
+
+```python
+def generate_report(issue_key: str, data: dict, metrics: dict) -> str:
+    """Генерирует markdown отчёт."""
+
+    state = data["state"]
+    issue_data = state.get("issue_data", {})
+
+    report = f"""# Отчёт о подготовке к реализации: {issue_key}
+
+**Задача:** {issue_data.get("summary", "N/A")}
+**Тип:** {issue_data.get("issue_type", "N/A")}
+**Приоритет:** {issue_data.get("priority", "N/A")}
+**Workflow:** {state.get("workflow_type", "full")}
+
+---
+
+## 1. Обзор
+
+### Цель
+{extract_goal_from_prd(data["artifacts"].get("prd.md", ""))}
+
+### Scope
+**В scope:**
+{extract_in_scope(data["artifacts"].get("prd.md", ""))}
+
+**Вне scope:**
+{extract_out_of_scope(data["artifacts"].get("prd.md", ""))}
+
+---
+
+## 2. Созданные документы
+
+| Документ | Файл | Статус |
+|----------|------|--------|
+| Интервью | interview.md | {"✅ Завершено" if "interview.md" in data["artifacts"] else "⏳ Не создан"} |
+| PRD | prd.md | {"✅ Завершено" if "prd.md" in data["artifacts"] else "⏳ Не создан"} |
+| Спецификация | spec.md | {"✅ Завершено" if "spec.md" in data["artifacts"] else "⏳ Не создан"} |
+| Лог дебатов | debate-log.md | {"✅ Завершено" if "debate-log.md" in data["artifacts"] else "⏳ Не создан"} |
+| План | plan.md | {"✅ Завершено" if "plan.md" in data["artifacts"] else "⏳ Не создан"} |
+
+---
+
+## 3. Дебаты
+
+### Статистика
+
+| Документ | Персоны | Раундов | Улучшений |
+|----------|---------|---------|-----------|
+| PRD | {format_personas(metrics["debates"]["prd"])} | {metrics["debates"]["prd"].get("rounds", 0)} | {metrics["debates"]["prd"].get("improvements", 0)} |
+| Spec | {format_personas(metrics["debates"]["spec"])} | {metrics["debates"]["spec"].get("rounds", 0)} | {metrics["debates"]["spec"].get("improvements", 0)} |
+| Plan | {format_personas(metrics["debates"]["plan"])} | {metrics["debates"]["plan"].get("rounds", 0)} | {metrics["debates"]["plan"].get("improvements", 0)} |
+
+### Участвовавшие модели
+
+{format_models_table(state.get("active_models", []))}
+
+### Персоны и фокусы
+
+{format_personas_details(state.get("focus_detection", {}))}
+
+### Ключевые улучшения
+
+{format_improvements_list(state.get("improvements", []))}
+
+---
+
+## 4. План реализации
+
+### Подзадачи
+
+| # | Ключ | Название | Зависит от | Оценка |
+|---|------|----------|------------|--------|
+{format_subtasks_table(metrics["subtasks"])}
+
+### Граф зависимостей
+
+```
+{generate_dependency_graph(metrics["subtasks"])}
+```
+
+---
+
+## 5. Технические решения
+
+### Архитектура
+{metrics["architecture"].get("summary", "Не определена")}
+
+### API
+{metrics["architecture"].get("api_summary", "Не определено")}
+
+### База данных
+{metrics["architecture"].get("db_summary", "Не определено")}
+
+### Безопасность
+{metrics["architecture"].get("security_summary", "Не определено")}
+
+---
+
+## 6. Риски и mitigation
+
+{format_risks_table(state.get("risks", []))}
+
+---
+
+## 7. Рекомендации
+
+### Рекомендуемые скиллы для реализации
+
+{format_recommended_skills(state.get("skill_recommendations", {}))}
+
+### Дополнительные проверки
+
+{format_additional_checks(state.get("additional_checks", []))}
+
+---
+
+**Отчёт сгенерирован:** {datetime.now().isoformat()}
+**Версия плагина:** 2.0.0
+
+---
+
+> Для начала реализации выполните:
+> `/jira-workflow {issue_key} --continue-to-implement`
+"""
+
+    return report
+```
+
+## Структура отчёта
+
+### Секция 1: Обзор
+- Цель из PRD
+- Scope (in/out)
+
+### Секция 2: Созданные документы
+- Таблица артефактов со статусом
+
+### Секция 3: Дебаты
+- Статистика по документам
+- Участвовавшие модели
+- Персоны и фокусы
+- Ключевые улучшения
+
+### Секция 4: План реализации
+- Таблица подзадач
+- ASCII граф зависимостей
+
+### Секция 5: Технические решения
+- Архитектура
+- API
+- База данных
+- Безопасность
+
+### Секция 6: Риски
+- Таблица рисков с mitigation
+
+### Секция 7: Рекомендации
+- Скиллы для реализации
+- Дополнительные проверки
+
+## Вспомогательные функции
+
+### Генерация графа зависимостей
+
+```python
+def generate_dependency_graph(subtasks: list) -> str:
+    """Генерирует ASCII граф зависимостей."""
+
+    lines = []
+    for task in subtasks:
+        deps = task.get("depends_on", [])
+        if deps:
+            for dep in deps:
+                lines.append(f"{dep} --> {task['key']}")
+        else:
+            lines.append(f"[start] --> {task['key']}")
+
+    # Простой ASCII формат
+    graph = []
+    levels = calculate_task_levels(subtasks)
+
+    for level, tasks in enumerate(levels):
+        if level == 0:
+            graph.append("  " + "  ".join(f"[{t}]" for t in tasks))
+        else:
+            prev_level = levels[level - 1]
+            # Draw connections
+            connections = []
+            for task in tasks:
+                task_deps = next(
+                    (t["depends_on"] for t in subtasks if t["key"] == task),
+                    []
+                )
+                for dep in task_deps:
+                    if dep in prev_level:
+                        connections.append(f"    |")
+            if connections:
+                graph.append("    " + "  ".join(connections))
+            graph.append("  " + "  ".join(f"[{t}]" for t in tasks))
+
+    return "\n".join(graph)
+```
+
+### Форматирование таблицы подзадач
+
+```python
+def format_subtasks_table(subtasks: list) -> str:
+    """Форматирует подзадачи в markdown таблицу."""
+    lines = []
+    for i, task in enumerate(subtasks, 1):
+        deps = ", ".join(task.get("depends_on", [])) or "—"
+        estimate = task.get("estimate", "—")
+        lines.append(
+            f"| {i} | {task['key']} | {task['summary']} | {deps} | {estimate} |"
+        )
+    return "\n".join(lines)
+```
+
+## Прикрепление к Jira
+
+После генерации:
+
+```python
+def finalize_report(issue_key: str, report_path: str):
+    """Прикрепляет отчёт к Jira и добавляет комментарий."""
+
+    # 1. Прикрепить report.md как вложение
+    attach_artifact(issue_key, report_path)
+
+    # 2. Добавить комментарий
+    invoke_skill("jira-comment",
+        issue_key=issue_key,
+        stage="report",
+        context={
+            "artifacts_count": 5,
+            "debates_count": 3,
+            "improvements_count": 12,
+            "subtasks_count": 5
+        }
+    )
+
+    # 3. Обновить статус (опционально)
+    # update_issue_status(issue_key, "Ready for Development")
+```
+
+## Вывод в консоль
+
+```
+[report] Генерирую итоговый отчёт для PROJ-123...
+
+         Документы: 5 файлов
+         Дебаты: 3 документа, 7 раундов, 12 улучшений
+         План: 5 подзадач
+
+         Отчёт: docs/jira/PROJ-123/report.md
+         Прикреплён к задаче: ✓
+
+         Готово к реализации!
+```
+
+## Основной процесс
+
+```python
+def generate_workflow_report(issue_key: str) -> str:
+    """Главная функция генерации отчёта."""
+
+    print(f"[report] Генерирую итоговый отчёт для {issue_key}...")
+
+    # 1. Загрузить данные
+    data = load_report_data(issue_key)
+
+    # 2. Извлечь метрики
+    metrics = extract_metrics(data)
+
+    print(f"         Документы: {len(data['artifacts'])} файлов")
+    print(f"         Дебаты: {len([d for d in metrics['debates'].values() if d])} документа, "
+          f"{metrics['total_rounds']} раундов, {metrics['total_improvements']} улучшений")
+    print(f"         План: {len(metrics['subtasks'])} подзадач")
+
+    # 3. Сгенерировать отчёт
+    report = generate_report(issue_key, data, metrics)
+
+    # 4. Сохранить
+    report_path = f"docs/jira/{issue_key}/report.md"
+    os.makedirs(os.path.dirname(report_path), exist_ok=True)
+    with open(report_path, 'w') as f:
+        f.write(report)
+
+    print(f"         Отчёт: {report_path}")
+
+    # 5. Прикрепить к Jira
+    finalize_report(issue_key, report_path)
+    print(f"         Прикреплён к задаче: ✓")
+
+    print(f"\n         Готово к реализации!")
+
+    return report_path
+```
+
+## Интеграция с workflow
+
+Вызывается автоматически после `debate_plan` и перед `implement`:
+
+```python
+# В jira-workflow
+if current_stage == "debate_plan" and consensus_reached:
+    # Генерируем отчёт перед переходом к implement
+    report_path = invoke_skill("jira-report", issue_key=issue_key)
+
+    # Показываем пользователю
+    print(f"\n[workflow] Отчёт готов: {report_path}")
+    print("[workflow] Проверьте отчёт перед началом реализации.")
+
+    # Спрашиваем подтверждение
+    confirm = ask_user("Начать реализацию?", options=["Да", "Нет, нужны правки"])
+
+    if confirm == "Да":
+        proceed_to_implement()
+```
diff --git a/.claude/skills/jira-skill-recommend/SKILL.md b/.claude/skills/jira-skill-recommend/SKILL.md
new file mode 100755 (executable)
index 0000000..0d01f12
--- /dev/null
@@ -0,0 +1,393 @@
+---
+name: jira-skill-recommend
+description: Проверка и рекомендация релевантных скиллов для задачи на основе фокусов
+triggers:
+  - recommend skills
+  - рекомендовать скиллы
+  - skill recommendations
+---
+
+# Jira Skill Recommend
+
+Анализирует задачу и рекомендует подключить релевантные скиллы для улучшения качества документации и реализации.
+
+## Входные параметры
+
+- `issue_data` — данные задачи из jira-fetch
+- `focuses` — обнаруженные фокусы из jira-focus-detect (опционально)
+
+## Маппинг фокусов на скиллы
+
+```json
+{
+  "frontend": [
+    "frontend-design",
+    "react-modernization",
+    "design-system-patterns",
+    "e2e-testing-patterns",
+    "nextjs-app-router-patterns",
+    "mobile-ios-design",
+    "mobile-android-design"
+  ],
+  "backend": [
+    "api-design-principles",
+    "architecture-patterns",
+    "auth-implementation-patterns",
+    "error-handling-patterns",
+    "microservices-patterns",
+    "sql-optimization-patterns",
+    "database-migration",
+    "async-python-patterns",
+    "dotnet-backend-patterns",
+    "fastapi-templates"
+  ],
+  "devops": [
+    "deployment-pipeline-design",
+    "k8s-manifest-generator",
+    "gitlab-ci-patterns",
+    "cost-optimization",
+    "prometheus-configuration",
+    "grafana-dashboards",
+    "secrets-management",
+    "workflow-orchestration-patterns"
+  ],
+  "security": [
+    "auth-implementation-patterns",
+    "secrets-management",
+    "memory-safety-patterns"
+  ],
+  "qa": [
+    "e2e-testing-patterns",
+    "python-testing-patterns",
+    "debugging-strategies",
+    "llm-evaluation"
+  ],
+  "python": [
+    "async-python-patterns",
+    "python-packaging",
+    "python-performance-optimization",
+    "python-testing-patterns",
+    "uv-package-manager",
+    "fastapi-templates"
+  ],
+  "ai_ml": [
+    "langchain-architecture",
+    "llm-evaluation",
+    "ml-pipeline-workflow",
+    "prompt-engineering-patterns",
+    "rag-implementation",
+    "hybrid-search-implementation",
+    "similarity-search-patterns",
+    "vector-index-tuning"
+  ],
+  "api": [
+    "api-design-principles",
+    "openapi-spec-generation"
+  ],
+  "database": [
+    "postgresql-table-design",
+    "sql-optimization-patterns",
+    "database-migration"
+  ],
+  "git": [
+    "git-advanced-workflows",
+    "monorepo-management"
+  ]
+}
+```
+
+## Процесс
+
+### 1. Определить фокусы задачи
+
+```python
+def get_task_focuses(issue_data: dict, provided_focuses: list = None) -> list:
+    """Получает фокусы задачи."""
+
+    if provided_focuses:
+        return provided_focuses
+
+    # Вызвать jira-focus-detect
+    focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data)
+    return focus_result["detected_personas"]
+```
+
+### 2. Получить список установленных скиллов
+
+```python
+def get_installed_skills() -> list:
+    """Получает список установленных скиллов из Claude Code."""
+
+    # Через Claude Code API или filesystem
+    skills_dir = os.path.expanduser("~/.claude/plugins")
+    installed = []
+
+    for plugin_dir in os.listdir(skills_dir):
+        plugin_path = os.path.join(skills_dir, plugin_dir)
+        if os.path.isdir(plugin_path):
+            skills_path = os.path.join(plugin_path, "skills")
+            if os.path.isdir(skills_path):
+                for skill_dir in os.listdir(skills_path):
+                    skill_file = os.path.join(skills_path, skill_dir, "SKILL.md")
+                    if os.path.exists(skill_file):
+                        installed.append(skill_dir)
+
+    # Также проверить встроенные скиллы
+    builtin_skills = [
+        "frontend-design",
+        "api-design-principles",
+        "architecture-patterns",
+        "debugging-strategies",
+        # ... и другие из списка в Skill tool
+    ]
+
+    return list(set(installed + builtin_skills))
+```
+
+### 3. Определить рекомендуемые скиллы
+
+```python
+def get_recommended_skills(focuses: list, skill_mapping: dict) -> list:
+    """Определяет рекомендуемые скиллы на основе фокусов."""
+
+    recommended = []
+    for focus in focuses:
+        if focus in skill_mapping:
+            recommended.extend(skill_mapping[focus])
+
+    # Убрать дубликаты, сохранив порядок
+    seen = set()
+    unique = []
+    for skill in recommended:
+        if skill not in seen:
+            seen.add(skill)
+            unique.append(skill)
+
+    return unique
+```
+
+### 4. Проверить покрытие
+
+```python
+def check_skill_coverage(recommended: list, installed: list) -> dict:
+    """Проверяет какие рекомендуемые скиллы установлены."""
+
+    installed_set = set(installed)
+
+    available = [s for s in recommended if s in installed_set]
+    missing = [s for s in recommended if s not in installed_set]
+
+    coverage = len(available) / len(recommended) if recommended else 1.0
+
+    return {
+        "available": available,
+        "missing": missing,
+        "coverage": coverage,
+        "coverage_percent": int(coverage * 100)
+    }
+```
+
+### 5. Приоритизация скиллов
+
+```python
+def prioritize_skills(skills: list, focuses: list, skill_mapping: dict) -> list:
+    """Сортирует скиллы по релевантности к фокусам."""
+
+    skill_scores = {}
+    for skill in skills:
+        score = 0
+        for focus in focuses:
+            if skill in skill_mapping.get(focus, []):
+                # Больший score для первых фокусов
+                focus_idx = focuses.index(focus)
+                score += 10 - focus_idx
+        skill_scores[skill] = score
+
+    return sorted(skills, key=lambda s: skill_scores.get(s, 0), reverse=True)
+```
+
+## Основной процесс
+
+```python
+def recommend_skills(issue_data: dict, focuses: list = None) -> dict:
+    """Главная функция рекомендации скиллов."""
+
+    print(f"[skill-recommend] Анализирую задачу...")
+
+    # 1. Получить фокусы
+    focuses = get_task_focuses(issue_data, focuses)
+    print(f"Обнаруженные фокусы: {', '.join(focuses)}")
+
+    # 2. Загрузить маппинг
+    skill_mapping = load_skill_mapping()
+
+    # 3. Получить рекомендуемые скиллы
+    recommended = get_recommended_skills(focuses, skill_mapping)
+
+    # 4. Получить установленные скиллы
+    installed = get_installed_skills()
+
+    # 5. Проверить покрытие
+    coverage = check_skill_coverage(recommended, installed)
+
+    # 6. Приоритизировать
+    available_prioritized = prioritize_skills(
+        coverage["available"], focuses, skill_mapping
+    )
+
+    return {
+        "focuses": focuses,
+        "recommended": recommended,
+        "available": available_prioritized,
+        "missing": coverage["missing"],
+        "coverage": coverage["coverage"],
+        "coverage_percent": coverage["coverage_percent"]
+    }
+```
+
+## Вывод
+
+```
+[skill-recommend] Анализирую задачу PROJ-123...
+
+Обнаруженные фокусы: backend, security, python
+
+Рекомендуемые скиллы:
+  ✅ api-design-principles (установлен)
+  ✅ architecture-patterns (установлен)
+  ✅ auth-implementation-patterns (установлен)
+  ❌ error-handling-patterns (не установлен)
+  ✅ python-testing-patterns (установлен)
+  ❌ secrets-management (не установлен)
+
+Покрытие: 66% (4/6 скиллов)
+
+Рекомендация: Установите недостающие скиллы для лучшего качества:
+
+  # Установка через superpowers plugin
+  Скиллы доступны в superpowers плагине
+
+Использовать доступные скиллы при генерации документации? [Y/n]
+```
+
+## Интерактивный режим
+
+```python
+def interactive_skill_selection(result: dict) -> list:
+    """Позволяет пользователю выбрать скиллы для использования."""
+
+    print("\nВыберите скиллы для использования:")
+    print("")
+
+    all_skills = result["available"] + result["missing"]
+
+    for i, skill in enumerate(all_skills, 1):
+        status = "✅" if skill in result["available"] else "❌"
+        recommended = "рекомендован" if skill in result["recommended"][:3] else ""
+        print(f"  [{status}] {i}) {skill} {recommended}")
+
+    print("")
+    selection = input("Введите номера через пробел (Enter для рекомендованных): ")
+
+    if not selection.strip():
+        # Использовать топ-3 доступных
+        return result["available"][:3]
+
+    selected = []
+    for num in selection.split():
+        try:
+            idx = int(num) - 1
+            if 0 <= idx < len(all_skills):
+                skill = all_skills[idx]
+                if skill in result["available"]:
+                    selected.append(skill)
+                else:
+                    print(f"  ⚠ {skill} не установлен, пропущен")
+        except ValueError:
+            pass
+
+    return selected if selected else result["available"][:3]
+```
+
+## Интеграция с PRD/Spec
+
+При генерации PRD и Spec можно использовать рекомендованные скиллы:
+
+```python
+def enhance_document_with_skills(document: str, skills: list, doc_type: str) -> str:
+    """Улучшает документ с помощью выбранных скиллов."""
+
+    for skill in skills:
+        print(f"[{doc_type}] Применяю скилл: {skill}")
+
+        # Вызвать скилл для получения рекомендаций
+        skill_output = invoke_skill(skill, context={
+            "document_type": doc_type,
+            "content_preview": document[:2000]
+        })
+
+        # Добавить секцию с рекомендациями скилла
+        if skill_output.get("recommendations"):
+            document += f"\n\n## Рекомендации от {skill}\n\n"
+            document += skill_output["recommendations"]
+
+    return document
+```
+
+## Сохранение в state
+
+```json
+{
+  "skill_recommendations": {
+    "focuses": ["backend", "security", "python"],
+    "recommended": ["api-design-principles", "architecture-patterns", "..."],
+    "available": ["api-design-principles", "architecture-patterns", "..."],
+    "missing": ["error-handling-patterns", "secrets-management"],
+    "coverage": 0.66,
+    "used_in_prd": ["api-design-principles"],
+    "used_in_spec": ["api-design-principles", "architecture-patterns"],
+    "used_in_plan": []
+  }
+}
+```
+
+## Интеграция с workflow
+
+Вызывается после `jira-focus-detect` и перед `jira-prd`:
+
+```python
+# В jira-workflow
+if current_stage == "interview":
+    # После интервью определяем фокусы и скиллы
+    focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data)
+
+    skill_result = invoke_skill("jira-skill-recommend",
+        issue_data=issue_data,
+        focuses=focus_result["detected_personas"]
+    )
+
+    # Сохраняем в state
+    state["focus_detection"] = focus_result
+    state["skill_recommendations"] = skill_result
+
+    # Показываем пользователю
+    if skill_result["coverage"] < 0.5:
+        print(f"[workflow] Низкое покрытие скиллами ({skill_result['coverage_percent']}%)")
+        print("[workflow] Качество документации может быть ниже")
+```
+
+## Описания скиллов для отчёта
+
+```python
+SKILL_DESCRIPTIONS = {
+    "api-design-principles": "REST/GraphQL дизайн, версионирование, документация",
+    "architecture-patterns": "Clean Architecture, Hexagonal, DDD",
+    "auth-implementation-patterns": "JWT, OAuth2, сессии, RBAC",
+    "error-handling-patterns": "Exceptions, Result types, graceful degradation",
+    "frontend-design": "UI компоненты, дизайн-система, responsive",
+    "e2e-testing-patterns": "Playwright, Cypress, test automation",
+    "python-testing-patterns": "pytest, fixtures, mocking, TDD",
+    "secrets-management": "Vault, AWS Secrets Manager, encryption",
+    # ... и другие
+}
+```
diff --git a/.claude/skills/jira-spec/SKILL.md b/.claude/skills/jira-spec/SKILL.md
new file mode 100755 (executable)
index 0000000..e92b017
--- /dev/null
@@ -0,0 +1,462 @@
+---
+name: jira-spec
+description: Генерация технической спецификации на основе PRD
+triggers:
+  - jira spec
+  - создать спецификацию
+  - technical specification
+---
+
+# Jira Spec Skill
+
+Генерирует техническую спецификацию для реализации на основе PRD и данных проекта.
+
+## Входные данные
+
+- PRD (или interview если quick workflow)
+- Структура проекта (автоматический анализ)
+- Существующие паттерны кода
+
+## Процесс
+
+### 1. Анализ кодовой базы
+
+Используй Glob и Grep для понимания структуры проекта:
+
+```bash
+# Структура директорий
+Glob: **/*.py, **/*.ts, **/*.js
+
+# Существующие модели
+Grep: "class.*Model", "interface.*"
+
+# API endpoints
+Grep: "@app.route", "@router", "app.get", "app.post"
+
+# Существующие сервисы
+Grep: "class.*Service", "class.*Repository"
+```
+
+### 2. Структура спецификации
+
+Создай `docs/jira/{issue_key}/spec.md`:
+
+```markdown
+# Technical Specification: {issue_key}
+
+**PRD:** docs/jira/{issue_key}/prd.md
+**Date:** {current_date}
+**Author:** Claude
+**Status:** Draft (pending debate)
+
+---
+
+## 1. Overview
+
+### 1.1 Summary
+{Техническое описание в 2-3 предложениях}
+
+### 1.2 Goals
+{Технические цели реализации}
+
+### 1.3 Background
+{Технический контекст, связь с существующей архитектурой}
+
+## 2. Architecture
+
+### 2.1 High-Level Design
+
+```
++-------------+     +-------------+     +-------------+
+|   Client    |---->|   API       |---->|   Service   |
++-------------+     +-------------+     +-------------+
+                                              |
+                                              v
+                                        +-------------+
+                                        |  Database   |
+                                        +-------------+
+```
+
+### 2.2 Component Diagram
+
+{Диаграмма компонентов в ASCII или Mermaid}
+
+### 2.3 Data Flow
+
+```
+1. Client sends request to /api/v1/{endpoint}
+2. API validates input using {schema}
+3. Service processes request
+4. Repository persists data
+5. Response returned to client
+```
+
+## 3. Detailed Design
+
+### 3.1 API Changes
+
+#### New Endpoints
+
+| Method | Endpoint | Description | Request | Response |
+|--------|----------|-------------|---------|----------|
+| POST | /api/v1/{resource} | Create | {schema} | {schema} |
+| GET | /api/v1/{resource}/{id} | Get by ID | - | {schema} |
+
+#### Request Schema
+
+```json
+{
+  "field1": "string",
+  "field2": 123,
+  "nested": {
+    "field3": true
+  }
+}
+```
+
+#### Response Schema
+
+```json
+{
+  "id": "uuid",
+  "field1": "string",
+  "created_at": "datetime"
+}
+```
+
+#### Error Responses
+
+| Status | Code | Description |
+|--------|------|-------------|
+| 400 | VALIDATION_ERROR | Invalid input |
+| 404 | NOT_FOUND | Resource not found |
+| 500 | INTERNAL_ERROR | Server error |
+
+### 3.2 Database Changes
+
+#### New Tables
+
+```sql
+CREATE TABLE {table_name} (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    {field1} VARCHAR(255) NOT NULL,
+    {field2} INTEGER,
+    created_at TIMESTAMP DEFAULT NOW(),
+    updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE INDEX idx_{table_name}_{field1} ON {table_name}({field1});
+```
+
+#### Schema Changes
+
+```sql
+ALTER TABLE {existing_table}
+ADD COLUMN {new_field} VARCHAR(100);
+```
+
+#### Migration Strategy
+
+1. Create new tables/columns
+2. Backfill data if needed
+3. Update application code
+4. Remove deprecated columns (if any)
+
+### 3.3 Service Layer
+
+#### New Services
+
+```python
+class {ServiceName}Service:
+    """
+    {Description}
+    """
+
+    def __init__(self, repository: {Repository}):
+        self.repository = repository
+
+    async def create(self, data: {CreateDTO}) -> {Entity}:
+        """Create new {entity}"""
+        pass
+
+    async def get_by_id(self, id: UUID) -> Optional[{Entity}]:
+        """Get {entity} by ID"""
+        pass
+```
+
+#### Modified Services
+
+```python
+# {existing_service}.py
+# Add new method:
+
+async def {new_method}(self, params: {Params}) -> {Result}:
+    """
+    {Description}
+    """
+    pass
+```
+
+### 3.4 Models
+
+#### New Models
+
+```python
+class {ModelName}(Base):
+    __tablename__ = "{table_name}"
+
+    id = Column(UUID, primary_key=True, default=uuid4)
+    {field1} = Column(String(255), nullable=False)
+    {field2} = Column(Integer)
+
+    # Relationships
+    {relation} = relationship("{RelatedModel}", back_populates="{back_ref}")
+```
+
+#### DTOs
+
+```python
+class {ModelName}Create(BaseModel):
+    field1: str
+    field2: Optional[int] = None
+
+class {ModelName}Response(BaseModel):
+    id: UUID
+    field1: str
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+```
+
+## 4. Implementation Details
+
+### 4.1 Key Algorithms
+
+{Описание ключевых алгоритмов если применимо}
+
+### 4.2 External Integrations
+
+| Service | Purpose | Auth | Endpoint |
+|---------|---------|------|----------|
+| {service} | {purpose} | API Key | {url} |
+
+### 4.3 Configuration
+
+```python
+# config.py
+{CONFIG_VAR}: str = Field(default="{default}", env="{ENV_VAR}")
+```
+
+### 4.4 Feature Flags
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| FEATURE_{NAME} | False | Enable {feature} |
+
+## 5. Testing Strategy
+
+### 5.1 Unit Tests
+
+```python
+# test_{module}.py
+
+def test_{function}_success():
+    """Test {function} with valid input"""
+    pass
+
+def test_{function}_invalid_input():
+    """Test {function} with invalid input"""
+    pass
+
+def test_{function}_edge_case():
+    """Test {function} edge case: {description}"""
+    pass
+```
+
+### 5.2 Integration Tests
+
+```python
+# test_{module}_integration.py
+
+async def test_{endpoint}_create():
+    """Test POST /api/v1/{resource}"""
+    pass
+
+async def test_{endpoint}_get():
+    """Test GET /api/v1/{resource}/{id}"""
+    pass
+```
+
+### 5.3 Test Coverage Requirements
+
+- Unit tests: >80% coverage
+- Integration tests: all API endpoints
+- Edge cases: all identified in interview
+
+## 6. Security Considerations
+
+### 6.1 Authentication
+{Требования к аутентификации}
+
+### 6.2 Authorization
+{Требования к авторизации}
+
+### 6.3 Data Validation
+{Правила валидации входных данных}
+
+### 6.4 Sensitive Data
+{Обработка чувствительных данных}
+
+## 7. Performance Considerations
+
+### 7.1 Expected Load
+- Requests per second: {rps}
+- Data volume: {volume}
+
+### 7.2 Optimization Strategies
+- {strategy_1}
+- {strategy_2}
+
+### 7.3 Caching
+| Data | Strategy | TTL |
+|------|----------|-----|
+| {data} | {strategy} | {ttl} |
+
+## 8. Monitoring & Observability
+
+### 8.1 Metrics
+| Metric | Type | Description |
+|--------|------|-------------|
+| {metric_name} | Counter/Gauge/Histogram | {description} |
+
+### 8.2 Logging
+```python
+logger.info("{event}", extra={"field": value})
+```
+
+### 8.3 Alerts
+| Condition | Severity | Action |
+|-----------|----------|--------|
+| {condition} | Warning/Critical | {action} |
+
+## 9. Rollout Plan
+
+### 9.1 Deployment Steps
+1. Deploy database migrations
+2. Deploy application with feature flag OFF
+3. Enable feature flag for 10% users
+4. Monitor metrics
+5. Gradual rollout to 100%
+
+### 9.2 Rollback Plan
+1. Disable feature flag
+2. Revert application deployment
+3. Rollback database migrations (if safe)
+
+## 10. Open Questions
+
+- [ ] {question_1} - TBD
+- [ ] {question_2} - TBD
+
+---
+
+## Appendix
+
+### A. File Changes Summary
+
+| File | Action | Description |
+|------|--------|-------------|
+| {path/to/file.py} | Create | New service |
+| {path/to/existing.py} | Modify | Add method |
+
+### B. Dependencies
+
+| Package | Version | Purpose |
+|---------|---------|---------|
+| {package} | {version} | {purpose} |
+
+---
+
+**Changelog:**
+| Version | Date | Author | Changes |
+|---------|------|--------|---------|
+| 0.1 | {date} | Claude | Initial draft |
+```
+
+### 3. Правила генерации
+
+1. **Соответствие кодовой базе** - использовать существующие паттерны и соглашения
+2. **Конкретные примеры** - реальный код, не псевдокод
+3. **Трассируемость к PRD** - каждое решение связано с requirement
+4. **Готовность к debate** - достаточно деталей для критики
+
+### 4. Обновление состояния
+
+```json
+{
+  "completed_stages": ["fetch", "interview", "prd", "debate_prd", "spec"],
+  "current_stage": "debate_spec",
+  "artifacts": {
+    "spec": "docs/jira/PROJ-123/spec.md"
+  }
+}
+```
+
+### 5. Прикрепление спецификации к задаче Jira
+
+```python
+# Прикрепить spec как вложение
+attach_artifact(issue_key, f"docs/jira/{issue_key}/spec.md")
+```
+
+**HTTP fallback:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "X-Atlassian-Token: no-check" \
+  -F "file=@docs/jira/${ISSUE_KEY}/spec.md" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}/attachments"
+```
+
+### 6. Синхронизация с Jira (комментарий на русском)
+
+Добавь комментарий:
+
+```
+jira_add_comment(issue_key, """
+📝 Техническая спецификация создана
+
+Документ: docs/jira/PROJ-123/spec.md
+
+Изменения:
+- API: {N} новых endpoints
+- БД: {N} таблиц/миграций
+- Сервисы: {N} новых/{N} изменённых
+
+Готово к дебатам (adversarial review).
+""")
+```
+
+### 7. Вывод
+
+```
+[spec] Спецификация сгенерирована
+       Сохранено: docs/jira/PROJ-123/spec.md
+       Прикреплена к задаче в Jira
+       Ссылка добавлена в Jira
+
+       Changes summary:
+       - API: 2 new endpoints
+       - Database: 1 new table, 1 migration
+       - Services: 1 new, 1 modified
+       - Tests: 8 unit, 4 integration
+
+       -> Переход к этапу: debate (документ: Spec)
+```
+
+## Выходные данные
+
+Передаёт в следующий этап (debate Spec):
+- Файл spec.md для критики AI-моделями
+- Summary изменений
diff --git a/.claude/skills/jira-sync/SKILL.md b/.claude/skills/jira-sync/SKILL.md
new file mode 100755 (executable)
index 0000000..f08effc
--- /dev/null
@@ -0,0 +1,314 @@
+---
+name: jira-sync
+description: Синхронизация статусов и артефактов между локальными tasks и Jira
+triggers:
+  - jira sync
+  - sync jira
+  - синхронизировать jira
+---
+
+# Jira Sync Skill
+
+Синхронизирует статусы задач, комментарии и артефакты между локальным workflow и Jira.
+
+## Режимы работы
+
+1. **Auto-sync** — автоматически через hook при завершении task
+2. **Manual sync** — по команде `/jira-sync`
+3. **Batch sync** — синхронизация всех открытых задач
+
+## Процесс
+
+### 1. Определение режима доступа к Jira
+
+Используется `jira_access_mode` из состояния workflow (`config/state/{issue_key}.json`).
+Если `"mcp"` — используются MCP tools, если `"http"` — HTTP fallback через curl.
+
+### 2. Получение текущего состояния
+
+```python
+def get_sync_state(issue_key: str) -> dict:
+    # Локальное состояние
+    local_state = read_state_file(f"config/state/{issue_key}.json")
+
+    # Состояние в Jira (MCP или HTTP)
+    jira_issue = await jira_get_issue(issue_key)
+    jira_subtasks = [
+        await jira_get_issue(st["key"])
+        for st in jira_issue["fields"]["subtasks"]
+    ]
+
+    return {
+        "local": local_state,
+        "jira": {
+            "parent": jira_issue,
+            "subtasks": jira_subtasks
+        }
+    }
+```
+
+**HTTP fallback для get_issue:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -H "Content-Type: application/json" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}"
+```
+
+### 3. Синхронизация статусов subtasks
+
+Маппинг статусов:
+
+```python
+STATUS_MAPPING = {
+    # Local TaskUpdate status -> Jira transition
+    "pending": "To Do",
+    "in_progress": "In Progress",
+    "completed": "Done"
+}
+
+async def sync_subtask_status(local_task: dict, jira_subtask: dict):
+    local_status = local_task["status"]
+    jira_status = jira_subtask["fields"]["status"]["name"]
+
+    expected_jira_status = STATUS_MAPPING[local_status]
+
+    if jira_status != expected_jira_status:
+        # Получить доступные transitions
+        transitions = await jira_get_transitions(jira_subtask["key"])
+
+        # Найти нужный transition
+        target_transition = find_transition(transitions, expected_jira_status)
+
+        if target_transition:
+            await jira_transition_issue(
+                jira_subtask["key"],
+                target_transition["id"]
+            )
+            return {"synced": True, "from": jira_status, "to": expected_jira_status}
+
+    return {"synced": False, "reason": "already_in_sync"}
+```
+
+**HTTP fallback для transitions:**
+```bash
+# Получить transitions
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -H "Content-Type: application/json" \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${SUBTASK_KEY}/transitions"
+
+# Выполнить transition
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"transition": {"id": "TRANSITION_ID"}}' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${SUBTASK_KEY}/transitions"
+```
+
+### 4. Синхронизация комментариев
+
+При важных событиях добавляй комментарии в Jira на русском языке:
+
+```python
+SYNC_EVENTS = {
+    "task_started": "🚀 Начата работа над подзадачей",
+    "task_completed": "✅ Подзадача завершена",
+    "task_blocked": "🚫 Заблокировано: {blocker}",
+    "code_committed": "📝 Код закоммичен: {commit_hash}",
+    "pr_created": "🔀 PR создан: {pr_url}"
+}
+
+async def sync_comment(event: str, subtask_key: str, context: dict):
+    message = SYNC_EVENTS[event].format(**context)
+    await jira_add_comment(subtask_key, message)
+```
+
+**HTTP fallback для add_comment:**
+```bash
+curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "MESSAGE"}]}]}}' \
+  "https://${ATLASSIAN_HOST}/rest/api/3/issue/${SUBTASK_KEY}/comment"
+```
+
+### 5. Прикрепление артефактов
+
+Прикрепляет файлы артефактов к задаче Jira:
+
+```python
+async def attach_artifacts(issue_key: str, artifacts: dict, access_mode: str):
+    """
+    Прикрепляет файлы артефактов к задаче Jira.
+    """
+    for artifact_type, path in artifacts.items():
+        if os.path.exists(path):
+            if access_mode == "mcp":
+                # Через MCP (если доступен tool jira_add_attachment)
+                await jira_add_attachment(issue_key, file_path=path)
+            else:
+                # HTTP fallback
+                await bash(f'''
+                    curl -s -u "${{ATLASSIAN_EMAIL}}:${{ATLASSIAN_TOKEN}}" \
+                      -X POST \
+                      -H "X-Atlassian-Token: no-check" \
+                      -F "file=@{path}" \
+                      "https://${{ATLASSIAN_HOST}}/rest/api/3/issue/{issue_key}/attachments"
+                ''')
+```
+
+### 6. Синхронизация артефактов (комментарии со ссылками)
+
+Добавляй ссылки на документы на русском языке:
+
+```python
+async def sync_artifacts(issue_key: str, artifacts: dict):
+    links = []
+
+    for artifact_type, path in artifacts.items():
+        links.append(f"- {artifact_type}: `{path}`")
+
+    comment = f"""
+📁 Артефакты обновлены:
+
+{chr(10).join(links)}
+
+Сгенерировано плагином jira-workflow
+"""
+
+    await jira_add_comment(issue_key, comment)
+```
+
+### 7. Обновление родительской задачи
+
+При завершении всех subtasks:
+
+```python
+async def check_and_update_parent(parent_key: str):
+    subtasks = await get_subtasks(parent_key)
+
+    all_done = all(
+        st["fields"]["status"]["name"] == "Done"
+        for st in subtasks
+    )
+
+    if all_done:
+        # Все subtasks завершены
+        await jira_add_comment(parent_key, f"""
+🎉 Все подзадачи завершены!
+Реализация готова к ревью.
+
+Артефакты:
+- PRD: docs/jira/{parent_key}/prd.md
+- Спецификация: docs/jira/{parent_key}/spec.md
+- Лог дебатов: docs/jira/{parent_key}/debate-log.md
+- План: docs/jira/{parent_key}/plan.md
+""")
+
+        # Опционально: transition родительской задачи
+        # await jira_transition_issue(parent_key, "Ready for Review")
+```
+
+### 8. Batch sync
+
+Синхронизация всех открытых задач:
+
+```python
+async def batch_sync():
+    # Найти все state files
+    state_files = glob("config/state/*.json")
+
+    results = []
+    for state_file in state_files:
+        issue_key = extract_issue_key(state_file)
+        state = read_state_file(state_file)
+
+        if state["current_stage"] != "completed":
+            result = await sync_single_issue(issue_key)
+            results.append(result)
+
+    return results
+```
+
+### 9. Conflict resolution
+
+При конфликтах между локальным и Jira состоянием:
+
+```python
+def resolve_conflict(local: dict, jira: dict) -> str:
+    """
+    Правила разрешения конфликтов:
+    1. Jira статус "Done" + local "in_progress" -> оставить Jira (кто-то закрыл вручную)
+    2. Local "completed" + Jira "In Progress" -> обновить Jira
+    3. Разные assignees -> предупредить пользователя
+    """
+
+    if jira["status"] == "Done" and local["status"] != "completed":
+        return "keep_jira"
+
+    if local["status"] == "completed" and jira["status"] != "Done":
+        return "update_jira"
+
+    if local.get("assignee") != jira.get("assignee"):
+        return "warn_user"
+
+    return "no_conflict"
+```
+
+### 10. Вывод
+
+```
+[jira-sync] Синхронизация PROJ-123...
+
+    Subtasks:
+    PROJ-123-1: Done -> Done (синхр.)
+    PROJ-123-2: Done -> Done (синхр.)
+    PROJ-123-3: In Progress -> Done (обновлено)
+    PROJ-123-4: In Progress -> In Progress (синхр.)
+    - PROJ-123-5: To Do (не начата)
+
+    Артефакты синхронизированы:
+    PRD прикреплён
+    Спецификация прикреплена
+    Лог дебатов прикреплён
+
+    Статус родителя: 3/5 подзадач завершено
+```
+
+## Автоматическая синхронизация
+
+Через hook `task-complete`:
+
+```yaml
+trigger: PostToolUse
+tool: TaskUpdate
+condition: status changed to "completed"
+action: call jira-sync for related Jira subtask
+```
+
+## API
+
+### Manual sync command
+
+```bash
+# Sync single issue
+/jira-sync PROJ-123
+
+# Sync all open issues
+/jira-sync --all
+
+# Dry run (show what would be synced)
+/jira-sync PROJ-123 --dry-run
+```
+
+## Выходные данные
+
+```json
+{
+  "issue_key": "PROJ-123",
+  "synced_subtasks": 3,
+  "conflicts_resolved": 1,
+  "artifacts_synced": 4,
+  "artifacts_attached": 4,
+  "parent_updated": true
+}
+```
diff --git a/.claude/skills/jira-workflow/SKILL.md b/.claude/skills/jira-workflow/SKILL.md
new file mode 100755 (executable)
index 0000000..62a332b
--- /dev/null
@@ -0,0 +1,381 @@
+---
+description: "Automated workflow for Jira tasks: fetch -> interview -> PRD -> debate(PRD) -> spec -> debate(Spec) -> plan -> debate(Plan) -> implement"
+argument-hint: "PROJ-123 [--jql query] [--type quick|full|decompose] [--resume] [--no-interview] [--select] [--dry-run]"
+allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Task", "TaskCreate", "TaskUpdate", "TaskList", "AskUserQuestion", "WebFetch", "Skill"]
+---
+
+# /jira-workflow
+
+Полный автоматизированный workflow для обработки задач из Jira.
+
+## Использование
+
+```bash
+# Одиночная задача
+/jira-workflow PROJ-123
+
+# Несколько задач
+/jira-workflow PROJ-123,PROJ-124,PROJ-125
+
+# JQL-запрос
+/jira-workflow --jql "project=PROJ AND sprint=current"
+
+# Интерактивный выбор (задачи текущего пользователя)
+/jira-workflow --select
+
+# Продолжить прерванный workflow
+/jira-workflow --resume PROJ-123
+
+# Пропустить интервью (для простых задач)
+/jira-workflow PROJ-123 --no-interview
+
+# Переопределить тип workflow
+/jira-workflow PROJ-123 --type quick
+
+# Dry run
+/jira-workflow PROJ-123 --dry-run
+```
+
+## Workflow
+
+```
+fetch -> interview -> PRD -> debate(PRD) -> spec -> debate(Spec) -> plan -> debate(Plan) -> implement
+           ^                  ^                ^                ^
+      интерактивно      2-5 раундов      2-10 раундов      1-3 раунда
+                        бизнес-req       тех.сложность     проверка
+```
+
+## Этапы
+
+### 1. Fetch (jira-fetch)
+Получение данных задачи из Jira, определение типа workflow.
+Поддержка MCP и HTTP fallback.
+
+### 2. Interview (jira-interview)
+Уточнение требований через структурированное интервью (adversarial-spec style).
+**Всегда выполняется** (если не указан `--no-interview`).
+
+### 3. PRD (jira-prd)
+Генерация Product Requirements Document.
+*Пропускается для `quick` workflow.*
+
+### 4. Debate PRD (jira-debate --document prd)
+Критика PRD AI-моделями: фокус на полноте требований, бизнес-ценности, критериях приёмки.
+**2-5 раундов** до достижения консенсуса (70% APPROVED).
+*Пропускается для `quick` workflow.*
+
+### 5. Spec (jira-spec)
+Генерация технической спецификации.
+
+### 6. Debate Spec (jira-debate --document spec)
+Критика спецификации 7 AI-моделями через OpenRouter:
+- GPT-5.2 (Lead Technical Critic)
+- DeepSeek v3.2 (Architecture Critic)
+- Grok 4.1 Fast (Edge Case Critic)
+- Gemini 3 Pro (Scalability Critic)
+- Perplexity Sonar Pro (Deep Research Critic)
+- GLM 4.7 (Alternative Perspective Critic)
+- MiMo-V2-Flash (Rapid Validation Critic)
+
+**2-10 раундов** до достижения консенсуса (70% APPROVED).
+
+### 7. Plan (jira-plan)
+Декомпозиция на subtasks, создание в Jira и локально через TaskCreate.
+
+### 8. Debate Plan (jira-debate --document plan)
+Критика плана AI-моделями: фокус на декомпозиции, зависимостях, рисках.
+**1-3 раунда** до достижения консенсуса.
+
+### 9. Implement (jira-implement)
+Реализация кода по плану с автоматической синхронизацией статусов.
+
+## Артефакты
+
+Все документы сохраняются в `docs/jira/{ISSUE-KEY}/`:
+
+```
+docs/jira/PROJ-123/
+├── interview.md      # Q&A из интервью
+├── prd.md            # Product Requirements Document
+├── spec.md           # Техническая спецификация
+├── debate-log.md     # Лог дебатов с AI-моделями
+├── plan.md           # План реализации
+└── report.md         # Финальный отчёт
+```
+
+Артефакты автоматически прикрепляются к задаче Jira как вложения и упоминаются в комментариях.
+
+## Фокусированные дебаты
+
+После интервью автоматически определяются персоны для фокусированных дебатов:
+
+- **Frontend-архитектор** — UI/UX, компоненты, state management
+- **Backend-архитектор** — API, БД, scalability
+- **DevOps-инженер** — CI/CD, инфраструктура, мониторинг
+- **Инженер безопасности** — auth, защита данных, compliance
+- **QA-инженер** — тестирование, edge cases, качество
+
+Дебаты проходят итерациями по персонам с фокусированными критериями.
+
+## Рекомендация скиллов
+
+Перед генерацией PRD/Spec анализируются доступные скиллы и рекомендуются релевантные:
+
+```
+[skill-recommend] Обнаруженные фокусы: backend, security
+
+Рекомендуемые скиллы:
+  ✅ api-design-principles (установлен)
+  ✅ auth-implementation-patterns (установлен)
+  ❌ secrets-management (не установлен)
+
+Покрытие: 66%
+```
+
+## Типы workflow
+
+| Issue Type | Workflow | Этапы |
+|------------|----------|-------|
+| Story | full | Все этапы (9) |
+| Task | full | Все этапы (9) |
+| Bug | quick | Без PRD и debate(PRD), меньше вопросов |
+| Epic | decompose | Без implement, создаёт child issues |
+| Sub-task | quick | Без PRD и debate(PRD), меньше вопросов |
+
+## Обработка ошибок
+
+### Transient ошибки (сеть, rate limit)
+Автоматический retry 3 раза с exponential backoff.
+
+### Критические ошибки
+1. Состояние сохраняется в `config/state/{ISSUE-KEY}.json`
+2. Можно продолжить через `--resume`
+
+## Требования
+
+### Environment Variables
+
+```bash
+# Atlassian
+export ATLASSIAN_HOST="company.atlassian.net"
+export ATLASSIAN_EMAIL="your-email@company.com"
+export ATLASSIAN_TOKEN="your-api-token"
+
+# OpenRouter (для дебатов)
+export OPENROUTER_API_KEY="your-openrouter-key"
+```
+
+### MCP Server (основной режим)
+
+Плагин использует `jira-mcp` (Go-бинарник) через MCP:
+
+```bash
+# Устанавливается автоматически через install.sh в ~/.local/bin/jira-mcp
+# Или вручную с https://github.com/nguyenvanduocit/jira-mcp/releases
+```
+
+### HTTP Fallback (если MCP недоступен)
+
+Если MCP-сервер недоступен, плагин автоматически переключается на прямые HTTP-запросы к Jira REST API v3, используя переменные `ATLASSIAN_HOST`, `ATLASSIAN_EMAIL`, `ATLASSIAN_TOKEN`.
+
+## Примеры
+
+### Полный workflow для Story
+
+```bash
+> /jira-workflow PROJ-123
+
+[fetch] Загружаю PROJ-123... (режим: mcp)
+        Type: Story | Priority: High
+[comment] Комментарий добавлен ✓
+
+[interview] 5 вопросов...
+        Q1: Scope? -> B
+        Q2: Edge cases? -> A
+        ...
+[comment] Комментарий добавлен ✓
+
+[focus-detect] Анализирую задачу...
+        Фокусы: backend (12), security (6), frontend (2)
+        Персоны: Backend-архитектор, Инженер безопасности
+
+[skill-recommend] Покрытие скиллов: 83% (5/6)
+        ✅ api-design-principles, architecture-patterns
+        ❌ secrets-management
+
+[prd] docs/jira/PROJ-123/prd.md
+[comment] Комментарий добавлен ✓
+
+[debate:prd] Фокусированные дебаты (2 персоны)
+  Итерация 1/2: Backend-архитектор
+    Раунд 1/2... консенсус за 1 раунд
+  Итерация 2/2: Инженер безопасности
+    Раунд 1/2... консенсус за 1 раунд
+[comment] Комментарий добавлен ✓
+
+[spec] docs/jira/PROJ-123/spec.md
+[comment] Комментарий добавлен ✓
+
+[debate:spec] Фокусированные дебаты (2 персоны)
+  Итерация 1/2: Backend-архитектор
+    Раунд 1/3... GPT-5.2: MAJOR, DeepSeek: CRITICAL
+    -> Обновляю спецификацию
+    Раунд 2/3... консенсус за 2 раунда
+  Итерация 2/2: Инженер безопасности
+    Раунд 1/3... GPT-5.2: MAJOR (CSRF)
+    -> Обновляю спецификацию
+    Раунд 2/3... консенсус за 2 раунда
+[comment] Комментарий добавлен ✓
+
+[plan] 5 subtasks созданы
+       PROJ-123-1 -> PROJ-123-5
+[comment] Комментарий добавлен ✓
+
+[debate:plan] Фокусированные дебаты
+  Итерация 1/2: Backend-архитектор
+    Раунд 1/2... консенсус за 1 раунд
+[comment] Комментарий добавлен ✓
+
+[report] Генерирую финальный отчёт...
+         Документы: 5 | Дебаты: 6 раундов | Улучшений: 8
+         docs/jira/PROJ-123/report.md
+         Прикреплён к задаче ✓
+
+[implement] Реализация...
+            [1/5] Database Migration ✓
+            [2/5] Models ✓
+            [3/5] Service ✓
+            [4/5] API ✓
+            [5/5] Tests (16 passed) ✓
+[comment] Финальный комментарий добавлен ✓
+
+PROJ-123 завершён!
+```
+
+### Быстрый багфикс
+
+```bash
+> /jira-workflow PROJ-456 --type quick
+
+[fetch] Type: Bug -> workflow: quick
+
+[interview] 2 вопроса...
+
+[spec] (без PRD)
+
+[debate:spec] 2 раунда -> консенсус
+
+[plan] 2 subtasks
+
+[debate:plan] 1 раунд -> консенсус
+
+[implement]
+
+PROJ-456 завершён!
+```
+
+### Resume после ошибки
+
+```bash
+> /jira-workflow --resume PROJ-123
+
+Найдено состояние для PROJ-123:
+  Завершено: fetch -> interview -> prd -> debate(prd) -> spec
+  Ошибка на: debate(spec) (round 3)
+  Причина: OpenRouter rate limit
+
+Продолжить? [Y/n] Y
+
+[debate:spec] Продолжаю с раунда 3...
+...
+```
+
+## Инструкции для Claude
+
+При вызове `/jira-workflow`:
+
+1. **Парсинг аргументов**
+   - Извлеки issue key(s), флаги, JQL
+   - Определи режим: single, batch, jql, select, resume
+
+2. **Определение метода доступа к Jira**
+   1. Попытаться вызвать MCP tool `jira_get_issue` с тестовым запросом
+   2. Если MCP доступен -> использовать MCP (режим: mcp)
+   3. Если MCP недоступен (ошибка / timeout / нет бинарника):
+      a. Проверить наличие `ATLASSIAN_HOST`, `ATLASSIAN_EMAIL`, `ATLASSIAN_TOKEN`
+      b. Если переменные установлены -> переключиться на HTTP fallback (режим: http)
+      c. Если нет -> ошибка с инструкцией по настройке
+   4. Сохранить режим в состояние workflow: `jira_access_mode: "mcp" | "http"`
+
+3. **Проверка prerequisites**
+   - Проверь ATLASSIAN_HOST, ATLASSIAN_EMAIL, ATLASSIAN_TOKEN
+   - Проверь OPENROUTER_API_KEY (для debate)
+
+4. **Выполнение workflow**
+
+   **ВАЖНО:** После каждого этапа ОБЯЗАТЕЛЬНО добавляй комментарий в Jira!
+
+   Последовательно выполняй:
+   ```
+   Skill: jira-fetch
+   → Добавь комментарий (stage: fetch) через jira_add_comment
+
+   Skill: jira-interview (если не --no-interview)
+   → Добавь комментарий (stage: interview)
+   → Прикрепи docs/jira/{ISSUE-KEY}/interview.md
+
+   Skill: jira-focus-detect
+   Skill: jira-skill-recommend
+
+   Skill: jira-prd (если не quick workflow)
+   → Добавь комментарий (stage: prd)
+   → Прикрепи docs/jira/{ISSUE-KEY}/prd.md
+
+   Skill: jira-debate --document prd --personas
+   → Добавь комментарий (stage: debate)
+   → Прикрепи/обнови docs/jira/{ISSUE-KEY}/debate-log.md
+
+   Skill: jira-spec
+   → Добавь комментарий (stage: spec)
+   → Прикрепи docs/jira/{ISSUE-KEY}/spec.md
+
+   Skill: jira-debate --document spec --personas
+   → Добавь комментарий (stage: debate)
+
+   Skill: jira-plan
+   → Добавь комментарий (stage: plan)
+   → Прикрепи docs/jira/{ISSUE-KEY}/plan.md
+
+   Skill: jira-debate --document plan --personas
+   → Добавь комментарий (stage: debate)
+
+   Skill: jira-report
+   → Прикрепи docs/jira/{ISSUE-KEY}/report.md
+
+   Skill: jira-implement
+   → Добавь комментарий (stage: complete)
+   ```
+
+   **Как добавлять комментарии:**
+   - MCP режим: `jira_add_comment(issue_key, comment="plain text")` — НЕ передавай ADF!
+   - HTTP режим: curl с ADF в body (см. jira-comment skill)
+
+   **Как прикреплять файлы:**
+   - ВСЕГДА через HTTP (MCP не поддерживает attachments):
+   ```bash
+   curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \
+     -X POST -H "X-Atlassian-Token: no-check" \
+     -F "file=@docs/jira/{ISSUE-KEY}/prd.md" \
+     "https://${ATLASSIAN_HOST}/rest/api/3/issue/{ISSUE-KEY}/attachments"
+   ```
+
+5. **Обработка ошибок**
+   - При transient: retry
+   - При critical: сохранить состояние, предложить resume
+
+6. **Синхронизация**
+   Jira-sync вызывается автоматически через hooks.
+   Артефакты прикрепляются к задаче как вложения.
+
+7. **Вывод**
+   Показывай прогресс на каждом этапе. Все комментарии в Jira на русском языке.
diff --git a/erp24/config/db-test.php b/erp24/config/db-test.php
new file mode 100644 (file)
index 0000000..a4f0df7
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+return [
+    'class' => 'yii\db\Connection',
+    'dsn' => 'pgsql:host=localhost;port=5434;dbname=erp24_test',
+    'username' => 'erp24_user',
+    'password' => 'erp24_pass',
+    'charset' => 'utf8',
+    'schemaMap' => [
+        'pgsql' => [
+            'class' => 'yii\db\pgsql\Schema',
+            'defaultSchema' => 'erp24',
+        ]
+    ],
+];
index d85a9c453491325aacdf332ea7322d92b925b4a2..571337db4b4381f165dbe01a352dea341c3307d5 100644 (file)
@@ -21,12 +21,6 @@ use yii_app\services\ShiftReminderService;
  */
 class ShiftReminderController extends Controller
 {
-    /**
-     * Хранилище для отслеживания rate limiting
-     * @var array
-     */
-    private static $rateLimitStorage = [];
-
     /**
      * {@inheritdoc}
      */
@@ -89,6 +83,9 @@ class ShiftReminderController extends Controller
     /**
      * Проверить rate limit для действия
      *
+     * Использует Yii::$app->cache для сохранения состояния между HTTP-запросами.
+     * Это критично для корректной работы rate limiting в production.
+     *
      * @param string $action Название действия
      * @param int $limit Максимальное количество запросов в минуту
      * @return bool true если лимит не превышен, false в противном случае
@@ -97,28 +94,33 @@ class ShiftReminderController extends Controller
     {
         $userId = Yii::$app->user->id;
         $key = "shift_reminder_{$action}_{$userId}";
+        $cache = Yii::$app->cache;
         $now = time();
 
-        // Инициализируем хранилище для пользователя, если его нет
-        if (!isset(self::$rateLimitStorage[$key])) {
-            self::$rateLimitStorage[$key] = [];
+        // Получаем текущие запросы из cache (persist между HTTP-запросами)
+        $requests = $cache->get($key);
+        if ($requests === false) {
+            $requests = [];
         }
 
         // Удаляем запросы старше 60 секунд
-        self::$rateLimitStorage[$key] = array_filter(
-            self::$rateLimitStorage[$key],
+        $requests = array_filter(
+            $requests,
             function ($timestamp) use ($now) {
                 return ($now - $timestamp) < 60;
             }
         );
 
         // Проверяем, не превышен ли лимит
-        if (count(self::$rateLimitStorage[$key]) >= $limit) {
+        if (count($requests) >= $limit) {
             return false;
         }
 
         // Добавляем текущий запрос
-        self::$rateLimitStorage[$key][] = $now;
+        $requests[] = $now;
+
+        // Сохраняем обновлённый список в cache с TTL 60 секунд
+        $cache->set($key, $requests, 60);
 
         return true;
     }
diff --git a/erp24/docker/nginx/default.conf b/erp24/docker/nginx/default.conf
new file mode 100644 (file)
index 0000000..82ca74c
--- /dev/null
@@ -0,0 +1,23 @@
+server {
+    listen 80;
+    server_name localhost;
+    root /app/web;
+    index index.php index.html;
+
+    charset utf-8;
+
+    location / {
+        try_files $uri $uri/ /index.php?$args;
+    }
+
+    location ~ \.php$ {
+        fastcgi_pass php:9000;
+        fastcgi_index index.php;
+        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+        include fastcgi_params;
+    }
+
+    location ~ /\.(ht|svn|git) {
+        deny all;
+    }
+}
diff --git a/erp24/docker/php/Dockerfile b/erp24/docker/php/Dockerfile
new file mode 100644 (file)
index 0000000..4dc83f6
--- /dev/null
@@ -0,0 +1,28 @@
+FROM php:8.1-cli
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+    git \
+    curl \
+    libpq-dev \
+    libzip-dev \
+    zip \
+    unzip \
+    && docker-php-ext-install pdo pdo_pgsql pgsql zip \
+    && apt-get clean \
+    && rm -rf /var/lib/apt/lists/*
+
+# Install Composer
+COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
+
+WORKDIR /app
+
+# Copy application
+COPY . /app
+
+# Install dependencies (if not already installed)
+RUN if [ ! -d "vendor" ]; then composer install --no-interaction --prefer-dist; fi
+
+EXPOSE 8000
+
+CMD ["php", "-S", "0.0.0.0:8000", "-t", "/app/web"]
diff --git a/erp24/docs/services/ShiftReminderService.md b/erp24/docs/services/ShiftReminderService.md
new file mode 100644 (file)
index 0000000..e6f02ec
--- /dev/null
@@ -0,0 +1,772 @@
+# ShiftReminderService
+
+## Назначение
+
+`ShiftReminderService` — сервис для управления системой обязательных напоминаний о рабочих сменах (открытие/закрытие). Обеспечивает корректную работу с переходами через полночь и временными окнами показа уведомлений.
+
+**Ключевая особенность:** время 00:00-05:59 относится к **предыдущему календарному дню**, чтобы подтверждение в 01:00 не блокировало показ напоминания в 07:50 того же календарного дня.
+
+**Задача:** ERP-43-J
+
+---
+
+## Пространство имён
+
+```php
+namespace yii_app\services;
+```
+
+---
+
+## Зависимости
+
+| Класс | Назначение |
+|-------|------------|
+| `app\models\ShiftReminderShown` | ActiveRecord модель для отслеживания показанных напоминаний |
+| `yii_app\records\Admin` | Модель администратора |
+| `yii_app\records\Timetable` | Расписание рабочих смен |
+| `yii_app\records\TimetableFactModel` | Фактические данные по сменам (открытие/закрытие) |
+| `Yii` | Основной класс фреймворка (для логирования и конфигурации) |
+
+---
+
+## Конфигурация (config/params.php)
+
+```php
+// Временные окна для показа напоминаний (в формате HH:MM)
+'SHIFT_REMINDER_TIME_WINDOWS' => ['07:50', '08:10', '19:50', '20:10'],
+
+// Допустимые ключи напоминаний (whitelist для безопасности)
+'SHIFT_REMINDER_ALLOWED_KEYS' => ['day_shift', 'night_shift'],
+
+// ID групп администраторов, которым показываются напоминания
+// Пустой массив = все пользователи с записями в timetable
+'SHIFT_REMINDER_ADMIN_GROUP_IDS' => [],
+```
+
+---
+
+## Архитектура
+
+### Диаграмма компонентов
+
+```mermaid
+graph TB
+    Controller[ShiftReminderController] -->|использует| Service[ShiftReminderService]
+    Service -->|создаёт/читает| Model[ShiftReminderShown]
+    Service -->|проверяет| Admin[Admin]
+    Service -->|читает| Timetable[Timetable]
+    Service -->|проверяет статус| TimetableFact[TimetableFactModel]
+
+    Controller -->|JSON API| Frontend[Frontend JS]
+    Frontend -->|AJAX каждые 30 сек| Controller
+
+    Service -->|читает конфиг| Params[config/params.php]
+```
+
+### Поток работы системы
+
+```mermaid
+sequenceDiagram
+    participant Frontend as Frontend (shift-reminder.js)
+    participant Controller as ShiftReminderController
+    participant Service as ShiftReminderService
+    participant DB as База данных
+
+    Note over Frontend: Таймер: каждые 30 сек
+    Frontend->>Controller: POST /shift-reminder/check
+    Controller->>Service: shouldShowReminder(userId, reminderKey)
+
+    Service->>Service: getReferenceDate(currentTime)
+    Service->>DB: TimetableFactModel::getLast()
+    Service->>Service: isShiftActionNeeded()
+    Service->>DB: ShiftReminderShown::find()
+    Service->>DB: Timetable::find()
+    Service->>Service: isUserAdministrator()
+
+    Service-->>Controller: true/false
+    Controller-->>Frontend: {show_reminder: true/false}
+
+    alt Показать напоминание
+        Frontend->>Frontend: Показать модальное окно
+        Note over Frontend: Пользователь нажал "Закрыть смену"
+        Frontend->>Controller: POST /shift-reminder/confirm
+        Controller->>Service: confirmReminder(userId, reminderKey)
+        Service->>DB: ShiftReminderShown::save()
+        Service-->>Controller: true
+        Controller-->>Frontend: {success: true}
+    end
+```
+
+---
+
+## Свойства
+
+Сервис не имеет свойств экземпляра — все методы статические.
+
+---
+
+## Методы
+
+### getReferenceDate()
+
+**Описание:**
+Получить reference_date для текущего времени с учетом логики перехода дня.
+
+**Логика day rollover:**
+- Время **00:00-05:59** → reference_date = предыдущий календарный день
+- Время **06:00-23:59** → reference_date = текущий календарный день
+
+Это гарантирует, что подтверждение напоминания в 01:00 (которое относится к предыдущему дню) не блокирует показ напоминания в 07:50 (которое относится к текущему дню).
+
+**Параметры:**
+- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+
+**Возвращает:**
+- `string` — дата в формате 'Y-m-d'
+
+**Пример:**
+
+```php
+// Время 01:00 относится к предыдущему дню
+$date = ShiftReminderService::getReferenceDate('2026-02-04 01:00:00');
+// Результат: '2026-02-03'
+
+// Время 07:50 относится к текущему дню
+$date = ShiftReminderService::getReferenceDate('2026-02-04 07:50:00');
+// Результат: '2026-02-04'
+
+// Без параметра использует текущее время
+$date = ShiftReminderService::getReferenceDate();
+```
+
+**Используется в:**
+- `shouldShowReminder()` — для определения даты проверки напоминания
+- `isShiftActionNeeded()` — для поиска смены
+- `isUserAdministrator()` — для проверки расписания
+- `createReminderRecord()` — для создания записи
+- `confirmReminder()` — для подтверждения
+
+---
+
+### isInTimeWindow()
+
+**Описание:**
+Проверить, находится ли текущее время в одном из временных окон для показа напоминаний.
+
+**Параметры:**
+- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+- `array|null $windows` — массив временных окон в формате ['HH:MM', ...]. Если null, используются окна из конфигурации.
+
+**Возвращает:**
+- `bool` — true если текущее время находится в одном из окон, false в противном случае
+
+**Пример:**
+
+```php
+// Проверка с использованием конфигурации (07:50, 08:10, 19:50, 20:10)
+$inWindow = ShiftReminderService::isInTimeWindow('2026-02-04 07:50:00');
+// Результат: true
+
+$inWindow = ShiftReminderService::isInTimeWindow('2026-02-04 07:51:00');
+// Результат: false (не попадает ни в одно окно)
+
+// Проверка с кастомными окнами
+$inWindow = ShiftReminderService::isInTimeWindow(
+    '2026-02-04 10:00:00',
+    ['10:00', '15:00']
+);
+// Результат: true
+```
+
+**Используется в:**
+- `ShiftReminderController::actionCheck()` — для определения автоматического reminder_key
+
+---
+
+### shouldShowReminder()
+
+**Описание:**
+Определить, нужно ли показать напоминание пользователю.
+
+**Проверяет следующие условия:**
+1. Действие со сменой ещё не выполнено в `timetable_fact`
+2. Напоминание не было подтверждено для текущего `reminder_key` и `reference_date`
+3. Пользователь принадлежит к группе администраторов (проверка через `timetable`)
+
+**Параметры:**
+- `int $userId` — ID пользователя
+- `string $reminderKey` — ключ напоминания (day_shift или night_shift)
+- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+
+**Возвращает:**
+- `bool` — true если нужно показать напоминание, false в противном случае
+
+**Пример:**
+
+```php
+// Проверка для пользователя 1 в дневной смене
+$shouldShow = ShiftReminderService::shouldShowReminder(1, 'day_shift');
+
+if ($shouldShow) {
+    // Показать модальное окно с напоминанием
+}
+```
+
+**Поток данных:**
+
+```mermaid
+graph TD
+    A[shouldShowReminder] --> B{isShiftActionNeeded?}
+    B -->|false| C[return false]
+    B -->|true| D{ShiftReminderShown exists?}
+    D -->|да, confirmed| E[return false]
+    D -->|нет| F{isUserAdministrator?}
+    F -->|false| G[return false]
+    F -->|true| H[return true]
+```
+
+**Список вызовов:**
+- `isShiftActionNeeded($userId, $currentTime)` — проверка необходимости действия со сменой
+- `getReferenceDate($currentTime)` — получение reference_date
+- `ShiftReminderShown::find()` — поиск подтверждённого напоминания в БД
+- `isUserAdministrator($userId, $currentTime)` — проверка прав пользователя
+
+**Используется в:**
+- `ShiftReminderController::actionCheck()` — основной endpoint проверки
+
+---
+
+### isShiftActionNeeded()
+
+**Описание:**
+Проверить, нужно ли выполнить действие со сменой (открытие/закрытие).
+
+**Логика:**
+- **Период закрытия (07:50-08:09, 19:50-20:09):** проверяем, есть ли открытая смена для закрытия
+- **Период открытия (08:10-19:49, 20:10-07:49):** проверяем, не открыта ли уже смена
+
+**Параметры:**
+- `int $userId` — ID пользователя
+- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+
+**Возвращает:**
+- `bool` — true если действие нужно выполнить, false если уже выполнено
+
+**Пример:**
+
+```php
+// В период закрытия (07:50-08:09)
+$needed = ShiftReminderService::isShiftActionNeeded(1, '2026-02-04 07:50:00');
+// true если есть открытая смена, false если смена уже закрыта
+
+// В период открытия (08:10-19:49)
+$needed = ShiftReminderService::isShiftActionNeeded(1, '2026-02-04 12:00:00');
+// true если смена не открыта, false если уже открыта
+```
+
+**Временные периоды:**
+
+| Время | Период | Действие | Проверка |
+|-------|--------|----------|----------|
+| 07:50-08:09 | Закрытие ночной | Закрыть смену | Есть ли открытая смена? |
+| 08:10-19:49 | Дневная смена | Открыть смену | Смена не открыта? |
+| 19:50-20:09 | Закрытие дневной | Закрыть смену | Есть ли открытая смена? |
+| 20:10-07:49 | Ночная смена | Открыть смену | Смена не открыта? |
+
+**Список вызовов:**
+- `getReferenceDate($currentTime)` — получение reference_date
+- `TimetableFactModel::getLast($userId, $referenceDate, true)` — получение последней смены пользователя
+
+**Используется в:**
+- `shouldShowReminder()` — первая проверка перед показом напоминания
+
+---
+
+### isUserAdministrator()
+
+**Описание:**
+Проверить, является ли пользователь администратором на текущую дату.
+
+**Интеграция с системой timetable:** проверяем, есть ли у пользователя запланированная рабочая смена на reference_date.
+
+**Параметры:**
+- `int $userId` — ID пользователя
+- `string|null $currentTime` — текущее время. Если null, используется текущее время.
+
+**Возвращает:**
+- `bool` — true если пользователь является администратором, false в противном случае
+
+**Пример:**
+
+```php
+$isAdmin = ShiftReminderService::isUserAdministrator(1);
+
+if (!$isAdmin) {
+    // Не показывать напоминание
+}
+```
+
+**Логика проверки:**
+
+```mermaid
+graph TD
+    A[isUserAdministrator] --> B{Admin exists?}
+    B -->|нет| C[return false]
+    B -->|да| D{В SHIFT_REMINDER_ADMIN_GROUP_IDS?}
+    D -->|нет| E[return false]
+    D -->|да или пустой массив| F{Есть запись в Timetable?}
+    F -->|нет| G[return false]
+    F -->|да| H[return true]
+```
+
+**Проверяемые типы смен:**
+- `1` — Timetable::TIMESLOT_WORK (работа)
+- `5` — Timetable::TIMESLOT_INTERNSHIP (стажировка)
+- `8` — Другой тип административной работы
+
+**Список вызовов:**
+- `Admin::findOne($userId)` — получение модели администратора
+- `getReferenceDate($currentTime)` — получение reference_date
+- `Timetable::find()` — проверка наличия смены в расписании
+
+**Используется в:**
+- `shouldShowReminder()` — финальная проверка перед показом напоминания
+
+---
+
+### createReminderRecord()
+
+**Описание:**
+Создать или получить запись о показанном напоминании.
+
+**Параметры:**
+- `int $userId` — ID пользователя
+- `string $reminderKey` — ключ напоминания
+- `string|null $currentTime` — текущее время. Если null, используется текущее время.
+
+**Возвращает:**
+- `ShiftReminderShown|null` — модель напоминания или null в случае ошибки
+
+**Пример:**
+
+```php
+$reminder = ShiftReminderService::createReminderRecord(1, 'day_shift');
+
+if ($reminder !== null) {
+    echo "Создана запись ID: " . $reminder->id;
+}
+```
+
+**Логика:**
+
+```mermaid
+graph TD
+    A[createReminderRecord] --> B{Запись существует?}
+    B -->|да| C[Вернуть существующую]
+    B -->|нет| D[Создать новую]
+    D --> E{save успешно?}
+    E -->|да| F[Вернуть модель]
+    E -->|нет| G[Логировать ошибку]
+    G --> H[Вернуть null]
+```
+
+**Список вызовов:**
+- `getReferenceDate($currentTime)` — получение reference_date
+- `ShiftReminderShown::find()` — поиск существующей записи
+- `new ShiftReminderShown()` — создание новой модели
+- `$reminder->save()` — сохранение в БД
+- `Yii::error()` — логирование ошибки
+
+**Используется в:**
+- `confirmReminder()` — перед подтверждением напоминания
+
+---
+
+### confirmReminder()
+
+**Описание:**
+Подтвердить напоминание.
+
+**Параметры:**
+- `int $userId` — ID пользователя
+- `string $reminderKey` — ключ напоминания
+- `string|null $currentTime` — текущее время. Если null, используется текущее время.
+
+**Возвращает:**
+- `bool` — true если подтверждение прошло успешно, false в противном случае
+
+**Пример:**
+
+```php
+$success = ShiftReminderService::confirmReminder(1, 'day_shift');
+
+if ($success) {
+    // Напоминание подтверждено
+}
+```
+
+**Логика:**
+
+```mermaid
+graph TD
+    A[confirmReminder] --> B[createReminderRecord]
+    B --> C{Запись создана?}
+    C -->|нет| D[return false]
+    C -->|да| E{Уже подтверждено?}
+    E -->|да| F[return true]
+    E -->|нет| G[markAsConfirmed]
+    G --> H[return результат save]
+```
+
+**Список вызовов:**
+- `createReminderRecord($userId, $reminderKey, $currentTime)` — создание/получение записи
+- `$reminder->isConfirmed()` — проверка статуса подтверждения
+- `$reminder->markAsConfirmed()` — установка confirmed_at и сохранение
+
+**Используется в:**
+- `ShiftReminderController::actionConfirm()` — основной endpoint подтверждения
+
+---
+
+### getAllowedReminderKeys()
+
+**Описание:**
+Получить список допустимых ключей напоминаний (whitelist).
+
+**Параметры:**
+Нет
+
+**Возвращает:**
+- `array` — массив допустимых reminder_key
+
+**Пример:**
+
+```php
+$keys = ShiftReminderService::getAllowedReminderKeys();
+// Результат: ['day_shift', 'night_shift']
+```
+
+**Используется в:**
+- `isValidReminderKey()` — для валидации
+- `ShiftReminderController::actionCheck()` — для формирования сообщения об ошибке
+
+---
+
+### isValidReminderKey()
+
+**Описание:**
+Проверить, является ли ключ напоминания допустимым.
+
+**Параметры:**
+- `string $reminderKey` — ключ напоминания для проверки
+
+**Возвращает:**
+- `bool` — true если ключ допустим, false в противном случае
+
+**Пример:**
+
+```php
+if (!ShiftReminderService::isValidReminderKey($key)) {
+    throw new \InvalidArgumentException('Invalid reminder key');
+}
+```
+
+**Список вызовов:**
+- `getAllowedReminderKeys()` — получение whitelist
+
+**Используется в:**
+- `ShiftReminderController::actionCheck()` — валидация входных данных
+- `ShiftReminderController::actionConfirm()` — валидация входных данных
+
+---
+
+## Интеграция с контроллером
+
+### ShiftReminderController
+
+Контроллер предоставляет JSON API для взаимодействия с сервисом:
+
+**Endpoints:**
+- `POST /shift-reminder/check` — проверить необходимость показа напоминания
+- `POST /shift-reminder/confirm` — подтвердить просмотр напоминания
+
+**Пример использования в контроллере:**
+
+```php
+public function actionCheck()
+{
+    $userId = Yii::$app->user->id;
+    $reminderKey = 'day_shift'; // автоматически определяется по времени
+
+    $shouldShow = ShiftReminderService::shouldShowReminder($userId, $reminderKey);
+
+    if ($shouldShow) {
+        return [
+            'success' => true,
+            'show_reminder' => true,
+            'reminder_key' => $reminderKey,
+            'reference_date' => ShiftReminderService::getReferenceDate(),
+        ];
+    }
+
+    return [
+        'success' => true,
+        'show_reminder' => false,
+    ];
+}
+```
+
+---
+
+## Интеграция с Frontend
+
+### JavaScript (shift-reminder.js)
+
+Frontend периодически опрашивает API каждые 30 секунд:
+
+```javascript
+setInterval(function() {
+    $.ajax({
+        url: '/shift-reminder/check',
+        method: 'POST',
+        dataType: 'json',
+        success: function(response) {
+            if (response.success && response.show_reminder) {
+                // Показать модальное окно
+                showReminderModal(response.reminder_key);
+            }
+        }
+    });
+}, 30000); // 30 секунд
+```
+
+---
+
+## Таблица БД
+
+### shift_reminder_shown
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | integer | PRIMARY KEY |
+| `user_id` | integer | NOT NULL, ID пользователя |
+| `reminder_key` | string(50) | NOT NULL, ключ напоминания |
+| `reference_date` | date | NOT NULL, календарная дата |
+| `confirmed_at` | timestamp | NULL, дата подтверждения |
+| `created_at` | timestamp | NOT NULL, дата создания |
+
+**Индексы:**
+- `UNIQUE (user_id, reminder_key, reference_date)` — предотвращение дублирования
+- `INDEX (confirmed_at)` — быстрый поиск подтверждённых
+
+**Миграция:**
+```bash
+php yii migrate/up --migrationPath=@app/migrations
+```
+
+---
+
+## Примеры использования
+
+### Пример 1: Проверка необходимости показа напоминания
+
+```php
+use yii_app\services\ShiftReminderService;
+
+$userId = 1;
+$reminderKey = 'day_shift';
+
+if (ShiftReminderService::shouldShowReminder($userId, $reminderKey)) {
+    echo "Нужно показать напоминание пользователю {$userId}";
+}
+```
+
+### Пример 2: Подтверждение напоминания
+
+```php
+$success = ShiftReminderService::confirmReminder($userId, $reminderKey);
+
+if ($success) {
+    echo "Напоминание подтверждено";
+} else {
+    echo "Ошибка подтверждения";
+}
+```
+
+### Пример 3: Работа с reference_date
+
+```php
+// Время 01:00 ночи
+$date1 = ShiftReminderService::getReferenceDate('2026-02-04 01:00:00');
+echo $date1; // 2026-02-03 (предыдущий день)
+
+// Время 07:50 утра
+$date2 = ShiftReminderService::getReferenceDate('2026-02-04 07:50:00');
+echo $date2; // 2026-02-04 (текущий день)
+
+// Подтверждение в 01:00 не блокирует показ в 07:50,
+// так как reference_date разные
+```
+
+### Пример 4: Валидация reminder_key
+
+```php
+$key = $_POST['reminder_key'] ?? null;
+
+if (!ShiftReminderService::isValidReminderKey($key)) {
+    throw new \yii\web\BadRequestHttpException(
+        'Invalid reminder_key. Allowed: ' .
+        implode(', ', ShiftReminderService::getAllowedReminderKeys())
+    );
+}
+```
+
+---
+
+## Тестирование
+
+### Unit-тесты
+
+Расположение: `tests/unit/services/ShiftReminderServiceTest.php`
+
+**Покрытие:**
+- getReferenceDate() — логика перехода через полночь (8 тестов)
+- isInTimeWindow() — проверка временных окон (11 тестов)
+- getAllowedReminderKeys() и isValidReminderKey() — валидация ключей (6 тестов)
+- Граничные случаи и безопасность (5 тестов)
+
+**Запуск:**
+```bash
+./vendor/bin/codecept run unit/services/ShiftReminderServiceTest
+```
+
+### Функциональные тесты
+
+Расположение: `tests/functional/ShiftReminderControllerCest.php`
+
+**Покрытие:**
+- Авторизация и доступ (4 теста)
+- actionCheck — валидация и бизнес-логика (4 теста)
+- actionConfirm — подтверждение напоминаний (5 тестов)
+- Безопасность (SQL injection, XSS) (2 теста)
+- Rate limiting (2 теста)
+
+**Запуск:**
+```bash
+./vendor/bin/codecept run functional/ShiftReminderControllerCest
+```
+
+---
+
+## Безопасность
+
+### Whitelist для reminder_key
+
+Сервис использует whitelist подход для валидации `reminder_key`:
+
+```php
+// Только эти ключи допустимы
+'SHIFT_REMINDER_ALLOWED_KEYS' => ['day_shift', 'night_shift']
+```
+
+Это защищает от:
+- SQL-инъекций
+- XSS-атак
+- Неожиданных значений
+
+### Rate Limiting
+
+Контроллер реализует rate limiting:
+- **actionCheck**: 10 запросов в минуту
+- **actionConfirm**: 5 запросов в минуту
+
+### CSRF Protection
+
+Все POST запросы защищены CSRF-токеном Yii2.
+
+---
+
+## Производительность
+
+### Оптимизация запросов к БД
+
+**Индексы:**
+- Уникальный индекс на `(user_id, reminder_key, reference_date)` — O(log n) для поиска
+- Индекс на `confirmed_at` — быстрая фильтрация подтверждённых
+
+**Кеширование:**
+Рекомендуется кешировать результаты:
+- `isUserAdministrator()` — кешировать на 5 минут
+- `isShiftActionNeeded()` — кешировать на 1 минуту
+
+**Пример кеширования:**
+
+```php
+$cacheKey = "shift_reminder_{$userId}_{$reminderKey}_" . date('Y-m-d-H-i');
+
+$shouldShow = Yii::$app->cache->getOrSet($cacheKey, function() use ($userId, $reminderKey) {
+    return ShiftReminderService::shouldShowReminder($userId, $reminderKey);
+}, 60); // 60 секунд
+```
+
+---
+
+## Логирование
+
+Сервис использует категорию логирования `shift-reminder`:
+
+```php
+Yii::error('Failed to create shift reminder record: ' . json_encode($errors), 'shift-reminder');
+```
+
+**Конфигурация логирования:**
+
+```php
+'log' => [
+    'targets' => [
+        [
+            'class' => 'yii\log\FileTarget',
+            'levels' => ['error', 'warning'],
+            'categories' => ['shift-reminder'],
+            'logFile' => '@runtime/logs/shift-reminder.log',
+        ],
+    ],
+],
+```
+
+---
+
+## Известные ограничения
+
+1. **Time-based логика:** методы завязаны на серверное время. При смене часового пояса сервера может потребоваться корректировка конфигурации.
+
+2. **In-memory rate limiting:** текущая реализация rate limiting в контроллере использует статическую переменную, которая сбрасывается при перезапуске PHP-FPM. Для production рекомендуется Redis или Memcached.
+
+3. **Зависимость от TimetableFactModel:** метод `isShiftActionNeeded()` полагается на корректность данных в таблице `timetable_fact`. При проблемах с этой таблицей напоминания могут работать некорректно.
+
+---
+
+## Roadmap / Будущие улучшения
+
+1. **Кеширование:** добавить Redis-кеш для `isUserAdministrator()` и `isShiftActionNeeded()`
+2. **Rate limiting:** перенести на Redis для персистентности между запросами
+3. **Метрики:** добавить сбор метрик (количество показанных/подтверждённых напоминаний)
+4. **Уведомления:** добавить push-уведомления вместо модального окна
+5. **Тестирование:** добавить интеграционные тесты с реальной БД
+
+---
+
+## Связанные документы
+
+- [ShiftReminderController](../controllers/ShiftReminderController.md) — контроллер для обработки API запросов
+- [ShiftReminderShown](../models/ShiftReminderShown.md) — модель для хранения данных
+- [Миграция m260127_105454](../migrations/m260127_105454_create_shift_reminder_shown_table.md) — создание таблицы БД
+- [Frontend shift-reminder.js](../frontend/shift-reminder.js.md) — клиентская часть
+
+---
+
+**Версия:** 1.0.0
+**Дата создания:** 2026-02-05
+**Автор:** Claude Code
+**Задача:** ERP-43-J
index f22fd7f3a985135941c9237d041743c05cca2b48..61ccbc6bdb4d8bf043197fb1769e3b2dd12b6ca3 100644 (file)
@@ -27,7 +27,7 @@ class ShiftReminderShown extends ActiveRecord
      */
     public static function tableName()
     {
-        return 'shift_reminder_shown';
+        return 'erp24.shift_reminder_shown';
     }
 
     /**
@@ -73,12 +73,16 @@ class ShiftReminderShown extends ActiveRecord
      * Отмечает напоминание как подтвержденное и сохраняет запись
      *
      * Устанавливает текущее время в поле confirmed_at и сохраняет модель.
+     * Идемпотентен: если напоминание уже подтверждено, timestamp не изменяется.
      *
      * @return bool true если сохранение прошло успешно, false в противном случае
      */
     public function markAsConfirmed()
     {
-        $this->confirmed_at = date('Y-m-d H:i:s');
+        // Идемпотентность: не изменяем timestamp если уже подтверждено
+        if ($this->confirmed_at === null) {
+            $this->confirmed_at = date('Y-m-d H:i:s');
+        }
         return $this->save(false);
     }
 }
diff --git a/erp24/tests/functional/ShiftReminderControllerCest.php b/erp24/tests/functional/ShiftReminderControllerCest.php
new file mode 100644 (file)
index 0000000..8c7b52a
--- /dev/null
@@ -0,0 +1,539 @@
+<?php
+
+namespace tests\functional;
+
+use FunctionalTester;
+use Yii;
+use app\models\ShiftReminderShown;
+use yii_app\records\Admin;
+
+/**
+ * Функциональные тесты для ShiftReminderController
+ *
+ * Покрывает:
+ * - Проверка необходимости показа напоминаний (actionCheck)
+ * - Подтверждение напоминаний (actionConfirm)
+ * - Авторизация и CSRF защита
+ * - Rate limiting
+ * - Валидация входных данных
+ *
+ * @package tests\functional
+ */
+class ShiftReminderControllerCest
+{
+    /**
+     * ID тестового пользователя
+     * @var int
+     */
+    private $testUserId = 1;
+
+    /**
+     * Подготовка перед каждым тестом
+     */
+    public function _before(FunctionalTester $I)
+    {
+        // Очистка таблицы напоминаний перед каждым тестом
+        ShiftReminderShown::deleteAll();
+    }
+
+    /**
+     * Очистка после каждого теста
+     */
+    public function _after(FunctionalTester $I)
+    {
+        // Очистка данных
+        ShiftReminderShown::deleteAll();
+    }
+
+    // ========================================================================
+    // ГРУППА 1: Тесты авторизации и доступа
+    // ========================================================================
+
+    /**
+     * Тест: неавторизованный пользователь не может получить доступ к actionCheck
+     */
+    public function testActionCheck_Unauthorized_ReturnsError(FunctionalTester $I)
+    {
+        // Arrange: не логинимся
+
+        // Act: отправляем POST запрос без авторизации
+        $I->sendPOST('/shift-reminder/check', [
+            'reminder_key' => 'day_shift',
+        ]);
+
+        // Assert: ожидаем редирект на страницу логина или ошибку 403
+        $I->seeResponseCodeIsClientError(); // 401, 403 или 3xx
+    }
+
+    /**
+     * Тест: неавторизованный пользователь не может получить доступ к actionConfirm
+     */
+    public function testActionConfirm_Unauthorized_ReturnsError(FunctionalTester $I)
+    {
+        // Arrange: не логинимся
+
+        // Act
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'day_shift',
+        ]);
+
+        // Assert
+        $I->seeResponseCodeIsClientError();
+    }
+
+    /**
+     * Тест: только POST метод разрешен для actionCheck
+     */
+    public function testActionCheck_GetMethod_ReturnsMethodNotAllowed(FunctionalTester $I)
+    {
+        // Arrange: логинимся как admin
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: отправляем GET запрос
+        $I->sendGET('/shift-reminder/check');
+
+        // Assert: должен быть ответ 405 Method Not Allowed
+        $I->seeResponseCodeIs(405);
+    }
+
+    /**
+     * Тест: только POST метод разрешен для actionConfirm
+     */
+    public function testActionConfirm_GetMethod_ReturnsMethodNotAllowed(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act
+        $I->sendGET('/shift-reminder/confirm');
+
+        // Assert
+        $I->seeResponseCodeIs(405);
+    }
+
+    // ========================================================================
+    // ГРУППА 2: Тесты actionCheck - валидация и бизнес-логика
+    // ========================================================================
+
+    /**
+     * Тест: успешная проверка с корректным reminder_key
+     */
+    public function testActionCheck_ValidRequest_ReturnsSuccess(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act
+        $I->sendPOST('/shift-reminder/check', [
+            'reminder_key' => 'day_shift',
+        ]);
+
+        // Assert
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => true,
+        ]);
+    }
+
+    /**
+     * Тест: некорректный reminder_key возвращает ошибку
+     */
+    public function testActionCheck_InvalidReminderKey_ReturnsError(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act
+        $I->sendPOST('/shift-reminder/check', [
+            'reminder_key' => 'invalid_shift',
+        ]);
+
+        // Assert
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => false,
+        ]);
+        $I->seeResponseContains('Invalid reminder_key');
+    }
+
+    /**
+     * Тест: автоматическое определение reminder_key на основе времени
+     *
+     * Примечание: контроллер игнорирует переданный reminder_key
+     * и определяет его автоматически на основе текущего времени
+     */
+    public function testActionCheck_AutoDetectsReminderKeyByTime(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: отправляем запрос (контроллер сам определит правильный ключ)
+        $I->sendPOST('/shift-reminder/check');
+
+        // Assert
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => true,
+        ]);
+
+        // Проверяем, что возвращен reminder_key
+        $response = json_decode($I->grabResponse(), true);
+        if (isset($response['show_reminder']) && $response['show_reminder']) {
+            $I->assertArrayHasKey('reminder_key', $response);
+            $I->assertContains(
+                $response['reminder_key'],
+                ['day_shift', 'night_shift']
+            );
+        }
+    }
+
+    /**
+     * Тест: повторная проверка после подтверждения не показывает напоминание
+     */
+    public function testActionCheck_AfterConfirm_ReturnsShowFalse(FunctionalTester $I)
+    {
+        // Arrange: логинимся и подтверждаем напоминание
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Сначала подтверждаем напоминание
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'day_shift',
+        ]);
+        $I->seeResponseCodeIsSuccessful();
+
+        // Act: проверяем снова
+        $I->sendPOST('/shift-reminder/check', [
+            'reminder_key' => 'day_shift',
+        ]);
+
+        // Assert: не должно показывать напоминание (уже подтверждено)
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseContainsJson([
+            'success' => true,
+            'show_reminder' => false,
+        ]);
+    }
+
+    // ========================================================================
+    // ГРУППА 3: Тесты actionConfirm - подтверждение напоминаний
+    // ========================================================================
+
+    /**
+     * Тест: успешное подтверждение напоминания
+     */
+    public function testActionConfirm_ValidRequest_ReturnsSuccess(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'day_shift',
+        ]);
+
+        // Assert
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => true,
+            'message' => 'Reminder confirmed successfully.',
+        ]);
+
+        // Проверяем что запись создана в БД
+        $record = ShiftReminderShown::find()
+            ->where([
+                'user_id' => $this->testUserId,
+                'reminder_key' => 'day_shift',
+            ])
+            ->one();
+
+        $I->assertNotNull($record, 'Запись должна быть создана в БД');
+        $I->assertNotNull($record->confirmed_at, 'Поле confirmed_at должно быть заполнено');
+    }
+
+    /**
+     * Тест: подтверждение без reminder_key возвращает ошибку
+     */
+    public function testActionConfirm_MissingReminderKey_ReturnsError(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: не передаем reminder_key
+        $I->sendPOST('/shift-reminder/confirm', []);
+
+        // Assert
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => false,
+        ]);
+        $I->seeResponseContains('reminder_key is required');
+    }
+
+    /**
+     * Тест: подтверждение с некорректным reminder_key возвращает ошибку
+     */
+    public function testActionConfirm_InvalidReminderKey_ReturnsError(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'invalid_key',
+        ]);
+
+        // Assert
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson([
+            'success' => false,
+        ]);
+        $I->seeResponseContains('Invalid reminder_key');
+    }
+
+    /**
+     * Тест: повторное подтверждение одного напоминания идемпотентно
+     */
+    public function testActionConfirm_DuplicateConfirm_IsIdempotent(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: подтверждаем дважды
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'day_shift',
+        ]);
+        $I->seeResponseCodeIsSuccessful();
+
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'day_shift',
+        ]);
+
+        // Assert: должно вернуть успех (идемпотентность)
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseContainsJson([
+            'success' => true,
+        ]);
+
+        // Проверяем что в БД только одна запись
+        $count = ShiftReminderShown::find()
+            ->where([
+                'user_id' => $this->testUserId,
+                'reminder_key' => 'day_shift',
+            ])
+            ->count();
+
+        $I->assertEquals(1, $count, 'Должна быть только одна запись в БД');
+    }
+
+    /**
+     * Тест: подтверждение для разных reminder_key создает отдельные записи
+     */
+    public function testActionConfirm_DifferentKeys_CreatesSeparateRecords(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: подтверждаем day_shift
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'day_shift',
+        ]);
+        $I->seeResponseCodeIsSuccessful();
+
+        // Act: подтверждаем night_shift
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'night_shift',
+        ]);
+        $I->seeResponseCodeIsSuccessful();
+
+        // Assert: должно быть 2 записи в БД
+        $count = ShiftReminderShown::find()
+            ->where(['user_id' => $this->testUserId])
+            ->count();
+
+        $I->assertEquals(2, $count, 'Должно быть 2 отдельные записи');
+    }
+
+    // ========================================================================
+    // ГРУППА 4: Тесты безопасности
+    // ========================================================================
+
+    /**
+     * Тест: попытка SQL-инъекции через reminder_key блокируется
+     */
+    public function testActionConfirm_SqlInjectionAttempt_IsBlocked(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: попытка инъекции
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => "day_shift' OR '1'='1",
+        ]);
+
+        // Assert: должна быть ошибка валидации
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseContainsJson([
+            'success' => false,
+        ]);
+    }
+
+    /**
+     * Тест: попытка XSS через reminder_key блокируется
+     */
+    public function testActionConfirm_XssAttempt_IsBlocked(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => '<script>alert("XSS")</script>',
+        ]);
+
+        // Assert
+        $I->seeResponseCodeIsSuccessful();
+        $I->seeResponseContainsJson([
+            'success' => false,
+        ]);
+    }
+
+    // ========================================================================
+    // ГРУППА 5: Тесты rate limiting
+    // ========================================================================
+
+    /**
+     * Тест: rate limiting для actionCheck (10 req/min)
+     *
+     * Примечание: этот тест может быть нестабильным из-за time-based логики.
+     * В production рекомендуется использовать Redis или Memcached для rate limiting.
+     */
+    public function testActionCheck_RateLimiting_BlocksExcessiveRequests(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: отправляем 11 запросов (лимит = 10)
+        for ($i = 0; $i < 11; $i++) {
+            $I->sendPOST('/shift-reminder/check', [
+                'reminder_key' => 'day_shift',
+            ]);
+
+            if ($i < 10) {
+                // Первые 10 запросов должны быть успешными
+                $I->seeResponseCodeIsSuccessful();
+            }
+        }
+
+        // Assert: 11-й запрос должен вернуть 429 Too Many Requests
+        $I->seeResponseCodeIs(429);
+        $I->seeResponseContains('Rate limit exceeded');
+    }
+
+    /**
+     * Тест: rate limiting для actionConfirm (5 req/min)
+     */
+    public function testActionConfirm_RateLimiting_BlocksExcessiveRequests(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: отправляем 6 запросов (лимит = 5)
+        for ($i = 0; $i < 6; $i++) {
+            $I->sendPOST('/shift-reminder/confirm', [
+                'reminder_key' => 'day_shift',
+            ]);
+
+            if ($i < 5) {
+                $I->seeResponseCodeIsSuccessful();
+            }
+        }
+
+        // Assert: 6-й запрос должен вернуть 429
+        $I->seeResponseCodeIs(429);
+        $I->seeResponseContains('Rate limit exceeded');
+    }
+
+    // ========================================================================
+    // ГРУППА 6: Тесты reference_date логики
+    // ========================================================================
+
+    /**
+     * Тест: подтверждение в разные даты создает отдельные записи
+     *
+     * Примечание: этот тест проверяет, что reference_date корректно используется
+     * для разделения напоминаний по дням.
+     */
+    public function testActionConfirm_DifferentDates_CreatesSeparateRecords(FunctionalTester $I)
+    {
+        // Arrange
+        $admin = Admin::findOne($this->testUserId);
+        if ($admin !== null) {
+            $I->amLoggedInAs($admin);
+        }
+
+        // Act: подтверждаем напоминание
+        $I->sendPOST('/shift-reminder/confirm', [
+            'reminder_key' => 'day_shift',
+        ]);
+        $I->seeResponseCodeIsSuccessful();
+
+        // Получаем reference_date из ответа
+        $response1 = json_decode($I->grabResponse(), true);
+        $I->assertArrayHasKey('reference_date', $response1);
+
+        // Note: В реальном тесте нужно было бы изменить время,
+        // но это требует mocking или изменения системного времени
+        // Здесь просто проверяем что поле reference_date присутствует
+    }
+}
diff --git a/erp24/tests/unit/models/ShiftReminderShownTest.php b/erp24/tests/unit/models/ShiftReminderShownTest.php
new file mode 100644 (file)
index 0000000..c370368
--- /dev/null
@@ -0,0 +1,403 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\models;
+
+use Codeception\Test\Unit;
+use app\models\ShiftReminderShown;
+use yii\db\IntegrityException;
+
+/**
+ * Unit тесты для модели ShiftReminderShown
+ *
+ * Покрывает:
+ * - Валидацию обязательных полей и форматов (7 тестов)
+ * - Бизнес-логику (isConfirmed, markAsConfirmed) (4 теста)
+ * - Database integrity и unique constraint (3 теста)
+ * - Автоматическую установку timestamps (1 тест)
+ *
+ * Всего: 15 тестов
+ */
+class ShiftReminderShownTest extends Unit
+{
+    /**
+     * @var \UnitTester
+     */
+    protected $tester;
+
+    protected function _before()
+    {
+        // Codeception автоматически откатывает транзакции после каждого теста
+        // Ручная очистка не требуется
+    }
+
+    // ==========================================
+    // VALIDATION TESTS (7 тестов)
+    // ==========================================
+
+    /**
+     * Тест: Модель проходит валидацию при наличии всех обязательных полей
+     */
+    public function testValidation_AllRequiredFieldsPresent_Success()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+
+        // Act
+        $result = $model->validate();
+
+        // Assert
+        $this->assertTrue($result, 'Model should validate with all required fields');
+        $this->assertEmpty($model->errors, 'Model should have no validation errors');
+    }
+
+    /**
+     * Тест: Модель не проходит валидацию без user_id
+     */
+    public function testValidation_MissingUserId_Fails()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+        // user_id не установлен
+
+        // Act
+        $result = $model->validate();
+
+        // Assert
+        $this->assertFalse($result, 'Model should fail validation without user_id');
+        $this->assertArrayHasKey('user_id', $model->errors);
+    }
+
+    /**
+     * Тест: Модель не проходит валидацию без reminder_key
+     */
+    public function testValidation_MissingReminderKey_Fails()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reference_date = '2026-02-10';
+        // reminder_key не установлен
+
+        // Act
+        $result = $model->validate();
+
+        // Assert
+        $this->assertFalse($result, 'Model should fail validation without reminder_key');
+        $this->assertArrayHasKey('reminder_key', $model->errors);
+    }
+
+    /**
+     * Тест: Модель не проходит валидацию без reference_date
+     */
+    public function testValidation_MissingReferenceDate_Fails()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        // reference_date не установлен
+
+        // Act
+        $result = $model->validate();
+
+        // Assert
+        $this->assertFalse($result, 'Model should fail validation without reference_date');
+        $this->assertArrayHasKey('reference_date', $model->errors);
+    }
+
+    /**
+     * Тест: Модель не проходит валидацию при неверном формате reference_date
+     */
+    public function testValidation_InvalidReferenceDateFormat_Fails()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '10-02-2026'; // Неверный формат (должен быть Y-m-d)
+
+        // Act
+        $result = $model->validate();
+
+        // Assert
+        $this->assertFalse($result, 'Model should fail validation with invalid date format');
+        $this->assertArrayHasKey('reference_date', $model->errors);
+    }
+
+    /**
+     * Тест: Модель проходит валидацию с reminder_key максимальной длины (50 символов)
+     */
+    public function testValidation_ReminderKeyMaxLength_Success()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = str_repeat('a', 50); // Ровно 50 символов
+        $model->reference_date = '2026-02-10';
+
+        // Act
+        $result = $model->validate();
+
+        // Assert
+        $this->assertTrue($result, 'Model should validate with reminder_key of max length (50)');
+        $this->assertEmpty($model->errors);
+    }
+
+    /**
+     * Тест: Модель не проходит валидацию с reminder_key более 50 символов
+     */
+    public function testValidation_ReminderKeyExceedsMaxLength_Fails()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = str_repeat('a', 51); // 51 символ (превышает лимит)
+        $model->reference_date = '2026-02-10';
+
+        // Act
+        $result = $model->validate();
+
+        // Assert
+        $this->assertFalse($result, 'Model should fail validation with reminder_key exceeding max length');
+        $this->assertArrayHasKey('reminder_key', $model->errors);
+    }
+
+    // ==========================================
+    // BUSINESS LOGIC TESTS (4 теста)
+    // ==========================================
+
+    /**
+     * Тест: isConfirmed() возвращает false когда confirmed_at = null
+     */
+    public function testIsConfirmed_WhenConfirmedAtNull_ReturnsFalse()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+        $model->confirmed_at = null;
+
+        // Act
+        $result = $model->isConfirmed();
+
+        // Assert
+        $this->assertFalse($result, 'isConfirmed() should return false when confirmed_at is null');
+    }
+
+    /**
+     * Тест: isConfirmed() возвращает true когда confirmed_at установлен
+     */
+    public function testIsConfirmed_WhenConfirmedAtSet_ReturnsTrue()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+        $model->confirmed_at = '2026-02-10 08:05:00';
+
+        // Act
+        $result = $model->isConfirmed();
+
+        // Assert
+        $this->assertTrue($result, 'isConfirmed() should return true when confirmed_at is set');
+    }
+
+    /**
+     * Тест: markAsConfirmed() устанавливает текущее время в confirmed_at и сохраняет
+     */
+    public function testMarkAsConfirmed_SetsCurrentTimestamp_Success()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+        $model->save(false); // Сохраняем без валидации для быстроты
+
+        $beforeTime = date('Y-m-d H:i:s');
+
+        // Act
+        $result = $model->markAsConfirmed();
+        $model->refresh(); // Перезагружаем из БД
+
+        $afterTime = date('Y-m-d H:i:s');
+
+        // Assert
+        $this->assertTrue($result, 'markAsConfirmed() should return true on success');
+        $this->assertNotNull($model->confirmed_at, 'confirmed_at should be set');
+        $this->assertGreaterThanOrEqual($beforeTime, $model->confirmed_at, 'confirmed_at should be >= time before call');
+        $this->assertLessThanOrEqual($afterTime, $model->confirmed_at, 'confirmed_at should be <= time after call');
+    }
+
+    /**
+     * Тест: markAsConfirmed() идемпотентен - повторный вызов не изменяет timestamp
+     */
+    public function testMarkAsConfirmed_Idempotent_DoesNotChangeTimestamp()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+        $model->save(false);
+
+        // Первое подтверждение
+        $model->markAsConfirmed();
+        $model->refresh();
+        $firstConfirmedAt = $model->confirmed_at;
+
+        // Небольшая задержка для уверенности что время изменилось бы
+        sleep(1);
+
+        // Act - второе подтверждение
+        $model->markAsConfirmed();
+        $model->refresh();
+        $secondConfirmedAt = $model->confirmed_at;
+
+        // Assert
+        $this->assertEquals(
+            $firstConfirmedAt,
+            $secondConfirmedAt,
+            'markAsConfirmed() should be idempotent - timestamp should not change on second call'
+        );
+    }
+
+    // ==========================================
+    // DATABASE INTEGRITY TESTS (3 теста)
+    // ==========================================
+
+    /**
+     * Тест: save() создаёт запись в БД
+     */
+    public function testSave_ValidModel_CreatesRecord()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+
+        // Act
+        $result = $model->save();
+
+        // Assert
+        $this->assertTrue($result, 'save() should return true');
+        $this->assertNotNull($model->id, 'Model should have id after save');
+
+        // Проверяем что запись действительно в БД
+        $found = ShiftReminderShown::findOne($model->id);
+        $this->assertNotNull($found, 'Saved model should be found in database');
+        $this->assertEquals(1, $found->user_id);
+        $this->assertEquals('day_shift', $found->reminder_key);
+        $this->assertEquals('2026-02-10', $found->reference_date);
+    }
+
+    /**
+     * Тест: save() с дублирующим ключом (user_id, reminder_key, reference_date) вызывает IntegrityException
+     *
+     * КРИТИЧЕСКИЙ ТЕСТ - проверяет защиту от race conditions через unique constraint
+     */
+    public function testSave_DuplicateKey_ThrowsIntegrityException()
+    {
+        // Arrange - создаём первую запись
+        $first = new ShiftReminderShown();
+        $first->user_id = 1;
+        $first->reminder_key = 'day_shift';
+        $first->reference_date = '2026-02-10';
+        $first->save(false);
+
+        // Создаём вторую запись с тем же ключом
+        $duplicate = new ShiftReminderShown();
+        $duplicate->user_id = 1;
+        $duplicate->reminder_key = 'day_shift';
+        $duplicate->reference_date = '2026-02-10';
+
+        // Act & Assert
+        $this->expectException(IntegrityException::class);
+        $duplicate->save(false); // false чтобы пропустить валидацию и дойти до БД
+    }
+
+    /**
+     * Тест: find() по user_id и reminder_key возвращает корректную запись
+     */
+    public function testFind_ByUserIdAndReminderKey_ReturnsCorrectRecord()
+    {
+        // Arrange - создаём несколько записей
+        $model1 = new ShiftReminderShown();
+        $model1->user_id = 1;
+        $model1->reminder_key = 'day_shift';
+        $model1->reference_date = '2026-02-10';
+        $model1->save(false);
+
+        $model2 = new ShiftReminderShown();
+        $model2->user_id = 1;
+        $model2->reminder_key = 'night_shift';
+        $model2->reference_date = '2026-02-10';
+        $model2->save(false);
+
+        $model3 = new ShiftReminderShown();
+        $model3->user_id = 2;
+        $model3->reminder_key = 'day_shift';
+        $model3->reference_date = '2026-02-10';
+        $model3->save(false);
+
+        // Act - ищем конкретную запись
+        $found = ShiftReminderShown::find()
+            ->where([
+                'user_id' => 1,
+                'reminder_key' => 'day_shift',
+                'reference_date' => '2026-02-10',
+            ])
+            ->one();
+
+        // Assert
+        $this->assertNotNull($found, 'Should find the record');
+        $this->assertEquals($model1->id, $found->id, 'Should find correct record');
+        $this->assertEquals(1, $found->user_id);
+        $this->assertEquals('day_shift', $found->reminder_key);
+    }
+
+    // ==========================================
+    // TIMESTAMP TESTS (1 тест)
+    // ==========================================
+
+    /**
+     * Тест: created_at автоматически устанавливается при создании записи
+     */
+    public function testCreatedAt_AutomaticallySet_OnCreate()
+    {
+        // Arrange
+        $model = new ShiftReminderShown();
+        $model->user_id = 1;
+        $model->reminder_key = 'day_shift';
+        $model->reference_date = '2026-02-10';
+
+        // Act
+        $model->save(false);
+        $model->refresh();
+
+        // Assert
+        $this->assertNotNull($model->created_at, 'created_at should be set automatically');
+
+        // Проверяем что created_at это валидный timestamp (обрезаем микросекунды)
+        $createdAtClean = preg_replace('/\.\d+$/', '', $model->created_at);
+        $createdTimestamp = strtotime($createdAtClean);
+        $this->assertNotFalse($createdTimestamp, 'created_at should be a valid timestamp');
+
+        // Проверяем что timestamp недавний (в пределах последних 24 часов)
+        // Это учитывает timezone различия между PHP и PostgreSQL
+        $oneDayAgo = time() - 86400;
+        $oneDayAhead = time() + 86400;
+        $this->assertGreaterThan($oneDayAgo, $createdTimestamp, 'created_at should be recent (within last 24 hours)');
+        $this->assertLessThan($oneDayAhead, $createdTimestamp, 'created_at should not be too far in future (within next 24 hours)');
+    }
+}
diff --git a/erp24/tests/unit/services/ShiftReminderServiceTest.php b/erp24/tests/unit/services/ShiftReminderServiceTest.php
new file mode 100644 (file)
index 0000000..0002d62
--- /dev/null
@@ -0,0 +1,654 @@
+<?php
+
+namespace app\tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\ShiftReminderService;
+
+/**
+ * Unit-тесты для ShiftReminderService
+ *
+ * Группа P1: Тестирование логики работы с датами и временными окнами
+ */
+class ShiftReminderServiceTest extends Unit
+{
+    // ========================================================================
+    // ГРУППА 1: getReferenceDate() - Логика перехода через полночь
+    // ========================================================================
+
+    /**
+     * Тест: время 00:00-05:59 должно возвращать предыдущий день
+     *
+     * Критическая логика: подтверждение в 01:00 относится к предыдущему дню,
+     * чтобы не блокировать показ напоминания в 07:50 того же календарного дня.
+     */
+    public function testGetReferenceDate_MidnightToFiveAm_ReturnsPreviousDay()
+    {
+        // Arrange: время 01:00
+        $testTime = '2026-02-04 01:00:00';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2026-02-03',
+            $result,
+            'Время 01:00 должно относиться к предыдущему дню (2026-02-03)'
+        );
+    }
+
+    /**
+     * Тест: время 06:00-23:59 должно возвращать текущий день
+     */
+    public function testGetReferenceDate_SixAmToMidnight_ReturnsCurrentDay()
+    {
+        // Arrange: время 07:50 (стандартное окно напоминания)
+        $testTime = '2026-02-04 07:50:00';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2026-02-04',
+            $result,
+            'Время 07:50 должно относиться к текущему дню (2026-02-04)'
+        );
+    }
+
+    /**
+     * Тест: граничный случай 00:00 (полночь) - первая секунда дня
+     */
+    public function testGetReferenceDate_Midnight_ReturnsPreviousDay()
+    {
+        // Arrange
+        $testTime = '2026-02-04 00:00:00';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2026-02-03',
+            $result,
+            'Полночь (00:00:00) должна относиться к предыдущему дню'
+        );
+    }
+
+    /**
+     * Тест: граничный случай 05:59:59 - последняя секунда "ночного" периода
+     */
+    public function testGetReferenceDate_FiveAmFiftyNine_ReturnsPreviousDay()
+    {
+        // Arrange
+        $testTime = '2026-02-04 05:59:59';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2026-02-03',
+            $result,
+            'Время 05:59:59 должно относиться к предыдущему дню (последняя секунда периода)'
+        );
+    }
+
+    /**
+     * Тест: граничный случай 06:00:00 - первая секунда "дневного" периода
+     */
+    public function testGetReferenceDate_SixAm_ReturnsCurrentDay()
+    {
+        // Arrange
+        $testTime = '2026-02-04 06:00:00';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2026-02-04',
+            $result,
+            'Время 06:00:00 должно относиться к текущему дню (первая секунда периода)'
+        );
+    }
+
+    /**
+     * Тест: полдень - типичное время дневной смены
+     */
+    public function testGetReferenceDate_Noon_ReturnsCurrentDay()
+    {
+        // Arrange
+        $testTime = '2026-02-04 12:00:00';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals('2026-02-04', $result);
+    }
+
+    /**
+     * Тест: конец дня 23:59:59 - последняя секунда перед переходом
+     */
+    public function testGetReferenceDate_EndOfDay_ReturnsCurrentDay()
+    {
+        // Arrange
+        $testTime = '2026-02-04 23:59:59';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2026-02-04',
+            $result,
+            'Время 23:59:59 должно относиться к текущему дню'
+        );
+    }
+
+    /**
+     * Тест: без параметра $currentTime должно использоваться текущее время
+     */
+    public function testGetReferenceDate_NoParameter_ReturnsToday()
+    {
+        // Act
+        $result = ShiftReminderService::getReferenceDate();
+
+        // Assert
+        $currentHour = (int)date('H');
+        $expectedDate = ($currentHour >= 0 && $currentHour < 6)
+            ? date('Y-m-d', strtotime('-1 day'))
+            : date('Y-m-d');
+
+        $this->assertEquals(
+            $expectedDate,
+            $result,
+            'Без параметра должно использоваться текущее время с корректной логикой'
+        );
+    }
+
+    // ========================================================================
+    // ГРУППА 2: isInTimeWindow() - Проверка временных окон напоминаний
+    // ========================================================================
+
+    /**
+     * Тест: точное совпадение с окном 07:50 (закрытие ночной смены)
+     */
+    public function testIsInTimeWindow_SevenFiftyAm_ReturnsTrue()
+    {
+        // Arrange
+        $testTime = '2026-02-04 07:50:00';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Время 07:50 должно попадать в окно напоминания'
+        );
+    }
+
+    /**
+     * Тест: точное совпадение с окном 08:10 (открытие дневной смены)
+     */
+    public function testIsInTimeWindow_EightTenAm_ReturnsTrue()
+    {
+        // Arrange
+        $testTime = '2026-02-04 08:10:00';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Время 08:10 должно попадать в окно напоминания'
+        );
+    }
+
+    /**
+     * Тест: точное совпадение с окном 19:50 (закрытие дневной смены)
+     */
+    public function testIsInTimeWindow_SevenFiftyPm_ReturnsTrue()
+    {
+        // Arrange
+        $testTime = '2026-02-04 19:50:00';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Время 19:50 должно попадать в окно напоминания'
+        );
+    }
+
+    /**
+     * Тест: точное совпадение с окном 20:10 (открытие ночной смены)
+     */
+    public function testIsInTimeWindow_EightTenPm_ReturnsTrue()
+    {
+        // Arrange
+        $testTime = '2026-02-04 20:10:00';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Время 20:10 должно попадать в окно напоминания'
+        );
+    }
+
+    /**
+     * Тест: несовпадение - одна минута до окна
+     */
+    public function testIsInTimeWindow_OneMinuteBefore_ReturnsFalse()
+    {
+        // Arrange: 07:49 - одна минута до первого окна
+        $testTime = '2026-02-04 07:49:00';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertFalse(
+            $result,
+            'Время 07:49 не должно попадать в окно напоминания'
+        );
+    }
+
+    /**
+     * Тест: несовпадение - одна минута после окна
+     */
+    public function testIsInTimeWindow_OneMinuteAfter_ReturnsFalse()
+    {
+        // Arrange: 07:51 - одна минута после первого окна
+        $testTime = '2026-02-04 07:51:00';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertFalse(
+            $result,
+            'Время 07:51 не должно попадать в окно напоминания'
+        );
+    }
+
+    /**
+     * Тест: произвольное время вне всех окон
+     */
+    public function testIsInTimeWindow_RandomTime_ReturnsFalse()
+    {
+        // Arrange: полдень - вне всех окон
+        $testTime = '2026-02-04 14:30:00';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertFalse(
+            $result,
+            'Произвольное время 14:30 не должно попадать в окна напоминаний'
+        );
+    }
+
+    /**
+     * Тест: проверка с секундами (должно игнорировать секунды)
+     */
+    public function testIsInTimeWindow_WithSeconds_IgnoresSeconds()
+    {
+        // Arrange: 07:50:45 - секунды должны игнорироваться
+        $testTime = '2026-02-04 07:50:45';
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Время 07:50:45 должно совпадать с окном 07:50 (секунды игнорируются)'
+        );
+    }
+
+    /**
+     * Тест: кастомные временные окна - совпадение
+     */
+    public function testIsInTimeWindow_CustomWindows_ReturnsTrue()
+    {
+        // Arrange
+        $testTime = '2026-02-04 10:00:00';
+        $customWindows = ['10:00', '15:00'];
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime, $customWindows);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Время 10:00 должно попадать в кастомное окно'
+        );
+    }
+
+    /**
+     * Тест: кастомные окна - несовпадение
+     */
+    public function testIsInTimeWindow_CustomWindowsNoMatch_ReturnsFalse()
+    {
+        // Arrange
+        $testTime = '2026-02-04 10:01:00';
+        $customWindows = ['10:00', '15:00'];
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime, $customWindows);
+
+        // Assert
+        $this->assertFalse(
+            $result,
+            'Время 10:01 не должно попадать в кастомные окна [10:00, 15:00]'
+        );
+    }
+
+    /**
+     * Тест: пустой массив окон - всегда false
+     */
+    public function testIsInTimeWindow_EmptyWindows_ReturnsFalse()
+    {
+        // Arrange
+        $testTime = '2026-02-04 07:50:00';
+        $emptyWindows = [];
+
+        // Act
+        $result = ShiftReminderService::isInTimeWindow($testTime, $emptyWindows);
+
+        // Assert
+        $this->assertFalse(
+            $result,
+            'При пустом массиве окон должно возвращаться false'
+        );
+    }
+
+    /**
+     * Тест: без параметра $currentTime должно использоваться текущее время
+     */
+    public function testIsInTimeWindow_NoParameter_UsesCurrentTime()
+    {
+        // Act
+        $result = ShiftReminderService::isInTimeWindow();
+
+        // Assert
+        // Результат зависит от текущего времени, проверяем что метод не падает
+        $this->assertIsBool($result, 'Метод должен вернуть boolean значение');
+    }
+
+    // ========================================================================
+    // ГРУППА 3: getAllowedReminderKeys() и isValidReminderKey() - Валидация ключей
+    // ========================================================================
+
+    /**
+     * Тест: получение списка допустимых ключей из конфигурации
+     */
+    public function testGetAllowedReminderKeys_ReturnsConfiguredKeys()
+    {
+        // Arrange: конфигурация установлена в params.php
+        // SHIFT_REMINDER_ALLOWED_KEYS = ['day_shift', 'night_shift']
+
+        // Act
+        $keys = ShiftReminderService::getAllowedReminderKeys();
+
+        // Assert
+        $this->assertIsArray($keys, 'Должен вернуть массив');
+        $this->assertContains('day_shift', $keys, 'Должен содержать day_shift');
+        $this->assertContains('night_shift', $keys, 'Должен содержать night_shift');
+        $this->assertCount(2, $keys, 'Должно быть ровно 2 ключа по умолчанию');
+    }
+
+    /**
+     * Тест: валидация корректного ключа day_shift
+     */
+    public function testIsValidReminderKey_DayShift_ReturnsTrue()
+    {
+        // Arrange
+        $key = 'day_shift';
+
+        // Act
+        $result = ShiftReminderService::isValidReminderKey($key);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Ключ "day_shift" должен быть валидным'
+        );
+    }
+
+    /**
+     * Тест: валидация корректного ключа night_shift
+     */
+    public function testIsValidReminderKey_NightShift_ReturnsTrue()
+    {
+        // Arrange
+        $key = 'night_shift';
+
+        // Act
+        $result = ShiftReminderService::isValidReminderKey($key);
+
+        // Assert
+        $this->assertTrue(
+            $result,
+            'Ключ "night_shift" должен быть валидным'
+        );
+    }
+
+    /**
+     * Тест: валидация некорректного ключа
+     */
+    public function testIsValidReminderKey_InvalidKey_ReturnsFalse()
+    {
+        // Arrange
+        $invalidKeys = [
+            'invalid_shift',
+            'morning_shift',
+            '',
+            'DAY_SHIFT', // регистр важен
+            'day shift', // пробел
+        ];
+
+        // Act & Assert
+        foreach ($invalidKeys as $key) {
+            $result = ShiftReminderService::isValidReminderKey($key);
+            $this->assertFalse(
+                $result,
+                "Ключ '{$key}' не должен быть валидным"
+            );
+        }
+    }
+
+    /**
+     * Тест: валидация с использованием строгого сравнения (strict)
+     */
+    public function testIsValidReminderKey_UsesStrictComparison()
+    {
+        // Arrange: цифровой ключ который может совпадать с индексом массива
+        $numericKey = '0';
+
+        // Act
+        $result = ShiftReminderService::isValidReminderKey($numericKey);
+
+        // Assert
+        $this->assertFalse(
+            $result,
+            'Должно использоваться строгое сравнение (strict), цифра "0" не должна совпадать с индексом'
+        );
+    }
+
+    // ========================================================================
+    // ГРУППА 4: Интеграционные тесты для isShiftActionNeeded()
+    // ========================================================================
+
+    /**
+     * Тест: проверка периодов закрытия смены
+     *
+     * Примечание: этот метод требует доступа к БД через TimetableFactModel.
+     * В unit-тестах проверяем только логику определения периодов.
+     */
+    public function testIsShiftActionNeeded_ClosingPeriodDetection()
+    {
+        // Arrange: время в период закрытия ночной смены (07:50-08:09)
+        $morningClosingTime = '2026-02-04 07:50:00';
+
+        // Arrange: время в период закрытия дневной смены (19:50-20:09)
+        $eveningClosingTime = '2026-02-04 19:50:00';
+
+        // Act & Assert
+        // Метод работает с БД, поэтому здесь проверяем только что он не падает
+        // Полные тесты должны быть в functional-тестах с подготовленной БД
+
+        // Проверяем что метод не падает с exception
+        $this->expectNotToPerformAssertions();
+        try {
+            ShiftReminderService::isShiftActionNeeded(1, $morningClosingTime);
+            ShiftReminderService::isShiftActionNeeded(1, $eveningClosingTime);
+        } catch (\Exception $e) {
+            $this->fail('Метод не должен бросать исключения: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * Тест: проверка периодов открытия смены
+     */
+    public function testIsShiftActionNeeded_OpeningPeriodDetection()
+    {
+        // Arrange: время в период дневной смены (08:10-19:49)
+        $dayShiftTime = '2026-02-04 12:00:00';
+
+        // Arrange: время в период ночной смены (20:10-07:49)
+        $nightShiftTime = '2026-02-04 22:00:00';
+
+        // Act & Assert
+        // Проверяем что метод не падает
+        $this->expectNotToPerformAssertions();
+        try {
+            ShiftReminderService::isShiftActionNeeded(1, $dayShiftTime);
+            ShiftReminderService::isShiftActionNeeded(1, $nightShiftTime);
+        } catch (\Exception $e) {
+            $this->fail('Метод не должен бросать исключения: ' . $e->getMessage());
+        }
+    }
+
+    // ========================================================================
+    // ГРУППА 5: Граничные случаи и edge cases
+    // ========================================================================
+
+    /**
+     * Тест: граница перехода дня в високосном году
+     */
+    public function testGetReferenceDate_LeapYearBoundary()
+    {
+        // Arrange: 1 марта 2024 (високосный год) в 01:00
+        $testTime = '2024-03-01 01:00:00';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2024-02-29',
+            $result,
+            'Должно корректно обработать переход через 29 февраля'
+        );
+    }
+
+    /**
+     * Тест: переход года
+     */
+    public function testGetReferenceDate_YearBoundary()
+    {
+        // Arrange: 1 января в 01:00
+        $testTime = '2026-01-01 01:00:00';
+
+        // Act
+        $result = ShiftReminderService::getReferenceDate($testTime);
+
+        // Assert
+        $this->assertEquals(
+            '2025-12-31',
+            $result,
+            'Должно корректно обработать переход года'
+        );
+    }
+
+    /**
+     * Тест: все временные окна в течение суток
+     */
+    public function testIsInTimeWindow_AllWindowsInDay()
+    {
+        // Arrange: все 4 стандартных окна
+        $windows = [
+            '2026-02-04 07:50:00' => true,  // утреннее закрытие
+            '2026-02-04 08:10:00' => true,  // утреннее открытие
+            '2026-02-04 19:50:00' => true,  // вечернее закрытие
+            '2026-02-04 20:10:00' => true,  // вечернее открытие
+        ];
+
+        // Act & Assert
+        foreach ($windows as $time => $expected) {
+            $result = ShiftReminderService::isInTimeWindow($time);
+            $this->assertEquals(
+                $expected,
+                $result,
+                "Время {$time} должно быть в окне напоминания"
+            );
+        }
+    }
+
+    /**
+     * Тест: защита от SQL-инъекций в reminder_key
+     *
+     * Проверяем, что whitelist корректно блокирует вредоносные входные данные
+     */
+    public function testIsValidReminderKey_SqlInjectionAttempt_ReturnsFalse()
+    {
+        // Arrange: попытка SQL-инъекции
+        $maliciousKeys = [
+            "day_shift' OR '1'='1",
+            "day_shift; DROP TABLE shift_reminder_shown;--",
+            "day_shift' UNION SELECT * FROM users--",
+        ];
+
+        // Act & Assert
+        foreach ($maliciousKeys as $key) {
+            $result = ShiftReminderService::isValidReminderKey($key);
+            $this->assertFalse(
+                $result,
+                "Вредоносный ключ должен быть отклонен: {$key}"
+            );
+        }
+    }
+
+    /**
+     * Тест: корректная обработка пустых и null значений
+     */
+    public function testIsValidReminderKey_EmptyAndNull_ReturnsFalse()
+    {
+        // Arrange
+        $invalidValues = [
+            '',
+            null,
+        ];
+
+        // Act & Assert
+        foreach ($invalidValues as $value) {
+            $result = ShiftReminderService::isValidReminderKey($value);
+            $this->assertFalse(
+                $result,
+                'Пустые и null значения должны быть невалидными'
+            );
+        }
+    }
+}
index 6fd54498f4f9ac9637433cddcadce6bf3c48f076..26f6229b1aa6726a5c8b0bd48809fc3ebc2df58a 100644 (file)
@@ -9,10 +9,15 @@
 (function() {
     'use strict';
 
-    // CSRF token extraction
+    // CSRF token extraction with validation
     const csrfParam = $('meta[name=csrf-param]').attr('content');
     const csrfToken = $('meta[name=csrf-token]').attr('content');
 
+    // Validate CSRF tokens are available
+    if (!csrfParam || !csrfToken) {
+        console.error('ShiftReminder: CSRF tokens not found in meta tags. AJAX requests will fail.');
+    }
+
     // Configuration
     const CONFIG = {
         pollInterval: 30000, // 30 seconds
             state.retryCount++;
         }
 
-        state.currentInterval = CONFIG.retryIntervals[state.retryCount];
+        // Ensure retryCount doesn't exceed array bounds (defensive programming)
+        const safeRetryCount = Math.min(state.retryCount, CONFIG.retryIntervals.length - 1);
+        state.currentInterval = CONFIG.retryIntervals[safeRetryCount];
         stopPolling();
         startPolling();
     }
         state.isModalVisible = true;
 
         // Create modal HTML
-        const modalHtml = `
-            <div id="shift-reminder-modal" class="shift-reminder-overlay">
-                <div class="shift-reminder-content">
-                    <div class="shift-reminder-icon">
-                        <i class="fa fa-bell fa-3x"></i>
-                    </div>
-                    <h2 class="shift-reminder-title">Напоминание о смене</h2>
-                    <p class="shift-reminder-message">
-                        ${data.message || 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.'}
-                    </p>
-                    <button id="shift-reminder-confirm-btn" class="btn btn-primary btn-lg">
-                        Подтвердить
-                    </button>
-                </div>
-            </div>
-        `;
+        // Create modal structure without user data (XSS protection)
+        const $modal = $('<div>').attr('id', 'shift-reminder-modal').addClass('shift-reminder-overlay');
+        const $content = $('<div>').addClass('shift-reminder-content');
+        const $icon = $('<div>').addClass('shift-reminder-icon').html('<i class="fa fa-bell fa-3x"></i>');
+        const $title = $('<h2>').addClass('shift-reminder-title').text('Напоминание о смене');
+        const $message = $('<p>').addClass('shift-reminder-message');
+        const $button = $('<button>')
+            .attr('id', 'shift-reminder-confirm-btn')
+            .addClass('btn btn-primary btn-lg')
+            .text('Подтвердить');
+
+        // Safely set message text (prevents XSS)
+        const messageText = data.message || 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.';
+        $message.text(messageText);
+
+        // Assemble modal
+        $content.append($icon, $title, $message, $button);
+        $modal.append($content);
 
         // Add modal to DOM
-        $('body').append(modalHtml);
+        $('body').append($modal);
 
         // Add modal styles
         addModalStyles();