Skill: spiceman161/sys-updater (version 1.1.0)

--- name: sys-updater description: Production-safe Ubuntu maintenance orchestrator: runs daily apt security updates, tracks non-security updates across apt/npm/pnpm/brew with quarantine + auto-review, applies only approved updates, rotates logs/state, and generates clear 09:00 MSK Telegram reports (including what was actually installed). metadata: { "openclaw": { "emoji": "🔄", "os": ["linux"], "requires": { "bins": ["apt-get", "npm", "brew", "clawhub"] }, }, } --- # System Updater (sys-updater) Comprehensive system maintenance automation for Ubuntu hosts with support for apt, npm, brew, and OpenClaw skills. ## What this skill does `sys-updater` is a conservative maintenance pipeline for Linux hosts running OpenClaw. It separates **security patching** from **feature upgrades**, keeps auditable state files, and sends a human-readable daily report. ### Core capabilities - **APT (Ubuntu)** - daily `apt-get update` - automatic security updates via `unattended-upgrade` - non-security upgrades only from explicit planned list - **NPM / PNPM / Brew** - detect outdated packages - keep them in tracked state - auto-review package risk (bugs/regressions/CVE signals) - install only approved/planned updates - **OpenClaw skills (ClawHub)** - checks installed skills and reports update status - **Daily report (09:00 MSK)** - current health/status - candidates/planned/blocked per manager - **explicit “actually installed” lines** for apt/npm/pnpm/brew ## Workflow ### Daily (06:00 MSK) ``` run_6am: ├── apt: update, security upgrades, simulate, track non-security ├── npm/brew: check outdated, add to tracking └── skills: auto-update immediately (no quarantine) ``` ### Report (09:00 MSK) - Summary of all package managers - Planned updates for next day - Blocked packages with reasons ### T+2 Days (Review) - Web search for bugs/regressions in tracked packages - Mark as planned or blocked based on findings ### T+3 Days (Upgrade) - Apply planned npm/brew upgrades - Send completion report ## State Files - `state/apt/last_run.json` — Last run results - `state/apt/tracked.json` — APT packages being tracked - `state/apt/npm_tracked.json` — NPM packages - `state/apt/brew_tracked.json` — Brew packages - `state/logs/apt_maint.log` — Daily logs (10-day rotation) ## Manual Commands ```bash # Daily maintenance (runs automatically) ./scripts/apt_maint.py run_6am # Generate report ./scripts/apt_maint.py report_9am # Check npm/brew only ./scripts/pkg_maint.py check # Review packages (after 2 days) ./scripts/pkg_maint.py review # Apply planned upgrades ./scripts/pkg_maint.py upgrade # Update skills only ./scripts/pkg_maint.py skills ``` ## Configuration Environment variables: - `SYS_UPDATER_BASE_DIR` — Base directory (default: ~/clawd/sys-updater) - `SYS_UPDATER_STATE_DIR` — State files location - `SYS_UPDATER_LOG_DIR` — Log files location ## Cron Jobs Requires 4 cron jobs: 1. `run_6am` — Daily 06:00 MSK (apt + check npm/brew + auto skills) 2. `report_9am` — Daily 09:00 MSK (Telegram report) 3. `review_2d` — T+2 days 09:00 MSK (web search bugs) 4. `upgrade_3d` — T+3 days 06:00 MSK (apply planned) ## Conservative Design - **Security updates**: Applied automatically via unattended-upgrade - **Non-security**: 2-day observation period with bug research - **User control**: Can block any package with reason - **Safety**: Dry-run simulation before any apt upgrade ## Requirements - Ubuntu with apt - Node.js + npm (for npm packages) - Homebrew (for brew packages) - OpenClaw with clawhub CLI - sudo access for specific apt commands (see below) ## Sudoers Configuration For unattended operation, grant the running user passwordless sudo for specific apt commands only. **Do not add the user to full sudoers.** Create file `/etc/sudoers.d/sys-updater`: ```bash # Allow sys-updater to run apt maintenance commands without password # Replace 'username' with your actual username username ALL=(root) NOPASSWD: /usr/bin/apt-get update username ALL=(root) NOPASSWD: /usr/bin/apt-get -s upgrade username ALL=(root) NOPASSWD: /usr/bin/unattended-upgrade -d ``` Set secure permissions: ```bash sudo chmod 440 /etc/sudoers.d/sys-updater sudo visudo -c # Verify syntax is valid ``` ### Required Commands Explained | Command | Purpose | |---------|---------| | `apt-get update` | Refresh package lists | | `apt-get -s upgrade` | Simulate upgrade (dry-run, no actual changes) | | `unattended-upgrade -d` | Apply security updates automatically | ### Security Notes - Only these 3 specific commands are allowed - No `apt-get upgrade` without `-s` (simulation only for tracking) - No `apt-get dist-upgrade` or `autoremove` - No package installation/removal through sudo - NPM and brew do not require sudo (user installs)

path: CLAUDE.md
content: # CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

sys-updater is a system maintenance automation tool for Ubuntu hosts. It runs safe daily maintenance (security updates + upgrade simulations), tracks pending non-security updates for manual review, and generates Telegram reports in Russian.

**Design philosophy:** Conservative and safe. Security updates are applied automatically via `unattended-upgrade`, but non-security upgrades are never applied automatically—only tracked and reported.

## Commands

```bash
# Run 6am maintenance (apt update, security updates, simulation, track packages)
python3 scripts/apt_maint.py run_6am

# Generate 9am report (reads state from last run)
python3 scripts/apt_maint.py report_9am
```

No external dependencies—uses only Python stdlib. Requires sudo NOPASSWD for apt-get and unattended-upgrade.

## Architecture

Single-file implementation: `scripts/apt_maint.py` (~245 lines)

**Two-phase daily workflow:**
1. `run_6am` - Executes maintenance, saves state to JSON
2. `report_9am` - Renders human-readable Telegram report from saved state

**State files** (default: `state/apt/`):
- `last_run.json` - Results from latest run (RunResult dataclass)
- `tracked.json` - Package tracking metadata (firstSeenAt, reviewedAt, planned, blocked, note)

Override state directory via `SYS_UPDATER_STATE_DIR` environment variable.

**Key constraints:**
- No full-upgrade/dist-upgrade
- No autoremove
- Non-security upgrades are informational only (tracked but not applied)


path: README.md
content: # sys-updater

System maintenance automation for Ubuntu/OpenClaw hosts.

It runs safe daily maintenance, separates security patching from non-security upgrades, tracks package decisions (candidate/planned/blocked), performs risk-aware auto-review for npm/pnpm/brew, and generates clear 09:00 MSK Telegram reports with **what was actually installed**.

## Philosophy

**Conservative and safe.** Security updates are applied automatically via `unattended-upgrade`, but non-security upgrades are never applied automatically—only tracked and reported.

## Quick Start

```bash
# Run 6am maintenance (apt update, security updates, simulation, track packages)
python3 scripts/apt_maint.py run_6am

# Generate 9am report (reads state from last run)
python3 scripts/apt_maint.py report_9am

# Dry-run mode (test without executing sudo commands)
python3 scripts/apt_maint.py run_6am --dry-run

# Verbose mode (also log to console)
python3 scripts/apt_maint.py run_6am --verbose
```

## Requirements

- Python 3.10+ (stdlib only, no dependencies)
- Ubuntu with `unattended-upgrades` installed
- Sudo NOPASSWD for apt-get and unattended-upgrade (see [docs/sudoers.md](docs/sudoers.md))

## Directory Structure

```
sys-updater/
├── scripts/
│   └── apt_maint.py      # Main script (~300 lines)
├── state/
│   ├── apt/
│   │   ├── last_run.json # Results from latest run
│   │   └── tracked.json  # Package tracking metadata
│   └── logs/
│       ├── apt_maint.log          # Current log file
│       ├── apt_maint.log.YYYY-MM-DD # Rotated daily (UTC midnight), kept 10 days
│       └── cron.log              # Optional: stdout/stderr redirection
├── docs/                  # Documentation
├── CLAUDE.md             # Instructions for Claude Code
└── README.md
```

## Schedule

Default intended schedule (Europe/Moscow):
- **06:00 MSK** — `run_6am` (apt update + unattended-upgrade + simulation + tracking)
- **09:00 MSK** — `report_9am` (renders Telegram report from saved state)

In this setup, scheduling is typically done via **OpenClaw cron jobs** (see `openclaw cron list`).

## Documentation

- [How it Works](docs/how-it-works.md) - Architecture and workflow
- [Scheduling](docs/scheduling.md) - Cron setup
- [Sudoers Setup](docs/sudoers.md) - Required permissions
- [State Files](docs/state-files.md) - JSON schema reference
- [Logging](docs/logging.md) - Log format and location
- [Operations](docs/operations.md) - Disable, rollback, troubleshooting
- [Extending](docs/extending.md) - Adding providers (brew/npm/etc)

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `SYS_UPDATER_BASE_DIR` | `/home/moltuser/clawd/sys-updater` | Base directory |
| `SYS_UPDATER_STATE_DIR` | `$BASE_DIR/state/apt` | State files location |
| `SYS_UPDATER_LOG_DIR` | `$BASE_DIR/state/logs` | Log files location |

## License

Internal tool, not for distribution.


path: docs/AUTO_REVIEW.md
content: # Auto-Review System for sys-updater

Автоматическая система ревью обновлений пакетов для npm и brew в sys-updater.

## Обзор

Система автоматически проверяет доступные обновления пакетов npm и brew перед их установкой, используя:
- Данные из npm registry и Homebrew API
- GitHub Issues для выявления критических проблем
- Changelog analysis для обнаружения breaking changes
- Правила semver для определения уровня риска

## Архитектура

```
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   pkg_maint.py  │────▶│   auto_review   │────▶│  retry_logic    │
│   (check mode)  │     │                 │     │                 │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                       │                       │
        ▼                       ▼                       ▼
   npm_tracked.json      auto_review.log         npm_tracked.json
   brew_tracked.json     (decisions audit)      brew_tracked.json
```

## Файлы

- `scripts/auto_review.py` — основной модуль ревью
- `scripts/retry_logic.py` — логика повторных попыток обновления
- `state/apt/npm_tracked.json` — состояние npm пакетов
- `state/apt/brew_tracked.json` — состояние brew пакетов
- `logs/auto_review.log` — лог решений (retention 30 дней)

## Расширенная структура tracked.json

```json
{
  "items": {
    "package-name": {
      "firstSeenAt": "2024-01-01T00:00:00Z",
      "reviewedAt": null,
      "planned": false,
      "blocked": false,
      "note": null,
      "currentVersion": "1.2.3",
      "latestVersion": "1.2.4",
      "manager": "npm",
      "type": "package",
      
      // Новые поля для auto-review:
      "releaseDate": "2024-01-01T00:00:00Z",
      "autoReviewAt": "2024-01-03T00:00:00Z",
      "reviewedBy": "auto",
      "reviewResult": "ok",
      "failedAttempts": 0,
      "lastAttemptAt": null
    }
  }
}
```

### Описание полей

| Поле | Тип | Описание |
|------|-----|----------|
| `releaseDate` | string (ISO) | Дата релиза версии из npm/brew API |
| `autoReviewAt` | string (ISO) | Время выполнения auto-review |
| `reviewedBy` | "auto" \| "manual" \| null | Кто принял решение |
| `reviewResult` | "ok" \| "blocked" \| "pending" \| null | Результат ревью |
| `failedAttempts` | int | Счётчик неудачных попыток обновления |
| `lastAttemptAt` | string (ISO) \| null | Время последней попытки |

## Критерии принятия решений

### Версии patch (x.y.Z)

**Автоматически OK**, если:
- Прошло ≥2 дня с релиза
- Changelog не содержит danger keywords

### Версии minor (x.Y.z)

**Требуется проверка:**
- Прошло ≥2 дня с релиза
- Нет critical/blocking issues в GitHub за последние 3 дня
- Changelog не содержит danger keywords

### Версии major (X.y.z)

**Всегда BLOCKED** — требует ручного ревью

### Danger Keywords

При обнаружении в changelog/issue title:
- `breaking` / `breaking change`
- `regression`
- `rollback`
- `critical`
- `security vulnerability`
- `deprecat` (deprecated, deprecation)
- `removed`
- `dropped support`
- `incompatible`

### Blocking Issue Labels

Issues с метками:
- `bug`
- `critical`
- `security`
- `regression`
- `blocking`
- `crash`
- `data loss`

### Логика retry

- Максимум 3 попытки обновления
- Минимум 1 день между попытками
- После 3 неудачных попыток — auto-block
- Счётчик сбрасывается при успешном обновлении

## Rate Limiting

### GitHub API

- Неаутентифицированный доступ: 60 запросов/час
- Минимум 1 секунда между запросами
- При достижении лимита — логируется warning, пакеты помечаются как pending

### npm Registry

- Ограничения не документированы
- Используем стандартные HTTP timeout (30s)

### Homebrew API

- Ограничения не документированы
- Используем стандартные HTTP timeout (30s)

## Использование

### Автоматический запуск

Auto-review выполняется автоматически в `run_6am()` после `pkg_maint.py`:

```
run_6am()
  ├── pkg_maint.py check
  ├── pkg_maint.py upgrade
  ├── retry_logic.process_retries()
  └── auto_review.run_auto_review()
```

### Ручной запуск

```bash
# Полный auto-review
python3 scripts/apt_maint.py auto-review

# Сухой прогон (без сохранения)
python3 scripts/apt_maint.py auto-review --dry-run

# Подробный вывод
python3 scripts/apt_maint.py auto-review --verbose
```

### Независимый запуск модулей

```bash
# Только auto-review
python3 scripts/auto_review.py --verbose

# Только retry logic
python3 scripts/retry_logic.py --verbose
```

## Пример лога (auto_review.log)

```
2024-01-15T06:05:23Z [INFO] === AUTO-REVIEW START ===
2024-01-15T06:05:24Z [INFO] Starting auto-review: 3 npm, 2 brew packages
2024-01-15T06:05:25Z [INFO] [OK] npm/axios: Patch version with no danger indicators
2024-01-15T06:05:26Z [INFO] [BLOCKED] npm/express: Major version update (4.x → 5.x) requires manual review
2024-01-15T06:05:27Z [INFO] [BLOCKED] brew/node: Changelog contains danger keywords: breaking, incompatible
2024-01-15T06:05:28Z [INFO] [OK] npm/lodash: Minor version, no critical issues found
2024-01-15T06:05:28Z [INFO] Auto-review complete: 5 reviewed, 2 approved, 2 blocked
2024-01-15T06:05:28Z [INFO] === AUTO-REVIEW END ===
```

## Тестирование

```bash
# Запуск всех тестов
cd /home/moltuser/clawd/sys-updater
python3 tests/test_auto_review.py

# С подробным выводом
python3 tests/test_auto_review.py -v
```

## Безопасность

1. **Нет автоматических обновлений major версий** — всегда требуется ручное ревью
2. **Grace period 2 дня** — время для community на нахождение проблем
3. **GitHub issues monitoring** — проверка на critical bugs
4. **Changelog scanning** — поиск breaking changes
5. **Failed attempt tracking** — автоматический блок после 3 неудач

## Отладка

### Проверка отдельного пакета

```python
import sys
sys.path.insert(0, '/home/moltuser/clawd/sys-updater/scripts')
from auto_review import check_npm_package, auto_review_package, PackageMeta

# Проверить npm пакет
result = check_npm_package("express", "5.0.0")
print(result)

# Полное ревью
meta = PackageMeta(
    name="express",
    current_version="4.18.0",
    latest_version="5.0.0",
    manager="npm"
)
result, reason = auto_review_package(meta)
print(result, reason)
```

### Просмотр логов

```bash
# Последние решения
tail -50 logs/auto_review.log

# Решения по конкретному пакету
grep "express" logs/auto_review.log

# Только blocked
grep "BLOCKED" logs/auto_review.log
```

## Интеграция с существующей системой

Система полностью совместима с существующими механизмами:

- `planned` — пакет помечается для обновления при `reviewResult: "ok"`
- `blocked` — пакет блокируется при `reviewResult: "blocked"`
- `pkg_maint.py upgrade` — обновляет только `planned=True`
- Ручное ревью через `reviewedBy: "manual"` — auto-review пропускает такие пакеты

## TODO / Roadmap

- [ ] Поддержка кастомных реестров npm
- [ ] Кэширование GitHub API ответов
- [ ] Интеграция с security advisories (npm audit, GitHub Security Advisories)
- [ ] Webhook notifications для blocked пакетов
- [ ] ML-based risk scoring


path: docs/REFACTOR-PLAN-2026-02-19.md
content: # Sys-updater Refactor Plan (2026-02-19)

## Scope
Refactor technical debt identified in review while keeping runtime behavior stable.

## Goals
1. Reduce duplication across package managers (npm/pnpm/brew).
2. Make pending/planned transitions deterministic and auditable.
3. Keep 06:00/09:00 flows stable with no sudo-policy regressions.

## Risks
- Breaking manager-specific edge cases (especially brew link recovery).
- Divergence between tracked state and real outdated state.

## Superpowers Pipeline

### 1) Spec (what “done” means)
- Shared manager registry used by check/review/upgrade code paths.
- npm/pnpm upgrade logic unified into one function.
- behavior parity preserved for:
  - pending->planned escalation
  - brew link recovery
  - post-upgrade recheck
- scripts compile + dry-run flows pass.

### 2) Plan
- [x] Add manager registry helpers in `pkg_maint.py`.
- [x] Refactor `check_mode` to generic manager loop.
- [x] Refactor `review_mode` to generic manager loop.
- [x] Extract shared npm/pnpm upgrade routine.
- [x] Keep brew upgrade logic specialized (link recovery).
- [x] Validate via py_compile + dry-run checks.

### 3) Execution notes
- Introduced:
  - `MANAGER_ORDER`, `MANAGER_TRACK_PATHS`, `MANAGER_CHECKERS`
  - `_load_all_tracked`, `_save_all_tracked`, `_count_flag`
  - `_upgrade_node_manager` (shared npm/pnpm path)
- Preserved brew-specific recovery logic.
- Kept report and state formats backward-compatible.

### 4) Validation
- `python3 -m py_compile scripts/*.py` ✅
- `python3 scripts/pkg_maint.py check` ✅
- `python3 scripts/pkg_maint.py upgrade --dry-run` ✅
- `python3 scripts/apt_maint.py report_9am` ✅
- `python3 scripts/apt_maint.py auto-review --dry-run` ✅

