/
ump-team
/
ump-infra
Обзор
Документация
Войти
/
ump-team
/
ump-infra
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev/test
scripts/verify_subagent.py
1 450 строк
71 KB
Dmitry Kochenov
v0.0.7: фаза 11+ — расширение правил, покрытие 24/24, улучшенная валидация
20 июл 2026, 08:19
20 июл 2026, 08:19
d7decb3
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Детерминированный post-чекер для каждого субагента. Каждый промпт требовал от субагента самопроверки, но LLM может галлюцинировать. Этот скрипт детерминированно проверяет результат — без LLM-усмотрения. Запуск: uv run python3 scripts/verify_subagent.py <prompt_id> <step_id> uv run python3 scripts/verify_subagent.py docs-writer 1.5 uv run python3 scripts/verify_subagent.py code-writer 2.1 uv run python3 scripts/verify_subagent.py step-finalizer 1.0 Поддерживаемые prompt_id: - docs-writer (после PHASE1) - code-writer (после IMPLEMENTATION) - test-runner (после TESTING) - docs-rewriter-human (после PHASE2A) - step-finalizer (после FINALIZATION) - adr-writer (после ADR) - step-pre-flight (после PREFLIGHT) - step-rollback (после ROLLBACK) Exit codes: 0 = OK (все проверки пройдены) 1 = FAIL (минимум одна проверка провалена — см. детали в stdout) 2 = prompt_id или step_id не найден 3 = ошибка скрипта (crash) """ from __future__ import annotations import argparse import contextlib import json import re import shlex import subprocess import sys from datetime import datetime, timedelta 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) # .ump/ в submodule-layout PLANS_DIR = BASE / 'plans' # _meta/ в submodule-layout лежит в INFRA_DIR (.ump/_meta/), в standalone — в BASE. # 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' AGENT_LOG = META_DIR / 'agent.log' STEP_PROGRESS = META_DIR / 'step-progress.yaml' # legacy path (: per-step preferred) def _step_progress_path(step_id: str) -> Path: """: per-step progress file path. Возвращает `_meta/step-progress-<step_id>.yaml` (новый формат). Legacy `_meta/step-progress.yaml` используется только как fallback для сессий, созданных до . """ safe_id = str(step_id).replace('.', '-').replace('/', '-') return META_DIR / f'step-progress-{safe_id}.yaml' def _step_progress_done_path(step_id: str) -> Path: """: per-step finalized progress file path.""" safe_id = str(step_id).replace('.', '-').replace('/', '-') return META_DIR / f'step-progress-{safe_id}.done.yaml' def _load_step_progress(step_id: str) -> dict | None: """: загрузить per-step progress (с legacy fallback). Возвращает (progress_dict, path_used) или (None, None) если файл не найден. """ pf = _step_progress_path(step_id) if pf.exists(): try: return yaml.safe_load(pf.read_text(encoding='utf-8')) or {} except Exception: return None # Legacy fallback для сессий до if STEP_PROGRESS.exists(): try: prog = yaml.safe_load(STEP_PROGRESS.read_text(encoding='utf-8')) or {} # Только если это progress для нашего шага if str(prog.get('step_id', '')) == str(step_id): return prog except Exception: pass return None STEP_STATE = BASE / 'STEP_STATE.md' ADR_DIR = BASE / 'docs' / 'для-разработчиков' / 'adr' # Импорт agent_log.add_entry. # В submodule-layout agent_log.py лежит в INFRA_DIR/scripts/. _scripts_dir = INFRA_DIR / 'scripts' if (INFRA_DIR / 'scripts').exists() else BASE / 'scripts' sys.path.insert(0, str(_scripts_dir)) try: from agent_log import add_entry except Exception: # pragma: no cover — fallback, чтобы скрипт не падал на импорте def add_entry(message: str, entry_type: str = 'action', step: str | None = None, phase: str | None = None, details: dict | None = None) -> None: META_DIR.mkdir(parents=True, exist_ok=True) # P2-4 fix (mypy): явная аннотация — entry может содержать str и dict. entry: dict[str, object] = { 'timestamp': datetime.now().isoformat(), 'type': entry_type, 'message': message, } if step: entry['step'] = step if phase: entry['phase'] = phase if details: entry['details'] = details try: with open(AGENT_LOG, 'a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') except Exception: pass # Поддерживаемые prompt_id → человекочитаемое имя фазы # P0-5 fix: добавлены code-reviewer, final-test-runner, final-reviewer SUPPORTED_PROMPTS = { 'docs-writer': 'PHASE1', 'code-writer': 'IMPLEMENTATION', 'test-runner': 'TESTING', 'code-reviewer': 'CODE_REVIEW', # P0-5 fix 'docs-rewriter-human': 'PHASE2A', 'final-test-runner': 'FINAL_TESTING', # P0-5 fix 'final-reviewer': 'FINAL_REVIEW', # P0-5 fix 'step-finalizer': 'FINALIZATION', 'adr-writer': 'ADR', 'step-pre-flight': 'PREFLIGHT', 'step-rollback': 'ROLLBACK', } # Phase 1 обязательные секции (по docs-writer.md / run_phase.py шаблону) PHASE1_SECTIONS = [ '## Цель', '## Контекст', # префикс — покрывает «## Контекст для обязательного чтения» '## План', '## Ожидаемый результат', '## VERIFIED_CHECKLIST', '## Верификация', '## История действий агента', '## Реестр файлов шага', '## Реестр тестов шага', ] # Phase 2 (обучающий формат) обязательные секции. # : читаются из _meta/rules-constants.yaml → phase2_markers, # чтобы быть синхронизированными с единым источником истины. # Fallback на жёстко заданный список — если yaml недоступен. # Каноничное имя «## Пошаговая инструкция» (см. verify_step_completion.py, # 02-documentation-rules.md, run_phase.py шаблон). _constants_path = INFRA_META_DIR / 'rules-constants.yaml' if _constants_path.exists(): try: _constants = yaml.safe_load(_constants_path.read_text(encoding='utf-8')) PHASE2A_SECTIONS = _constants.get('phase2_markers', []) or [] except Exception: PHASE2A_SECTIONS = [] else: PHASE2A_SECTIONS = [] if not PHASE2A_SECTIONS: # Fallback (канонический список из 8 маркеров — sync с verify_step_completion.py) PHASE2A_SECTIONS = [ '> Обучающая документация', '## Что вы получите в конце шага', '## Предварительные требования', '## Почему именно так', '## Пошаговая инструкция', '## Типичные ошибки и их решения', '## Как проверить', '## Что дальше', ] PHASE1_MARKER = '> Phase 1 — инструкция для AI-агента' PHASE2_MARKER = '> Обучающая документация' # Маркеры для поиска в markdown вне код-блоков TODO_PATTERNS = [r'\bTODO\b', r'\bFIXME\b', r'\bXXX\b', r'\bplaceholder\b'] # HTML-теги и токены, которые не считаются плейсхолдерами <...> HTML_TAGS = { 'br', '/br', 'sup', '/sup', 'sub', '/sub', 'b', '/b', 'i', '/i', 'strong', '/strong', 'em', '/em', 'code', '/code', 'hr', '/hr', 'a', '/a', 'p', '/p', 'ul', '/ul', 'ol', '/ol', 'li', '/li', 'table', '/table', 'tr', '/tr', 'td', '/td', 'th', '/th', 'img', 'details', '/details', 'summary', '/summary', } # ============================================================================ # Результат проверки # ============================================================================ class Check: """Один пункт проверки. status: 'pass' | 'fail' | 'warn'.""" __slots__ = ('name', 'status', 'detail') def __init__(self, name: str, status: str, detail: str = ''): self.name = name self.status = status self.detail = detail @property def passed(self) -> bool: return self.status == 'pass' # ============================================================================ # Утилиты # ============================================================================ def find_step_in_yaml(step_id: str) -> dict | None: """Найти шаг в plans/*.yaml (кроме 99-*). Как в orchestrate_step.py.""" 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 str(step.get('id')) == str(step_id): step['_yaml_file'] = yfile.name return step return None def get_files_to_create(step: dict) -> list[str]: """Получить список файлов для создания из YAML. Поддерживает оба ключа: files_to_create (требование задачи) и files_to_commit (поле, реально используемое в существующих планах). """ files = step.get('files_to_create') or step.get('files_to_commit') or [] if isinstance(files, str): return [files] return list(files) def read_docs_target(step: dict) -> str | None: """Прочитать docs_target. Вернуть None, если файл не существует.""" docs_target = step.get('docs_target', '') if not docs_target: return None full = BASE / docs_target if not full.exists(): return None try: return full.read_text(encoding='utf-8') except Exception: return None def docs_target_path(step: dict) -> Path | None: docs_target = step.get('docs_target', '') if not docs_target: return None return BASE / docs_target def strip_code_blocks(content: str) -> str: """Вернуть content с удалёнными строками внутри ``` блоков. Простой парсер markdown: строка считается «в код-блоке», если нечётное количество ``` встретилось до неё. Строка с самим ``` тоже удаляется (это fence, не содержимое). """ out_lines: list[str] = [] in_code = False for line in content.split('\n'): # Считаем количество ``` в строке (fence может быть с языком: ```python) fences = line.count('```') if fences % 2 == 1: in_code = not in_code continue # саму строку-fence не включаем if in_code: continue out_lines.append(line) return '\n'.join(out_lines) def extract_section(content: str, header_re: str) -> str: """Извлечь тело markdown-секции по заголовку (regex для header). Возвращает текст между заголовком и следующим `## ` (или концом файла). """ pattern = re.compile(rf'{header_re}[^\n]*\n(.*?)(?=\n## |\Z)', re.DOTALL) m = pattern.search(content) return m.group(1) if m else '' def count_table_data_rows(section: str, header_keyword: str | None = None) -> int: """Подсчитать строки таблицы (| ... |), исключая заголовок и разделитель. Если header_keyword задан, строки, содержащие это слово, исключаются (например, 'Время' для таблицы истории). """ rows = re.findall(r'^\|.*\|$', section, re.MULTILINE) data_rows: list[str] = [] for r in rows: if re.match(r'^\|[\s\-:|]+\|$', r): continue # разделитель if header_keyword and header_keyword in r: continue data_rows.append(r) return len(data_rows) def find_placeholders(text: str) -> list[str]: """Найти плейсхолдеры <...> в тексте (вне код-блоков). HTML-теги и разрешённые имена (URL, owner, и т.д.) игнорируются. Возвращает список вида ['строка 12: <foo>']. """ ALLOWED = { 'username', 'owner', 'your_email@example.com', 'you', 'personal-owner', 'work', 'path', 'version', 'x.y', 'nn', 'n', 'm', 'step_id', 'tool', 'имя_инструмента', 'действие', 'url', 'url_репозитория', 'branch', 'commit', 'hash', 'sha', } findings: list[str] = [] for i, line in enumerate(text.split('\n'), 1): # Пропустить HTML-комментарии if line.strip().startswith('<!--') or line.strip().endswith('-->'): continue for m in re.finditer(r'<([^>]+)>', line): inner = m.group(1).strip().lower() if inner in HTML_TAGS: continue if inner in ALLOWED: continue # Атрибуты HTML-тегов (a href="...") — игнорировать if re.match(r'^[a-z]+\s', inner) or inner.startswith('a ') or 'href' in inner: continue findings.append(f'строка {i}: <{m.group(1)}>') return findings def find_todo_markers(text: str) -> list[str]: """Найти TODO/FIXME/XXX/placeholder (case-insensitive) вне код-блоков.""" findings: list[str] = [] for i, line in enumerate(text.split('\n'), 1): for pat in TODO_PATTERNS: if re.search(pat, line, re.IGNORECASE): findings.append(f'строка {i}: {line.strip()[:80]}') break return findings def _git(args: list[str], timeout: int = 15) -> tuple[int, str, str]: """Запустить git-команду. Вернуть (returncode, stdout, stderr).""" try: r = subprocess.run(['git'] + args, capture_output=True, text=True, cwd=BASE, timeout=timeout) return r.returncode, r.stdout.strip(), r.stderr.strip() except subprocess.TimeoutExpired: return 124, '', 'TIMEOUT' except Exception as e: return 125, '', str(e) def _run(cmd: str | list[str], timeout: int = 30) -> tuple[int, str, str]: """Запустить команду. Вернуть (returncode, stdout, stderr). P0-7 fix: ранее использовался subprocess.run(cmd, shell=True, ...), что позволяло инъекции через интерполируемые переменные (branch_name, full path). Теперь — list-form: если cmd — str, парсим через shlex.split() (без shell). Если в cmd есть shell-метасимволы — отказываемся выполнять, возвращаем error code 125 с понятным сообщением. """ try: if isinstance(cmd, str): # Проверка на shell-метасимволы — если есть, отказ. shell_metachars = ('&&', '||', ';', '|', '>', '<', '$(', '`') if any(mc in cmd for mc in shell_metachars): return 125, '', ( f'shell metachars in command (rewrite to list-form): {cmd!r}' ) args = shlex.split(cmd) else: args = cmd r = subprocess.run(args, capture_output=True, text=True, cwd=BASE, timeout=timeout) return r.returncode, r.stdout.strip(), r.stderr.strip() except subprocess.TimeoutExpired: return 124, '', 'TIMEOUT' except Exception as e: return 125, '', str(e) # ============================================================================ # Чекер: docs-writer (после PHASE1) # ============================================================================ def check_docs_writer(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] docs_path = docs_target_path(step) if docs_path is None or not docs_path.exists(): checks.append(Check('docs_target exists', 'fail', f'docs_target не указан или не существует: {step.get("docs_target", "")}')) return checks checks.append(Check('docs_target exists', 'pass', f'{step.get("docs_target")}')) content = read_docs_target(step) or '' # Phase 1 маркер if PHASE1_MARKER in content: checks.append(Check('Phase 1 marker present', 'pass')) else: checks.append(Check('Phase 1 marker present', 'fail', f'нет маркера "{PHASE1_MARKER}" — docs-writer не записал Phase 1')) # 9 обязательных секций missing = [] for sec in PHASE1_SECTIONS: # Префикс-матч для ## Контекст (покрывает «## Контекст для обязательного чтения») if sec == '## Контекст': found = bool(re.search(r'^## Контекст(\s|$)', content, re.MULTILINE)) else: found = sec in content if not found: missing.append(sec) if missing: checks.append(Check('All 9 sections present', 'fail', f'отсутствуют секции: {", ".join(missing)}')) else: checks.append(Check('All 9 sections present', 'pass')) # Плейсхолдеры <...> вне код-блоков stripped = strip_code_blocks(content) placeholders = find_placeholders(stripped) if placeholders: checks.append(Check('No placeholders outside code blocks', 'fail', f'найдено {len(placeholders)}: {placeholders[:3]}')) else: checks.append(Check('No placeholders outside code blocks', 'pass')) # TODO/FIXME/XXX/placeholder вне код-блоков todos = find_todo_markers(stripped) if todos: checks.append(Check('No TODO/FIXME/XXX markers', 'fail', f'найдено {len(todos)}: {todos[:3]}')) else: checks.append(Check('No TODO/FIXME/XXX markers', 'pass')) # VERIFIED_CHECKLIST содержит ≥ 1 пункт `- [ ]` (шаблон заполнен) vc_section = extract_section(content, r'## VERIFIED_CHECKLIST') if not vc_section: checks.append(Check('VERIFIED_CHECKLIST has items', 'fail', 'секция VERIFIED_CHECKLIST не найдена')) else: open_items = re.findall(r'^- \[ \]\s+', vc_section, re.MULTILINE) closed_items = re.findall(r'^- \[x\]\s+', vc_section, re.MULTILINE | re.IGNORECASE) total = len(open_items) + len(closed_items) if total < 1: checks.append(Check('VERIFIED_CHECKLIST has items', 'fail', 'шаблон VERIFIED_CHECKLIST пуст — нет ни одного пункта')) else: checks.append(Check('VERIFIED_CHECKLIST has items', 'pass', f'{total} пунктов (open={len(open_items)}, closed={len(closed_items)})')) # Реестр файлов шага содержит ≥ 1 строку с | (не заголовок) fr_section = extract_section(content, r'## Реестр файлов шага') fr_rows = count_table_data_rows(fr_section) if fr_rows < 1: checks.append(Check('File registry has entries', 'fail', 'Реестр файлов шага пуст (нет строк с | кроме заголовка)')) else: checks.append(Check('File registry has entries', 'pass', f'{fr_rows} записей')) # История действий содержит ≥ 1 записи (помимо заголовка таблицы) hist_section = extract_section(content, r'## История действий агента') hist_rows = count_table_data_rows(hist_section, header_keyword='Время') if hist_rows < 1: checks.append(Check('History has entries', 'fail', 'История действий пуста (нет записей помимо заголовка)')) else: # WARN, если < 5 (минимум для finalization) if hist_rows < 5: checks.append(Check('History has entries', 'warn', f'{hist_rows} записей (WARN: минимум 5 для finalization, ' f'но OK для docs-writer)')) else: checks.append(Check('History has entries', 'pass', f'{hist_rows} записей')) return checks # ============================================================================ # Чекер: code-writer (после IMPLEMENTATION) # ============================================================================ def check_code_writer(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] files = get_files_to_create(step) if not files: # Если YAML не задаёт files_to_create/files_to_commit — это допустимо # для шагов, где файлы создаются динамически. Считаем это WARN, не FAIL. checks.append(Check('files_to_create specified in YAML', 'warn', 'ни files_to_create, ни files_to_commit не заданы в YAML — ' 'проверка существования файлов пропускается')) else: for f in files: full = BASE / f if full.exists(): # P11-E4-1 fix (фаза 11, Неделя 3, E4): ранее .exists() без проверки # размера позволял агенту сделать `touch file.py` (0 bytes) и пройти # IMPLEMENTATION. Теперь дополнительно проверяем, что файл не пустой # для нетестовых файлов (test_* могут быть пустыми stub'ами). # Для .py файлов минимальный осмысленный размер — 10 байт # (например, "pass\n" = 5 байт, "import os\n" = 10 байт). try: file_size = full.stat().st_size except OSError as e: checks.append(Check(f'File exists: {f}', 'fail', f'не удалось получить размер: {e}')) continue # Test files (test_*.py, *_test.py) могут быть пустыми stub'ами — # это допустимо на ранних этапах. Для остальных файлов нулевой # размер — подозрителен. is_test_file = ( f.startswith('test_') or f.endswith('_test.py') or '/tests/' in f or '\\tests\\' in f ) if file_size == 0 and not is_test_file: checks.append(Check(f'File exists: {f}', 'fail', f'файл {f} существует, но пустой (0 байт) — ' f'возможно, агент сделал touch без реализации')) else: checks.append(Check(f'File exists: {f}', 'pass', f'{file_size} байт' if file_size else '0 байт (test stub)')) else: checks.append(Check(f'File not created: {f}', 'fail', f'файл {f} не существует на диске')) # py_compile для .py файлов py_files = [f for f in files if f.endswith('.py') and (BASE / f).exists()] for f in py_files: full = BASE / f # P0-7 fix: list-form без shell=True. sys.executable — py_compile # должен использовать тот же интерпретатор. rc, out, err = _run([sys.executable, '-m', 'py_compile', str(full)], timeout=30) if rc == 0: checks.append(Check(f'py_compile OK: {f}', 'pass')) else: # Извлечь последнюю строку ошибки (SyntaxError: ...) err_line = err.split('\n')[-1] if err else f'exit {rc}' checks.append(Check(f'py_compile failed: {f}', 'fail', f'{sys.executable} -m py_compile вернул exit {rc}: {err_line[:200]}')) # Реестр файлов шага в docs_target должен перечислять все файлы из files_to_create content = read_docs_target(step) if content is None: checks.append(Check('docs_target readable', 'fail', f'docs_target {step.get("docs_target", "")} не существует или нечитаем')) else: checks.append(Check('docs_target readable', 'pass')) fr_section = extract_section(content, r'## Реестр файлов шага') if not fr_section and files: checks.append(Check('File registry lists all files', 'fail', 'секция «Реестр файлов шага» не найдена в docs_target')) elif not files: checks.append(Check('File registry lists all files', 'warn', 'files_to_create пуст — проверка реестра пропускается')) else: missing_in_registry = [] for f in files: # Имя файла может быть указано как basename в таблице basename = Path(f).name if f in fr_section or basename in fr_section: continue missing_in_registry.append(f) if missing_in_registry: checks.append(Check('File registry lists all files', 'fail', f'не перечислены в реестре: {missing_in_registry}')) else: checks.append(Check('File registry lists all files', 'pass', f'все {len(files)} файлов упомянуты')) return checks # ============================================================================ # Чекер: test-runner (после TESTING) # ============================================================================ def check_test_runner(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] content = read_docs_target(step) if content is None: checks.append(Check('docs_target readable', 'fail', f'docs_target {step.get("docs_target", "")} не существует')) return checks checks.append(Check('docs_target readable', 'pass')) # Секция «## Реестр тестов шага» tr_section = extract_section(content, r'## Реестр тестов шага') if not tr_section: checks.append(Check('Test registry section present', 'fail', 'секция «## Реестр тестов шага» не найдена в docs_target')) else: checks.append(Check('Test registry section present', 'pass')) # PASS/PASSED и не только FAIL # P11-E4-3 fix (фаза 11, Неделя 3, E4): ранее regex применялся к сырой # секции, включая code blocks. PASS внутри ```python ... ``` (например, # в примере "if status == PASS: ...") ложно матчилося. Теперь сначала # удаляем code blocks перед regex-поиском. Используем существующий # хелпер strip_code_blocks (строка 285). tr_section_no_code = strip_code_blocks(tr_section) has_pass = bool(re.search(r'\bPASS(ed)?\b', tr_section_no_code, re.IGNORECASE)) has_fail = bool(re.search(r'\bFAIL(ed)?\b', tr_section_no_code, re.IGNORECASE)) if not has_pass: checks.append(Check('At least one PASS record', 'fail', 'в реестре тестов нет записей PASS/PASSED вне code blocks')) else: if has_fail and not has_pass: checks.append(Check('At least one PASS record', 'fail', 'только FAIL записи — нет ни одного успешного теста')) else: checks.append(Check('At least one PASS record', 'pass', 'есть PASS/PASSED запись вне code blocks')) # Проверка запуска verify-команды через agent.log за последние 30 минут recent_test_log = _has_recent_test_log(step_id, minutes=30) if recent_test_log is None: checks.append(Check('verify command logged recently', 'warn', '_meta/agent.log недоступен — невозможно проверить лог запуска')) elif recent_test_log: checks.append(Check('verify command logged recently', 'pass', 'в agent.log есть записи type=test для этого шага за последние 30 мин')) else: checks.append(Check('verify command logged recently', 'warn', 'нет записей type=test в agent.log за последние 30 минут — ' 'тесты могли запускаться вне логирования')) return checks def _has_recent_test_log(step_id: str, minutes: int = 30) -> bool | None: """Проверить, есть ли в agent.log записи с type=test и step=step_id за последние `minutes` минут. Вернуть None, если лог недоступен. """ if not AGENT_LOG.exists(): return None cutoff = datetime.now() - timedelta(minutes=minutes) try: with open(AGENT_LOG, encoding='utf-8') as f: for line in f: try: entry = json.loads(line) except Exception: continue if entry.get('type') != 'test': continue if str(entry.get('step', '')) != str(step_id): continue ts_str = entry.get('timestamp', '') if not ts_str: continue try: ts = datetime.fromisoformat(ts_str) except Exception: continue if ts >= cutoff: return True except Exception: return None return False # ============================================================================ # P0-5 fix: Чекер: code-reviewer (после CODE_REVIEW) # ============================================================================ def check_code_reviewer(step: dict, step_id: str) -> list[Check]: """Проверка code-reviewer: должен быть раздел '## Code Review' в docs_target.""" checks: list[Check] = [] content = read_docs_target(step) if content is None: checks.append(Check('docs_target readable', 'fail', f'docs_target {step.get("docs_target", "")} не существует')) return checks checks.append(Check('docs_target readable', 'pass')) # Секция «## Code Review» cr_section = extract_section(content, r'## Code Review') if not cr_section: checks.append(Check('Code Review section present', 'fail', 'секция «## Code Review» не найдена — code-reviewer не отработал')) else: checks.append(Check('Code Review section present', 'pass')) # Проверка наличия P0/P1/P2 меток или решения по ревью has_severity = bool(re.search(r'\bP[012]\b', cr_section)) # P11-E4-4 fix (фаза 11, Неделя 3, E4): ранее `\b(ok)\b` матчило любое # вхождение "ok" в любом контексте (включая "ok, я понял" в комментарии # или "lookup" — нет, \b тут спасает, но "ok" в произвольной строке # всё равно ложно срабатывало). Теперь требуем более конкретные # decision-маркеры: «итог: ok», «решение: ok», «review: ok», либо # полные слова approve/reject/changes requested. Также игнорируем # code blocks (через strip_code_blocks) — "ok" в коде не должен # считаться решением ревью. cr_section_no_code = strip_code_blocks(cr_section) # Decision-фразы ищем как "label: value" — require colon before ok. has_decision = bool(re.search( r'(?:итог|решение|review|вердикт|结论)\s*[:=]\s*(ok|approve|reject|changes requested|одобрено|отклонено|правки запрошены)', cr_section_no_code, re.IGNORECASE, )) or bool(re.search( r'\b(approve|reject|changes requested)\b', cr_section_no_code, re.IGNORECASE, )) if has_severity or has_decision: checks.append(Check('Review summary present', 'pass', 'найдены метки P0/P1/P2 или явное решение по ревью')) else: checks.append(Check('Review summary present', 'warn', 'нет меток P0/P1/P2 или явного решения (итог/решение: ok/approve/...) — ' 'возможно, ревью не завершено')) return checks # ============================================================================ # P0-5 fix: Чекер: final-test-runner (после FINAL_TESTING) # ============================================================================ def check_final_test_runner(step: dict, step_id: str) -> list[Check]: """Проверка final-test-runner: должен быть раздел '## Final Testing' в docs_target.""" checks: list[Check] = [] content = read_docs_target(step) if content is None: checks.append(Check('docs_target readable', 'fail', f'docs_target {step.get("docs_target", "")} не существует')) return checks checks.append(Check('docs_target readable', 'pass')) # Секция «## Final Testing» ft_section = extract_section(content, r'## Final Testing') if not ft_section: checks.append(Check('Final Testing section present', 'fail', 'секция «## Final Testing» не найдена — final-test-runner не отработал')) else: checks.append(Check('Final Testing section present', 'pass')) # Проверка записи PASS в реестре тестов (повторный verify после PHASE2B) tr_section = extract_section(content, r'## Реестр тестов шага') if tr_section: has_pass_after_final = bool(re.search(r'\bPASS(ed)?\b.*final', tr_section, re.IGNORECASE)) or \ bool(re.search(r'final.*\bPASS(ed)?\b', tr_section, re.IGNORECASE)) if has_pass_after_final: checks.append(Check('Final verify PASS recorded', 'pass', 'найдена запись PASS в финальной проверке')) else: # Мягкая проверка: если есть PASS после раздела Final Testing — OK checks.append(Check('Final verify PASS recorded', 'warn', 'явной записи final-PASS не найдено — проверь реестр тестов вручную')) else: checks.append(Check('Final verify PASS recorded', 'warn', 'секция «## Реестр тестов шага» не найдена')) return checks # ============================================================================ # P0-5 fix: Чекер: final-reviewer (после FINAL_REVIEW) # ============================================================================ def check_final_reviewer(step: dict, step_id: str) -> list[Check]: """Проверка final-reviewer: должен быть раздел '## Final Review' в docs_target.""" checks: list[Check] = [] content = read_docs_target(step) if content is None: checks.append(Check('docs_target readable', 'fail', f'docs_target {step.get("docs_target", "")} не существует')) return checks checks.append(Check('docs_target readable', 'pass')) # Секция «## Final Review» fr_section = extract_section(content, r'## Final Review') if not fr_section: checks.append(Check('Final Review section present', 'fail', 'секция «## Final Review» не найдена — final-reviewer не отработал')) else: checks.append(Check('Final Review section present', 'pass')) # Проверка release readiness has_release = bool(re.search(r'\b(release|ready|approved)\b', fr_section, re.IGNORECASE)) has_blocking = bool(re.search(r'\b(blocking|reject|hold)\b', fr_section, re.IGNORECASE)) if has_release and not has_blocking: checks.append(Check('Release readiness OK', 'pass', 'найдены маркеры готовности к релизу')) elif has_blocking: checks.append(Check('Release readiness OK', 'fail', 'найдены блокирующие маркеры — release hold')) else: checks.append(Check('Release readiness OK', 'warn', 'нет явных маркеров release-ready — проверь вручную')) return checks # ============================================================================ # Чекер: docs-rewriter-human (после PHASE2A) # ============================================================================ def check_docs_rewriter_human(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] content = read_docs_target(step) if content is None: checks.append(Check('docs_target readable', 'fail', f'docs_target {step.get("docs_target", "")} не существует')) return checks checks.append(Check('docs_target readable', 'pass')) # Содержит Phase 2 маркер if PHASE2_MARKER in content: checks.append(Check('Phase 2 marker present', 'pass')) else: checks.append(Check('Phase 2 marker present', 'fail', f'нет маркера "{PHASE2_MARKER}"')) # НЕ содержит Phase 1 маркер if PHASE1_MARKER in content: checks.append(Check('Phase 1 marker removed', 'fail', f'остался маркер "{PHASE1_MARKER}" — Phase 2 не применена корректно')) else: checks.append(Check('Phase 1 marker removed', 'pass')) # Phase 2 обязательные секции missing = [] for sec in PHASE2A_SECTIONS: if sec == '## Контекст': found = bool(re.search(r'^## Контекст(\s|$)', content, re.MULTILINE)) elif sec == '## Пошаговая инструкция': # : каноничное имя «## Пошаговая инструкция». # Обратная совместимость: принимаем и устаревший вариант «## Пошаговое руководство». found = bool(re.search(r'^## Пошаг(овая инструкция|овое руководство)(\s|$)', content, re.MULTILINE)) else: found = sec in content if not found: missing.append(sec) if missing: checks.append(Check('Phase 2 sections present', 'fail', f'отсутствуют секции: {", ".join(missing)}')) else: checks.append(Check('Phase 2 sections present', 'pass', f'все {len(PHASE2A_SECTIONS)} секций на месте')) # Плейсхолдеры <...> вне код-блоков stripped = strip_code_blocks(content) placeholders = find_placeholders(stripped) if placeholders: checks.append(Check('No placeholders outside code blocks', 'fail', f'найдено {len(placeholders)}: {placeholders[:3]}')) else: checks.append(Check('No placeholders outside code blocks', 'pass')) # : нет dangling blockquote (строки, начинающиеся с '>' и не # являющиеся Phase 2 маркером). После замены Phase 1 → Phase 2 маркера # в run_phase.py:phase_phase2a могла остаться 3-я строка Phase 1 маркера. # Phase 2 маркер состоит из 2 строк '> Обучающая документация...': ph2_lines = [ '> Обучающая документация. После прохождения этого материала вы сможете', '> воспроизвести шаг вручную без AI-агента.', ] dangling = [] for line in stripped.split('\n'): ln = line.strip() if not ln.startswith('>'): continue # Допустимые blockquote-строки — Phase 2 маркер if ln in ph2_lines or any(ln.startswith(p.rstrip()) for p in ph2_lines): continue # Допустимо: blockquote с продолжением, если не выглядит как обрывок # Phase 1 маркера ("> инструкция для AI-агента" и т.п.) if 'инструкция для AI-агента' in ln or 'Phase 1' in ln: dangling.append(ln) if dangling: checks.append(Check('No dangling Phase 1 blockquote', 'fail', f'обнаружены обрывки Phase 1 маркера: {dangling[:3]}')) else: checks.append(Check('No dangling Phase 1 blockquote', 'pass')) return checks # ============================================================================ # Чекер: step-finalizer (после FINALIZATION) # ============================================================================ def check_step_finalizer(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] # STEP_STATE.md → COMPLETED_STEPS содержит строку с <step_id> if not STEP_STATE.exists(): checks.append(Check('STEP_STATE.md exists', 'fail', 'STEP_STATE.md не существует')) else: checks.append(Check('STEP_STATE.md exists', 'pass')) state_content = STEP_STATE.read_text(encoding='utf-8') # COMPLETED_STEPS секция cs_match = re.search(r'## COMPLETED_STEPS\s*\n(.*?)(?=\n## |\Z)', state_content, re.DOTALL) if not cs_match: checks.append(Check('COMPLETED_STEPS has step row', 'fail', 'секция COMPLETED_STEPS не найдена в STEP_STATE.md')) else: cs_section = cs_match.group(1) # Строка таблицы, начинающаяся с | и содержащая step_id row_found = False for line in cs_section.split('\n'): if line.strip().startswith('|') and step_id in line: # Не заголовок и не разделитель if not re.match(r'^\|[\s\-:|]+\|$', line) and 'Шаг' not in line: row_found = True break if row_found: checks.append(Check('COMPLETED_STEPS has step row', 'pass', f'строка с step_id={step_id} найдена')) else: checks.append(Check('COMPLETED_STEPS has step row', 'fail', f'в таблице COMPLETED_STEPS нет строки с step_id={step_id}')) # CURRENT_PHASE: awaiting-continue cp_match = re.search(r'CURRENT_PHASE:\s*(\S+)', state_content) if cp_match: cp_val = cp_match.group(1).strip() if cp_val == 'awaiting-continue': checks.append(Check('CURRENT_PHASE=awaiting-continue', 'pass')) else: checks.append(Check('CURRENT_PHASE=awaiting-continue', 'fail', f'CURRENT_PHASE={cp_val} (ожидалось awaiting-continue)')) else: checks.append(Check('CURRENT_PHASE=awaiting-continue', 'fail', 'поле CURRENT_PHASE не найдено в STEP_STATE.md')) # feature-ветка существует (НЕ удалена — политика finalize_step.py:14): # P11-A2-17 fix (фаза 11, Неделя 3, P0): ранее check_step_finalizer требовал # удаления feature-ветки, но finalize_step.py:14 явно заявляет # «Feature-ветка НЕ удаляется (для истории и возможного rollback)». Это # расхождение приводило к постоянному false-positive FAIL на критическом # пути верификации финализации. Теперь check проходит, если feature-ветка # существует (т.е. finalize_step её создал и не удалил — штатное поведение). # # P11-E4-2 fix (фаза 11, Неделя 3, E4): ранее в первом фиксе A2-17 обе # ветки (exists/not-exists) возвращали PASS — это позволяло агенту пропустить # создание feature-ветки. Теперь: # - branch exists → PASS (штатное поведение finalize_step) # - branch missing → WARN (возможно finalize_step не запускался, либо # ветка уже слита и удалена вручную — человек должен подтвердить) rc, out, err = _git(['branch', '--list', f'feature/step-{step_id}-*']) if rc == 0: branches = [b.strip() for b in out.split('\n') if b.strip()] if branches: checks.append(Check('feature branch exists (not deleted, per finalize_step.py:14)', 'pass', f'найдена feature-ветка: {branches[0]} (сохранена для истории/rollback)')) else: checks.append(Check('feature branch exists', 'warn', f'локальных feature/step-{step_id}-* веток нет — ' f'убедитесь, что finalize_step.py запускался и PR был создан')) else: checks.append(Check('feature branch check', 'warn', f'git branch --list вернул rc={rc}: {err[:120]}')) # : per-step progress file переименован в step-progress-<step_id>.done.yaml per_step_file = _step_progress_path(step_id) done_file = _step_progress_done_path(step_id) if per_step_file.exists(): checks.append(Check('step-progress.yaml renamed/deleted', 'fail', f'{per_step_file.name} ещё существует — должен быть переименован в {done_file.name}')) elif done_file.exists(): checks.append(Check('step-progress.yaml renamed/deleted', 'pass', f'переименован в {done_file.name}')) elif STEP_PROGRESS.exists(): # Legacy: единый файл (до ) — проверяем, что он не привязан к текущему шагу try: prog = yaml.safe_load(STEP_PROGRESS.read_text(encoding='utf-8')) or {} prog_step = str(prog.get('step_id', '')) except Exception: prog_step = '?' if prog_step == str(step_id): checks.append(Check('step-progress.yaml renamed/deleted', 'fail', f'_meta/step-progress.yaml (legacy) ещё существует и привязан к шагу ' f'{step_id} — должен быть переименован в .done.yaml')) else: checks.append(Check('step-progress.yaml renamed/deleted', 'warn', f'step-progress.yaml (legacy) существует, но для шага {prog_step} ' f'(не {step_id}) — вероятно ОК')) else: # Ни per-step, ни legacy, ни .done.yaml — значит, удалён checks.append(Check('step-progress.yaml renamed/deleted', 'pass', 'step-progress файл удалён (или не создавался)')) # git log main содержит коммит feat(step-<step_id>): в последних 5 коммитах rc, out, err = _git(['log', '-5', '--pretty=%s']) if rc == 0: commit_pattern = f'feat(step-{step_id}):' if commit_pattern in out: checks.append(Check('feat(step-...) commit in main', 'pass', 'коммит найден в последних 5 коммитах main')) else: # Возможно, мы не на main — проверить любую ветку rc2, out2, _ = _git(['log', '-5', 'main', '--pretty=%s']) if rc2 == 0 and commit_pattern in out2: checks.append(Check('feat(step-...) commit in main', 'pass', 'коммит найден в main (через git log main)')) else: checks.append(Check('feat(step-...) commit in main', 'fail', f'коммит "{commit_pattern}" не найден в последних 5 коммитах.\n' f'Темы коммитов:\n{out[:300]}')) else: checks.append(Check('feat(step-...) commit in main', 'warn', f'git log недоступен (rc={rc}): {err[:120]}')) return checks # ============================================================================ # Чекер: adr-writer (после ADR) # ============================================================================ def check_adr_writer(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] if not ADR_DIR.exists(): checks.append(Check('ADR directory exists', 'fail', f'директория {ADR_DIR.relative_to(BASE)} не существует')) return checks checks.append(Check('ADR directory exists', 'pass')) # Найти ADR-файлы, созданные/изменённые этим шагом. # Эвристика: ADR-файлы упомянуты в verify-команде шага. verify_cmds = step.get('verify', []) or [] verify_text = ' '.join(verify_cmds) if isinstance(verify_cmds, list) else str(verify_cmds) # Извлечь пути вида docs/для-разработчиков/adr/NNNN-*.md из verify adr_paths_in_step = re.findall( r'docs/для-разработчиков/adr/[0-9]{4}-[^\s\'"]+\.md', verify_text, ) # Если verify не упоминает конкретные файлы — взять все ADR-файлы в директории if not adr_paths_in_step: all_adr = sorted(ADR_DIR.glob('[0-9][0-9][0-9][0-9]-*.md')) adr_files_rel = [str(p.relative_to(BASE)) for p in all_adr] if not adr_files_rel: checks.append(Check('ADR file exists', 'fail', 'в директории adr/ нет файлов NNNN-*.md и verify не указывает ADR-файлы')) return checks checks.append(Check('ADR file exists', 'warn', f'verify не указывает конкретные ADR-файлы — проверяем все {len(adr_files_rel)} ' f'в директории: {adr_files_rel[:3]}')) adr_files_rel_to_check = adr_files_rel else: adr_files_rel_to_check = adr_paths_in_step # Проверить, что каждый файл существует missing_files = [] for rel in adr_paths_in_step: if not (BASE / rel).exists(): missing_files.append(rel) if missing_files: checks.append(Check('ADR file exists', 'fail', f'ADR-файлы не найдены: {missing_files}')) # Продолжить проверку тех, что существуют adr_files_rel_to_check = [r for r in adr_paths_in_step if (BASE / r).exists()] if not adr_files_rel_to_check: return checks else: checks.append(Check('ADR file exists', 'pass', f'{len(adr_paths_in_step)} ADR-файл(ов) найдено: ' f'{[Path(r).name for r in adr_paths_in_step]}')) # Маркер > ADR-STATUS: marker_missing = [] for rel in adr_files_rel_to_check: content = (BASE / rel).read_text(encoding='utf-8') if '> ADR-STATUS:' not in content: marker_missing.append(rel) if marker_missing: checks.append(Check('ADR-STATUS marker present', 'fail', f'нет маркера "> ADR-STATUS:" в: {[Path(r).name for r in marker_missing]}')) else: checks.append(Check('ADR-STATUS marker present', 'pass', f'все {len(adr_files_rel_to_check)} ADR содержат маркер')) # Уникальный 4-значный префикс (нет другого ADR-файла с тем же префиксом) all_adr_files = sorted(ADR_DIR.glob('[0-9][0-9][0-9][0-9]-*.md')) prefix_to_files: dict[str, list[str]] = {} for p in all_adr_files: prefix = p.name[:4] prefix_to_files.setdefault(prefix, []).append(p.name) duplicates = {pfx: names for pfx, names in prefix_to_files.items() if len(names) > 1} if duplicates: checks.append(Check('Unique ADR number prefix', 'fail', f'дубликаты 4-значных префиксов: {duplicates}')) else: checks.append(Check('Unique ADR number prefix', 'pass', f'{len(all_adr_files)} ADR-файлов, все префиксы уникальны')) return checks # ============================================================================ # Чекер: step-pre-flight (после PREFLIGHT) # ============================================================================ def check_step_pre_flight(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] # feature-ветка создана rc, out, err = _git(['branch', '--show-current']) if rc == 0: branch = out.strip() if branch.startswith('feature/step-'): checks.append(Check('feature branch created', 'pass', f'текущая ветка: {branch}')) else: expected = step.get('branch', f'feature/step-{step_id}') checks.append(Check('feature branch created', 'fail', f'текущая ветка "{branch}" не начинается с "feature/step-". ' f'Ожидалась: {expected}')) else: checks.append(Check('feature branch created', 'fail', f'git branch --show-current failed (rc={rc}): {err[:120]}')) # : _meta/step-progress-<step_id>.yaml существует (per-step) per_step_file = _step_progress_path(step_id) if not per_step_file.exists() and not STEP_PROGRESS.exists(): checks.append(Check('step-progress.yaml exists', 'fail', f'{per_step_file.name} (и legacy step-progress.yaml) не существует — ' f'pre-flight не инициализировал прогресс')) return checks checks.append(Check('step-progress.yaml exists', 'pass')) prog = _load_step_progress(step_id) if prog is None: # Файл существует, но не парсится checks.append(Check('step-progress.yaml readable', 'fail', 'YAML parse error или файл пустой')) return checks checks.append(Check('step-progress.yaml readable', 'pass')) # main_last_commit поле (критично для rollback) if 'main_last_commit' in prog and prog['main_last_commit']: checks.append(Check('main_last_commit saved', 'pass', f'main_last_commit={prog["main_last_commit"]}')) else: checks.append(Check('main_last_commit saved', 'fail', 'поле main_last_commit отсутствует или пусто в step-progress.yaml — ' 'rollback будет невозможен для fail_phase=finalization')) # current_phase != FAILED cp = str(prog.get('current_phase', '')) if cp.upper() == 'FAILED': checks.append(Check('current_phase != FAILED', 'fail', 'current_phase=FAILED — шаг в состоянии отказа')) elif not cp: checks.append(Check('current_phase != FAILED', 'warn', 'current_phase пуст')) else: checks.append(Check('current_phase != FAILED', 'pass', f'current_phase={cp}')) return checks # ============================================================================ # Чекер: step-rollback (после ROLLBACK) # ============================================================================ def check_step_rollback(step: dict, step_id: str) -> list[Check]: checks: list[Check] = [] # docs_target удалён (или восстановлен к состоянию до шага) docs_path = docs_target_path(step) if docs_path is None: checks.append(Check('docs_target removed/restored', 'warn', 'docs_target не указан в YAML')) elif not docs_path.exists(): checks.append(Check('docs_target removed/restored', 'pass', f'{docs_path.relative_to(BASE)} удалён')) else: # Файл существует — мог быть восстановлен к состоянию до шага. # Проверить, что в нём нет Phase 1/2 маркеров этого шага (эвристика): # если есть Phase 1 маркер «> Phase 1 — инструкция» — возможно, откат неполный. content = docs_path.read_text(encoding='utf-8') if PHASE1_MARKER in content or PHASE2_MARKER in content: checks.append(Check('docs_target removed/restored', 'fail', f'{docs_path.relative_to(BASE)} существует и содержит Phase-маркеры — ' f'откат не вернул файл к до-шаговому состоянию')) else: checks.append(Check('docs_target removed/restored', 'warn', f'{docs_path.relative_to(BASE)} существует, но без Phase-маркеров — ' f'возможно, восстановлен к предыдущему состоянию')) # feature-ветка удалена локально rc, out, _ = _git(['branch', '--list', f'feature/step-{step_id}-*']) if rc == 0: branches = [b.strip() for b in out.split('\n') if b.strip()] if not branches: checks.append(Check('feature branch deleted locally', 'pass')) else: checks.append(Check('feature branch deleted locally', 'fail', f'ветки ещё существуют: {branches}')) else: checks.append(Check('feature branch deleted locally', 'warn', f'git branch --list вернул rc={rc}')) # git status чистый (нет untracked, кроме _meta/) rc, out, _ = _git(['status', '--porcelain']) if rc == 0: dirty = [] for line in out.split('\n'): if not line.strip(): continue # Porcelain format: XY filename path = line[3:].strip().strip('"') if path.startswith('_meta/'): continue dirty.append(line.strip()) if not dirty: checks.append(Check('git status clean (except _meta/)', 'pass')) else: checks.append(Check('git status clean (except _meta/)', 'fail', f'нечистые пути: {dirty[:5]}')) else: checks.append(Check('git status clean (except _meta/)', 'warn', f'git status failed (rc={rc})')) # PR закрыт: gh pr list --head <branch> пусто (если был PR) # Определить имя ветки из YAML branch_name = step.get('branch', f'feature/step-{step_id}') # P0-7 fix: list-form без shell=True. `2>&1` не нужно — capture_output # захватывает оба потока. branch_name передаётся как один argv-элемент, # shell-метасимволы в нём не сработают. rc, out, err = _run(['gh', 'pr', 'list', '--head', branch_name, '--state', 'open'], timeout=20) if rc == 0: if not out.strip(): checks.append(Check('PR closed (if existed)', 'pass', 'открытых PR для ветки нет')) else: checks.append(Check('PR closed (if existed)', 'fail', f'найдены открытые PR:\n{out[:300]}')) else: # gh может быть не установлен или нет remote — это WARN checks.append(Check('PR closed (if existed)', 'warn', f'gh pr list недоступен (rc={rc}): {err[:120]}')) return checks # ============================================================================ # Диспетчер # ============================================================================ CHECKERS = { 'docs-writer': check_docs_writer, 'code-writer': check_code_writer, 'test-runner': check_test_runner, 'code-reviewer': check_code_reviewer, # P0-5 fix 'docs-rewriter-human': check_docs_rewriter_human, 'final-test-runner': check_final_test_runner, # P0-5 fix 'final-reviewer': check_final_reviewer, # P0-5 fix 'step-finalizer': check_step_finalizer, 'adr-writer': check_adr_writer, 'step-pre-flight': check_step_pre_flight, 'step-rollback': check_step_rollback, } def format_check(check: Check) -> str: icon = {'pass': '✓', 'fail': '✗', 'warn': '!'}[check.status] suffix = f': {check.detail}' if check.detail else '' return f' [{icon}] {check.name}{suffix}' def main() -> int: parser = argparse.ArgumentParser( description='Детерминированный post-чекер для субагента (ERR-P-030 / ERR-FB-001)', usage='verify_subagent.py <prompt_id> <step_id>', ) parser.add_argument('prompt_id', help='ID промпта (docs-writer, code-writer, ...)') parser.add_argument('step_id', help='ID шага, например 1.5') args = parser.parse_args() prompt_id = args.prompt_id step_id = args.step_id # Валидация prompt_id if prompt_id not in SUPPORTED_PROMPTS: print(f'ERROR: неизвестный prompt_id "{prompt_id}".', file=sys.stderr) print(f'Поддерживаемые: {", ".join(sorted(SUPPORTED_PROMPTS))}', file=sys.stderr) sys.exit(2) # Поиск шага в YAML try: step = find_step_in_yaml(step_id) except Exception as e: print(f'CRASH при поиске шага: {e}', file=sys.stderr) sys.exit(3) if step is None: print(f'ERROR: шаг "{step_id}" не найден в plans/*.yaml', file=sys.stderr) sys.exit(2) # Запуск чекера phase_name = SUPPORTED_PROMPTS[prompt_id] checker = CHECKERS[prompt_id] print(f'verify_subagent.py: {prompt_id} for step {step_id}') print(f' (phase: {phase_name}, yaml: {step.get("_yaml_file", "?")})') print() try: checks = checker(step, step_id) except Exception as e: print(f'CRASH в чекере {prompt_id}: {e}', file=sys.stderr) add_entry( f'verify_subagent.py CRASH: prompt={prompt_id} step={step_id} err={e}', entry_type='error', step=step_id, details={'prompt_id': prompt_id, 'step_id': step_id, 'result': 'crash', 'error': str(e)}, ) sys.exit(3) # Печать результатов for c in checks: print(format_check(c)) # Подсчёт total = len(checks) passed = sum(1 for c in checks if c.status == 'pass') failed = sum(1 for c in checks if c.status == 'fail') warns = sum(1 for c in checks if c.status == 'warn') print() if failed > 0: print(f'Result: FAIL ({passed}/{total} checks passed, {warns} warns)') # Подсказка по действию action_hint = _action_hint(prompt_id, checks) if action_hint: print(f'Action required: {action_hint}') result_str = 'FAIL' exit_code = 1 else: warn_note = f', {warns} warns' if warns else '' print(f'Result: PASS ({passed}/{total} checks passed{warn_note})') result_str = 'PASS' if warns == 0 else 'PASS_WITH_WARN' exit_code = 0 # Логирование add_entry( f'verify_subagent.py {prompt_id} for step {step_id}: {result_str} ' f'({passed}/{total} passed, {failed} failed, {warns} warns)', entry_type='verify', step=step_id, phase=phase_name, details={ 'prompt_id': prompt_id, 'step_id': step_id, 'result': result_str, 'passed': passed, 'failed': failed, 'warns': warns, 'total': total, 'checks': [ {'name': c.name, 'status': c.status, 'detail': c.detail} for c in checks ], }, ) sys.exit(exit_code) def _action_hint(prompt_id: str, checks: list[Check]) -> str: """Сгенерировать человекочитаемую подсказку по следующему действию при FAIL.""" failed_names = [c.name for c in checks if c.status == 'fail'] if prompt_id == 'docs-writer': return 'перезапустить docs-writer.md — Phase 1 документация некорректна' if prompt_id == 'code-writer': if any('File not created' in n for n in failed_names): return 'перезапустить code-writer.md для создания недостающих файлов' if any('py_compile' in n for n in failed_names): return 'исправить синтаксические ошибки в .py файлах и перезапустить code-writer.md' return 'проверить docs_target → Реестр файлов шага и перезапустить code-writer.md' if prompt_id == 'test-runner': return 'перезапустить test-runner.md — verify-команда не записала PASS в реестр тестов' if prompt_id == 'docs-rewriter-human': return 'перезапустить docs-rewriter-human.md — Phase 2 (обучающий формат) не применена' if prompt_id == 'step-finalizer': return 'проверить finalize_step.py и/или step-finalizer.md — финализация незавершена' if prompt_id == 'adr-writer': return 'перезапустить adr-writer.md — ADR-файлы отсутствуют или некорректны' if prompt_id == 'step-pre-flight': return 'перезапустить step-pre-flight.md — pre-flight не завершён (ветка/main_last_commit)' if prompt_id == 'step-rollback': return 'проверить step-rollback.md — откат выполнен не полностью' return '' if __name__ == '__main__': try: main() except KeyboardInterrupt: print('\nInterrupted', file=sys.stderr) sys.exit(3) except SystemExit: raise except Exception as e: # Любой непредвиденный сбой → exit 3 import traceback print(f'CRASH: {e}', file=sys.stderr) traceback.print_exc() with contextlib.suppress(Exception): add_entry( f'verify_subagent.py UNEXPECTED CRASH: {e}', entry_type='error', details={'error': str(e), 'traceback': traceback.format_exc()[:2000]}, ) sys.exit(3)