#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

[ "$(id -un)" = "ops" ] || {
  echo "STOP: запускать на VM130/router-ops пользователем ops"
  exit 1
}

STEP="STEP_050LP1_PUBLISH_CANONICAL_VM101_AUTONOMOUS_RECOVERY_PLAN"
PLAN_ID="vm101-hmn-autonomous-egress-recovery"
PLAN_SCHEMA_VERSION="1"

TOKEN="e94a0859747d7b96f29c7fdafc2d0351ba603bb0a7e9e5a4"
PUBLIC_BASE="https://helena-background-beam-harry.trycloudflare.com/r/${TOKEN}"

PREVIOUS_ARCHITECTURE_PLAN="${PUBLIC_BASE}/20260711-143018_local_architecture_plan_hmn_egress_recovery_step050i10/"
XS_MAP="${PUBLIC_BASE}/20260711-120734_xs_map_audit_repair_publish/"
GLOBAL_PROJECT_PLAN="${PUBLIC_BASE}/20260711-123348_global_project_plan_wg_paid/"

EVIDENCE_H2="${PUBLIC_BASE}/20260711-113010_step050h2_salvage_counter_state_after_guard_test/"
EVIDENCE_I9="${PUBLIC_BASE}/20260711-141947_step050i9_full_audit_minimal_enable_commit_dryrun/"

ROOT="/opt/router-ops"
BIN_DIR="${ROOT}/bin"
STATE_ROOT="${ROOT}/state"
PLAN_STATE_DIR="${STATE_ROOT}/local-plans/${PLAN_ID}"
PUBROOT="${ROOT}/public/r/${TOKEN}"

TS="$(date -u +%Y%m%d-%H%M%S)"

REPORT_SLUG="${TS}_step050lp1_publish_canonical_vm101_recovery_plan"
REPORT_DIR="${PUBROOT}/${REPORT_SLUG}"

PLAN_SLUG="${TS}_local_architecture_plan_vm101_autonomous_hmn_recovery"
PLAN_DIR="${PUBROOT}/${PLAN_SLUG}"

TRYCF_REPORT="${PUBLIC_BASE}/${REPORT_SLUG}/"
REPORT_TXT="${TRYCF_REPORT}report.txt"
FACTS_JSON="${TRYCF_REPORT}facts.json"
ARCHITECTURE_PLAN="${PUBLIC_BASE}/${PLAN_SLUG}/"

mkdir -p \
  "$REPORT_DIR" \
  "$REPORT_DIR/sources" \
  "$PLAN_DIR" \
  "$PLAN_STATE_DIR" \
  "$BIN_DIR" \
  "$STATE_ROOT"

# Точный пользовательский copy-paste script сохраняется
# первым артефактом до дальнейших проверок.
cp -a "$0" "$REPORT_DIR/step.sh"
chmod 600 "$REPORT_DIR/step.sh"

PROGRESS_LOG="${REPORT_DIR}/progress.log"
: > "$PROGRESS_LOG"

stage() {
  echo
  echo ">>> [$1] $2" | tee -a "$PROGRESS_LOG"

  date -u '+%Y-%m-%dT%H:%M:%SZ' |
    sed 's/^/    utc=/' |
    tee -a "$PROGRESS_LOG"
}

print_links() {
  echo
  echo "TRYCF_REPORT=$TRYCF_REPORT"
  echo "REPORT_TXT=$REPORT_TXT"
  echo "FACTS_JSON=$FACTS_JSON"
  echo "ARCHITECTURE_PLAN=$ARCHITECTURE_PLAN"
  echo "XS_MAP=$XS_MAP"
  echo "GLOBAL_PROJECT_PLAN=$GLOBAL_PROJECT_PLAN"
}

write_stop() {
  local reason="$1"
  local rc="$2"
  local line="$3"

  cat > "$REPORT_DIR/report.txt" <<EOF
=== ${STEP} RESULT ===
step=${STEP}
decision=STOP_${STEP}_${reason}
all_ok=false
mode=DOCUMENTATION_ONLY
error_rc=${rc}
error_line=${line}
production_modified=false
vm100_modified=false
vm101_modified=false
vm121_modified=false

TRYCF_REPORT=${TRYCF_REPORT}
REPORT_TXT=${REPORT_TXT}
FACTS_JSON=${FACTS_JSON}
ARCHITECTURE_PLAN=${PREVIOUS_ARCHITECTURE_PLAN}
XS_MAP=${XS_MAP}
GLOBAL_PROJECT_PLAN=${GLOBAL_PROJECT_PLAN}
EOF

  python3 - \
    "$STEP" \
    "$reason" \
    "$rc" \
    "$line" \
    "$TRYCF_REPORT" \
    "$REPORT_TXT" \
    "$FACTS_JSON" \
    "$PREVIOUS_ARCHITECTURE_PLAN" \
    "$XS_MAP" \
    "$GLOBAL_PROJECT_PLAN" \
    > "$REPORT_DIR/facts.json" <<'PY'
import json
import sys

(
    step,
    reason,
    rc,
    line,
    report,
    report_txt,
    facts_json,
    architecture,
    xs_map,
    global_plan,
) = sys.argv[1:]

print(json.dumps({
    "schema": "router-step-facts-v1",
    "step": step,
    "assessment": {
        "decision": f"STOP_{step}_{reason}",
        "all_ok": False,
        "error_rc": int(rc),
        "error_line": int(line),
    },
    "mode": "DOCUMENTATION_ONLY",
    "safety": {
        "production_modified": False,
        "vm100_modified": False,
        "vm101_modified": False,
        "vm121_modified": False,
    },
    "publish": {
        "trycf_report": report,
        "report_txt": report_txt,
        "facts_json": facts_json,
        "architecture_plan": architecture,
        "xs_map": xs_map,
        "global_project_plan": global_plan,
    },
}, ensure_ascii=False, indent=2))
PY
}

on_error() {
  local rc="$?"
  local line="$1"

  trap - ERR
  write_stop "UNEXPECTED_ERROR" "$rc" "$line"
  print_links
  exit "$rc"
}