## Result
Core manager orchestration debt reduced; behavior remains stable.

## Next refactor batch
1. Extract manager adapters into `scripts/managers/*.py` (full modularization).
2. Add tests for:
   - pnpm no-importer case
   - stale pending -> planned
   - brew link recovery path
3. Add strict run markers in 06:00 flow (`START/END`) with correlation id.


path: docs/extending.md
content: # Extending sys-updater

sys-updater is designed for apt on Ubuntu, but the architecture can be extended to other package managers.

## Adding a New Provider

### 1. Create a new script

```
scripts/
├── apt_maint.py      # existing
├── brew_maint.py     # new: Homebrew
├── npm_maint.py      # new: npm global packages
└── ...
```

### 2. Follow the same pattern

Each provider should implement:

```python
def run_6am() -> ProviderResult:
    """Execute maintenance, save state."""
    pass

def render_report() -> str:
    """Generate human-readable report."""
    pass
```

### 3. State file convention

```
state/
├── apt/
│   ├── last_run.json
│   └── tracked.json
├── brew/
│   ├── last_run.json
│   └── tracked.json
├── npm/
│   ├── last_run.json
│   └── tracked.json
└── logs/
    └── *.log
```

## Example: Homebrew Provider

```python
# scripts/brew_maint.py

def run_6am() -> RunResult:
    # 1. brew update
    sh(["brew", "update"])

    # 2. brew upgrade (dry-run)
    cp = sh(["brew", "upgrade", "--dry-run"], check=False)

    # 3. List outdated
    outdated = sh(["brew", "outdated", "--json"])

    # 4. Save state
    save_json(STATE_DIR / "brew" / "last_run.json", result.__dict__)

    return result
```

## Example: npm Global Packages

```python
# scripts/npm_maint.py

def run_6am() -> RunResult:
    # 1. Check outdated
    cp = sh(["npm", "outdated", "-g", "--json"], check=False)

    # 2. Parse and track
    outdated = json.loads(cp.stdout) if cp.stdout else {}

    # 3. Save state
    save_json(STATE_DIR / "npm" / "last_run.json", result.__dict__)

    return result
```

## Future Providers

Potential providers to add:

| Provider | Use case |
|----------|----------|
| `brew` | macOS Homebrew packages |
| `npm` | Node.js global packages |
| `pip` | Python system packages |
| `snap` | Ubuntu Snap packages |
| `flatpak` | Flatpak applications |
| `docker` | Docker image updates |
| `openclaw` | OpenClaw model updates |
| `claude` | Claude Code updates |

## Unified Report

A future `report_all.py` could aggregate all providers:

```python
def render_unified_report():
    providers = ["apt", "brew", "npm"]
    sections = []

    for p in providers:
        state = load_json(STATE_DIR / p / "last_run.json", None)
        if state:
            sections.append(render_provider_section(p, state))

    return "\n\n".join(sections)
```

## Contributing

When adding a provider:

1. Keep it conservative (no auto-apply for non-security)
2. Use the same state file schema where possible
3. Add logging following the same pattern
4. Update CLAUDE.md with new commands
5. Add documentation in docs/


path: docs/how-it-works.md
content: # How sys-updater Works

## Overview

sys-updater is a two-phase daily workflow for safe system maintenance:

1. **06:00 MSK** - `run_6am`: Execute maintenance, save state
2. **09:00 MSK** - `report_9am`: Generate human-readable report from saved state

## Architecture

Single-file implementation: `scripts/apt_maint.py` (~300 lines, Python stdlib only)

```
┌─────────────────────────────────────────────────────────────────┐
│                        run_6am (06:00 MSK)                      │
├─────────────────────────────────────────────────────────────────┤
│  1. apt-get update          → refresh package lists            │
│  2. unattended-upgrade -d   → apply security updates           │
│  3. apt-get -s upgrade      → simulate (dry-run) full upgrade  │
│  4. apt list --upgradable   → list pending non-security pkgs   │
│  5. save state              → last_run.json, tracked.json      │
└─────────────────────────────────────────────────────────────────┘
                              ↓
                    state/apt/last_run.json
                    state/apt/tracked.json
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│                       report_9am (09:00)                        │
├─────────────────────────────────────────────────────────────────┤
│  1. load state files                                            │
│  2. render Telegram report (Russian)                            │
│  3. print to stdout (caller sends to Telegram)                  │
└─────────────────────────────────────────────────────────────────┘
```

## Key Constraints

| Constraint | Rationale |
|------------|-----------|
| No `dist-upgrade` / `full-upgrade` | Could break system with major version changes |
| No `autoremove` | Could remove packages needed by other software |
| Non-security upgrades NOT applied | Require manual review before applying |
| Security updates via `unattended-upgrade` | Official Ubuntu mechanism, well-tested |

## RunResult Dataclass

The core data structure saved after each run:

```python
@dataclass
class RunResult:
    ranAt: str              # ISO timestamp
    aptUpdateOk: bool       # apt-get update succeeded
    unattendedOk: bool      # unattended-upgrade succeeded
    simulatedOk: bool       # apt-get -s upgrade succeeded
    updatedPackages: list   # packages updated (from apt history)
    plannedApplied: list    # reserved for future (manual apply)
    securityNote: str|None  # last line from unattended-upgrade
    upgradable: list        # pending non-security packages
    simulateSummary: str    # e.g. "5 upgraded, 0 newly installed..."
```

## Package Tracking

`tracked.json` maintains metadata for each non-security package.

**Automatic cleanup:** Packages that are no longer upgradable (already upgraded or removed from repos) are automatically removed from tracking, unless they are marked as `blocked` or `planned` (indicating admin made a decision about them).

```json
{
  "createdAt": "2024-01-15T06:00:00Z",
  "items": {
    "package-name": {
      "firstSeenAt": "2024-01-15T06:00:00Z",
      "reviewedAt": null,
      "planned": false,
      "blocked": false,
      "note": null
    }
  }
}
```

- **firstSeenAt**: When package first appeared as upgradable
- **reviewedAt**: When admin reviewed (null = not reviewed)
- **planned**: Mark for future upgrade (informational only)
- **blocked**: Block from planned upgrades (known issues)
- **note**: Admin notes (e.g., "wait for v2.1 bug fix")


path: docs/logging.md
content: # Logging

## Location

Logs are stored in `state/logs/` (configurable via `SYS_UPDATER_LOG_DIR`).

```
state/logs/
├── apt_maint.log           # Current day's log
├── apt_maint.log.2024-01-14 # Previous days (rotated)
├── apt_maint.log.2024-01-13
└── ...
```

## Rotation

- **Rotation**: Daily at midnight UTC
- **Retention**: 10 days (configurable in code: `backupCount=10`)
- **Handler**: `TimedRotatingFileHandler` from Python stdlib

## Log Format

```
2024-01-15T06:00:05Z [INFO] === run_6am START ===
2024-01-15T06:00:05Z [INFO] State files: last_run=/path/to/last_run.json, tracked=/path/to/tracked.json
2024-01-15T06:00:05Z [INFO] Running: sudo apt-get update
2024-01-15T06:00:12Z [INFO] apt-get update: OK
2024-01-15T06:00:12Z [INFO] Running: sudo unattended-upgrade -d
2024-01-15T06:00:45Z [INFO] unattended-upgrade: OK (rc=0)
...
2024-01-15T06:01:02Z [INFO] === run_6am END (success) ===
```

Format: `%(asctime)s [%(levelname)s] %(message)s`

## What is Logged

| Event | Level | Example |
|-------|-------|---------|
| Phase start/end | INFO | `=== run_6am START ===` |
| Command execution | INFO | `Running: sudo apt-get update` |
| Command result | INFO/ERROR | `apt-get update: OK` or `FAILED (...)` |
| State file paths | INFO | `State files: last_run=..., tracked=...` |
| Package counts | INFO | `Upgradable packages found: 5` |
| Tracking summary | INFO | `Tracking summary: planned=2, blocked=1` |
| Unhandled exceptions | ERROR | Full traceback |

## What is NOT Logged

- Passwords or authentication tokens
- Telegram bot tokens or session strings
- Full command output (only summaries)
- Environment variables containing secrets

## Console Logging (Verbose Mode)

Use `--verbose` / `-v` to also log to stderr:

```bash
python3 scripts/apt_maint.py run_6am --verbose
```

Console output uses simplified format: `[LEVEL] message`

## Viewing Logs

```bash
# Recent entries
tail -100 state/logs/apt_maint.log

# Follow in real-time
tail -f state/logs/apt_maint.log

# Search for errors
grep ERROR state/logs/apt_maint.log*

# Last run summary
grep -A 20 "run_6am START" state/logs/apt_maint.log | tail -25
```

## Cron Logs

Cron output (stdout/stderr not captured by the logging system) goes to `state/logs/cron.log` if configured in crontab with redirection.


path: docs/operations.md
content: # Operations

## Disable sys-updater

### Temporary (one day)

Comment out cron entries:

```bash
crontab -e
# Comment the lines with #
```

### Permanent

```bash
# Remove cron entries
crontab -e  # Delete sys-updater lines

# Or if using systemd
sudo systemctl disable --now sys-updater-6am.timer
```

## Rollback / Recovery

### If a security update broke something

1. Check what was updated:
   ```bash
   cat state/apt/last_run.json | jq '.updatedPackages'
   # Or check apt history
   cat /var/log/apt/history.log | tail -100
   ```

2. Downgrade specific package:
   ```bash
   # Find available versions
   apt-cache policy <package-name>

   # Install specific version
   sudo apt-get install <package-name>=<version>
   ```

3. Block the package from future updates:
   ```bash
   sudo apt-mark hold <package-name>
   ```

### If state files are corrupted

```bash
# Backup corrupted files
mv state/apt/last_run.json state/apt/last_run.json.bak
mv state/apt/tracked.json state/apt/tracked.json.bak

# Re-run to regenerate
python3 scripts/apt_maint.py run_6am
```

## Command-Line Options

```bash
# Dry-run mode: simulate without executing sudo commands
python3 scripts/apt_maint.py run_6am --dry-run
python3 scripts/apt_maint.py run_6am -n

# Verbose mode: also log to console (stderr)
python3 scripts/apt_maint.py run_6am --verbose
python3 scripts/apt_maint.py run_6am -v

# Combine both
python3 scripts/apt_maint.py run_6am -n -v
```

## Troubleshooting

### "Another instance is running"

A lock file prevents parallel `run_6am` executions:

```bash
# Check if another instance is running
cat state/apt/.run_6am.lock

# If stale (process doesn't exist), remove manually
rm state/apt/.run_6am.lock
```

### "No data" in report

The 06:00 run hasn't executed or failed. Check:

```bash
# Check logs
tail -50 state/logs/apt_maint.log

# Check if state file exists
ls -la state/apt/

# Manual run
python3 scripts/apt_maint.py run_6am
```

### Sudo password prompt

Sudoers not configured. See [sudoers.md](sudoers.md).

### unattended-upgrade fails

```bash
# Check unattended-upgrades status
sudo unattended-upgrade -d -v

# Check configuration
cat /etc/apt/apt.conf.d/50unattended-upgrades
```

### Package stuck in "tracked"

Edit `tracked.json` manually:

```bash
vim state/apt/tracked.json
# Set reviewedAt to current timestamp, or blocked: true
```

## Maintenance Commands

```bash
# View current state
cat state/apt/last_run.json | python3 -m json.tool
cat state/apt/tracked.json | python3 -m json.tool

# Count pending packages
cat state/apt/tracked.json | jq '.items | length'

# List blocked packages
cat state/apt/tracked.json | jq '.items | to_entries | map(select(.value.blocked)) | .[].key'

# Clear old tracking (reset)
rm state/apt/tracked.json
python3 scripts/apt_maint.py run_6am
```


path: docs/scheduling.md
content: # Scheduling

## Scheduling Options

### Option A (recommended here): OpenClaw cron

This repo is usually scheduled by OpenClaw cron jobs (timezone set to Europe/Moscow in the job):
- 06:00 MSK → `scripts/apt_maint.py run_6am`
- 09:00 MSK → `scripts/apt_maint.py report_9am`

Check:
```bash
openclaw cron list
openclaw cron runs --id <jobId>
```

### Option B: System crontab

Add to crontab (`crontab -e` as moltuser):

```cron
# sys-updater: daily maintenance at 06:00 MSK (03:00 UTC)
0 3 * * * /usr/bin/python3 /home/moltuser/clawd/sys-updater/scripts/apt_maint.py run_6am >> /home/moltuser/clawd/sys-updater/state/logs/cron.log 2>&1

# sys-updater: daily report at 09:00 MSK (06:00 UTC)
# Note: actual Telegram sending is handled by the caller (e.g., OpenClaw cron job)
0 6 * * * /usr/bin/python3 /home/moltuser/clawd/sys-updater/scripts/apt_maint.py report_9am >> /home/moltuser/clawd/sys-updater/state/logs/cron.log 2>&1
```

## Alternative: Systemd Timer

Create `/etc/systemd/system/sys-updater-6am.service`:

```ini
[Unit]
Description=sys-updater 6am maintenance
After=network-online.target

[Service]
Type=oneshot
User=moltuser
ExecStart=/usr/bin/python3 /home/moltuser/clawd/sys-updater/scripts/apt_maint.py run_6am
```

Create `/etc/systemd/system/sys-updater-6am.timer`:

```ini
[Unit]
Description=Run sys-updater daily at 06:00 UTC

[Timer]
OnCalendar=*-*-* 06:00:00 UTC
Persistent=true

[Install]
WantedBy=timers.target
```

Enable:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now sys-updater-6am.timer
```

## Manual Execution

```bash
# Test run_6am (requires sudo permissions)
python3 scripts/apt_maint.py run_6am

