/
ump-team
/
ump-infra
Обзор
Документация
Войти
/
ump-team
/
ump-infra
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev/test
scripts/run_phase.py
1 626 строк
90 KB
Dmitry Kochenov
v0.0.4: аудит фаза 8 — покрытие CLI-модулей, рефакторинг run_phase.main(), P2-bug fix, MCP/A2A/OTel integration
19 июл 2026, 20:49
19 июл 2026, 20:49
eacfd60
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Универсальный исполнитель фаз шага. ЗАМЕЩАЕТ субагентов для механических фаз. Агент вызывает этот скрипт, а не пытается прочитать 43KB промпт в контекст субагента. Фазы, которые скрипт выполняет ПОЛНОСТЬЮ (без участия агента): PREFLIGHT — создать ветку, проверить контекст, обновить STEP_STATE TESTING — запустить verify, записать результаты в docs_target PHASE2B — проверить сохраняемые секции FINALIZATION — вызвать finalize_step.py Фазы, где скрипт делает ПОДГОТОВКУ, а агент — ТВОРЧЕСКУЮ работу: PHASE1 — скрипт создаёт шаблон docs_target, агент заполняет секции IMPLEMENTATION — скрипт подготавливает окружение, агент пишет код PHASE2A — скрипт отмечает секции для переписывания, агент переписывает АВТО-ЛОГИРОВАНИЕ: все действия автоматически пишутся в _meta/agent.log Запуск: uv run python scripts/run_phase.py <step_id> <phase> uv run python scripts/run_phase.py 1.0 PREFLIGHT uv run python scripts/run_phase.py 1.0 PHASE1 uv run python scripts/run_phase.py 1.0 TESTING Exit codes: 0 — фаза выполнена 1 — ошибка 2 — шаг не найден 3 — precondition не пройден """ from __future__ import annotations import argparse import json import os import re import shlex import subprocess import sys import tempfile from datetime import datetime from pathlib import Path import yaml # Единая точка правды для BASE-резолвинга. try: from ump.paths import detect_base, resolve_infra_dir except ImportError: _here = Path(__file__).resolve().parent sys.path.insert(0, str(_here.parent)) from ump.paths import detect_base, resolve_infra_dir # type: ignore[no-redef] BASE = detect_base(__file__) INFRA_DIR = resolve_infra_dir(BASE) PLANS_DIR = BASE / 'plans' # Runtime state (progress, agent.log, snapshots) — ВСЕГДА в BASE/_meta/, # потому что это состояние шагов пользователя, не infra-константы. META_DIR = BASE / '_meta' # Infra-константы (rules-constants.yaml, immutable-snapshot.json, schema) — в INFRA_DIR/_meta/. INFRA_META_DIR = INFRA_DIR / '_meta' if (INFRA_DIR / '_meta').exists() else BASE / '_meta' ERRORS_FILE = META_DIR / 'errors.json' # Импорт agent_log — в submodule-layout лежит в INFRA_DIR/scripts/. _scripts_dir = INFRA_DIR / 'scripts' if (INFRA_DIR / 'scripts').exists() else BASE / 'scripts' sys.path.insert(0, str(_scripts_dir)) import contextlib from agent_log import add_entry # noqa: E402 # ============================================================================ # FIX-1.11: атомарная запись файла через temp + os.replace (POSIX) # ============================================================================ def atomic_write_text(path: Path, content: str) -> None: """Атомарная запись файла через temp + os.replace (POSIX). P1-15 fix: точечный except OSError вместо Exception — все ошибки здесь файловые (write/flush/fsync/replace). """ path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), prefix='.tmp-', suffix=path.suffix) try: with os.fdopen(fd, 'w', encoding='utf-8') as f: f.write(content) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path) except OSError: with contextlib.suppress(OSError): os.unlink(tmp_path) raise # ============================================================================ # ============================================================================ def save_error(step_id: str, phase: str, message: str, details: dict | None = None) -> None: """Сохранить последнюю ошибку шага в _meta/errors.json. Файл переживает потерю контекста. При следующем запуске фазы run_phase.py читает его и выводит пользователю контекст предыдущей ошибки. """ ERRORS_FILE.parent.mkdir(parents=True, exist_ok=True) data = {} if ERRORS_FILE.exists(): try: data = json.loads(ERRORS_FILE.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): # P1-15 fix: точечный except. data = {} data[step_id] = { 'step_id': step_id, 'phase': phase, 'message': message, 'details': details or {}, 'timestamp': datetime.now().isoformat(), 'attempts': data.get(step_id, {}).get('attempts', 0) + 1, } ERRORS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') def load_last_error(step_id: str) -> dict | None: """Прочитать последнюю ошибку шага из _meta/errors.json.""" if not ERRORS_FILE.exists(): return None try: data = json.loads(ERRORS_FILE.read_text(encoding='utf-8')) return data.get(step_id) except (OSError, json.JSONDecodeError): # P1-15 fix: точечный except — OSError для файла, JSONDecodeError для парсинга. return None def clear_error(step_id: str) -> None: """Очистить ошибку шага после успешного выполнения фазы.""" if not ERRORS_FILE.exists(): return try: data = json.loads(ERRORS_FILE.read_text(encoding='utf-8')) if step_id in data: del data[step_id] ERRORS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') except (OSError, json.JSONDecodeError): # P1-15 fix: точечный except вместо Exception. pass def get_completed_steps() -> list[str]: """Прочитать STEP_STATE.md, вернуть список ID завершённых шагов. Шаг считается завершённым, если он присутствует в таблице COMPLETED_STEPS. """ step_state = BASE / 'STEP_STATE.md' if not step_state.exists(): return [] content = step_state.read_text(encoding='utf-8') # Найти секцию COMPLETED_STEPS m = re.search(r'## COMPLETED_STEPS\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if not m: return [] section = m.group(1) # Извлечь первый столбец каждой строки таблицы (ID шага) steps = [] for line in section.split('\n'): line = line.strip() if not line.startswith('|'): continue # Пропустить заголовок и разделитель if 'Шаг' in line and 'Название' in line: continue if re.match(r'^\|[\s\-:|]+\|$', line): continue cols = [c.strip() for c in line.split('|')] # cols[0] пустой (до первой |), cols[1] = ID if len(cols) >= 2 and cols[1]: steps.append(cols[1]) return steps def find_next_pending_step(after_step_id: str) -> tuple[str, str] | None: """Найти следующий незавершённый шаг после данного. Возвращает (step_id, title) или None, если все шаги завершены. Используется для подсказки пользователю, что делать дальше. """ completed = set(get_completed_steps()) # Собрать все шаги в порядке их следования в YAML all_steps: list[tuple[str, str]] = [] for yfile in sorted(PLANS_DIR.glob('*.yaml')): if yfile.name.startswith('99'): continue try: data = yaml.safe_load(yfile.read_text(encoding='utf-8')) except (OSError, yaml.YAMLError): # P1-15 fix: точечный except — OSError для файла, YAMLError для парсинга. continue for step in data.get('steps', []): sid = step.get('id', '') title = step.get('title', '') if sid: all_steps.append((sid, title)) # Найти after_step_id и вернуть следующий незавершённый try: idx = next(i for i, (s, _) in enumerate(all_steps) if s == after_step_id) except StopIteration: return None for sid, title in all_steps[idx + 1:]: if sid not in completed: return sid, title return None def check_step_already_completed(step_id: str, phase: str, force: bool) -> int: """Проверить, завершён ли уже шаг. Если шаг уже в COMPLETED_STEPS — вывести подсказку и выйти. Если --force — пропустить проверку (пользователь явно хочет переписать шаг). Returns: 0 — можно продолжать (шаг не завершён ИЛИ force=True) 8 — шаг уже завершён, выход с подсказкой """ if force: print(f'\n⚠️ --force: принудительный перезапуск шага {step_id} (фаза {phase})') print(' Шаг будет переписан. Это разрушительная операция.\n') return 0 completed = get_completed_steps() if step_id not in completed: return 0 # Шаг уже завершён — вывести подсказку print(f'\n{"=" * 70}') print(f'ℹ️ ШАГ {step_id} УЖЕ ЗАВЕРШЁН') print(f'{"=" * 70}') print(f'\nШаг {step_id} присутствует в STEP_STATE.md → COMPLETED_STEPS.') print('Это означает, что он был финализирован ранее (squash-merge в main).') print('\nВозможные причины, почему вы пытаетесь запустить его снова:') print(' 1. Вы случайно перепутали номер шага') print(' 2. Вы хотите переписать шаг принудительно') print(' 3. Шаг был завершён некорректно и нужно его переделать') # Подсказать следующий шаг nxt = find_next_pending_step(step_id) if nxt: next_id, next_title = nxt print(f'\n👉 СЛЕДУЮЩИЙ НЕЗАВЕРШЁННЫЙ ШАГ: {next_id} — {next_title}') print(f' Команда: uv run python3 scripts/run_phase.py {next_id} PREFLIGHT') else: print(f'\n✅ Все шаги после {step_id} уже завершены.') print(' Возможно, проект полностью выполнен.') print(f'\nЕСЛИ ВЫ ДЕЙСТВИТЕЛЬНО ХОТИТЕ ПЕРЕЗАПУСАТЬ ШАГ {step_id}:') print(f' uv run python3 scripts/run_phase.py {step_id} {phase} --force') print('\n⚠️ --force удалит progress-файл шага и запустит фазу заново.') print(' Для PREFLIGHT с --force также удаляется feature-ветка (если есть).') print(f'{"=" * 70}\n') return 8 def find_step(step_id: str) -> dict | None: """Найти шаг в YAML.""" for yfile in sorted(PLANS_DIR.glob('*.yaml')): if yfile.name.startswith('99'): continue try: data = yaml.safe_load(yfile.read_text(encoding='utf-8')) except (OSError, yaml.YAMLError): # P1-15 fix: точечный except — OSError для файла, YAMLError для парсинга. continue for step in data.get('steps', []): if step.get('id') == step_id: return step return None def run(cmd: str | list[str], check: bool = True) -> subprocess.CompletedProcess: """Запустить команду. P0-7 fix: ранее использовался subprocess.run(cmd, shell=True, ...), что позволяло инъекции через интерполируемые переменные (step_branch, verify_cmd, и т.д.). Теперь — list-form через shlex.split() (для str) или прямой list (для list[str]). Без shell=True: команды с `2>/dev/null`, `||`, `|`, `&&`, `;`, `test ...` не работают как раньше. callers должны переписать на Python-логику или list-form без shell-конструктов. """ args = shlex.split(cmd) if isinstance(cmd, str) else cmd return subprocess.run(args, capture_output=True, text=True, cwd=BASE, timeout=120) def run_list(args: list[str], check: bool = True, cwd: Path | None = None) -> subprocess.CompletedProcess: """FIX-1.1: Запустить команду в list form (без shell=True, безопасно от инъекций).""" return subprocess.run(args, capture_output=True, text=True, cwd=cwd or BASE, timeout=120) def log(msg: str, step: str, entry_type: str = 'action', details: dict | None = None): """Авто-логирование в agent.log + errors.log для ошибок.""" print(f' [{entry_type}] {msg}') add_entry(msg, entry_type, step=step, details=details) # Дублировать ошибки в отдельный файл для отладки if entry_type == 'error': errors_log = META_DIR / 'errors.log' errors_log.parent.mkdir(parents=True, exist_ok=True) from datetime import datetime ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') with open(errors_log, 'a', encoding='utf-8') as f: f.write(f'{ts} [{step}] {msg}\n') if details: for k, v in details.items(): f.write(f' {k}: {v}\n') # FIX-1.9: активировать loop detector try: # : использовать sys.executable вместо 'python3', чтобы # гарантированно наследовать тот же интерпретатор (uv-venv). Прямой # вызов 'python3' в среде без активного venv падал бы с ImportError на # PyYAML и других deps. AGENTS.md запрещает вызов python3 scripts/... # напрямую для агента, но внутри скриптов оркестрации это необходимо # и безопасно — мы НЕ меняли правило для пользователя. subprocess.run([sys.executable, str(_scripts_dir / 'orchestrate_step.py'), step, '--record-action', f'{entry_type}:{msg[:80]}'], capture_output=True, text=True, cwd=BASE, timeout=5) except Exception: pass def set_phase(step_id: str, phase: str, status: str) -> bool: """Обновить прогресс-файл через orchestrate_step.py (FIX-1.5: проверка return code).""" # : sys.executable — см. комментарий в log() r = subprocess.run([sys.executable, str(_scripts_dir / 'orchestrate_step.py'), step_id, '--set-phase', phase, status], capture_output=True, text=True, cwd=BASE) if r.returncode != 0: log(f'set_phase FAILED (exit {r.returncode}): {r.stderr[:300]}', step_id, 'error') return False return True def sed_step_state(old: str, new: str): """Атомарно обновить CURRENT_PHASE в STEP_STATE.md. BUG-FIX (v25.2): предыдущая реализация через shell sed -i была хрупкой: - не работала при Windows line endings (\\r\\n) - не сообщала об ошибке если pattern не найден - могла оставить файл в неконсистентном состоянии при краше Новая реализация через Python regex + atomic_write_text. : если pattern 'old' не найден — force-set 'new' всё равно. Это предотвращает state desync когда предыдущая фаза не установила ожидаемое значение (например, TESTING вызывается с CURRENT_PHASE=phase1 вместо testing). Раньше функция просто предупреждала и НЕ обновляла, что приводило к рассинхронизации STEP_STATE.md (машина B) и step-progress.yaml (машина A) — см. BUG-2 в задаче V27-SCRIPTS. """ step_state = BASE / 'STEP_STATE.md' if not step_state.exists(): return content = step_state.read_text(encoding='utf-8') # BUG-FIX: убрать возможные \r (Windows line endings) перед match # и нормализовать обратно после normalized = content.replace('\r\n', '\n').replace('\r', '\n') # Заменить ALL вхождения (включая возможные дубли) — только точное совпадение значения pattern = re.compile(r'(^CURRENT_PHASE:\s*)' + re.escape(old) + r'(\s*$)', re.MULTILINE) new_content, n = pattern.subn(r'\g<1>' + new + r'\g<2>', normalized) if n == 0: # : pattern не найден — проверить, не равно ли уже текущее значение new current_match = re.search(r'^CURRENT_PHASE:\s*(\S+)', normalized, re.MULTILINE) current_value = current_match.group(1) if current_match else '' if current_value == new: # Уже правильное значение — тихо пропускаем, без warning return # : pattern не найден и значение другое — force-set new force_pattern = re.compile(r'(^CURRENT_PHASE:\s*).+?(\s*$)', re.MULTILINE) new_content, n2 = force_pattern.subn(r'\g<1>' + new + r'\g<2>', normalized) if n2 > 0: if new_content != content: atomic_write_text(step_state, new_content) log(f'sed_step_state: CURRENT_PHASE {current_value} → {new} (force-set)', '', 'action') else: # CURRENT_PHASE строки вообще нет — добавить после CURRENT_STEP lines = normalized.split('\n') new_lines = [] inserted = False for line in lines: new_lines.append(line) if not inserted and line.startswith('CURRENT_STEP:'): new_lines.append(f'CURRENT_PHASE: {new}') inserted = True if not inserted: # Не нашли CURRENT_STEP — добавить в начало new_lines.insert(0, f'CURRENT_PHASE: {new}') new_content = '\n'.join(new_lines) if new_content != content: atomic_write_text(step_state, new_content) log(f'sed_step_state: CURRENT_PHASE строка не найдена — добавлена {new}', '', 'warning') return if new_content != content: atomic_write_text(step_state, new_content) log(f'sed_step_state: CURRENT_PHASE {old} → {new} ({n} замен)', '', 'action') # ============================================================================ # [P0, NEW-P1-7] — Детерминированный пост-чекер для субагентов # ============================================================================ def verify_subagent(prompt_id: str, step_id: str) -> bool: """: вызвать детерминированный пост-чекер для субагента. Возвращает True ТОЛЬКО если скрипт существует и вернул exit 0. Возвращает False если скрипт отсутствует ИЛИ вернул non-zero. Предыдущая версия () возвращала True при отсутствии скрипта (graceful degradation) — это критическая дыра: субагент формально «PASS» без какой-либо проверки. закрывает её. """ verify_script = _scripts_dir / 'verify_subagent.py' if not verify_script.exists(): print(f'VERIFY_SUBAGENT FAIL: verify_subagent.py not found at {verify_script}', file=sys.stderr) return False try: r = subprocess.run( # : sys.executable [sys.executable, str(_scripts_dir / 'verify_subagent.py'), prompt_id, step_id], capture_output=True, text=True, cwd=BASE, timeout=60 ) except subprocess.TimeoutExpired: print(f'VERIFY_SUBAGENT FAIL: timeout (60s) for {prompt_id}', file=sys.stderr) return False print(r.stdout) if r.returncode != 0: print(f'VERIFY_SUBAGENT FAIL: {prompt_id} returned exit {r.returncode}', file=sys.stderr) if r.stderr: print(r.stderr[:500], file=sys.stderr) return False return True # ============================================================================ # ФАЗА: PREFLIGHT # ============================================================================ def phase_preflight(step: dict, step_id: str, force: bool = False) -> int: """Pre-flight: создать ветку, проверить контекст.""" step_branch = step.get('branch', f'feature/step-{step_id}') step.get('step_type', '') requires_approval = step.get('requires_approval', False) print(f'\n=== PREFLIGHT для шага {step_id} ===') if force: print(f'\n--force: очистка предыдущего состояния шага {step_id}') # Удалить feature-ветку, если существует # P0-7 fix: list-form без shell=True. `2>/dev/null` не нужно — capture_output подавляет stderr. r = run(['git', 'rev-parse', '--verify', step_branch], check=False) if r.returncode == 0: current = run(['git', 'branch', '--show-current']).stdout.strip() if current == step_branch: run(['git', 'checkout', 'main'], check=False) run(['git', 'branch', '-D', step_branch], check=False) log(f'--force: feature-ветка {step_branch} удалена', step_id, 'action') # Удалить progress-файл (: per-step файл) safe_step_id = step_id.replace('.', '-') progress_file = META_DIR / f'step-progress-{safe_step_id}.yaml' done_file = META_DIR / f'step-progress-{safe_step_id}.done.yaml' # Legacy единый файл — тоже удаляем, если остался со старых сессий legacy_progress_file = META_DIR / 'step-progress.yaml' if progress_file.exists(): progress_file.unlink() log(f'--force: {progress_file.name} удалён', step_id, 'action') if legacy_progress_file.exists(): legacy_progress_file.unlink() log('--force: _meta/step-progress.yaml (legacy) удалён', step_id, 'action') if done_file.exists(): done_file.unlink() log(f'--force: {done_file.name} удалён', step_id, 'action') # Оставлена очистка legacy ack-файла для миграции со старых версий. ack_file = META_DIR / f'step-{step_id}-PLAN.ack' if ack_file.exists(): ack_file.unlink() log('--force: legacy ack-файл удалён (v37 — ack-файл упразднён, TASK_LIST = report-only)', step_id, 'action') # Очистить ошибку clear_error(step_id) # 1. Проверить cwd log('Проверка cwd', step_id, 'action') # P0-7 fix: `test -f AGENTS.md && echo OK || echo FAIL` — shell-конструкция. # Заменено на Python os.path.exists. r_out = 'OK' if (BASE / 'AGENTS.md').exists() else 'FAIL' if 'FAIL' in r_out: log('ERROR: не в корне проекта (нет AGENTS.md)', step_id, 'error') return 1 log('cwd OK', step_id, 'test') # 2. Проверить git r = run(['git', 'rev-parse', '--is-inside-work-tree'], check=False) if r.stdout.strip() != 'true': log('Git не инициализирован', step_id, 'error') return 1 # 3. Проверить STEP_STATE.md для resume step_state = BASE / 'STEP_STATE.md' if step_state.exists(): content = step_state.read_text(encoding='utf-8') # Извлечь CURRENT_PHASE — только из начала строки (YAML block) # FIX-3.1:Specification Pattern — строгое matching с валидацией значений m = re.search(r'^CURRENT_PHASE:\s*(\S+)$', content, re.MULTILINE) if m: current_phase = m.group(1) # Валидация значения (разрешены все возможные фазы шага) # FIX-3.2: STEP_STATE.md использует lowercase для CURRENT_PHASE # Допустимые значения: awaiting-continue, pre-flight, phase1, implementation, # testing, phase2, finalization, failed (все в lowercase) if current_phase not in [ 'awaiting-continue', 'pre-flight', 'phase1', 'implementation', 'testing', 'phase2', 'finalization', 'failed' ]: log(f'Invalid CURRENT_PHASE value: {current_phase}', step_id, 'error') print(f'\n⚠️ INVALID STATE: CURRENT_PHASE={current_phase}') print(' Допустимые значения: awaiting-continue, pre-flight, phase1, implementation,') print(' testing, phase2, finalization, failed') print(' Запустите: uv run python3 scripts/run_phase.py <step> PREFLIGHT --force') return 1 if current_phase == 'awaiting-continue': log('CONTINUE-ГЕЙТ: awaiting-continue. Скажите "продолжить"', step_id, 'gate') print('\n⚠️ CONTINUE-ГЕЙТ: предыдущий шаг финализирован.') print(' Скажите "продолжить" для запуска этого шага.') return 1 log(f'Resume: CURRENT_PHASE={current_phase}', step_id, 'action') # 4. Approval gate if requires_approval: approval_reason = step.get('approval_reason', '') log(f'Approval gate: {approval_reason}', step_id, 'gate') print(f'\n⏸ ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ: {approval_reason}') print(' Скажите "подтверждаю" для продолжения.') return 1 # 5. Создать feature-ветку # P0-7 fix: list-form без shell=True. r = run(['git', 'branch', '--show-current']) current_branch = r.stdout.strip() if current_branch == step_branch: log(f'Уже на ветке {step_branch}', step_id, 'action') else: if current_branch != 'main': run(['git', 'checkout', 'main'], check=False) r = run(['git', 'checkout', '-b', step_branch], check=False) if r.returncode != 0: run(['git', 'checkout', step_branch], check=False) log(f'Ветка {step_branch} активна', step_id, 'action') # FIX-1.8: реальная проверка, что мы на feature-ветке current = subprocess.run(['git', 'branch', '--show-current'], capture_output=True, text=True, cwd=BASE).stdout.strip() if current != step_branch: log(f'ERROR: не удалось переключиться на {step_branch} (current={current})', step_id, 'error') return 1 # 6. STEP_STATE.md — verify или создать из template # : создавать из template для ВСЕХ шагов, не только 1.0. # Проблема: после PR merge на GitVerse, git checkout main может удалить # STEP_STATE.md (он в .gitignore / assume-unchanged). Шаг 1.1+ тоже # должен уметь пересоздавать STEP_STATE.md из template. if not step_state.exists(): template = BASE / 'STEP_STATE.template.md' if template.exists(): import shutil shutil.copy2(template, step_state) log('STEP_STATE.md создан из template (не существовал)', step_id, 'action') # Пометить как assume-unchanged, чтобы git не отслеживал изменения run(['git', 'update-index', '--assume-unchanged', 'STEP_STATE.md'], check=False) else: log('ERROR: STEP_STATE.md и STEP_STATE.template.md отсутствуют — архив повреждён', step_id, 'error') return 1 else: log('STEP_STATE.md присутствует', step_id, 'test') sed_step_state('awaiting-continue', 'pre-flight') sed_step_state('implementation', 'pre-flight') # FIX-1.10: Сохранить main_last_commit для возможного rollback main_last_commit = subprocess.run(['git', 'log', 'main', '--format=%H', '-1'], capture_output=True, text=True, cwd=BASE).stdout.strip() if main_last_commit: # Записать в _meta/step-progress-<step_id>.yaml (: per-step) try: # : sys.executable — см. комментарий в log() subprocess.run([sys.executable, str(_scripts_dir / 'orchestrate_step.py'), step_id, '--set-main-last-commit', main_last_commit], capture_output=True, text=True, cwd=BASE, timeout=5) except Exception: pass log(f'main_last_commit сохранён: {main_last_commit[:8]}', step_id, 'action') # 7. Обновить прогресс (FIX-1.5: проверять return code для PREFLIGHT) if not set_phase(step_id, 'PREFLIGHT', 'completed'): log('PREFLIGHT: set_phase(completed) failed — exiting', step_id, 'error') return 1 log('PREFLIGHT завершён', step_id, 'phase') print(f'\n✓ PREFLIGHT завершён. Ветка: {step_branch}') return 0 # ============================================================================ # ФАЗА: PHASE1 — подготовка шаблона, агент заполняет # ============================================================================ def phase_phase1(step: dict, step_id: str) -> int: """Phase 1: создать шаблон docs_target для заполнения агентом.""" docs_target = step.get('docs_target', '') step_title = step.get('title', '') verify_list = step.get('verify', ['']) verify_cmd = verify_list[0] if verify_list and isinstance(verify_list[0], str) else str(verify_list[0]) if verify_list else '' expected_result = step.get('expected_result', '') print(f'\n=== PHASE1 для шага {step_id} ===') if not docs_target: log('docs_target не указан', step_id, 'error') return 1 full = BASE / docs_target full.parent.mkdir(parents=True, exist_ok=True) if full.exists(): content = full.read_text(encoding='utf-8') step_type = step.get('step_type', '') if step_type in ('bootstrap', 'document'): # Для bootstrap — проверяем только что файл не пустой и есть H1 if content.strip() and content.startswith('# '): log(f'{docs_target} уже заполнен ({step_type}) — обновляю', step_id, 'action') else: log(f'{docs_target} существует, но пустой — пересоздаю', step_id, 'warning') full.unlink() elif '> Phase 1 — инструкция для AI-агента' in content: log(f'{docs_target} уже в Phase 1 — обновляю', step_id, 'action') elif '> Обучающая документация' in content: log(f'{docs_target} уже в Phase 2 — пропускаю', step_id, 'warning') # НЕ выходим с exit 0. Раньше (v29/v31) skip-ветка логировала ошибку, # но делала sys.exit(0) — формально PHASE1 = completed, но артефакта нет. # Теперь: если _validate_phase_completion падает (no Phase 1 marker) — # это BLOCKING error, exit 1. if not set_phase(step_id, 'PHASE1', 'completed'): log('PHASE1: set_phase(completed) FAILED в skip-ветке', step_id, 'error') log('docs_target в Phase 2, но Phase 1 маркер отсутствует — это нарушение 02-documentation-rules.md', step_id, 'error') log(f'Требуется: в {docs_target} должен быть маркер "> Phase 1 — инструкция для AI-агента" ДО Phase 2', step_id, 'error') log('Решение: откатить docs_target к Phase 1 (убрать "> Обучающая документация"), либо вручную добавить Phase 1 маркер и переписать Phase 2 заново', step_id, 'error') print('\n❌ PHASE1 BLOCKED (v32 Fix B): docs_target в Phase 2, но Phase 1 маркер отсутствует.') print(f' Файл: {docs_target}') print(' Нарушение: 02-documentation-rules.md — Phase 1 обязательна перед Phase 2.') print(' Решение:') print(f' 1. Откатить docs_target: git checkout HEAD -- {docs_target}') print(f' 2. Перезапустить PHASE1: make step-run STEP={step_id} PHASE=PHASE1') print(' 3. Если файл новый — удалить и пересоздать через PHASE1') sys.exit(1) clear_error(step_id) log('Фаза PHASE1 завершена (skip — файл уже в Phase 2, маркер Phase 1 был ранее)', step_id, 'phase') print('\n✓ PHASE1: пропущен (файл уже в Phase 2, Phase 1 маркер валиден)') # FIX-1.3: обойти main()-лог «Шаблон Phase 1 создан» — он был бы ложью здесь sys.exit(0) # Создать шаблон now = datetime.now().strftime('%Y-%m-%d %H:%M') step_type = step.get('step_type', '') # Для bootstrap/document — упрощённый шаблон без Phase 2 маркера if step_type in ('bootstrap', 'document'): template = f"""# Шаг {step_id} — {step_title} > Verify-only шаг (step_type={step_type}). Проверяет, что файлы/инструменты на месте. > См. .agent/cards/bootstrap.card.md — упрощённая карточка. ## Цель {expected_result} ## VERIFIED_CHECKLIST ### Файлы - [ ] Файлы шага проверены (см. verify) ### Тесты - [ ] `{verify_cmd}` — PASSED ### Документация - [ ] Phase 1 написана (этот файл) - [ ] Sidebar в docs/.vitepress/config.mts обновлён (если нужно) ## Верификация ```bash {verify_cmd} ``` ## История действий агента | Время | Действие | Результат | |------|---------|-----------| | {now} | Старт Phase 1: run_phase.py | начало | | {now} | Шаблон Phase 1 создан (bootstrap) | упрощённый | | {now} | Phase 1 завершена | готов к TESTING | ## Реестр файлов шага ### Созданные файлы | Файл | Описание | |------|----------| | (нет — {step_type} не создаёт файлы) | | ### Изменённые файлы | Файл | Изменение | |------|-----------| | (нет — {step_type} не меняет файлы) | | ### Удалённые файлы | Файл | Причина | |------|---------| | (нет) | | ## Реестр тестов шага | Время | Команда | Результат | |------|---------|-----------| """ full.write_text(template, encoding='utf-8') log(f'Шаблон Phase 1 создан ({step_type}, упрощённый): {docs_target}', step_id, 'action', {'lines': len(template.split('\n'))}) sed_step_state('pre-flight', 'phase1') # FIX-1.3: НЕ помечать PHASE1 completed автоматически — только in_progress if not set_phase(step_id, 'PHASE1', 'in_progress'): log('PHASE1: set_phase(in_progress) failed', step_id, 'error') print(f'\n✓ PHASE1: упрощённый шаблон ({step_type}) создан в {docs_target}') print(' Агент должен:') print(' 1. Закрыть ВСЕ пункты VERIFIED_CHECKLIST [x] (после проверки)') print(f' 2. После заполнения запустить: uv run python3 scripts/orchestrate_step.py {step_id} --set-phase PHASE1 completed') print(f' 3. Затем: uv run python3 scripts/run_phase.py {step_id} TESTING') print(f' НЕ нужно: Phase 2 маркер, 11 секций (для {step_type})') return 0 # Для implementation-шагов — полный шаблон с Phase 1 маркером template = f"""# Шаг {step_id} — {step_title} > Phase 1 — инструкция для AI-агента. После завершения шага этот файл > будет переписан в Phase 2 (обучающий формат для человека) промптом > .agent/prompts/docs-rewriter-human.md ## Цель {expected_result} ## Контекст для обязательного чтения Агент обязан перейти по каждой ссылке и прочитать файл полностью: 1. [00-manifest.md](../../00-manifest.md) — архитектура проекта 2. [.agent/rules/01-step-lifecycle.md](../../.agent/rules/01-step-lifecycle.md) — цикл жизни шага ## План 1. Проверить предусловия шага 2. Выполнить основную задачу шага 3. Запустить верификацию ## Ожидаемый результат {expected_result} ## VERIFIED_CHECKLIST ### Файлы - [ ] Файлы шага созданы ### Тесты - [ ] `{verify_cmd}` — PASSED ### Документация - [ ] Phase 1 написана (этот файл) - [ ] Sidebar в docs/.vitepress/config.mts обновлён ## Верификация ```bash {verify_cmd} ``` ## История действий агента | Время | Действие | Результат | |------|---------|-----------| | {now} | Старт Phase 1: run_phase.py | начало | | {now} | Шаблон Phase 1 создан | 9 секций | | {now} | Контекст загружен | 00-manifest.md, .agent/rules/ | | {now} | План шага сформирован | из YAML plans/ | | {now} | Phase 1 завершена | готов к IMPLEMENTATION | ## Реестр файлов шага ### Созданные файлы | Файл | Описание | |------|----------| ### Изменённые файлы | Файл | Изменение | |------|-----------| ### Удалённые файлы | Файл | Причина | |------|---------| ## Реестр тестов шага | Время | Команда | Результат | |------|---------|-----------| """ full.write_text(template, encoding='utf-8') log(f'Шаблон Phase 1 создан: {docs_target}', step_id, 'action', {'lines': len(template.split('\n'))}) # Обновить STEP_STATE sed_step_state('pre-flight', 'phase1') # FIX-1.3: НЕ помечать PHASE1 completed автоматически — только in_progress if not set_phase(step_id, 'PHASE1', 'in_progress'): log('PHASE1: set_phase(in_progress) failed', step_id, 'error') # ============================================================ # ============================================================ # Сразу после создания шаблона проверить, что маркер Phase 1 на месте. # Если нет — это баг в шаблоне (например, агент перезаписал файл). # Не даём агенту перейти к TESTING без валидного Phase 1 маркера. if step_type not in ('bootstrap', 'document'): try: written = full.read_text(encoding='utf-8') if '> Phase 1 — инструкция для AI-агента' not in written: log('PHASE1 (v32 Fix D): созданный шаблон НЕ содержит Phase 1 маркер!', step_id, 'error') log(f'Файл: {docs_target}', step_id, 'error') log(f'Это нарушение 02-documentation-rules.md — Phase 1 маркер обязателен для step_type={step_type}', step_id, 'error') print('\n❌ PHASE1 BLOCKED (v32 Fix D): шаблон создан, но Phase 1 маркер отсутствует.', file=sys.stderr) print(f' Файл: {docs_target}', file=sys.stderr) print(' Ожидаемый маркер: "> Phase 1 — инструкция для AI-агента"', file=sys.stderr) print(' Решение: проверьте шаблон в run_phase.py:phase_phase1() — возможно, он повреждён.', file=sys.stderr) sys.exit(1) else: log('PHASE1 (v32 Fix D): Phase 1 маркер на месте — OK', step_id, 'test') except Exception as e: log(f'PHASE1 (v32 Fix D): post-write check failed: {e}', step_id, 'warning') print(f'\n✓ PHASE1: шаблон создан в {docs_target}') print(' Агент должен заполнить секции: Цель, Контекст, План, VERIFIED_CHECKLIST') print(f' После заполнения: uv run python3 scripts/orchestrate_step.py {step_id} --set-phase PHASE1 completed') # [P1, NEW-P1-7 complete] — агент запускает verify-фазу через run_phase.py print('\n=== Творческая фаза завершена. Запусти верификатор: ===') print(f'uv run python3 scripts/run_phase.py {step_id} PHASE1_VERIFY') print('Если FAIL — исправь docs_target и перезапусти.') print('Только после PASS — переход к следующей фазе.') return 0 # ============================================================================ # ФАЗА: TESTING — запустить verify, записать результаты # ============================================================================ def phase_testing(step: dict, step_id: str) -> int: """Testing: запустить verify-команду, записать в docs_target, закрыть VERIFIED_CHECKLIST.""" # : установить CURRENT_PHASE: testing в начале фазы # Раньше этот переход пропускался, что приводило к state desync: # TESTING вызывался с CURRENT_PHASE=phase1 (или implementation), # а sed_step_state('testing', 'phase2') в конце FAIL-ила. sed_step_state('phase1', 'testing') # если был phase1 sed_step_state('implementation', 'testing') # если был implementation verify_list = step.get('verify', ['']) verify_cmd = verify_list[0] if verify_list and isinstance(verify_list[0], str) else str(verify_list[0]) if verify_list else '' docs_target = step.get('docs_target', '') print(f'\n=== TESTING для шага {step_id} ===') if not verify_cmd: log('verify не указан — пропускаю', step_id, 'warning') # FIX-1.5: проверять return value (TESTING — не критично, не exit 1) if not set_phase(step_id, 'TESTING', 'completed'): log('TESTING: set_phase(completed) failed (verify не задан)', step_id, 'error') return 0 # Запустить verify # P0-7 fix: verify_cmd из YAML может содержать shell-метасимволы — # если есть, отказываемся выполнять (то же поведение, что и в # verify_step_completion.py::check_verify_command). log(f'Запуск verify: {verify_cmd}', step_id, 'test') _shell_metachars = ('&&', '||', ';', '|', '>', '<', '$(', '`') if any(mc in verify_cmd for mc in _shell_metachars): log( f'verify FAIL: команда содержит shell-метасимволы: {verify_cmd!r}. ' f'Перепишите verify в YAML как list-form: verify: [["cmd", "arg1"]].', step_id, 'error', ) return 1 # shlex.split парсит verify_cmd в argv, без shell. r = run(shlex.split(verify_cmd), check=False) now = datetime.now().strftime('%Y-%m-%d %H:%M') if r.returncode == 0: result_str = 'PASS' log('verify PASS', step_id, 'test') else: result_str = f'FAIL (exit {r.returncode})' log(f'verify FAIL: {r.stderr[:200]}', step_id, 'error') # Записать в docs_target if docs_target and (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') test_row = f'| {now} | `{verify_cmd}` | {result_str} |\n' # Добавить строку в Реестр тестов content = re.sub( r'(\| Время \| Команда \| Результат \|\n\|------\|---------\|-----------\|\n)', rf'\1{test_row}', content, count=1 ) # Добавить запись в Историю действий (FIX-1.7: гибкий разделитель таблицы) history_row = f'| {now} | Запуск verify | {result_str} |\n' content = re.sub( r'(\| Время \| Действие \| Результат \|\n\|[\s\-|]+\|[\s\-|]+\|[\s\-|]+\|\n)', rf'\1{history_row}', content, count=1 ) # FIX-1.6: закрыть ТОЛЬКО пункт, явно соответствующий verify-команде (не blanket-regex) if r.returncode == 0: verify_pattern = re.compile( r'^(- \[ \] )(`?' + re.escape(verify_cmd) + r'`?)(.*)$', re.MULTILINE ) new_content, n = verify_pattern.subn(r'- [x] \2\3', content) if n > 0: content = new_content log(f'VERIFIED_CHECKLIST: закрыт пункт для verify-команды ({n} совпадение)', step_id, 'test') else: log('VERIFIED_CHECKLIST: пункт для verify-команды не найден — агент закроет вручную', step_id, 'warning') (BASE / docs_target).write_text(content, encoding='utf-8') log(f'Результат записан в {docs_target}', step_id, 'action') # [P1, NEW-V25-3] — verify_subagent ДО set_phase(completed). # Семантически консистентно с PHASE1_VERIFY / IMPLEMENTATION_VERIFY / TESTING_VERIFY / # PHASE2A_VERIFY (см. main(): verify_subagent вызывается ДО set_phase(completed)). # Если verify_subagent FAIL — НЕ отмечать TESTING completed, exit 1. # 1. verify_cmd FAIL → mark failed, return 1 (verify_subagent не нужен — # тесты уже упали, Реестр тестов будет содержать FAIL-запись). if r.returncode != 0: sed_step_state('testing', 'phase2') if not set_phase(step_id, 'TESTING', 'failed'): log('TESTING: set_phase(failed) failed', step_id, 'error') print(f'\n✗ TESTING FAIL: verify вернул exit {r.returncode}') print(f' Команда: {verify_cmd}') print(f' stderr: {r.stderr[:300]}') return 1 # 2. verify_cmd PASS → запустить verify_subagent ДО set_phase(completed). # [P1, NEW-V24-2] — детерминированный пост-чекер test-runner. if not verify_subagent('test-runner', step_id): log('verify_subagent test-runner FAIL — проверь docs_target → Реестр тестов', step_id, 'error') # : НЕ отмечать completed. Отметить failed и exit 1 — # тесты запущены, но Реестр тестов / VERIFIED_CHECKLIST некорректен. if not set_phase(step_id, 'TESTING', 'failed'): log('TESTING: set_phase(failed) failed', step_id, 'error') print('\n❌ TESTING verify_subagent FAIL. Проверь секцию "Реестр тестов" в docs_target.', file=sys.stderr) print(' Исправь docs_target и перезапусти:', file=sys.stderr) print(f' uv run python3 scripts/run_phase.py {step_id} TESTING_VERIFY', file=sys.stderr) return 1 # 3. verify_subagent PASS → отметить TESTING completed. sed_step_state('testing', 'phase2') if not set_phase(step_id, 'TESTING', 'completed'): log('TESTING: set_phase(completed) failed', step_id, 'error') print('\n✓ TESTING: verify PASS') # [P1, NEW-P1-7 complete] — для TESTING фазы тоже выводим инструкцию # (тест-раннер уже отработал inline, но пользователь может перезапустить externally) print('\n=== Тестирование завершено. Запусти верификатор: ===') print(f'uv run python3 scripts/run_phase.py {step_id} TESTING_VERIFY') return 0 # ============================================================================ # ФАЗА: PHASE2B — проверить сохраняемые секции # ============================================================================ def phase_phase2b(step: dict, step_id: str) -> int: """Phase 2B: проверить, что сохраняемые секции не потеряны.""" docs_target = step.get('docs_target', '') print(f'\n=== PHASE2B для шага {step_id} ===') if not docs_target or not (BASE / docs_target).exists(): log(f'docs_target не существует: {docs_target}', step_id, 'error') return 1 content = (BASE / docs_target).read_text(encoding='utf-8') checks = [] # 1. История действий ≥ 5 (или ≥ 3 для verify-only шагов) # : bootstrap — упрощённая проверка (минимум 3 записи), # консистентно с finalize_step.py и verify_step_completion.py. step_type = step.get('step_type', '') min_history = 3 if step_type in ('document', 'user_action', 'bootstrap') else 5 match = re.search(r'## История действий агента\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if match: rows = re.findall(r'^\|.*\|$', match.group(1), re.MULTILINE) data_rows = [r for r in rows if not re.match(r'^\|[\s\-:|]+\|$', r) and 'Время' not in r] ok = len(data_rows) >= min_history checks.append((f'История ≥ {min_history}', ok, f'{len(data_rows)} записей')) else: checks.append(('История действий', False, 'секция не найдена')) # 2. Реестр файлов — 3 подсекции for sub in ['### Созданные файлы', '### Изменённые файлы', '### Удалённые файлы']: ok = sub in content checks.append((sub, ok, 'OK' if ok else 'отсутствует')) # 3. Реестр тестов — есть PASS match = re.search(r'## Реестр тестов шага\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if match: has_pass = 'PASS' in match.group(1) checks.append(('Реестр тестов has PASS', has_pass, 'OK' if has_pass else 'нет PASS')) else: checks.append(('Реестр тестов', False, 'секция не найдена')) # 4. VERIFIED_CHECKLIST — все [x] match = re.search(r'## VERIFIED_CHECKLIST\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if match: open_items = re.findall(r'^- \[ \]\s+(.+)$', match.group(1), re.MULTILINE) ok = len(open_items) == 0 checks.append(('VERIFIED_CHECKLIST закрыт', ok, f'{len(open_items)} незакрытых' if open_items else 'все [x]')) else: checks.append(('VERIFIED_CHECKLIST', False, 'секция не найдена')) # Вывод all_ok = True for name, ok, detail in checks: icon = '✓' if ok else '✗' print(f' [{icon}] {name}: {detail}') log(f'Check "{name}": {"PASS" if ok else "FAIL"} — {detail}', step_id, 'test' if ok else 'error') if not ok: all_ok = False if all_ok: # FIX-1.5: проверять return value (PHASE2B — не критично, не exit 1) if not set_phase(step_id, 'PHASE2B', 'completed'): log('PHASE2B: set_phase(completed) failed', step_id, 'error') print('\n✓ PHASE2B: все сохраняемые секции intact') return 0 else: if not set_phase(step_id, 'PHASE2B', 'failed'): log('PHASE2B: set_phase(failed) failed', step_id, 'error') print('\n✗ PHASE2B: есть проблемы — исправьте и повторите') save_error(step_id, 'PHASE2B', 'Сохраняемые секции повреждены', {'failed_checks': [n for n, ok, _ in checks if not ok]}) return 1 # ============================================================================ # ФАЗА: FINALIZATION — вызвать finalize_step.py # ============================================================================ def phase_finalization(step: dict, step_id: str) -> int: """Finalization: вызвать finalize_step.py + verify_subagent ().""" # : установить CURRENT_PHASE: finalization в начале фазы # Раньше фаза не делала sed_step_state, и STEP_STATE.md оставался в phase2, # тогда как progress.yaml уже показывал FINALIZATION → check_state_sync # WARN + set_phase блокировался на total_attempts>=5. sed_step_state('phase2', 'finalization') sed_step_state('testing', 'finalization') # fallback если phase2 не был установлен print(f'\n=== FINALIZATION для шага {step_id} ===') log('Запуск finalize_step.py', step_id, 'action') # : sys.executable # P0-7 fix: list-form без shell=True. shlex.quote не нужен — list-form # сам экранирует каждый argv-элемент. finalizer = str(_scripts_dir / 'finalize_step.py') r = run([sys.executable, finalizer, step_id], check=False) if r.returncode != 0: log(f'FINALIZATION FAIL (exit {r.returncode})', step_id, 'error') print(r.stdout) print(r.stderr, file=sys.stderr) save_error(step_id, 'FINALIZATION', f'finalize_step.py exit {r.returncode}') return 1 # [P1, NEW-P1-12] — post-finalization verify_subagent FAIL — не блокировать, # но создать recovery-инструкцию. Шаг уже физически финализирован (git commit, push, # STEP_STATE обновлён, ветка удалена). Возврат return 1 не откатывает изменения, # recovery path тупиковый — поэтому логируем WARN и продолжаем. # Предыдущая версия () делала return 1 — это блокировало следующий шаг, # но не откатывало git-изменения, оставляя систему в рассинхронизированном состоянии. if not verify_subagent('step-finalizer', step_id): log('post-finalization verify_subagent FAIL — шаг финализирован, но проверки не прошли', step_id, 'warning') print('\n⚠️ POST-FINALIZATION WARNING:', file=sys.stderr) print(f'Шаг {step_id} физически финализирован (git commit, push, STEP_STATE обновлён).', file=sys.stderr) print('Но verify_subagent обнаружил проблемы. Recovery:', file=sys.stderr) print(' 1. Проверьте _meta/agent.log на детали', file=sys.stderr) print(f' 2. Если критично — запустите step-rollback.md для шага {step_id}', file=sys.stderr) print(' 3. Или создайте новый шаг (1.Xa) для исправления', file=sys.stderr) # Сохранить warning в errors.json (не error) try: errors_file = META_DIR / 'errors.json' errors = json.loads(errors_file.read_text(encoding='utf-8')) if errors_file.exists() else {} errors[step_id] = errors.get(step_id, {}) errors[step_id]['post_finalization_warning'] = True errors[step_id]['timestamp'] = datetime.now().isoformat() atomic_write_text(errors_file, json.dumps(errors, ensure_ascii=False, indent=2)) except Exception: pass # НЕ return 1 — шаг уже финализирован # [P0]: НЕ вызывать set_phase(FINALIZATION, completed) после finalize_step.py. # Проблема: finalize_step.py переименовывает _meta/step-progress-<step_id>.yaml # в .done.yaml в конце своей работы. set_phase после этого FAIL с # "progress file does not exist" — шаг уже финализирован, но run_phase # reports FAIL. # Решение: finalize_step.py уже делает всё (git commit, STEP_STATE update, # progress rename). set_phase здесь избыточен. log('FINALIZATION завершена (finalize_step.py уже обновил progress и STEP_STATE)', step_id, 'phase') print(r.stdout) return 0 # ============================================================================ # MAIN # ============================================================================ def _run_verify_phase(step_id: str, phase: str) -> int: """Обработать *_VERIFY фазу. P2-refactor (v0.0.4 audit): вынесено из main() для снижения cyclomatic complexity. Ранее main() содержал 7 elif-веток для verify-фаз (каждая ~15 строк с дублированием). Теперь — единая таблица VERIFY_PHASES. Returns: 0 — verify PASS, фаза отмечена completed. 1 — verify FAIL, ошибка сохранена. -1 — phase не является verify-фазой (caller должен продолжить). """ VERIFY_PHASES: dict[str, tuple[str, str, tuple[str, str] | None]] = { 'PHASE1_VERIFY': ('docs-writer', 'PHASE1', None), 'IMPLEMENTATION_VERIFY': ('code-writer', 'IMPLEMENTATION', None), 'TESTING_VERIFY': ('test-runner', 'TESTING', None), 'PHASE2A_VERIFY': ('docs-rewriter-human', 'PHASE2A', None), 'CODE_REVIEW_VERIFY': ('code-reviewer', 'CODE_REVIEW', ('code-review', 'testing')), 'FINAL_TESTING_VERIFY': ('final-test-runner', 'FINAL_TESTING', ('final-testing', 'phase2')), 'FINAL_REVIEW_VERIFY': ('final-reviewer', 'FINAL_REVIEW', ('final-review', 'phase2')), } if phase not in VERIFY_PHASES: return -1 prompt_id, target_phase, revert = VERIFY_PHASES[phase] if not verify_subagent(prompt_id, step_id): print(f'\n❌ {phase} верификация FAIL.', file=sys.stderr) print(f' prompt_id: {prompt_id}', file=sys.stderr) print(f' Повторить: uv run python3 scripts/run_phase.py {step_id} {phase}', file=sys.stderr) save_error(step_id, phase, f'verify_subagent({prompt_id}) FAIL') if revert is not None: sed_step_state(*revert) return 1 if not set_phase(step_id, target_phase, 'completed'): return 1 clear_error(step_id) print(f'\n✓ {target_phase} верифицирован. Переход к следующей фазе.') return 0 def _check_branch_matches_step(step_id: str, phase: str, force: bool) -> None: """Проверить, что текущая git-ветка соответствует шагу. P2-refactor (v0.0.4 audit): вынесено из main() для снижения CC. При несоответствии — exit 5. При force=True или отсутствии git — пропустить. """ if force: return phases_requiring_branch_check = ( 'PREFLIGHT', 'PHASE1', 'IMPLEMENTATION', 'TESTING', 'CODE_REVIEW', 'PHASE2A', 'PHASE2B', 'FINALIZATION', ) if phase not in phases_requiring_branch_check: return if step_id == '1.0': return try: branch_result = subprocess.run( ['git', 'branch', '--show-current'], capture_output=True, text=True, cwd=BASE, timeout=5 ) current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else '' except Exception: current_branch = '' if not current_branch: return step_id_parts = step_id.split('.') if len(step_id_parts) != 2: return x_part, y_part = step_id_parts pattern1 = re.compile(rf'^feature/step-{re.escape(x_part)}\.{re.escape(y_part)}(?:[-.]|$)') pattern2 = re.compile(rf'^feature/step-{re.escape(x_part)}-{re.escape(y_part)}(?:[-.]|$)') if current_branch == 'main' and phase != 'PREFLIGHT': print('\n❌ BRANCH MISMATCH', file=sys.stderr) print(f' Ожидалась ветка: feature/step-{x_part}.{y_part}-*', file=sys.stderr) print(f' Текущая ветка: {current_branch} (main)', file=sys.stderr) print(f' Фаза: {phase}', file=sys.stderr) print(' Для принудительного запуска: --force', file=sys.stderr) sys.exit(5) if not (pattern1.match(current_branch) or pattern2.match(current_branch)): print('\n❌ BRANCH MISMATCH', file=sys.stderr) print(f' Ожидалась ветка: feature/step-{x_part}.{y_part}-*', file=sys.stderr) print(f' Текущая ветка: {current_branch}', file=sys.stderr) print(' Для принудительного запуска: --force', file=sys.stderr) sys.exit(5) log(f'BRANCH CHECK OK: ветка {current_branch} соответствует шагу {step_id}', step_id, 'action') def main(): parser = argparse.ArgumentParser(description='Универсальный исполнитель фаз шага') parser.add_argument('step_id', help='ID шага, например 1.0') parser.add_argument('phase', help='Фаза: TASK_LIST|PREFLIGHT|PHASE1|IMPLEMENTATION|TESTING|CODE_REVIEW|PHASE2A|PHASE2B|FINALIZATION ' 'или verify-фазы PHASE1_VERIFY|IMPLEMENTATION_VERIFY|TESTING_VERIFY|PHASE2A_VERIFY|CODE_REVIEW_VERIFY ' '') parser.add_argument('--force', action='store_true', ) args = parser.parse_args() step = find_step(args.step_id) if step is None: print(f'ERROR: шаг {args.step_id} не найден', file=sys.stderr) sys.exit(2) phase = args.phase.upper() # P2-refactor (v0.0.4): вынесено в _check_branch_matches_step. _check_branch_matches_step(args.step_id, phase, args.force) # P2-refactor (v0.0.4): вынесено в _run_verify_phase. verify_result = _run_verify_phase(args.step_id, phase) if verify_result >= 0: sys.exit(verify_result) # verify_result == -1: phase не verify-фаза, продолжаем elif phase == 'FINAL_TESTING': # Скрипт выводит инструкцию для агента, который вызывает final-test-runner.md. print(f'\n=== FINAL_TESTING для шага {args.step_id} ===\n') print('Главный агент должен вызвать субагента:') print(' .agent/prompts/final-test-runner.md') print('') print(f'Параметры из YAML (step {args.step_id}):') print(f' step_id: {args.step_id}') print(f' docs_target: {step.get("docs_target", "?")}') print(f' verify: {step.get("verify", "?")}') print(f' step_type: {step.get("step_type", "?")}') print('') print('После повторного verify — запустить верификацию:') print(f' make step-verify STEP={args.step_id} PROMPT=final-test-runner') print(f' # или: uv run python3 scripts/run_phase.py {args.step_id} FINAL_TESTING_VERIFY') print('') # Обновить CURRENT_PHASE на final-testing try: step_state = (BASE / 'STEP_STATE.md').read_text(encoding='utf-8') if 'CURRENT_PHASE: final-testing' not in step_state: new_state = re.sub( r'^CURRENT_PHASE:.*$', 'CURRENT_PHASE: final-testing', step_state, count=1, flags=re.MULTILINE, ) atomic_write_text(BASE / 'STEP_STATE.md', new_state) except Exception as e: log(f'FINAL_TESTING: не удалось обновить CURRENT_PHASE: {e}', args.step_id, 'warning') log('FINAL_TESTING: инструкции выведены, ожидается вызов final-test-runner.md', args.step_id, 'phase') sys.exit(0) elif phase == 'FINAL_REVIEW': # Скрипт выводит инструкцию для агента, который вызывает final-reviewer.md. print(f'\n=== FINAL_REVIEW для шага {args.step_id} ===\n') print('Главный агент должен вызвать субагента:') print(' .agent/prompts/final-reviewer.md') print('') print(f'Параметры из YAML (step {args.step_id}):') print(f' step_id: {args.step_id}') print(f' docs_target: {step.get("docs_target", "?")}') print(f' verify: {step.get("verify", "?")}') print(f' step_type: {step.get("step_type", "?")}') print(f' context_to_read: {step.get("context_to_read", [])}') print('') print('После ревью — запустить верификацию:') print(f' make step-verify STEP={args.step_id} PROMPT=final-reviewer') print(f' # или: uv run python3 scripts/run_phase.py {args.step_id} FINAL_REVIEW_VERIFY') print('') print('Если PASS (P0=0, P1≤2) → переход к FINALIZATION (PR GATE через permission-policy.yaml).') print('Если FAIL — возврат на PHASE2A (проблема в доке) или TESTING (проблема в коде).') print('') # Обновить CURRENT_PHASE на final-review try: step_state = (BASE / 'STEP_STATE.md').read_text(encoding='utf-8') if 'CURRENT_PHASE: final-review' not in step_state: new_state = re.sub( r'^CURRENT_PHASE:.*$', 'CURRENT_PHASE: final-review', step_state, count=1, flags=re.MULTILINE, ) atomic_write_text(BASE / 'STEP_STATE.md', new_state) except Exception as e: log(f'FINAL_REVIEW: не удалось обновить CURRENT_PHASE: {e}', args.step_id, 'warning') log('FINAL_REVIEW: инструкции выведены, ожидается вызов final-reviewer.md', args.step_id, 'phase') sys.exit(0) elif phase == 'TASK_LIST': # Скрипт НЕ выполняет всю работу — он только выводит инструкциицию для агента, # который затем вызывает task-list-generator.md как субагента. # Здесь: проверить, что docs_target не существует (или существует, но без Task List секции), # вывести инструкциию агенту, обновить CURRENT_PHASE: task-list. print(f'\n=== TASK_LIST для шага {args.step_id} ===\n') print('Главный агент должен вызвать субагента:') print(' .agent/prompts/task-list-generator.md') print('') print(f'Параметры из YAML (step {args.step_id}):') print(f' step_id: {args.step_id}') print(f' step_title: {step.get("title", "?")}') print(f' step_type: {step.get("step_type", "?")}') print(f' branch: {step.get("branch", "?")}') print(f' docs_target: {step.get("docs_target", "?")}') print(f' verify: {step.get("verify", "?")}') print(f' expected_result: {step.get("expected_result", "?")[:200]}...') print('') pass # TASK_LIST = report-only print('Ack-файл больше НЕ требуется.') print('Затем:') print(f' make step-init STEP={args.step_id} # v36: --no-ack по умолчанию') print(f' make step-run STEP={args.step_id} PHASE=PREFLIGHT') print('') # Обновить CURRENT_PHASE sed_step_state('pre-flight', 'task-list') # если было pre-flight (например, после STAGE-ANALYSIS), поставить task-list # Или просто поставить task-list (если текущая фаза — что-то другое) try: step_state = (BASE / 'STEP_STATE.md').read_text(encoding='utf-8') if 'CURRENT_PHASE: task-list' not in step_state: # Атомарная замена любой CURRENT_PHASE строки на task-list new_state = re.sub( r'^CURRENT_PHASE:.*$', 'CURRENT_PHASE: task-list', step_state, count=1, flags=re.MULTILINE, ) atomic_write_text(BASE / 'STEP_STATE.md', new_state) except Exception as e: log(f'TASK_LIST: не удалось обновить CURRENT_PHASE: {e}', args.step_id, 'warning') log('TASK_LIST: инструкции выведены, ожидается вызов task-list-generator.md', args.step_id, 'phase') sys.exit(0) # В v36/v36.1 оставался legacy-код, требовавший ack-файл при отсутствии progress-файла. # Это прямой конфликт с v36 (TASK_LIST = report-only, ack-файл упразднён). # Теперь: если progress-файла нет — auto-init через orchestrate_step.py (без ack-файла). # : per-step progress file progress_file = META_DIR / f"step-progress-{args.step_id.replace('.', '-')}.yaml" if not progress_file.exists(): print(f'INFO (v37): progress file missing for step {args.step_id}, auto-initializing...', file=sys.stderr) pass # ack-файл НЕ требуется print(f' → Запуск: scripts/orchestrate_step.py {args.step_id} --init-progress', file=sys.stderr) # Auto-init progress-файла без ack-файла (v37 — по умолчанию) # : sys.executable — см. комментарий в log() r = subprocess.run([sys.executable, str(_scripts_dir / 'orchestrate_step.py'), args.step_id, '--init-progress', '--no-ack'], capture_output=True, text=True, cwd=BASE) if r.returncode != 0: print('ERROR: failed to init progress file:', file=sys.stderr) print(r.stderr, file=sys.stderr) print('', file=sys.stderr) print('Manual recovery:', file=sys.stderr) print(f' uv run python3 scripts/orchestrate_step.py {args.step_id} --init-progress --no-ack', file=sys.stderr) sys.exit(3) # [P0]: убран ошибочный elif — он срабатывал когда progress-файл # СУЩЕСТВУЕТ и фаза НЕ PREFLIGHT, и выдавал "progress file missing" для # существующего файла. Это блокировало все фазы кроме PREFLIGHT. rc = check_step_already_completed(args.step_id, phase, args.force) if rc != 0: sys.exit(rc) last_err = load_last_error(args.step_id) if last_err: print(f'\n⚠️ ПРЕДЫДУЩАЯ ОШИБКА ШАГА {args.step_id} (попытка #{last_err.get("attempts", 1)}):') print(f' Фаза: {last_err.get("phase", "?")}') print(f' Сообщение: {last_err.get("message", "?")}') details = last_err.get('details', {}) if details: print(f' Детали: {json.dumps(details, ensure_ascii=False)[:300]}') print(f' Время: {last_err.get("timestamp", "?")[:19]}') print() # Авто-лог: запуск фазы log(f'Фаза {phase} начата для шага {args.step_id}', args.step_id, 'phase') # : can_start_phase ДО set_phase(in_progress). # Если can_start_phase FAIL — exit 3 БЕЗ вызова set_phase, # иначе шаг останется in_progress без возможности перезапуска. # : sys.executable — см. комментарий в log() r = subprocess.run([sys.executable, str(_scripts_dir / 'orchestrate_step.py'), args.step_id, '--can-start', phase], capture_output=True, text=True, cwd=BASE) if r.returncode != 0: print('PRECONDITION FAILED:', file=sys.stderr) print(r.stderr, file=sys.stderr) sys.exit(3) # Обновить прогресс: in_progress (FIX-1.5: проверять return value) # Только после успешной проверки предусловий () # ============================================================ # ============================================================ # Раньше loop_detector считал total_attempts в progress-файле, но run_phase.py # не проверял threshold перед запуском. Агент мог повторять фазу 5+ раз, # и loop_detector молчал. Теперь: если total_attempts >= 5 — BLOCK с exit 4. try: subprocess.run( [sys.executable, str(_scripts_dir / 'orchestrate_step.py'), args.step_id, '--show-progress'], capture_output=True, text=True, cwd=BASE, timeout=10 ).stdout # Найти total_attempts для текущей фазы # Формат в progress-файле: phases: PHASE1: total_attempts: N # Но --show-progress выводит текст — парсим import re as _re # Прямое чтение progress-файла (надёжнее) safe_step = args.step_id.replace('.', '-') prog_file = META_DIR / f'step-progress-{safe_step}.yaml' if prog_file.exists(): prog_text = prog_file.read_text(encoding='utf-8') # Найти блок текущей фазы phase_block_pattern = _re.compile( rf'^\s+{phase}:\s*\n((?:\s+[a-z_]+:.*\n)*)', _re.MULTILINE ) m = phase_block_pattern.search(prog_text) if m: block = m.group(1) ta_match = _re.search(r'total_attempts:\s*(\d+)', block) if ta_match: total = int(ta_match.group(1)) if total >= 5: log(f'LOOP DETECTOR (v32 Fix C): фаза {phase} достигла total_attempts={total} (cross-session limit)', args.step_id, 'error') print(f'\n❌ LOOP DETECTOR: фаза {phase} уже запускалась {total} раз (cross-session).', file=sys.stderr) print(' Это признак зацикливания — агент повторяет одно и то же действие без прогресса.', file=sys.stderr) print(' Решение:', file=sys.stderr) print(' 1. Проанализируйте _meta/agent.log — что агент делает на каждой итерации', file=sys.stderr) print(f' 2. Сбросить loop detector: make loop-reset STEP={args.step_id}', file=sys.stderr) print(f' 3. Если проблема системная — откатить шаг: make step-reset STEP={args.step_id}', file=sys.stderr) print(' 4. Эскалировать к пользователю — возможно, шаг требует правки YAML', file=sys.stderr) sys.exit(4) elif total >= 3: log(f'LOOP DETECTOR WARNING: фаза {phase} total_attempts={total} (3 из 5 — близко к пределу)', args.step_id, 'warning') print(f'⚠️ LOOP DETECTOR: фаза {phase} запускалась {total} раз. Осталось {5-total} попыток до блокировки.', file=sys.stderr) except Exception as e: log(f'loop detector check failed (non-blocking): {e}', args.step_id, 'warning') if not set_phase(args.step_id, phase, 'in_progress'): log(f'set_phase(in_progress) failed для фазы {phase}', args.step_id, 'error') # PREFLIGHT и FINALIZATION — критичные фазы, exit 1 при ошибке if phase in ('PREFLIGHT', 'FINALIZATION'): save_error(args.step_id, phase, f'set_phase(in_progress) failed for {phase}') sys.exit(1) # Маршрутизация handlers = { 'PREFLIGHT': phase_preflight, 'PHASE1': phase_phase1, 'TESTING': phase_testing, 'PHASE2B': phase_phase2b, 'FINALIZATION': phase_finalization, } handler = handlers.get(phase) if handler: exit_code = handler(step, args.step_id, args.force) if phase == 'PREFLIGHT' else handler(step, args.step_id) else: # IMPLEMENTATION и PHASE2A — агент делает сам, но скрипт помогает print(f'\n=== {phase} для шага {args.step_id} ===') if phase == 'IMPLEMENTATION': # : установить CURRENT_PHASE: implementation в начале фазы # Раньше PHASE1 → IMPLEMENTATION не делал sed_step_state, и STEP_STATE.md # оставался в phase1, тогда как progress.yaml показывал IMPLEMENTATION. sed_step_state('phase1', 'implementation') sed_step_state('testing', 'implementation') # fallback если фаза перезапускается print('Эта фаза требует творческой работы агента.') print('Скрипт подготовил окружение. Агент, выполни задачу:') print(f' 1. Прочитай docs_target: {step.get("docs_target", "")}') print(' 2. Создай файлы согласно плану') print(' 3. Обнови Историю действий и Реестр файлов в docs_target') print(f' 4. После завершения — запусти: run_phase.py {args.step_id} TESTING') # [P1, NEW-P1-7 complete] — агент запускает verify-фазу через run_phase.py print('\n=== Творческая фаза завершена. Запусти верификатор: ===') print(f'uv run python3 scripts/run_phase.py {args.step_id} IMPLEMENTATION_VERIFY') print('Если FAIL — исправь docs_target и перезапусти.') print('Только после PASS — переход к следующей фазе.') log('Фаза IMPLEMENTATION — ожидает творческой работы агента', args.step_id, 'action') exit_code = 0 elif phase == 'PHASE2A': # : установить CURRENT_PHASE: phase2 в начале PHASE2A # Раньше PHASE2A не делал sed_step_state, и STEP_STATE.md оставался в # testing (или phase1 если testing не успел установиться), что приводило # к state desync с progress.yaml=PHASE2A. sed_step_state('testing', 'phase2') sed_step_state('phase1', 'phase2') # fallback если testing не был установлен sed_step_state('implementation', 'phase2') # fallback # Автоматически заменить Phase 1 маркер на Phase 2 docs_target = step.get('docs_target', '') if docs_target and (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') # Заменить ТОЛЬКО маркер (не трогать остальной файл!) old_marker = '> Phase 1 — инструкция для AI-агента' new_marker = '> Обучающая документация. После прохождения этого материала вы сможете\n> воспроизвести шаг вручную без AI-агента.' if old_marker in content: # Заменить построчно: найти 3 строки маркера и заменить # : skip_next_2 был багом — после первой итерации флаг # сбрасывался, и 3-я строка Phase 1 маркера оставалась как dangling blockquote. # Используем счётчик skip_remaining для пропуска ровно 2 строк. lines = content.split('\n') new_lines = [] skip_remaining = 0 for _i, line in enumerate(lines): if skip_remaining > 0: skip_remaining -= 1 continue if old_marker in line: # Заменить эту строку + следующие 2 строки (маркер из 3 строк) new_lines.append(new_marker.split('\n')[0]) new_lines.append(new_marker.split('\n')[1]) # Пропустить следующие 2 строки Phase 1 маркера skip_remaining = 2 continue new_lines.append(line) content = '\n'.join(new_lines) (BASE / docs_target).write_text(content, encoding='utf-8') log(f'Phase 2 маркер установлен в {docs_target}', args.step_id, 'action') elif 'Обучающая документация' in content: log('Phase 2 маркер уже установлен', args.step_id, 'warning') else: log('НЕ найден Phase 1 маркер — агент должен установить Phase 2 вручную', args.step_id, 'warning') print('Phase 2 маркер установлен автоматически.') print('Агент, добавь обучающие секции:') print(' 1. ## Что вы получите в конце шага') print(' 2. ## Предварительные требования') print(' 3. ## Почему именно так (2+ абзаца)') print(' 4. ## Пошаговая инструкция (2+ подшага)') print(' 5. ## Как проверить') print(' 6. ## Типичные ошибки и их решения (таблица 2+ строк)') print(' 7. ## Что дальше') print(' НЕ трогай: История, Реестры, VERIFIED_CHECKLIST') print(f' После завершения — запусти: run_phase.py {args.step_id} PHASE2B') # [P1, NEW-P1-7 complete] — агент запускает verify-фазу через run_phase.py print('\n=== Творческая фаза завершена. Запусти верификатор: ===') print(f'uv run python3 scripts/run_phase.py {args.step_id} PHASE2A_VERIFY') print('Если FAIL — исправь docs_target и перезапусти.') print('Только после PASS — переход к следующей фазе.') log('Фаза PHASE2A — маркер установлен, агент заполняет секции', args.step_id, 'action') exit_code = 0 elif phase == 'CODE_REVIEW': # P0-4 fix: реализация фазы CODE_REVIEW (раньше попадала в "Неизвестная фаза") # Аналогично IMPLEMENTATION и PHASE2A — творческая фаза для AI-агента. # Скрипт подготавливает окружение и инструктирует агента вызвать code-reviewer.md. sed_step_state('testing', 'code-review') sed_step_state('implementation', 'code-review') # fallback print(f'\n=== CODE_REVIEW для шага {args.step_id} ===') print('Главный агент должен вызвать субагента:') print(' .agent/prompts/code-reviewer.md') print('') print(f'Параметры из YAML (step {args.step_id}):') print(f' step_id: {args.step_id}') print(f' docs_target: {step.get("docs_target", "?")}') print(f' verify: {step.get("verify", "?")}') print(f' step_type: {step.get("step_type", "?")}') print('') print('Code-reviewer должен:') print(' 1. Прочитать все изменённые файлы шага (см. Реестр файлов в docs_target)') print(' 2. Добавить секцию "## Code Review" в docs_target') print(' 3. Указать находки по категориям P0/P1/P2 (P0=blocking, P1=major, P2=minor)') print(' 4. Дать итоговое решение: approve / changes requested / reject') print('') print('Критерии PASS (для CODE_REVIEW_VERIFY):') print(' - P0 = 0 (нет блокирующих находок)') print(' - P1 <= 2 (допускается не более 2 major)') print(' - Секция "## Code Review" присутствует в docs_target') print('') print('После ревью — запустить верификацию:') print(f' make step-verify STEP={args.step_id} PROMPT=code-reviewer') print(f' # или: uv run python3 scripts/run_phase.py {args.step_id} CODE_REVIEW_VERIFY') print('') print('Если PASS (P0=0, P1<=2) → переход к PHASE2A.') print('Если FAIL — возврат на TESTING (агент чинит через debug-fixer.md → code-writer.md).') log('Фаза CODE_REVIEW — инструкции выведены, ожидается вызов code-reviewer.md', args.step_id, 'phase') exit_code = 0 else: print(f'Неизвестная фаза: {phase}') exit_code = 1 # FIX-1.3: для PHASE1/IMPLEMENTATION/PHASE2A фаза только in_progress — не логировать «завершена успешно» if exit_code == 0: clear_error(args.step_id) if phase == 'PHASE1': log(f'Шаблон Phase 1 создан. Агент должен заполнить секции. После заполнения агент запускает: uv run python3 scripts/orchestrate_step.py {args.step_id} --set-phase PHASE1 completed', args.step_id, 'phase') elif phase in ('IMPLEMENTATION', 'PHASE2A', 'CODE_REVIEW'): log(f'Фаза {phase} in_progress. Агент должен выполнить творческую работу. После завершения: uv run python3 scripts/orchestrate_step.py {args.step_id} --set-phase {phase} completed', args.step_id, 'phase') else: log(f'Фаза {phase} завершена успешно', args.step_id, 'phase') else: log(f'Фаза {phase} завершилась с ошибкой (exit {exit_code})', args.step_id, 'error') save_error(args.step_id, phase, f'Фаза {phase} завершилась с ошибкой (exit {exit_code})') sys.exit(exit_code) if __name__ == '__main__': main()