trap 'on_error "$LINENO"' ERR

stage "01/08" "Создаю канонический источник локального плана"

cat > "$PLAN_STATE_DIR/canonical-plan.md" <<'PLAN'
# Канонический локальный план VM101

## Автономное восстановление VPN egress HideMyName

## 1. Область действия

Этот локальный план относится только к VM101.

VM100 и VM121:

- не участвуют в реализации этой state machine;
- не являются обязательными критериями PASS локальных этапов;
- могут проверяться позже в отдельных интеграционных планах.

Основная цель VM101 — автономно сохранять интернет у клиентов при последовательной деградации VPN-инфраструктуры.

Приоритеты:

1. Локально восстановить отказавший VPN-слот.
2. При накоплении отказов полностью обновить пул HideMyName.
3. При недоступности HideMyName продолжить работу на оставшихся VPN.
4. При исчерпании кандидатов перераспределять пользователей на оставшиеся рабочие VPN.
5. При отсутствии всех рабочих VPN перевести пользователей в Direct.
6. В режиме Direct продолжать bootstrap-восстановление.
7. После получения рабочего транспорта обновить HideMyName-пул, восстановить пять VPN-слотов и вернуть пользователей на VPN.

---

# 2. Основные сущности

## 2.1. Рабочие VPN-слоты

Рабочий набор:

- `vpn1`
- `vpn2`
- `vpn3`
- `vpn4`
- `vpn5`

Каждый слот содержит один активный предварительно протестированный HideMyName-туннель.

## 2.2. Пул кандидатов

После получения списка HideMyName система:

1. тестирует доступные туннели;
2. формирует локальный упорядоченный пул;
3. назначает первые пять подходящих кандидатов в `vpn1..vpn5`;
4. оставляет остальные как резерв;
5. исключает активные дубликаты;
6. исключает quarantined endpoints текущего поколения.

Пул должен иметь идентификатор поколения.

## 2.3. Quarantine

Когда endpoint активного слота перестаёт работать:

1. endpoint удаляется из рабочего обращения;
2. endpoint помещается в quarantine;
3. endpoint не может повторно использоваться в текущем поколении пула;
4. повторное использование возможно только после:
   - нового получения списка HideMyName;
   - нового тестирования;
   - успешного прохождения теста.

Полное обновление списка не означает автоматического доверия старым endpoints. Рабочими становятся только заново протестированные endpoints.

## 2.4. Счётчик ремонтов

Глобальный счётчик VM101:

`repair_events_since_full_refresh`

Он увеличивается после каждой успешной замены отказавшего активного VPN-туннеля.

Это могут быть:

- отказы пяти разных слотов;
- несколько последовательных отказов одного слота;
- любая комбинация успешных локальных замен.

Счётчик:

- не сбрасывается после единичного ремонта;
- не сбрасывается от временного возвращения системы в рабочее состояние;
- сбрасывается только после успешного полного refresh, тестирования и установки нового рабочего набора.

Параметр:

`FULL_REFRESH_AFTER_REPAIRS`

Начальное значение:

`5`

---

# 3. State machine

## STATE A — NORMAL

Условия:

- пять VPN-слотов работают;
- пользователи распределены по VPN;
- Direct failopen выключен;
- health monitor контролирует каждый слот.

При отказе одного слота:

`NORMAL -> LOCAL_REPAIR`

---

## STATE B — LOCAL_REPAIR

Health monitor обнаруживает отказ и вызывает recovery manager HideMyName.

Recovery manager:

1. подтверждает отказ;
2. помещает текущий endpoint в quarantine;
3. выбирает следующий протестированный резервный endpoint;
4. меняет только отказавший слот;
5. поднимает интерфейс;
6. проверяет фактический интернет через этот интерфейс;
7. при успехе увеличивает `repair_events_since_full_refresh`.

Если счётчик меньше порога:

`LOCAL_REPAIR -> NORMAL`

Если счётчик достиг порога:

`LOCAL_REPAIR -> FULL_POOL_REFRESH`

Если кандидата нет:

`LOCAL_REPAIR -> SLOT_EXHAUSTED`

---

## STATE C — FULL_POOL_REFRESH

После достижения порога система пытается:

1. получить новый список туннелей HideMyName;
2. протестировать полученные туннели;
3. сформировать новое поколение пула;
4. выбрать пять рабочих endpoints;
5. загрузить их в `vpn1..vpn5`;
6. проверить каждый слот;
7. проверить таблицы маршрутизации;
8. проверить фактический egress;
9. активировать новый рабочий набор.

Refresh считается успешным только после фактической проверки нового набора.

После успеха:

- `repair_events_since_full_refresh=0`;
- создаётся новое поколение пула;
- старый quarantine архивируется;
- успешно протестированные старые endpoints снова могут использоваться;
- система возвращается в `NORMAL`.

Переход:

`FULL_POOL_REFRESH -> NORMAL`

Если HideMyName недоступен или кандидатов недостаточно:

`FULL_POOL_REFRESH -> DEGRADED_POOL`

Неуспешный refresh не должен уничтожать оставшиеся рабочие VPN.

---

## STATE D — DEGRADED_POOL

HideMyName временно недоступен, но один или несколько VPN ещё работают.

Система:

1. сохраняет оставшиеся рабочие VPN;
2. продолжает обслуживать пользователей;
3. продолжает заменять отказавшие слоты из последнего протестированного пула;
4. соблюдает quarantine;
5. периодически повторяет full refresh;
6. не сбрасывает repair counter;
7. не считает систему полностью восстановленной.

Параметр:

`HMN_REFRESH_RETRY_INTERVAL_SEC`

Если refresh удался:

`DEGRADED_POOL -> NORMAL`

Если свободного кандидата для очередного слота нет:

`DEGRADED_POOL -> SLOT_EXHAUSTED`

---

## STATE E — SLOT_EXHAUSTED / CONSOLIDATION

Если слот отказал, а рабочего резервного кандидата нет:

