/
kochenov
/
uni
Обзор
Документация
Войти
/
kochenov
/
uni
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
scripts/orchestrate_step.py
1 203 строки
62 KB
Dmitry Kochenov
fix(step-1.0): финализация шага 1.0 и поддержка FINAL_TESTING/FINAL_REVIEW
16 июл 2026, 23:41
16 июл 2026, 23:41
dfac4cd
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Helper script for step-orchestrator.md. Helps the orchestrator parse YAML, generate plan structure, manage progress file, and check preconditions for phase transitions. This is deterministic — the agent doesn't need to parse YAML itself (which is error-prone and token-expensive). Запуск: uv run python scripts/orchestrate_step.py <step_id> --print-step uv run python scripts/orchestrate_step.py <step_id> --init-progress uv run python scripts/orchestrate_step.py <step_id> --set-phase <PHASE> <STATUS> uv run python scripts/orchestrate_step.py <step_id> --show-progress uv run python scripts/orchestrate_step.py <step_id> --can-start <PHASE> uv run python scripts/orchestrate_step.py <step_id> --gen-plan > docs/NN-stage/step-X-Y-PLAN.md Exit codes: 0 = OK 1 = step not found in YAML 2 = progress file missing (for --set-phase, --show-progress, --can-start) 3 = invalid phase transition """ import argparse import os from subprocess import PIPE import re import sys import tempfile import time from datetime import datetime from pathlib import Path import yaml BASE = Path(__file__).parent.parent PLANS_DIR = BASE / 'plans' # FIX-V25-1.1: per-step progress files для поддержки parallel_group. # PROGRESS_FILE остаётся как legacy-путь для обратной совместимости. PROGRESS_FILE = BASE / '_meta' / 'step-progress.yaml' def progress_file_for(step_id: str) -> Path: """FIX-V25-1.1: per-step progress file для поддержки parallel_group. Каждый шаг имеет свой progress-файл, что позволяет параллельным шагам (в одной parallel_group) не конфликтовать при записи состояния. """ safe_id = step_id.replace('.', '-').replace('/', '-') return BASE / '_meta' / f'step-progress-{safe_id}.yaml' # 11 фаз в строгом порядке (аудит v35: +TASK_LIST, +CODE_REVIEW; аудит v36: +FINAL_TESTING, +FINAL_REVIEW) # См. _meta/rules-constants.yaml → phases_list (единый источник истины) PHASES = [ 'TASK_LIST', # аудит v35 'PREFLIGHT', 'PHASE1', 'IMPLEMENTATION', 'TESTING', 'CODE_REVIEW', # аудит v35 'PHASE2A', 'PHASE2B', 'FINAL_TESTING', # аудит v36 'FINAL_REVIEW', # аудит v36 'FINALIZATION', ] # Valid status values VALID_STATUSES = ['pending', 'in_progress', 'completed', 'failed', 'skipped'] def find_step_in_yaml(step_id: str) -> dict | None: """Найти запись шага во всех YAML-файлах plans/.""" if not PLANS_DIR.exists(): return None 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 Exception: continue for step in data.get('steps', []): if step.get('id') == step_id: step['_yaml_file'] = yfile.name step['_stage'] = data.get('stage') step['_stage_name'] = data.get('name') return step return None def print_step(step: dict) -> None: """Напечатать краткую инфу о шаге (для генерации плана).""" print(f"step_id: {step.get('id')}") print(f"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"requires_approval: {step.get('requires_approval', False)}") # Аудит v36: parallel_group DEPRECATED — не выводим в print_step if step.get('parallel_group'): print(f"# WARNING (v36): parallel_group={step.get('parallel_group')} — DEPRECATED, ignored") print(f"verify: {step.get('verify', [''])[0]}") print(f"expected_result: {step.get('expected_result', '')}") print("context_to_read:") for ctx in step.get('context_to_read', []): print(f" - {ctx}") print("prompts_to_use:") for p in step.get('prompts_to_use', []): print(f" - {p}") def init_progress_file(step: dict, force: bool = False, require_ack: bool = False) -> None: """Создать _meta/step-progress-<step_id>.yaml с начальным состоянием. FIX-V25-1.1: per-step progress file (каждый шаг — свой файл), что позволяет параллельным шагам (parallel_group) не конфликтовать. Защита от случайного перезаписи (Проблема #15). Аудит v36 (2026-07-16): ack-файл больше НЕ требуется. В v35 ack-файл был нужен как маркер подтверждения плана пользователем (TASK_LIST ждал «выполняй»). В v36 (TASK_LIST = report-only) подтверждение не запрашивается — выполнение продолжается автоматически. Параметр `require_ack` сохранён для обратной совместимости, но по умолчанию False. """ step_id = step.get('id') pf = progress_file_for(step_id) pf.parent.mkdir(parents=True, exist_ok=True) # Аудит v36: ack-файл больше НЕ требуется. Параметр сохранён для обратной совместимости. ack_file = pf.parent / f"step-{step_id}-PLAN.ack" if require_ack and not ack_file.exists(): print(f"ERROR (legacy): plan not acknowledged. " f"В v36 ack-файл НЕ требуется (TASK_LIST = report-only). " f"Если вы видите эту ошибку — используйте --no-ack.", file=sys.stderr) sys.exit(4) # Проблема #15: защита от случайного перезаписи существующего файла # FIX-V25-1.1: теперь каждый шаг имеет свой файл, поэтому достаточно # проверить только совпадение step_id (другой шаг физически не помешает). if pf.exists() and not force: try: existing = yaml.safe_load(pf.read_text(encoding='utf-8')) except Exception: existing = None if existing and existing.get('step_id') == step_id: print(f"ERROR: progress file for step '{step_id}' already exists " f"(current_phase={existing.get('current_phase')}). " f"This is a RESUME scenario — use --show-progress to inspect, " f"do NOT re-init. To force re-init, use --force.", file=sys.stderr) sys.exit(6) now = datetime.now().strftime('%Y-%m-%d %H:%M') progress = { 'step_id': step_id, 'step_title': step.get('title'), 'branch': step.get('branch'), 'docs_target': step.get('docs_target'), 'step_type': step.get('step_type'), 'started_at': now, 'current_phase': 'PREFLIGHT', 'finished_at': None, 'phases': {}, 'loop_detector': { 'last_action': None, 'last_action_count': 0, 'action_history': [], }, 'notes': [], } for phase in PHASES: # Для step_type=document фаза IMPLEMENTATION пропускается if phase == 'IMPLEMENTATION' and step.get('step_type') == 'document': progress['phases'][phase] = { 'status': 'skipped', 'started_at': None, 'finished_at': None, 'attempts': 0, 'last_error': 'skipped: step_type=document', } else: progress['phases'][phase] = { 'status': 'pending', 'started_at': None, 'finished_at': None, 'attempts': 0, 'last_error': None, } # FIX-1.12: атомарная запись через save_progress (temp + os.replace) # FIX-V25-1.1: передаём step_id, чтобы писАть в per-step файл save_progress(progress, step_id=step_id) print(f"Created: {pf}") print(f"Current phase: {progress['current_phase']}") # Аудит v36: ack-файл больше НЕ требуется, но если существует — упоминаем для истории if ack_file.exists(): print(f"Note: legacy ack-файл {ack_file} существует (v36 — игнорируется)") def _validate_phase_completion(step_id: str, phase: str, progress: dict) -> str | None: """Проблема #3: валидация фактического выполнения фазы перед отметкой completed. Возвращает строку с ошибкой, если фаза не может быть completed. """ import subprocess def _git(args: list[str]) -> str: try: return subprocess.run(['git'] + args, capture_output=True, text=True, cwd=BASE, timeout=10).stdout.strip() except Exception: return '' if phase == 'PREFLIGHT': # Должна быть активна feature-ветка branch = _git(['branch', '--show-current']) expected = progress.get('branch', '') if not branch.startswith('feature/step-'): return (f"PREFLIGHT cannot be completed: current branch is '{branch}', " f"expected feature-branch '{expected}'. " f"Run step-pre-flight.md first to create the branch.") if expected and branch != expected: return (f"PREFLIGHT cannot be completed: branch mismatch " f"(actual='{branch}', expected='{expected}')") elif phase == 'PHASE1': docs_target = progress.get('docs_target', '') if not docs_target or not (BASE / docs_target).exists(): return (f"PHASE1 cannot be completed: docs_target '{docs_target}' does not exist. " f"Run docs-writer.md first.") # FIX-V27-8: для bootstrap/document шагов Phase 1 маркер не требуется # (упрощённый шаблон без маркера) step = find_step_in_yaml(progress.get('step_id', '')) step_type = step.get('step_type', '') if step else '' if step_type not in ('bootstrap', 'document', 'user_action'): content = (BASE / docs_target).read_text(encoding='utf-8') if '> Phase 1 — инструкция для AI-агента' not in content: return (f"PHASE1 cannot be completed: Phase 1 marker missing in {docs_target}. " f"docs-writer.md did not write Phase 1 documentation.") elif phase == 'IMPLEMENTATION': # FIX-1.15: проверить, что все files_to_create существуют docs_target = progress.get('docs_target', '') if not docs_target or not (BASE / docs_target).exists(): return f"IMPLEMENTATION cannot be completed: docs_target '{docs_target}' does not exist." # Найти YAML шага для получения files_to_create step = find_step_in_yaml(progress.get('step_id', '')) files_to_create = step.get('files_to_create', []) if step else [] if files_to_create: missing = [f for f in files_to_create if not (BASE / f).exists()] if missing: return f"IMPLEMENTATION cannot be completed: missing files: {missing[:5]}" elif phase == 'PHASE2A': # FIX-1.15: проверить, что Phase 2 маркер установлен и Phase 1 убран # FIX-V27-9: для bootstrap/document шагов Phase 2 маркер не требуется docs_target = progress.get('docs_target', '') step = find_step_in_yaml(progress.get('step_id', '')) step_type = step.get('step_type', '') if step else '' if step_type in ('bootstrap', 'document', 'user_action'): pass # упрощённая проверка — маркеры не требуются elif docs_target and (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') if '> Обучающая документация' not in content: return f"PHASE2A cannot be completed: Phase 2 marker missing in {docs_target}." if '> Phase 1 — инструкция для AI-агента' in content: return "PHASE2A cannot be completed: Phase 1 marker still present — Phase 2 not applied." elif phase == 'TESTING': # verify должен быть запущен и зафиксирован в реестре тестов docs_target = progress.get('docs_target', '') if docs_target and (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') if 'PASS' not in content and 'PASSED' not in content: return (f"TESTING cannot be completed: no PASS/PASSED record in {docs_target}. " f"Run test-runner.md first.") elif phase == 'CODE_REVIEW': # Аудит v35: code-reviewer должен записать секцию "## Code Review" docs_target = progress.get('docs_target', '') if docs_target and (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') if '## Code Review' not in content: return (f"CODE_REVIEW cannot be completed: '## Code Review' section missing in {docs_target}. " f"Run code-reviewer.md first.") elif phase == 'FINAL_TESTING': # Аудит v36: final-test-runner должен записать секцию "## Final Testing" docs_target = progress.get('docs_target', '') if docs_target and (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') if '## Final Testing' not in content: return (f"FINAL_TESTING cannot be completed: '## Final Testing' section missing in {docs_target}. " f"Run final-test-runner.md first.") # Проверить что в секции есть PASS # FIX: извлечь всё после ## Final Testing до следующего ## или конца файла after_marker = content.split('## Final Testing')[-1] # Ищем следующий заголовок ## или конец строки next_header_match = re.search(r'\n## ', after_marker) if next_header_match: ft_section = after_marker[:next_header_match.start()] else: ft_section = after_marker if 'PASS' not in ft_section: return (f"FINAL_TESTING cannot be completed: no PASS in Final Testing section. " f"Verify commands failed.") elif phase == 'FINAL_REVIEW': # Аудит v36: final-reviewer должен записать секцию "## Final Review" docs_target = progress.get('docs_target', '') if docs_target and (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') if '## Final Review' not in content: return (f"FINAL_REVIEW cannot be completed: '## Final Review' section missing in {docs_target}. " f"Run final-reviewer.md first.") # Проверить что нет P0 (блокирующих) fr_section = content.split('## Final Review')[-1].split('## ')[0] # Ищем "P0 (критические): N" — если N > 0, FAIL p0_match = re.search(r'P0.*?[:\s]+(\d+)', fr_section) if p0_match and int(p0_match.group(1)) > 0: return (f"FINAL_REVIEW cannot be completed: P0 count = {p0_match.group(1)} > 0. " f"Fix blocking issues first.") elif phase == 'FINALIZATION': # Аудит v31 (START-WITH-AGENT.md §3.1): FINALIZATION = commit + push + PR. # Merge в main делается вручную через GitVerse web UI — агент НЕ делает merge. # Поэтому feature-ветка ДОЛЖНА существовать после FINALIZATION (не удаляется). # Раньше (v29/v30) здесь была проверка, что ветка удалена — теперь это ошибка. branch = _git(['branch', '--show-current']) # Разрешаем быть либо на feature-ветке (сразу после commit+push+PR), # либо на main (если агент уже сделал git checkout main после PR). # Главное — не требовать удаления feature-ветки. expected = progress.get('branch', '') if expected and branch != expected and branch != 'main': return (f"FINALIZATION warning: на ветке '{branch}', ожидалась '{expected}' или 'main'. " f"Это не ошибка в v31 — merge делается вручную через GitVerse web UI.") # Примечание v31: feature-ветка НЕ должна быть удалена. # Если она удалена — это нарушение политики v31, но не блокирующая ошибка # (возможно, проект мигрировал со старой версии). if expected: branches = _git(['branch', '--list', expected]) if not branches: # В v31 это НЕ ошибка, но предупреждаем — ветка нужна для rollback print(f'⚠ ВНИМАНИЕ (v31): feature-ветка {expected!r} не найдена. ' f'Политика v31 требует сохранять ветки для истории и rollback. ' f'Если вы мигрируете с v29/v30 — это ожидаемо.', file=sys.stderr) return None def load_progress(step_id: str | None = None) -> dict | None: """Загрузить progress-файл с обработкой ошибок (FIX-1.13). FIX-V25-1.1: если step_id задан — читать per-step файл `_meta/step-progress-<step_id>.yaml`. Иначе — читать единый legacy-файл PROGRESS_FILE (для обратной совместимости со старым кодом). Если файл повреждён (невалидный YAML), выводит диагностическое сообщение и завершается с exit code 2. """ if step_id: pf = progress_file_for(step_id) else: pf = PROGRESS_FILE if not pf.exists(): return None try: return yaml.safe_load(pf.read_text(encoding='utf-8')) except yaml.YAMLError as e: print(f'ERROR: progress file corrupted: {e}', file=sys.stderr) print('Run: uv run python3 scripts/orchestrate_step.py <step_id> --init-progress --force', file=sys.stderr) sys.exit(2) def save_progress(progress: dict, step_id: str | None = None) -> None: """Атомарная запись progress-файла (FIX-1.12). FIX-V25-1.1: если step_id задан — писать в per-step файл `_meta/step-progress-<step_id>.yaml`. Иначе — в единый legacy-файл PROGRESS_FILE. """ if step_id: pf = progress_file_for(step_id) else: pf = PROGRESS_FILE pf.parent.mkdir(parents=True, exist_ok=True) content = yaml.safe_dump(progress, allow_unicode=True, sort_keys=False, default_flow_style=False) fd, tmp_path = tempfile.mkstemp(dir=str(pf.parent), prefix='.tmp-progress-', suffix='.yaml') try: with os.fdopen(fd, 'w', encoding='utf-8') as f: f.write(content) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, pf) except Exception: try: os.unlink(tmp_path) except OSError: pass raise # ============================================================================ # FIX-V25-1.2 [P0, NEW-P0-7] — Защита от двойного fallback (.subagent-lock) # ============================================================================ # TTL блокировки субагента в минутах. После этого считается stale # и автоматически удаляется при следующей попытке acquire/check. SUBAGENT_LOCK_TTL_MINUTES = 30 def _subagent_lock_path(step_id: str, prompt_id: str) -> Path: """Путь к lock-файлу субагента для пары (step_id, prompt_id).""" safe_step = step_id.replace('.', '-').replace('/', '-') # Нормализовать prompt_id (только безопасные символы) safe_prompt = re.sub(r'[^A-Za-z0-9._-]', '-', prompt_id) return BASE / '_meta' / f'.subagent-lock-{safe_step}-{safe_prompt}' def acquire_subagent_lock(step_id: str, prompt_id: str, ttl_minutes: int = SUBAGENT_LOCK_TTL_MINUTES) -> bool: """FIX-V25-1.2: приобрести блокировку для субагента. Возвращает True если блокировка получена, False если уже занята (другой процесс выполняет этого субагента в пределах TTL). Stale-блокировки (старше ttl_minutes) автоматически удаляются. """ lock_file = _subagent_lock_path(step_id, prompt_id) if lock_file.exists(): # Проверить TTL age_minutes = (time.time() - lock_file.stat().st_mtime) / 60 if age_minutes < ttl_minutes: return False # блокировка активна # Stale lock — удалить try: lock_file.unlink() except OSError: pass lock_file.parent.mkdir(parents=True, exist_ok=True) lock_file.write_text( f'step_id={step_id}\n' f'prompt_id={prompt_id}\n' f'created_at={datetime.now().isoformat()}\n' f'pid={os.getpid()}\n', encoding='utf-8', ) return True def release_subagent_lock(step_id: str, prompt_id: str) -> None: """Освободить блокировку субагента (FIX-V25-1.2).""" lock_file = _subagent_lock_path(step_id, prompt_id) try: lock_file.unlink(missing_ok=True) except TypeError: # Python <3.8 — missing_ok не поддерживается try: lock_file.unlink() except OSError: pass except OSError: pass def check_subagent_lock(step_id: str, prompt_id: str, ttl_minutes: int = SUBAGENT_LOCK_TTL_MINUTES) -> bool: """Проверить, активна ли блокировка субагента (FIX-V25-1.2). Возвращает True если блокировка активна (моложе ttl_minutes), False — если отсутствует или stale (тогда автоматически удаляется). """ lock_file = _subagent_lock_path(step_id, prompt_id) if not lock_file.exists(): return False age_minutes = (time.time() - lock_file.stat().st_mtime) / 60 if age_minutes >= ttl_minutes: # stale try: lock_file.unlink() except OSError: pass return False return True def check_state_sync(step_id: str) -> None: """FIX-V24-3.2: расширенная проверка рассинхронизации state-машин. Проверяет не только FAILED, но и DONE/mismatch состояний между step-progress.yaml (машина A) и STEP_STATE.md (машина B). """ # FIX-V25-1.1: читать per-step progress-файл progress = load_progress(step_id) if progress is None: return current_phase = progress.get('current_phase', '') step_state = BASE / 'STEP_STATE.md' if not step_state.exists(): return content = step_state.read_text(encoding='utf-8') m = re.search(r'^CURRENT_PHASE:\s*(\S+)', content, re.MULTILINE) step_state_phase = m.group(1) if m else '' # Mapping: progress phase → STEP_STATE phase PHASE_MAPPING = { 'PREFLIGHT': 'pre-flight', 'PHASE1': 'phase1', 'IMPLEMENTATION': 'implementation', 'TESTING': 'testing', 'PHASE2A': 'phase2', 'PHASE2B': 'phase2', 'FINAL_TESTING': 'final-testing', # аудит v36 'FINAL_REVIEW': 'final-review', # аудит v36 'FINALIZATION': 'finalization', 'DONE': 'awaiting-continue', } expected_step_state = PHASE_MAPPING.get(current_phase) # Case 1: FAILED — критическая рассинхронизация if current_phase == 'FAILED' and step_state_phase not in ('pre-flight', 'awaiting-continue', ''): print('ERROR: state machines desynchronized:', file=sys.stderr) print(f' step-progress.yaml: current_phase={current_phase}', file=sys.stderr) print(f' STEP_STATE.md: CURRENT_PHASE={step_state_phase}', file=sys.stderr) print(' Manual intervention required.', file=sys.stderr) sys.exit(3) # Case 2: DONE в progress, но STEP_STATE не awaiting-continue if current_phase == 'DONE' and step_state_phase != 'awaiting-continue': print(f'WARN: step-progress.yaml=DONE, but STEP_STATE.md.CURRENT_PHASE={step_state_phase}', file=sys.stderr) print(' Это может быть нормально, если finalize_step.py ещё не завершился.', file=sys.stderr) # Не exit — только warn # Case 3: явное несоответствие (например, progress=PHASE1, STEP_STATE=finalization) if expected_step_state and step_state_phase and step_state_phase != expected_step_state: # FIX-V27-4: авто-исправление state desync вместо просто warning. # Проблема: после desync, set_phase блокируется на total_attempts>=5, # и шаг застревает навсегда. Решение: force-set STEP_STATE.CURRENT_PHASE # из progress (машина A — источник истины), чтобы машины снова # синхронизировались. Это безопасно, т.к. progress.yaml обновляется # атомарно через save_progress() и отражает последний known-good state. print(f'WARN: phase mismatch: progress={current_phase} → expected STEP_STATE={expected_step_state}, actual={step_state_phase}', file=sys.stderr) print(f' Авто-исправление: force-set STEP_STATE.CURRENT_PHASE={expected_step_state}', file=sys.stderr) new_content = re.sub( r'(^CURRENT_PHASE:\s*).+?(\s*$)', rf'\g<1>{expected_step_state}\g<2>', content, flags=re.MULTILINE ) if new_content != content: try: step_state.write_text(new_content, encoding='utf-8') print(f' STEP_STATE.md обновлён: CURRENT_PHASE={expected_step_state}', file=sys.stderr) except OSError as e: print(f' Не удалось обновить STEP_STATE.md: {e}', file=sys.stderr) def set_phase(step_id: str, phase: str, status: str) -> None: """Обновить статус фазы в progress-файле.""" if phase not in PHASES: print(f"ERROR: invalid phase '{phase}'. Valid: {', '.join(PHASES)}", file=sys.stderr) sys.exit(3) if status not in VALID_STATUSES: print(f"ERROR: invalid status '{status}'. Valid: {', '.join(VALID_STATUSES)}", file=sys.stderr) sys.exit(3) # FIX-V25-1.1: читать per-step progress-файл progress = load_progress(step_id) if progress is None: print("ERROR: progress file does not exist. Run --init-progress first.", file=sys.stderr) sys.exit(2) if progress.get('step_id') != step_id: print(f"ERROR: progress file is for step '{progress.get('step_id')}', not '{step_id}'", file=sys.stderr) sys.exit(2) # Проблема #3: валидация фактического выполнения фазы if status == 'completed': err = _validate_phase_completion(step_id, phase, progress) if err: print(f"ERROR: {err}", file=sys.stderr) sys.exit(5) now = datetime.now().strftime('%Y-%m-%d %H:%M') progress['phases'][phase]['status'] = status if status == 'in_progress': progress['phases'][phase]['started_at'] = now progress['phases'][phase]['attempts'] = progress['phases'][phase].get('attempts', 0) + 1 # FIX-1.20: cross-session total_attempts (не сбрасывается при resume) progress['phases'][phase]['total_attempts'] = progress['phases'][phase].get('total_attempts', 0) + 1 total = progress['phases'][phase].get('total_attempts', 0) if total >= 5: print(f"ERROR: phase {phase} reached total_attempts={total} (cross-session limit). Manual intervention required.", file=sys.stderr) sys.exit(7) elif status in ('completed', 'failed', 'skipped'): progress['phases'][phase]['finished_at'] = now if status == 'completed' and phase != 'FINALIZATION': # Перейти к следующей фазе idx = PHASES.index(phase) if idx + 1 < len(PHASES): next_phase = PHASES[idx + 1] # Пропустить skipped фазы while next_phase in progress['phases'] and progress['phases'][next_phase]['status'] == 'skipped': idx2 = PHASES.index(next_phase) if idx2 + 1 < len(PHASES): next_phase = PHASES[idx2 + 1] else: next_phase = None break if next_phase: progress['current_phase'] = next_phase elif phase == 'FINALIZATION' and status == 'completed': progress['current_phase'] = 'DONE' progress['finished_at'] = now elif status == 'failed': progress['current_phase'] = 'FAILED' # FIX-V25-1.1: писать в per-step progress-файл save_progress(progress, step_id=step_id) print(f"Updated: phase={phase}, status={status}") print(f"Current phase: {progress['current_phase']}") def record_action(step_id: str, action: str) -> None: """Проблема #8: loop detector — записать действие и проверить петлю.""" # FIX-V25-1.1: читать per-step progress-файл progress = load_progress(step_id) if progress is None: print("ERROR: no progress file. Run --init-progress first.", file=sys.stderr) sys.exit(2) # FIX-1.19: нормализация путей для сравнения (readFile:./a/b == readFile:a/b) if action.startswith('readFile:'): path = action.split(':', 1)[1].strip() normalized = os.path.normpath(path) action = f'readFile:{normalized}' ld = progress.setdefault('loop_detector', {}) history = ld.setdefault('action_history', []) history.append(action) # Хранить только последние 10 if len(history) > 10: history = history[-10:] ld['action_history'] = history # Проверка петли: 3+ одинаковых подряд loop_detected = False loop_msg = '' if len(history) >= 3 and len(set(history[-3:])) == 1: loop_detected = True loop_msg = f"action '{history[-1]}' repeated 3+ times in a row" else: # Также: тот же readFile 3+ раза за последние 10 from collections import Counter reads = [a for a in history if a.startswith('readFile:')] if reads: counter = Counter(reads) most_common, count = counter.most_common(1)[0] if count >= 3: loop_detected = True loop_msg = f"'{most_common}' recorded {count} times in last {len(history)} actions" if loop_detected: ld['last_action'] = history[-1] if history else None ld['last_action_count'] = 3 # FIX-V25-1.1: писать в per-step progress-файл save_progress(progress, step_id=step_id) print(f"LOOP DETECTED: {loop_msg}", file=sys.stderr) print(f"Action history (last {len(history)}):", file=sys.stderr) for i, a in enumerate(history, 1): print(f" {i}. {a}", file=sys.stderr) print("STOP. See 08-antipatterns.md for recovery.", file=sys.stderr) sys.exit(7) ld['last_action'] = history[-1] if history else None ld['last_action_count'] = 1 # FIX-V25-1.1: писать в per-step progress-файл save_progress(progress, step_id=step_id) print(f"Recorded: {action}") def check_loop(step_id: str) -> None: """Проблема #8: проверить loop_detector без записи действия.""" # FIX-V25-1.1: читать per-step progress-файл progress = load_progress(step_id) if progress is None: print("OK: no progress file") sys.exit(0) ld = progress.get('loop_detector', {}) count = ld.get('last_action_count', 0) if count >= 3: print(f"LOOP DETECTED: last_action_count={count}", file=sys.stderr) sys.exit(7) print(f"OK: no loop detected (last_action_count={count})") def reset_loop(step_id: str) -> None: """FIX-1.16: сбросить loop_detector (реализация --reset-loop). FIX-V25-5.9: сбросить loop_detector И total_attempts (cross-session счётчик). Без сброса total_attempts --reset-loop бесполезен после 4 неудач: 5-я попытка снова блокируется по S2 (total_attempts >= 5). ⚠️ ВНИМАНИЕ: --reset-loop теперь сбрасывает cross-session защиту. Использовать ТОЛЬКО после ручного анализа причины петель (см. 08-antipatterns.md). """ # FIX-V25-1.1: читать per-step progress-файл progress = load_progress(step_id) if progress is None: print("ERROR: no progress file", file=sys.stderr) sys.exit(2) ld = progress.setdefault('loop_detector', {}) ld['action_history'] = [] ld['last_action'] = None ld['last_action_count'] = 0 # Сбросить attempts и total_attempts для всех фаз со статусом failed или in_progress # FIX-V25-6: также сбрасывать in_progress, иначе после --reset-loop total_attempts # продолжает накапливаться и следующая попытка снова блокируется. for phase, ph in progress.get('phases', {}).items(): if ph.get('status') in ('failed', 'in_progress'): ph['status'] = 'pending' if ph.get('status') == 'in_progress' else 'pending' ph['attempts'] = 0 ph['total_attempts'] = 0 # FIX-V25-5.9: сбросить cross-session счётчик ph['last_error'] = None # FIX-V25-1.1: писать в per-step progress-файл save_progress(progress, step_id=step_id) print(f"Loop detector AND total_attempts reset for step {step_id}") def show_progress(step_id: str) -> None: """Показать текущий прогресс.""" # FIX-V25-1.1: читать per-step progress-файл progress = load_progress(step_id) if progress is None: print("ERROR: progress file does not exist.", file=sys.stderr) sys.exit(2) print(f"Step: {progress.get('step_id')} — {progress.get('step_title')}") print(f"Branch: {progress.get('branch')}") print(f"Started: {progress.get('started_at')}") print(f"Current phase: {progress.get('current_phase')}") print(f"Finished: {progress.get('finished_at', '—')}") print() print("Phases:") for phase in PHASES: ph = progress.get('phases', {}).get(phase, {}) status = ph.get('status', 'pending') icon = {'completed': '✓', 'in_progress': '→', 'failed': '✗', 'skipped': '—', 'pending': ' '}[status] attempts = ph.get('attempts', 0) attempts_str = f" (попытка {attempts})" if attempts > 0 else "" print(f" [{icon}] {phase:15} {status:11}{attempts_str}") if ph.get('last_error'): print(f" last_error: {ph['last_error']}") # Проблема #17: показывать action_history print() print("Loop detector:") ld = progress.get('loop_detector', {}) print(f" last_action: {ld.get('last_action', '—')}") print(f" last_action_count: {ld.get('last_action_count', 0)}") history = ld.get('action_history', []) if history: print(f" action_history (last {len(history)}):") for i, action in enumerate(history, 1): print(f" {i}. {action}") else: print(" action_history: (empty)") notes = progress.get('notes', []) if notes: print() print(f"Notes ({len(notes)}):") for n in notes: print(f" - {n}") def can_start_phase(step_id: str, phase: str) -> None: """Проверить, можно ли запустить фазу (все предыдущие completed/skipped). BUG-FIX (v25.2): ранее проверялся только status в progress-файле, но НЕ проверялось, что артефакты реально существуют. Если progress говорил "PHASE1 completed" но docs_target был удалён — TESTING запускался и падал с "docs_target не существует". Теперь проверяем артефакты тоже. """ if phase not in PHASES: print(f"ERROR: invalid phase '{phase}'", file=sys.stderr) sys.exit(3) # FIX-V25-1.1: читать per-step progress-файл progress = load_progress(step_id) if progress is None: print("ERROR: progress file does not exist.", file=sys.stderr) sys.exit(2) idx = PHASES.index(phase) if idx == 0: print(f"OK: {phase} — первая фаза, можно запускать.") sys.exit(0) # Проверить все предыдущие фазы for prev_phase in PHASES[:idx]: prev_status = progress.get('phases', {}).get(prev_phase, {}).get('status', 'pending') if prev_status not in ('completed', 'skipped'): print(f"BLOCKED: предыдущая фаза {prev_phase} не завершена (status={prev_status}).", file=sys.stderr) sys.exit(3) # BUG-FIX (v25.2): верификация артефактов для completed фаз. # Если артефакт отсутствует — reset фазы в pending и BLOCK. if prev_status == 'completed': docs_target = progress.get('docs_target', '') if prev_phase == 'PHASE1' and docs_target: if not (BASE / docs_target).exists(): print(f'BLOCKED: фаза PHASE1 помечена completed, но docs_target {docs_target} не существует.', file=sys.stderr) print(f' Reset PHASE1 в pending. Перезапустите: run_phase.py {step_id} PHASE1', file=sys.stderr) # Авто-исправление: reset в pending progress['phases'][prev_phase]['status'] = 'pending' progress['phases'][prev_phase]['last_error'] = f'docs_target {docs_target} missing' save_progress(progress, step_id=step_id) sys.exit(3) if prev_phase == 'IMPLEMENTATION': # Проверить, что files_to_create существуют step = find_step_in_yaml(step_id) files_to_create = step.get('files_to_create', []) if step else [] if files_to_create: missing = [f for f in files_to_create if not (BASE / f).exists()] if missing: print(f'BLOCKED: фаза IMPLEMENTATION помечена completed, но файлы отсутствуют: {missing[:3]}', file=sys.stderr) progress['phases'][prev_phase]['status'] = 'pending' progress['phases'][prev_phase]['last_error'] = f'missing files: {missing[:3]}' save_progress(progress, step_id=step_id) sys.exit(3) if prev_phase == 'TESTING' and docs_target: # Проверить, что в docs_target есть запись PASS if (BASE / docs_target).exists(): content = (BASE / docs_target).read_text(encoding='utf-8') if 'PASS' not in content and 'PASSED' not in content: print(f'BLOCKED: фаза TESTING помечена completed, но нет записи PASS в {docs_target}', file=sys.stderr) progress['phases'][prev_phase]['status'] = 'pending' progress['phases'][prev_phase]['last_error'] = 'no PASS record in docs_target' save_progress(progress, step_id=step_id) sys.exit(3) print(f"OK: {phase} — все предыдущие фазы завершены, можно запускать.") sys.exit(0) def generate_plan(step: dict) -> str: """Сгенерировать шаблон плана docs/NN-stage/step-X-Y-PLAN.md. Аудит v32 (Fix A): план теперь включает YAML-задачи шага, а не только фазы. Раньше план содержал только чек-лист фаз (PREFLIGHT → PHASE1 → ... → FINALIZATION). Аудит v36: теперь 11 фаз (TASK_LIST → PREFLIGHT → PHASE1 → IMPLEMENTATION → TESTING → CODE_REVIEW → PHASE2A → PHASE2B → FINAL_TESTING → FINAL_REVIEW → FINALIZATION). без конкретных задач из YAML. Агент мог «следить за фазами», но не делать то, что прописано в expected_result / verify / files_to_create. Теперь план содержит: 1. Чек-лист фаз (как раньше) 2. КОНКРЕТНЫЕ ЗАДАЧИ ШАГА из YAML: - expected_result — что должно быть сделано (агент обязан выполнить) - verify — команды проверки (агент обязан запустить и получить PASS) - files_to_create — список файлов (агент обязан создать) - prompts_to_use — какие субагенты будут вызваны - context_to_read — что агент должен прочитать - requires_approval / parallel_group — флаги гейтов 3. Контрольный чек-лист перед финализацией """ step_id = step.get('id') title = step.get('title', '') stage = step.get('_stage', '') stage_name = step.get('_stage_name', '') docs_target = step.get('docs_target', '') verify = step.get('verify', [''])[0] if step.get('verify') else '' expected_result = step.get('expected_result', '') step_type = step.get('step_type', 'implementation') branch = step.get('branch', '') context_to_read = step.get('context_to_read', []) prompts_to_use = step.get('prompts_to_use', []) files_to_create = step.get('files_to_create', []) files_to_commit = step.get('files_to_commit', []) requires_approval = step.get('requires_approval', False) requires_approval_reason = step.get('approval_reason', '') parallel_group = step.get('parallel_group', '') note = step.get('note', '') now = datetime.now().strftime('%Y-%m-%d %H:%M') # Адаптировать фазы под step_type if step_type == 'document': impl_phase = '- [~] **IMPLEMENTATION** — пропускается (step_type=document)' elif step_type == 'bootstrap': impl_phase = '- [~] **IMPLEMENTATION** — пропускается (step_type=bootstrap, verify-only)' elif step_type == 'adr': impl_phase = '- [ ] **IMPLEMENTATION** — создать ADR файлы (adr-writer.md)' else: impl_phase = '- [ ] **IMPLEMENTATION** — реализовать код/файлы (code-writer.md)' risks = [] if requires_approval: risks.append(f'- ⚠️ Требует approval gate (см. step-pre-flight.md §0.2). Причина: {requires_approval_reason}') if parallel_group: risks.append(f'- ⚠️ Параллельная группа `{parallel_group}` — может потребоваться rebase') if 'npm' in (verify or '') or 'uv run' in (verify or ''): risks.append('- Verify запускает внешние инструменты — может упасть на CI') if not risks: risks.append('- Стандартный шаг без особых рисков') context_lines = '\n'.join(f" {i+1}. {ctx}" for i, ctx in enumerate(context_to_read)) or ' (нет — шаг не требует доп. контекста)' prompts_lines = '\n'.join(f" - {p}" for p in prompts_to_use) or ' (нет)' files_create_lines = '\n'.join(f" - [ ] `{f}`" for f in files_to_create) if files_to_create else ' (нет — step_type не требует файлов)' files_commit_lines = '\n'.join(f" - `{f}`" for f in files_to_commit) if files_to_commit else ' (нет)' # ============================================================ # Аудит v32 (Fix A): КОНКРЕТНЫЕ ЗАДАЧИ ШАГА из YAML # ============================================================ # Превратить expected_result в чек-лист задач (если там несколько предложений) tasks_from_expected = [] if expected_result: # Разбить по точкам / запятым / переносам for part in expected_result.replace('. ', '.\n').split('\n'): part = part.strip().rstrip('.') if part and len(part) > 10: tasks_from_expected.append(f'- [ ] {part}') tasks_block = '\n'.join(tasks_from_expected) if tasks_from_expected else '- [ ] (expected_result пустой — агент должен определить задачи сам)' # Контрольный чек-лист перед финализацией finalization_checklist = [] if step_type == 'implementation': finalization_checklist.append('- [ ] docs_target содержит маркер `> Phase 1 — инструкция для AI-агента` (после PHASE1)') finalization_checklist.append('- [ ] docs_target содержит маркер `> Обучающая документация` (после PHASE2A)') finalization_checklist.append('- [ ] docs_target НЕ содержит маркер Phase 1 (после PHASE2A — убран)') # Аудит v36: финальные review-гейты finalization_checklist.append('- [ ] docs_target содержит секцию `## Final Testing (аудит v36)` (v36)') finalization_checklist.append('- [ ] docs_target содержит секцию `## Final Review (аудит v36)` (v36)') finalization_checklist.append('- [ ] Final Review: P0=0, P1≤2 (v36 — release readiness)') finalization_checklist.append('- [ ] VERIFIED_CHECKLIST — все пункты `[x]`') finalization_checklist.append('- [ ] История действий агента — ≥ 5 записей') finalization_checklist.append('- [ ] Реестр файлов шага — заполнен (Созданные/Изменённые/Удалённые)') finalization_checklist.append('- [ ] Реестр тестов — есть PASS') elif step_type in ('bootstrap', 'document'): finalization_checklist.append('- [ ] docs_target содержит H1 (`# Шаг X.Y — ...`)') finalization_checklist.append('- [ ] VERIFIED_CHECKLIST — все пункты `[x]`') finalization_checklist.append('- [ ] История действий — ≥ 3 записи (для bootstrap/document)') finalization_checklist.append('- [ ] Реестр файлов — заполнен') elif step_type == 'adr': finalization_checklist.append('- [ ] ADR файлы созданы в docs/для-разработчиков/adr/') finalization_checklist.append('- [ ] Каждый ADR имеет `> ADR-STATUS: proposed` → потом `approved`') finalization_checklist.append('- [ ] ADR-ГЕЙТ пройден (пользователь сказал «одобряю ADR-NNNN»)') if files_to_create: finalization_checklist.append(f'- [ ] Все files_to_create существуют ({len(files_to_create)} файлов)') if verify: finalization_checklist.append(f'- [ ] Verify команда PASSED: `{verify}`') finalization_checklist.append('- [ ] `make protect-check` PASSED (immutable-файлы не изменены)') finalization_checklist.append('- [ ] PR создан (merge — ручной, политика v31)') finalization_checklist_str = '\n'.join(finalization_checklist) # note из YAML (если есть) note_block = f"\n## Примечание из YAML\n\n{note}\n" if note else '' plan = f"""# План шага {step_id} — {title} > Сгенерировано: {now} > Команда пользователя: выполни шаг {step_id} > Stage: {stage} — {stage_name} > Аудит v32: план включает конкретные задачи из YAML (не только фазы) ## ⏸ ОЖИДАНИЕ ПОДТВЕРЖДЕНИЯ (gate: plan-approval) **Этот план НЕ будет выполнен без явной команды пользователя.** После прочтения плана скажите: "выполняй" — запустить все фазы по порядку "правки" — я отредактирую план, затем скажу "выполняй" "стоп" — отменить выполнение шага Пока вы не ответили, агент НЕ создаёт `_meta/step-progress.yaml`, НЕ создаёт feature-ветку, НЕ запускает субагентов. ## Контекст шага (из YAML) - **Тип:** {step_type} - **Ветка:** {branch} - **docs_target:** {docs_target} - **Verify:** `{verify}` - **Ожидаемый результат:** {expected_result} - **requires_approval:** {requires_approval}{' (' + requires_approval_reason + ')' if requires_approval_reason else ''} - **parallel_group:** {parallel_group or '(нет)'} {note_block} ## 🎯 КОНКРЕТНЫЕ ЗАДАЧИ ШАГА (из YAML — обязательно к выполнению) > Аудит v32 (Fix A): эти задачи — из `expected_result` и `verify` YAML-плана. > Агент НЕ может пропустить их, даже если фазы формально пройдены. > Контроль — в §«Чек-лист перед финализацией». ### Что должно быть сделано (expected_result → задачи) {tasks_block} ### Verify команда (обязательна к запуску) ```bash {verify} ``` Если verify FAIL — шаг НЕ финализируется. Исправляй до PASS или до 3 итераций debug-fixer. ### Файлы для создания (files_to_create из YAML) {files_create_lines} ### Файлы для коммита (files_to_commit из YAML) {files_commit_lines} ## Фазы выполнения (state machine — 11 фаз, аудит v35+v36) - [ ] **TASK_LIST** — генерация списка задач шага (task-list-generator.md, аудит v35) - Показать план пользователю (ИНФОРМАЦИОННО, аудит v36 — НЕ ждать «выполняй») - Записать task-list в docs_target → ## Task List шага - [ ] **PREFLIGHT** — создать feature-ветку, проверить контекст (step-pre-flight.md) - `git checkout main && git pull origin main && git checkout -b {branch}` - Прочитать context_to_read (см. ниже) - [ ] **PHASE1** — написать документацию для агента (docs-writer.md) - Создать `{docs_target}` с маркером `> Phase 1 — инструкция для AI-агента` - Заполнить все 9 секций Phase 1 (для step_type=implementation) - {impl_phase} - Выполнить задачи из §«КОНКРЕТНЫЕ ЗАДАЧИ ШАГА» - Создать files_to_create (если указаны) - [ ] **TESTING** — запустить verify и тесты (test-runner.md) - Запустить: `{verify}` - Записать результат в Реестр тестов (PASS/FAIL) - [ ] **CODE_REVIEW** — полный code review по 8 аспектам (code-reviewer.md, аудит v35) - P0/P1/P2/P3 severity; при P0≥1 или P1≥3 → возврат на TESTING - [ ] **PHASE2A** — переписать новые секции для человека (docs-rewriter-human.md §1-2) - Заменить маркер Phase 1 на `> Обучающая документация` - [ ] **PHASE2B** — проверить сохраняемые секции (docs-rewriter-human.md §3) - [ ] **FINAL_TESTING** — повторный verify после PHASE2B (final-test-runner.md, аудит v36) - Структурные проверки: 9 секций Phase 1, 8 маркеров Phase 2, VERIFIED_CHECKLIST - [ ] **FINAL_REVIEW** — release-level ревью перед FINALIZATION (final-reviewer.md, аудит v36) - 10 аспектов: 8 как в CODE_REVIEW + Release Readiness + Documentation Quality - При P0≥1 или P1≥3 → возврат на PHASE2A (или TESTING если проблема в коде) - [ ] **FINALIZATION** — commit + push + PR (step-finalizer.md, политика v31+v36) - Merge в main — ТОЛЬКО ручной через GitVerse web UI (локальный merge запрещён) ## Контекст для чтения (агент обязан прочитать каждый файл) {context_lines} ## Промпты субагентов (вызываются через verify_subagent.py) {prompts_lines} ## Риски шага {chr(10).join(risks)} ## ✅ Чек-лист перед финализацией (хард-гейты v32) > Аудит v32 (Fix B): `set_phase(X, completed)` теперь валидирует артефакты. > Если хоть один пункт ниже не выполнен — финализация ЗАБЛОКИРОВАНА. {finalization_checklist_str} ## Прогресс После запуска выполнения прогресс отслеживается в `_meta/step-progress-{step_id.replace('.', '-')}.yaml`. Команда просмотра: `uv run python scripts/orchestrate_step.py {step_id} --show-progress` ## Команды для пользователя - «выполняй» — запустить шаг (PREFLIGHT → ... → FINALIZATION) - «продолжи фазу» — запустить следующую фазу - «покажи прогресс» — `make step-show STEP={step_id}` - «откатить» — `make step-reset STEP={step_id}` - «стоп» — остановить выполнение """ return plan def main(): parser = argparse.ArgumentParser(description='Step orchestrator helper') parser.add_argument('step_id', help='Идентификатор шага, например 1.6') parser.add_argument('--print-step', action='store_true', help='Напечатать краткую инфу о шаге из YAML') parser.add_argument('--init-progress', action='store_true', help='Создать _meta/step-progress-<step_id>.yaml (FIX-V25-1.1: per-step; аудит v36: ack-файл НЕ требуется)') parser.add_argument('--force', action='store_true', help='Принудительно перезаписать progress-файл (только для recovery)') parser.add_argument('--no-ack', action='store_true', help='Не требовать ack-файл (аудит v36: ack-файл НЕ требуется по умолчанию; флаг сохранён для обратной совместимости)') parser.add_argument('--set-phase', nargs=2, metavar=('PHASE', 'STATUS'), help='Обновить статус фазы (с валидацией фактического выполнения)') parser.add_argument('--show-progress', action='store_true', help='Показать текущий прогресс + loop_detector action_history') parser.add_argument('--can-start', metavar='PHASE', help='Проверить, можно ли запустить фазу') parser.add_argument('--gen-plan', action='store_true', help='Сгенерировать шаблон плана на stdout') # Проблема #8: loop detector команды parser.add_argument('--record-action', metavar='ACTION', help='Записать действие в loop_detector.action_history и проверить петлю') parser.add_argument('--check-loop', action='store_true', help='Проверить loop_detector, exit 0=OK, exit 7=loop detected') # FIX-1.16: --reset-loop (реализация для make loop-reset) parser.add_argument('--reset-loop', action='store_true', help='Сбросить loop_detector и attempts для failed-фаз (для recovery после loop)') # FIX-1.17: --set-main-last-commit (для run_phase.py preflight) parser.add_argument('--set-main-last-commit', metavar='COMMIT_SHA', help='Сохранить SHA последнего коммита main (для возможного rollback)') # FIX-V25-1.2: защита от двойного fallback (subagent-lock) parser.add_argument('--acquire-subagent-lock', metavar='PROMPT_ID', help='FIX-V25-1.2: приобрести блокировку субагента для (step_id, PROMPT_ID). ' 'Exit 0 — получена, exit 1 — уже занята в пределах TTL (30 мин).') parser.add_argument('--release-subagent-lock', metavar='PROMPT_ID', help='FIX-V25-1.2: освободить блокировку субагента для (step_id, PROMPT_ID).') parser.add_argument('--check-subagent-lock', metavar='PROMPT_ID', help='FIX-V25-1.2: проверить, активна ли блокировка субагента для (step_id, PROMPT_ID). ' 'Exit 0 — активна, exit 1 — свободна.') args = parser.parse_args() # FIX-V25-1.2: subagent-lock операции не требуют существования шага в YAML # и не должны проходить check_state_sync. Они работают на уровне lock-файлов. if args.acquire_subagent_lock: ok = acquire_subagent_lock(args.step_id, args.acquire_subagent_lock) if ok: print(f"OK: lock acquired for ({args.step_id}, {args.acquire_subagent_lock})") sys.exit(0) else: print(f"LOCKED: ({args.step_id}, {args.acquire_subagent_lock}) уже занята " f"(моложе TTL={SUBAGENT_LOCK_TTL_MINUTES} мин)", file=sys.stderr) sys.exit(1) elif args.release_subagent_lock: release_subagent_lock(args.step_id, args.release_subagent_lock) print(f"OK: lock released for ({args.step_id}, {args.release_subagent_lock})") sys.exit(0) elif args.check_subagent_lock: active = check_subagent_lock(args.step_id, args.check_subagent_lock) if active: print(f"ACTIVE: ({args.step_id}, {args.check_subagent_lock}) занята") sys.exit(0) else: print(f"FREE: ({args.step_id}, {args.check_subagent_lock}) свободна") sys.exit(1) # Найти шаг в YAML (для большинства команд нужно) step = find_step_in_yaml(args.step_id) if step is None: print(f"ERROR: step '{args.step_id}' not found in any plans/*.yaml", file=sys.stderr) sys.exit(1) # FIX-1.14: проверить рассинхронизацию state-машин (после find_step) check_state_sync(args.step_id) if args.print_step: print_step(step) elif args.init_progress: init_progress_file(step, force=args.force, require_ack=not args.no_ack) elif args.set_phase: set_phase(args.step_id, args.set_phase[0], args.set_phase[1]) elif args.show_progress: show_progress(args.step_id) elif args.can_start: can_start_phase(args.step_id, args.can_start) elif args.gen_plan: print(generate_plan(step)) elif args.record_action: record_action(args.step_id, args.record_action) elif args.check_loop: check_loop(args.step_id) elif args.reset_loop: reset_loop(args.step_id) elif args.set_main_last_commit: # FIX-V25-1.1: читать/писать per-step progress-файл progress = load_progress(args.step_id) if progress is None: print("ERROR: no progress file", file=sys.stderr) sys.exit(2) progress['main_last_commit'] = args.set_main_last_commit save_progress(progress, step_id=args.step_id) print(f"main_last_commit saved: {args.set_main_last_commit[:8]}") else: parser.print_help() sys.exit(2) if __name__ == '__main__': main()