--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
--- /dev/null
+---
+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
+```
--- /dev/null
+---
+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 для включения в следующие этапы
--- /dev/null
+---
+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)
+- Путь к файлу состояния
--- /dev/null
+{
+ "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
--- /dev/null
+---
+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": {...}
+ }
+}
+```
--- /dev/null
+---
+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"
+ ]
+}
+```
--- /dev/null
+---
+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
+- Обновлённое состояние
--- /dev/null
+---
+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 с зависимостями
--- /dev/null
+---
+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 для спецификации
--- /dev/null
+---
+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()
+```
--- /dev/null
+---
+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",
+ # ... и другие
+}
+```
--- /dev/null
+---
+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 изменений
--- /dev/null
+---
+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
+}
+```
--- /dev/null
+---
+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 на русском языке.
--- /dev/null
+<?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',
+ ]
+ ],
+];
*/
class ShiftReminderController extends Controller
{
- /**
- * Хранилище для отслеживания rate limiting
- * @var array
- */
- private static $rateLimitStorage = [];
-
/**
* {@inheritdoc}
*/
/**
* Проверить rate limit для действия
*
+ * Использует Yii::$app->cache для сохранения состояния между HTTP-запросами.
+ * Это критично для корректной работы rate limiting в production.
+ *
* @param string $action Название действия
* @param int $limit Максимальное количество запросов в минуту
* @return bool true если лимит не превышен, false в противном случае
{
$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;
}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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"]
--- /dev/null
+# 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
*/
public static function tableName()
{
- return 'shift_reminder_shown';
+ return 'erp24.shift_reminder_shown';
}
/**
* Отмечает напоминание как подтвержденное и сохраняет запись
*
* Устанавливает текущее время в поле 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);
}
}
--- /dev/null
+<?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 присутствует
+ }
+}
--- /dev/null
+<?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)');
+ }
+}
--- /dev/null
+<?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 значения должны быть невалидными'
+ );
+ }
+ }
+}
(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();