1. слот объявляется недоступным;
2. пользователи слота перераспределяются по оставшимся рабочим VPN;
3. балансировка использует только подтверждённо рабочие слоты;
4. система продолжает попытки full refresh;
5. оставшиеся слоты продолжают ремонтироваться, пока существуют кандидаты.

Варианты:

- четыре рабочих слота — пользователи распределяются по четырём;
- два рабочих слота — пользователи распределяются по двум;
- один рабочий слот — все VPN-пользователи временно работают через него;
- ноль рабочих слотов — переход в Direct.

Уменьшение числа слотов само по себе не включает Direct.

Если появляется новый рабочий кандидат:

`SLOT_EXHAUSTED -> DEGRADED_POOL`

или:

`SLOT_EXHAUSTED -> NORMAL`

Если рабочие слоты закончились:

`SLOT_EXHAUSTED -> DIRECT_EMERGENCY`

---

## STATE F — DIRECT_EMERGENCY

Условие входа:

`healthy_vpn_slot_count=0`

Действия:

1. все пользователи переводятся в Direct;
2. фиксируется аварийное состояние;
3. Direct становится временным клиентским egress;
4. последний пул не удаляется;
5. quarantine не удаляется;
6. recovery manager продолжает работу;
7. запускается bootstrap recovery manager.

Direct — не восстановление VPN. Это последний способ сохранить клиентам интернет.

Переход:

`DIRECT_EMERGENCY -> BOOTSTRAP_RECOVERY`

---

# 4. Bootstrap recovery manager

## 4.1. Общий контракт

`bootstrap_recovery_manager` — абстрактный компонент.

Его задача:

> Получить любой рабочий транспорт, через который можно обратиться к HideMyName или получить новый рабочий VPN-пул.

Основная state machine не должна зависеть от конкретной bootstrap-стратегии.

Стратегии выполняются в настраиваемом порядке:

`BOOTSTRAP_STRATEGY_ORDER`

---

## 4.2. Стратегия по умолчанию: cached pool через vpn1

Название:

`cached_pool_vpn1_strategy`

Алгоритм:

1. Клиентский трафик остаётся в Direct.
2. `vpn1` используется как технический bootstrap-слот.
3. В `vpn1` по очереди загружаются кандидаты из последнего сохранённого пула.
4. После каждого кандидата проверяется VPN egress.
5. Неуспешный endpoint помечается как bootstrap-failed или остаётся quarantined.
6. Через настраиваемый интервал пробуется следующий кандидат.
7. Перебор продолжается по заданной политике.

Параметры:

- `BOOTSTRAP_SLOT=vpn1`
- `BOOTSTRAP_RETRY_INTERVAL_SEC`
- `BOOTSTRAP_CANDIDATE_RETRY_COUNT`

Bootstrap-туннель не должен автоматически принимать клиентский трафик.

Его первая задача — дать управляющий транспорт для обращения к HideMyName.

---

## 4.3. Альтернативная стратегия: WireGuard через Деденево

Название:

`ddn_wireguard_bootstrap_strategy`

Алгоритм:

1. VM101 поднимает резервный WireGuard до Деденево.
2. Проверяет наличие интернета через Деденево.
3. При успехе запрос HideMyName выполняется через этот транспорт.
4. Новый пул тестируется.
5. Восстанавливаются `vpn1..vpn5`.
6. После возврата штатного VPN резервный транспорт отключается или остаётся в standby.

В дальнейшем могут быть добавлены другие стратегии без переписывания основной state machine.

---

# 5. Возврат из Direct

Когда bootstrap-стратегия получила рабочий транспорт:

1. через него выполняется запрос нового списка HideMyName;
2. тестируются кандидаты;
3. создаётся новое поколение пула;
4. пять рабочих endpoints назначаются в `vpn1..vpn5`;
5. проверяется каждый интерфейс;
6. проверяется policy routing;
7. проверяется фактический egress;
8. проверяется отсутствие quarantined endpoints в активном наборе.

Только после успешной проверки штатного набора:

- пользователи переводятся с Direct обратно на VPN;
- Direct failopen выключается;
- repair counter сбрасывается;
- bootstrap-состояние очищается;
- система возвращается в `NORMAL`.

Поднятие одного bootstrap-туннеля не означает завершение восстановления.

---

# 6. Настраиваемые параметры

Минимальный набор:

- `HEALTH_CHECK_INTERVAL_SEC`
- `HEALTH_FAILURES_BEFORE_DOWN`
- `LOCAL_REPAIR_RETEST_COUNT`
- `FULL_REFRESH_AFTER_REPAIRS`
- `HMN_REFRESH_RETRY_INTERVAL_SEC`
- `BOOTSTRAP_RETRY_INTERVAL_SEC`
- `BOOTSTRAP_CANDIDATE_RETRY_COUNT`
- `BOOTSTRAP_SLOT`
- `BOOTSTRAP_STRATEGY_ORDER`
- `CANDIDATE_TEST_TIMEOUT_SEC`
- `CANDIDATE_TEST_RETRIES`
- `MIN_HEALTHY_SLOTS_TO_EXIT_DIRECT`
- `DIRECT_FAILOPEN_ENABLED`
- `EMERGENCY_COMMIT_ENABLED`

Начальные значения архитектуры:

- `BOOTSTRAP_SLOT=vpn1`
- `FULL_REFRESH_AFTER_REPAIRS=5`
- вход в Direct при `healthy_vpn_slot_count=0`;
- выход из Direct после восстановления и проверки штатного рабочего набора.

---

# 7. Safety-инварианты

1. Отказ одного слота не останавливает остальные.
2. Quarantined endpoint не возвращается без нового тестирования.
3. Неуспешный full refresh не уничтожает рабочие VPN.
4. Direct включается только при отсутствии рабочих VPN.
5. Direct не останавливает recovery.
6. Bootstrap-слот не принимает клиентский трафик без отдельного решения.
7. Возврат с Direct выполняется только после проверки нового рабочего набора.
8. Изменение VPN-конфигурации имеет backup и rollback.
9. Пороги и интервалы настраиваются.
10. Provider-specific HideMyName-код отделён от общей state machine.
11. Bootstrap-стратегии реализуются через общий контракт.
12. Локальный план ограничен VM101.
13. VM100 и VM121 не являются блокирующими критериями локальных STEP.
14. Repair counter сбрасывается только после успешного full refresh.
15. Старый пул сохраняется до доказанного запуска нового пула.

