/
kochenov
/
uni
Обзор
Документация
Войти
/
kochenov
/
uni
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
scripts/finalize_step.py
930 строк
52 KB
Dmitry Kochenov
Initial commit: UMP base structure
16 июл 2026, 21:14
16 июл 2026, 21:14
86b4d0e
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Автоматический финализатор шага (аудит v11, обновлён v31). Заменяет чтение step-finalizer.md (46KB) одной командой. Выполняет (аудит v31): 1. Проверка docs_target (Phase 2, VERIFIED_CHECKLIST, история ≥ 5) 2. Многосторонняя проверка через verify_step_completion.py 3. Git: commit в feature-ветке → push → создать PR ⚠ Merge в main НЕ делается автоматически (политика v31) — пользователь merge'ит через GitVerse web UI, затем говорит «merge готов» 4. Обновление STEP_STATE.md (CURRENT_PHASE=awaiting-merge, COMPLETED_STEPS) 5. Feature-ветка НЕ удаляется (для истории и возможного rollback) 6. Обновление снимка файлов (detect_manual_changes.py --update-snapshot) 7. Логирование всех действий После merge пользователь запускает: make diagnostics STEP=X.Y (пост-merge диагностика с сохранением лога в _meta/diagnostics/) Запуск: uv run python scripts/finalize_step.py <step_id> uv run python scripts/finalize_step.py 1.0 Exit codes: 0 — шаг финализирован успешно (PR создан, ожидание ручного merge) 1 — ошибка (проверки не пройдены, git конфликт, и т.д.) 2 — шаг не найден """ import argparse import os import re import subprocess from subprocess import PIPE import sys from datetime import datetime from pathlib import Path import yaml BASE = Path(__file__).parent.parent PLANS_DIR = BASE / 'plans' # Импорт функций из agent_log sys.path.insert(0, str(BASE / 'scripts')) from agent_log import add_entry def find_step(step_id: str) -> tuple[dict, str, str] | None: """Найти шаг в YAML. Вернуть (step_dict, yaml_filename, next_step_dict).""" 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 steps = data.get('steps', []) for i, step in enumerate(steps): if step.get('id') == step_id: next_step = steps[i + 1] if i + 1 < len(steps) else None return step, yfile.name, next_step return None def run(cmd: str, check: bool = True, cwd: Path | None = None) -> subprocess.CompletedProcess: """Запустить shell-команду.""" if cwd is None: cwd = BASE result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd, timeout=120) if check and result.returncode != 0: print(f'ERROR: command failed: {cmd}', file=sys.stderr) print(f'stdout: {result.stdout[:500]}', file=sys.stderr) print(f'stderr: {result.stderr[:500]}', file=sys.stderr) return result if check and result.returncode != 0: print(f'ERROR: command failed: {cmd}', file=sys.stderr) print(f'stdout: {result.stdout[:500]}', file=sys.stderr) print(f'stderr: {result.stderr[:500]}', file=sys.stderr) return result def run_list(args: list[str], check: bool = True, cwd: Path | None = None) -> subprocess.CompletedProcess: """Запустить команду через subprocess.run с list form (без shell=True).""" if cwd is None: cwd = BASE result = subprocess.run(args, capture_output=True, text=True, cwd=cwd, timeout=120) if check and result.returncode != 0: print(f'ERROR: command failed: {" ".join(args)}', file=sys.stderr) print(f'stdout: {result.stdout[:500]}', file=sys.stderr) print(f'stderr: {result.stderr[:500]}', file=sys.stderr) return result def atomic_write_text(filepath: Path, content: str) -> None: """FIX-2.4: атомарная запись текстового файла через temp + os.replace.""" import tempfile fd, tmp_path = tempfile.mkstemp(dir=str(filepath.parent), prefix='.tmp-', suffix=filepath.suffix or '.tmp') try: with os.fdopen(fd, 'w', encoding='utf-8') as f: f.write(content) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, filepath) except Exception: try: os.unlink(tmp_path) except OSError: pass raise def log(msg: str, step: str, entry_type: str = 'action', details: dict | None = None): """Логировать действие.""" print(f'[{entry_type}] {msg}') add_entry(msg, entry_type, step=step, details=details) def check_docs_target(step: dict, step_id: str) -> tuple[bool, str]: """Проверить docs_target. Аудит v22: для step_type=bootstrap упрощённая проверка — НЕ требуется Phase 2 маркер и 11 обязательных секций. Достаточно: - VERIFIED_CHECKLIST закрыт - История ≥ 3 записей """ docs_target = step.get('docs_target', '') if not docs_target: return False, 'docs_target не указан' full = BASE / docs_target if not full.exists(): return False, f'файл {docs_target} не существует' content = full.read_text(encoding='utf-8') step_type = step.get('step_type', '') # Для bootstrap-шагов — упрощённая проверка (аудит v22) if step_type == 'bootstrap': # VERIFIED_CHECKLIST — все [x] match = re.search(r'## VERIFIED_CHECKLIST\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if not match: return False, 'секция VERIFIED_CHECKLIST не найдена (требуется для bootstrap)' open_items = re.findall(r'^- \[ \]\s+(.+)$', match.group(1), re.MULTILINE) if open_items: return False, f'незакрытые пункты VERIFIED_CHECKLIST: {open_items[:3]}' # История ≥ 3 записей match = re.search(r'## История действий агента\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if match: rows = re.findall(r'^\|.*\|$', match.group(1), re.MULTILINE) data_rows = [r for r in rows if not re.match(r'^\|[\s\-:|]+\|$', r) and 'Время' not in r] if len(data_rows) < 3: return False, f'история действий: {len(data_rows)} записей (нужно ≥ 3 для bootstrap)' else: return False, 'секция История действий не найдена' return True, 'OK (bootstrap: VERIFIED_CHECKLIST + история ≥ 3)' # Для остальных шагов — полная проверка # Phase 2 маркер if '> Phase 1 — инструкция для AI-агента' in content and '> Обучающая документация' not in content: return False, 'Phase 2 не применена (остался Phase 1 маркер)' # VERIFIED_CHECKLIST — все [x] match = re.search(r'## VERIFIED_CHECKLIST\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if not match: return False, 'секция VERIFIED_CHECKLIST не найдена' open_items = re.findall(r'^- \[ \]\s+(.+)$', match.group(1), re.MULTILINE) if open_items: return False, f'незакрытые пункты: {open_items[:3]}' # История ≥ 5 (или ≥ 3 для verify-only шагов: document, user_action) min_history = 3 if step_type in ('document', 'user_action') else 5 match = re.search(r'## История действий агента\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if match: rows = re.findall(r'^\|.*\|$', match.group(1), re.MULTILINE) data_rows = [r for r in rows if not re.match(r'^\|[\s\-:|]+\|$', r) and 'Время' not in r] if len(data_rows) < min_history: return False, f'история действий: {len(data_rows)} записей (нужно ≥ {min_history})' return True, 'OK' def _prepare_safe_checkout_main(step_id: str) -> None: """FIX-V27-1: обработать untracked immutable-snapshot.json перед `git checkout main`. Проблема: `_meta/immutable-snapshot.json` коммитится в репозитории (v26.1 fix), но feature-ветка может не иметь коммита с этим файлом. Тогда git checkout main FAIL с "untracked files would be overwritten". Также файл мог быть модифицирован protect_files.py --update во время выполнения шага. Решение: перед checkout main — если файл tracked в текущей ветке, сбросить его к HEAD-версии (`git checkout -- <file>`); если untracked — удалить локально, чтобы main восстановил его из своего коммита. """ snapshot_rel = '_meta/immutable-snapshot.json' snapshot_path = BASE / '_meta' / 'immutable-snapshot.json' # Проверить, tracked ли файл в текущей ветке r = run(f'git ls-files --error-unmatch {snapshot_rel} 2>/dev/null', check=False) if r.returncode == 0: # Файл tracked — git checkout -- восстановит HEAD-версию run(f'git checkout -- {snapshot_rel} 2>/dev/null || true', check=False) log('immutable-snapshot.json (tracked) сброшен к HEAD для safe checkout', step_id, 'action') else: # Файл untracked — удалить локально, main восстановит из своего коммита if snapshot_path.exists(): snapshot_path.unlink() log('immutable-snapshot.json (untracked) удалён для safe checkout', step_id, 'action') def git_operations(step: dict, step_id: str, step_branch: str) -> tuple[bool, str]: """Выполнить git-операции: commit, push feature-ветки, создать PR (аудит v31). Стратегия v31+v36 (START-WITH-AGENT.md §3.1, аудит v36 — единый путь): - Один шаг = одна ветка = один PR = РУЧНОЙ merge в main через GitVerse web UI - Если агент не на feature-ветке — переключиться на неё - После тестов → commit → push feature-ветки → создать PR - ⚠ Merge в main НЕ делается автоматически — пользователь merge'ит через GitVerse web UI, затем говорит «merge готов» → `make diagnostics STEP=X.Y` - Feature-ветка НЕ удаляется (политика v31 — для истории и rollback) Удалено в v31 (раньше было в v29/v30): - gh pr merge --squash (автоматический merge) - git merge --squash (fallback локальный merge) - git push origin main (main обновляется через web UI) - git branch -D (ветки не удалять) Аудит v36 — подтверждение запрета: - Локальный `git merge --squash` КАТЕГОРИЧЕСКИ ЗАПРЕЩЁН. - Это уже не «одна из стратегий», а единственно допустимый путь — PR GitVerse UI. - См. .agent/runtime/permission-policy.yaml → git_merge_squash_local: deny """ step_title = step.get('title', '') # FIX-2.2: путь к бэкапу STEP_STATE.md — определяется заранее, используется в любой ветке merge step_state_backup = BASE / '_meta' / f'step-state-backup-{step_id}.md' # 1. Убедиться, что мы на feature-ветке (аудит v18 — автопереключение) branch = run('git branch --show-current').stdout.strip() if branch != step_branch: log(f'Текущая ветка: {branch}, нужно: {step_branch}', step_id, 'warning') # Проверить, существует ли feature-ветка r = run(f'git rev-parse --verify {step_branch} 2>/dev/null') if r.returncode == 0: # Ветка существует — переключаемся log(f'Переключение на {step_branch}', step_id, 'action') run('git stash 2>/dev/null || true', check=False) r = run(f'git checkout {step_branch}') run('git stash pop 2>/dev/null || true', check=False) if r.returncode != 0: return False, f'Не удалось переключиться на {step_branch}: {r.stderr}' else: # Ветка не существует — создаём от main log(f'Создание {step_branch} от main', step_id, 'action') run('git stash 2>/dev/null || true', check=False) r = run('git rev-parse --verify main 2>/dev/null') if r.returncode == 0: _prepare_safe_checkout_main(step_id) run('git checkout main') run(f'git checkout -b {step_branch}') run('git stash pop 2>/dev/null || true', check=False) # Поверить повторно branch = run('git branch --show-current').stdout.strip() if branch != step_branch: return False, f'Не на feature-ветке (current={branch}, expected={step_branch})' # 2. Аудит v22: умный git add — только файлы этого шага, а не git add . # Проблема v21: git add . подбирает защищённые файлы из рабочего каталога # (созданные при итерациях), что блокирует pre-commit hook. # Решение: добавлять только: # - docs_target (явно) # - STEP_STATE.md (force, обход assume-unchanged) — управляется в main() # - files_to_commit из YAML шага (если указано) log('Умный git add (только файлы шага, аудит v22)', step_id, 'action') docs_target = step.get('docs_target', '') files_to_commit = step.get('files_to_commit', []) # 2a. Unstage защищённых файлов, если они случайно попали в индекс # FIX-2.6: читать список protected из immutable.txt, а не хардкодить sys.path.insert(0, str(BASE / 'scripts')) try: from protect_files import load_immutable_list protected_to_unstage = load_immutable_list() except ImportError: protected_to_unstage = [ '.agent/rules/', '.agent/prompts/', '.agent/cards/', '.agent/immutable.txt', 'scripts/', 'plans/', '00-manifest.md', 'AGENTS.md', 'Makefile', 'pyproject.toml', 'package.json', '.gitignore', '.nvmrc', 'STEP_STATE.template.md', 'CHANGELOG-META.md', ] staged_result = run('git diff --cached --name-only') staged = staged_result.stdout.strip().split('\n') if staged_result.stdout.strip() else [] unstaged_count = 0 for f in staged: for prot in protected_to_unstage: if f.startswith(prot) and f != 'STEP_STATE.md': run(f'git reset HEAD -- {f} 2>/dev/null || true', check=False) unstaged_count += 1 break if unstaged_count > 0: log(f'Unstaged {unstaged_count} protected files (всего, без перечисления)', step_id, 'action') # 2b. Удалить runtime-файлы из git index (кроме STEP_STATE.md — он управляется в main()) run('git rm --cached _meta/agent.log 2>/dev/null || true', check=False) run('git rm --cached _meta/agent.log.* 2>/dev/null || true', check=False) run('git rm --cached _meta/agent-snapshot.json 2>/dev/null || true', check=False) run('git rm --cached _meta/manual-changes.log 2>/dev/null || true', check=False) run('git rm --cached _meta/step-progress.yaml 2>/dev/null || true', check=False) run('git rm --cached _meta/step-progress-*.yaml 2>/dev/null || true', check=False) run('git rm --cached _meta/step-progress-*.done.yaml 2>/dev/null || true', check=False) run('git rm --cached _meta/.subagent-lock-* 2>/dev/null || true', check=False) run('git rm --cached _meta/step-*.ack 2>/dev/null || true', check=False) run('git rm --cached _meta/commit-msg-*.txt 2>/dev/null || true', check=False) run('git rm --cached _meta/user-settings.local.yaml 2>/dev/null || true', check=False) run('git rm --cached _meta/errors.json 2>/dev/null || true', check=False) run('git rm --cached _meta/errors.log 2>/dev/null || true', check=False) run('git rm --cached _meta/immutable-snapshot.json 2>/dev/null || true', check=False) run('git rm --cached _meta/.allow-protected-edit 2>/dev/null || true', check=False) # 2c. Добавить docs_target if docs_target and (BASE / docs_target).exists(): r = run(f'git add {docs_target}', check=False) if r.returncode == 0: log(f'git add {docs_target}', step_id, 'action') else: log(f'git add {docs_target} failed: {r.stderr[:200]}', step_id, 'warning') # 2d. Добавить files_to_commit из YAML (если указано) # FIX-2.5: проверить, что files_to_commit не попадают в immutable-список sys.path.insert(0, str(BASE / 'scripts')) try: from protect_files import expand_paths, load_immutable_list immutable_files = {str(f.relative_to(BASE)) for f in expand_paths(load_immutable_list())} for f in files_to_commit: if f in immutable_files: log(f'REFUSED: file_to_commit {f} is immutable! Это нарушает PROTECT-ГЕЙТ.', step_id, 'error') return False, f'file_to_commit {f} is immutable — cannot finalize step that modifies protected file via YAML' except ImportError: pass # protect_files.py недоступен — пропустить проверку for f in files_to_commit: if (BASE / f).exists(): r = run(f'git add {f}', check=False) if r.returncode == 0: log(f'git add {f} (из files_to_commit)', step_id, 'action') # 4. git commit в feature-ветке commit_msg = f'feat(step-{step_id}): {step_title}\n\nДокументация: {step.get("docs_target", "")}' msg_file = BASE / '_meta' / f'commit-msg-{step_id}.txt' msg_file.parent.mkdir(parents=True, exist_ok=True) msg_file.write_text(commit_msg, encoding='utf-8') log('git commit в feature-ветке', step_id, 'commit') r = run_list(['git', 'commit', '-F', str(msg_file)]) if r.returncode != 0 and 'nothing to commit' not in r.stdout and 'nothing to commit' not in r.stderr: log('git commit -F failed, trying -m', step_id, 'warning') short_msg = f'feat(step-{step_id}): {step_title}' r = run_list(['git', 'commit', '-m', short_msg]) if r.returncode != 0 and 'nothing to commit' not in r.stdout and 'nothing to commit' not in r.stderr: log(f'git commit returned {r.returncode}: {r.stdout[:200]}', step_id, 'warning') # 5. Push feature-ветки # Аудит v31 (START-WITH-AGENT.md §3.1): ВСЕГДА push feature-ветки — без этого # нельзя создать PR. Раньше push делался только для parallel_group — теперь всегда. log(f'git push origin {step_branch}', step_id, 'action') r = run(f'git push origin {step_branch} 2>&1', check=False) if r.returncode != 0: # Возможно --set-upstream для новой ветки r = run(f'git push --set-upstream origin {step_branch} 2>&1', check=False) if r.returncode != 0: log(f'git push failed: {r.stderr[:200]}', step_id, 'error') # Не return — продолжить, push может быть невозможен без remote log('Продолжаем без push (возможно нет remote)', step_id, 'warning') else: log('git push OK (set-upstream)', step_id, 'action') else: log('git push OK', step_id, 'action') # 6. Создать PR (через gh CLI для GitVerse/GitHub) log(f'Создание PR для шага {step_id}', step_id, 'action') pr_created = False pr_url = '' # Попытка через gh CLI # FIX-2.8: сначала проверить, есть ли уже PR для этой ветки r = run(f'gh pr list --head {step_branch} --json number --jq .[0].number 2>&1', check=False) existing_pr = r.stdout.strip() if existing_pr and existing_pr.isdigit(): pr_url = f'#{existing_pr}' pr_created = True log(f'PR уже существует: {pr_url} (переиспользуем)', step_id, 'action') else: r = run(f'gh pr create --title "Шаг {step_id}: {step_title}" --body "Автоматический PR для шага {step_id}" --base main --head {step_branch} 2>&1', check=False) if r.returncode == 0: pr_url = r.stdout.strip() pr_created = True log(f'PR создан через gh CLI: {pr_url}', step_id, 'action', {'pr_url': pr_url}) else: log(f'gh CLI не установлен или не сработал: {r.stderr[:200]}', step_id, 'warning') # Fallback: показать ссылку на GitVerse web UI для ручного создания PR r_remote = run('git remote get-url origin 2>/dev/null') remote_url = r_remote.stdout.strip() if remote_url: owner_repo = remote_url # Извлечь owner/repo из URL вида https://gitverse.ru/owner/repo.git import re as _re m = _re.search(r'[:/]([^/]+/[^/]+?)(?:\.git)?$', remote_url) if m: owner_repo = m.group(1) pr_url = f'https://gitverse.ru/{owner_repo}/pull/new/{step_branch}' log(f'Создайте PR вручную на GitVerse: {pr_url or step_branch + " → main"}', step_id, 'warning') # ============================================================ # Аудит v31 (START-WITH-AGENT.md §3.1 шаг 9): # ОЖИДАНИЕ РУЧНОГО MERGE — Агент НЕ делает merge автоматически. # Раньше здесь был блок 7-8 (gh pr merge / git merge --squash / git push main). # В v31 этот блок удалён — merge делается только через GitVerse web UI # после ручного подтверждения пользователя. # # После merge пользователь говорит «merge готов» — агент запускает # пост-merge диагностику: `make diagnostics STEP=X.Y` # ============================================================ # 7. Уведомить пользователя о необходимости ручного merge print(f'\n{"=" * 70}') print(f'⏸ ШАГ {step_id} ГОТОВ К РУЧНОМУ MERGE') print(f'{"=" * 70}') if pr_created: print(f'\nPR создан: {pr_url}') print(f'Ветка: {step_branch}') print('\nДействия пользователя:') print(' 1. Откройте PR в GitVerse web UI') print(' 2. Ревьюте изменения') print(' 3. Нажмите «Squash and merge»') print(' 4. Скажите агенту: «merge готов»') else: print(f'\nВетка {step_branch} запушена (или push не удался — см. лог)') print('PR не создан автоматически (gh CLI не установлен или нет remote)') if pr_url: print(f'Создайте PR вручную: {pr_url}') print('\nПосле merge скажите агенту: «merge готов»') print(f'\n⚠ Feature-ветка {step_branch} НЕ удаляется (политика v31).') print(f' После merge будет запущена пост-merge диагностика (make diagnostics STEP={step_id}).') print() # 8. НЕ делать git push origin main — main обновляется через web UI merge # НЕ удалять feature-ветку — политика v31 (ветки сохраняются для истории и rollback) log(f'Feature-ветка {step_branch} сохранена (не удаляется, политика v31)', step_id, 'action') log('Ожидание ручного merge через GitVerse web UI (аудит v31)', step_id, 'action') # ============================================================ # Аудит v34 (Fix 1): АВТОМАТИЧЕСКОЕ СОЗДАНИЕ ТЕГА ШАГА # ============================================================ # Раньше тег не создавался вообще — finalize_step.py завершался после PR, # а make tag-create никто не вызывал. Теперь: тег step-X.Y создаётся # на feature-ветке (привязан к коммиту шага) сразу после push. # Тег отправляется на remote (если есть). tag_name = f'step-{step_id}' log(f'Создание тега {tag_name} на feature-ветке {step_branch}', step_id, 'action') # Получить title шага для annotated tag try: r_title = run(f'{sys.executable} scripts/get_step_title.py {step_id}', check=False, ) step_title = (r_title.stdout.strip() if r_title.returncode == 0 else '').replace('"', r'\"').replace('\\', r'\\') tag_msg = f'Шаг {step_id}: {step_title}' if step_title else f'Шаг {step_id}' except Exception: tag_msg = f'Шаг {step_id}' # Создать annotated tag (не падать, если уже существует) r_tag = run(f'git tag -a {tag_name} -m "{tag_msg}"', check=False, ) if r_tag.returncode == 0: log(f'✓ Тег {tag_name} создан на {step_branch}', step_id, 'action') print(f' ✓ Тег {tag_name} создан') # Отправить тег на remote r_remote = run('git remote -v', check=False, ) if r_remote.stdout.strip(): r_push_tag = run(f'git push origin {tag_name}', check=False, ) if r_push_tag.returncode == 0: log(f'✓ Тег {tag_name} отправлен на remote', step_id, 'action') print(f' ✓ Тег {tag_name} отправлен на remote') else: log(f'⚠ Не удалось отправить тег {tag_name}: {r_push_tag.stderr[:200]}', step_id, 'warning') print(f' ⚠ Тег {tag_name} не отправлен (нет прав или нет сети)') else: # Тег уже существует — это нормально при повторном запуске check_exists = run(f'git tag -l {tag_name}', check=False, ) if check_exists.returncode == 0 and tag_name in check_exists.stdout: log(f'Тег {tag_name} уже существует — пропускаю', step_id, 'warning') print(f' ℹ Тег {tag_name} уже существует') else: log(f'⚠ Не удалось создать тег {tag_name}: {r_tag.stderr[:200]}', step_id, 'warning') print(f' ⚠ Тег {tag_name} не создан: {r_tag.stderr[:100]}') # 9. Удалить временный файл msg_file.unlink(missing_ok=True) return True, f'PR создан, тег {tag_name} создан, ожидание ручного merge (политика v31)' def update_step_state(step: dict, step_id: str, next_step: dict | None): """Обновить STEP_STATE.md.""" step_state = BASE / 'STEP_STATE.md' if not step_state.exists(): log('STEP_STATE.md не существует — пропускаю обновление', step_id, 'warning') return now = datetime.now().strftime('%Y-%m-%d') content = step_state.read_text(encoding='utf-8') # CURRENT_STEP → next_step_id (или остаётся, если next нет) next_step_id = next_step.get('id', step_id) if next_step else step_id next_step_docs = next_step.get('docs_target', '') if next_step else '' # LAST_COMPLETED_STEP → step_id content = re.sub(r'LAST_COMPLETED_STEP:.*', f'LAST_COMPLETED_STEP: {step_id}', content) # CURRENT_PHASE → awaiting-continue (аудит v31 — означает «PR создан, ожидание # ручного merge + затем команда продолжить»). Раньше merge делался автоматически # перед этой фазой — теперь merge делается вручную через GitVerse web UI. # Состояние awaiting-continue остаётся — оно означает «шаг завершён со стороны # агента, ждём пользователя». Пользователь должен: 1) merge в web UI, # 2) «merge готов» → make diagnostics, 3) «продолжить» → следующий шаг. content = re.sub(r'CURRENT_PHASE:.*', 'CURRENT_PHASE: awaiting-continue', content) # CURRENT_STEP → next if next_step: content = re.sub(r'CURRENT_STEP:.*', f'CURRENT_STEP: {next_step_id}', content) content = re.sub(r'CURRENT_STEP_DOCS:.*', f'CURRENT_STEP_DOCS: {next_step_docs}', content) # Добавить в COMPLETED_STEPS step_title = step.get('title', '') docs_target = step.get('docs_target', '') new_row = f'| {step_id} | {step_title} | {now} | [{docs_target}]({docs_target}) |' # Найти секцию COMPLETED_STEPS и добавить строку после заголовка таблицы # FIX-2.3: идемпотентность — не добавлять дубликат if f'| {step_id} |' in content: log(f'STEP_STATE.md уже содержит запись о шаге {step_id} — пропуск добавления', step_id, 'warning') else: content = re.sub( r'(\| Шаг \| Название \| Дата \| Документация \|\n\|-----\|----------\|------\|--------------\|\n)', rf'\1{new_row}\n', content, count=1 ) # ---------------------------------------------------------------------- # FIX-V24-1.1 [P0, NEW-P-003] — Перенос новых ADR в ADR_REGISTRY (§2.4) # ---------------------------------------------------------------------- # Прочитать docs_target → Реестр файлов шага, найти ADR, проверить approval, # добавить approved-записи в STEP_STATE.md → ADR_REGISTRY. docs_target_path = BASE / docs_target if docs_target else None if docs_target_path and docs_target_path.exists(): try: dt_content = docs_target_path.read_text(encoding='utf-8') except Exception: dt_content = '' adr_pattern = re.compile(r'docs/для-разработчиков/adr/(\d{4})-([a-z0-9-]+)\.md') adr_files_in_step = set(adr_pattern.findall(dt_content)) if adr_files_in_step: # Найти секцию ADR_REGISTRY в STEP_STATE.md. # Шаблон STEP_STATE.template.md использует заголовок: # | ADR | Название | Статус | Ссылка | # Поддерживаем также альтернативный формат из спецификации: # | ADR | Slug | Название | Дата | adr_registry_match = re.search( r'(## ADR_REGISTRY\s*\n\| ADR \|.*?\|\n\|[-\s|]+\|\n)', content ) if adr_registry_match: # Найти строку-заголовок (начинается с '| ADR |'). Используем # regex-поиск внутри match-блока, а не split('\n')[1], т.к. # между '## ADR_REGISTRY' и заголовком может быть пустая строка. header_line_match = re.search( r'^\| ADR \|.*\|$', adr_registry_match.group(1), re.MULTILINE ) header_line = header_line_match.group(0) if header_line_match else '' new_adr_rows: list[str] = [] for adr_num, adr_slug in sorted(adr_files_in_step): adr_path = BASE / 'docs' / 'для-разработчиков' / 'adr' / f'{adr_num}-{adr_slug}.md' if not adr_path.exists(): continue try: adr_content = adr_path.read_text(encoding='utf-8') except Exception: continue # Проверить, что ADR approved (gate: adr) if '> ADR-STATUS: approved' not in adr_content: log(f'ADR {adr_num}-{adr_slug} не approved — пропуск ADR_REGISTRY', step_id, 'warning') continue # Идемпотентность — не добавлять дубликат if f'| ADR-{adr_num} |' in content: continue # Извлечь заголовок (первый H1) title_match = re.search(r'^# (.+)$', adr_content, re.MULTILINE) adr_title = title_match.group(1).strip() if title_match else adr_slug # Адаптировать формат к заголовку секции if 'Slug' in header_line: new_adr_row = f'| ADR-{adr_num} | {adr_slug} | {adr_title} | {now} |' elif 'Статус' in header_line: adr_link = f'docs/для-разработчиков/adr/{adr_num}-{adr_slug}.md' new_adr_row = f'| ADR-{adr_num} | {adr_title} | Принято | [{adr_link}] |' else: # Fallback: используем формат из спецификации new_adr_row = f'| ADR-{adr_num} | {adr_slug} | {adr_title} | {now} |' new_adr_rows.append(new_adr_row) log(f'ADR-{adr_num} добавлен в ADR_REGISTRY', step_id, 'action') if new_adr_rows: content = content.replace( adr_registry_match.group(1), adr_registry_match.group(1) + '\n'.join(new_adr_rows) + '\n', 1 ) # ---------------------------------------------------------------------- # FIX-V24-1.2 [P0, NEW-P-003] — Обновление NEXT_ACTIONS (§2.6) # ---------------------------------------------------------------------- if next_step: next_id = next_step.get('id', '') next_title = next_step.get('title', '') next_actions_text = f'1. Запустить шаг {next_id} — {next_title}' # Найти секцию NEXT_ACTIONS (markdown-заголовок) и заменить её содержимое. # ВАЖНО: используем lambda-замену, чтобы текст next_actions_text не # интерпретировался как regex-template (иначе \1{1. Запустить...} даёт # ошибку 'invalid group reference 11'). next_actions_pattern = re.compile( r'(## NEXT_ACTIONS\s*\n)(.*?)(?=\n## |\n---\s*\n|\Z)', re.DOTALL ) if next_actions_pattern.search(content): def _next_actions_repl(m: re.Match) -> str: return m.group(1) + next_actions_text + '\n' content = next_actions_pattern.sub(_next_actions_repl, content, count=1) log(f'NEXT_ACTIONS обновлён: шаг {next_id}', step_id, 'action') # ---------------------------------------------------------------------- # FIX-V24-1.3 [P0, NEW-P-003] — Создание STAGE_XX_SUMMARY.md (§2.7) # ---------------------------------------------------------------------- generates_summary = step.get('generates_stage_summary', False) if generates_summary and docs_target: stage_match = re.match(r'docs/(\d{2})-', docs_target) if stage_match: stage_num = stage_match.group(1) summary_path = BASE / 'docs' / 'для-разработчиков' / f'STAGE_{stage_num}_SUMMARY.md' try: summary_path.parent.mkdir(parents=True, exist_ok=True) except Exception: pass # Собрать все завершённые шаги этого этапа из STEP_STATE.md → COMPLETED_STEPS stage_steps: list[str] = [] for line in content.split('\n'): if f'| {stage_num}.' in line and '|' in line: stage_steps.append(line) summary_content = f"""# Stage {stage_num} — Summary > Сгенерировано: {now} > Этап финализирован на шаге {step_id} ## Завершённые шаги этапа {chr(10).join(stage_steps) if stage_steps else '(нет записей)'} ## Ключевые артефакты - Манифест: `00-manifest.md` - Документация этапа: `docs/{stage_num}-*/` - ADR этого этапа: см. `STEP_STATE.md → ADR_REGISTRY` ## Что дальше Следующий этап: см. `plans/` — следующий YAML-файл после текущего. ## Метрики - Шагов в этапе: {len(stage_steps)} - Финализация: {step_id} """ try: atomic_write_text(summary_path, summary_content) log(f'Создан STAGE_{stage_num}_SUMMARY.md', step_id, 'action') except Exception as e: log(f'Не удалось создать STAGE_{stage_num}_SUMMARY.md: {e}', step_id, 'warning') # ---------------------------------------------------------------------- # FIX-V24-1.5 [P0, NEW-P-003] — TECHNICAL_DEBT: TODO/FIXME из созданных файлов (§2.5) # ---------------------------------------------------------------------- files_to_commit = step.get('files_to_commit', []) todos_found: list[str] = [] for f in files_to_commit: fpath = BASE / f if fpath.exists() and fpath.suffix in ('.py', '.ts', '.vue', '.js'): try: fcontent = fpath.read_text(encoding='utf-8', errors='ignore') except Exception: continue for line_num, line in enumerate(fcontent.split('\n'), 1): if 'TODO' in line or 'FIXME' in line or 'XXX' in line: todos_found.append(f'{f}:{line_num}: {line.strip()[:100]}') if todos_found: td_match = re.search(r'(## TECHNICAL_DEBT\s*\n)', content) if td_match: new_td_lines = '\n'.join(f'- [{step_id}] {t}' for t in todos_found[:20]) content = content.replace( td_match.group(1), td_match.group(1) + new_td_lines + '\n', 1 ) log(f'TECHNICAL_DEBT: добавлено {len(todos_found[:20])} записей', step_id, 'action') # FIX-2.4: атомарная запись через temp + os.replace import tempfile fd, tmp_path = tempfile.mkstemp(dir=str(step_state.parent), prefix='.tmp-step-state-', suffix='.md') try: with os.fdopen(fd, 'w', encoding='utf-8') as f: f.write(content) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, step_state) except Exception: try: os.unlink(tmp_path) except OSError: pass raise log(f'STEP_STATE.md обновлён: CURRENT_STEP={next_step_id}, LAST_COMPLETED={step_id}', step_id, 'action') def main(): parser = argparse.ArgumentParser(description='Автоматический финализатор шага') parser.add_argument('step_id', help='ID шага, например 1.0') parser.add_argument('--skip-verify', action='store_true', help='Пропустить verify_step_completion.py') args = parser.parse_args() step_info = find_step(args.step_id) if step_info is None: print(f'ERROR: шаг {args.step_id} не найден', file=sys.stderr) sys.exit(2) step, yfile, next_step = step_info step_branch = step.get('branch', f'feature/step-{args.step_id}') step_type = step.get('step_type', '') # FIX-2.7: pre-check — если маркер уже существует, это аномалия allow_marker = BASE / '_meta' / '.allow-protected-edit' if allow_marker.exists(): print(f'ERROR: обнаружен остаточный маркер {allow_marker}', file=sys.stderr) print('Это означает, что предыдущий запуск finalize_step.py был прерван.', file=sys.stderr) print(f'Удалите маркер вручную: rm {allow_marker}', file=sys.stderr) print(f'Или проверьте содержимое: cat {allow_marker}', file=sys.stderr) sys.exit(1) # FIX-2.11: flock для сериализации одновременных запусков finalize_step import fcntl lock_file = BASE / '_meta' / '.finalize.lock' lock_file.parent.mkdir(parents=True, exist_ok=True) lock_fd = open(lock_file, 'w') try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: print('ERROR: другой процесс finalize_step.py уже запущен. Дождитесь завершения.', file=sys.stderr) sys.exit(1) # Держим lock_fd открытым до конца main() # FIX-2.12: обернуть основной код в try/except KeyboardInterrupt try: print(f'\n{"=" * 70}') print(f'ФИНАЛИЗАЦИЯ ШАГА {args.step_id} — {step.get("title", "")}') print(f'Ветка: {step_branch}') print(f'Тип: {step_type}') print(f'{"=" * 70}\n') # 1. Проверка docs_target print('--- Проверка docs_target ---') ok, msg = check_docs_target(step, args.step_id) print(f' [{"✓" if ok else "✗"}] {msg}') if not ok: log(f'Финализация FAIL: docs_target — {msg}', args.step_id, 'error') sys.exit(1) log(f'docs_target проверен: {msg}', args.step_id, 'test') # 1.5 Аудит v21: проверить immutable-файлы через protect_files.py print('\n--- Проверка immutable-файлов (protect_files.py) ---') protect_script = BASE / 'scripts' / 'protect_files.py' if protect_script.exists(): # FIX-V30-3.3: использовать sys.executable для надёжного наследования venv r = run(f'{sys.executable} scripts/protect_files.py --check', check=False) print(r.stdout) if r.returncode != 0: print('\n❌ Защищённые файлы были изменены без разрешения!') print('См. .agent/immutable.txt и 06-interactive-protocol.md §7 (PROTECT-ГЕЙТ).') log('Финализация FAIL: protect_files.py обнаружил несанкционированные изменения', args.step_id, 'error') sys.exit(1) log('protect_files.py PASSED — immutable-файлы не изменены', args.step_id, 'test') else: print(' (protect_files.py не найден — пропуск)') # 2. Многосторонняя проверка (если не --skip-verify) if not args.skip_verify: print('\n--- Многосторонняя проверка (verify_step_completion.py) ---') # FIX-V30-3.3: sys.executable r = run(f'{sys.executable} scripts/verify_step_completion.py {args.step_id}', check=False) print(r.stdout) if r.returncode != 0: print(f'\n❌ Многосторонняя проверка НЕ пройдена (exit {r.returncode})') print('Используйте --skip-verify для пропуска (не рекомендуется).') log(f'Финализация FAIL: verify_step_completion exit {r.returncode}', args.step_id, 'error') sys.exit(1) log('verify_step_completion PASSED', args.step_id, 'test') # 3. Git-операции ВНАЧАЛЕ (FIX-V24-1.7: сначала git, потом STEP_STATE.md) # Если git_operations упадёт (merge conflict) — STEP_STATE.md останется # неповреждённым, шаг не будет отмечен как completed преждевременно. # Альтернатива из спецификации: STEP_STATE.md помечен assume-unchanged # и не входит в git-коммит — обновляем его ПОСЛЕ успешных git-операций. print('\n--- Git-операции ---') allow_marker.parent.mkdir(parents=True, exist_ok=True) # FIX-V24-3.12: маркер как YAML manifest с конкретным списком разрешённых файлов. # Раньше маркер был свободным текстом и protect_files.py мог интерпретировать # его как «allowed: ['*']». Теперь — ограничение только STEP_STATE.md + docs_target. _docs_target = step.get('docs_target', '') manifest_content = ( '# Auto-created by finalize_step.py\n' 'allowed:\n' ' - STEP_STATE.md\n' f' - {_docs_target}\n' f'reason: finalize_step.py для шага {args.step_id}\n' f'created_at: {datetime.now().isoformat()}\n' 'session: finalize_step.py\n' 'expires: null\n' ) allow_marker.write_text(manifest_content, encoding='utf-8') log('Создан маркер _meta/.allow-protected-edit (YAML manifest, FIX-V24-3.12)', args.step_id, 'action') try: ok, msg = git_operations(step, args.step_id, step_branch) finally: # Удалить маркер в любом случае (даже при ошибке) if allow_marker.exists(): allow_marker.unlink() log('Удалён маркер _meta/.allow-protected-edit', args.step_id, 'action') print(f' [{"✓" if ok else "✗"}] {msg}') if not ok: log(f'Финализация FAIL: git — {msg} (STEP_STATE.md НЕ обновлён — откат безопасен)', args.step_id, 'error') sys.exit(1) # 4. ОБНОВЛЕНИЕ STEP_STATE.md ПОСЛЕ успешных git-операций (FIX-V24-1.7) # STEP_STATE.md помечен assume-unchanged, не входит в git-коммит. # Обновляется только после успеха git — чтобы при rollback не было ложного completed. # FIX-V24-1.1/1.2/1.3/1.5 также выполняются внутри update_step_state(): # - перенос ADR в ADR_REGISTRY # - обновление NEXT_ACTIONS # - создание STAGE_XX_SUMMARY.md (если generates_stage_summary) # - сбор TODO/FIXME в TECHNICAL_DEBT print('\n--- Обновление STEP_STATE.md (после git-операций) ---') update_step_state(step, args.step_id, next_step) print(' ✓ STEP_STATE.md обновлён') # 5. Обновить снимок файлов print('\n--- Обновление снимка файлов ---') # FIX-V30-3.3: sys.executable run(f'{sys.executable} scripts/detect_manual_changes.py --update-snapshot --quiet 2>/dev/null || true', check=False) print(' ✓ Снимок обновлён') # 6. Финальный лог next_step_id = next_step.get('id', '?') if next_step else '?' next_step_title = next_step.get('title', '') if next_step else '' log(f'Шаг {args.step_id} финализирован. Следующий: {next_step_id}', args.step_id, 'commit', details={'next_step': next_step_id, 'next_title': next_step_title}) print(f'\n{"=" * 70}') print(f'✓ ШАГ {args.step_id} ФИНАЛИЗИРОВАН (commit + push + PR + tag)') print(f'{"=" * 70}') print(f'\nGit: feature-ветка {step_branch} запушена, PR создан, тег step-{args.step_id} создан.') print('⚠ Merge в main НЕ выполнен — ожидание ручного merge через GitVerse web UI (политика v31).') print(' После merge скажите: «merge готов» — запустится пост-merge диагностика.') print(' Затем «продолжить» — для запуска следующего шага.') print(f'Документация: {step.get("docs_target", "")}') print(f'\nСледующий шаг: {next_step_id} — {next_step_title}') print() # Переименовать progress-файл (FIX-V25-1.1: per-step file) safe_step_id = args.step_id.replace('.', '-') progress_file = BASE / '_meta' / f'step-progress-{safe_step_id}.yaml' done_file = BASE / '_meta' / f'step-progress-{safe_step_id}.done.yaml' if progress_file.exists(): progress_file.rename(done_file) print(f'Progress-файл: {done_file.name}') else: # Legacy fallback: если остался единый step-progress.yaml — переименовать его legacy_file = BASE / '_meta' / 'step-progress.yaml' if legacy_file.exists(): legacy_file.rename(done_file) print(f'Progress-файл (legacy): {done_file.name}') # FIX-V24-1.4 [P0, NEW-P-003] — Запустить gen_progress.py для обновления docs/progress.md (§2.8) gen_progress_script = BASE / 'scripts' / 'gen_progress.py' if gen_progress_script.exists(): r = subprocess.run( # FIX-V30-3.3: sys.executable — гарантированно тот же интерпретатор [sys.executable, 'scripts/gen_progress.py'], capture_output=True, text=True, cwd=BASE, timeout=30 ) if r.returncode == 0: log('docs/progress.md обновлён через gen_progress.py', args.step_id, 'action') print(' ✓ docs/progress.md обновлён') else: log(f'gen_progress.py failed: {r.stderr[:200]}', args.step_id, 'warning') print(' ⚠ gen_progress.py failed (см. лог)') else: log('gen_progress.py не найден — пропуск обновления docs/progress.md', args.step_id, 'warning') sys.exit(0) except KeyboardInterrupt: print('\nПрервано пользователем (Ctrl+C)', file=sys.stderr) log('Прервано пользователем в фазе финализации', args.step_id, 'error') # Удалить маркер, если создан if allow_marker.exists(): allow_marker.unlink() sys.exit(130) if __name__ == '__main__': main()