# Test report_9am (no sudo needed, reads state files)
python3 scripts/apt_maint.py report_9am
```


path: docs/state-files.md
content: # State Files

All state is stored in JSON files under `state/apt/` (configurable via `SYS_UPDATER_STATE_DIR`).

## last_run.json

Results from the most recent `run_6am` execution.

```json
{
  "ranAt": "2024-01-15T06:00:05Z",
  "aptUpdateOk": true,
  "unattendedOk": true,
  "simulatedOk": true,
  "updatedPackages": ["openssl", "libssl3"],
  "plannedApplied": [],
  "securityNote": "All upgrades installed",
  "upgradable": ["nodejs", "npm", "docker-ce"],
  "simulateSummary": "3 upgraded, 0 newly installed, 0 to remove and 0 not upgraded."
}
```

| Field | Type | Description |
|-------|------|-------------|
| `ranAt` | string | ISO 8601 timestamp (UTC) |
| `aptUpdateOk` | bool | `apt-get update` succeeded |
| `unattendedOk` | bool | `unattended-upgrade` succeeded |
| `simulatedOk` | bool | `apt-get -s upgrade` succeeded |
| `updatedPackages` | string[] | Packages updated (from apt history) |
| `plannedApplied` | string[] | Reserved for future manual apply feature |
| `securityNote` | string? | Last line from unattended-upgrade output |
| `upgradable` | string[] | Pending non-security packages |
| `simulateSummary` | string | Summary line from simulation |

## tracked.json

Long-term tracking metadata for non-security packages.

```json
{
  "createdAt": "2024-01-10T06:00:00Z",
  "items": {
    "nodejs": {
      "firstSeenAt": "2024-01-10T06:00:00Z",
      "reviewedAt": "2024-01-12T10:30:00Z",
      "planned": true,
      "blocked": false,
      "note": "Reviewed changelog, safe to upgrade"
    },
    "docker-ce": {
      "firstSeenAt": "2024-01-14T06:00:00Z",
      "reviewedAt": null,
      "planned": false,
      "blocked": false,
      "note": null
    },
    "php8.1": {
      "firstSeenAt": "2024-01-08T06:00:00Z",
      "reviewedAt": "2024-01-09T11:00:00Z",
      "planned": false,
      "blocked": true,
      "note": "Known regression in 8.1.27, wait for 8.1.28"
    }
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `createdAt` | string | When tracking file was created |
| `items` | object | Map of package name → metadata |
| `items.*.firstSeenAt` | string | When package first appeared as upgradable |
| `items.*.reviewedAt` | string? | When admin reviewed (null = not reviewed) |
| `items.*.planned` | bool | Marked for future upgrade |
| `items.*.blocked` | bool | Blocked due to known issues |
| `items.*.note` | string? | Admin notes |

## Manual Editing

You can edit `tracked.json` manually to:

- Mark packages as reviewed: set `reviewedAt` to current timestamp
- Plan upgrades: set `planned: true`
- Block risky packages: set `blocked: true`, add `note`

Example workflow:

```bash
# Edit tracked.json
vim state/apt/tracked.json

# Re-run report to see changes
python3 scripts/apt_maint.py report_9am
```


path: docs/sudoers.md
content: # Sudoers Setup

sys-updater requires passwordless sudo for specific apt commands.

## Required Commands

```
/usr/bin/apt-get update
/usr/bin/apt-get -s upgrade
/usr/sbin/unattended-upgrade -d
```

## Configuration

Create `/etc/sudoers.d/sys-updater`:

```sudoers
# sys-updater: allow moltuser to run apt maintenance without password
moltuser ALL=(ALL) NOPASSWD: /usr/bin/apt-get update
moltuser ALL=(ALL) NOPASSWD: /usr/bin/apt-get -s upgrade
moltuser ALL=(ALL) NOPASSWD: /usr/sbin/unattended-upgrade -d
```

Apply:

```bash
sudo visudo -f /etc/sudoers.d/sys-updater
# Paste the content above, save
sudo chmod 440 /etc/sudoers.d/sys-updater
```

## Verify

```bash
# Should run without password prompt
sudo -n apt-get update
sudo -n apt-get -s upgrade
sudo -n unattended-upgrade -d
```

## Security Notes

- Only simulation (`-s`) is allowed for upgrade, not actual upgrade
- `unattended-upgrade` is the official Ubuntu security update mechanism
- No `apt-get install`, `remove`, or `autoremove` permissions granted
- Logs do **not** store full stdout/stderr of apt commands (only summaries) and never capture passwords/tokens

## Troubleshooting

If you see "sudo: a password is required":

1. Check file permissions: `ls -la /etc/sudoers.d/sys-updater`
2. Validate syntax: `sudo visudo -c -f /etc/sudoers.d/sys-updater`
3. Ensure no conflicting rules in `/etc/sudoers` or other files in `/etc/sudoers.d/`


path: scripts/apt_maint.py
content: #!/usr/bin/env python3
"""Apt maintenance helper for the OpenClaw host (sys-updater).

Modes:
- run_6am: sudo apt-get update; sudo unattended-upgrade; sudo apt-get -s upgrade; snapshot upgradable pkgs.
- report_9am: render a human report from last run state.

Conservative by design:
- No full-upgrade/dist-upgrade
- Non-security upgrades are applied only from explicit planned list
"""

from __future__ import annotations

import argparse
import fcntl
import json
import logging
import logging.handlers
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any

# Import auto-review modules
sys.path.insert(0, str(Path(__file__).parent))
import auto_review
import retry_logic

# === Constants ===
DEFAULT_TIMEOUT = 600  # 10 minutes for long-running commands like unattended-upgrade
APT_HISTORY_HOURS = 24  # Only consider upgrades from last N hours

# === Directories ===
BASE_DIR = Path(os.getenv("SYS_UPDATER_BASE_DIR", "/home/moltuser/clawd/sys-updater"))
STATE_DIR = Path(os.getenv("SYS_UPDATER_STATE_DIR", BASE_DIR / "state" / "apt"))
LOG_DIR = Path(os.getenv("SYS_UPDATER_LOG_DIR", BASE_DIR / "state" / "logs"))
LOCK_FILE = STATE_DIR / ".run_6am.lock"

LAST_RUN_PATH = STATE_DIR / "last_run.json"
TRACK_PATH = STATE_DIR / "tracked.json"

# Paths for npm/brew tracking (used by auto-review)
NPM_TRACK_PATH = STATE_DIR / "npm_tracked.json"
PNPM_TRACK_PATH = STATE_DIR / "pnpm_tracked.json"
BREW_TRACK_PATH = STATE_DIR / "brew_tracked.json"

# === Logging setup: daily rotation, keep 10 days ===
LOG_FILE = LOG_DIR / "apt_maint.log"
AUTO_REVIEW_LOG_FILE = LOG_DIR / "auto_review.log"

# Global state
_dry_run = False
_verbose = False


def _setup_auto_review_logging() -> logging.Logger:
    """Configure separate logging for auto-review decisions."""
    logger = logging.getLogger("auto_review")
    logger.handlers.clear()
    logger.setLevel(logging.DEBUG)
    
    # File handler: daily rotation, 30 days retention for audit trail
    file_handler = logging.handlers.TimedRotatingFileHandler(
        AUTO_REVIEW_LOG_FILE,
        when="midnight",
        interval=1,
        backupCount=30,
        encoding="utf-8",
        utc=True,
    )
    file_handler.suffix = "%Y-%m-%d"
    formatter = logging.Formatter(
        "%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%Y-%m-%dT%H:%M:%SZ",
    )
    file_handler.setFormatter(formatter)
    logger.addHandler(file_handler)
    
    return logger


def _ensure_directories() -> None:
    """Create required directories and verify write permissions."""
    for d in (STATE_DIR, LOG_DIR):
        d.mkdir(parents=True, exist_ok=True)
        # Verify write permission
        test_file = d / ".write_test"
        try:
            test_file.write_text("test")
            test_file.unlink()
        except PermissionError:
            raise PermissionError(f"No write permission for directory: {d}")


def _setup_logging(verbose: bool = False) -> logging.Logger:
    """Configure file logging with daily rotation (10 days retention).

    Args:
        verbose: If True, also log to console (stderr).
    """
    logger = logging.getLogger("sys_updater")

    # Clear existing handlers to allow reconfiguration
    logger.handlers.clear()
    logger.setLevel(logging.DEBUG)

    # File handler: daily rotation, 10 days
    file_handler = logging.handlers.TimedRotatingFileHandler(
        LOG_FILE,
        when="midnight",
        interval=1,
        backupCount=10,
        encoding="utf-8",
        utc=True,
    )
    file_handler.suffix = "%Y-%m-%d"
    file_formatter = logging.Formatter(
        "%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%Y-%m-%dT%H:%M:%SZ",
    )
    file_handler.setFormatter(file_formatter)
    logger.addHandler(file_handler)

    # Console handler (if verbose)
    if verbose:
        console_handler = logging.StreamHandler(sys.stderr)
        console_handler.setLevel(logging.INFO)
        console_formatter = logging.Formatter("[%(levelname)s] %(message)s")
        console_handler.setFormatter(console_formatter)
        logger.addHandler(console_handler)

    return logger


# Initialize with file-only logging; reconfigure if --verbose
_ensure_directories()
log = _setup_logging(verbose=False)


def sh(
    cmd: list[str],
    *,
    check: bool = True,
    timeout: int = DEFAULT_TIMEOUT,
) -> subprocess.CompletedProcess[str]:
    """Run a shell command with timeout.

    Args:
        cmd: Command and arguments.
        check: Raise on non-zero exit code.
        timeout: Timeout in seconds (default 600s = 10min).

    Returns:
        CompletedProcess with stdout/stderr captured.

    Raises:
        subprocess.TimeoutExpired: If command exceeds timeout.
        subprocess.CalledProcessError: If check=True and command fails.
    """
    global _dry_run
    if _dry_run and cmd[0] == "sudo":
        log.info("[DRY-RUN] Would execute: %s", " ".join(cmd))
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
    return subprocess.run(cmd, text=True, capture_output=True, check=check, timeout=timeout)


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def now_utc() -> datetime:
    return datetime.now(timezone.utc)


def sudo_cmd(args: list[str]) -> list[str]:
    return ["sudo", "-n", *args]


@dataclass
class RunResult:
    ranAt: str
    aptUpdateOk: bool
    unattendedOk: bool
    simulatedOk: bool
    updatedPackages: list[str]
    plannedApplied: list[str]
    # Per-manager applied upgrades from pkg_maint.py upgrade summary
    pkgAppliedNpm: list[str]
    pkgAppliedPnpm: list[str]
    pkgAppliedBrew: list[str]
    securityNote: str | None
    upgradable: list[str]
    simulateSummary: str


def parse_apt_upgrade_simulation(output: str) -> str:
    m = re.search(r"\d+ upgraded, \d+ newly installed, \d+ to remove and \d+ not upgraded\.", output)
    if m:
        return m.group(0)
    lines = output.strip().splitlines()
    return lines[-1] if lines else ""


def list_upgradable() -> list[str]:
    """List upgradable packages using apt directly (no bash wrapper)."""
    # Use apt list directly, suppressing the warning about CLI stability
    env = os.environ.copy()
    env["LANG"] = "C.UTF-8"
    cp = subprocess.run(
        ["apt", "list", "--upgradable"],
        text=True,
        capture_output=True,
        check=True,
        env=env,
        timeout=60,
    )
    pkgs: list[str] = []
    for line in cp.stdout.splitlines():
        if not line or line.startswith("Listing"):
            continue
        # Format: "package/repo version [upgradable from: old_version]"
        name = line.split("/")[0].strip()
        if name:
            pkgs.append(name)
    return sorted(set(pkgs))


def parse_recent_upgrades_from_history(hours: int = APT_HISTORY_HOURS) -> list[str]:
    """Parse apt history.log for packages upgraded in the last N hours.

    Args:
        hours: Only consider upgrades from the last N hours.

    Returns:
        List of package names that were upgraded recently.
    """
    p = Path("/var/log/apt/history.log")
    if not p.exists():
        return []
    try:
        text = p.read_text(encoding="utf-8", errors="replace")
    except PermissionError:
        return []

    cutoff = now_utc() - timedelta(hours=hours)
    upgraded: list[str] = []

    # Parse history.log blocks
    # Format:
    # Start-Date: 2024-01-15  06:00:05
    # Commandline: ...
    # Upgrade: pkg1:arch (old, new), pkg2:arch (old, new)
    # End-Date: ...
    #
    # IMPORTANT: do NOT split by comma, because version tuples also contain commas.
    # Extract package tokens with regex: <name>:<arch> (

    current_date: datetime | None = None
    for line in text.splitlines():
        if line.startswith("Start-Date:"):
            date_str = line.replace("Start-Date:", "").strip()
            try:
                date_str = re.sub(r"\s+", " ", date_str)
                current_date = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
                current_date = current_date.replace(tzinfo=timezone.utc)
            except ValueError:
                current_date = None
        elif line.startswith("Upgrade:") and current_date and current_date >= cutoff:
            payload = line.replace("Upgrade:", "")
            # Examples matched:
            #   libexpat1:amd64 (2.6.1-2ubuntu0.3, 2.6.1-2ubuntu0.4)
            #   cloud-init:all (24.4.1-0ubuntu0~24.04.2, ...)
            names = re.findall(r"([A-Za-z0-9.+-]+):[^\s]+\s*\(", payload)
            upgraded.extend(names)

    return sorted(set(upgraded))


def apply_planned_apt_upgrades(planned: list[str], upgradable_set: set[str]) -> tuple[list[str], list[str]]:
    """Apply explicitly planned non-security upgrades via apt-get install.

    Returns:
        (applied_packages, failed_packages)
    """
    # Only attempt packages that are still upgradable now.
    targets = sorted([p for p in planned if p in upgradable_set])
    if not targets:
        return [], []

    try:
        log.info("Running: sudo apt-get install -y %s", " ".join(targets))
        cp = sh(sudo_cmd(["apt-get", "install", "-y", *targets]), check=False, timeout=DEFAULT_TIMEOUT)
        if cp.returncode == 0:
            log.info("planned install: OK (%d packages)", len(targets))
            return targets, []

        log.error("planned install: FAIL (rc=%d)", cp.returncode)
        # Conservative fallback: mark all as failed if batch install failed.
        return [], targets
    except subprocess.TimeoutExpired:
        log.error("planned install: TIMEOUT (%ds)", DEFAULT_TIMEOUT)
        return [], targets
    except Exception as e:
        log.error("planned install: FAILED (%s)", e)
        return [], targets


def load_json(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        log.warning("Failed to load %s: %s; using default", path, e)
        return default


def save_json(path: Path, data: Any) -> None:
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


class LockFile:
    """Context manager for exclusive file locking (prevents parallel runs)."""

    def __init__(self, path: Path):
        self.path = path
        self.fd: int | None = None

    def __enter__(self) -> "LockFile":
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.fd = os.open(str(self.path), os.O_CREAT | os.O_RDWR)
        try:
            fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            os.close(self.fd)
            raise RuntimeError(f"Another instance is running (lock: {self.path})")
        # Write PID for debugging
        os.ftruncate(self.fd, 0)
        os.write(self.fd, f"{os.getpid()}\n".encode())
        return self

    def __exit__(self, *args: Any) -> None:
        if self.fd is not None:
            fcntl.flock(self.fd, fcntl.LOCK_UN)
            os.close(self.fd)
            try:
                self.path.unlink()
            except OSError:
                pass


def extract_last_line(text: str | None) -> str | None:
    """Safely extract the last non-empty line from text."""
    if not text:
        return None
    lines = text.strip().splitlines()
    return lines[-1] if lines else None


def parse_pkg_upgrade_summary(summary_stdout: str) -> dict[str, list[str]]:
    """Parse pkg_maint.py upgrade summary into per-manager applied lists."""
    applied = {"npm": [], "pnpm": [], "brew": []}
    if not summary_stdout:
        return applied

    for line in summary_stdout.splitlines():
        line = line.strip()
        # Expected lines like:
        # "✅ npm: pkg1, pkg2"
        # "✅ brew: uv"
        m = re.match(r"^✅\s+(npm|pnpm|brew):\s+(.+)$", line)
        if not m:
            continue
        manager = m.group(1)
        pkgs = [p.strip() for p in m.group(2).split(",") if p.strip()]
        applied[manager] = sorted(set(pkgs))

    return applied


def run_6am() -> RunResult:
    ran_at = now_iso()
    t0 = datetime.now(timezone.utc)
    log.info("=== run_6am START ===")
    log.info("State files: last_run=%s, tracked=%s", LAST_RUN_PATH, TRACK_PATH)
    log.info("PHASE[apt]: begin")

    apt_update_ok = True
    try:
        log.info("Running: sudo apt-get update")
        sh(sudo_cmd(["apt-get", "update"]), check=True, timeout=300)
        log.info("apt-get update: OK")
    except subprocess.TimeoutExpired:
        apt_update_ok = False
        log.error("apt-get update: TIMEOUT (300s)")
    except Exception as e:
        apt_update_ok = False
        log.error("apt-get update: FAILED (%s)", e)

    unattended_ok = True
    security_note: str | None = None
    try:
        log.info("Running: sudo unattended-upgrade -d")
        cp = sh(sudo_cmd(["unattended-upgrade", "-d"]), check=False, timeout=DEFAULT_TIMEOUT)
        unattended_ok = cp.returncode == 0
        security_note = extract_last_line(cp.stdout)
        log.info("unattended-upgrade: %s (rc=%d)", "OK" if unattended_ok else "FAIL", cp.returncode)
    except subprocess.TimeoutExpired:
        unattended_ok = False
        log.error("unattended-upgrade: TIMEOUT (%ds)", DEFAULT_TIMEOUT)
    except Exception as e:
        unattended_ok = False
        log.error("unattended-upgrade: FAILED (%s)", e)

    simulated_ok = True
    sim_summary = ""
    try:
        log.info("Running: sudo apt-get -s upgrade (simulation)")
        cp = sh(sudo_cmd(["apt-get", "-s", "upgrade"]), check=False, timeout=120)
        simulated_ok = cp.returncode == 0
        sim_summary = parse_apt_upgrade_simulation((cp.stdout or "") + "\n" + (cp.stderr or ""))
        log.info("apt-get -s upgrade: %s; summary=%s", "OK" if simulated_ok else "FAIL", sim_summary)
    except subprocess.TimeoutExpired:
        simulated_ok = False
        log.error("apt-get -s upgrade: TIMEOUT (120s)")
    except Exception as e:
        simulated_ok = False
        log.error("apt-get -s upgrade: FAILED (%s)", e)

    upgradable: list[str] = []
    try:
        upgradable = list_upgradable()
        log.info("Upgradable packages found: %d", len(upgradable))
    except subprocess.TimeoutExpired:
        log.error("list_upgradable: TIMEOUT")
    except Exception as e:
        log.error("list_upgradable: FAILED (%s)", e)

    updated_pkgs = parse_recent_upgrades_from_history(hours=APT_HISTORY_HOURS)
    log.info("Recently updated packages (last %dh): %d", APT_HISTORY_HOURS, len(updated_pkgs))

    # Load and update tracked packages
    tracked = load_json(TRACK_PATH, {"createdAt": ran_at, "items": {}})
    items: dict[str, Any] = tracked.get("items") or {}

    # Track new packages
    new_tracked = 0
    for pkg in upgradable:
        if pkg not in items:
            new_tracked += 1
            items[pkg] = {
                "firstSeenAt": ran_at,
                "reviewedAt": None,
                "planned": False,
                "blocked": False,
                "note": None,
            }

    # Cleanup: remove packages no longer upgradable.
    # Keep only blocked entries as a short-lived safety memory.
    upgradable_set = set(upgradable)
    removed = 0
    to_remove = []
    for pkg, meta in items.items():
        if pkg not in upgradable_set:
            if not meta.get("blocked"):
                to_remove.append(pkg)
    for pkg in to_remove:
        del items[pkg]
        removed += 1

    if removed > 0:
        log.info("Cleanup: removed %d packages no longer upgradable", removed)

    # Recovery: packages reviewed as OK but planned flag wasn't set (review agent bug recovery).
    recovered = 0
    for pkg, meta in items.items():
        note = (meta.get("note") or "").lower()
        if (
            meta.get("reviewedAt")
            and not meta.get("planned")
            and not meta.get("blocked")
            and ("review ok" in note or "auto-approved" in note)
        ):
            meta["planned"] = True
            recovered += 1
            log.info("Recovered stuck package: %s → planned=True (note=%r)", pkg, meta.get("note"))
    if recovered > 0:
        log.info("Recovery: set planned=True for %d stuck packages (reviewed OK but flag missing)", recovered)

    tracked["items"] = items
    save_json(TRACK_PATH, tracked)
    log.info("Updated tracked.json: %d new, %d removed, %d recovered, %d total", new_tracked, removed, recovered, len(items))

    # Log summary of planned/blocked decisions
    planned_count = sum(1 for m in items.values() if m.get("planned") and not m.get("blocked"))
    blocked_count = sum(1 for m in items.values() if m.get("blocked"))
    log.info("Tracking summary: planned=%d, blocked=%d", planned_count, blocked_count)

    # Apply explicitly planned non-security apt upgrades.
    upgradable_set = set(upgradable)
    planned_targets = sorted([p for p, m in items.items() if m.get("planned") and not m.get("blocked")])
    applied_planned, failed_planned = apply_planned_apt_upgrades(planned_targets, upgradable_set)

    pkg_applied = {"npm": [], "pnpm": [], "brew": []}

    # Remove successfully applied packages from tracking (they're no longer upgradable).
    for pkg in applied_planned:
        items.pop(pkg, None)
        log.info("Removed from tracking (installed): %s", pkg)
    if applied_planned:
        log.info("Removed %d installed packages from tracked.json", len(applied_planned))

    tracked["items"] = items
    save_json(TRACK_PATH, tracked)

    result = RunResult(
        ranAt=ran_at,
        aptUpdateOk=apt_update_ok,
        unattendedOk=unattended_ok,
        simulatedOk=simulated_ok,
        updatedPackages=updated_pkgs,
        plannedApplied=applied_planned,
        pkgAppliedNpm=[],
        pkgAppliedPnpm=[],
        pkgAppliedBrew=[],
        securityNote=security_note,
        upgradable=upgradable,
        simulateSummary=sim_summary,
    )
    save_json(LAST_RUN_PATH, result.__dict__)
    log.info("Saved last_run.json")

    if failed_planned:
        log.warning("planned install failed for: %s", ", ".join(failed_planned))

    # Cleanup orphaned dependencies
    try:
        log.info("Running: sudo apt-get autoremove -y")
        cp = sh(sudo_cmd(["apt-get", "autoremove", "-y"]), check=False, timeout=300)
        if cp.returncode == 0:
            log.info("apt-get autoremove: OK")
        else:
            log.warning("apt-get autoremove: FAIL (rc=%d)", cp.returncode)
    except subprocess.TimeoutExpired:
        log.error("apt-get autoremove: TIMEOUT (300s)")
    except Exception as e:
        log.error("apt-get autoremove: FAILED (%s)", e)

    log.info("PHASE[apt]: end")
    log.info("PHASE[pkg-maint]: begin")

    # Run pkg_maint.py for npm/brew checks + planned upgrades
    try:
        pkg_maint_path = Path(__file__).parent / "pkg_maint.py"
        if pkg_maint_path.exists():
            log.info("Running: pkg_maint.py check for npm/brew")
            cp_check = subprocess.run(
                ["python3", str(pkg_maint_path), "check"],
                capture_output=True, text=True, timeout=180
            )
            if cp_check.returncode == 0:
                log.info("pkg_maint.py check: OK")
            else:
                log.warning("pkg_maint.py check: rc=%d, err=%s", cp_check.returncode, cp_check.stderr[:200])

            log.info("Running: pkg_maint.py upgrade for npm/brew planned packages")
            cp_upgrade = subprocess.run(
                ["python3", str(pkg_maint_path), "upgrade"],
                capture_output=True, text=True, timeout=600
            )
            if cp_upgrade.returncode == 0:
                log.info("pkg_maint.py upgrade: OK")
                if cp_upgrade.stdout:
                    log.info("pkg_maint.py upgrade summary: %s", cp_upgrade.stdout.strip().replace("\n", " | ")[:400])
                    pkg_applied = parse_pkg_upgrade_summary(cp_upgrade.stdout)
            else:
                log.warning("pkg_maint.py upgrade: rc=%d, err=%s", cp_upgrade.returncode, cp_upgrade.stderr[:200])
        else:
            log.warning("pkg_maint.py not found at %s", pkg_maint_path)
    except Exception as e:
        log.error("pkg_maint.py failed: %s", e)

    log.info("PHASE[pkg-maint]: end")

    # Persist per-manager applied upgrades (from pkg_maint) for 09:00 reporting.
    result.pkgAppliedNpm = pkg_applied.get("npm", [])
    result.pkgAppliedPnpm = pkg_applied.get("pnpm", [])
    result.pkgAppliedBrew = pkg_applied.get("brew", [])
    save_json(LAST_RUN_PATH, result.__dict__)
    log.info(
        "Saved pkg-applied summary: npm=%d, pnpm=%d, brew=%d",
        len(result.pkgAppliedNpm),
        len(result.pkgAppliedPnpm),
        len(result.pkgAppliedBrew),
    )

    log.info("PHASE[auto-review]: begin")

    # === Auto-Review and Retry Logic for npm/brew packages ===
    try:
        # Setup separate logging for auto-review
        ar_log = _setup_auto_review_logging()
        ar_log.info("=== AUTO-REVIEW START ===")
        
        # First: process any pending retries from previous failures
        log.info("Processing pending retries for failed upgrades...")
        retry_summary = retry_logic.process_retries(
            NPM_TRACK_PATH,
            BREW_TRACK_PATH,
            PNPM_TRACK_PATH,
            dry_run=_dry_run
        )
        total_retry_attempts = (
            retry_summary["npm"]["attempted"] + 
            retry_summary["brew"]["attempted"] + retry_summary.get("pnpm", {}).get("attempted", 0)
        )
        if total_retry_attempts > 0:
            log.info(
                "Retry processing: %d attempted, %d succeeded, %d blocked",
                total_retry_attempts,
                retry_summary["npm"]["succeeded"] + retry_summary["brew"]["succeeded"] + retry_summary.get("pnpm", {}).get("succeeded", 0),
                retry_summary["npm"]["blocked"] + retry_summary["brew"]["blocked"] + retry_summary.get("pnpm", {}).get("blocked", 0)
            )
        
        # Then: run auto-review for new/updated packages
        log.info("Running auto-review for npm/brew packages...")
        ar_summary = auto_review.run_auto_review(
            NPM_TRACK_PATH,
            BREW_TRACK_PATH,
            PNPM_TRACK_PATH,
            dry_run=_dry_run
        )
        
        total_reviewed = (
            ar_summary["npm"]["reviewed"] + 
            ar_summary["brew"]["reviewed"] + ar_summary.get("pnpm", {}).get("reviewed", 0)
        )
        total_approved = (
            ar_summary["npm"]["approved"] + 
            ar_summary["brew"]["approved"] + ar_summary.get("pnpm", {}).get("approved", 0)
        )
        total_blocked = (
            ar_summary["npm"]["blocked"] + 
            ar_summary["brew"]["blocked"] + ar_summary.get("pnpm", {}).get("blocked", 0)
        )
        
        log.info(
            "Auto-review complete: %d reviewed, %d approved, %d blocked",
            total_reviewed,
            total_approved,
            total_blocked
        )
        ar_log.info(
            "Auto-review: reviewed=%d, approved=%d, blocked=%d, pending=%d",
            total_reviewed,
            total_approved,
            total_blocked,
            ar_summary["npm"]["pending"] + ar_summary["brew"]["pending"] + ar_summary.get("pnpm", {}).get("pending", 0)
        )
        
        # Log details of decisions
        for detail in ar_summary.get("details", []):
            if detail["result"] in ("ok", "blocked"):
                ar_log.info(
                    "[%s] %s/%s: %s",
                    detail["result"].upper(),
                    detail["manager"],
                    detail["name"],
                    detail.get("reason", "No reason provided")[:200],
                )

    except Exception as e:
        log.error("Auto-review failed: %s", e)
        ar_log = logging.getLogger("auto_review")
        ar_log.error("Auto-review error: %s", e)
    finally:
        try:
            logging.getLogger("auto_review").info("=== AUTO-REVIEW END ===")
        except Exception:
            pass

    log.info("PHASE[auto-review]: end")
    elapsed = int((datetime.now(timezone.utc) - t0).total_seconds())
    log.info("=== run_6am END (success, duration=%ss) ===", elapsed)
    return result


def render_report(now_msk_label: str = "09:00 MSK") -> str:
    log.info("=== report_9am START ===")
    log.info("Reading state files: last_run=%s, tracked=%s", LAST_RUN_PATH, TRACK_PATH)
    last = load_json(LAST_RUN_PATH, None)
    tracked = load_json(TRACK_PATH, {"items": {}})

    if not last:
        log.warning("No last_run.json found")
        log.info("=== report_9am END ===")
        return "🕘 Отчёт (09:00 MSK)\n\n⚠️ Нет данных: задача 06:00 ещё не запускалась или не смогла сохранить состояние."

    updated_raw = last.get("updatedPackages") or []

    def sanitize_pkg_names(values: list[str]) -> list[str]:
        cleaned: list[str] = []
        for v in values:
            if not isinstance(v, str):
                continue
            name = v.strip()
            if not name:
                continue
            # Drop obvious version fragments from legacy parser output, e.g. "1.2.11-1ubuntu0.2)"
            if name.endswith(")") or re.match(r"^[0-9]", name):
                continue
            cleaned.append(name)
        return sorted(set(cleaned))

    updated = sanitize_pkg_names(updated_raw)
    applied_planned = last.get("plannedApplied") or []
    sim = last.get("simulateSummary") or ""

    items: dict[str, Any] = (tracked.get("items") or {})

    # APT non-security categories
    candidates_pkgs = sorted([p for p, meta in items.items() if not meta.get("reviewedAt") and not meta.get("blocked")])
    planned_pkgs    = sorted([p for p, meta in items.items() if meta.get("planned") and not meta.get("blocked")])
    blocked_pkgs    = sorted([p for p, meta in items.items() if meta.get("blocked")])
    # Stuck = reviewed OK but planned flag was never set (recovery picks these up at next 06:00)
    stuck_pkgs      = sorted([
        p for p, meta in items.items()
        if meta.get("reviewedAt") and not meta.get("planned") and not meta.get("blocked")
        and ("review ok" in (meta.get("note") or "").lower() or "auto-approved" in (meta.get("note") or "").lower())
    ])

    def fmt_list(pkgs: list[str], max_n: int = 25) -> str:
        if not pkgs:
            return "—"
        if len(pkgs) <= max_n:
            return ", ".join(pkgs)
        return ", ".join(pkgs[:max_n]) + f" …(+{len(pkgs)-max_n})"

    def pkg_section(label: str, candidates: list[str], planned: list[str], blocked: list[str],
                    stuck: list[str] | None = None, max_n: int = 15) -> list[str]:
        """Render a uniform package-manager section matching the APT layout."""
        sec: list[str] = [label]
        sec.append(f"- кандидаты (ожидают ревью): {fmt_list(candidates, max_n)}")
        if stuck:
            sec.append(f"- ⚠️ зависших (ревью OK, флаг не выставлен): {fmt_list(stuck, max_n)}")
        sec.append(f"- 🔄 запланировано к установке: {fmt_list(planned, max_n)}")
        if blocked:
            sec.append(f"- 🚫 заблокировано: {fmt_list(blocked, max_n)}")
        return sec

    lines: list[str] = []

    lines.append(f"🕘 Отчёт ({now_msk_label})")
    lines.append("")

    lines.append("⚙️ Сегодня в 06:00:")
    lines.append(f"- apt update: {'OK' if last.get('aptUpdateOk') else 'FAIL'}")
    lines.append(f"- security updates (unattended-upgrade): {'OK' if last.get('unattendedOk') else 'FAIL'}")
    lines.append(f"- обновилось: {fmt_list(updated) if updated else '—'}")
    sim_note = f"{sim} (до установки planned)" if applied_planned and sim else sim
    lines.append(f"- симуляция (до установки): {'OK' if last.get('simulatedOk') else 'FAIL'}; {sim_note}")
    if applied_planned:
        lines.append(f"- ✅ установлено non-security planned: {fmt_list(applied_planned)}")

    pkg_applied_npm = last.get("pkgAppliedNpm") or []
    pkg_applied_pnpm = last.get("pkgAppliedPnpm") or []
    pkg_applied_brew = last.get("pkgAppliedBrew") or []
    if pkg_applied_npm:
        lines.append(f"- ✅ установлено npm planned: {fmt_list(pkg_applied_npm)}")
    if pkg_applied_pnpm:
        lines.append(f"- ✅ установлено pnpm planned: {fmt_list(pkg_applied_pnpm)}")
    if pkg_applied_brew:
        lines.append(f"- ✅ установлено brew planned: {fmt_list(pkg_applied_brew)}")
    lines.append("")

    # APT non-security section (same structure as other managers below)
    lines.extend(pkg_section(
        "📦 apt (non-security):",
        candidates_pkgs, planned_pkgs, blocked_pkgs, stuck=stuck_pkgs,
    ))
    lines.append("")

    # npm / pnpm / brew sections using tracked.json data (mirrors APT layout)
    npm_data    = load_json(NPM_TRACK_PATH,  {"items": {}})
    pnpm_data   = load_json(PNPM_TRACK_PATH, {"items": {}})
    brew_data   = load_json(BREW_TRACK_PATH, {"items": {}})

    def categorise(track_items: dict[str, Any]) -> tuple[list[str], list[str], list[str]]:
        cands   = sorted([k for k, v in track_items.items()
                          if not v.get("planned") and not v.get("blocked")
                          and (not v.get("reviewedAt") or v.get("reviewResult") == "pending")])
        planneds = sorted([k for k, v in track_items.items() if v.get("planned") and not v.get("blocked")])
        blockeds = sorted([k for k, v in track_items.items() if v.get("blocked")])
        return cands, planneds, blockeds

    npm_c,  npm_p,  npm_b  = categorise(npm_data.get("items",  {}))
    pnpm_c, pnpm_p, pnpm_b = categorise(pnpm_data.get("items", {}))
    brew_c, brew_p, brew_b = categorise(brew_data.get("items", {}))

    for label, c, p, b in [
        ("📦 npm (global):", npm_c, npm_p, npm_b),
        ("📦 pnpm (global):", pnpm_c, pnpm_p, pnpm_b),
        ("📦 brew:", brew_c, brew_p, brew_b),
    ]:
        if any([c, p, b]):
            lines.extend(pkg_section(label, c, p, b, max_n=10))
            lines.append("")

    # Compute review-due lists for "tomorrow" section
    now_utc = datetime.now(timezone.utc)

    def due_for_review(track_path: Path) -> list[str]:
        data = load_json(track_path, {"items": {}})
        result = []
        for name, meta in data.get("items", {}).items():
            if meta.get("reviewedAt") or meta.get("blocked"):
                continue
            first = meta.get("firstSeenAt")
            if not first:
                continue
            try:
                dt = datetime.fromisoformat(first.replace("Z", "+00:00"))
                if (now_utc - dt).days >= 2:
                    result.append(name)
            except (ValueError, TypeError):
                pass
        return sorted(result)

    apt_review_due = []
    for name, meta in items.items():
        if not meta.get("reviewedAt") and not meta.get("blocked"):
            first = meta.get("firstSeenAt")
            if first:
                try:
                    dt = datetime.fromisoformat(first.replace("Z", "+00:00"))
                    if (now_utc - dt).days >= 1:  # will be due tomorrow
                        apt_review_due.append(name)
                except (ValueError, TypeError):
                    pass

    npm_review_due  = due_for_review(NPM_TRACK_PATH)
    pnpm_review_due = due_for_review(PNPM_TRACK_PATH)
    brew_review_due = due_for_review(BREW_TRACK_PATH)

    lines.append("📅 Запланировано на завтра 06:00:")
    lines.append("- security updates + симуляция (всегда)")
    lines.append(f"- apt non-security install: {fmt_list(planned_pkgs)}")
    lines.append(f"- apt ревью (06:30): {fmt_list(apt_review_due) if apt_review_due else '—'}")
    if npm_review_due or pnpm_review_due or brew_review_due:
        all_pkg_due = (
            [f"npm/{n}" for n in npm_review_due] +
            [f"pnpm/{n}" for n in pnpm_review_due] +
            [f"brew/{n}" for n in brew_review_due]
        )
        lines.append(f"- auto-review pkg (06:00): {fmt_list(all_pkg_due)}")

    # Skills section
    try:
        result = subprocess.run(
            ["clawhub", "list"], capture_output=True, text=True, timeout=30
        )
        if result.returncode == 0 and result.stdout:
            skill_count = len([l for l in result.stdout.strip().split('\n') if l.strip()])
            
            # Check for skill updates
            skills_updated = []
            skills_with_changes = []
            try:
                update_result = subprocess.run(
                    ["clawhub", "update", "--all", "--no-input"],
                    capture_output=True, text=True, timeout=60
                )
                if update_result.returncode == 0:
                    for line in update_result.stdout.split('\n'):
                        if 'updating' in line.lower() and '->' in line:
                            # Extract skill name and version
                            match = re.search(r'[Uu]pdating\s+(\S+).*->\s+(\S+)', line)
                            if match:
                                skills_updated.append(f"{match.group(1)}({match.group(2)})")
                        elif 'local changes' in line.lower():
                            match = re.search(r'^-\s*Checking\s+(\S+)', line, re.MULTILINE)
                            if match:
                                skills_with_changes.append(match.group(1))
            except Exception:
                pass
            
            lines.append("")
            if skills_updated:
                lines.append(f"🧩 Skills: {skill_count} (обновлены: {', '.join(skills_updated)})")
            elif skills_with_changes:
                lines.append(f"🧩 Skills: {skill_count} ({len(skills_with_changes)} с локальными изменениями)")
            else:
                lines.append(f"🧩 Skills: {skill_count} (обновлений нет)")
    except Exception:
        pass

    log.info("Report generated: updated=%d, tracked=%d, planned=%d, blocked=%d",
             len(updated), len(candidates_pkgs), len(planned_pkgs), len(blocked_pkgs))
    log.info("=== report_9am END ===")
    return "\n".join(lines)


def run_auto_review_command(dry_run: bool = False) -> str:
    """Manual auto-review command for npm/brew packages.
    
    Similar to run_6am auto-review but can be run independently.
    
    Args:
        dry_run: If True, don't save changes
        
    Returns:
        Human-readable report of review results
    """
    log.info("=== AUTO-REVIEW COMMAND START ===")
    ar_log = _setup_auto_review_logging()
    ar_log.info("=== MANUAL AUTO-REVIEW START ===")
    
    lines = ["🔍 Результат авто-ревью пакетов:"]
    
    try:
        # Process retries first
        retry_summary = retry_logic.process_retries(
            NPM_TRACK_PATH,
            BREW_TRACK_PATH,
            PNPM_TRACK_PATH,
            dry_run=dry_run
        )
        
        total_retry_attempts = (
            retry_summary["npm"]["attempted"] + 
            retry_summary["brew"]["attempted"] + retry_summary.get("pnpm", {}).get("attempted", 0)
        )
        
        if total_retry_attempts > 0:
            lines.append("\n🔄 Обработка повторных попыток:")
            lines.append(f"  npm: {retry_summary['npm']['succeeded']} успешно, {retry_summary['npm']['failed']} неудачно, {retry_summary['npm']['blocked']} заблокировано")
            lines.append(f"  pnpm: {retry_summary.get('pnpm', {}).get('succeeded', 0)} успешно, {retry_summary.get('pnpm', {}).get('failed', 0)} неудачно, {retry_summary.get('pnpm', {}).get('blocked', 0)} заблокировано")
            lines.append(f"  brew: {retry_summary['brew']['succeeded']} успешно, {retry_summary['brew']['failed']} неудачно, {retry_summary['brew']['blocked']} заблокировано")
        
        # Run auto-review
        ar_summary = auto_review.run_auto_review(
            NPM_TRACK_PATH,
            BREW_TRACK_PATH,
            PNPM_TRACK_PATH,
            dry_run=dry_run
        )
        
        total_reviewed = (
            ar_summary["npm"]["reviewed"] + 
            ar_summary["brew"]["reviewed"] + ar_summary.get("pnpm", {}).get("reviewed", 0)
        )
        total_approved = (
            ar_summary["npm"]["approved"] + 
            ar_summary["brew"]["approved"] + ar_summary.get("pnpm", {}).get("approved", 0)
        )
        total_blocked = (
            ar_summary["npm"]["blocked"] + 
            ar_summary["brew"]["blocked"] + ar_summary.get("pnpm", {}).get("blocked", 0)
        )
        total_pending = (
            ar_summary["npm"]["pending"] + 
            ar_summary["brew"]["pending"] + ar_summary.get("pnpm", {}).get("pending", 0)
        )
        
        lines.append(f"\n📊 Проверено пакетов: {total_reviewed}")
        lines.append(f"  ✅ Одобрено для обновления: {total_approved}")
        lines.append(f"  🚫 Заблокировано: {total_blocked}")
        lines.append(f"  ⏳ Ожидают (недостаточно времени): {total_pending}")
        
        # Show details of approved packages
        approved_details = [
            d for d in ar_summary.get("details", [])
            if d["result"] == "ok"
        ]
        if approved_details:
            lines.append("\n📦 Одобрены для обновления:")
            for d in approved_details[:10]:  # Limit to 10
                lines.append(f"  - {d['manager']}/{d['name']}")
            if len(approved_details) > 10:
                lines.append(f"  ... и ещё {len(approved_details) - 10}")
        
        # Show details of blocked packages
        blocked_details = [
            d for d in ar_summary.get("details", [])
            if d["result"] == "blocked"
        ]
        if blocked_details:
            lines.append("\n⚠️ Заблокированы:")
            for d in blocked_details[:10]:
                reason = d.get("reason", "")
                # Truncate reason
                if len(reason) > 80:
                    reason = reason[:77] + "..."
                lines.append(f"  - {d['manager']}/{d['name']}: {reason}")
            if len(blocked_details) > 10:
                lines.append(f"  ... и ещё {len(blocked_details) - 10}")
        
        # Log to auto_review.log
        for detail in ar_summary.get("details", []):
            if detail["result"] in ("ok", "blocked"):
                ar_log.info(
                    "[%s] %s/%s: %s",
                    detail["result"].upper(),
                    detail["manager"],
                    detail["name"],
                    detail.get("reason", "No reason")[:200]
                )
        
        ar_log.info("=== MANUAL AUTO-REVIEW END ===")
        log.info("=== AUTO-REVIEW COMMAND END ===")
        
        if dry_run:
            lines.append("\n⚠️ Режим сухого прогона (изменения не сохранены)")
        
        return "\n".join(lines)
        
    except Exception as e:
        log.error("Auto-review command failed: %s", e)
        ar_log.error("Manual auto-review error: %s", e)
        return f"❌ Ошибка авто-ревью: {e}"


def main() -> int:
    global _dry_run, _verbose, log

    ap = argparse.ArgumentParser(
        description="Apt maintenance helper for sys-updater.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 apt_maint.py run_6am           # Run daily maintenance
  python3 apt_maint.py run_6am --dry-run # Simulate without executing sudo commands
  python3 apt_maint.py report_9am        # Generate Telegram report
  python3 apt_maint.py report_9am -v     # Generate report with verbose logging
  python3 apt_maint.py auto-review       # Run auto-review manually
  python3 apt_maint.py auto-review -n    # Dry-run auto-review
""",
    )
    ap.add_argument("mode", choices=["run_6am", "report_9am", "auto-review"], help="Operation mode")
    ap.add_argument(
        "--dry-run", "-n",
        action="store_true",
        help="Don't execute sudo commands, just log what would happen",
    )
    ap.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="Also log to console (stderr)",
    )
    args = ap.parse_args()

    _dry_run = args.dry_run
    _verbose = args.verbose

    # Reconfigure logging if verbose requested
    if _verbose:
        log = _setup_logging(verbose=True)

    if _dry_run:
        log.info("DRY-RUN mode enabled: sudo commands will be skipped")

    exit_code = 0
    try:
        if args.mode == "run_6am":
            with LockFile(LOCK_FILE):
                run_6am()
        elif args.mode == "report_9am":
            print(render_report())
        elif args.mode == "auto-review":
            print(run_auto_review_command(dry_run=_dry_run))
        else:
            log.error("Unknown mode: %s", args.mode)
            exit_code = 2
    except RuntimeError as e:
        # LockFile error (another instance running)
        log.error("%s", e)
        print(f"Error: {e}", file=sys.stderr)
        exit_code = 1
    except Exception as e:
        log.exception("Unhandled exception in mode=%s: %s", args.mode, e)
        exit_code = 1

    if exit_code != 0:
        log.error("Exiting with code %d", exit_code)
    return exit_code


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/auto_review.py
content: #!/usr/bin/env python3
"""Auto-review module for package updates (sys-updater extension).

Performs automatic review of npm and brew packages before upgrading.
Uses public GitHub/npm APIs to check for issues and changelogs.

Criteria for auto-approval:
- Patch versions (x.y.Z): automatically OK after 2 days
- Minor versions (x.Y.z): requires issue check on GitHub
- Major versions (X.y.z): always blocked, requires manual review

Criteria for auto-block:
- Critical/blocking issues in GitHub (last 3 days)
- Changelog contains "breaking", "regression", "rollback"
- 3+ failed upgrade attempts
"""

from __future__ import annotations

import json
import logging
import re
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from enum import Enum
from pathlib import Path
from typing import Any, Optional
import sys
from urllib.parse import quote

# Setup logging
logger = logging.getLogger("auto_review")

# Rate limiting for GitHub API (60 req/hour for unauthenticated)
GITHUB_RATE_LIMIT = 60
_github_requests_made = 0
_last_github_request_time: Optional[datetime] = None

# Review delay in days (minimum time before auto-review)
REVIEW_DAYS = 2

def is_due_for_review(item: dict, days: int = REVIEW_DAYS) -> bool:
    """Check if package is due for review (firstSeenAt + days has passed)."""
    first_seen = item.get("firstSeenAt")
    if not first_seen:
        return False

    try:
        first_dt = datetime.fromisoformat(first_seen.replace("Z", "+00:00"))
        now = datetime.now(timezone.utc)
        days_passed = (now - first_dt).days
        return days_passed >= days
    except (ValueError, TypeError) as e:
        logger.warning(f"Failed to parse firstSeenAt '{first_seen}': {e}")
        return False


def is_due_for_auto_review(release_date: Optional[str], days: int = REVIEW_DAYS) -> bool:
    """Compatibility helper: check release-date based review maturity."""
    if not release_date:
        return False
    try:
        dt = datetime.fromisoformat(str(release_date).replace("Z", "+00:00"))
        return (datetime.now(timezone.utc) - dt) >= timedelta(days=days)
    except (ValueError, TypeError):
        return False


# Danger keywords in changelogs.
# NOTE: keep this conservative but avoid noisy false positives.
STRONG_DANGER_KEYWORDS = [
    "breaking",
    "breaking change",
    "breaking changes",
    "regression",
    "rollback",
    "critical",
    "security vulnerability",
    "incompatible",
]

# "removed"/"deprecated" alone can be benign; treat as risky only with context.
WEAK_DANGER_KEYWORDS = [
    "deprecated",
    "removed",
]


class ReviewResult(Enum):
    """Result of package review."""
    OK = "ok"
    BLOCKED = "blocked"
    PENDING = "pending"
    ERROR = "error"


class VersionType(Enum):
    """Type of version change."""
    PATCH = "patch"  # x.y.Z -> x.y.W
    MINOR = "minor"  # x.Y.z -> x.W.z
    MAJOR = "major"  # X.y.z -> W.y.z


@dataclass
class PackageMeta:
    """Package metadata for review."""
    name: str
    current_version: str
    latest_version: str
    manager: str  # "npm" or "brew"
    first_seen_at: Optional[str] = None
    release_date: Optional[str] = None
    auto_review_at: Optional[str] = None
    reviewed_by: Optional[str] = None
    review_result: Optional[str] = None
    failed_attempts: int = 0
    last_attempt_at: Optional[str] = None
    issues: list = field(default_factory=list)
    changelog_content: Optional[str] = None
    type: str = "package"  # "package" or "formula"


def now_iso() -> str:
    """Return current time in ISO format."""
    return datetime.now(timezone.utc).isoformat()


def parse_version(version_str: str) -> tuple[int, int, int]:
    """Parse version string to (major, minor, patch) tuple.
    
    Handles:
    - 1.2.3 -> (1, 2, 3)
    - 1.2.3-alpha -> (1, 2, 3)
    - v1.2.3 -> (1, 2, 3)
    """
    # Remove leading 'v' and trailing pre-release/build/packager suffixes
    clean = version_str.lstrip("vV").split("-")[0].split("+")[0].split("_")[0]
    parts = clean.split(".")
    
    try:
        major = int(parts[0]) if len(parts) > 0 else 0
        minor = int(parts[1]) if len(parts) > 1 else 0
        patch = int(parts[2]) if len(parts) > 2 else 0
        return (major, minor, patch)
    except ValueError:
        # Fallback for non-numeric versions
        return (0, 0, 0)


def get_version_type(current: str, latest: str) -> VersionType:
    """Determine version change type.
    
    Returns:
        VersionType.MAJOR for X.y.z -> W.y.z (X != W)
        VersionType.MINOR for x.Y.z -> x.W.z (Y != W)
        VersionType.PATCH for x.y.Z -> x.y.W (Z != W)
    """
    cur = parse_version(current)
    lat = parse_version(latest)
    
    if lat[0] != cur[0]:
        return VersionType.MAJOR
    elif lat[1] != cur[1]:
        return VersionType.MINOR
    else:
        return VersionType.PATCH


def github_api_request(url: str) -> Optional[dict]:
    """Make GitHub API request with rate limiting.
    
    Returns parsed JSON or None on error.
    """
    global _github_requests_made, _last_github_request_time
    
    # Rate limit: max 60 requests per hour for unauthenticated
    if _github_requests_made >= GITHUB_RATE_LIMIT - 5:  # Keep 5 req buffer
        logger.warning(f"GitHub API rate limit approaching ({_github_requests_made}/{GITHUB_RATE_LIMIT})")
        # Check if we should wait
        if _last_github_request_time:
            elapsed = datetime.now() - _last_github_request_time
            if elapsed < timedelta(hours=1):
                logger.error("GitHub API rate limit exceeded, skipping request")
                return None
        # Reset counter if hour passed
        _github_requests_made = 0
    
    headers = {
        "Accept": "application/vnd.github.v3+json",
        "User-Agent": "sys-updater-auto-review/1.0",
    }
    
    try:
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=15) as resp:
            _github_requests_made += 1
            _last_github_request_time = datetime.now()
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        if e.code == 403:
            logger.warning(f"GitHub API rate limited: {url}")
        elif e.code == 404:
            logger.debug(f"GitHub resource not found: {url}")
        else:
            logger.error(f"GitHub API error {e.code}: {url}")
        return None
    except Exception as e:
        logger.error(f"GitHub API request failed: {url} - {e}")
        return None


def get_npm_package_info(package_name: str) -> Optional[dict]:
    """Fetch package info from npm registry."""
    try:
        url = f"https://registry.npmjs.org/{quote(package_name, safe='')}/latest"
        with urllib.request.urlopen(url, timeout=10) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except Exception as e:
        logger.debug(f"Failed to fetch npm info for {package_name}: {e}")
        return None


def get_github_repo_from_npm(package_name: str) -> Optional[str]:
    """Extract GitHub repo URL from npm package metadata."""
    info = get_npm_package_info(package_name)
    if not info:
        return None
    
    # Check repository field
    repo = info.get("repository", {})
    if isinstance(repo, dict):
        url = repo.get("url", "")
    elif isinstance(repo, str):
        url = repo
    else:
        url = ""
    
    # Extract owner/repo from GitHub URL
    if "github.com" in url:
        # Handle git+https://github.com/owner/repo.git
        match = re.search(r"github\.com/([^/]+)/([^/\.]+)", url)
        if match:
            return f"{match.group(1)}/{match.group(2)}"
    
    # Check homepage as fallback
    homepage = info.get("homepage", "")
    if "github.com" in homepage:
        match = re.search(r"github\.com/([^/]+)/([^/\.]+)", homepage)
        if match:
            return f"{match.group(1)}/{match.group(2)}"
    
    return None


def get_github_repo_from_brew(formula_name: str) -> Optional[str]:
    """Extract GitHub repo from brew formula.
    
    Uses `brew info --json=v2` to get formula metadata.
    """
    try:
        result = subprocess.run(
            ["brew", "info", "--json=v2", formula_name],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode != 0:
            logger.debug(f"brew info failed for {formula_name}: {result.stderr[:200]}")
            return None
        
        data = json.loads(result.stdout)
        # Formula is in data["formulas"][0]
        formulas = data.get("formulas", [])
        if not formulas:
            return None
        
        formula = formulas[0]
        homepage = formula.get("homepage", "")
        
        # Extract GitHub repo from homepage
        if "github.com" in homepage:
            match = re.search(r"github\.com/([^/]+)/([^/\.]+)", homepage)
            if match:
                return f"{match.group(1)}/{match.group(2)}"
        
        # Check head URL (source repo)
        head = formula.get("head", "")
        if "github.com" in head:
            match = re.search(r"github\.com/([^/]+)/([^/\.]+)", head)
            if match:
                return f"{match.group(1)}/{match.group(2)}"
        
        return None
    except Exception as e:
        logger.debug(f"Failed to get brew info for {formula_name}: {e}")
        return None


def check_npm_package(package_name: str) -> dict[str, Any]:
    """Compatibility wrapper for tests/legacy callers."""
    repo = get_github_repo_from_npm(package_name)
    issues = fetch_github_issues(repo) if repo else []
    return {
        "release_date": None,
        "github_repo": repo,
        "issues": issues,
        "changelog_url": f"https://github.com/{repo}" if repo else None,
    }


def check_brew_formula(formula_name: str) -> dict[str, Any]:
    """Compatibility wrapper for tests/legacy callers."""
    repo = get_github_repo_from_brew(formula_name)
    issues = fetch_github_issues(repo) if repo else []
    return {
        "release_date": None,
        "github_repo": repo,
        "issues": issues,
        "changelog_url": f"https://github.com/{repo}" if repo else None,
    }


def fetch_github_issues(repo: str, days: int = 3) -> list[dict]:
    """Fetch recent issues from GitHub repository.
    
    Args:
        repo: Owner/repo format (e.g., "facebook/react")
        days: How many days back to check
        
    Returns:
        List of issue dicts with title, labels, created_at
    """
    since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
    url = f"https://api.github.com/repos/{repo}/issues?state=all&since={since}&per_page=30"
    
    data = github_api_request(url)
    if not data:
        return []
    
    issues = []
    for item in data:
        # Skip pull requests (GitHub returns PRs as issues too)
        if "pull_request" in item:
            continue
        
        issues.append({
            "title": item.get("title", ""),
            "labels": [l.get("name", "") for l in item.get("labels", [])],
            "state": item.get("state", "open"),
            "created_at": item.get("created_at", ""),
            "body": item.get("body", "")[:500],  # First 500 chars
        })
    
    return issues


def analyze_issues(issues: list[dict]) -> dict:
    """Analyze GitHub issues for critical/blocking problems.
    
    Returns:
        Dict with:
        - has_critical_issues: bool
        - blocking_issues: list of issue titles
    """
    critical_keywords = [
        "critical",
        "security",
        "vulnerability",
        "cve",
        "crash",
        "data loss",
        "breaking",
        "regression",
        "rollback",
    ]
    
    blocking_issues = []
    
    for issue in issues:
        title_lower = issue["title"].lower()
        body_lower = issue.get("body", "").lower()
        labels = [l.lower() for l in issue["labels"]]
        
        # Check for critical keywords in title or body
        is_critical = any(kw in title_lower or kw in body_lower for kw in critical_keywords)
        
        # Check for critical labels
        has_critical_label = any(
            l in ["critical", "security", "bug", "regression", "breaking"]
            for l in labels
        )
        
        # Only count open issues as blocking
        if issue["state"] == "open" and (is_critical or has_critical_label):
            blocking_issues.append(issue["title"])
    
    return {
        "has_critical_issues": len(blocking_issues) > 0,
        "critical_count": len(blocking_issues),
        "blocking_issues": blocking_issues[:5],  # Max 5
        "total_issues_checked": len(issues),
    }


def fetch_changelog(repo: str, version: str) -> Optional[str]:
    """Try to fetch changelog from common locations.
    
    Checks:
    - CHANGELOG.md
    - CHANGELOG
    - HISTORY.md
    - releases/tag/v{version}
    """
    paths = [
        f"https://raw.githubusercontent.com/{repo}/main/CHANGELOG.md",
        f"https://raw.githubusercontent.com/{repo}/master/CHANGELOG.md",
        f"https://raw.githubusercontent.com/{repo}/main/CHANGELOG",
        f"https://raw.githubusercontent.com/{repo}/master/CHANGELOG",
        f"https://raw.githubusercontent.com/{repo}/main/HISTORY.md",
        f"https://raw.githubusercontent.com/{repo}/master/HISTORY.md",
    ]
    
    for url in paths:
        try:
            req = urllib.request.Request(url)
            with urllib.request.urlopen(req, timeout=10) as resp:
                content = resp.read().decode("utf-8", errors="ignore")
                # Return first 5000 chars (should cover recent changes)
                return content[:5000]
        except urllib.error.HTTPError as e:
            if e.code == 404:
                continue  # Try next path
            break
        except Exception:
            break
    
    return None


def analyze_changelog(content: Optional[str], current_ver: str, latest_ver: str) -> dict:
    """Analyze changelog for danger keywords with reduced false positives.

    Priority:
    1) Analyze latest version section when available.
    2) Fallback to the first chunk of changelog text.
    """
    if not content:
        return {
            "has_danger_keywords": False,
            "danger_matches": [],
            "changelog_length": 0,
            "version_section": None,
        }

    # Best-effort extraction for latest version section.
    version_section = None
    latest_patterns = [
        rf"^##\s*\[?v?{re.escape(latest_ver)}\]?[^\n]*$",
        rf"^#\s*\[?v?{re.escape(latest_ver)}\]?[^\n]*$",
    ]
    lines = content.splitlines()
    start = None
    for i, line in enumerate(lines):
        if any(re.match(p, line.strip(), flags=re.IGNORECASE) for p in latest_patterns):
            start = i
            break
    if start is not None:
        end = len(lines)
        for j in range(start + 1, len(lines)):
            if re.match(r"^##\s+", lines[j].strip()):
                end = j
                break
        version_section = "\n".join(lines[start:end]).strip() or None

    # Prefer latest section; fallback to first part of full changelog.
    analysis_text = (version_section or content[:2500]).lower()

    found_keywords: list[str] = []

    # Strong keywords: direct block signals.
    for keyword in STRONG_DANGER_KEYWORDS:
        if keyword in analysis_text:
            found_keywords.append(keyword)

    # Weak keywords are too noisy for hard-blocking by themselves.
    # Keep them for future scoring/telemetry, but do not block on them now.
    _weak_hits = [k for k in WEAK_DANGER_KEYWORDS if k in analysis_text]

    return {
        "has_danger_keywords": len(found_keywords) > 0,
        "danger_matches": sorted(set(found_keywords)),
        "changelog_length": len(content),
        "version_section": version_section,
    }


def auto_review_package(meta: PackageMeta) -> tuple[ReviewResult, str]:
    """Perform automatic review of a package."""
    # Hard stop for repeated failures.
    if (meta.failed_attempts or 0) >= 3:
        return ReviewResult.BLOCKED, "Auto-blocked due to failed upgrade attempts"

    # Determine version type
    version_type = get_version_type(meta.current_version, meta.latest_version)

    # Major versions: always blocked (requires manual review)
    if version_type == VersionType.MAJOR:
        return ReviewResult.BLOCKED, f"Major version bump ({meta.current_version} -> {meta.latest_version}), requires manual review"

    # Collect package metadata via compatibility wrappers (easy to mock in tests)
    if meta.manager in ("npm", "pnpm"):
        info = check_npm_package(meta.name)
    else:
        info = check_brew_formula(meta.name)

    release_date = info.get("release_date") or meta.release_date
    if release_date and not is_due_for_auto_review(release_date, days=REVIEW_DAYS):
        return ReviewResult.PENDING, "Release is too fresh for auto-review"

    repo = info.get("github_repo")

    # If no repo found: conservative behavior.
    if not repo:
        if version_type == VersionType.PATCH:
            return ReviewResult.OK, "Patch version, no GitHub repo found (conservative approval)"
        return ReviewResult.PENDING, "No GitHub repository found for issue check, manual review recommended"

    logger.debug(f"Found GitHub repo for {meta.name}: {repo}")

    # Use prefetched issues when available.
    meta.issues = info.get("issues") or fetch_github_issues(repo)

    # Fetch and analyze changelog.
    meta.changelog_content = fetch_changelog(repo, meta.latest_version)
    changelog_analysis = analyze_changelog(meta.changelog_content, meta.current_version, meta.latest_version)
    if changelog_analysis["has_danger_keywords"]:
        keywords = ", ".join(changelog_analysis["danger_matches"])
        return ReviewResult.BLOCKED, f"Changelog contains danger keywords: {keywords}"

    # Patch versions are low risk if no danger indicators.
    if version_type == VersionType.PATCH:
        return ReviewResult.OK, "Patch version with no danger indicators"

    # Minor versions: check GitHub issues.
    if version_type == VersionType.MINOR:
        issue_analysis = analyze_issues(meta.issues)
        if issue_analysis["has_critical_issues"]:
            issues_str = "; ".join(issue_analysis["blocking_issues"][:3])
            return ReviewResult.BLOCKED, f"Critical issues found: {issues_str}"
        return ReviewResult.OK, "Minor version, no critical issues found"

    return ReviewResult.ERROR, "Unknown review state"


def _is_due_for_blocked_recheck(meta: dict, days: int = REVIEW_DAYS) -> bool:
    """Check if a blocked package is due for periodic re-check.

    For auto-blocked packages we re-check every `days` since the last
    auto review (fallback: reviewedAt/firstSeenAt).
    """
    anchor = meta.get("autoReviewAt") or meta.get("reviewedAt") or meta.get("firstSeenAt")
    if not anchor:
        return False
    try:
        anchor_dt = datetime.fromisoformat(str(anchor).replace("Z", "+00:00"))
    except (ValueError, TypeError) as e:
        logger.warning(f"Failed to parse recheck anchor '{anchor}': {e}")
        return False
    return datetime.now(timezone.utc) >= anchor_dt + timedelta(days=days)


def _is_auto_block(meta: dict) -> bool:
    """True for packages auto-blocked by the review system.

    Any auto-blocked package should be periodically re-checked. Restricting
    re-checks only to notes containing specific keywords (e.g. "regression")
    can leave packages permanently blocked after fixes are released.
    """
    return (
        meta.get("reviewResult") == "blocked"
        and meta.get("blocked") is True
        and meta.get("reviewedBy") == "auto"
    )


def review_single_package(
    name: str,
    meta: dict,
    manager: str
) -> dict:
    """Review a single package and return updated metadata.
    
    Only processes packages that are due for review (firstSeenAt + 2 days).
    Skips packages that are not yet due to save API calls.
    
    Args:
        name: Package name
        meta: Current metadata dict from tracked.json
        manager: "npm" or "brew"
        
    Returns:
        Updated metadata dict with review fields
    """
    auto_block = _is_auto_block(meta)

    # Check if package is due for review.
    # - regular packages: firstSeenAt + REVIEW_DAYS
    # - auto-blocked packages: periodic re-check every REVIEW_DAYS
    if auto_block:
        if not _is_due_for_blocked_recheck(meta, days=REVIEW_DAYS):
            logger.debug(
                "Skipping %s: auto-blocked, not yet due for re-check "
                "(autoReviewAt=%s)",
                name,
                meta.get("autoReviewAt") or meta.get("reviewedAt") or meta.get("firstSeenAt"),
            )
            return meta
    elif not is_due_for_review(meta, days=REVIEW_DAYS):
        logger.debug(f"Skipping {name}: not yet due for review (firstSeenAt={meta.get('firstSeenAt')})")
        return meta  # Return unchanged

    # Skip if already reviewed manually
    if meta.get("reviewedBy") == "manual":
        logger.debug(f"Skipping {name}: already manually reviewed")
        return meta

    # Skip if already blocked unless:
    # - failed attempts increased, or
    # - this is a periodic re-check of an auto-blocked package
    if (
        meta.get("reviewResult") == "blocked"
        and not auto_block
        and not meta.get("failedAttempts", 0) > meta.get("_prev_failedAttempts", 0)
    ):
        logger.debug(f"Skipping {name}: already blocked")
        return meta

    # Skip if already planned (already approved)
    if meta.get("planned") and meta.get("reviewResult") == "ok":
        logger.debug(f"Skipping {name}: already planned for upgrade")
        return meta
    
    # Create PackageMeta from stored data
    pkg = PackageMeta(
        name=name,
        current_version=meta.get("currentVersion", "?"),
        latest_version=meta.get("latestVersion", "?"),
        manager=manager,
        first_seen_at=meta.get("firstSeenAt"),
        release_date=meta.get("releaseDate"),
        auto_review_at=meta.get("autoReviewAt"),
        reviewed_by=meta.get("reviewedBy"),
        review_result=meta.get("reviewResult"),
        failed_attempts=meta.get("failedAttempts", 0),
        last_attempt_at=meta.get("lastAttemptAt"),
        type=meta.get("type", "package"),
    )
    
    # Perform auto-review
    result, reason = auto_review_package(pkg)
    
    # Update metadata
    updated = meta.copy()
    updated["reviewResult"] = result.value
    updated["reviewedBy"] = "auto" if result != ReviewResult.PENDING else None
    updated["autoReviewAt"] = now_iso()
    
    if result == ReviewResult.OK:
        updated["planned"] = True
        updated["blocked"] = False
        updated["note"] = f"Auto-approved: {reason}"
        logger.info(f"✅ {manager}/{name}: {reason}")
    elif result == ReviewResult.BLOCKED:
        updated["blocked"] = True
        updated["planned"] = False
        updated["note"] = f"Auto-blocked: {reason}"
        logger.info(f"🚫 {manager}/{name}: {reason}")
    elif result == ReviewResult.PENDING:
        updated["planned"] = False
        updated["blocked"] = False
        updated["note"] = f"Pending: {reason}"
        logger.debug(f"⏳ {manager}/{name}: {reason}")
    else:
        updated["note"] = f"Review error: {reason}"
        logger.warning(f"⚠️ {manager}/{name}: {reason}")
    
    return updated


def run_auto_review(
    npm_tracked_path: Path,
    brew_tracked_path: Path,
    pnpm_tracked_path: Path | None = None,
    dry_run: bool = False
) -> dict[str, Any]:
    """Run auto-review on all tracked packages.

    Only processes packages that are due for review (firstSeenAt + 2 days).
    """
    summary = {
        "npm": {"reviewed": 0, "approved": 0, "blocked": 0, "pending": 0, "errors": 0, "skipped_not_due": 0},
        "brew": {"reviewed": 0, "approved": 0, "blocked": 0, "pending": 0, "errors": 0, "skipped_not_due": 0},
        "pnpm": {"reviewed": 0, "approved": 0, "blocked": 0, "pending": 0, "errors": 0, "skipped_not_due": 0},
        "details": [],
    }

    def load(path: Path | None, label: str) -> dict[str, Any]:
        if not path or not path.exists():
            return {}
        try:
            with open(path, "r") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError) as e:
            logger.error(f"Failed to load {label}: {e}")
            return {}

    npm_data = load(npm_tracked_path, "npm_tracked.json")
    brew_data = load(brew_tracked_path, "brew_tracked.json")
    pnpm_data = load(pnpm_tracked_path, "pnpm_tracked.json")

    npm_items = npm_data.get("items", {})
    brew_items = brew_data.get("items", {})
    pnpm_items = pnpm_data.get("items", {})

    def _is_due_item(m: dict) -> bool:
        if _is_auto_block(m):
            return _is_due_for_blocked_recheck(m)
        return is_due_for_review(m)

    npm_due = sum(1 for m in npm_items.values() if _is_due_item(m))
    brew_due = sum(1 for m in brew_items.values() if _is_due_item(m))
    pnpm_due = sum(1 for m in pnpm_items.values() if _is_due_item(m))

    logger.info(
        "Starting auto-review: %d npm (%d due), %d brew (%d due), %d pnpm (%d due) packages",
        len(npm_items), npm_due, len(brew_items), brew_due, len(pnpm_items), pnpm_due,
    )

    def process(manager: str, items: dict[str, Any]) -> None:
        for name, meta in items.items():
            updated = review_single_package(name, meta, manager)
            items[name] = updated

            if updated.get("reviewedBy") == "auto":
                result = updated.get("reviewResult")
                summary[manager]["reviewed"] += 1
                if result == "ok":
                    summary[manager]["approved"] += 1
                elif result == "blocked":
                    summary[manager]["blocked"] += 1
                elif result == "pending":
                    summary[manager]["pending"] += 1
                else:
                    summary[manager]["errors"] += 1

                summary["details"].append({
                    "name": name,
                    "manager": manager,
                    "result": result,
                    "reason": updated.get("note", ""),
                })
            elif updated == meta:
                summary[manager]["skipped_not_due"] += 1

    process("npm", npm_items)
    process("brew", brew_items)
    process("pnpm", pnpm_items)

    if not dry_run:
        now = now_iso()
        npm_data["items"] = npm_items
        brew_data["items"] = brew_items
        npm_data["lastAutoReview"] = now
        brew_data["lastAutoReview"] = now

        if pnpm_tracked_path:
            pnpm_data["items"] = pnpm_items
            pnpm_data["lastAutoReview"] = now

        for path, data, label in [
            (npm_tracked_path, npm_data, "npm_tracked.json"),
            (brew_tracked_path, brew_data, "brew_tracked.json"),
            (pnpm_tracked_path, pnpm_data, "pnpm_tracked.json") if pnpm_tracked_path else (None, None, None),
        ]:
            if not path:
                continue
            try:
                path.parent.mkdir(parents=True, exist_ok=True)
                with open(path, "w") as f:
                    json.dump(data, f, indent=2)
                logger.info(f"Saved {label}")
            except IOError as e:
                logger.error(f"Failed to save {label}: {e}")

    total_reviewed = summary["npm"]["reviewed"] + summary["brew"]["reviewed"] + summary["pnpm"]["reviewed"]
    total_skipped = summary["npm"]["skipped_not_due"] + summary["brew"]["skipped_not_due"] + summary["pnpm"]["skipped_not_due"]
    total_approved = summary["npm"]["approved"] + summary["brew"]["approved"] + summary["pnpm"]["approved"]
    total_blocked = summary["npm"]["blocked"] + summary["brew"]["blocked"] + summary["pnpm"]["blocked"]

    logger.info(
        "Auto-review complete: %d reviewed (%d approved, %d blocked), %d skipped (not yet due)",
        total_reviewed, total_approved, total_blocked, total_skipped,
    )

    return summary


def main():
    """CLI entry point for manual auto-review runs."""
    import argparse
    
    parser = argparse.ArgumentParser(description="Auto-review npm/pnpm/brew packages")
    parser.add_argument("--npm-path", type=Path, default=Path("/home/moltuser/clawd/sys-updater/state/apt/npm_tracked.json"))
    parser.add_argument("--brew-path", type=Path, default=Path("/home/moltuser/clawd/sys-updater/state/apt/brew_tracked.json"))
    parser.add_argument("--pnpm-path", type=Path, default=Path("/home/moltuser/clawd/sys-updater/state/apt/pnpm_tracked.json"))
    parser.add_argument("--dry-run", action="store_true", help="Don't save changes")
    parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging")
    
    args = parser.parse_args()
    
    # Setup logging
    log_level = logging.DEBUG if args.verbose else logging.INFO
    logging.basicConfig(
        level=log_level,
        format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
        datefmt="%Y-%m-%dT%H:%M:%S",
    )
    
    # Run auto-review
    summary = run_auto_review(args.npm_path, args.brew_path, args.pnpm_path, args.dry_run)
    
    # Print summary as JSON
    print(json.dumps(summary, indent=2))
    
    # Exit with error if any errors occurred
    total_errors = summary["npm"]["errors"] + summary["brew"]["errors"] + summary["pnpm"]["errors"]
    if total_errors > 0:
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/managers/__init__.py
content: """Package manager adapter primitives for sys-updater."""

from .adapters import ManagerAdapter, build_manager_registry

__all__ = ["ManagerAdapter", "build_manager_registry"]


path: scripts/managers/adapters.py
content: from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable


@dataclass(frozen=True)
class ManagerAdapter:
    name: str
    title: str
    track_path: Path
    check_fn: Callable[[], dict[str, Any]]


def build_manager_registry(
    *,
    npm_path: Path,
    pnpm_path: Path,
    brew_path: Path,
    check_npm: Callable[[], dict[str, Any]],
    check_pnpm: Callable[[], dict[str, Any]],
    check_brew: Callable[[], dict[str, Any]],
) -> list[ManagerAdapter]:
    """Build canonical manager registry for pkg_maint orchestration."""
    return [
        ManagerAdapter(name="npm", title="NPM", track_path=npm_path, check_fn=check_npm),
        ManagerAdapter(name="pnpm", title="PNPM", track_path=pnpm_path, check_fn=check_pnpm),
        ManagerAdapter(name="brew", title="Brew", track_path=brew_path, check_fn=check_brew),
    ]


path: scripts/pkg_maint.py
content: #!/usr/bin/env python3
"""Package manager maintenance helper for OpenClaw host (sys-updater extension).

Extends apt_maint.py to support npm and brew package managers with
the same conservative workflow: track -> review after 2 days -> upgrade.

Modes:
- check: Update tracked state (run during run_6am)
- review: Review packages for bugs/risks (manual or cron after 2 days)
- upgrade: Apply planned upgrades (manual or auto after review)

Conservative by design:
- Non-security upgrades are tracked for 2 days before auto-upgrade
- User can block packages with known bugs
- Security updates (not typical for npm/brew) would need manual handling
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import subprocess
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Callable

from managers.adapters import build_manager_registry

# === Directories (same as apt_maint.py) ===
BASE_DIR = Path(os.getenv("SYS_UPDATER_BASE_DIR", "/home/moltuser/clawd/sys-updater"))
STATE_DIR = Path(os.getenv("SYS_UPDATER_STATE_DIR", BASE_DIR / "state" / "apt"))
LOG_DIR = Path(os.getenv("SYS_UPDATER_LOG_DIR", BASE_DIR / "state" / "logs"))

NPM_TRACK_PATH = STATE_DIR / "npm_tracked.json"
PNPM_TRACK_PATH = STATE_DIR / "pnpm_tracked.json"
BREW_TRACK_PATH = STATE_DIR / "brew_tracked.json"

REVIEW_DAYS = 2  # Same as apt policy
AUTO_PLAN_PENDING_AFTER_DAYS = 4  # Escalate long-pending items to planned

# ClawHub / OpenClaw skills - update immediately without quarantine
SKILLS_WORKDIR = Path(os.getenv("CLAWHUB_WORKDIR", "/home/moltuser/clawd"))
SKILLS_DIR = SKILLS_WORKDIR / "skills"

# Setup logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("pkg_maint")


def sh(cmd: list[str], check: bool = False, timeout: int = 60) -> tuple[int, str, str]:
    """Run command, return (returncode, stdout, stderr)."""
    try:
        result = subprocess.run(
            cmd, text=True, capture_output=True, check=check, timeout=timeout
        )
        return result.returncode, result.stdout, result.stderr
    except FileNotFoundError:
        return 127, "", f"Command not found: {cmd[0]}"
    except subprocess.TimeoutExpired:
        return -1, "", "Command timed out"


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def now_utc() -> datetime:
    return datetime.now(timezone.utc)


def parse_iso(ts: str) -> datetime:
    """Parse ISO timestamp string to datetime."""
    ts = ts.replace("Z", "+00:00")
    return datetime.fromisoformat(ts)


def check_npm() -> dict[str, Any]:
    """Check for outdated npm global packages."""
    result = {"installed": False, "outdated": []}
    
    rc, _, _ = sh(["npm", "--version"])
    if rc != 0:
        return result
    
    result["installed"] = True
    rc, stdout, stderr = sh(["npm", "outdated", "-g", "--json", "--depth=0"], timeout=30)
    
    if rc not in (0, 1):
        log.warning("npm outdated failed: %s", stderr)
        return result
    
    try:
        data = json.loads(stdout) if stdout else {}
        for pkg, info in data.items():
            result["outdated"].append({
                "name": pkg,
                "current": info.get("current", "?"),
                "wanted": info.get("wanted", "?"),
                "latest": info.get("latest", "?"),
                "location": info.get("location", "global"),
            })
    except json.JSONDecodeError:
        log.warning("Failed to parse npm outdated output")
    
    return result


def check_pnpm() -> dict[str, Any]:
    """Check for outdated pnpm global packages."""
    result = {"installed": False, "outdated": []}

    rc, _, _ = sh(["pnpm", "--version"])
    if rc != 0:
        return result

    result["installed"] = True
    rc, stdout, stderr = sh(["pnpm", "outdated", "-g", "--json"], timeout=30)
    if rc == 1 and "ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND" in ((stderr or "") + (stdout or "")):
        # No pnpm global store initialized yet; treat as no outdated packages.
        return result
    if rc not in (0, 1):
        log.warning("pnpm outdated failed: %s", stderr)
        return result

    try:
        data = json.loads(stdout) if stdout else {}
        if isinstance(data, dict):
            iterable = data.items()
            for pkg, info in iterable:
                if not isinstance(info, dict):
                    continue
                result["outdated"].append({
                    "name": pkg,
                    "current": info.get("current", "?"),
                    "wanted": info.get("wanted", "?"),
                    "latest": info.get("latest", "?"),
                    "location": info.get("location", "global"),
                })
        elif isinstance(data, list):
            # Fallback shape used by some pnpm versions
            for info in data:
                if not isinstance(info, dict):
                    continue
                name = info.get("name")
                if not name:
                    continue
                result["outdated"].append({
                    "name": name,
                    "current": info.get("current", "?"),
                    "wanted": info.get("wanted", "?"),
                    "latest": info.get("latest", "?"),
                    "location": "global",
                })
    except json.JSONDecodeError:
        log.warning("Failed to parse pnpm outdated output")

    return result


def check_brew() -> dict[str, Any]:
    """Check for outdated brew packages."""
    result = {"installed": False, "outdated": []}
    
    rc, _, _ = sh(["brew", "--version"])
    if rc != 0:
        return result
    
    result["installed"] = True
    rc, stdout, stderr = sh(["brew", "outdated", "--json"], timeout=30)
    
    if rc != 0:
        log.warning("brew outdated failed: %s", stderr)
        return result
    
    try:
        data = json.loads(stdout) if stdout else {}
        for pkg in data.get("formulae", []):
            result["outdated"].append({
                "name": pkg.get("name"),
                "current": pkg.get("installed_versions", ["?"])[0],
                "latest": pkg.get("current_version", "?"),
                "type": "formula",
            })
        for pkg in data.get("casks", []):
            result["outdated"].append({
                "name": pkg.get("name"),
                "current": pkg.get("installed_versions", ["?"])[0],
                "latest": pkg.get("current_version", "?"),
                "type": "cask",
            })
    except json.JSONDecodeError:
        log.warning("Failed to parse brew outdated output")
    
    return result


def check_skills() -> dict[str, Any]:
    """Check for outdated ClawHub/OpenClaw skills and update immediately."""
    result = {"installed": False, "checked": [], "updated": [], "failed": []}
    
    # Check clawhub is available
    rc, _, _ = sh(["clawhub", "--version"])
    if rc != 0:
        log.debug("clawhub not available")
        return result
    
    result["installed"] = True
    
    # Get list of installed skills
    rc, stdout, stderr = sh(["clawhub", "list", "--json"], timeout=30)
    if rc != 0:
        log.warning("clawhub list failed: %s", stderr)
        return result
    
    try:
        skills = json.loads(stdout) if stdout else []
    except json.JSONDecodeError:
        log.warning("Failed to parse clawhub list output")
        return result
    
    if not skills:
        log.info("No skills installed")
        return result
    
    # Check each skill for updates
    for skill in skills:
        slug = skill.get("slug") or skill.get("name")
        if not slug:
            continue
        
        result["checked"].append(slug)
        current_version = skill.get("version", "?")
        
        log.info("Checking skill: %s (current: %s)", slug, current_version)
        
        # Try to update (clawhub update will skip if already latest)
        rc, stdout, stderr = sh(["clawhub", "update", slug], timeout=120)
        
        if rc == 0:
            # Check if actually updated by looking for upgrade message
            if "upgraded" in stdout.lower() or "updated" in stdout.lower():
                log.info("Updated skill: %s", slug)
                result["updated"].append({
                    "name": slug,
                    "previous": current_version,
                    "status": "updated"
                })
            else:
                log.debug("Skill %s already up to date", slug)
        else:
            log.error("Failed to update skill %s: %s", slug, stderr[:200])
            result["failed"].append({"name": slug, "error": stderr[:200]})
    
    return result


def upgrade_skills(dry_run: bool = False) -> str:
    """Upgrade all skills immediately (no quarantine for skills)."""
    log.info("=== SKILLS UPGRADE START (dry_run=%s) ===", dry_run)
    
    if dry_run:
        # clawhub doesn't support --dry-run, just list skills
        rc, stdout, stderr = sh(["clawhub", "list"], timeout=60)
        if rc != 0:
            return f"❌ clawhub list failed: {stderr[:200]}"
        
        lines = ["🔍 Skills (dry-run not supported by clawhub):"]
        lines.append("Will check and update all installed skills")
        lines.append("\nInstalled skills:")
        lines.append(stdout if stdout else "No skills found")
        return "\n".join(lines)
    
    # Real update
    result = check_skills()
    
    lines = ["📦 Обновление скиллов OpenClaw:"]
    
    if result["updated"]:
        lines.append(f"✅ Обновлено: {len(result['updated'])}")
        for skill in result["updated"]:
            lines.append(f"   - {skill['name']}")
    
    if result["failed"]:
        lines.append(f"❌ Ошибки: {len(result['failed'])}")
        for skill in result["failed"]:
            lines.append(f"   - {skill['name']}: {skill['error'][:80]}")
    
    if not result["updated"] and not result["failed"]:
        lines.append("✓ Все скиллы актуальны")
    
    log.info("Skills: checked=%d, updated=%d, failed=%d",
             len(result["checked"]), len(result["updated"]), len(result["failed"]))
    log.info("=== SKILLS UPGRADE END ===")
    return "\n".join(lines)


def load_tracked(path: Path) -> dict:
    """Load tracked packages state."""
    if path.exists():
        try:
            with open(path, "r") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError) as e:
            log.warning("Failed to load %s: %s", path, e)
    return {"items": {}, "lastCheck": None, "createdAt": now_iso()}


def save_tracked(path: Path, data: dict) -> None:
    """Save tracked packages state."""
    try:
        with open(path, "w") as f:
            json.dump(data, f, indent=2)
    except IOError as e:
        log.error("Failed to save %s: %s", path, e)


def update_tracked(
    tracked: dict,
    current_outdated: list[dict],
    manager: str
) -> tuple[dict, int, int]:
    """Update tracked packages with current state. Returns (tracked, new_count, removed_count)."""
    items = tracked.get("items", {})
    now = now_iso()
    new_count = 0
    
    # Mark packages no longer outdated
    # NOTE: Keep packages that are still outdated to preserve firstSeenAt timer
    current_names = {p["name"] for p in current_outdated}
    to_remove = []
    for name in list(items.keys()):
        if name not in current_names:
            # Only remove if no longer outdated AND no user decision made
            if not items[name].get("blocked") and not items[name].get("planned"):
                to_remove.append(name)
        # If name IS in current_names (still outdated), keep it to preserve timer
    
    for name in to_remove:
        del items[name]
        log.info("Removed %s from tracking (no longer outdated)", name)
    
    # Add/update outdated packages
    for pkg in current_outdated:
        name = pkg["name"]
        if name not in items:
            new_count += 1
            items[name] = {
                "firstSeenAt": now,
                "reviewedAt": None,
                "planned": False,
                "blocked": False,
                "note": None,
            }
            log.info("Added %s to tracking (first seen)", name)
        
        items[name]["currentVersion"] = pkg.get("current", "?")
        items[name]["latestVersion"] = pkg.get("latest", pkg.get("wanted", "?"))
        items[name]["type"] = pkg.get("type", "package")
        items[name]["manager"] = manager
    
    tracked["items"] = items
    tracked["lastCheck"] = now
    return tracked, new_count, len(to_remove)


def is_due_for_review(item: dict, days: int = REVIEW_DAYS) -> bool:
    """Check if package is due for review (firstSeenAt + days has passed)."""
    first_seen = item.get("firstSeenAt")
    if not first_seen:
        return False

    try:
        first_dt = parse_iso(first_seen)
        cutoff = first_dt + timedelta(days=days)
        return now_utc() >= cutoff
    except (ValueError, TypeError):
        return False


def auto_plan_long_pending(tracked: dict, manager: str, days: int = AUTO_PLAN_PENDING_AFTER_DAYS) -> int:
    """Escalate long-pending items to planned upgrades.

    Policy:
    - only items with reviewResult == "pending"
    - not blocked
    - not already planned
    - older than `days` since firstSeenAt
    """
    changed = 0
    items = tracked.get("items", {})
    now = now_utc()

    for name, meta in items.items():
        if meta.get("blocked") or meta.get("planned"):
            continue
        if meta.get("reviewResult") != "pending":
            continue

        first_seen = meta.get("firstSeenAt")
        if not first_seen:
            continue

        try:
            first_dt = parse_iso(first_seen)
        except (ValueError, TypeError):
            continue

        if (now - first_dt) < timedelta(days=days):
            continue

        meta["planned"] = True
        note = meta.get("note") or ""
        suffix = f"Auto-planned after pending>{days}d"
        meta["note"] = f"{note}; {suffix}" if note else suffix
        meta["reviewedBy"] = meta.get("reviewedBy") or "auto"
        changed += 1
        log.info("Auto-planned long-pending %s/%s (firstSeenAt=%s)", manager, name, first_seen)

    return changed


MANAGER_ADAPTERS = build_manager_registry(
    npm_path=NPM_TRACK_PATH,
    pnpm_path=PNPM_TRACK_PATH,
    brew_path=BREW_TRACK_PATH,
    check_npm=check_npm,
    check_pnpm=check_pnpm,
    check_brew=check_brew,
)
MANAGER_ORDER = [m.name for m in MANAGER_ADAPTERS]
MANAGER_TRACK_PATHS: dict[str, Path] = {m.name: m.track_path for m in MANAGER_ADAPTERS}
MANAGER_CHECKERS: dict[str, Callable[[], dict[str, Any]]] = {m.name: m.check_fn for m in MANAGER_ADAPTERS}
MANAGER_TITLES: dict[str, str] = {m.name: m.title for m in MANAGER_ADAPTERS}


def _manager_title(manager: str) -> str:
    return MANAGER_TITLES.get(manager, manager.upper())


def _load_all_tracked() -> dict[str, dict[str, Any]]:
    return {m: load_tracked(MANAGER_TRACK_PATHS[m]) for m in MANAGER_ORDER}


def _save_all_tracked(tracked_by_manager: dict[str, dict[str, Any]]) -> None:
    for manager in MANAGER_ORDER:
        save_tracked(MANAGER_TRACK_PATHS[manager], tracked_by_manager[manager])


def _count_flag(tracked_by_manager: dict[str, dict[str, Any]], flag: str) -> int:
    total = 0
    for manager in MANAGER_ORDER:
        total += sum(1 for v in tracked_by_manager[manager].get("items", {}).values() if v.get(flag))
    return total


def check_mode() -> None:
    """Run check mode: update tracked state for all managers."""
    log.info("=== CHECK MODE START ===")
    STATE_DIR.mkdir(parents=True, exist_ok=True)

    tracked_by_manager = _load_all_tracked()

    for manager in MANAGER_ORDER:
        log.info("Checking %s packages...", manager)
        result = MANAGER_CHECKERS[manager]()
        tracked = tracked_by_manager[manager]
        tracked, new_count, removed_count = update_tracked(tracked, result["outdated"], manager)
        tracked_by_manager[manager] = tracked
        save_tracked(MANAGER_TRACK_PATHS[manager], tracked)
        log.info(
            "%s: %d outdated (%d new, %d removed)",
            _manager_title(manager),
            len(result["outdated"]),
            new_count,
            removed_count,
        )

    planned_count = _count_flag(tracked_by_manager, "planned")
    blocked_count = _count_flag(tracked_by_manager, "blocked")
    log.info("Summary: planned=%d, blocked=%d", planned_count, blocked_count)

    # Update skills immediately (no quarantine for skills)
    log.info("Updating OpenClaw skills...")
    skills_result = check_skills()
    if skills_result["installed"]:
        log.info("Skills: checked=%d, updated=%d, failed=%d",
                 len(skills_result["checked"]), len(skills_result["updated"]), len(skills_result["failed"]))
    else:
        log.info("clawhub not available, skipping skills")

    log.info("=== CHECK MODE END ===")


def review_mode() -> str:
    """Review packages due for review (2+ days old)."""
    log.info("=== REVIEW MODE START ===")

    tracked_by_manager = _load_all_tracked()
    review_list: list[dict[str, Any]] = []

    for manager in MANAGER_ORDER:
        for name, meta in tracked_by_manager[manager].get("items", {}).items():
            if meta.get("reviewedAt") or meta.get("blocked") or meta.get("planned"):
                continue
            if not is_due_for_review(meta):
                continue
            review_list.append({
                "name": name,
                "manager": manager,
                "current": meta.get("currentVersion", "?"),
                "latest": meta.get("latestVersion", "?"),
                "firstSeen": meta.get("firstSeenAt"),
                "type": meta.get("type", "package"),
            })

    if not review_list:
        log.info("No packages due for review")
        return "Нет пакетов для ревью (все новые, не прошло 2 дня, или уже обработаны)."

    lines = ["📋 Пакеты для ревью (прошло 2+ дней):"]
    for pkg in review_list:
        lines.append(f"  - {pkg['name']} ({pkg['manager']}): {pkg['current']} → {pkg['latest']}")

    lines.append("")
    lines.append("🔍 Рекомендация: проверь эти пакеты на наличие багов/регрессий")
    lines.append("   перед автоматическим обновлением.")
    lines.append("")
    lines.append("Действия:")
    lines.append("  1. Поискать 'package_name latest version bug regression' в Google")
    lines.append("  2. Если найдены серьезные проблемы → заблокировать")
    lines.append("  3. Если всё чисто → пометить planned=True для обновления")

    log.info("Found %d packages for review", len(review_list))
    return "\n".join(lines)


def _upgrade_node_manager(
    manager: str,
    tracked: dict[str, Any],
    dry_run: bool,
    upgraded: list[str],
    failed: list[tuple[str, str, str]],
) -> None:
    """Upgrade planned npm/pnpm packages with shared logic."""
    cmd_prefix = [manager, "update", "-g"]

    for name, meta in tracked.get("items", {}).items():
        if not meta.get("planned") or meta.get("blocked"):
            continue

        log.info("Upgrading %s package: %s", manager, name)
        if dry_run:
            log.info("[DRY-RUN] Would run: %s", " ".join([*cmd_prefix, name]))
            upgraded.append(name)
            continue

        rc, stdout, stderr = sh([*cmd_prefix, name], timeout=120)
        if rc == 0:
            log.info("Successfully upgraded %s", name)
            upgraded.append(name)
            meta["planned"] = False
            meta["failedAttempts"] = 0
            meta["lastAttemptAt"] = now_iso()
            continue

        err = (stderr or stdout or f"{manager} update failed").strip()
        meta["failedAttempts"] = int(meta.get("failedAttempts", 0)) + 1
        meta["lastAttemptAt"] = now_iso()
        log.error("Failed to upgrade %s: %s", name, err[:200])
        failed.append((name, manager, err[:200]))


def upgrade_mode(dry_run: bool = False) -> str:
    """Apply planned upgrades. Same as apt workflow.

    Enhancements:
    - Auto-plan long-pending review items (pending > AUTO_PLAN_PENDING_AFTER_DAYS)
    - Brew link-error recovery retry
    - Post-upgrade recheck to sync tracked state with actual outdated lists
    """
    log.info("=== UPGRADE MODE START (dry_run=%s) ===", dry_run)

    tracked_by_manager = _load_all_tracked()

    # Escalate long-pending items to planned so they don't stall forever.
    auto_planned_by_manager: dict[str, int] = {
        manager: auto_plan_long_pending(tracked_by_manager[manager], manager)
        for manager in MANAGER_ORDER
    }

    npm_upgraded: list[str] = []
    pnpm_upgraded: list[str] = []
    brew_upgraded: list[str] = []
    failed: list[tuple[str, str, str]] = []

    _upgrade_node_manager("npm", tracked_by_manager["npm"], dry_run, npm_upgraded, failed)
    _upgrade_node_manager("pnpm", tracked_by_manager["pnpm"], dry_run, pnpm_upgraded, failed)

    brew_tracked = tracked_by_manager["brew"]

    # Upgrade brew packages marked as planned
    for name, meta in brew_tracked.get("items", {}).items():
        if not meta.get("planned") or meta.get("blocked"):
            continue

        log.info("Upgrading brew package: %s", name)
        if dry_run:
            log.info("[DRY-RUN] Would run: brew upgrade %s", name)
            brew_upgraded.append(name)
            continue

        rc, stdout, stderr = sh(["brew", "upgrade", name], timeout=120)
        if rc == 0:
            log.info("Successfully upgraded %s", name)
            brew_upgraded.append(name)
            meta["planned"] = False
            meta["failedAttempts"] = 0
            meta["lastAttemptAt"] = now_iso()
            continue

        combined_err = (stderr or "") + "\n" + (stdout or "")
        link_retry_ok = False
        if "brew link" in combined_err.lower() or "link step did not complete" in combined_err.lower():
            log.warning("Detected brew link failure for %s, retrying link", name)
            rc_link, out_link, err_link = sh(["brew", "link", "--overwrite", name], timeout=90)
            if rc_link == 0:
                log.info("brew link recovery succeeded for %s", name)
                link_retry_ok = True
            else:
                # second conservative attempt for keg-only edge cases
                rc_link2, out_link2, err_link2 = sh(["brew", "link", "--overwrite", "--force", name], timeout=90)
                if rc_link2 == 0:
                    log.info("brew link --force recovery succeeded for %s", name)
                    link_retry_ok = True
                else:
                    combined_err += f"\nlink-retry: {(err_link or out_link or '').strip()} {(err_link2 or out_link2 or '').strip()}"

        if link_retry_ok:
            brew_upgraded.append(name)
            meta["planned"] = False
            meta["failedAttempts"] = 0
            meta["lastAttemptAt"] = now_iso()
            meta["note"] = "Recovered via brew link retry"
            continue

        err = combined_err.strip() or "brew upgrade failed"
        meta["failedAttempts"] = int(meta.get("failedAttempts", 0)) + 1
        meta["lastAttemptAt"] = now_iso()
        log.error("Failed to upgrade %s: %s", name, err[:200])
        failed.append((name, "brew", err[:200]))

    # Post-upgrade recheck to keep tracked state fresh and avoid stale reporting.
    if not dry_run:
        for manager in MANAGER_ORDER:
            result = MANAGER_CHECKERS[manager]()
            tracked, _, _ = update_tracked(tracked_by_manager[manager], result["outdated"], manager)
            tracked_by_manager[manager] = tracked

    # Save updated state
    _save_all_tracked(tracked_by_manager)

    # Build report
    lines = ["🚀 Результат обновления:"]

    if any(auto_planned_by_manager.values()):
        lines.append(
            "📝 Auto-planned pending: "
            f"npm={auto_planned_by_manager['npm']}, "
            f"pnpm={auto_planned_by_manager['pnpm']}, "
            f"brew={auto_planned_by_manager['brew']} "
            f"(>{AUTO_PLAN_PENDING_AFTER_DAYS}d pending)"
        )

    if npm_upgraded:
        lines.append(f"✅ npm: {', '.join(npm_upgraded)}")
    if pnpm_upgraded:
        lines.append(f"✅ pnpm: {', '.join(pnpm_upgraded)}")
    if brew_upgraded:
        lines.append(f"✅ brew: {', '.join(brew_upgraded)}")
    if failed:
        lines.append(f"❌ Ошибки: {len(failed)}")
        for name, manager, err in failed:
            lines.append(f"   - {name} ({manager}): {err[:100]}")

    if not npm_upgraded and not pnpm_upgraded and not brew_upgraded and not failed and not any(auto_planned_by_manager.values()):
        lines.append("Нет запланированных пакетов для обновления.")

    log.info("=== UPGRADE MODE END ===")
    return "\n".join(lines)


def main():
    ap = argparse.ArgumentParser(
        description="Package manager maintenance for npm/pnpm/brew/skills (like apt workflow).",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Modes:
  check    - Update tracked state + auto-update skills (run daily in run_6am)
  review   - Show packages due for bug review (after 2 days)
  upgrade  - Apply planned npm/pnpm/brew upgrades
  skills   - Update OpenClaw skills immediately (no quarantine)

Examples:
  python3 pkg_maint.py check              # Daily check + auto skills update
  python3 pkg_maint.py review             # Review packages after 2 days
  python3 pkg_maint.py upgrade --dry-run  # Simulate npm/pnpm/brew upgrade
  python3 pkg_maint.py upgrade            # Apply npm/pnpm/brew upgrades
  python3 pkg_maint.py skills --dry-run   # Check skill updates
""",
    )
    ap.add_argument("mode", choices=["check", "review", "upgrade", "skills"], help="Operation mode")
    ap.add_argument("--dry-run", "-n", action="store_true", help="Don't actually upgrade")
    args = ap.parse_args()
    
    if args.mode == "check":
        check_mode()
    elif args.mode == "review":
        print(review_mode())
    elif args.mode == "upgrade":
        print(upgrade_mode(dry_run=args.dry_run))
    elif args.mode == "skills":
        print(upgrade_skills(dry_run=args.dry_run))


if __name__ == "__main__":
    main()


path: scripts/retry_logic.py
content: #!/usr/bin/env python3
"""Retry logic module for package upgrades with fallback handling.

Handles failed upgrade attempts and implements exponential backoff.
After 3 failed attempts, packages are automatically blocked.
"""

from __future__ import annotations

import json
import logging
import subprocess
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger("retry_logic")

# Maximum retry attempts before auto-block
MAX_RETRY_ATTEMPTS = 3
# Days to wait between retry attempts
RETRY_DELAY_DAYS = 1


def now_iso() -> str:
    """Get current UTC time in ISO format."""
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def parse_iso(ts: str) -> datetime:
    """Parse ISO timestamp string to datetime."""
    ts = ts.replace("Z", "+00:00")
    return datetime.fromisoformat(ts)


def can_retry(item: dict) -> bool:
    """Check if package is eligible for retry.

    Args:
        item: Package metadata dict

    Returns:
        True if retry should be attempted
    """
    failed_attempts = item.get("failedAttempts", 0)

    # Max attempts reached
    if failed_attempts >= MAX_RETRY_ATTEMPTS:
        logger.debug(f"Max attempts ({MAX_RETRY_ATTEMPTS}) reached, blocking")
        return False

    # Check if enough time passed since last attempt
    last_attempt = item.get("lastAttemptAt")
    if last_attempt:
        try:
            last_dt = parse_iso(last_attempt)
            cutoff = last_dt + timedelta(days=RETRY_DELAY_DAYS)
            if datetime.now(timezone.utc) < cutoff:
                logger.debug(f"Too soon to retry, next attempt after {cutoff.isoformat()}")
                return False
        except (ValueError, TypeError):
            pass  # Invalid timestamp, allow retry

    return True


def run_command(cmd: list[str], timeout: int = 120) -> tuple[bool, str]:
    """Run a shell command and return success status.

    Args:
        cmd: Command and arguments
        timeout: Timeout in seconds

    Returns:
        (success, error_message)
    """
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )

        if result.returncode == 0:
            return True, ""
        else:
            error = result.stderr.strip() if result.stderr else result.stdout.strip()
            # Truncate long errors
            if len(error) > 500:
                error = error[:500] + "..."
            return False, error

    except subprocess.TimeoutExpired:
        return False, f"Command timed out after {timeout}s"
    except FileNotFoundError:
        return False, f"Command not found: {cmd[0]}"
    except Exception as e:
        return False, str(e)


def upgrade_npm_package(name: str, dry_run: bool = False) -> tuple[bool, str]:
    """Attempt to upgrade an npm package."""
    if dry_run:
        logger.info(f"[DRY-RUN] Would run: npm update -g {name}")
        return True, ""

    logger.info(f"Upgrading npm package: {name}")
    return run_command(["npm", "update", "-g", name], timeout=120)


def upgrade_brew_package(name: str, dry_run: bool = False) -> tuple[bool, str]:
    """Attempt to upgrade a brew package."""
    if dry_run:
        logger.info(f"[DRY-RUN] Would run: brew upgrade {name}")
        return True, ""

    logger.info(f"Upgrading brew package: {name}")
    return run_command(["brew", "upgrade", name], timeout=120)


def upgrade_pnpm_package(name: str, dry_run: bool = False) -> tuple[bool, str]:
    """Attempt to upgrade a pnpm global package."""
    if dry_run:
        logger.info(f"[DRY-RUN] Would run: pnpm update -g {name}")
        return True, ""

    logger.info(f"Upgrading pnpm package: {name}")
    return run_command(["pnpm", "update", "-g", name], timeout=120)


def retry_upgrade(
    name: str,
    manager: str,
    meta: dict,
    dry_run: bool = False
) -> dict:
    """Attempt to upgrade a package with retry tracking."""
    updated = meta.copy()

    # Check if we should retry
    if not can_retry(meta):
        failed_attempts = meta.get("failedAttempts", 0)
        if failed_attempts >= MAX_RETRY_ATTEMPTS:
            updated["blocked"] = True
            updated["reviewResult"] = "blocked"
            updated["reviewedBy"] = "auto"
            updated["note"] = f"Auto-blocked after {MAX_RETRY_ATTEMPTS} failed upgrade attempts"
            logger.warning(f"🚫 {manager}/{name}: Auto-blocked after {MAX_RETRY_ATTEMPTS} failures")
        return updated

    # Attempt upgrade
    if manager == "npm":
        success, error = upgrade_npm_package(name, dry_run=dry_run)
    elif manager == "brew":
        success, error = upgrade_brew_package(name, dry_run=dry_run)
    elif manager == "pnpm":
        success, error = upgrade_pnpm_package(name, dry_run=dry_run)
    else:
        logger.error(f"Unknown manager: {manager}")
        return updated

    # Update metadata based on result
    updated["lastAttemptAt"] = now_iso()

    if success:
        # Success: reset counters and mark as upgraded
        updated["failedAttempts"] = 0
        updated["planned"] = False
        updated["reviewResult"] = "ok"
        updated["reviewedBy"] = "auto"
        updated["note"] = "Successfully upgraded"
        logger.info(f"✅ {manager}/{name}: Upgrade successful")
    else:
        # Failure: increment counter
        failed_attempts = updated.get("failedAttempts", 0) + 1
        updated["failedAttempts"] = failed_attempts
        updated["note"] = f"Upgrade failed (attempt {failed_attempts}/{MAX_RETRY_ATTEMPTS}): {error[:200]}"
        logger.warning(f"❌ {manager}/{name}: Upgrade failed (attempt {failed_attempts}/{MAX_RETRY_ATTEMPTS}): {error[:100]}")

        # Auto-block if max attempts reached
        if failed_attempts >= MAX_RETRY_ATTEMPTS:
            updated["blocked"] = True
            updated["planned"] = False
            updated["reviewResult"] = "blocked"
            updated["reviewedBy"] = "auto"
            updated["note"] = f"Auto-blocked after {MAX_RETRY_ATTEMPTS} failed attempts. Last error: {error[:200]}"
            logger.error(f"🚫 {manager}/{name}: Auto-blocked after {MAX_RETRY_ATTEMPTS} failures")

    return updated


def process_retries(
    npm_tracked_path: Path,
    brew_tracked_path: Path,
    pnpm_tracked_path: Path | None = None,
    dry_run: bool = False
) -> dict[str, Any]:
    """Process pending retries for npm/brew/pnpm."""
    summary = {
        "npm": {"attempted": 0, "succeeded": 0, "failed": 0, "blocked": 0},
        "brew": {"attempted": 0, "succeeded": 0, "failed": 0, "blocked": 0},
        "pnpm": {"attempted": 0, "succeeded": 0, "failed": 0, "blocked": 0},
        "details": [],
    }

    def load(path: Path, label: str) -> dict[str, Any]:
        if not path or not path.exists():
            return {}
        try:
            with open(path, "r") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError) as e:
            logger.error(f"Failed to load {label}: {e}")
            return {}

    npm_data = load(npm_tracked_path, "npm_tracked.json")
    brew_data = load(brew_tracked_path, "brew_tracked.json")
    pnpm_data = load(pnpm_tracked_path, "pnpm_tracked.json") if pnpm_tracked_path else {}

    npm_items = npm_data.get("items", {})
    brew_items = brew_data.get("items", {})
    pnpm_items = pnpm_data.get("items", {})

    def queue(items: dict[str, Any]):
        return [
            (name, meta) for name, meta in items.items()
            if meta.get("planned") and meta.get("failedAttempts", 0) > 0 and not meta.get("blocked")
        ]

    npm_retries = queue(npm_items)
    brew_retries = queue(brew_items)
    pnpm_retries = queue(pnpm_items)

    logger.info(
        "Retry queue: %d npm, %d brew, %d pnpm packages",
        len(npm_retries), len(brew_retries), len(pnpm_retries)
    )

    def process(manager: str, retries: list[tuple[str, dict]], items: dict[str, Any]) -> None:
        for name, meta in retries:
            summary[manager]["attempted"] += 1
            updated = retry_upgrade(name, manager, meta, dry_run=dry_run)
            items[name] = updated

            if not updated.get("failedAttempts"):
                summary[manager]["succeeded"] += 1
                summary["details"].append({"name": name, "manager": manager, "result": "success"})
            elif updated.get("blocked"):
                summary[manager]["blocked"] += 1
                summary["details"].append({
                    "name": name,
                    "manager": manager,
                    "result": "blocked",
                    "reason": updated.get("note", ""),
                })
            else:
                summary[manager]["failed"] += 1
                summary["details"].append({
                    "name": name,
                    "manager": manager,
                    "result": "failed",
                    "attempt": updated.get("failedAttempts", 0),
                })

    process("npm", npm_retries, npm_items)
    process("brew", brew_retries, brew_items)
    process("pnpm", pnpm_retries, pnpm_items)

    if not dry_run:
        now = now_iso()
        npm_data["items"] = npm_items
        brew_data["items"] = brew_items
        npm_data["lastRetryRun"] = now
        brew_data["lastRetryRun"] = now
        if pnpm_tracked_path:
            pnpm_data["items"] = pnpm_items
            pnpm_data["lastRetryRun"] = now

        for path, data, label in [
            (npm_tracked_path, npm_data, "npm_tracked.json"),
            (brew_tracked_path, brew_data, "brew_tracked.json"),
            (pnpm_tracked_path, pnpm_data, "pnpm_tracked.json") if pnpm_tracked_path else (None, None, None),
        ]:
            if not path:
                continue
            try:
                path.parent.mkdir(parents=True, exist_ok=True)
                with open(path, "w") as f:
                    json.dump(data, f, indent=2)
                logger.info(f"Saved {label}")
            except IOError as e:
                logger.error(f"Failed to save {label}: {e}")

    total_attempted = summary["npm"]["attempted"] + summary["brew"]["attempted"] + summary["pnpm"]["attempted"]
    total_succeeded = summary["npm"]["succeeded"] + summary["brew"]["succeeded"] + summary["pnpm"]["succeeded"]
    total_failed = summary["npm"]["failed"] + summary["brew"]["failed"] + summary["pnpm"]["failed"]
    total_blocked = summary["npm"]["blocked"] + summary["brew"]["blocked"] + summary["pnpm"]["blocked"]

    logger.info(
        "Retry processing complete: %d attempted, %d succeeded, %d failed, %d blocked",
        total_attempted, total_succeeded, total_failed, total_blocked
    )

    return summary


if __name__ == "__main__":
    import argparse

    ap = argparse.ArgumentParser(description="Retry failed package upgrades")
    ap.add_argument("--npm-path", type=Path, default=Path("/home/moltuser/clawd/sys-updater/state/apt/npm_tracked.json"))
    ap.add_argument("--brew-path", type=Path, default=Path("/home/moltuser/clawd/sys-updater/state/apt/brew_tracked.json"))
    ap.add_argument("--pnpm-path", type=Path, default=Path("/home/moltuser/clawd/sys-updater/state/apt/pnpm_tracked.json"))
    ap.add_argument("--dry-run", "-n", action="store_true", help="Don't actually upgrade")
    ap.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
    args = ap.parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
    )

    result = process_retries(args.npm_path, args.brew_path, args.pnpm_path, dry_run=args.dry_run)
    print(json.dumps(result, indent=2))


path: tests/test_pkg_maint.py
content: #!/usr/bin/env python3
"""Unit tests for pkg_maint refactor paths.

Covers:
- pnpm no-importer fallback handling
- auto-plan of long-pending items
- brew link recovery path in upgrade_mode
"""

import json
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch

# Add scripts dir to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

import pkg_maint


class TestPkgMaint(unittest.TestCase):
    def test_check_pnpm_no_importer_manifest(self):
        """pnpm global store not initialized should not be treated as error."""
        with patch.object(pkg_maint, "sh") as mock_sh:
            mock_sh.side_effect = [
                (0, "10.30.0", ""),
                (1, "ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND No package.json", ""),
            ]

            result = pkg_maint.check_pnpm()

        self.assertTrue(result["installed"])
        self.assertEqual(result["outdated"], [])

    def test_auto_plan_long_pending(self):
        """Old pending items should be escalated to planned=true."""
        now = datetime.now(timezone.utc)
        tracked = {
            "items": {
                "old_pending": {
                    "firstSeenAt": (now - timedelta(days=7)).isoformat().replace("+00:00", "Z"),
                    "reviewResult": "pending",
                    "planned": False,
                    "blocked": False,
                    "note": "Pending: manual review recommended",
                },
                "fresh_pending": {
                    "firstSeenAt": (now - timedelta(days=1)).isoformat().replace("+00:00", "Z"),
                    "reviewResult": "pending",
                    "planned": False,
                    "blocked": False,
                    "note": "Pending: manual review recommended",
                },
            }
        }

        changed = pkg_maint.auto_plan_long_pending(tracked, "brew", days=4)

        self.assertEqual(changed, 1)
        self.assertTrue(tracked["items"]["old_pending"]["planned"])
        self.assertFalse(tracked["items"]["fresh_pending"]["planned"])

    def test_upgrade_mode_brew_link_recovery(self):
        """When brew upgrade fails on link step, link retry should recover."""
        with tempfile.TemporaryDirectory() as td:
            base = Path(td)
            npm_path = base / "npm_tracked.json"
            pnpm_path = base / "pnpm_tracked.json"
            brew_path = base / "brew_tracked.json"

            npm_path.write_text(json.dumps({"items": {}}))
            pnpm_path.write_text(json.dumps({"items": {}}))
            brew_path.write_text(
                json.dumps(
                    {
                        "items": {
                            "zlib": {
                                "firstSeenAt": "2026-02-10T00:00:00Z",
                                "planned": True,
                                "blocked": False,
                                "reviewResult": "ok",
                                "manager": "brew",
                                "currentVersion": "1.3.1_1",
                                "latestVersion": "1.3.2",
                            }
                        }
                    }
                )
            )

            def sh_side_effect(cmd, check=False, timeout=60):
                if cmd[:3] == ["brew", "upgrade", "zlib"]:
                    return 1, "", "Error: The `brew link` step did not complete successfully"
                if cmd[:4] == ["brew", "link", "--overwrite", "zlib"]:
                    return 0, "linked", ""
                if cmd[:2] == ["npm", "--version"]:
                    return 0, "10", ""
                if cmd[:4] == ["npm", "outdated", "-g", "--json"]:
                    return 0, "{}", ""
                if cmd[:2] == ["pnpm", "--version"]:
                    return 0, "10", ""
                if cmd[:4] == ["pnpm", "outdated", "-g", "--json"]:
                    return 0, "{}", ""
                if cmd[:2] == ["brew", "--version"]:
                    return 0, "Homebrew", ""
                if cmd[:3] == ["brew", "outdated", "--json"]:
                    return 0, json.dumps({"formulae": [], "casks": []}), ""
                return 0, "", ""

            with (
                patch.object(pkg_maint, "NPM_TRACK_PATH", npm_path),
                patch.object(pkg_maint, "PNPM_TRACK_PATH", pnpm_path),
                patch.object(pkg_maint, "BREW_TRACK_PATH", brew_path),
                patch.dict(
                    pkg_maint.MANAGER_TRACK_PATHS,
                    {"npm": npm_path, "pnpm": pnpm_path, "brew": brew_path},
                    clear=True,
                ),
                patch.object(pkg_maint, "sh", side_effect=sh_side_effect),
            ):
                report = pkg_maint.upgrade_mode(dry_run=False)

            self.assertIn("✅ brew: zlib", report)

            updated = json.loads(brew_path.read_text())
            # After successful recovery + post-upgrade recheck the item may be removed
            # from tracking (no longer outdated). If still present, it must be unplanned.
            if "zlib" in updated.get("items", {}):
                item = updated["items"]["zlib"]
                self.assertFalse(item["planned"])


if __name__ == "__main__":
    unittest.main(verbosity=2)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)