---

# 8. Полное испытание

После реализации выполняется последовательная имитация на VM101:

1. Отказ одного VPN.
2. Local repair.
3. Quarantine.
4. Повторные отказы до порога.
5. Успешный full HideMyName refresh.
6. Новая серия отказов.
7. Недоступность HideMyName.
8. Работа на остаточном пуле.
9. Исчерпание резервных кандидатов.
10. Консолидация пользователей на оставшихся слотах.
11. Последовательное уничтожение всех рабочих VPN.
12. Переход пользователей в Direct.
13. Cached-pool bootstrap через `vpn1`.
14. Проверка альтернативного bootstrap transport.
15. Получение нового HideMyName-пула.
16. Восстановление всех пяти VPN.
17. Возврат пользователей с Direct на VPN.
18. Проверка перезагрузочной устойчивости state machine.
PLAN

stage "02/08" "Создаю машинно-читаемый реестр этапов"

python3 - \
  "$PLAN_SCHEMA_VERSION" \
  "$PLAN_ID" \
  "$TS" \
  "$EVIDENCE_H2" \
  "$EVIDENCE_I9" \
  > "$PLAN_STATE_DIR/milestones.json" <<'PY'
import json
import sys

schema_version, plan_id, timestamp, evidence_h2, evidence_i9 = sys.argv[1:]

milestones = [
    {
        "id": "M01",
        "title": "Health monitor обнаруживает отказ VPN-слота",
        "status": "done",
        "evidence": [evidence_h2],
        "notes": "Watcher и strict status checks доказаны.",
    },
    {
        "id": "M02",
        "title": "Локальная замена отказавшего VPN-туннеля",
        "status": "done",
        "evidence": [evidence_h2],
        "notes": "Watcher → dispatcher → adapter repair доказан.",
    },
    {
        "id": "M03",
        "title": "Quarantine отказавших endpoints",
        "status": "done",
        "evidence": [evidence_h2],
        "notes": "Отказавший endpoint исключается из повторного выбора.",
    },
    {
        "id": "M04",
        "title": "Глобальный repair counter после локальных замен",
        "status": "done",
        "evidence": [evidence_h2],
        "notes": "Счётчик увеличивается после успешного ремонта.",
    },
    {
        "id": "M05",
        "title": "Порог full refresh и dry-run решения",
        "status": "done",
        "evidence": [evidence_h2, evidence_i9],
        "notes": "При изолированном count=5 доказано would_run_emergency_refresh.",
    },
    {
        "id": "M06",
        "title": "Постоянно включить emergency commit на VM101",
        "status": "in_progress",
        "evidence": [evidence_i9],
        "notes": (
            "VM101-проверка прошла в STEP_050I9, но изменение было "
            "откачено из-за нерелевантного VM121-блокера."
        ),
    },
    {
        "id": "M07",
        "title": "Выполнить контролируемый реальный full HideMyName refresh",
        "status": "pending",
        "evidence": [],
        "notes": "Обновить список, протестировать и безопасно установить пять слотов.",
    },
    {
        "id": "M08",
        "title": "Реализовать DEGRADED_POOL при ошибке HideMyName refresh",
        "status": "pending",
        "evidence": [],
        "notes": "Продолжать работу на оставшихся VPN и старом резервном пуле.",
    },
    {
        "id": "M09",
        "title": "Реализовать SLOT_EXHAUSTED и консолидацию пользователей",
        "status": "pending",
        "evidence": [],
        "notes": "Перераспределять пользователей только по рабочим слотам.",
    },
    {
        "id": "M10",
        "title": "Реализовать DIRECT_EMERGENCY при нуле рабочих VPN",
        "status": "pending",
        "evidence": [],
        "notes": "Перевести всех пользователей в Direct и продолжить recovery.",
    },
    {
        "id": "M11",
        "title": "Реализовать cached-pool bootstrap через vpn1",
        "status": "pending",
        "evidence": [],
        "notes": "Перебирать сохранённые кандидаты с настраиваемым интервалом.",
    },
    {
        "id": "M12",
        "title": "Реализовать абстрактный bootstrap strategy contract",
        "status": "pending",
        "evidence": [],
        "notes": "Поддержать будущий резервный транспорт, включая Деденево.",
    },
    {
        "id": "M13",
        "title": "Реализовать безопасный возврат Direct → VPN",
        "status": "pending",
        "evidence": [],
        "notes": "Возврат только после проверки полного рабочего набора.",
    },
    {
        "id": "M14",
        "title": "Провести полную последовательную имитацию отказов на VM101",
        "status": "pending",
        "evidence": [],
        "notes": "От единичного отказа до Direct и обратного восстановления.",
    },
    {
        "id": "M15",
        "title": "Доказать перезагрузочную устойчивость state machine",
        "status": "pending",
        "evidence": [],
        "notes": "Состояние, timers, services, pool и quarantine переживают reboot.",
    },
]

document = {
    "schema": "vm101-local-plan-milestones-v1",
    "schema_version": int(schema_version),
    "plan_id": plan_id,
    "created_at_utc": timestamp,
    "updated_at_utc": timestamp,
    "scope": ["VM101"],
    "excluded_blocking_scopes": ["VM100", "VM121"],
    "current_milestone": "M06",
    "milestones": milestones,
}

print(json.dumps(document, ensure_ascii=False, indent=2))
PY

stage "03/08" "Устанавливаю renderer и инструменты переиздания"

cat > "$PLAN_STATE_DIR/render_plan.py" <<'PY'
#!/usr/bin/env python3

import argparse
import html
import json
import shutil
from pathlib import Path

