/
ump-team
/
ump-infra
Обзор
Документация
Войти
/
ump-team
/
ump-infra
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev/test
scripts/ump.py
1 928 строк
81 KB
Dmitry Kochenov
fix: удалить мёртвый код (vulture P0) — _cp_delete_all и reload
09 авг 2026, 17:15
09 авг 2026, 17:15
0f44940
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ UMP CLI — Универсальный CLI для управления планом, шагами и тестированием. v0.0.1: Typer + Rich + Pydantic. Master `ump setup` для инициализации проекта. Главная цель: AI-агент работает ТОЛЬКО через `uv run .ump/scripts/ump.py ...`. Использование: uv run .ump/scripts/ump.py --help uv run .ump/scripts/ump.py setup # мастер настройки uv run .ump/scripts/ump.py status # статус проекта uv run .ump/scripts/ump.py plan list # все этапы и шаги uv run .ump/scripts/ump.py plan show 1.0 # детали шага uv run .ump/scripts/ump.py step start 1.0 uv run .ump/scripts/ump.py step run 1.0 --phase PHASE1 uv run .ump/scripts/ump.py test run uv run .ump/scripts/ump.py agent log --tail 20 """ from __future__ import annotations import json import os import shlex import subprocess import sys from datetime import datetime from pathlib import Path from typing import Annotated, Any import typer import yaml from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel from rich.table import Table # ─── UMP package imports ───────────────────────────────────────────────────── # Чтобы `from scripts.ump.config import ...` работал и при запуске как скрипт, # и при установке через pyproject.toml entry point, добавляем parent в sys.path try: from scripts.ump.config import UmpConfig, export_json_schema from scripts.ump.paths import get_paths from scripts.ump.setup import ( flags_to_answers, run_setup, ) except ImportError: # Fallback: запускаем как standalone скрипт _here = Path(__file__).resolve().parent sys.path.insert(0, str(_here.parent)) from ump.config import UmpConfig, export_json_schema # type: ignore from ump.paths import get_paths # type: ignore from ump.setup import flags_to_answers, run_setup # type: ignore # ─── Константы ────────────────────────────────────────────────────────────── # BASE — корень проекта (родитель .ump/). # ump.py может лежать либо в <project>/.ump/scripts/ump.py (submodule), # либо в <project>/scripts/ump.py (legacy/standalone). Поддерживаем оба. _SCRIPT_DIR = Path(__file__).resolve().parent # .../scripts/ или .../.ump/scripts/ _PARENT = _SCRIPT_DIR.parent # .../.ump/ или .../<project>/ if _PARENT.name == '.ump': # Submodule layout: ump.py в <project>/.ump/scripts/ump.py → project root = _PARENT.parent BASE = _PARENT.parent else: # Standalone layout: ump.py в <project>/scripts/ump.py → project root = _PARENT BASE = _PARENT # INFRA_DIR — где лежит сама инфраструктура UMP (.agent/, examples, templates) INFRA_DIR = _PARENT if _PARENT.name == '.ump' else _PARENT LOG_FILE = BASE / '_meta' / 'agent.log' CONSTANTS_FILE = INFRA_DIR / '_meta' / 'rules-constants.yaml' STEP_STATE = BASE / 'STEP_STATE.md' PLANS_DIR = BASE / 'plans' CONFIG_FILE = BASE / 'ump-ui-config.yaml' VERSION = 'v0.0.6' console = Console() # ─── Утилиты ──────────────────────────────────────────────────────────────── def load_constants() -> dict: """ Загрузить константы проекта. В v0.0.1: приоритет — ump-ui-config.yaml (если есть), затем _meta/rules-constants.yaml (legacy). В будущих версиях rules-constants.yaml будет удалён. """ # Приоритет 1: ump-ui-config.yaml (новый путь) paths = get_paths() if paths.config: cfg = paths.config # P1-18 fix: читать gates из cfg.agent.interactive.gates + добавить stage_analysis (constant) interactive_gates = cfg.agent.interactive.gates gates_list = ['stage_analysis'] # stage_analysis — константа, не отключается if interactive_gates.settings: gates_list.append('settings') if interactive_gates.deps: gates_list.append('deps') if interactive_gates.adr: gates_list.append('adr') if interactive_gates.verify_done: gates_list.append('verify_done') if interactive_gates.ready_to_continue: gates_list.append('ready_to_continue') if interactive_gates.schema_change: gates_list.append('schema_change') # protect — всегда True gates_list.append('protect') return { 'phases_count': len(cfg.plans.lifecycle), 'phases_list': cfg.plans.lifecycle, 'parallel_steps': 'opt_in_per_phase', # legacy field 'parallel_config': { 'max_concurrency': 4, 'parallel_eligible_phases': ['TESTING', 'CODE_REVIEW', 'FINAL_TESTING', 'FINAL_REVIEW'], }, 'total_steps_all_plans': sum( len(p.get('steps', [])) for p in load_plans() ), 'gates_count': len(gates_list), 'gates_list': gates_list, } # Приоритет 2: legacy rules-constants.yaml if not CONSTANTS_FILE.exists(): return {} return yaml.safe_load(CONSTANTS_FILE.read_text(encoding='utf-8')) or {} def load_plans() -> list[dict]: """Загрузить все YAML-планы (читает путь из ump-ui-config.yaml).""" paths = get_paths() plans_dir = paths.plans plans = [] if not plans_dir.exists(): return plans for f in sorted(plans_dir.glob('*.yaml')): if f.name.startswith('99'): continue try: data = yaml.safe_load(f.read_text(encoding='utf-8')) if data and 'steps' in data: data['_file'] = f.name plans.append(data) except Exception: pass return plans def find_step(step_id: str) -> tuple[dict, dict] | None: """Найти шаг по ID во всех планах. Возвращает (step, plan).""" for plan in load_plans(): for step in plan.get('steps', []): if str(step.get('id')) == step_id: return step, plan return None def run_cmd(cmd: str | list[str], cwd: Path | None = None) -> tuple[int, str, str]: """Выполнить команду, вернуть (exit_code, stdout, stderr). P0-7 fix: ранее использовался subprocess.run(cmd, shell=True, ...), что позволяло инъекции через интерполируемые переменные (branch, step_id, validator_path, и т.д.). Теперь — list-form через shlex.split() (для str) или прямой list (для list[str]). Без shell=True: команды с `2>/dev/null`, `||`, `|`, `&&`, `;` не работают как раньше. callers должны переписать на Python-логику или list-form. """ args = shlex.split(cmd) if isinstance(cmd, str) else cmd r = subprocess.run( args, capture_output=True, text=True, cwd=cwd or BASE, timeout=120 ) return r.returncode, r.stdout.strip(), r.stderr.strip() def read_completed_steps() -> set[str]: """ Прочитать COMPLETED_STEPS. Приоритет: 1. ump-ui-config.yaml → progress.last_completed_step (если есть) 2. STEP_STATE.md (legacy) → таблица COMPLETED_STEPS """ # Приоритет 1: memory-bank/progress.md (Cline-style) — если есть paths = get_paths() progress_md = paths.memory_bank / 'progress.md' if progress_md.exists(): content = progress_md.read_text(encoding='utf-8') steps = set() for line in content.split('\n'): line = line.strip() if line.startswith('| ') and not line.startswith('| Шаг') and not line.startswith('|---') and not line.startswith('| Step'): parts = [p.strip() for p in line.split('|') if p.strip()] if parts and '.' in parts[0]: steps.add(parts[0]) if steps: return steps # Приоритет 2: STEP_STATE.md (legacy) step_state = paths.step_state if not step_state.exists(): return set() content = step_state.read_text(encoding='utf-8') steps = set() for line in content.split('\n'): line = line.strip() if line.startswith('| ') and not line.startswith('| Шаг') and not line.startswith('|---'): parts = [p.strip() for p in line.split('|') if p.strip()] if parts and '.' in parts[0]: steps.add(parts[0]) return steps def read_current_phase() -> str: """ Прочитать CURRENT_PHASE. Приоритет: 1. ump-ui-config.yaml → progress.current_phase (если есть) 2. STEP_STATE.md (legacy) → CURRENT_PHASE: """ # Приоритет 1: ump-ui-config.yaml paths = get_paths() if paths.config: phase = paths.config.progress.current_phase if phase and phase != 'not_started': return phase # Если конфиг говорит not_started, но STEP_STATE.md говорит иначе — вернём STEP_STATE # (это для случая когда пользователь только что setup'нул, но уже что-то сделал в legacy) # Приоритет 2: STEP_STATE.md (legacy) step_state = paths.step_state if not step_state.exists(): return 'not_started' content = step_state.read_text(encoding='utf-8') for line in content.split('\n'): if line.startswith('CURRENT_PHASE:'): return line.split(':', 1)[1].strip() return 'not_started' def get_all_steps() -> list[tuple[str, str, str, str]]: """Вернуть список всех шагов: (step_id, title, stage, step_type).""" result = [] for plan in load_plans(): stage = plan.get('stage', '?') for step in plan.get('steps', []): sid = str(step.get('id', '?')) title = step.get('title', '')[:50] stype = step.get('step_type', '?') result.append((sid, title, str(stage), stype)) return result # ─── Приложение ───────────────────────────────────────────────────────────── app = typer.Typer( name='ump', help='Универсальный CLI для управления планом, шагами и тестированием UMP.', rich_markup_mode='rich', no_args_is_help=True, invoke_without_command=True, ) plan_app = typer.Typer(help='Команды работы с YAML-планами этапов.', rich_markup_mode='rich') step_app = typer.Typer(help='Команды управления шагами (lifecycle).', rich_markup_mode='rich') test_app = typer.Typer(help='Команды запуска тестов и покрытия.', rich_markup_mode='rich') agent_app = typer.Typer(help='Команды работы с AI-агентом (generic, claude, cursor, copilot, cline).', rich_markup_mode='rich') protect_app = typer.Typer(help='Команды защиты immutable-файлов.', rich_markup_mode='rich') worktree_app = typer.Typer(help='Команды управления git worktrees.', rich_markup_mode='rich') parallel_app = typer.Typer(help='Команды параллельных sub-agents.', rich_markup_mode='rich') app.add_typer(plan_app, name='plan') app.add_typer(step_app, name='step') app.add_typer(test_app, name='test') app.add_typer(agent_app, name='agent') app.add_typer(protect_app, name='protect') app.add_typer(worktree_app, name='worktree') app.add_typer(parallel_app, name='parallel') # Под-приложение для управления конфигом config_app = typer.Typer(help='Команды управленияump-ui-config.yaml.', rich_markup_mode='rich') app.add_typer(config_app, name='config') # P11-CI fix (фаза 11+, Неделя 4): manifest sub-app для генерации кастомного манифеста. # См. docs/MANIFEST-AND-RULES.md §4 —ump manifest generate. manifest_app = typer.Typer(help='Команды управления манифестом проекта (00-manifest.md).', rich_markup_mode='rich') app.add_typer(manifest_app, name='manifest') # Analytics sub-app ( Этап 3) analytics_app = typer.Typer(help='Команды аналитики и ретроспектив.', rich_markup_mode='rich') app.add_typer(analytics_app, name='analytics') # Budget sub-app ( Этап 4) budget_app = typer.Typer(help='Команды бюджета и стоимости AI-агентов.', rich_markup_mode='rich') app.add_typer(budget_app, name='budget') # ─── callback: --version ──────────────────────────────────────────────────── @app.callback() def main( version: Annotated[bool, typer.Option('--version', '-V', help='Показать версию UMP CLI')] = False, ) -> None: """UMP CLI — управление планом, шагами, тестированием и AI-агентом.""" if version: console.print(f'[bold green]UMP CLI[/] [cyan]{VERSION}[/]') console.print(f' Python: {sys.version.split()[0]}') console.print(f' Path: {BASE}') raise typer.Exit() # ─── status ───────────────────────────────────────────────────────────────── # UMP Phase 0: Import status API and event hooks # Define stubs before try/except so basedpyright sees a single variable type. import contextlib as _contextlib from typing import Any class EventHooks: _callbacks: dict[str, list[Any]] = { 'on_step_started': [], 'on_phase_completed': [], 'on_gate_failed': [], 'on_checkpoint_saved': [], 'on_step_finalized': [], } @classmethod def register(cls, event_name: str, callback: Any) -> None: if event_name not in cls._callbacks: raise ValueError(f'Unknown event: {event_name}') cls._callbacks[event_name].append(callback) @classmethod def trigger(cls, event_name: str, **kwargs: Any) -> None: if event_name not in cls._callbacks: return for cb in cls._callbacks[event_name]: with _contextlib.suppress(Exception): cb(**kwargs) def get_project_status() -> dict: return {'status': 'unavailable', 'step': 'unknown'} def status_to_json(status: dict) -> str: import json return json.dumps(status, ensure_ascii=False) try: from scripts.ump.status_api import ( EventHooks as _real_EventHooks, get_project_status as _real_get_project_status, status_to_json as _real_status_to_json, ) except ImportError: pass else: EventHooks = _real_EventHooks # type: ignore[assignment] get_project_status = _real_get_project_status # type: ignore[assignment] status_to_json = _real_status_to_json # type: ignore[assignment] # UMP Phase 2: Import notify callbacks and register with event hooks try: from scripts.ump.notify import ( notify_checkpoint_saved, notify_gate_failed, notify_phase_completed, notify_step_finalized, notify_step_started, ) EventHooks.register('on_step_started', notify_step_started) EventHooks.register('on_phase_completed', notify_phase_completed) EventHooks.register('on_gate_failed', notify_gate_failed) EventHooks.register('on_checkpoint_saved', notify_checkpoint_saved) EventHooks.register('on_step_finalized', notify_step_finalized) except ImportError: pass def log_action( message: str, entry_type: str = 'action', step: str | None = None, phase: str | None = None, details: dict | None = None, ) -> None: """Записать действие в лог и вызвать event hooks.""" # Путь лога из конфига (с fallback на _meta/agent.log) paths = get_paths() log_file = paths.agent_log log_file.parent.mkdir(parents=True, exist_ok=True) entry: dict = { 'timestamp': datetime.now().isoformat(), 'type': entry_type, 'message': message, 'ump_version': VERSION, 'hostname': os.uname().nodename, 'pid': os.getpid(), } if step: entry['step'] = step if phase: entry['phase'] = phase if details: entry['details'] = details with open(log_file, 'a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') # UMP Phase 0: Trigger event hooks after logging (which also call notify callbacks) try: if entry_type == 'step_start': EventHooks.trigger('on_step_started', step_id=step or '?', phase=phase) elif entry_type == 'phase' and details and details.get('status') == 'success': EventHooks.trigger('on_phase_completed', step_id=step or '?', phase=phase or '?', status='success') elif entry_type == 'gate' and details: gate_name = details.get('gate_name', 'unknown') message = details.get('message', 'Gate check failed') EventHooks.trigger('on_gate_failed', gate_name=gate_name, step_id=step or '?', message=message) elif entry_type == 'step_complete': EventHooks.trigger('on_step_finalized', step_id=step or '?', status='success') elif entry_type == 'step_fail': EventHooks.trigger('on_step_finalized', step_id=step or '?', status='failed') except Exception: pass @app.command() def status( json_output: Annotated[bool, typer.Option('--json', '-j', help='Вывести в машинно-читаемом формате')] = False, ) -> None: """Показать статус проекта: фаза, завершённые шаги, прогресс.""" load_constants() read_current_phase() read_completed_steps() all_steps = get_all_steps() len(all_steps) get_paths() # UMP Phase 0: JSON mode via Status API if json_output: try: status = get_project_status() print(status_to_json(status)) return except Exception as e: console.print(f'[red]❌ Ошибка Status API: {e}[/]') console.print('[yellow]⚠️ Используется fallback на текстовый вывод[/]') # ─── guide ────────────────────────────────────────────────────────────────── @app.command() def guide() -> None: """Вывести инструкцию для GigaCode агента (контекст для начала работы).""" phase = read_current_phase() completed = read_completed_steps() all_steps = get_all_steps() total = len(all_steps) # Найти следующий шаг next_step = None for sid, title, stage, stype in all_steps: if sid not in completed: next_step = (sid, title, stage, stype) break guide_text = f"""# Инструкция для GigaCode агента ## Статус проекта - Версия: UMP {VERSION} - Фаза: {phase} - Завершено: {len(completed)} / {total} шагов ## Главное правило **ВСЕ команды — через `uv run ump ...`**. Не вызывай `python3 scripts/...` напрямую. ## Следующий шаг """ if next_step: sid, title, stage, stype = next_step guide_text += f"""- ID: `{sid}` - Название: {title} - Этап: {stage} - Тип: {stype} ### Команды для выполнения: ```bash uv run ump plan show {sid} # детали шага uv run ump step start {sid} # начать шаг (создать ветку) uv run ump step run {sid} --phase PHASE1 # запустить фазу uv run ump step verify {sid} --prompt docs-writer # верификация uv run ump step finalize {sid} # финализация (commit, push, PR) ``` ### План как task list (TodoWrite): ```bash uv run ump plan tasks {sid} # получить задачи шага для TodoWrite ``` """ else: guide_text += 'Все шаги завершены!\n' guide_text += """ ## 11 фаз цикла шага ``` TASK_LIST → PREFLIGHT → PHASE1 → IMPLEMENTATION → TESTING → CODE_REVIEW → PHASE2A → PHASE2B → FINAL_TESTING → FINAL_REVIEW → FINALIZATION ``` ## Принципы - Одно действие = один коммит (multi-commit policy) - Шаг закончен = PR + gate - При ошибках: мыслить вслух [МЫСЛЬ]/[ПОИСК]/[ДЕЙСТВИЕ] - Параллельность: opt_in_per_phase (TESTING, CODE_REVIEW, FINAL_TESTING, FINAL_REVIEW) ## Контекст — экономно! - НЕ читай файлы правил целиком — `make extract-section FILE=... SECTION=...` - НЕ читай plans/*.yaml целиком — `uv run ump plan show X.Y` - Бюджет: ≤30KB контекста на один шаг """ console.print(Markdown(guide_text)) log_action('GUIDE: выведена инструкция для GigaCode агента', 'info') # ─── plan ─────────────────────────────────────────────────────────────────── @plan_app.command('list') def plan_list() -> None: """Список всех этапов и шагов.""" completed = read_completed_steps() plans = load_plans() table = Table(title='Этапы UMP', show_header=True) table.add_column('ID', style='cyan', no_wrap=True) table.add_column('Этап', style='yellow') table.add_column('Название', style='white') table.add_column('Тип', style='dim') table.add_column('Статус', style='green') for plan in plans: stage = plan.get('stage', '?') plan.get('name', '?') for step in plan.get('steps', []): sid = str(step.get('id', '?')) title = step.get('title', '')[:45] stype = step.get('step_type', '?') status = '✅' if sid in completed else '⏳' table.add_row(sid, str(stage), title, stype, status) console.print(table) log_action(f'PLAN LIST: показано {sum(len(p.get("steps",[])) for p in plans)} шагов', 'info') @plan_app.command('show') def plan_show( step_id: Annotated[str, typer.Argument(help='ID шага, например 1.0')], ) -> None: """Показать детали конкретного шага.""" result = find_step(step_id) if not result: console.print(f'[red]❌ Шаг {step_id} не найден[/]') raise typer.Exit(1) step, plan = result console.print(Panel(f'[bold cyan]Шаг {step_id}[/] — {step.get("title", "")}', expand=False)) info = Table(show_header=False, box=None) info.add_column('Поле', style='cyan', no_wrap=True) info.add_column('Значение', style='white') info.add_row('Этап', f'{plan.get("stage", "?")} — {plan.get("name", "?")}') info.add_row('Тип', step.get('step_type', '?')) info.add_row('Ветка', step.get('branch', '—')) info.add_row('docs_target', step.get('docs_target', '—')) verify = step.get('verify', []) if verify: info.add_row('Verify', verify[0] if isinstance(verify, list) else str(verify)) expected = step.get('expected_result', '') if expected: info.add_row('Ожидаемый результат', expected[:100] + ('...' if len(expected) > 100 else '')) console.print(info) # Context to read ctx = step.get('context_to_read', []) if ctx: console.print('\n[bold]Контекст для чтения:[/]') for c in ctx: exists = '✅' if (BASE / c.split('#')[0]).exists() else '⏳' console.print(f' {exists} {c}') # Prompts prompts = step.get('prompts_to_use', []) if prompts: console.print('\n[bold]Промпты субагентов:[/]') for p in prompts: console.print(f' → {p}') # gigacode block gc = step.get('gigacode', {}) if gc: console.print('\n[bold]GigaCode блок:[/]') console.print(f' Описание: {gc.get("description", "—")}') console.print(f' Роль агента: {gc.get("agent_role", "—")}') calls = gc.get('subagent_calls', []) if calls: console.print(f' Субагенты: {", ".join(calls)}') log_action(f'PLAN SHOW: шаг {step_id}', 'info', step=step_id) @plan_app.command('tasks') def plan_tasks( step_id: Annotated[str, typer.Argument(help='ID шага, например 1.0')], ) -> None: """Вывести задачи шага в формате для GigaCode TodoWrite.""" result = find_step(step_id) if not result: console.print(f'[red]❌ Шаг {step_id} не найден[/]') raise typer.Exit(1) step, _ = result console.print(f'[bold cyan]Task List для шага {step_id}[/]') console.print('[dim]Скопируйте в TodoWrite GigaCode:[/]\n') tasks = [] # Из gigacode.instructions gc = step.get('gigacode', {}) instructions = gc.get('instructions', []) for i, instr in enumerate(instructions, 1): tasks.append((str(i), instr)) # Если нет instructions — из expected_result if not tasks: expected = step.get('expected_result', '') if expected: for i, part in enumerate(expected.split('. '), 1): if part.strip(): tasks.append((str(i), part.strip())) # Из verify verify = step.get('verify', []) if verify: v = verify[0] if isinstance(verify, list) else str(verify) tasks.append((str(len(tasks) + 1), f'Запустить verify: {v}')) # Финализация tasks.append((str(len(tasks) + 1), 'Git commit (одно действие = один коммит)')) tasks.append((str(len(tasks) + 1), 'Создать PR + gate')) # Вывод в формате TodoWrite console.print('```json') todo_items = [] for tid, title in tasks: todo_items.append({'id': tid, 'title': title, 'status': 'pending'}) console.print_json(json.dumps(todo_items, ensure_ascii=False)) console.print('```\n') # Также в текстовом виде console.print('[bold]Текстовый вид:[/]') for tid, title in tasks: console.print(f' [ ] {tid}. {title}') log_action(f'PLAN TASKS: шаг {step_id}, {len(tasks)} задач', 'info', step=step_id) @plan_app.command('validate') def plan_validate() -> None: """Валидация всех YAML-планов.""" console.print('[bold]Валидация планов...[/]') # v0.0.1: запускаем validate_plans.py напрямую (без uv), чтобы избежать cache проблем paths = get_paths() validator_path = paths.infra / 'scripts' / 'validate_plans.py' if not validator_path.exists(): # Fallback на BASE/scripts/ (standalone layout) validator_path = paths.base / 'scripts' / 'validate_plans.py' # P0-7 fix: list-form без shell=True. code, out, err = run_cmd(['python3', str(validator_path)]) if code == 0: console.print('[green]✅ Все проверки пройдены — VALID[/]') else: console.print('[red]❌ Ошибки валидации:[/]') console.print(out) if err: console.print(f'[dim]{err}[/]') log_action(f'PLAN VALIDATE: exit={code}', 'validation') @plan_app.command('next') def plan_next() -> None: """Показать следующий шаг для выполнения.""" completed = read_completed_steps() all_steps = get_all_steps() for sid, title, stage, stype in all_steps: if sid not in completed: console.print(Panel( f'[bold green]Следующий шаг: {sid}[/] — {title}\n' f'Этап: {stage} | Тип: {stype}\n\n' f'Команды:\n' f' [cyan]uv run ump plan show {sid}[/]\n' f' [cyan]uv run ump plan tasks {sid}[/]\n' f' [cyan]uv run ump step start {sid}[/]', expand=False, )) log_action(f'PLAN NEXT: шаг {sid}', 'info', step=sid) return console.print('[green]✅ Все шаги завершены![/]') # ─── step ─────────────────────────────────────────────────────────────────── @step_app.command('start') def step_start( step_id: Annotated[str, typer.Argument(help='ID шага, например 1.0')], ) -> None: """Начать шаг: создать feature-ветку, инициализировать progress.""" result = find_step(step_id) if not result: console.print(f'[red]❌ Шаг {step_id} не найден[/]') raise typer.Exit(1) step, plan = result # Branch pattern из конфига (с fallback на step.branch и feature/step-<id>) paths = get_paths() if paths.config: prefix = paths.config.git.branch.prefix pattern = paths.config.git.branch.pattern branch = step.get('branch') or pattern.format( prefix=prefix, step_id=step_id, slug=step.get('title', '').lower().replace(' ', '-')[:30] ) else: branch = step.get('branch', f'feature/step-{step_id}') console.print(f'[bold cyan]Начало шага {step_id}[/] — {step.get("title", "")}') console.print(f' Ветка: {branch}') # Создать ветку if branch == 'main': console.print(' [dim]Шаг 1.0 — коммит в main[/]') else: # P0-7 fix: list-form без shell=True. branch из YAML-плана может # содержать shell-метасимволы — без list-form это инъекция. code, out, err = run_cmd(['git', 'checkout', '-b', branch]) if code == 0: console.print(' [green]✅ Ветка создана[/]') else: code2, _, _ = run_cmd(['git', 'checkout', branch]) if code2 == 0: console.print(' [yellow]⚠ Переключились на существующую ветку[/]') else: console.print(f' [red]❌ Ошибка создания ветки: {err}[/]') raise typer.Exit(1) # Инициализировать progress # P0-7 fix: list-form без shell=True. code, out, err = run_cmd([ 'python3', str(get_paths().infra / 'scripts' / 'orchestrate_step.py'), step_id, '--init-progress', '--no-ack', ]) if code == 0: console.print(' [green]✅ Progress создан[/]') else: console.print(f' [yellow]⚠ Progress: {err[:80]}[/]') log_action(f'STEP START: шаг {step_id}, ветка {branch}', 'step_start', step=step_id) console.print('\n[bold]Далее:[/]') console.print(f' [cyan]uv run ump plan tasks {step_id}[/] — получить task list') console.print(f' [cyan]uv run ump step run {step_id} --phase PHASE1[/]') @step_app.command('run') def step_run( step_id: Annotated[str, typer.Argument(help='ID шага')], phase: Annotated[str, typer.Option('--phase', '-p', help='Фаза: PHASE1, TESTING, etc.')], ) -> None: """Запустить фазу шага.""" console.print(f'[bold cyan]Запуск фазы {phase}[/] для шага {step_id}...') code, out, err = run_cmd([ 'python3', str(get_paths().infra / 'scripts' / 'run_phase.py'), step_id, phase, ]) if code == 0: console.print(f'[green]✅ Фаза {phase} завершена[/]') else: console.print(f'[red]❌ Фаза {phase} FAILED (exit {code})[/]') if out: console.print(out[-500:]) if err: console.print(f'[dim]{err[-300:]}[/]') log_action(f'STEP RUN: шаг {step_id}, фаза {phase}, exit={code}', 'phase' if code == 0 else 'error', step=step_id, phase=phase) @step_app.command('verify') def step_verify( step_id: Annotated[str, typer.Argument(help='ID шага')], prompt: Annotated[str, typer.Option('--prompt', '-p', help='Промпт: docs-writer, code-writer, etc.')], ) -> None: """Верифицировать субагента.""" console.print(f'[bold]Верификация {prompt}[/] для шага {step_id}...') # P0-9 fix: verify_subagent.py ожидает <prompt_id> <step_id> (в таком порядке) # P0-7 fix: list-form без shell=True. shlex.quote не нужен — list-form # сам экранирует каждый argv-элемент. verify_script = get_paths().infra / 'scripts' / 'verify_subagent.py' code, out, err = run_cmd([sys.executable, str(verify_script), prompt, step_id]) if code == 0: console.print('[green]✅ Верификация PASS[/]') else: console.print(f'[red]❌ Верификация FAIL (exit {code})[/]') if err: console.print(f'[dim]{err[-300:]}[/]') log_action(f'STEP VERIFY: шаг {step_id}, промпт {prompt}, exit={code}', 'validation', step=step_id) @step_app.command('finalize') def step_finalize( step_id: Annotated[str, typer.Argument(help='ID шага')], ) -> None: """Финализировать шаг: commit, push, PR.""" console.print(f'[bold cyan]Финализация шага {step_id}...[/]') console.print('[yellow]⚠ Это создаст PR. Убедитесь что все фазы завершены.[/]') confirm = typer.confirm('Продолжить?') if not confirm: console.print('[dim]Отменено[/]') raise typer.Exit() # P0-7 fix: list-form без shell=True. code, out, err = run_cmd([ 'python3', str(get_paths().infra / 'scripts' / 'finalize_step.py'), step_id, ]) if code == 0: console.print(f'[green]✅ Шаг {step_id} финализирован[/]') console.print(out[-500:]) else: console.print(f'[red]❌ Финализация FAIL (exit {code})[/]') console.print(err[-500:]) log_action(f'STEP FINALIZE: шаг {step_id}, exit={code}', 'step_complete' if code == 0 else 'step_fail', step=step_id) @step_app.command('reset') def step_reset( step_id: Annotated[str, typer.Argument(help='ID шага')], ) -> None: """Сбросить прогресс шага.""" confirm = typer.confirm(f'Сбросить прогресс шага {step_id}?') if not confirm: raise typer.Exit() # P0-7 fix: list-form без shell=True. code, _, err = run_cmd([ 'python3', str(get_paths().infra / 'scripts' / 'orchestrate_step.py'), step_id, '--force', '--init-progress', '--no-ack', ]) if code == 0: console.print('[green]✅ Прогресс сброшен[/]') else: console.print(f'[red]❌ {err}[/]') log_action(f'STEP RESET: шаг {step_id}', 'warning', step=step_id) # ─── test ─────────────────────────────────────────────────────────────────── @test_app.command('run') def test_run( phase: Annotated[str, typer.Option('--phase', '-p', help='Слой: unit, integration, property, contract, e2e, all')] = 'all', verbose: Annotated[bool, typer.Option('--verbose', '-v', help='Подробный вывод')] = False, ) -> None: """Запустить тесты.""" phase_map = { 'all': 'uv run pytest -v' + (' --tb=long' if verbose else ''), 'unit': 'uv run pytest tests/unit apps/backend/core/tests/unit -v', 'integration': 'uv run pytest tests/integration apps/backend/core/tests/integration -v', 'property': 'uv run pytest tests/property apps/backend/core/tests/property -v -s', 'contract': 'uv run pytest tests/contract apps/backend/core/tests/contract -v', 'e2e': 'uv run pytest tests/e2e apps/backend/core/tests/e2e -v --tb=long', } cmd = phase_map.get(phase, phase_map['all']) console.print(f'[bold]Запуск тестов: {phase}[/]') console.print(f'[dim]{cmd}[/]\n') code, out, err = run_cmd(cmd) if code == 0: console.print('[green]✅ Тесты PASS[/]') else: console.print(f'[red]❌ Тесты FAIL (exit {code})[/]') # Показать последние строки lines = (out + '\n' + err).strip().split('\n') for line in lines[-20:]: console.print(line) log_action(f'TEST RUN: phase={phase}, exit={code}', 'test' if code == 0 else 'error', phase=phase) @test_app.command('coverage') def test_coverage() -> None: """Отчёт покрытия кода.""" console.print('[bold]Запуск coverage...[/]') code, out, _ = run_cmd('uv run pytest --cov=apps --cov=scripts --cov-branch --cov-report=term-missing -q') console.print(out[-1000:] if out else 'Нет данных') log_action(f'TEST COVERAGE: exit={code}', 'test') @test_app.command('property') def test_property() -> None: """Property-based тесты (Hypothesis).""" console.print('[bold]Запуск Hypothesis property-based тестов...[/]') code, out, _ = run_cmd('uv run pytest tests/property apps/backend/core/tests/property -v -s --hypothesis-show-statistics') console.print(out[-1500:] if out else 'Нет property тестов') log_action(f'TEST PROPERTY: exit={code}', 'test') @test_app.command('contract') def test_contract() -> None: """Contract тесты (Pact).""" console.print('[bold]Запуск Pact contract тестов...[/]') code, out, _ = run_cmd('uv run pytest tests/contract apps/backend/core/tests/contract -v') console.print(out[-1000:] if out else 'Нет contract тестов') log_action(f'TEST CONTRACT: exit={code}', 'test') @test_app.command('e2e') def test_e2e() -> None: """E2E тесты (Playwright).""" console.print('[bold]Запуск E2E тестов...[/]') code, out, _ = run_cmd('uv run pytest tests/e2e apps/backend/core/tests/e2e -v --tb=long') console.print(out[-1000:] if out else 'Нет E2E тестов') log_action(f'TEST E2E: exit={code}', 'test') # ─── agent ────────────────────────────────────────────────────────────────── @agent_app.command('status') def agent_status() -> None: """Статус AI-агента: фаза, лог, ветка.""" phase = read_current_phase() _, branch, _ = run_cmd('git branch --show-current') # Путь лога из конфига paths = get_paths() log_file = paths.agent_log table = Table(title='Статус AI-агента', show_header=False) table.add_column('Параметр', style='cyan') table.add_column('Значение', style='white') table.add_row('Фаза', phase) table.add_row('Ветка', branch or '—') table.add_row('Лог файл', str(log_file) + (' ✅' if log_file.exists() else ' ⏳')) if log_file.exists(): lines = log_file.read_text(encoding='utf-8').strip().split('\n') table.add_row('Записей в логе', str(len(lines))) if lines: try: last = json.loads(lines[-1]) table.add_row('Последняя запись', last.get('message', '?')[:60]) table.add_row('Время', last.get('timestamp', '?')[:19]) except Exception: pass console.print(table) @agent_app.command('log') def agent_log( tail: Annotated[int, typer.Option('--tail', '-n', help='Кол-во последних записей')] = 20, step: Annotated[str | None, typer.Option('--step', '-s', help='Фильтр по шагу')] = None, grep: Annotated[str | None, typer.Option('--grep', '-g', help='Поиск по тексту')] = None, ) -> None: """Показать лог агента.""" paths = get_paths() log_file = paths.agent_log if not log_file.exists(): console.print('[yellow]Лог пуст. Выполните действие для создания записи.[/]') return lines = log_file.read_text(encoding='utf-8').strip().split('\n') entries = [] for line in lines: try: entry = json.loads(line) if step and entry.get('step') != step: continue if grep and grep.lower() not in entry.get('message', '').lower(): continue entries.append(entry) except Exception: pass if not entries: console.print('[yellow]Записей не найдено.[/]') return table = Table(title=f'Лог агента (последние {tail})', show_header=True) table.add_column('Время', style='dim', width=19) table.add_column('Тип', style='cyan', width=12) table.add_column('Шаг', style='yellow', width=6) table.add_column('Сообщение', style='white') for entry in entries[-tail:]: ts = entry.get('timestamp', '')[:19] etype = entry.get('type', '?') step_val = entry.get('step', '') msg = entry.get('message', '')[:80] table.add_row(ts, etype, step_val, msg) console.print(table) # ─── protect ──────────────────────────────────────────────────────────────── @protect_app.command('check') def protect_check() -> None: """Проверить immutable-файлы (SHA-256).""" console.print('[bold]Проверка immutable-файлов...[/]') # P0-7 fix: list-form без shell=True. shlex.quote не нужен — list-form сам экранирует. protect_script = get_paths().infra / 'scripts' / 'protect_files.py' code, out, err = run_cmd([sys.executable, str(protect_script), '--check']) if code == 0: console.print('[green]✅ Все immutable-файлы не изменены[/]') if out: console.print(out) else: console.print('[red]❌ Обнаружены изменения:[/]') # P0-11 fix: protect_files.py пишет детали в stderr — показываем оба потока if out: console.print(out) if err: console.print(f'[dim]{err}[/]') log_action(f'PROTECT CHECK: exit={code}', 'validation') @protect_app.command('list') def protect_list() -> None: """Список immutable-файлов.""" # P0-7 fix: list-form без shell=True. code, out, _ = run_cmd([ 'python3', str(get_paths().infra / 'scripts' / 'protect_files.py'), '--list', ]) console.print(out) # ─── worktree ─────────────────────────────────────────────────────────────── @worktree_app.command('create') def worktree_create( step_id: Annotated[str, typer.Argument(help='ID шага')], phase: Annotated[str, typer.Argument(help='Фаза: CODE_REVIEW, TESTING, etc.')], subagent: Annotated[str, typer.Argument(help='Имя sub-agent: security-reviewer')], ) -> None: """Создать git worktree для параллельного sub-agent.""" console.print(f'[bold]Создание worktree:[/] {step_id}/{phase}/{subagent}') # P0-10 fix: использовать абсолютный путь через paths.infra # P0-7 fix: list-form без shell=True. shlex.quote не нужен — list-form сам экранирует. worktree_script = get_paths().infra / 'scripts' / 'worktree-init.sh' code, out, err = run_cmd([str(worktree_script), step_id, phase, subagent]) if code == 0: console.print(f'[green]✅ Worktree создан: {out}[/]') else: console.print(f'[red]❌ {err}[/]') log_action(f'WORKTREE CREATE: {step_id}/{phase}/{subagent}, exit={code}', 'action', step=step_id, phase=phase) @worktree_app.command('list') def worktree_list() -> None: """Список worktrees.""" code, out, _ = run_cmd('git worktree list') console.print(out) @worktree_app.command('remove') def worktree_remove( path: Annotated[str, typer.Argument(help='Путь к worktree')], force: Annotated[bool, typer.Option('--force', '-f', help='Принудительно')] = False, ) -> None: """Удалить worktree.""" cmd = f'git worktree remove {"--force" if force else ""} {path}' code, _, err = run_cmd(cmd) if code == 0: console.print(f'[green]✅ Worktree удалён: {path}[/]') else: console.print(f'[red]❌ {err}[/]') # ─── parallel ─────────────────────────────────────────────────────────────── @parallel_app.command('status') def parallel_status() -> None: """Статус параллельных sub-agents.""" constants = load_constants() pc = constants.get('parallel_config', {}) table = Table(title='Параллельные sub-agents', show_header=False) table.add_column('Параметр', style='cyan') table.add_column('Значение', style='white') table.add_row('Режим', str(constants.get('parallel_steps', '?'))) table.add_row('Max concurrency', str(pc.get('max_concurrency', 4))) table.add_row('Timeout (сек)', str(pc.get('subagent_timeout_seconds', 300))) table.add_row('Sequential integration', str(pc.get('sequential_integration', True))) table.add_row('Quality gate', str(pc.get('quality_gate_required', True))) table.add_row('Human review gate', str(pc.get('human_review_gate', True))) console.print(table) @parallel_app.command('eligible') def parallel_eligible() -> None: """Список parallel-eligible фаз.""" constants = load_constants() pc = constants.get('parallel_config', {}) eligible = pc.get('parallel_eligible_phases', []) forbidden = pc.get('parallel_forbidden_phases', []) console.print('[bold green]✅ Parallel-eligible фазы:[/]') for p in eligible: console.print(f' {p}') console.print('\n[bold red]❌ Parallel-forbidden фазы:[/]') for p in forbidden: console.print(f' {p}') # ─── init — REMOVED in v0.0.2 ─────────────────────────────────────────────── # P1-7 fix: init() был dead code, дублирующий setup() с теми же багами (nested git). # Используйте `ump setup` для полной инициализации проекта. # ─── setup (мастер настройки проекта) ──────────────────────────────────────── # ─── dashboard (UMP Phase 1) ──────────────────────────────────────────────── @app.command() def dashboard( host: Annotated[str, typer.Option('--host', '-h', help='Хост для запуска')] = '127.0.0.1', port: Annotated[int, typer.Option('--port', '-p', help='Порт для запуска')] = 8000, ) -> None: """Запустить локальный веб-дашборд UMP (FastAPI).""" console.print('[bold cyan]UMP Dashboard v0.0.1[/]') console.print(f' Запуск на [green]http://{host}:{port}[/]') console.print(' Открыть в браузере для просмотра прогресса проекта') import subprocess import sys cmd = [ sys.executable, str(Path(__file__).parent / 'dashboard.py'), ] env = os.environ.copy() env['DASHBOARD_HOST'] = host env['DASHBOARD_PORT'] = str(port) try: subprocess.run(cmd, env=env, check=True) except subprocess.CalledProcessError as e: console.print(f'[red]❌ Ошибка запуска dashboard: {e}[/]') raise typer.Exit(code=1) from None @app.command() def setup( check: Annotated[bool, typer.Option('--check', help='Только проверить существующую конфигурацию')] = False, migrate: Annotated[bool, typer.Option('--migrate', help='Мигрировать конфиг при обновлении ump-infra')] = False, update: Annotated[bool, typer.Option('--update', help='Re-render конфига из .ump-answers.yml')] = False, yes: Annotated[bool, typer.Option('--yes', '-y', help='Неинтерактивный режим (для CI)')] = False, # Флаги для неинтерактивного режима (1:1 с промптами wizard'а) project_name: Annotated[str | None, typer.Option('--project-name')] = None, display_name: Annotated[str | None, typer.Option('--display-name')] = None, description: Annotated[str | None, typer.Option('--description')] = None, project_type: Annotated[str | None, typer.Option( '--project-type', help='saas | web-app | cli | library | ml-service | custom', )] = None, version: Annotated[str | None, typer.Option('--version', help='SemVer, например 0.1.0')] = None, language: Annotated[str | None, typer.Option( '--language', help='ru | en | mixed', )] = None, git_remote: Annotated[str | None, typer.Option('--git-remote', help='URL git remote')] = None, default_branch: Annotated[str | None, typer.Option('--default-branch', help='main | master | develop')] = None, git_user_name: Annotated[str | None, typer.Option('--git-user-name')] = None, git_user_email: Annotated[str | None, typer.Option('--git-user-email')] = None, commit_convention: Annotated[str | None, typer.Option( '--commit-convention', help='conventional | plain', )] = None, branch_prefix: Annotated[str | None, typer.Option('--branch-prefix', help='например feature/')] = None, agents: Annotated[str | None, typer.Option( '--agents', help='Список через запятую: generic,claude,cursor,copilot,cline (generic обязателен)', )] = None, model: Annotated[str | None, typer.Option('--model', help='например glm-4.6, gpt-4, claude-3.5')] = None, budget_kb: Annotated[int | None, typer.Option('--budget-kb', help='Контекст-бюджет AI, по умолчанию 30')] = None, plans_dir: Annotated[str | None, typer.Option('--plans-dir', help='путь к plans/, по умолчанию plans/')] = None, memory_bank_dir: Annotated[str | None, typer.Option('--memory-bank-dir', help='путь к memory-bank/')] = None, source_dir: Annotated[str | None, typer.Option('--source-dir', help='путь к исходникам, по умолчанию apps/')] = None, tests_dir: Annotated[str | None, typer.Option('--tests-dir', help='путь к тестам, по умолчанию tests/')] = None, python_ver: Annotated[str | None, typer.Option('--python-ver', help='например >=3.14')] = None, node_ver: Annotated[str | None, typer.Option('--node-ver', help='например >=24.18')] = None, example: Annotated[str | None, typer.Option( '--example', help='saas-platform | web-app | blank', )] = None, ) -> None: """ Мастер настройки проекта: создаёт ump-ui-config.yaml, AGENTS.md, memory-bank/. Режимы: uv run .ump/scripts/ump.py setup # интерактивный uv run .ump/scripts/ump.py setup --check # проверка uv run .ump/scripts/ump.py setup --migrate # миграция uv run .ump/scripts/ump.py setup --update # re-render uv run .ump/scripts/ump.py setup --yes --project-name X --git-remote URL # CI """ # Валидация значений Enum-полей VALID_PROJECT_TYPES = {'saas', 'web-app', 'cli', 'library', 'ml-service', 'custom'} VALID_LANGUAGES = {'ru', 'en', 'mixed'} VALID_CONVENTIONS = {'conventional', 'plain'} VALID_EXAMPLES = {'saas-platform', 'web-app', 'blank'} VALID_AGENTS = {'generic', 'claude', 'cursor', 'copilot', 'cline'} if project_type and project_type not in VALID_PROJECT_TYPES: console.print(f'[red]❌ --project-type {project_type!r} некорректен. Допустимо: {sorted(VALID_PROJECT_TYPES)}[/]') raise typer.Exit(1) if language and language not in VALID_LANGUAGES: console.print(f'[red]❌ --language {language!r} некорректен. Допустимо: {sorted(VALID_LANGUAGES)}[/]') raise typer.Exit(1) if commit_convention and commit_convention not in VALID_CONVENTIONS: console.print(f'[red]❌ --commit-convention {commit_convention!r} некорректен. Допустимо: {sorted(VALID_CONVENTIONS)}[/]') raise typer.Exit(1) if example and example not in VALID_EXAMPLES: console.print(f'[red]❌ --example {example!r} некорректен. Допустимо: {sorted(VALID_EXAMPLES)}[/]') raise typer.Exit(1) if agents: agent_list = [a.strip() for a in agents.split(',') if a.strip()] invalid = set(agent_list) - VALID_AGENTS if invalid: console.print(f'[red]❌ --agents содержит неизвестные: {sorted(invalid)}. Допустимо: {sorted(VALID_AGENTS)}[/]') raise typer.Exit(1) if 'generic' not in agent_list: console.print('[red]❌ --agents должен содержать \'generic\' (AGENTS.md — открытый стандарт)[/]') raise typer.Exit(1) # Взаимоисключающие опции exclusive = sum([check, migrate, update]) if exclusive > 1: console.print('[red]❌ --check, --migrate, --update взаимоисключающи[/]') raise typer.Exit(1) # Если есть флаги с --yes — это неинтерактивный режим has_flags = any([ project_name, display_name, description, project_type, version, language, git_remote, default_branch, git_user_name, git_user_email, commit_convention, branch_prefix, agents, model, budget_kb, plans_dir, memory_bank_dir, source_dir, tests_dir, python_ver, node_ver, example, ]) if check: exit_code = run_setup(BASE, check_only=True) raise typer.Exit(exit_code) if migrate: exit_code = run_setup(BASE, migrate=True, yes=yes) raise typer.Exit(exit_code) if update: exit_code = run_setup(BASE, update=True, yes=yes) raise typer.Exit(exit_code) if has_flags or yes: # Неинтерактивный режим answers = flags_to_answers( project_name=project_name, display_name=display_name, description=description, project_type=project_type, version=version, language=language, git_remote=git_remote, default_branch=default_branch, git_user_name=git_user_name, git_user_email=git_user_email, commit_convention=commit_convention, branch_prefix=branch_prefix, agents=agents, model=model, budget_kb=budget_kb, plans_dir=plans_dir, memory_bank_dir=memory_bank_dir, source_dir=source_dir, tests_dir=tests_dir, python_ver=python_ver, node_ver=node_ver, example=example, ) exit_code = run_setup( BASE, interactive=False, answers_override=answers, yes=yes ) else: # Интерактивный режим exit_code = run_setup(BASE, interactive=True, yes=yes) log_action(f'SETUP: exit={exit_code}', 'action') raise typer.Exit(exit_code) # ─── config (управление конфигом) ──────────────────────────────────────────── @config_app.command('validate') def config_validate() -> None: """Валидировать ump-ui-config.yaml (с layered override).""" if not CONFIG_FILE.exists(): console.print(f'[red]❌ {CONFIG_FILE} не найден. Запустите [cyan]ump setup[/] сначала.[/]') raise typer.Exit(1) try: cfg = UmpConfig.load(BASE) except Exception as e: console.print(f'[red]❌ Ошибка загрузки: {e}[/]') raise typer.Exit(1) from None errors = cfg.validate_runtime() if errors: console.print('[red]❌ Runtime-валидация не пройдена:[/]') for err in errors: console.print(f' • {err}') raise typer.Exit(1) console.print(f'[green]✅ {CONFIG_FILE} валиден[/]') console.print(f' Project: {cfg.project.name} ({cfg.project.display_name})') console.print(f' Type: {cfg.project.type}') console.print(f' Agents: {", ".join(cfg.agent.enabled)}') console.print(f' Language: {cfg.project.language}') log_action('CONFIG VALIDATE: OK', 'validation') @config_app.command('schema') def config_schema( json_out: Annotated[bool, typer.Option('--json', help='Вывести как JSON')] = False, ) -> None: """Показать JSON Schema для ump-ui-config.yaml.""" schema = export_json_schema() if json_out: console.print_json(json.dumps(schema, ensure_ascii=False)) else: # Краткая сводка console.print('[bold]UMP Config JSON Schema[/]') console.print(f' Properties: {len(schema.get("properties", {}))}') for name in schema.get('properties', {}): console.print(f' • {name}') console.print('\n[dim]Полная схема: ump config schema --json[/]') @config_app.command('get') def config_get( key: Annotated[str, typer.Argument(help='Путь через точку: project.name, agent.context.budget_kb')], ) -> None: """Получить значение ключа из конфига (с layered override).""" if not CONFIG_FILE.exists(): console.print(f'[red]❌ {CONFIG_FILE} не найден.[/]') raise typer.Exit(1) try: cfg = UmpConfig.load(BASE) except Exception as e: console.print(f'[red]❌ Ошибка загрузки конфига: {e}[/]') console.print('[dim]Запустите `ump config validate` для деталей.[/]') raise typer.Exit(1) from None # Идём по пути obj: object = cfg for part in key.split('.'): if hasattr(obj, part): obj = getattr(obj, part) elif isinstance(obj, dict) and part in obj: obj = obj[part] else: console.print(f'[red]❌ Ключ {key!r} не найден (часть {part!r})[/]') raise typer.Exit(1) if isinstance(obj, (str, int, float, bool)): console.print(f'[cyan]{key}[/] = [green]{obj}[/]') else: console.print(f'[cyan]{key}[/]:') console.print_json(json.dumps(obj, ensure_ascii=False, default=str)) @config_app.command('set') def config_set( key: Annotated[str, typer.Argument(help='Путь через точку: project.name, agent.context.budget_kb')], value: Annotated[str, typer.Argument(help='Новое значение (строка, число, bool)')], local: Annotated[bool, typer.Option('--local', '-l', help='Записать в ump-ui-config.local.yaml (gitignored)')] = False, ) -> None: """ Установить значение ключа в конфиге. Примеры: ump config set project.name "my-app" ump config set agent.context.budget_kb 50 ump config set git.user.email "ivan@example.com" --local """ if not CONFIG_FILE.exists(): console.print(f'[red]❌ {CONFIG_FILE} не найден. Запустите [cyan]ump setup[/] сначала.[/]') raise typer.Exit(1) # Парсим value: пробуем int/float/bool, иначе строка parsed_value: Any if value.lower() in ('true', 'yes'): parsed_value = True elif value.lower() in ('false', 'no'): parsed_value = False elif value.lstrip('-').isdigit(): parsed_value = int(value) else: try: parsed_value = float(value) except ValueError: parsed_value = value # Определяем целевой файл target_file = BASE / ('ump-ui-config.local.yaml' if local else 'ump-ui-config.yaml') # Сохраняем оригинальный заголовок (комментарии) для отката при ошибке валидации original_content = target_file.read_text(encoding='utf-8') if target_file.exists() else '' # Загружаем существующий YAML (как dict, не валидируем) import yaml as _yaml data: dict[str, Any] = {} if target_file.exists(): try: data = _yaml.safe_load(target_file.read_text(encoding='utf-8')) or {} except Exception as e: console.print(f'[red]❌ Ошибка чтения {target_file}: {e}[/]') raise typer.Exit(1) from None # Идём по пути, создавая промежуточные dict'ы parts = key.split('.') node = data for part in parts[:-1]: if part not in node or not isinstance(node[part], dict): node[part] = {} node = node[part] node[parts[-1]] = parsed_value # P1-8 fix: сохраняем оригинальный заголовок (первые строки-комментарии) header_lines = [] for line in original_content.split('\n'): if line.startswith('#') or not line.strip(): header_lines.append(line) else: break header = '\n'.join(header_lines).rstrip() + '\n' if header_lines else ( f'# {"Local override (gitignored)" if local else "ump-ui-config.yaml — updated by `ump config set`"}\n' ) # Записываем target_file.write_text( header + _yaml.safe_dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False, indent=2), encoding='utf-8', ) # P1-8 fix: валидация после записи (только для non-local, т.к. local-файл не полная конфигурация) if not local: try: cfg_check = UmpConfig.load(BASE) errors = cfg_check.validate_runtime() if errors: # Откат target_file.write_text(original_content, encoding='utf-8') console.print('[red]❌ Валидация после записи не пройдена — откат:[/]') for err in errors: console.print(f' • {err}') raise typer.Exit(1) except Exception as e: # Откат target_file.write_text(original_content, encoding='utf-8') console.print(f'[red]❌ Ошибка валидации после записи — откат: {e}[/]') raise typer.Exit(1) from None console.print(f'[green]✓[/] [cyan]{key}[/] = [green]{parsed_value!r}[/] → {target_file.name}') log_action(f'CONFIG SET: {key}={parsed_value!r} (local={local})', 'action') # ─── agent brief (короткий контекст для AI) ────────────────────────────────── @agent_app.command('brief') def agent_brief() -> None: """ Вывести короткий контекст для AI-агента (вместо чтения всех файлов). Используется при старте сессии. """ if not CONFIG_FILE.exists(): console.print(f'[red]❌ {CONFIG_FILE} не найден. Запустите [cyan]ump setup[/].[/]') raise typer.Exit(1) try: cfg = UmpConfig.load(BASE) except Exception as e: console.print(f'[red]❌ Ошибка загрузки конфига: {e}[/]') raise typer.Exit(1) from None phase = read_current_phase() completed = read_completed_steps() all_steps = get_all_steps() table = Table(title=f'Agent Brief — {cfg.project.display_name}', show_header=False) table.add_column('Key', style='cyan', no_wrap=True) table.add_column('Value', style='white') table.add_row('Project', cfg.project.name) table.add_row('Type', cfg.project.type) table.add_row('Version', cfg.project.version) table.add_row('Language', cfg.project.language) table.add_row('Current step', f'{cfg.progress.current_step} (phase: {phase})') table.add_row('Completed', f'{len(completed)} / {len(all_steps)}') table.add_row('AI agents', ', '.join(cfg.agent.enabled)) table.add_row('Memory bank', cfg.paths.memory_bank) table.add_row('Plans dir', cfg.paths.plans) table.add_row('Budget KB', str(cfg.agent.context.budget_kb)) console.print(table) console.print('\n[bold]Memory Bank files (читай при старте):[/]') mb_dir = BASE / cfg.paths.memory_bank for fname in cfg.agent.memory_bank.files: fpath = mb_dir / fname exists = '✓' if fpath.exists() else '✗' console.print(f' {exists} {cfg.paths.memory_bank}{fname}') console.print('\n[bold]Команды:[/]') console.print(' [cyan]uv run .ump/scripts/ump.py plan next[/]') console.print(f' [cyan]uv run .ump/scripts/ump.py plan show {cfg.progress.current_step}[/]') log_action('AGENT BRIEF: выведен', 'info') # ─── manifest: генерация 00-manifest.md ───────────────────────────────────── @manifest_app.command('generate') def manifest_generate( output: str = typer.Option('00-manifest.md', '--output', '-o', help='Выходной файл (default: 00-manifest.md)'), template: str = typer.Option(None, '--template', help='Кастомный шаблон ({key} плейсхолдеры)'), force: bool = typer.Option(False, '--force', help='Перезаписать существующий файл'), non_interactive: bool = typer.Option(False, '--non-interactive', help='Non-interactive mode (все параметры через flags)'), name: str = typer.Option('My Project', '--name', help='Название проекта'), description: str = typer.Option('Проект на Universal Modular Platform.', '--description'), app_type: str = typer.Option('web-app', '--type', help='Тип: web-app/saas-platform/cli-tool/api-service/library'), backend: str = typer.Option('Python 3.12', '--backend'), frontend: str = typer.Option('', '--frontend', help='Frontend-стек (пусто = нет)'), database: str = typer.Option('PostgreSQL 18', '--database'), cache: str = typer.Option('Redis 8', '--cache'), framework: str = typer.Option('FastAPI', '--framework'), orm: str = typer.Option('SQLAlchemy', '--orm'), deploy: str = typer.Option('Docker', '--deploy'), ci: str = typer.Option('GitVerse Actions', '--ci'), stages: int = typer.Option(10, '--stages', help='Количество этапов (1-50)'), language: str = typer.Option('RU', '--language', help='RU / EN / both'), mvp_stages: int = typer.Option(5, '--mvp-stages', help='Этапов до MVP'), ) -> None: """Сгенерировать 00-manifest.md для проекта. P11-CI fix (фаза 11+, Неделя 4): интерактивная генерация кастомного манифеста. См. docs/MANIFEST-AND-RULES.md §4. Примеры: # Interactive mode: ump manifest generate # Non-interactive (для CI/скриптов): ump manifest generate --non-interactive --name "My SaaS" --type saas-platform \\ --backend "Python 3.12" --frontend "Vue 3.5" --stages 10 --mvp-stages 5 """ # Импортируем generator (отложенный импорт — чтобы не тянуть зависимость в CLI) try: # Если scripts/ в sys.path — прямой импорт from manifest_generator import ManifestAnswers, ask_interactive, generate_manifest except ImportError: # Fallback: добавить scripts/ в sys.path import sys as _sys _here = Path(__file__).resolve().parent if str(_here) not in _sys.path: _sys.path.insert(0, str(_here)) from manifest_generator import ManifestAnswers, ask_interactive, generate_manifest if non_interactive: answers = ManifestAnswers( name=name, description=description, type=app_type, backend=backend, frontend=frontend, database=database, cache=cache, framework=framework, orm=orm, deploy=deploy, ci=ci, stages=stages, language=language, mvp_stages=mvp_stages, ) else: answers = ask_interactive() template_path = Path(template) if template else None rc = generate_manifest(answers, Path(output), template_path, overwrite=force) if rc != 0: raise typer.Exit(code=rc) @manifest_app.command('show') def manifest_show() -> None: """Показать текущий манифест (00-manifest.md).""" manifest_path = Path('00-manifest.md') if not manifest_path.exists(): console.print('[red]❌ 00-manifest.md не найден.[/]') console.print(' Создайте: [cyan]ump manifest generate[/]') raise typer.Exit(code=1) content = manifest_path.read_text(encoding='utf-8') console.print(content) # ─── analytics report ( Этап 3) ───────────────────────────────────────────── @analytics_app.command('report') def analytics_report( output: Annotated[str, typer.Option('--output', '-o', help='Выходной формат: console/md/csv')] = 'console', step_id: Annotated[str | None, typer.Option('--step', help='ID шага для детальной аналитики')] = None, phase: Annotated[str | None, typer.Option('--phase', help='Фаза для фильтрации')] = None, ) -> None: """Аналитический отчет по проекту ( Этап 3). Среднее время шага/фазы, самая "узкая" фаза, % провалов гейтов, тренд по этапам. """ log_file = BASE / '_meta' / 'agent.log' if not log_file.exists(): console.print('[red]❌ agent.log не найден. Запустите шаги для сбора данных.[/]') raise typer.Exit(1) entries = [] try: with open(log_file, encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) entries.append(entry) except json.JSONDecodeError: continue except Exception as e: console.print(f'[red]❌ Ошибка чтения лога: {e}[/]') raise typer.Exit(1) from None if not entries: console.print('[yellow]⚠️ Лог пуст. Нет данных для анализа.[/]') return if step_id: entries = [e for e in entries if e.get('step') == step_id] if phase: entries = [e for e in entries if e.get('phase') == phase] phase_times = {} phase_entries = {} gate_failures = 0 total_gates = 0 step_start_times = {} step_end_times = {} for entry in entries: step = entry.get('step') phase_name = entry.get('phase', 'unknown') entry_type = entry.get('type') timestamp = entry.get('timestamp') if entry_type == 'step_start' and step: step_start_times[step] = timestamp elif entry_type == 'step_complete' and step: step_end_times[step] = timestamp elif entry_type == 'phase' and phase_name: if phase_name not in phase_times: phase_times[phase_name] = [] phase_entries[phase_name] = 0 phase_times[phase_name].append(timestamp) phase_entries[phase_name] += 1 elif entry_type == 'gate': total_gates += 1 if entry.get('details', {}).get('status') == 'failed': gate_failures += 1 console.print(Panel('[bold cyan]Аналитический отчет[/]', expand=False)) table = Table(show_header=True) table.add_column('Метрика', style='cyan') table.add_column('Значение', style='green') total_steps = len(step_end_times) total_time = 0 if step_start_times and step_end_times: for step, start in step_start_times.items(): if step in step_end_times: try: start_dt = datetime.fromisoformat(start) end_dt = datetime.fromisoformat(step_end_times[step]) total_time += (end_dt - start_dt).total_seconds() except Exception: pass avg_time_per_step = total_time / total_steps if total_steps > 0 else 0 table.add_row('Всего шагов', str(total_steps)) table.add_row('Общее время (сек)', f'{total_time:.1f}') table.add_row('Среднее время/шаг (сек)', f'{avg_time_per_step:.1f}') table.add_row('Всего записей', str(len(entries))) console.print(table) if phase_times: console.print('\n[bold]Время по фазам:[/]') phase_table = Table(show_header=True) phase_table.add_column('Фаза', style='yellow') phase_table.add_column('Количество', style='green') phase_table.add_column('Среднее время (сек)', style='cyan') for phase_name in sorted(phase_times.keys()): count = phase_entries[phase_name] avg = 0 if len(phase_times[phase_name]) > 1: try: times = [] for ts in phase_times[phase_name]: times.append(datetime.fromisoformat(ts)) if len(times) >= 2: avg = (max(times) - min(times)).total_seconds() / len(times) except Exception: pass phase_table.add_row(phase_name, str(count), f'{avg:.1f}') console.print(phase_table) if total_gates > 0: pass_rate = (total_gates - gate_failures) / total_gates * 100 if total_gates > 0 else 100 console.print('\n[bold]Гейты:[/]') gate_table = Table(show_header=False) gate_table.add_column('Показатель', style='cyan') gate_table.add_column('Значение', style='green') gate_table.add_row('Всего проверок', str(total_gates)) gate_table.add_row('Провалов', str(gate_failures)) gate_table.add_row('Проход率', f'{pass_rate:.1f}%') console.print(gate_table) if output == 'md': console.print('\n[bold]Markdown версия:[/]') console.print('```markdown') console.print('# Аналитический отчет') console.print(f'- **Всего шагов:** {total_steps}') console.print(f'- **Общее время:** {total_time:.1f} сек') console.print(f'- **Среднее время/шаг:** {avg_time_per_step:.1f} сек') console.print(f'- **Всего записей:** {len(entries)}') console.print('```') if output == 'csv': console.print('\ntimestamp,type,message,step,phase') for entry in entries: ts = entry.get('timestamp', '') etype = entry.get('type', '') msg = entry.get('message', '').replace(',', ';') step = entry.get('step', '') phase = entry.get('phase', '') console.print(f'{ts},{etype},{msg},{step},{phase}') log_action(f'ANALYTICS REPORT: output={output}, step={step_id or "all"}, phase={phase or "all"}', 'info') # ─── budget status ( Этап 4) ──────────────────────────────────────────────── @budget_app.command('status') def budget_status( step_id: Annotated[str | None, typer.Option('--step', help='ID шага для бюджета')] = None, threshold: Annotated[float, typer.Option('--threshold', help='Порог предупреждения в %')] = 80.0, ) -> None: """Статус бюджета AI-агентов ( Этап 4). Расход по шагу/этапу/проекту, лимиты и алерты. """ config_path = BASE / 'ump-ui-config.yaml' budget_limit = 100.0 current_spent = 0.0 if config_path.exists(): try: import yaml config = yaml.safe_load(config_path.read_text(encoding='utf-8')) budget_limit = float(config.get('analytics', {}).get('budget_limit_usd', 100.0)) except Exception: pass log_file = BASE / '_meta' / 'agent.log' step_costs = {} if log_file.exists(): try: with open(log_file, encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) step = entry.get('step') tokens = entry.get('tokens_used', 0) cost = entry.get('cost_usd', 0.0) if step: if step not in step_costs: step_costs[step] = {'tokens': 0, 'cost': 0.0} step_costs[step]['tokens'] += tokens step_costs[step]['cost'] += cost current_spent += cost except json.JSONDecodeError: continue except Exception: pass console.print(Panel('[bold cyan]Статус бюджета AI-агентов[/]', expand=False)) table = Table(show_header=True) table.add_column('Показатель', style='cyan') table.add_column('Значение', style='green') table.add_row('Лимит (USD)', f'${budget_limit:.2f}') table.add_row('Использовано (USD)', f'${current_spent:.2f}') usage_percent = (current_spent / budget_limit * 100) if budget_limit > 0 else 0 table.add_row('Использование', f'{usage_percent:.1f}%') if usage_percent >= threshold: table.add_row('Статус', '[yellow]⚠️ 接近 лимита[/]') else: table.add_row('Статус', '[green]✅ В норме[/]') console.print(table) if step_costs: console.print('\n[bold]Расход по шагам:[/]') step_table = Table(show_header=True) step_table.add_column('Шаг', style='yellow') step_table.add_column('Токены', style='green') step_table.add_column('Стоимость (USD)', style='cyan') sorted_steps = sorted(step_costs.items(), key=lambda x: x[1]['cost'], reverse=True) for step, costs in sorted_steps: if step_id and step != step_id: continue step_table.add_row(step, str(costs['tokens']), f'${costs["cost"]:.4f}') console.print(step_table) if usage_percent >= threshold: console.print(f'\n[yellow]⚠️ Предупреждение: бюджет {usage_percent:.1f}% использован[/]') log_action(f'BUDGET STATUS: used=${current_spent:.2f}, limit=${budget_limit:.2f}, step={step_id or "all"}', 'info') # ─── Точка входа ──────────────────────────────────────────────────────────── if __name__ == '__main__': app()