STATUS_LABELS = {
    "done": "ВЫПОЛНЕНО",
    "in_progress": "В РАБОТЕ",
    "pending": "ОЖИДАЕТ",
    "blocked": "ЗАБЛОКИРОВАНО",
}

STATUS_MARKERS = {
    "done": "[x]",
    "in_progress": "[ ] 🟡",
    "pending": "[ ]",
    "blocked": "[ ] 🔴",
}


def load_json(path: Path):
    with path.open(encoding="utf-8") as source:
        return json.load(source)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--state-dir", required=True)
    parser.add_argument("--output-dir", required=True)
    parser.add_argument("--generated-at", required=True)
    parser.add_argument("--source-step", required=True)
    parser.add_argument("--current-report", required=True)
    parser.add_argument("--architecture-plan", required=True)
    parser.add_argument("--xs-map", required=True)
    parser.add_argument("--global-plan", required=True)
    parser.add_argument("--previous-plan", required=True)
    args = parser.parse_args()

    state_dir = Path(args.state_dir)
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    milestones = load_json(state_dir / "milestones.json")
    body = (state_dir / "canonical-plan.md").read_text(encoding="utf-8")

    entries = milestones["milestones"]

    counts = {
        status: sum(1 for item in entries if item["status"] == status)
        for status in STATUS_LABELS
    }

    total = len(entries)
    done = counts["done"]

    current = [
        item
        for item in entries
        if item["status"] == "in_progress"
    ]

    if not current:
        current = [
            item
            for item in entries
            if item["status"] == "pending"
        ][:1]

    lines = [
        "# Канонический локальный план VM101",
        "",
        "## Состояние реализации",
        "",
        f"- Версия схемы: `{milestones['schema_version']}`",
        f"- План: `{milestones['plan_id']}`",
        f"- Опубликовано: `{args.generated_at} UTC`",
        f"- Источник публикации: `{args.source_step}`",
        "- Область: **только VM101**",
        "- VM100 и VM121 не являются блокирующими критериями этого плана.",
        f"- Выполнено: **{done}/{total}**",
        "",
        "### Текущая точка",
        "",
    ]

    if current:
        for item in current:
            lines.append(
                f"- **{item['id']} — {item['title']}**"
            )

            if item.get("notes"):
                lines.append(f"  - {item['notes']}")
    else:
        lines.append("- Все этапы отмечены выполненными.")

    lines.extend([
        "",
        "## Реестр этапов",
        "",
    ])

    for item in entries:
        status = item["status"]
        marker = STATUS_MARKERS[status]
        label = STATUS_LABELS[status]

        lines.append(
            f"- {marker} **{item['id']} — {item['title']}** "
            f"— `{label}`"
        )

        if item.get("notes"):
            lines.append(f"  - {item['notes']}")

        for evidence in item.get("evidence", []):
            lines.append(f"  - Evidence: {evidence}")

    lines.extend([
        "",
        "## Ссылки текущей публикации",
        "",
        f"- CURRENT REPORT: {args.current_report}",
        f"- LOCAL ARCHITECTURE PLAN: {args.architecture_plan}",
        f"- XS MAP: {args.xs_map}",
        f"- GLOBAL PROJECT PLAN: {args.global_plan}",
        f"- PREVIOUS LOCAL PLAN: {args.previous_plan}",
        "",
        "---",
        "",
        body,
        "",
    ])

    plan_text = "\n".join(lines)

    (output_dir / "plan.md").write_text(
        plan_text,
        encoding="utf-8",
    )

    source_dir = output_dir / "source"
    source_dir.mkdir(exist_ok=True)

    for name in (
        "canonical-plan.md",
        "milestones.json",
        "render_plan.py",
        "set_status.py",
        "republish.sh",
        "README.md",
    ):
        source = state_dir / name

        if source.exists():
            shutil.copy2(source, source_dir / name)

    facts = {
        "schema": "vm101-local-plan-publication-v1",
        "generated_at_utc": args.generated_at,
        "source_step": args.source_step,
        "plan_id": milestones["plan_id"],
        "schema_version": milestones["schema_version"],
        "scope": milestones["scope"],
        "excluded_blocking_scopes":
            milestones["excluded_blocking_scopes"],
        "current_milestone":
            current[0]["id"] if current else None,
        "progress": {
            "total": total,
            **counts,
        },
        "milestones": entries,
        "publish": {
            "current_report": args.current_report,
            "architecture_plan": args.architecture_plan,
            "xs_map": args.xs_map,
            "global_project_plan": args.global_plan,
            "previous_plan": args.previous_plan,
        },
    }

    (output_dir / "facts.json").write_text(
        json.dumps(
            facts,
            ensure_ascii=False,
            indent=2,
        ) + "\n",
        encoding="utf-8",
    )

    milestone_rows = []

    for item in entries:
        status = STATUS_LABELS[item["status"]]

        milestone_rows.append(
            "<tr>"
            f"<td>{html.escape(item['id'])}</td>"
            f"<td>{html.escape(item['title'])}</td>"
            f"<td>{html.escape(status)}</td>"
            "</tr>"
        )

    index = f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Канонический локальный план VM101</title>
<style>
body {{
  font-family: system-ui, sans-serif;
  max-width: 1100px;
  margin: 40px auto;
  padding: 0 20px;
  line-height: 1.5;
}}
table {{
  border-collapse: collapse;
  width: 100%;
}}
th, td {{
  border: 1px solid #bbb;
  padding: 8px;
  text-align: left;
}}
code {{
  background: #eee;
  padding: 2px 5px;
}}
</style>
</head>
<body>
<h1>Канонический локальный план VM101</h1>

<p>
Выполнено: <strong>{done}/{total}</strong><br>
Текущий этап:
<strong>{html.escape(current[0]['id'] if current else 'COMPLETE')}</strong>
</p>

<p>
<a href="plan.md">plan.md</a> ·
<a href="facts.json">facts.json</a> ·
<a href="source/">source/</a>
</p>

<h2>Этапы</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Этап</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{''.join(milestone_rows)}
</tbody>
</table>

<h2>Ссылки</h2>
<ul>
<li><a href="{html.escape(args.current_report)}">Current report</a></li>
<li><a href="{html.escape(args.architecture_plan)}">Architecture plan</a></li>
<li><a href="{html.escape(args.xs_map)}">XS Map</a></li>
<li><a href="{html.escape(args.global_plan)}">Global project plan</a></li>
<li><a href="{html.escape(args.previous_plan)}">Previous local plan</a></li>
</ul>
</body>
</html>
"""

    (output_dir / "index.html").write_text(
        index,
        encoding="utf-8",
    )


if __name__ == "__main__":
    main()
PY

cat > "$PLAN_STATE_DIR/set_status.py" <<'PY'
#!/usr/bin/env python3

import argparse
import datetime as dt
import json
import os
import tempfile
from pathlib import Path

ALLOWED = {
    "done",
    "in_progress",
    "pending",
    "blocked",
}


def main():
    parser = argparse.ArgumentParser(
        description="Изменить статус этапа локального плана VM101."
    )

    parser.add_argument("milestone_id")
    parser.add_argument("status", choices=sorted(ALLOWED))
    parser.add_argument("--evidence", action="append", default=[])
    parser.add_argument("--note")
    parser.add_argument(
        "--state-dir",
        default=(
            "/opt/router-ops/state/local-plans/"
            "vm101-hmn-autonomous-egress-recovery"
        ),
    )

    args = parser.parse_args()

    state_dir = Path(args.state_dir)
    path = state_dir / "milestones.json"

    with path.open(encoding="utf-8") as source:
        data = json.load(source)

    target = None

    for item in data["milestones"]:
        if item["id"] == args.milestone_id:
            target = item
            break

    if target is None:
        raise SystemExit(
            f"Не найден этап {args.milestone_id}"
        )

    if args.status == "in_progress":
        for item in data["milestones"]:
            if (
                item["id"] != args.milestone_id
                and item["status"] == "in_progress"
            ):
                item["status"] = "pending"

        data["current_milestone"] = args.milestone_id

    target["status"] = args.status

    if args.note is not None:
        target["notes"] = args.note

    if args.evidence:
        existing = target.setdefault("evidence", [])

        for value in args.evidence:
            if value not in existing:
                existing.append(value)

    if args.status == "done":
        pending = [
            item
            for item in data["milestones"]
            if item["status"] == "pending"
        ]

        in_progress = [
            item
            for item in data["milestones"]
            if item["status"] == "in_progress"
        ]

        if not in_progress and pending:
            pending[0]["status"] = "in_progress"
            data["current_milestone"] = pending[0]["id"]

    data["updated_at_utc"] = (
        dt.datetime.now(dt.timezone.utc)
        .strftime("%Y%m%d-%H%M%S")
    )

    fd, temporary = tempfile.mkstemp(
        prefix="milestones.",
        suffix=".json",
        dir=state_dir,
    )

    try:
        with os.fdopen(fd, "w", encoding="utf-8") as output:
            json.dump(
                data,
                output,
                ensure_ascii=False,
                indent=2,
            )
            output.write("\n")

        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)

    print(
        f"{args.milestone_id}: {target['status']}"
    )


if __name__ == "__main__":
    main()
PY

cat > "$PLAN_STATE_DIR/republish.sh" <<'REPUBLISH'
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

STATE_DIR="/opt/router-ops/state/local-plans/vm101-hmn-autonomous-egress-recovery"
STATE_ROOT="/opt/router-ops/state"

TOKEN="e94a0859747d7b96f29c7fdafc2d0351ba603bb0a7e9e5a4"
PUBLIC_BASE="https://helena-background-beam-harry.trycloudflare.com/r/${TOKEN}"
PUBROOT="/opt/router-ops/public/r/${TOKEN}"

XS_MAP="${PUBLIC_BASE}/20260711-120734_xs_map_audit_repair_publish/"
GLOBAL_PLAN="${PUBLIC_BASE}/20260711-123348_global_project_plan_wg_paid/"

SOURCE_STEP="${1:-MANUAL_VM101_LOCAL_PLAN_REPUBLISH}"
CURRENT_REPORT="${2:-NOT_PROVIDED}"

PREVIOUS_PLAN="$(
  cat "$STATE_ROOT/current-local-architecture-plan-url.txt" \
    2>/dev/null ||
  true
)"

[ -n "$PREVIOUS_PLAN" ] ||
  PREVIOUS_PLAN="NONE"

TS="$(date -u +%Y%m%d-%H%M%S)"
SLUG="${TS}_local_architecture_plan_vm101_autonomous_hmn_recovery"
OUTPUT_DIR="${PUBROOT}/${SLUG}"
PLAN_URL="${PUBLIC_BASE}/${SLUG}/"

mkdir -p "$OUTPUT_DIR"

python3 "$STATE_DIR/render_plan.py" \
  --state-dir "$STATE_DIR" \
  --output-dir "$OUTPUT_DIR" \
  --generated-at "$TS" \
  --source-step "$SOURCE_STEP" \
  --current-report "$CURRENT_REPORT" \
  --architecture-plan "$PLAN_URL" \
  --xs-map "$XS_MAP" \
  --global-plan "$GLOBAL_PLAN" \
  --previous-plan "$PREVIOUS_PLAN"

find "$OUTPUT_DIR" \
  -type f \
  ! -name SHA256SUMS \
  -print0 |
  sort -z |
  xargs -0 sha256sum \
  > "$OUTPUT_DIR/SHA256SUMS"

printf '%s\n' \
  "$PLAN_URL" \
  > "$STATE_ROOT/current-local-architecture-plan-url.txt"

cat > "$STATE_ROOT/current-vm101-local-plan.env" <<EOF
UPDATED_AT_UTC=${TS}
SOURCE_STEP=${SOURCE_STEP}
ARCHITECTURE_PLAN=${PLAN_URL}
XS_MAP=${XS_MAP}
GLOBAL_PROJECT_PLAN=${GLOBAL_PLAN}
EOF

ln -sfn \
  "$SLUG" \
  "${PUBROOT}/LOCAL_ARCHITECTURE_PLAN_VM101_LATEST"

echo "ARCHITECTURE_PLAN=$PLAN_URL"
REPUBLISH

cat > "$PLAN_STATE_DIR/README.md" <<'README'
# VM101 canonical local plan

## Файлы

- `canonical-plan.md` — неизменяемая логика и архитектура.
- `milestones.json` — статусы этапов и evidence.
- `set_status.py` — изменение статуса.
- `render_plan.py` — генерация опубликованной версии.
- `republish.sh` — выпуск новой immutable-публикации.

## Отметить этап выполненным

Пример команды:

    /opt/router-ops/bin/vm101-local-plan-status \
      M06 done \
      --evidence "URL_ОТЧЁТА" \
      --note "Emergency commit включён и проверен на VM101"

После статуса `done` первый ожидающий этап автоматически переводится
в `in_progress`, если другого активного этапа нет.

## Установить произвольный статус

Пример команды:

    /opt/router-ops/bin/vm101-local-plan-status \
      M07 in_progress

Допустимые статусы:

- `done`
- `in_progress`
- `pending`
- `blocked`

## Переопубликовать план

Пример команды:

    /opt/router-ops/bin/vm101-local-plan-republish \
      STEP_NAME \
      CURRENT_REPORT_URL

Команда создаёт новую immutable-папку и печатает новую ссылку:

    ARCHITECTURE_PLAN=https://...
README

chmod 700 \
  "$PLAN_STATE_DIR/render_plan.py" \
  "$PLAN_STATE_DIR/set_status.py" \
  "$PLAN_STATE_DIR/republish.sh"

cat > "$BIN_DIR/vm101-local-plan-status" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail

exec python3 \
  /opt/router-ops/state/local-plans/vm101-hmn-autonomous-egress-recovery/set_status.py \
  "$@"
SH

cat > "$BIN_DIR/vm101-local-plan-republish" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail

exec \
  /opt/router-ops/state/local-plans/vm101-hmn-autonomous-egress-recovery/republish.sh \
  "$@"
SH

chmod 700 \
  "$BIN_DIR/vm101-local-plan-status" \
  "$BIN_DIR/vm101-local-plan-republish"

stage "04/08" "Проверяю созданные исходники"

python3 -m py_compile \
  "$PLAN_STATE_DIR/render_plan.py" \
  "$PLAN_STATE_DIR/set_status.py"

bash -n \
  "$PLAN_STATE_DIR/republish.sh" \
  "$BIN_DIR/vm101-local-plan-status" \
  "$BIN_DIR/vm101-local-plan-republish"

python3 - \
  "$PLAN_STATE_DIR/milestones.json" <<'PY'
import json
import sys

with open(sys.argv[1], encoding="utf-8") as source:
    data = json.load(source)

assert data["schema"] == "vm101-local-plan-milestones-v1"
assert data["scope"] == ["VM101"]
assert data["excluded_blocking_scopes"] == ["VM100", "VM121"]

milestones = data["milestones"]

assert len(milestones) == 15
assert len({item["id"] for item in milestones}) == 15
assert sum(item["status"] == "in_progress" for item in milestones) == 1
assert data["current_milestone"] == "M06"

done = {
    item["id"]
    for item in milestones
    if item["status"] == "done"
}

assert done == {"M01", "M02", "M03", "M04", "M05"}
PY

stage "05/08" "Публикую первую каноническую версию"

python3 "$PLAN_STATE_DIR/render_plan.py" \
  --state-dir "$PLAN_STATE_DIR" \
  --output-dir "$PLAN_DIR" \
  --generated-at "$TS" \
  --source-step "$STEP" \
  --current-report "$TRYCF_REPORT" \
  --architecture-plan "$ARCHITECTURE_PLAN" \
  --xs-map "$XS_MAP" \
  --global-plan "$GLOBAL_PROJECT_PLAN" \
  --previous-plan "$PREVIOUS_ARCHITECTURE_PLAN"

find "$PLAN_DIR" \
  -type f \
  ! -name SHA256SUMS \
  -print0 |
  sort -z |
  xargs -0 sha256sum \
  > "$PLAN_DIR/SHA256SUMS"

stage "06/08" "Копирую канонические источники в отчёт"

cp -a \
  "$PLAN_STATE_DIR/canonical-plan.md" \
  "$REPORT_DIR/sources/canonical-plan.md"

cp -a \
  "$PLAN_STATE_DIR/milestones.json" \
  "$REPORT_DIR/sources/milestones.json"

cp -a \
  "$PLAN_STATE_DIR/render_plan.py" \
  "$REPORT_DIR/sources/render_plan.py"

cp -a \
  "$PLAN_STATE_DIR/set_status.py" \
  "$REPORT_DIR/sources/set_status.py"

cp -a \
  "$PLAN_STATE_DIR/republish.sh" \
  "$REPORT_DIR/sources/republish.sh"

cp -a \
  "$PLAN_STATE_DIR/README.md" \
  "$REPORT_DIR/sources/README.md"

stage "07/08" "Публикую report и facts"

cat > "$REPORT_DIR/report.txt" <<EOF
=== ${STEP} RESULT ===
step=${STEP}
decision=PASS_${STEP}
all_ok=true
mode=DOCUMENTATION_ONLY
production_modified=false
vm100_modified=false
vm101_modified=false
vm121_modified=false

canonical_plan_created=true
republishable_plan_created=true
milestone_registry_created=true
milestone_total=15
milestone_done=5
milestone_in_progress=1
current_milestone=M06
current_milestone_title=Постоянно включить emergency commit на VM101

installed_tools:
  status_tool=${BIN_DIR}/vm101-local-plan-status
  republish_tool=${BIN_DIR}/vm101-local-plan-republish

artifacts:
  step_script=step.sh
  canonical_plan=sources/canonical-plan.md
  milestones=sources/milestones.json
  renderer=sources/render_plan.py
  status_editor=sources/set_status.py
  republisher=sources/republish.sh
  usage=sources/README.md

TRYCF_REPORT=${TRYCF_REPORT}
REPORT_TXT=${REPORT_TXT}
FACTS_JSON=${FACTS_JSON}
ARCHITECTURE_PLAN=${ARCHITECTURE_PLAN}
XS_MAP=${XS_MAP}
GLOBAL_PROJECT_PLAN=${GLOBAL_PROJECT_PLAN}
EOF

python3 - \
  "$STEP" \
  "$TS" \
  "$PLAN_ID" \
  "$PLAN_SCHEMA_VERSION" \
  "$TRYCF_REPORT" \
  "$REPORT_TXT" \
  "$FACTS_JSON" \
  "$ARCHITECTURE_PLAN" \
  "$XS_MAP" \
  "$GLOBAL_PROJECT_PLAN" \
  "$PREVIOUS_ARCHITECTURE_PLAN" \
  > "$REPORT_DIR/facts.json" <<'PY'
import json
import sys

(
    step,
    timestamp,
    plan_id,
    schema_version,
    report,
    report_txt,
    facts_json,
    architecture,
    xs_map,
    global_plan,
    previous_plan,
) = sys.argv[1:]

print(json.dumps({
    "schema": "router-step-facts-v1",
    "step": step,
    "generated_at_utc": timestamp,
    "assessment": {
        "decision": f"PASS_{step}",
        "all_ok": True,
    },
    "mode": "DOCUMENTATION_ONLY",
    "safety": {
        "production_modified": False,
        "vm100_modified": False,
        "vm101_modified": False,
        "vm121_modified": False,
    },
    "canonical_plan": {
        "plan_id": plan_id,
        "schema_version": int(schema_version),
        "scope": ["VM101"],
        "excluded_blocking_scopes": ["VM100", "VM121"],
        "milestone_total": 15,
        "milestone_done": 5,
        "milestone_in_progress": 1,
        "current_milestone": "M06",
        "republishable": True,
    },
    "installed_tools": {
        "status":
            "/opt/router-ops/bin/vm101-local-plan-status",
        "republish":
            "/opt/router-ops/bin/vm101-local-plan-republish",
    },
    "artifacts": {
        "step_script": "step.sh",
        "canonical_plan":
            "sources/canonical-plan.md",
        "milestones":
            "sources/milestones.json",
        "renderer":
            "sources/render_plan.py",
        "status_editor":
            "sources/set_status.py",
        "republisher":
            "sources/republish.sh",
        "usage":
            "sources/README.md",
    },
    "publish": {
        "trycf_report": report,
        "report_txt": report_txt,
        "facts_json": facts_json,
        "architecture_plan": architecture,
        "xs_map": xs_map,
        "global_project_plan": global_plan,
        "previous_architecture_plan": previous_plan,
    },
}, ensure_ascii=False, indent=2))
PY

cat > "$REPORT_DIR/index.html" <<EOF
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${STEP}</title>
</head>
<body style="font-family:system-ui;max-width:1100px;margin:40px auto;padding:0 20px;line-height:1.5">

<h1>${STEP}</h1>

<h2>Результат</h2>
<ul>
<li><a href="report.txt">report.txt</a></li>
<li><a href="facts.json">facts.json</a></li>
<li><a href="progress.log">progress.log</a></li>
<li><a href="step.sh">step.sh</a></li>
</ul>

<h2>Канонические исходники</h2>
<ul>
<li><a href="sources/canonical-plan.md">canonical-plan.md</a></li>
<li><a href="sources/milestones.json">milestones.json</a></li>
<li><a href="sources/render_plan.py">render_plan.py</a></li>
<li><a href="sources/set_status.py">set_status.py</a></li>
<li><a href="sources/republish.sh">republish.sh</a></li>
<li><a href="sources/README.md">README.md</a></li>
</ul>

<h2>Планы</h2>
<ul>
<li><a href="${ARCHITECTURE_PLAN}">Local architecture plan</a></li>
<li><a href="${XS_MAP}">XS Map</a></li>
<li><a href="${GLOBAL_PROJECT_PLAN}">Global project plan</a></li>
<li><a href="${PREVIOUS_ARCHITECTURE_PLAN}">Previous local plan</a></li>
</ul>

</body>
</html>
EOF

find "$REPORT_DIR" \
  -type f \
  ! -name SHA256SUMS \
  -print0 |
  sort -z |
  xargs -0 sha256sum \
  > "$REPORT_DIR/SHA256SUMS"

stage "08/08" "Обновляю current pointers"

printf '%s\n' \
  "$ARCHITECTURE_PLAN" \
  > "$STATE_ROOT/current-local-architecture-plan-url.txt"

cat > "$STATE_ROOT/current-project-links.env" <<EOF
UPDATED_AT_UTC=${TS}
SOURCE_STEP=${STEP}
CURRENT_REPORT=${TRYCF_REPORT}
ARCHITECTURE_PLAN=${ARCHITECTURE_PLAN}
XS_MAP=${XS_MAP}
GLOBAL_PROJECT_PLAN=${GLOBAL_PROJECT_PLAN}
EOF

cat > "$STATE_ROOT/current-vm101-local-plan.env" <<EOF
UPDATED_AT_UTC=${TS}
PLAN_ID=${PLAN_ID}
SOURCE_STEP=${STEP}
CURRENT_MILESTONE=M06
CURRENT_REPORT=${TRYCF_REPORT}
ARCHITECTURE_PLAN=${ARCHITECTURE_PLAN}
XS_MAP=${XS_MAP}
GLOBAL_PROJECT_PLAN=${GLOBAL_PROJECT_PLAN}
EOF

ln -sfn \
  "$PLAN_SLUG" \
  "${PUBROOT}/LOCAL_ARCHITECTURE_PLAN_VM101_LATEST"

trap - ERR

echo "decision=PASS_${STEP}" |
  tee -a "$PROGRESS_LOG"

echo "current_milestone=M06" |
  tee -a "$PROGRESS_LOG"

echo "production_modified=false" |
  tee -a "$PROGRESS_LOG"

print_links
