Claude Skill

maintaining-windows-health

Hands-on playbook for Windows 11 disk cleanup, dev-machine optimization, and proactive health alerting. Use when the PC is full or slow, when a BSOD / Kernel-Power 41 / crash dump / commit-memory pressure happened, when the user asks to free disk space, audit storage, set up disk

LLM Mart · 0 points · 2 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download codealive-ai-ai-driven-development-skills_maintaining-windows-health-68a302a.zip · 67 KB
Part of codealive-ai/ai-driven-development — 21 skills

Install

skills CLI npx skills add https://github.com/CodeAlive-AI/ai-driven-development/tree/main/skills/maintaining-windows-health
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install codealive-ai-ai-driven-development@llmmart
Git git clone https://github.com/CodeAlive-AI/ai-driven-development.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole codealive-ai/ai-driven-development collection as a plugin from our marketplace. Git is the plain clone.

README

maintaining-windows-health

A hands-on playbook skill for Windows 11 disk cleanup, dev-machine optimization, and proactive health alerting. It is the Windows port of maintaining-macos-health — same three-layer architecture and the same drift-protection safety invariant, rebuilt around Windows-native tooling.

Recovery and prevention, not blind deletion. The skill follows a managed cycle: inventory → classify → delete only with Microsoft-supported tools → verify integrity → keep a rollback path.

What it does

Three layers, mirroring the macOS skill:

  1. Triage (references/triage.md) — classify which signal fired: disk-driven, commit-memory-driven, BSOD/crash, or "feels slow" — with a read-only PowerShell snapshot.
  2. Recovery (references/cleanup-tiers.md, never-touch.md, native-tools.md) — a 10-tier, risk-ordered cleanup playbook (Storage Sense → discuss-first), a hard blacklist of what never to delete, and the native-tooling safety floor + project-artifact purge map.
  3. Automation / alerting (references/alerting.md, assets/*.ps1) — a drift-protected HTML cleanup UI plus a Task Scheduler + BurntToast alerter (3 CRITICAL-only triggers, hysteresis, calibration window).

The safety invariant

scan  →  build JSON  →  user picks in the HTML UI  →  selection JSON  →  apply deletes ONLY selected_items

assets/apply-cleanup-selection.py is the only sanctioned way to apply a cleanup. It never hand-rolls Remove-Item; it reads the user's picks from the selection JSON and validates every command before running it. The validator is a Windows rewrite (not a translation) of the macOS one, because NTFS + PowerShell offer several ways to spell a protected path that a naive check misses:

  • NTFS path canonicalization (separator unification, .. collapse, trailing dot/space stripping) like Win32 GetFullPath
  • refusal of UNC/device paths (\\?\, \\.\), 8.3 short names (PROGRA~1), Alternate Data Streams, and non-filesystem providers (HKLM:, Env:)
  • a longest-prefix-wins allow/deny classifier so carve-outs resolve correctly (C:\Windows denied, but C:\Windows\Temp allowed; C:\Users\me allowed, but C:\Users\me\.ssh denied)
  • a two-tier wrapper model: cache tools (npm/docker/dotnet…) are trusted; irreversible tools (vssadmin delete, DISM /ResetBase, wevtutil cl, pnputil /delete-driver, powercfg /h off) require an explicit protected-override
  • a refusal of command chaining/injection metacharacters (; | & \ $( :: > <`)

Quick start

Read-only preflight audit:

.\assets\Audit-WinHealth.ps1     # drives, Component Store, shadow storage, drivers, CFA, KFM, BitLocker, reparse points, dumps

Interactive cleanup (requires Python 3 for the HTML UI):

python3 .\assets\render-cleanup-plan.py %TEMP%\cleanup-data.json   # opens the picker, writes the selection JSON
python3 .\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json   # applies only what was picked

Install the alerter (non-elevated, interactive session — required for toasts):

.\assets\Install-WinHealthCheck.ps1        # installs BurntToast, registers the 5-min task, runs once
.\assets\Install-WinHealthCheck.ps1 -Test  # synthetic disk alert to confirm the toast pipeline

File map

SKILL.md                         agent entry point (workflows A–D, safety rules, quirks)
references/
  triage.md                      which signal fired (read-only snapshot)
  cleanup-tiers.md               10 risk-ordered tiers, PowerShell blocks
  never-touch.md                 hard blacklist + Windows-only dangers
  native-tools.md                safety floor, purge map, validator rules, 3rd-party caveats
  alerting.md                    Task Scheduler + BurntToast + ntfy alerter design
assets/
  win-health-check.ps1           the monitor (PS 5.1 compatible)
  win-health-check.config.ps1    thresholds
  Install-WinHealthCheck.ps1     registers the task (interactive session); -Test / -Uninstall
  Audit-WinHealth.ps1            read-only preflight inventory
  render-cleanup-plan.py         HTML cleanup UI (cross-platform, Python 3)
  apply-cleanup-selection.py     the only sanctioned apply path (Windows validator)
  test_validate_command.py       adversarial validator tests (safety-core gate)

Key Windows-specific guardrails

  • Toasts need an interactive session. The scheduled task is registered as the logged-on user (LogonType Interactive), never SYSTEM — a SYSTEM task's toasts silently no-op.
  • Remove-Item is permanent (no Recycle Bin) and recursive deletes can escape through junctions — always -LiteralPath, never recurse a reparse point.
  • OneDrive KFM / Files On-Demand — deleting redirected/placeholder files propagates to the cloud and every device.
  • Clean with commands, not folders — WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil, hiberfil via powercfg. The registry is never a cleanup target.
  • Commit %, not "available MB", is the memory signal — Available MBytes includes reclaimable standby/cache.

Verification status

Authored and statically verified on a macOS machine (no Windows runtime available):

  • ✅ Verified here: PowerShell AST parse of all .ps1 (via pwsh), py_compile of both Python scripts, and the adversarial validator suite (test_validate_command.py) — pure NTFS string logic that runs on any OS and is the safety-core gate.
  • ⚠️ Requires a Windows smoke-test before fully trusting: real NTFS behavior of GetFullPath/8.3/ADS/reparse resolution, toast rendering and AUMID attribution, SYSTEM-vs-interactive session behavior, Task Scheduler registration semantics on S0ix/battery, and the exact output of DISM/vssadmin/wevtutil/pnputil. Gate the alerter and apply-on-real-paths behind this smoke-test.

Credits & sources

Built on official Microsoft Learn guidance for Storage Sense, cleanmgr, DISM (WinSxS), pnputil, vssadmin, wevtutil, powercfg, Get-WinEvent, Task Scheduler, and BurntToast, plus the macOS sibling's incident-validated architecture. See references/ for inline citations of the behavior each rule depends on.

Skill manifest

Maintaining Windows 11 Health

Recovery and prevention playbook for Windows 11 disk and memory crises. A Windows port of the maintaining-macos-health skill: same three layers (triage → tiered recovery → automation/alerting) and the same safety invariant — scan → JSON → user picks in a UI → apply deletes only what was picked — but with Windows-native tooling and Windows-specific "never touch" rules. The same playbook works for routine cleanup or first-time setup on a new machine.

Table of contents

When to use

Trigger on any of:

  • Disk free < 20 % or user complains about being out of space
  • BSOD / unexpected reboot / Kernel-Power 41 / WHEA error / a new crash dump in C:\Windows\Minidump
  • "PC is slow", commit pressure high (% Committed Bytes In Use > 85), pagefile growing
  • User wants to set up monitoring/alerting from scratch
  • Migration to a new Windows PC → restore the same alerter
  • General "clean my PC" / "audit storage" / "free space" requests

Skill layout

File Use for
references/triage.md First 5 minutes — which signal fired (disk / commit-memory / BSOD-crash / "feels slow"), read-only snapshot
references/cleanup-tiers.md Tiered cleanup playbook (10 tiers, low-risk → discuss-first), copy-paste-safe PowerShell blocks
references/never-touch.md Categories that must not be deleted even elevated (hard-protected prefixes synced with the validator + Windows-only dangers)
references/native-tools.md The safety floor: native Microsoft tooling, project-artifact purge marker→target map, the apply-script validator rules, third-party caveats
references/alerting.md Full alerter design: 3 CRITICAL-only triggers, hysteresis, calibration, Task Scheduler interactive session, BurntToast + ntfy, S0ix/battery
assets/win-health-check.ps1 Production PowerShell monitor (PS 5.1 compatible)
assets/win-health-check.config.ps1 Default thresholds
assets/Install-WinHealthCheck.ps1 Registers the scheduled task in the interactive user session; -Test / -Uninstall
assets/Audit-WinHealth.ps1 Read-only preflight inventory (drives, Component Store, shadow storage, profiles, drivers, CFA, KFM, BitLocker, reparse points, dumps)
assets/render-cleanup-plan.py Interactive HTML cleanup-plan UI. Renders categorised checkboxes from a JSON of scan findings, serves on 127.0.0.1:18347, opens the browser, waits for the user's selection, writes it to %TEMP%\cleanup-selection-<ts>.json. Used by Workflow A. Requires Python 3.
assets/apply-cleanup-selection.py The only sanctioned way to apply a cleanup selection. Reads selected_items from a selection JSON and executes each item's command via PowerShell. Windows-rewritten validator (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal, two-tier wrappers) + operations log. Supports --dry-run.
assets/test_validate_command.py Adversarial unit tests for the validator — the safety-core gate. Runs on any OS.

Read the relevant reference before acting. Do NOT operate from memory of these files — the details are calibrated to Windows-specific failure modes and small changes break safety.

Core mental model

  1. Native tooling is the safety floor. Unlike macOS (Mole), Windows has no mature community safety tool — so Storage Sense / Cleanup recommendations / cleanmgr / DISM do the heavy lifting, and the apply-script validator is the only line behind project-artifact purge. Use the supported command for anything the OS maintains itself.
  2. Clean with commands, not by deleting folders. WinSxS → DISM; Driver Store → pnputil; shadow copies → vssadmin; Event Logs → wevtutil; hiberfil → powercfg. Hand-deleting these corrupts the OS.
  3. Commit %, not "available MB", is the memory signal. Available MBytes includes reclaimable standby/cache; commit-limit exhaustion is the real Windows OOM.
  4. Drift protection via the selection JSON. The HTML UI writes exactly what the user picked; apply-cleanup-selection.py deletes only selected_items. Never hand-roll Remove-Item in the apply phase.
  5. Escalate conservatively. Start zero-risk (Storage Sense, caches, Recycle Bin), only reach project artifacts, Docker/WSL VHDX, Component Store, and elevated/discuss-first tiers if needed.

Standard workflows

A. "Free space NOW" (incident response)

  1. Triage — read references/triage.md, identify which signal fired and how urgent. Run assets/Audit-WinHealth.ps1 (read-only) for the preflight inventory (drives, Component Store, shadow storage, drivers, CFA / OneDrive KFM / BitLocker / reparse points).
  2. Snapshot baseline — record free GB on the target drive.
  3. Run all scans, don't delete yet — work the tiers in references/cleanup-tiers.md in inventory mode (list candidates, sizes, ages), including the large-file visibility pass. Capture everything before opening the browser; deletion comes only after the user picks via the UI. The cleanup UI should be a single final questionnaire containing safe cleanup candidates, protected/discuss-first items, and large suspicious user/workload files together, not a sequence of separate UI rounds.
  4. Python gate — the cleanup UI needs Python 3. Check for a real interpreter (py -3 --version, or a python.exe that isn't the Microsoft Store alias). If Python is missing, ask the user for permission to install it (winget install Python.Python.3.12), then continue. Do not silently skip the UI.
  5. Resolve unknown items before building JSON — for every candidate > 500 MB you cannot explain in one sentence (unfamiliar app/folder, vendor cache, VM/VHDX, model weights), research it first: check references/never-touch.md, then delegate a quick lookup to the web-searcher subagent ("what is <path> on Windows 11, safe to delete in 2026"). Write a concrete description (1–3 sentences in the user's language) into the item. Never show vague placeholders like "unknown".
  6. Show large-but-not-safe items too — any explainable item > 500 MB that is user-owned or workload-owned but not a safe cache must still appear in the same final UI, normally protected: true, default_selected: false, with a concrete warning. Examples: old .7z/.zip/.iso archives, .gguf/.safetensors model weights, project/game assets, app diagnostic dumps, profiler snapshots, synced-folder media, and VHDX files. Visibility is required because only the user can know whether these are still needed. Do not include hard-protected system internals as bare-delete candidates; show them only through supported tools, or omit deletion entirely if no supported action exists.
  7. Build one data JSON — every candidate becomes a structured item (id, label, path, size_bytes, age_days, kind, PowerShell command, mandatory description, optional protected + warning). Use the schema in assets/render-cleanup-plan.py. Irreversible-tool commands (DISM /ResetBase, vssadmin delete, wevtutil cl, pnputil /delete-driver, powercfg /h off) should be marked protected: true. Do not open the UI until this unified JSON contains both safe default-selected items and protected unchecked items.
  8. Render and open the cleanup UI:
    python3 <skill>\assets\render-cleanup-plan.py %TEMP%\cleanup-data-<ts>.json
    
    It serves on 127.0.0.1:18347, opens the browser, and blocks until Submit/Cancel. On submit it writes %TEMP%\cleanup-selection-<ts>.json. Tell the user out loud: "браузер открыт — поставь галочки, нажми Submit, потом пингани меня." Then stop and wait.
  9. After the user pings — read the selection JSON, render their choices back in chat (categories, item list, total GB, any protected overrides flagged), and ask one explicit confirmation before deleting.
  10. Apply via the helper — never hand-rolled Remove-Item:
python3 <skill>\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json
#   add --scan-root D:\projects to allow bare deletes on an external dev drive

It reads selected_items, validates each command (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal), skips protected items not in protected_overrides, runs each via powershell.exe, and logs to %LOCALAPPDATA%\win-health\operations.log. --dry-run previews. The selection JSON is the single source of truth. 11. Post-check — report the free-space delta, then Dism /Online /Cleanup-Image /CheckHealth + sfc /scannow to confirm nothing was broken. Stop at the goal.

Hard-protected items (per references/never-touch.md) must always appear in the UI with "protected": true + a concrete warning — the UI dims them and requires a per-item confirm before they can be checked. Never omit a protected item user data depends on; visibility teaches the surrounding risk.

B. "Set up alerting" (new machine or first time)

Run a non-elevated PowerShell as the user who should receive alerts (interactive session — required for toasts):

cd <skill>\assets
.\Install-WinHealthCheck.ps1     # installs BurntToast, copies script+config, registers the task, runs once

Then read references/alerting.md for tuning. The task is registered in the interactive user session (NOT SYSTEM — that silently swallows toasts), every 5 minutes, -StartWhenAvailable, battery-friendly. The first 7-day calibration window is silent (logs only). Verify with .\Install-WinHealthCheck.ps1 -Test.

C. Alerter stopped working / making noise

Read references/alerting.md § Troubleshooting. Common causes:

  • Task running as SYSTEM / "whether user is logged on or not" → toasts silently never render. Re-run the installer (interactive principal).
  • BurntToast not installed → toast attributed to "PowerShell" or absent. Install-Module BurntToast -Scope CurrentUser.
  • Laptop never checks → was AC-only / paused in sleep. Re-run installer (battery flags + -StartWhenAvailable).
  • Constant alerts during heavy work → create %LOCALAPPDATA%\win-health\silent, raise thresholds, or increase hysteresis.

D. "Uninstall an app cleanly"

Prefer winget uninstall --id <App.Id> (clean, supported). For apps with stubborn leftovers, BCUninstaller (review the leftover list) is the dev-friendly option. Never hand-delete Program Files install dirs or registry keys to "remove" an app — that orphans the MSI/uninstall state. Always confirm before removing user data folders.

Safety rules (non-negotiable)

  1. Never delete without dry-run + user confirmation for any tier ≥ 5 or any elevated operation.
  2. Never bypass references/never-touch.md — even if the user explicitly asks. Push back, explain the consequence.
  3. Clean with commands, not folders. WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil (export before clear), hiberfil via powercfg. Never Remove-Item these.
  4. The registry is never a cleanup target. No registry cleaners (Microsoft-unsupported). The validator refuses registry-provider deletes.
  5. Apply phase reads only the selection JSON. Never hand-roll Remove-Item or hard-code paths from the earlier scan when applying — that's how you delete items the user unchecked. Use assets/apply-cleanup-selection.py, which iterates selected_items only.
  6. Remove-Item is permanent (no Recycle Bin) and recursive deletes can escape through junctions — always -LiteralPath, never recurse a reparse point. Prefer move-to-quarantine when the user is unsure.
  7. No auto-cleanup tied to alerts. Alerts notify; the human decides.
  8. Keep crash dumps until triaged — they're forensic evidence, not cleanup.

Domain quirks captured

  • SYSTEM session can't show toasts. A scheduled task as SYSTEM executes but its toasts silently no-op (Session 0 has no desktop). Register the task in the interactive user session — the Windows analog of the macOS "osascript → Script Editor" trap.
  • Remove-Item -Recurse follows junctions and can delete the target's contents — the classic data-loss bug. Dev trees are full of junctions (npm/pnpm store, Docker, WSL). Use -LiteralPath; never recurse a FILE_ATTRIBUTE_REPARSE_POINT.
  • Remove-Item bypasses the Recycle Bin — deletion is immediate and permanent, unlike dragging to the bin.
  • OneDrive KFM redirects Desktop/Documents/Pictures into OneDrive — deleting there propagates to all devices. Files On-Demand placeholders (RECALL_ON_DATA_ACCESS/OFFLINE) are cloud originals; deleting the stub deletes the cloud file.
  • Controlled Folder Access blocks deletes in Documents/Pictures/Desktop with Access Denied from Defender — distinguish from a real error.
  • Available MBytes includes standby/cache — high "available" doesn't mean healthy; commit-limit exhaustion is the real OOM signal.
  • pagefile.sys and swapfile.sys are two different files; C:\Windows\Installer + Package Cache break MSI repair if deleted; Prefetch should not be cleaned (myth).
  • Modern Standby (S0ix) makes wake timers unreliable and hides S3 — normal, not a fault. The task pauses in sleep and catches up on wake via -StartWhenAvailable.
  • General rule: if you can't describe a folder in one sentence (especially > 500 MB), don't guess — delegate a web-searcher lookup before writing the item's description. If you can describe it but it may be user data, still show it in the single final cleanup UI as protected and unchecked by default.

Outcomes scale

A representative recovery on a dev machine that hit ~8 % free under heavy AI/Docker/WSL load:

  • Largest single contribution: project build artifacts (node_modules, bin/obj, target, .next) via Tier 7 purge.
  • WSL2 / Docker *.vhdx compaction (not deletion): often 10–40 GB reclaimed.
  • Component Store cleanup via DISM after many cumulative updates: a few GB.
  • Package-manager caches (npm/pnpm/NuGet/pip/cargo/gradle): a few GB.
  • Downloads review (old installers, ISOs): variable, often 10–20 GB.
  • Elevated tier (Windows.old after rollback check, Delivery Optimization, exported+cleared logs): variable.

Active alerter installed with a 7-day calibration window; verified via the synthetic disk-trigger test before going live. Numbers scale with workload and disk size — light users see less; heavy AI/Docker/WSL/IDE users see more.

Files (ai-driven-development)
  • assets
    • apply-cleanup-selection.py 20.2 KB
      #!/usr/bin/env python3
      r"""
      apply-cleanup-selection.py — the ONLY sanctioned way to apply a cleanup
      selection produced by render-cleanup-plan.py on Windows 11.
      
      Why this exists
      ---------------
      The maintaining-windows-health skill splits scan-and-plan from apply on purpose:
      the user picks items in the HTML UI, the server writes a selection JSON, and
      THIS script reads that JSON and executes each item's `command`. The skill
      forbids hand-rolled `Remove-Item` blocks during apply, because they're how you
      end up deleting items the user explicitly unchecked. The single-source-of-truth
      is the selection JSON written by the UI server — nothing else.
      
      This is a Windows-specific REWRITE of the macOS validator, NOT a translation.
      The macOS validator's safety rested on POSIX assumptions that are individually
      FALSE on Windows: a single root `/`, case-sensitivity, one path separator, `~`
      HOME expansion, no drive letters, no UNC/device paths, no Alternate Data
      Streams, no 8.3 short names, no non-filesystem providers (HKLM:, Env:, Cert:).
      A naive "swap the prefixes" port silently fails open. So the validator below is
      rebuilt around NTFS path canonicalization (`ntpath` mirrors Win32 `GetFullPath`
      behavior even when this script is unit-tested on macOS).
      
      Safety model (what `validate_command` enforces)
      -----------------------------------------------
      1. Reject command chaining / injection metacharacters outright: ; | & ` $( @( ::
         > < newline. PowerShell's metacharacter surface is far wider than bash, so a
         `command` like `docker ps; Remove-Item C:\Windows -Recurse` must never pass
         just because it starts with a trusted wrapper.
      2. Two-tier wrapper model (Windows-specific):
         - SAFE wrappers (self-policing AND non-destructive): npm/pnpm/yarn/dotnet/
           nuget/pip/uv/cargo/go/gradle/mvn/docker/winget/cleanmgr/Clear-RecycleBin…
           -> trusted, no protection required.
         - TOOL wrappers (self-policing) whose destructive subcommands are
           irreversible: vssadmin delete/resize, Dism …/ResetBase, wevtutil cl,
           pnputil /delete-driver, powercfg /h off, Disable-ComputerRestore
           -> trusted to target correctly, BUT require `protected:true` +
           `protected_overrides` because the effect is irreversible. (The macOS model
           "wrapper == safe" is false on Windows.)
      3. Bare delete (Remove-Item/del/rd/rmdir/erase): every path token is
         env-expanded -> rejected if it is a UNC/device path (\\?\, \\.\, \\server),
         an 8.3 short name (PROGRA~1), a non-filesystem provider (HKLM:, Env:, …), or
         carries an Alternate Data Stream (file.txt:hidden) -> canonicalized with
         ntpath.normpath + trailing dot/space stripping (Win32 behavior) -> required
         to be drive-absolute (`<letter>:\…`) and never a bare drive root -> classified
         against an allow-list and a deny-list using LONGEST-PREFIX-WINS so carve-outs
         resolve correctly (e.g. C:\Windows is denied but C:\Windows\Temp is allowed;
         C:\Users\me is allowed but C:\Users\me\.ssh is denied).
      4. Non-FileSystem PowerShell providers are refused for any delete verb —
         registry "cleaning" is explicitly unsupported by Microsoft and out of scope.
      
      Operations log: Mole-compatible TSV appended to
      %LOCALAPPDATA%\win-health\operations.log.
      
      Usage
      -----
          apply-cleanup-selection.py <selection.json>                 # apply
          apply-cleanup-selection.py <selection.json> --dry-run
          apply-cleanup-selection.py <selection.json> --scan-root D:\projects   # repeatable
      
      Exit codes
      ----------
          0  - all items applied (or dry-run completed)
          1  - bad input or fatal validation error
          2  - one or more items failed (others may have succeeded)
      """
      from __future__ import annotations
      
      import argparse
      import json
      import ntpath
      import os
      import re
      import shlex
      import subprocess
      import sys
      from datetime import datetime
      from pathlib import Path
      
      # ---------------------------------------------------------------------------
      # Operations log — %LOCALAPPDATA%\win-health\operations.log (fallback to ~).
      # ---------------------------------------------------------------------------
      def _op_log_path() -> Path:
          base = os.environ.get("LOCALAPPDATA")
          if base:
              return Path(base) / "win-health" / "operations.log"
          return Path.home() / "AppData" / "Local" / "win-health" / "operations.log"
      
      
      # ---------------------------------------------------------------------------
      # Command classification heads (lowercased).
      # ---------------------------------------------------------------------------
      # Self-policing AND non-destructive to user data. Trusted, no protection needed.
      SAFE_WRAPPER_HEADS = {
          "npm", "pnpm", "yarn", "bun",
          "dotnet", "nuget",
          "pip", "pip3", "uv", "poetry", "pipenv",
          "cargo", "go",
          "gradle", "gradlew", "gradlew.bat",
          "mvn", "maven",
          "docker", "docker-compose",
          "winget",
          "cleanmgr", "cleanmgr.exe",
          "clear-recyclebin",
          "delete-deliveryoptimizationcache",
          "optimize-vhd", "optimize-volume",
          "brew",  # harmless cross-platform passthrough (no-op on Windows)
      }
      
      # Self-policing tools whose *destructive* subcommands are irreversible.
      # Recognized as wrappers (so they skip bare-path validation) but flagged as
      # requiring protection when the subcommand is destructive.
      TOOL_WRAPPER_HEADS = {
          "dism", "dism.exe",
          "vssadmin",
          "wevtutil",
          "pnputil", "pnputil.exe",
          "powercfg",
          "disable-computerrestore",
          "checkpoint-computer",
      }
      
      # Bare delete verbs / aliases (filesystem). Each gets full path validation.
      BARE_DELETE_HEADS = {
          "remove-item", "remove-item.exe", "ri", "rm", "rmdir", "rd", "del", "erase",
      }
      
      # Substrings that, if present anywhere in the command, indicate chaining,
      # redirection, sub-expression evaluation, .NET static calls, or device/provider
      # qualifiers. Any of these => reject. (`&&`/`||` are covered by `&`/`|`.)
      FORBIDDEN_SUBSTRINGS = (";", "|", "&", "`", "$(", "@(", "::", ">", "<", "\n", "\r")
      
      # Markers of an irreversible TOOL-wrapper invocation (lowercased command).
      _DESTRUCTIVE_TOOL_PATTERNS = (
          ("vssadmin", "delete"),
          ("vssadmin", "resize"),
          ("dism", "/resetbase"),
          ("wevtutil", "cl "),
          ("wevtutil", "clear-log"),
          ("pnputil", "/delete-driver"),
          ("pnputil", "-d "),
          ("powercfg", "/h off"),
          ("powercfg", "/hibernate off"),
          ("disable-computerrestore", ""),
      )
      
      
      # ---------------------------------------------------------------------------
      # Path helpers (ntpath everywhere so behavior matches Win32 even on macOS CI).
      # ---------------------------------------------------------------------------
      def _env(name: str, default: str = "") -> str:
          return os.environ.get(name, default)
      
      
      def _expandvars(token: str) -> str:
          """Expand %VAR% / $VAR using ntpath semantics (Windows-style), regardless
          of the host OS this validator runs on (so unit tests on macOS match)."""
          return ntpath.expandvars(token)
      
      
      def _norm(path: str) -> str:
          """Canonicalize like Win32 GetFullPath: unify separators, collapse `..`/`.`,
          and strip trailing dots/spaces from every component (NTFS truncates them)."""
          p = path.replace("/", "\\")
          p = ntpath.normpath(p)
          drive, rest = ntpath.splitdrive(p)
          parts = [seg.rstrip(" .") for seg in rest.split("\\")]
          return drive + "\\".join(parts)
      
      
      def _under(path_low: str, prefix_low: str) -> bool:
          """True if path_low == prefix_low or is a child of it (component-aware)."""
          if path_low == prefix_low:
              return True
          sep = "" if prefix_low.endswith("\\") else "\\"
          return path_low.startswith(prefix_low + sep)
      
      
      def _allow_prefixes(scan_roots: list[str]) -> list[str]:
          sysdrive = _env("SystemDrive", "C:")
          sysroot = _env("SystemRoot", _env("windir", sysdrive + "\\Windows"))
          userprofile = _env("USERPROFILE", str(Path.home()))
          temp = _env("TEMP", _env("TMP", ""))
          localappdata = _env("LOCALAPPDATA", userprofile + "\\AppData\\Local")
          prefixes = [
              userprofile,                     # broad: most dev work lives under HOME
              sysroot + "\\Temp",              # C:\Windows\Temp carve-out inside C:\Windows
              localappdata + "\\Temp",
          ]
          if temp:
              prefixes.append(temp)
          # Extra scan roots: CLI --scan-root plus WIN_HEALTH_SCAN_ROOTS (`;`-separated).
          env_roots = _env("WIN_HEALTH_SCAN_ROOTS", "")
          for r in list(scan_roots) + [x for x in env_roots.split(";") if x.strip()]:
              prefixes.append(r)
          out = []
          for p in prefixes:
              if not p:
                  continue
              out.append(_norm(_expandvars(p)).lower())
          return [p for p in out if p]
      
      
      def _deny_prefixes() -> list[str]:
          sysdrive = _env("SystemDrive", "C:")
          sysroot = _env("SystemRoot", _env("windir", sysdrive + "\\Windows"))
          pf = _env("ProgramFiles", sysdrive + "\\Program Files")
          pfx86 = _env("ProgramFiles(x86)", sysdrive + "\\Program Files (x86)")
          pf6432 = _env("ProgramW6432", pf)
          programdata = _env("ProgramData", sysdrive + "\\ProgramData")
          userprofile = _env("USERPROFILE", str(Path.home()))
          appdata = _env("APPDATA", userprofile + "\\AppData\\Roaming")
          localappdata = _env("LOCALAPPDATA", userprofile + "\\AppData\\Local")
          raw = [
              # OS roots (covers System32, SysWOW64, WinSxS, DriverStore, config,
              # SoftwareDistribution, Installer, Minidump, MEMORY.DMP, Prefetch, …).
              sysroot,
              # Program installs.
              pf, pfx86, pf6432,
              programdata + "\\Microsoft",
              programdata + "\\Package Cache",
              # Boot / recovery / volume metadata.
              sysdrive + "\\$Recycle.Bin",
              sysdrive + "\\System Volume Information",
              sysdrive + "\\Recovery",
              sysdrive + "\\$WinREAgent",
              sysdrive + "\\Config.Msi",
              sysdrive + "\\PerfLogs",
              sysdrive + "\\Boot",
              sysdrive + "\\EFI",
              # Paging / hibernation / crash files (file-level).
              sysdrive + "\\pagefile.sys",
              sysdrive + "\\swapfile.sys",
              sysdrive + "\\hiberfil.sys",
              sysdrive + "\\DumpStack.log.tmp",
              # Credential / key stores under HOME (deny beats the broad HOME allow).
              userprofile + "\\.ssh",
              userprofile + "\\.gnupg",
              userprofile + "\\.aws",
              userprofile + "\\.azure",
              userprofile + "\\.kube",
              userprofile + "\\.docker",
              userprofile + "\\.netrc",
              userprofile + "\\.git-credentials",
              appdata + "\\Microsoft\\Crypto",
              appdata + "\\Microsoft\\Protect",
              appdata + "\\Microsoft\\Credentials",
              appdata + "\\Microsoft\\SystemCertificates",
              appdata + "\\gh\\hosts.yml",
              localappdata + "\\Microsoft\\Credentials",
          ]
          return [_norm(_expandvars(p)).lower() for p in raw]
      
      
      def _classify(path_low: str, allow: list[str], deny: list[str]) -> tuple[str, str]:
          """Longest-prefix-wins. Returns ('deny'|'allow'|'none', matched_prefix)."""
          best_allow = max((p for p in allow if _under(path_low, p)), key=len, default=None)
          best_deny = max((p for p in deny if _under(path_low, p)), key=len, default=None)
          if best_deny is not None and (best_allow is None or len(best_deny) >= len(best_allow)):
              return "deny", best_deny
          if best_allow is not None:
              return "allow", best_allow
          return "none", ""
      
      
      def _unquote(tok: str) -> str:
          if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in "\"'":
              return tok[1:-1]
          return tok
      
      
      def _looks_like_path(tok: str) -> bool:
          if tok.startswith(("\\\\", "%", "$", "~")):
              return True
          if re.match(r"^[A-Za-z][A-Za-z0-9]*:", tok):  # drive (1 letter) or provider (more)
              return True
          return "\\" in tok or "/" in tok
      
      
      def _validate_path_token(tok: str, allow: list[str], deny: list[str]) -> tuple[bool, str]:
          expand = _expandvars(tok)
          if expand.startswith("\\\\"):
              return False, f"UNC/device path not allowed: {tok!r}"
          if re.search(r"~\d", expand):
              return False, f"8.3 short name not allowed: {tok!r}"
          m = re.match(r"^([A-Za-z][A-Za-z0-9]*):", expand)
          if m and len(m.group(1)) > 1:
              return False, f"non-filesystem provider not allowed: {tok!r}"
          if ":" in expand[2:]:  # any colon past the drive letter == Alternate Data Stream
              return False, f"alternate data stream not allowed: {tok!r}"
          low = _norm(expand).lower()
          if not re.match(r"^[a-z]:\\", low):
              return False, f"path is not drive-absolute: {tok!r} -> {low!r}"
          if re.match(r"^[a-z]:\\?$", low):
              return False, f"refuses bare drive root: {low!r}"
          decision, matched = _classify(low, allow, deny)
          if decision == "deny":
              return False, f"targets a protected prefix ({matched!r}): {low!r}"
          if decision == "none":
              return False, f"outside allowed zones (HOME/TEMP/scan-roots): {low!r}"
          return True, ""
      
      
      def _is_destructive_tool(cmd_low: str) -> bool:
          for head, needle in _DESTRUCTIVE_TOOL_PATTERNS:
              if cmd_low.startswith(head) and (needle == "" or needle in cmd_low):
                  return True
          return False
      
      
      def validate_command(command: str, scan_roots: list[str] | None = None
                           ) -> tuple[bool, str, bool]:
          """Return (ok, reason, requires_protection)."""
          scan_roots = scan_roots or []
          cmd = command.strip()
          if not cmd:
              return False, "empty command", False
      
          for bad in FORBIDDEN_SUBSTRINGS:
              if bad in cmd:
                  label = bad.replace("\n", "\\n").replace("\r", "\\r")
                  return False, f"command contains forbidden metacharacter {label!r}", False
      
          try:
              toks = [_unquote(t) for t in shlex.split(cmd, posix=False)]
          except ValueError as exc:
              return False, f"unparseable command ({exc})", False
          if not toks:
              return False, "no tokens", False
      
          head = toks[0].lower()
          cmd_low = cmd.lower()
      
          if head in SAFE_WRAPPER_HEADS:
              return True, "", False
          if head in TOOL_WRAPPER_HEADS:
              return True, "", _is_destructive_tool(cmd_low)
      
          if head not in BARE_DELETE_HEADS:
              return False, f"only delete verbs or vetted wrappers allowed, got {toks[0]!r}", False
      
          allow = _allow_prefixes(scan_roots)
          deny = _deny_prefixes()
          path_tokens = [t for t in toks[1:] if _looks_like_path(t)]
          if not path_tokens:
              return False, "delete command without a target path", False
          for t in path_tokens:
              ok, reason = _validate_path_token(t, allow, deny)
              if not ok:
                  return False, reason, False
          return True, "", False
      
      
      # ---------------------------------------------------------------------------
      # Reporting helpers.
      # ---------------------------------------------------------------------------
      def human_size(num_bytes: int) -> str:
          if num_bytes is None or num_bytes == 0:
              return "0 B"
          n = float(num_bytes)
          for unit in ("B", "KB", "MB", "GB", "TB"):
              if abs(n) < 1024.0:
                  return f"{int(n)} {unit}" if unit == "B" else f"{n:3.1f} {unit}"
              n /= 1024.0
          return f"{n:.1f} PB"
      
      
      def log_op(action: str, path: str, size: str, status: str) -> None:
          op_log = _op_log_path()
          op_log.parent.mkdir(parents=True, exist_ok=True)
          ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
          row = f"{ts}\tapply-selection\t{action}\t{path}\t{size}\t{status}\n"
          with op_log.open("a", encoding="utf-8") as f:
              f.write(row)
      
      
      def _run(command: str) -> subprocess.CompletedProcess:
          """Execute one validated command via PowerShell (no profile, non-interactive)."""
          return subprocess.run(
              ["powershell.exe", "-NoProfile", "-NonInteractive",
               "-ExecutionPolicy", "Bypass", "-Command", command],
              capture_output=True, text=True, timeout=900,
          )
      
      
      def load_selection_json(path: Path) -> dict:
          """Load a cleanup selection JSON, tolerating a UTF-8 BOM.
      
          Windows PowerShell `Set-Content -Encoding UTF8` (and several editors)
          write a BOM. `json.loads` rejects a leading U+FEFF under encoding=utf-8;
          utf-8-sig strips it when present and is a no-op for BOM-less files.
          """
          return json.loads(path.read_text(encoding="utf-8-sig"))
      
      
      def main(argv: list[str]) -> int:
          ap = argparse.ArgumentParser(description="Apply a cleanup selection JSON (Windows)")
          ap.add_argument("selection", help="path to cleanup-selection-<ts>.json")
          ap.add_argument("--dry-run", action="store_true", help="print actions, don't execute")
          ap.add_argument("--scan-root", action="append", default=[],
                          help="additional absolute root where bare deletes are allowed (repeatable)")
          ap.add_argument("--continue-on-error", action="store_true",
                          help="keep going if an item fails (default: stop on first error)")
          args = ap.parse_args(argv[1:])
      
          sel_path = Path(args.selection)
          if not sel_path.is_file():
              print(f"selection file not found: {sel_path}", file=sys.stderr)
              return 1
          try:
              sel = load_selection_json(sel_path)
          except Exception as exc:
              print(f"invalid selection JSON: {exc}", file=sys.stderr)
              return 1
      
          items = sel.get("selected_items", []) or []
          if not items:
              print("selected_items is empty — nothing to do", file=sys.stderr)
              return 0
      
          protected_overrides = set(sel.get("protected_overrides", []) or [])
          total_bytes = sum(it.get("size_bytes", 0) or 0 for it in items)
      
          print(f"=== Applying selection from {sel_path.name} ===")
          print(f"Items: {len(items)} | Estimated: {human_size(total_bytes)}")
          print(f"Protected overrides: {len(protected_overrides)}")
          print(f"Mode: {'DRY-RUN' if args.dry_run else 'APPLY'}")
          print()
      
          if not args.dry_run:
              log_op("APPLY-START", "-", "-", "START")
      
          ok = skipped = failed = 0
      
          for idx, item in enumerate(items, 1):
              item_id = item.get("id", "?")
              label = item.get("label", item_id)
              path = item.get("path", "")
              command = item.get("command")
              size_bytes = item.get("size_bytes", 0) or 0
              protected = bool(item.get("protected", False))
              prefix = f"[{idx:>2}/{len(items)}]"
      
              if not command:
                  print(f"{prefix} SKIP (no command): {label}")
                  log_op("SKIP", path or item_id, human_size(size_bytes), "NO-COMMAND")
                  skipped += 1
                  continue
      
              valid, reason, needs_protection = validate_command(command, args.scan_root)
              if not valid:
                  print(f"{prefix} SKIP (validation: {reason}): {label}")
                  log_op("SKIP", path or item_id, human_size(size_bytes), f"VALIDATION-FAILED:{reason}")
                  skipped += 1
                  continue
      
              effective_protected = protected or needs_protection
              if effective_protected and item_id not in protected_overrides:
                  why = "protected" if protected else "irreversible-tool"
                  print(f"{prefix} SKIP ({why}, not overridden): {label}")
                  log_op("SKIP", path or item_id, human_size(size_bytes), "PROTECTED-NOT-OVERRIDDEN")
                  skipped += 1
                  continue
      
              flag = " [OVERRIDE]" if effective_protected else ""
              print(f"{prefix} {label}{flag} — {human_size(size_bytes)}")
              print(f"          -> {command}")
      
              if args.dry_run:
                  ok += 1
                  continue
      
              try:
                  proc = _run(command)
                  if proc.returncode == 0:
                      log_op("PROTECTED-OVERRIDE" if effective_protected else "REMOVED",
                             path or item_id, human_size(size_bytes), "OK")
                      ok += 1
                  else:
                      stderr_tail = (proc.stderr or "").strip()[-300:]
                      print(f"          FAIL exit={proc.returncode}: {stderr_tail}")
                      log_op("REMOVE", path or item_id, human_size(size_bytes),
                             f"FAIL-EXIT-{proc.returncode}")
                      failed += 1
                      if not args.continue_on_error:
                          print("Stopping on first error. Re-run with --continue-on-error to override.")
                          break
              except subprocess.TimeoutExpired:
                  print("          TIMEOUT after 900s")
                  log_op("REMOVE", path or item_id, human_size(size_bytes), "TIMEOUT")
                  failed += 1
              except Exception as exc:
                  print(f"          ERROR: {exc}")
                  log_op("REMOVE", path or item_id, human_size(size_bytes), f"ERROR:{exc}")
                  failed += 1
      
          print()
          print(f"=== Done: {ok} ok | {skipped} skipped | {failed} failed ===")
          if not args.dry_run:
              log_op("APPLY-END", "-", "-", f"OK={ok} SKIP={skipped} FAIL={failed}")
          return 0 if failed == 0 else 2
      
      
      if __name__ == "__main__":
          raise SystemExit(main(sys.argv))
      
    • Audit-WinHealth.ps1 7.1 KB · in bundle
    • Install-WinHealthCheck.ps1 6.6 KB · in bundle
    • render-cleanup-plan.py 45.1 KB
      #!/usr/bin/env python3
      """
      render-cleanup-plan.py — generate an interactive HTML cleanup-plan UI,
      serve it on 127.0.0.1, open it in the default browser, and wait for the
      user's selection. Designed to be invoked by the maintaining-macos-health
      skill after a scan.
      
      Usage:
          render-cleanup-plan.py <data.json>
      
      <data.json> schema:
          {
            "baseline": {
              "container_free_gb": 117.2,
              "container_total_gb": 460,
              "container_used_gb": 329,
              "uptime": "...",
              "memory_free_pct": 40
            },
            "categories": [
              {
                "id": "tier-1-3",
                "title": "Tier 1-3 — Безрисковые кэши",
                "subtitle": "Регенерируются на следующем билде",
                "tier": 1,
                "default_open": true,
                "items": [
                  {
                    "id": "uniq-stable-id",
                    "label": "User app cache (mo clean)",
                    "path": "(via mo clean)",
                    "size_bytes": 8723000000,
                    "age_days": null,
                    "kind": "cache",
                    "command": "mo clean --confirm",
                    "protected": false,
                    "warning": null,
                    "default_selected": true
                  }, ...
                ]
              }, ...
            ]
          }
      
      Selection JSON written to <tempdir>/cleanup-selection-<ts>.json on submit
      (tempdir = %TEMP% on Windows, /tmp on macOS/Linux — tempfile.gettempdir()):
          {
            "timestamp": "2026-05-22T18:40:00",
            "selected_ids": ["...", "..."],
            "selected_items": [<full item objects>],
            "totals": {"count": 12, "size_bytes": 25000000000},
            "protected_overrides": ["id1", ...]  # protected items the user explicitly opted in
          }
      
      Stdout: path to selection JSON when submit succeeds (or "CANCELLED\\n").
      Exit code: 0 on submit, 1 on cancel/timeout/error.
      """
      from __future__ import annotations
      
      import html
      import http.server
      import json
      import os
      import socketserver
      import sys
      import tempfile
      import threading
      import time
      import webbrowser
      from datetime import datetime
      from pathlib import Path
      from typing import Any
      
      PORT = 18347
      HOST = "127.0.0.1"
      SUBMIT_TIMEOUT_SEC = 60 * 60  # 1 hour — user may take their time
      
      
      def human_size(num_bytes: int) -> str:
          if num_bytes is None:
              return "—"
          for unit in ("B", "KB", "MB", "GB", "TB"):
              if abs(num_bytes) < 1024.0:
                  return f"{num_bytes:3.1f} {unit}" if unit != "B" else f"{int(num_bytes)} {unit}"
              num_bytes /= 1024.0
          return f"{num_bytes:.1f} PB"
      
      
      def size_class(num_bytes: int) -> str:
          if num_bytes is None:
              return "size-unknown"
          gb = num_bytes / (1024**3)
          if gb >= 5:
              return "size-xl"
          if gb >= 1:
              return "size-lg"
          if gb >= 0.1:
              return "size-md"
          return "size-sm"
      
      
      def render_html(data: dict[str, Any]) -> str:
          baseline = data.get("baseline", {})
          container_free_gb = baseline.get("container_free_gb")
          container_total_gb = baseline.get("container_total_gb")
          container_used_gb = baseline.get("container_used_gb")
      
          pct_free = (container_free_gb / container_total_gb * 100) if container_total_gb else 0
      
          categories_html_parts: list[str] = []
          total_candidates_bytes = 0
          total_items_count = 0
      
          for cat in data.get("categories", []):
              cat_id = html.escape(cat["id"])
              cat_title = html.escape(cat["title"])
              cat_subtitle = html.escape(cat.get("subtitle", ""))
              default_open = "open" if cat.get("default_open", False) else ""
              tier = cat.get("tier", "")
      
              items_html: list[str] = []
              cat_bytes = 0
              cat_count = 0
              # Sort items inside each category by size descending — largest first.
              sorted_items = sorted(
                  cat.get("items", []),
                  key=lambda it: -(it.get("size_bytes") or 0),
              )
              for item in sorted_items:
                  item_id = html.escape(item["id"])
                  label = html.escape(item["label"])
                  path = html.escape(item.get("path", ""))
                  size_bytes = item.get("size_bytes", 0) or 0
                  age_days = item.get("age_days")
                  kind = html.escape(item.get("kind", ""))
                  command = item.get("command")
                  protected = bool(item.get("protected", False))
                  warning = item.get("warning")
                  default_selected = bool(item.get("default_selected", not protected))
      
                  cat_bytes += size_bytes
                  cat_count += 1
                  total_candidates_bytes += size_bytes
                  total_items_count += 1
      
                  sz_label = human_size(size_bytes)
                  sz_cls = size_class(size_bytes)
                  age_label = f"{age_days}d" if age_days is not None else ""
                  row_classes = ["item-row"]
                  if protected:
                      row_classes.append("protected")
                  checked = "checked" if default_selected else ""
                  data_attrs = (
                      f'data-id="{item_id}" '
                      f'data-size="{size_bytes}" '
                      f'data-protected="{int(protected)}" '
                  )
                  warning_attr = (
                      f' data-warning="{html.escape(warning, quote=True)}"'
                      if warning
                      else ""
                  )
      
                  # Build a structured tooltip payload (JSON-encoded into a data attribute)
                  tooltip_payload = {
                      "label": item["label"],
                      "description": item.get("description", ""),
                      "path": item.get("path", ""),
                      "size": sz_label,
                      "kind": kind,
                      "age": (f"{age_days} days" if age_days is not None else None),
                      "command": command,
                      "warning": warning,
                      "protected": protected,
                  }
                  tooltip_attr = html.escape(json.dumps(tooltip_payload, ensure_ascii=False), quote=True)
      
                  badge_html = (
                      '<span class="badge badge-protected" aria-label="protected">🔒</span>'
                      if protected
                      else ""
                  )
      
                  items_html.append(
                      f'''
                      <label class="{' '.join(row_classes)}" data-tooltip="{tooltip_attr}"{warning_attr}>
                        <input type="checkbox" class="item-cb" {checked} {data_attrs}>
                        <span class="size {sz_cls}">{sz_label}</span>
                        <span class="age">{age_label}</span>
                        <span class="kind">{kind}</span>
                        <div class="item-main">
                          <div class="label">{label}{badge_html}</div>
                          <div class="path">{path}</div>
                        </div>
                      </label>
                      '''
                  )
      
              category_total = human_size(cat_bytes)
              tier_str = str(tier)
              if tier_str in ("1-3",):
                  tier_cls = "cat-tier-safe"
              elif tier_str in ("7", "5", "8"):
                  tier_cls = "cat-tier-medium"
              elif tier_str == "10":
                  tier_cls = "cat-tier-careful"
              elif tier_str == "P":
                  tier_cls = "cat-tier-protected"
              else:
                  tier_cls = ""
              categories_html_parts.append(
                  f'''
                  <details class="category" {default_open} data-cat="{cat_id}" data-cat-total-bytes="{cat_bytes}" data-cat-total-count="{cat_count}">
                    <summary>
                      <span class="cat-toggle">▸</span>
                      <span class="cat-tier {tier_cls}">T{tier}</span>
                      <span class="cat-title">{cat_title}</span>
                      <span class="cat-subtitle">{cat_subtitle}</span>
                      <span class="cat-stats">
                        <span class="cat-sel-size">0 B</span><span class="sep">/</span><span class="cat-total-size">{category_total}</span>
                        <span class="dot">·</span>
                        <span class="cat-sel-count">0</span><span class="sep">/</span><span class="cat-total-count">{cat_count}</span> items
                      </span>
                      <label class="select-all-wrap" onclick="event.stopPropagation()">
                        <input type="checkbox" class="select-all" data-cat="{cat_id}">
                        <span class="select-all-label">all</span>
                      </label>
                    </summary>
                    <div class="category-body">
                      {''.join(items_html)}
                    </div>
                  </details>
                  '''
              )
      
          categories_html = "\n".join(categories_html_parts)
          total_candidates_label = human_size(total_candidates_bytes)
          generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
      
          container_free_bytes = int((container_free_gb or 0) * (1024 ** 3))
          container_total_bytes = int((container_total_gb or 0) * (1024 ** 3))
          return HTML_TEMPLATE.format(
              container_free_gb=f"{container_free_gb:.1f}" if container_free_gb else "—",
              container_total_gb=f"{container_total_gb:.0f}" if container_total_gb else "—",
              container_used_gb=f"{container_used_gb:.0f}" if container_used_gb else "—",
              container_free_bytes=container_free_bytes,
              container_total_bytes=container_total_bytes,
              pct_free=f"{pct_free:.0f}",
              total_candidates_label=total_candidates_label,
              total_items_count=total_items_count,
              categories_html=categories_html,
              generated_at=generated_at,
          )
      
      
      HTML_TEMPLATE = r"""<!doctype html>
      <html lang="en" data-theme="auto">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>macOS cleanup plan</title>
        <style>
          :root {{
            --bg: #fafaf9;
            --fg: #1c1917;
            --muted: #78716c;
            --card: #ffffff;
            --border: #e7e5e4;
            --border-strong: #d6d3d1;
            --accent: #2563eb;
            --accent-fg: #ffffff;
            --warn: #c2410c;
            --warn-bg: #fff7ed;
            --danger: #b91c1c;
            --ok: #15803d;
            --shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);
            --size-sm: #16a34a;
            --size-md: #ca8a04;
            --size-lg: #ea580c;
            --size-xl: #dc2626;
          }}
          @media (prefers-color-scheme: dark) {{
            :root {{
              --bg: #18181b;
              --fg: #f4f4f5;
              --muted: #a1a1aa;
              --card: #27272a;
              --border: #3f3f46;
              --border-strong: #52525b;
              --accent: #60a5fa;
              --accent-fg: #0c0a09;
              --warn: #fb923c;
              --warn-bg: #431407;
              --danger: #f87171;
              --ok: #4ade80;
              --shadow: 0 1px 2px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.3);
            }}
          }}
          * {{ box-sizing: border-box; }}
          html, body {{ margin: 0; padding: 0; background: var(--bg); color: var(--fg); }}
          body {{
            font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
            font-size: 14px;
            line-height: 1.5;
            padding-bottom: 120px;
          }}
          header {{
            position: sticky;
            top: 0;
            z-index: 10;
            background: var(--bg);
            border-bottom: 1px solid var(--border);
            padding: 16px 24px;
            backdrop-filter: blur(8px);
          }}
          header h1 {{
            font-size: 18px;
            margin: 0 0 6px 0;
            font-weight: 600;
            letter-spacing: -0.01em;
          }}
          header .meta {{
            display: flex;
            gap: 16px;
            flex-wrap: wrap;
            font-size: 12px;
            color: var(--muted);
          }}
          header .meta strong {{ color: var(--fg); font-weight: 600; }}
          .container {{ max-width: 1100px; margin: 0 auto; padding: 24px; }}
          .category {{
            background: var(--card);
            border: 1px solid var(--border);
            border-left: 3px solid var(--border);
            border-radius: 10px;
            margin-bottom: 14px;
            box-shadow: var(--shadow);
            overflow: hidden;
            transition: border-left-color 0.15s;
          }}
          .category.has-selection {{ border-left-color: var(--accent); }}
          .category summary {{
            display: grid;
            grid-template-columns: 18px 56px minmax(180px, max-content) 1fr auto auto;
            grid-template-rows: auto auto;
            column-gap: 14px;
            row-gap: 4px;
            align-items: center;
            padding: 14px 18px;
            cursor: pointer;
            list-style: none;
            user-select: none;
          }}
          .category summary .cat-toggle,
          .category summary .cat-tier,
          .category summary .cat-title,
          .category summary .cat-stats,
          .category summary .select-all-wrap {{ grid-row: 1; }}
          .category summary .cat-subtitle {{
            grid-column: 3 / -1;
            grid-row: 2;
            margin-top: -2px;
          }}
          .category summary::-webkit-details-marker {{ display: none; }}
          .cat-toggle {{
            display: inline-flex;
            align-items: center;
            justify-content: center;
            transition: transform 0.15s;
            color: var(--fg);
            font-size: 14px;
            width: 18px;
            line-height: 1;
          }}
          .category[open] .cat-toggle {{ transform: rotate(90deg); }}
          .cat-tier {{
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 52px;
            height: 22px;
            padding: 0 10px;
            border-radius: 6px;
            background: var(--border);
            color: var(--muted);
            font-size: 11px;
            font-weight: 700;
            letter-spacing: 0.02em;
            white-space: nowrap;
          }}
          .cat-tier-safe {{ background: rgba(34,197,94,0.18); color: var(--ok); }}
          .cat-tier-medium {{ background: rgba(37,99,235,0.18); color: var(--accent); }}
          .cat-tier-careful {{ background: rgba(194,65,12,0.22); color: var(--warn); }}
          .cat-tier-protected {{ background: rgba(185,28,28,0.22); color: var(--danger); }}
          .cat-title {{
            font-weight: 600;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
          }}
          .cat-subtitle {{
            color: var(--muted);
            font-size: 12px;
            font-weight: 400;
            white-space: normal;
            line-height: 1.4;
          }}
          .cat-stats {{
            color: var(--muted);
            font-size: 12px;
            font-variant-numeric: tabular-nums;
            white-space: nowrap;
            display: inline-flex;
            align-items: baseline;
            gap: 4px;
          }}
          .cat-stats .cat-sel-size {{
            color: var(--accent);
            font-weight: 700;
            font-size: 13px;
          }}
          .cat-stats .cat-total-size,
          .cat-stats .cat-sel-count,
          .cat-stats .cat-total-count {{ color: var(--fg); font-weight: 500; }}
          .cat-stats .sep, .cat-stats .dot {{ color: var(--muted); margin: 0 2px; }}
          .category.empty-selection .cat-sel-size {{ color: var(--muted); font-weight: 500; }}
          .select-all-wrap {{
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 4px 10px;
            border-radius: 6px;
            background: var(--bg);
            font-size: 11px;
            color: var(--muted);
            cursor: pointer;
            white-space: nowrap;
          }}
          .select-all-wrap input {{ margin: 0; cursor: pointer; }}
          .actions .select-all-wrap {{
            padding: 10px 14px;
            border: 1px solid var(--border-strong);
            color: var(--fg);
            font-weight: 500;
          }}
          .category-body {{
            border-top: 1px solid var(--border);
            padding: 4px 0;
          }}
          .item-row {{
            display: grid;
            grid-template-columns: 22px 78px 44px 110px 1fr;
            column-gap: 12px;
            align-items: center;
            padding: 8px 18px;
            cursor: pointer;
            border-radius: 6px;
            margin: 2px 8px;
            transition: background 0.1s;
            min-height: 44px;
          }}
          .item-row:hover {{ background: var(--bg); }}
          .item-row.protected {{ opacity: 0.6; }}
          .item-row.protected:hover {{ opacity: 0.95; }}
          .item-row input[type="checkbox"] {{ margin: 0; cursor: pointer; transform: scale(1.1); }}
          .item-row .size {{
            font-variant-numeric: tabular-nums;
            font-weight: 600;
            text-align: right;
            font-size: 12px;
          }}
          .size-sm {{ color: var(--size-sm); }}
          .size-md {{ color: var(--size-md); }}
          .size-lg {{ color: var(--size-lg); }}
          .size-xl {{ color: var(--size-xl); }}
          .size-unknown {{ color: var(--muted); }}
          .item-row .age {{
            font-size: 11px;
            color: var(--muted);
            font-variant-numeric: tabular-nums;
            text-align: right;
          }}
          .item-row .kind {{
            font-size: 11px;
            color: var(--muted);
            padding: 2px 6px;
            background: var(--bg);
            border-radius: 4px;
            text-align: center;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
          }}
          .item-main {{
            min-width: 0;
            display: flex;
            flex-direction: column;
            gap: 2px;
          }}
          .item-main .label {{
            font-weight: 500;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            display: flex;
            align-items: center;
            gap: 6px;
          }}
          .item-main .path {{
            font-family: "SF Mono", "Menlo", "Consolas", monospace;
            font-size: 11px;
            color: var(--muted);
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
            direction: ltr;
          }}
          .badge {{
            display: inline-flex;
            align-items: center;
            font-size: 10px;
            padding: 1px 5px;
            border-radius: 4px;
            font-weight: 600;
            letter-spacing: 0.02em;
            line-height: 1;
          }}
          .badge-protected {{
            background: var(--warn-bg);
            color: var(--warn);
            border: 1px solid var(--warn);
          }}
          footer {{
            position: fixed;
            bottom: 0;
            left: 0;
            right: 0;
            background: var(--card);
            border-top: 1px solid var(--border-strong);
            padding: 14px 24px;
            box-shadow: 0 -4px 12px rgba(0,0,0,0.06);
            z-index: 100;
          }}
          footer .inner {{
            max-width: 1100px;
            margin: 0 auto;
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 16px;
          }}
          .summary-stats {{
            display: flex;
            gap: 28px;
            align-items: baseline;
          }}
          .summary-stats .big {{
            font-size: 22px;
            font-weight: 700;
            font-variant-numeric: tabular-nums;
            letter-spacing: -0.02em;
          }}
          .summary-stats .big.accent {{ color: var(--accent); }}
          .summary-stats .label-sub {{
            color: var(--muted);
            font-size: 12px;
            margin-left: 4px;
          }}
          .summary-stats .muted-dot {{ margin: 0 4px; }}
          .summary-stats .after-preview {{
            display: flex;
            align-items: baseline;
            gap: 4px;
            padding: 6px 14px;
            background: var(--bg);
            border: 1px solid var(--border);
            border-radius: 8px;
          }}
          .summary-stats .after-preview .big {{
            color: var(--ok);
            font-size: 20px;
          }}
          .summary-stats .protected-count {{ color: var(--warn); }}
          .actions button {{
            font: inherit;
            padding: 10px 18px;
            border: 1px solid var(--border-strong);
            border-radius: 8px;
            background: var(--card);
            color: var(--fg);
            cursor: pointer;
            font-weight: 500;
            transition: all 0.1s;
          }}
          .actions button:hover {{ background: var(--bg); }}
          .actions button.primary {{
            background: var(--accent);
            color: var(--accent-fg);
            border-color: var(--accent);
            font-weight: 600;
            margin-left: 8px;
          }}
          .actions button.primary:hover {{ filter: brightness(1.1); }}
          .actions button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
          #done-screen {{
            display: none;
            position: fixed;
            inset: 0;
            background: var(--bg);
            align-items: center;
            justify-content: center;
            flex-direction: column;
            gap: 16px;
            text-align: center;
            padding: 24px;
            z-index: 1000;
          }}
          #done-screen.shown {{ display: flex; }}
          #done-screen .check {{
            font-size: 48px;
            width: 80px;
            height: 80px;
            border-radius: 50%;
            background: var(--ok);
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            font-weight: bold;
          }}
          #done-screen h2 {{ margin: 0; font-size: 22px; }}
          #done-screen p {{ color: var(--muted); margin: 0; max-width: 480px; }}
          #done-screen .done-stats {{
            display: flex;
            gap: 28px;
            align-items: baseline;
            background: var(--card);
            border: 1px solid var(--border);
            border-radius: 10px;
            padding: 16px 22px;
            box-shadow: var(--shadow);
          }}
          #done-screen .done-stats .stat-block {{
            display: flex;
            flex-direction: column;
            align-items: flex-start;
            gap: 2px;
          }}
          #done-screen .done-stats .stat-num {{
            font-size: 22px;
            font-weight: 700;
            letter-spacing: -0.02em;
            font-variant-numeric: tabular-nums;
          }}
          #done-screen .done-stats .stat-num.accent {{ color: var(--accent); }}
          #done-screen .done-stats .stat-num.ok {{ color: var(--ok); }}
          #done-screen .done-stats .stat-label {{
            color: var(--muted);
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: 0.04em;
          }}
          #done-screen code {{
            background: var(--card);
            padding: 4px 8px;
            border-radius: 4px;
            font-family: "SF Mono", monospace;
            font-size: 12px;
          }}
          .toast {{
            position: fixed;
            top: 24px;
            right: 24px;
            background: var(--danger);
            color: white;
            padding: 12px 16px;
            border-radius: 8px;
            box-shadow: var(--shadow);
            z-index: 200;
            opacity: 0;
            transform: translateY(-10px);
            transition: all 0.2s;
            pointer-events: none;
          }}
          .toast.shown {{ opacity: 1; transform: translateY(0); }}
          #tt {{
            position: fixed;
            z-index: 200;
            max-width: 520px;
            min-width: 280px;
            background: var(--card);
            color: var(--fg);
            border: 1px solid var(--border-strong);
            border-radius: 8px;
            padding: 12px 14px;
            box-shadow: 0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12);
            font-size: 12px;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.08s ease-out;
            display: none;
          }}
          #tt.shown {{ opacity: 1; display: block; }}
          #tt .tt-title {{
            font-weight: 600;
            font-size: 13px;
            margin: 0 0 6px 0;
            letter-spacing: -0.01em;
          }}
          #tt .tt-desc {{
            color: var(--fg);
            font-size: 12px;
            line-height: 1.5;
            margin: 0 0 10px 0;
            padding: 8px 10px;
            background: var(--bg);
            border-radius: 6px;
            border-left: 3px solid var(--accent);
          }}
          #tt .tt-row {{
            display: grid;
            grid-template-columns: 78px 1fr;
            gap: 8px;
            align-items: baseline;
            padding: 2px 0;
          }}
          #tt .tt-label {{
            color: var(--muted);
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: 0.04em;
          }}
          #tt .tt-value {{
            font-family: "SF Mono", "Menlo", "Consolas", monospace;
            font-size: 11.5px;
            word-break: break-all;
            white-space: pre-wrap;
          }}
          #tt .tt-value.tt-mono-strong {{
            color: var(--fg);
            font-weight: 500;
          }}
          #tt .tt-warning {{
            margin-top: 8px;
            padding: 8px 10px;
            border-radius: 6px;
            background: var(--warn-bg);
            color: var(--warn);
            border: 1px solid var(--warn);
            font-size: 11.5px;
            line-height: 1.45;
          }}
          #tt .tt-protected {{
            margin-top: 8px;
            padding: 6px 10px;
            border-radius: 6px;
            background: var(--warn-bg);
            color: var(--warn);
            font-weight: 600;
            font-size: 11px;
            letter-spacing: 0.02em;
          }}
          @media (max-width: 720px) {{
            .item-row {{ grid-template-columns: 22px 60px 1fr; }}
            .item-row .age, .item-row .kind {{ display: none; }}
            .item-main .path {{ display: none; }}
            .category summary {{ grid-template-columns: 18px 52px 1fr auto; }}
            .cat-subtitle, .cat-stats {{ display: none; }}
          }}
          @media (prefers-reduced-motion: reduce) {{
            *, *::before, *::after {{
              transition-duration: 0.01ms !important;
              animation-duration: 0.01ms !important;
              animation-iteration-count: 1 !important;
            }}
          }}
        </style>
      </head>
      <body>
        <header>
          <h1>🧹 Windows cleanup plan</h1>
          <div class="meta">
            <span><strong>{container_free_gb} GB</strong> free <span class="label-sub">/ {container_total_gb} GB · {pct_free}%</span></span>
            <span><strong>{container_used_gb} GB</strong> used</span>
            <span><strong>{total_items_count}</strong> candidate items · <strong>{total_candidates_label}</strong> total</span>
            <span class="label-sub">Generated {generated_at}</span>
          </div>
        </header>
      
        <main class="container">
          {categories_html}
        </main>
      
        <footer>
          <div class="inner">
            <div class="summary-stats">
              <div>
                <span class="big accent" id="sel-size">0 B</span>
                <span class="label-sub">selected</span>
                <span class="label-sub muted-dot">·</span>
                <span class="label-sub"><span id="sel-count">0</span> items</span>
              </div>
              <div class="after-preview" id="after-preview">
                <span class="label-sub">after cleanup →</span>
                <span class="big" id="after-free">{container_free_gb} GB</span>
                <span class="label-sub">free (<span id="after-pct">{pct_free}</span>%)</span>
              </div>
              <div id="protected-warning" style="display:none">
                <span class="big protected-count" id="sel-protected">0</span>
                <span class="label-sub">protected ⚠</span>
              </div>
            </div>
            <div class="actions">
              <label class="select-all-wrap">
                <input type="checkbox" id="select-all-global">
                <span class="select-all-label">all files</span>
              </label>
              <button id="cancel-btn" type="button">Cancel</button>
              <button id="submit-btn" class="primary" type="button" disabled>Submit plan →</button>
            </div>
          </div>
        </footer>
      
        <div id="done-screen">
          <div class="check">✓</div>
          <h2>Plan submitted</h2>
          <p id="done-summary">You can close this window. Return to the terminal — the agent will show your selection and ask for confirmation before applying any changes.</p>
          <div id="done-stats" class="done-stats"></div>
          <code id="done-path"></code>
        </div>
      
        <div class="toast" id="toast"></div>
      
        <div id="tt" role="tooltip"></div>
      
        <script>
          const allItems = document.querySelectorAll('.item-cb');
          const selSize = document.getElementById('sel-size');
          const selCount = document.getElementById('sel-count');
          const selProtected = document.getElementById('sel-protected');
          const protectedWarning = document.getElementById('protected-warning');
          const selectAllGlobal = document.getElementById('select-all-global');
          const submitBtn = document.getElementById('submit-btn');
          const cancelBtn = document.getElementById('cancel-btn');
          const doneScreen = document.getElementById('done-screen');
          const donePath = document.getElementById('done-path');
          const toast = document.getElementById('toast');
      
          function humanSize(bytes) {{
            const units = ['B', 'KB', 'MB', 'GB', 'TB'];
            let n = bytes, i = 0;
            while (n >= 1024 && i < units.length - 1) {{ n /= 1024; i++; }}
            return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i];
          }}
      
          function showToast(msg, ms) {{
            toast.textContent = msg;
            toast.classList.add('shown');
            setTimeout(() => toast.classList.remove('shown'), ms || 3000);
          }}
      
          const BASELINE_FREE_BYTES = {container_free_bytes};
          const BASELINE_TOTAL_BYTES = {container_total_bytes};
          const afterFree = document.getElementById('after-free');
          const afterPct = document.getElementById('after-pct');
          function updateTotals() {{
            let totalBytes = 0, count = 0, protectedCount = 0;
            allItems.forEach(cb => {{
              if (cb.checked) {{
                count++;
                totalBytes += parseInt(cb.dataset.size, 10) || 0;
                if (cb.dataset.protected === '1') protectedCount++;
              }}
            }});
            selSize.textContent = humanSize(totalBytes);
            selCount.textContent = count;
            submitBtn.disabled = count === 0;
      
            // After-cleanup preview: free + selected, capped at total
            const afterBytes = Math.min(BASELINE_FREE_BYTES + totalBytes, BASELINE_TOTAL_BYTES);
            const afterGb = afterBytes / (1024 ** 3);
            const afterPctVal = (afterBytes / BASELINE_TOTAL_BYTES) * 100;
            if (afterFree) afterFree.textContent = afterGb.toFixed(1) + ' GB';
            if (afterPct) afterPct.textContent = afterPctVal.toFixed(0);
      
            if (protectedCount > 0) {{
              protectedWarning.style.display = '';
              selProtected.textContent = protectedCount;
            }} else {{
              protectedWarning.style.display = 'none';
            }}
            // category counters + per-category live size
            document.querySelectorAll('.category').forEach(cat => {{
              const items = cat.querySelectorAll('.item-cb');
              const checked = cat.querySelectorAll('.item-cb:checked');
              let catBytes = 0;
              checked.forEach(cb => {{ catBytes += parseInt(cb.dataset.size, 10) || 0; }});
              const selSizeEl = cat.querySelector('.cat-sel-size');
              const selCountEl = cat.querySelector('.cat-sel-count');
              if (selSizeEl) selSizeEl.textContent = humanSize(catBytes);
              if (selCountEl) selCountEl.textContent = checked.length;
              cat.classList.toggle('empty-selection', checked.length === 0);
              cat.classList.toggle('has-selection', checked.length > 0);
              const selectAll = cat.querySelector('.select-all');
              if (selectAll) {{
                selectAll.checked = items.length > 0 && items.length === checked.length;
                selectAll.indeterminate = checked.length > 0 && checked.length < items.length;
              }}
            }});
            if (selectAllGlobal) {{
              selectAllGlobal.checked = allItems.length > 0 && count === allItems.length;
              selectAllGlobal.indeterminate = count > 0 && count < allItems.length;
            }}
          }}
      
          // Esc → cancel (peak-end / keyboard-friendly)
          document.addEventListener('keydown', (ev) => {{
            if (ev.key === 'Escape' && !doneScreen.classList.contains('shown')) {{
              ev.preventDefault();
              cancelBtn.click();
            }}
          }});
      
          allItems.forEach(cb => {{
            cb.addEventListener('change', (ev) => {{
              const row = cb.closest('.item-row');
              if (cb.checked && cb.dataset.protected === '1') {{
                const warning = row.dataset.warning || 'This item is protected. Deleting it can lose user data or break apps.';
                if (!confirm('⚠️ PROTECTED ITEM\\n\\n' + warning + '\\n\\nAre you sure you want to include this?')) {{
                  cb.checked = false;
                }}
              }}
              updateTotals();
            }});
          }});
      
          function confirmProtectedBulk(scopeLabel, protectedCount) {{
            if (protectedCount === 0) return true;
            return confirm(
              '⚠️ PROTECTED ITEMS\\n\\n' +
              scopeLabel + ' includes ' + protectedCount + ' protected item(s). ' +
              'These may include user data, synced files, dumps, models, VM disks, or irreversible system actions.\\n\\n' +
              'Select them anyway?'
            );
          }}
      
          function setItemsChecked(items, checked, scopeLabel) {{
            const itemList = Array.from(items);
            const protectedCount = itemList.filter(cb => cb.dataset.protected === '1').length;
            if (checked && !confirmProtectedBulk(scopeLabel, protectedCount)) {{
              return false;
            }}
            itemList.forEach(cb => {{ cb.checked = checked; }});
            updateTotals();
            if (checked && protectedCount > 0) {{
              showToast('Selected ' + protectedCount + ' protected item(s). Review before Submit.', 5000);
            }}
            return true;
          }}
      
          document.querySelectorAll('.select-all').forEach(sa => {{
            sa.addEventListener('change', (ev) => {{
              ev.stopPropagation();
              const catId = sa.dataset.cat;
              const cat = document.querySelector('.category[data-cat="' + catId + '"]');
              const items = cat.querySelectorAll('.item-cb');
              if (!setItemsChecked(items, sa.checked, 'This category')) {{
                updateTotals();
              }}
            }});
          }});
      
          selectAllGlobal.addEventListener('change', () => {{
            if (!setItemsChecked(allItems, selectAllGlobal.checked, 'The full cleanup plan')) {{
              updateTotals();
            }}
          }});
      
          cancelBtn.addEventListener('click', async () => {{
            if (!confirm('Cancel and close? No cleanup will be performed.')) return;
            try {{
              await fetch('/cancel', {{ method: 'POST' }});
            }} catch (e) {{}}
            doneScreen.querySelector('.check').textContent = '×';
            doneScreen.querySelector('.check').style.background = 'var(--muted)';
            doneScreen.querySelector('h2').textContent = 'Cancelled';
            doneScreen.querySelector('p').textContent = 'No changes were made. You can close this window.';
            donePath.style.display = 'none';
            doneScreen.classList.add('shown');
          }});
      
          submitBtn.addEventListener('click', async () => {{
            const selected = [];
            const protectedOverrides = [];
            let totalBytes = 0;
            allItems.forEach(cb => {{
              if (cb.checked) {{
                selected.push(cb.dataset.id);
                totalBytes += parseInt(cb.dataset.size, 10) || 0;
                if (cb.dataset.protected === '1') protectedOverrides.push(cb.dataset.id);
              }}
            }});
            if (selected.length === 0) {{ showToast('Nothing selected.'); return; }}
            const payload = {{
              selected_ids: selected,
              protected_overrides: protectedOverrides,
              totals: {{ count: selected.length, size_bytes: totalBytes }}
            }};
            submitBtn.disabled = true;
            submitBtn.textContent = 'Sending…';
            try {{
              const resp = await fetch('/submit', {{
                method: 'POST',
                headers: {{ 'Content-Type': 'application/json' }},
                body: JSON.stringify(payload)
              }});
              if (!resp.ok) throw new Error('HTTP ' + resp.status);
              const result = await resp.json();
              donePath.textContent = result.path || '';
              const afterBytes = Math.min(BASELINE_FREE_BYTES + totalBytes, BASELINE_TOTAL_BYTES);
              const afterGb = (afterBytes / (1024 ** 3)).toFixed(1);
              const afterPctVal = ((afterBytes / BASELINE_TOTAL_BYTES) * 100).toFixed(0);
              document.getElementById('done-stats').innerHTML =
                '<div class="stat-block"><span class="stat-num accent">' + humanSize(totalBytes) + '</span><span class="stat-label">to be freed</span></div>' +
                '<div class="stat-block"><span class="stat-num">' + selected.length + '</span><span class="stat-label">items</span></div>' +
                '<div class="stat-block"><span class="stat-num ok">' + afterGb + ' GB</span><span class="stat-label">free after (' + afterPctVal + '%)</span></div>' +
                (protectedOverrides.length > 0
                  ? '<div class="stat-block"><span class="stat-num" style="color:var(--warn)">' + protectedOverrides.length + ' ⚠</span><span class="stat-label">protected overrides</span></div>'
                  : '');
              doneScreen.classList.add('shown');
            }} catch (e) {{
              showToast('Submit failed: ' + e.message, 6000);
              submitBtn.disabled = false;
              submitBtn.textContent = 'Submit plan →';
            }}
          }});
      
          updateTotals();
      
          // -----------------------------------------------------------------------
          // Custom tooltip — multi-line, structured, instant
          // -----------------------------------------------------------------------
          const tt = document.getElementById('tt');
          function escapeHtml(s) {{
            return String(s == null ? '' : s)
              .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
              .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
          }}
          function renderTooltip(payload) {{
            const descHtml = payload.description
              ? '<div class="tt-desc">' + escapeHtml(payload.description) + '</div>'
              : '';
            const rows = [];
            if (payload.path)    rows.push(['Path',    payload.path,    'tt-mono-strong']);
            if (payload.kind)    rows.push(['Kind',    payload.kind]);
            if (payload.size)    rows.push(['Size',    payload.size]);
            if (payload.age)     rows.push(['Modified', payload.age + ' ago']);
            if (payload.command) rows.push(['Command', payload.command, 'tt-mono-strong']);
            const rowsHtml = rows.map(([l, v, cls]) =>
              '<div class="tt-row"><div class="tt-label">' + escapeHtml(l) +
              '</div><div class="tt-value ' + (cls || '') + '">' + escapeHtml(v) + '</div></div>'
            ).join('');
            const warnHtml = payload.warning
              ? '<div class="tt-warning">⚠ ' + escapeHtml(payload.warning) + '</div>'
              : '';
            const protHtml = payload.protected
              ? '<div class="tt-protected">🔒 PROTECTED — checking requires explicit confirm</div>'
              : '';
            return '<div class="tt-title">' + escapeHtml(payload.label || '') + '</div>' +
                   descHtml + rowsHtml + warnHtml + protHtml;
          }}
          function positionTooltip(clientX, clientY) {{
            const margin = 14;
            const rect = tt.getBoundingClientRect();
            const vw = window.innerWidth, vh = window.innerHeight;
            let x = clientX + 18;
            let y = clientY + 18;
            if (x + rect.width + margin > vw) x = Math.max(margin, clientX - rect.width - 18);
            if (y + rect.height + margin > vh) y = Math.max(margin, clientY - rect.height - 18);
            tt.style.left = x + 'px';
            tt.style.top = y + 'px';
          }}
          function showTooltipFromEvent(target, ev) {{
            const raw = target.getAttribute('data-tooltip');
            if (!raw) return;
            let payload;
            try {{ payload = JSON.parse(raw); }} catch (e) {{ return; }}
            tt.innerHTML = renderTooltip(payload);
            tt.classList.add('shown');
            positionTooltip(ev.clientX, ev.clientY);
          }}
          function hideTooltip() {{ tt.classList.remove('shown'); }}
          document.addEventListener('mouseover', (ev) => {{
            const target = ev.target.closest('[data-tooltip]');
            if (target) showTooltipFromEvent(target, ev);
          }});
          document.addEventListener('mousemove', (ev) => {{
            if (!tt.classList.contains('shown')) return;
            const target = ev.target.closest('[data-tooltip]');
            if (!target) {{ hideTooltip(); return; }}
            positionTooltip(ev.clientX, ev.clientY);
          }});
          document.addEventListener('mouseout', (ev) => {{
            if (!ev.relatedTarget || !ev.relatedTarget.closest('[data-tooltip]')) hideTooltip();
          }});
          document.addEventListener('scroll', hideTooltip, true);
        </script>
      </body>
      </html>
      """
      
      
      class CleanupServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
          """ThreadingMixIn so requests don't block each other; daemon_threads so the process
          can exit cleanly even if a request thread is mid-flight."""
          allow_reuse_address = True
          daemon_threads = True
      
          def __init__(self, *args, **kwargs):
              super().__init__(*args, **kwargs)
              self.submission: dict | None = None
              self.cancelled = False
              self.shutdown_event = threading.Event()
      
      
      def make_handler(html_content: str, data_categories: list[dict]):
          # index items by id for resolving selection back to full objects
          id_to_item: dict[str, dict] = {}
          id_to_category: dict[str, str] = {}
          for cat in data_categories:
              for item in cat.get("items", []):
                  id_to_item[item["id"]] = item
                  id_to_category[item["id"]] = cat["id"]
      
          class Handler(http.server.BaseHTTPRequestHandler):
              def log_message(self, fmt, *args):
                  pass  # silence stderr
      
              def do_GET(self):
                  if self.path in ("/", "/index.html"):
                      body = html_content.encode("utf-8")
                      self.send_response(200)
                      self.send_header("Content-Type", "text/html; charset=utf-8")
                      self.send_header("Content-Length", str(len(body)))
                      self.end_headers()
                      self.wfile.write(body)
                  elif self.path == "/health":
                      self.send_response(200)
                      self.send_header("Content-Type", "text/plain")
                      self.end_headers()
                      self.wfile.write(b"ok")
                  else:
                      self.send_error(404)
      
              def do_POST(self):
                  try:
                      length = int(self.headers.get("Content-Length", "0"))
                      raw = self.rfile.read(length) if length else b""
                  except Exception as exc:
                      self.send_error(400, f"bad request: {exc}")
                      return
      
                  if self.path == "/cancel":
                      self.server.cancelled = True
                      try:
                          self.send_response(200)
                          self.send_header("Content-Type", "application/json")
                          self.send_header("Connection", "close")
                          self.end_headers()
                          self.wfile.write(b'{"ok":true}')
                          self.wfile.flush()
                      except Exception:
                          pass
                      # Signal main thread to exit. ThreadingMixIn + daemon_threads means
                      # serve_forever() is still running on another thread; setting the event
                      # lets main wake up, call server.shutdown(), and the loop ends cleanly.
                      self.server.shutdown_event.set()
                      return
      
                  if self.path == "/submit":
                      print(f"[render-cleanup-plan] received /submit ({len(raw)} bytes)", file=sys.stderr)
                      try:
                          payload = json.loads(raw.decode("utf-8")) if raw else {}
                      except Exception as exc:
                          self.send_error(400, f"bad json: {exc}")
                          return
                      try:
                          selected_ids = payload.get("selected_ids", []) or []
                          protected_overrides = payload.get("protected_overrides", []) or []
                          selected_items = [
                              {**id_to_item[i], "category_id": id_to_category.get(i, "")}
                              for i in selected_ids
                              if i in id_to_item
                          ]
                          total_bytes = sum(it.get("size_bytes", 0) or 0 for it in selected_items)
                          selection = {
                              "timestamp": datetime.now().isoformat(),
                              "selected_ids": selected_ids,
                              "selected_items": selected_items,
                              "protected_overrides": protected_overrides,
                              "totals": {"count": len(selected_items), "size_bytes": total_bytes},
                          }
                          ts = datetime.now().strftime("%Y%m%d-%H%M%S")
                          # Cross-platform temp dir: /tmp on macOS/Linux, %TEMP% on Windows.
                          out_path = Path(tempfile.gettempdir()) / f"cleanup-selection-{ts}.json"
                          out_path.write_text(json.dumps(selection, indent=2, ensure_ascii=False), encoding="utf-8")
                          self.server.submission = {"selection": selection, "path": str(out_path)}
                          body = json.dumps({"ok": True, "path": str(out_path)}).encode("utf-8")
                          self.send_response(200)
                          self.send_header("Content-Type", "application/json")
                          self.send_header("Content-Length", str(len(body)))
                          self.send_header("Connection", "close")
                          self.end_headers()
                          self.wfile.write(body)
                          self.wfile.flush()
                      except Exception as exc:
                          print(f"[render-cleanup-plan] /submit handler error: {exc}", file=sys.stderr)
                          try:
                              self.send_error(500, f"server error: {exc}")
                          except Exception:
                              pass
                          return
                      # Now signal main to exit. No sleep needed — ThreadingMixIn means the
                      # response has been written and flushed before this point.
                      self.server.shutdown_event.set()
                      return
      
                  self.send_error(404)
      
          return Handler
      
      
      def main(argv: list[str]) -> int:
          if len(argv) < 2:
              print("usage: render-cleanup-plan.py <data.json>", file=sys.stderr)
              return 2
          data_path = Path(argv[1])
          if not data_path.is_file():
              print(f"data file not found: {data_path}", file=sys.stderr)
              return 2
          data = json.loads(data_path.read_text(encoding="utf-8-sig"))
          html_content = render_html(data)
      
          handler = make_handler(html_content, data.get("categories", []))
          try:
              server = CleanupServer((HOST, PORT), handler)
          except OSError as exc:
              print(f"failed to bind {HOST}:{PORT}: {exc}", file=sys.stderr)
              return 1
      
          server_thread = threading.Thread(target=server.serve_forever, daemon=True)
          server_thread.start()
      
          url = f"http://{HOST}:{PORT}/"
          # eprint, not stdout — stdout is reserved for the final selection path
          print(f"[render-cleanup-plan] serving at {url}", file=sys.stderr)
          print(f"[render-cleanup-plan] waiting for user selection (timeout {SUBMIT_TIMEOUT_SEC}s)…", file=sys.stderr)
      
          # macOS: `open` works for arbitrary URLs and respects the user's default browser
          try:
              if sys.platform == "darwin":
                  os.system(f"open '{url}'")
              else:
                  webbrowser.open(url)
          except Exception:
              pass
      
          finished = server.shutdown_event.wait(timeout=SUBMIT_TIMEOUT_SEC)
          print("[render-cleanup-plan] shutdown_event received, stopping server", file=sys.stderr)
          try:
              server.shutdown()
          except Exception as exc:
              print(f"[render-cleanup-plan] server.shutdown() error: {exc}", file=sys.stderr)
          try:
              server.server_close()
          except Exception:
              pass
          server_thread.join(timeout=2)
      
          if not finished:
              print("TIMEOUT", file=sys.stderr)
              return 1
          if server.cancelled:
              print("CANCELLED")
              return 1
          if server.submission:
              print(server.submission["path"])
              return 0
          print("NO_SUBMISSION", file=sys.stderr)
          return 1
      
      
      if __name__ == "__main__":
          raise SystemExit(main(sys.argv))
      
    • test_validate_command.py 10 KB
      #!/usr/bin/env python3
      """
      test_validate_command.py — adversarial unit tests for the apply-script
      validator. This is the SAFETY-CORE GATE of maintaining-windows-health.
      
      It is pure string/path logic (ntpath-based), so it runs and fully verifies the
      Windows validator on ANY OS — including the macOS machine the skill is authored
      on, where no NTFS volume exists. Run it before trusting the apply script:
      
          python3 test_validate_command.py
      
      Exit code 0 = all cases pass; 1 = at least one regression.
      """
      from __future__ import annotations
      
      import importlib.util
      import os
      import sys
      from pathlib import Path
      
      HERE = Path(__file__).resolve().parent
      
      # Synthetic Windows environment so the validator's allow/deny prefixes resolve
      # deterministically regardless of host OS.
      FAKE_ENV = {
          "SystemDrive": "C:",
          "SystemRoot": r"C:\Windows",
          "windir": r"C:\Windows",
          "ProgramFiles": r"C:\Program Files",
          "ProgramFiles(x86)": r"C:\Program Files (x86)",
          "ProgramW6432": r"C:\Program Files",
          "ProgramData": r"C:\ProgramData",
          "USERPROFILE": r"C:\Users\dev",
          "APPDATA": r"C:\Users\dev\AppData\Roaming",
          "LOCALAPPDATA": r"C:\Users\dev\AppData\Local",
          "TEMP": r"C:\Users\dev\AppData\Local\Temp",
          "TMP": r"C:\Users\dev\AppData\Local\Temp",
      }
      
      
      def _load_validator():
          spec = importlib.util.spec_from_file_location(
              "apply_cleanup_selection", HERE / "apply-cleanup-selection.py")
          mod = importlib.util.module_from_spec(spec)
          spec.loader.exec_module(mod)
          return mod
      
      
      # Each case: (command, expect_ok, expect_requires_protection, scan_roots, note)
      CASES = [
          # ---- Allowed bare deletes -------------------------------------------------
          (r"Remove-Item -LiteralPath 'C:\Users\dev\AppData\Local\Temp\foo' -Recurse -Force",
           True, False, [], "user TEMP"),
          (r'Remove-Item -LiteralPath "C:\Users\dev\Downloads\old.iso" -Force',
           True, False, [], "Downloads under HOME"),
          (r"del C:\Windows\Temp\stale.tmp", True, False, [], "C:\\Windows\\Temp carve-out"),
          (r"Remove-Item -LiteralPath '%TEMP%\bar' -Recurse", True, False, [], "env-expanded TEMP"),
          (r"Remove-Item -LiteralPath 'C:\Users\dev\source\repos\proj\node_modules' -Recurse -Force",
           True, False, [], "project artifact under HOME"),
          (r"Remove-Item -LiteralPath 'D:\projects\app\target' -Recurse",
           True, False, [r"D:\projects"], "scan-root opt-in"),
      
          # ---- Denied bare deletes --------------------------------------------------
          (r"Remove-Item -LiteralPath 'C:\Windows\System32\drivers\x' -Recurse",
           False, False, [], "System32"),
          (r"Remove-Item -LiteralPath 'C:\Windows\WinSxS' -Recurse", False, False, [], "WinSxS"),
          (r"Remove-Item -LiteralPath 'C:\Windows' -Recurse", False, False, [], "Windows root"),
          (r"Remove-Item -LiteralPath 'C:\Program Files\App' -Recurse", False, False, [], "Program Files"),
          (r"Remove-Item -LiteralPath 'C:\Program Files (x86)\App' -Recurse",
           False, False, [], "Program Files x86"),
          (r"Remove-Item -LiteralPath 'C:\ProgramData\Microsoft\X' -Recurse",
           False, False, [], "ProgramData\\Microsoft"),
          (r"Remove-Item -LiteralPath 'C:\ProgramData\Package Cache\X' -Recurse",
           False, False, [], "Package Cache"),
          (r"Remove-Item -LiteralPath 'C:\Users\dev\.ssh' -Recurse",
           False, False, [], "credential dir beats HOME allow"),
          (r"Remove-Item -LiteralPath 'C:\Users\dev\.aws\credentials' -Force",
           False, False, [], "AWS creds"),
          (r"Remove-Item -LiteralPath 'C:\Users\dev\AppData\Roaming\Microsoft\Protect\S-1-5' -Recurse",
           False, False, [], "DPAPI master keys"),
          (r"Remove-Item -LiteralPath 'C:\pagefile.sys' -Force", False, False, [], "pagefile"),
          (r"Remove-Item -LiteralPath 'C:\swapfile.sys' -Force", False, False, [], "swapfile"),
          (r"Remove-Item -LiteralPath 'C:\hiberfil.sys' -Force", False, False, [], "hiberfil"),
          (r"Remove-Item -LiteralPath 'C:\System Volume Information\x' -Recurse",
           False, False, [], "VSS/restore store"),
          (r"Remove-Item -LiteralPath 'C:\$Recycle.Bin\S-1-5\x' -Recurse",
           False, False, [], "recycle bin internals"),
          (r"Remove-Item -LiteralPath 'C:\' -Recurse", False, False, [], "bare drive root"),
          (r"Remove-Item -LiteralPath 'D:\randomstuff\x' -Recurse",
           False, False, [], "outside zones, no scan-root"),
      
          # ---- Canonicalization / spoofing attempts (must all be denied) -----------
          (r"Remove-Item -LiteralPath 'c:\windows\system32\x' -Recurse",
           False, False, [], "case-insensitive deny"),
          (r"Remove-Item -LiteralPath 'C:/Windows/System32/x' -Recurse",
           False, False, [], "forward slashes normalized"),
          (r"Remove-Item -LiteralPath 'C:\Users\dev\..\..\Windows\System32\x' -Recurse",
           False, False, [], "dot-dot traversal collapses into Windows"),
          (r"Remove-Item -LiteralPath 'C:\WINDOWS.\System32\evil' -Recurse",
           False, False, [], "trailing-dot component stripped"),
          (r"Remove-Item -LiteralPath 'C:\PROGRA~1\App' -Recurse",
           False, False, [], "8.3 short name refused"),
          (r"Remove-Item -LiteralPath 'C:\Users\dev\file.txt:hidden' -Force",
           False, False, [], "alternate data stream refused"),
          (r"Remove-Item -LiteralPath '\\server\share\x' -Recurse",
           False, False, [], "UNC refused"),
          (r"Remove-Item -LiteralPath '\\?\C:\Windows\..\Windows\System32\x' -Recurse",
           False, False, [], "\\\\?\\ device path refused"),
          (r"Remove-Item -Path HKLM:\Software\Foo -Recurse",
           False, False, [], "registry provider refused"),
          (r"Remove-Item -Path Env:\Foo", False, False, [], "Env provider refused"),
      
          # ---- Command-injection / chaining (must all be denied) -------------------
          (r"docker ps; Remove-Item C:\Windows -Recurse", False, False, [], "semicolon chaining"),
          (r"Remove-Item C:\Windows -Recurse | Out-Null", False, False, [], "pipe chaining"),
          (r"Remove-Item C:\Windows -Recurse & whoami", False, False, [], "ampersand chaining"),
          (r"[IO.Directory]::Delete('C:\Windows')", False, False, [], ".NET static call (::)"),
          (r"Remove-Item $(Get-Evil)", False, False, [], "subexpression"),
      
          # ---- Safe wrappers (ok, no protection) -----------------------------------
          (r"dotnet nuget locals all --clear", True, False, [], "nuget cache"),
          (r"docker system prune -af", True, False, [], "docker prune"),
          (r"Clear-RecycleBin -Force", True, False, [], "recycle bin cmdlet"),
          (r"cleanmgr /sagerun:1", True, False, [], "disk cleanup profile"),
          (r"npm cache clean --force", True, False, [], "npm cache"),
          (r"winget uninstall --id Some.App", True, False, [], "winget uninstall"),
          (r"Dism.exe /Online /Cleanup-Image /AnalyzeComponentStore",
           True, False, [], "DISM analyze (read-only)"),
          (r"vssadmin list shadowstorage", True, False, [], "vssadmin list (read-only)"),
      
          # ---- Tool wrappers, destructive => require protection ---------------------
          (r"Dism.exe /Online /Cleanup-Image /StartComponentCleanup /ResetBase",
           True, True, [], "DISM /ResetBase irreversible"),
          (r"vssadmin delete shadows /for=C: /oldest", True, True, [], "delete shadow copies"),
          (r"wevtutil cl Application", True, True, [], "clear event log"),
          (r"pnputil /delete-driver oem42.inf /uninstall", True, True, [], "driver removal"),
          (r"powercfg /h off", True, True, [], "disable hibernation"),
      
          # ---- Neither delete nor wrapper => refused -------------------------------
          (r"Stop-Service WSearch", False, False, [], "arbitrary cmdlet refused"),
          (r"Format-Volume -DriveLetter D", False, False, [], "format refused"),
          (r"", False, False, [], "empty command"),
      ]
      
      
      def test_load_selection_bom() -> int:
          """Regression for issue #1: PowerShell UTF8 selection JSON carries a BOM."""
          import json
          import tempfile
          from pathlib import Path
      
          mod = _load_validator()
          payload = {
              "selected_items": [
                  {
                      "id": "t1",
                      "label": "temp sample",
                      "path": r"C:\Users\dev\AppData\Local\Temp\sample",
                      "command": (
                          r"Remove-Item -LiteralPath "
                          r"'C:\Users\dev\AppData\Local\Temp\sample' "
                          r"-Recurse -Force"
                      ),
                      "size_bytes": 1,
                  }
              ],
              "protected_overrides": [],
          }
          body = json.dumps(payload, indent=2).encode("utf-8")
          bom_body = b"\xef\xbb\xbf" + body
          failed = 0
          with tempfile.TemporaryDirectory() as td:
              plain = Path(td) / "plain.json"
              bom = Path(td) / "bom.json"
              plain.write_bytes(body)
              bom.write_bytes(bom_body)
              try:
                  a = mod.load_selection_json(plain)
                  b = mod.load_selection_json(bom)
              except Exception as exc:
                  print(f"FAIL [BOM load raised]: {exc}")
                  return 1
              if a != payload or b != payload:
                  print("FAIL [BOM load] decoded payload mismatch")
                  failed += 1
              else:
                  print("OK   [BOM load] utf-8 and utf-8-sig selection JSON both load")
              code = mod.main([str(HERE / "apply-cleanup-selection.py"), str(bom), "--dry-run"])
              if code != 0:
                  print(f"FAIL [BOM dry-run] exit={code}")
                  failed += 1
              else:
                  print("OK   [BOM dry-run] apply --dry-run accepts BOM selection")
          return failed
      
      def main() -> int:
          os.environ.update(FAKE_ENV)
          mod = _load_validator()
          passed = failed = 0
          for command, exp_ok, exp_prot, scan_roots, note in CASES:
              ok, reason, prot = mod.validate_command(command, scan_roots)
              good = (ok == exp_ok) and (prot == exp_prot if exp_ok else True)
              if good:
                  passed += 1
              else:
                  failed += 1
                  print(f"FAIL [{note}]")
                  print(f"     cmd      = {command!r}")
                  print(f"     expected = ok={exp_ok} prot={exp_prot}")
                  print(f"     got      = ok={ok} prot={prot} reason={reason!r}")
          bom_failed = test_load_selection_bom()
          failed += bom_failed
          if bom_failed == 0:
              passed += 2
      
          print()
          print(f"=== {passed} passed | {failed} failed | {len(CASES)} total (+ BOM checks) ===")
          return 0 if failed == 0 else 1
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • win-health-check.config.ps1 1.4 KB · in bundle
    • win-health-check.ps1 11.8 KB · in bundle
  • references
    • alerting.md 9.4 KB
      # Active alerting
      
      Design and operations of the `win-health-check` scheduled task — the active complement to passively watching Task Manager / Resource Monitor. Windows analog of the macOS `mac-health-check` LaunchAgent.
      
      ## Table of contents
      
      - [Design principles](#design-principles)
      - [Files (canonical paths)](#files-canonical-paths)
      - [Why these specific choices](#why-these-specific-choices)
      - [Install / restore on a new PC](#install--restore-on-a-new-pc)
      - [Verify it works](#verify-it-works)
      - [Configuration tuning](#configuration-tuning)
      - [Daily operations](#daily-operations)
      - [Troubleshooting](#troubleshooting)
      - [Removal](#removal)
      
      ## Design principles
      
      1. **Three CRITICAL-only triggers**, no warnings or hints:
         - Disk free below configured % (default 10).
         - Commit pressure above % AND available RAM below % (defaults 90 / 5). **Commit % is primary** — `Available MBytes` includes standby/cache, so it alone is not a low-memory signal; commit-limit exhaustion is.
         - A new crash minidump in `C:\Windows\Minidump` (immediate, no hysteresis).
      2. **Hysteresis**: 3 consecutive readings before the disk/memory alert (15 min on a 5-min cadence). Kills transient spikes.
      3. **Cooldown**: 30 min between repeats of the same key. Prevents storms.
      4. **Calibration window**: first 7 days log-only. Observe your noise floor before alerts engage.
      5. **Suppress flag**: create `%LOCALAPPDATA%\win-health\silent` to mute during heavy work — no need to unregister the task.
      6. **No auto-cleanup.** The alert notifies; the human decides. (Google SRE alert-fatigue consensus.)
      
      ## Files (canonical paths)
      
      ```
      %LOCALAPPDATA%\win-health\win-health-check.ps1          # the script (installed copy)
      %LOCALAPPDATA%\win-health\win-health-check.config.ps1   # thresholds & switches
      %LOCALAPPDATA%\win-health\silent                        # touch to suppress
      %LOCALAPPDATA%\win-health\logs\health.log               # script's own log
      %LOCALAPPDATA%\win-health\install_date                  # epoch of first run (calibration)
      %LOCALAPPDATA%\win-health\dumps_seen                    # dedup of crash dumps seen
      %LOCALAPPDATA%\win-health\counter.{disk,memory}         # hysteresis counters
      %LOCALAPPDATA%\win-health\cooldown.<key>                # cooldown timestamps
      Scheduled Task: \WinHealthCheck                         # per-user, interactive session
      ```
      
      The skill's `assets/` directory holds the reference copies (`win-health-check.ps1`, `win-health-check.config.ps1`, `Install-WinHealthCheck.ps1`).
      
      ## Why these specific choices
      
      | Choice | Reason |
      |---|---|
      | **Task runs in the INTERACTIVE user session** (principal = logged-on user, `LogonType Interactive`, `RunLevel Limited`) | This is load-bearing. A task running as **SYSTEM** (or "run whether user is logged on or not") executes fine but its toasts **silently never render** — toasts are drawn by `explorer.exe` in the user's desktop session (Session 1+), not Session 0. This is the exact Windows analog of the macOS "osascript → Script Editor" failure. `Highest` run level is unnecessary for toasts and can hand the task a non-interactive token, so use `Limited`. |
      | **BurntToast for toasts** | BurntToast registers its own AppUserModelID, so the toast is attributed cleanly (not to "PowerShell") and click/activation works. Install once: `Install-Module BurntToast -Scope CurrentUser`. |
      | **Native WinRT as fallback** | Zero-dependency `ToastNotificationManager`, but **requires AUMID registration** or it doesn't render / is attributed to "PowerShell" — the same pathology as the macOS osascript fallback. Documented but secondary. |
      | **ntfy.sh as the headless escape hatch** | More important than on macOS: when there is **no interactive session** (locked machine, server SKU, SYSTEM task), toasts are impossible and ntfy is the only channel. `Invoke-RestMethod` works from any session. |
      | **`-Once` + `-RepetitionInterval 5min`** | The only reliable way to get indefinite sub-hourly repetition via the ScheduledTasks cmdlets. |
      | **`-StartWhenAvailable`** | Catch up once on wake after a missed trigger — the analog of the macOS plist's `StartCalendarInterval` coalescing (the macOS lesson was that `StartInterval` pauses during sleep). |
      | **`-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries`** | Default tasks are AC-only; without these a laptop on battery never checks — a silent-death mode the macOS LaunchAgent doesn't have. |
      | **Minidump file poll as primary crash signal; narrow `Get-WinEvent` only as confirmation** | A broad `Get-WinEvent` over a wide window is slow and throws "no events found" as a terminating error — the analog of the macOS `log show` being too slow. Polling `C:\Windows\Minidump` is cheap. |
      | **Commit % as primary memory signal** | `Available MBytes` counts reclaimable standby/cache; commit-limit exhaustion is the real OOM. |
      
      **Modern Standby (S0ix):** on many Windows 11 laptops, wake timers are unreliable and `powercfg /a` won't show S3 — this is normal, not a fault. The task pauses during sleep and `-StartWhenAvailable` runs it once on wake. Accepted, same trade-off as macOS.
      
      ## Install / restore on a new PC
      
      Run a **non-elevated** PowerShell as the user who should receive alerts (interactive-session principal). From the skill's `assets/`:
      
      ```powershell
      cd <skill>\assets
      # Installs BurntToast, copies script+config to %LOCALAPPDATA%\win-health, registers the task,
      # runs once to verify:
      .\Install-WinHealthCheck.ps1
      ```
      
      What it does: installs the BurntToast module (CurrentUser), copies the script and a default config, then registers `\WinHealthCheck` with the interactive-session principal, 5-minute repetition, `-StartWhenAvailable`, battery-friendly settings, and a 4-minute execution limit.
      
      To skip the module (use native WinRT or ntfy only): `.\Install-WinHealthCheck.ps1 -SkipBurntToast`.
      
      ## Verify it works
      
      ```powershell
      # Synthetic disk-critical alert (no real danger): forces DiskCriticalPct=99, hysteresis=1, calibration=0
      .\Install-WinHealthCheck.ps1 -Test
      ```
      
      You should see a toast and an `ALERT key=disk_critical … -> delivered` line in `%LOCALAPPDATA%\win-health\logs\health.log`. Confirm the task is registered:
      
      ```powershell
      Get-ScheduledTask -TaskName WinHealthCheck | Select-Object TaskName, State
      Get-ScheduledTaskInfo -TaskName WinHealthCheck | Select-Object LastRunTime, LastTaskResult, NextRunTime
      ```
      
      ## Configuration tuning
      
      Edit `%LOCALAPPDATA%\win-health\win-health-check.config.ps1`:
      
      | Variable | Default | When to change |
      |---|---|---|
      | `$DiskCriticalPct` | 10 | lower if you routinely run >90% full and accept it; raise for earlier warning |
      | `$CommitCriticalPct` | 90 | the primary memory trigger; lower to 85 for earlier warning |
      | `$MemFreeCriticalPct` | 5 | confirmation gate; match your normal heavy-use floor + margin |
      | `$CooldownMinutes` | 30 | lower for more reminders; raise to silence repeats |
      | `$HysteresisReadings` | 3 | 1 = instant; 5–6 = very stable only |
      | `$CalibrationDays` | 7 | 0 to skip on a known-good restore |
      | `$Notifier` | auto | `burnttoast` / `winrt` / `none` to force |
      | `$NtfyUrl` | empty | `https://ntfy.sh/<long-unguessable-topic>` for phone/desktop push |
      | `$DumpDir` | `%SystemRoot%\Minidump` | override only for testing |
      
      ## Daily operations
      
      ```powershell
      # Is it running?
      Get-ScheduledTaskInfo -TaskName WinHealthCheck | Select-Object LastRunTime, LastTaskResult
      
      # Watch the log
      Get-Content (Join-Path $env:LOCALAPPDATA 'win-health\logs\health.log') -Wait -Tail 20
      
      # Suppress during heavy work, then re-enable
      New-Item -ItemType File -Force -Path (Join-Path $env:LOCALAPPDATA 'win-health\silent')
      Remove-Item (Join-Path $env:LOCALAPPDATA 'win-health\silent')
      
      # Force a check now
      & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $env:LOCALAPPDATA 'win-health\win-health-check.ps1')
      ```
      
      ## Troubleshooting
      
      ### Task runs but no toast appears
      1. **Most common:** the task is running as SYSTEM / "whether user is logged on or not". Re-run `Install-WinHealthCheck.ps1` so the principal is the interactive user (`LogonType Interactive`). Verify: `(Get-ScheduledTask WinHealthCheck).Principal`.
      2. Confirm BurntToast: `Get-Module -ListAvailable BurntToast`. If absent, `Install-Module BurntToast -Scope CurrentUser`.
      3. Check Settings → System → Notifications is on and Focus Assist / Do Not Disturb isn't suppressing it.
      4. As a session-independent fallback, set `$NtfyUrl` and subscribe in the ntfy app.
      
      ### Toast attributed to "PowerShell"
      The native WinRT fallback fired without an AUMID. Install BurntToast (it registers an AUMID) and set `$Notifier = 'burnttoast'`.
      
      ### Task never runs on a laptop
      It was AC-only or paused during sleep. Re-run the installer (sets `-AllowStartIfOnBatteries`); accept that S0ix may delay the post-wake run (`-StartWhenAvailable` catches up once).
      
      ### Constant alerts during heavy work
      Past calibration and your normal workload exceeds thresholds. Suppress (`silent` file), raise thresholds, or increase `$HysteresisReadings`.
      
      ### "I want phone push too"
      Set `$NtfyUrl = 'https://ntfy.sh/<your-private-uuid-topic>'` (≥ 32 random chars — public topics are world-readable), subscribe in the ntfy mobile app, and test:
      
      ```powershell
      Invoke-RestMethod -Method Post -Uri 'https://ntfy.sh/<topic>' -Body 'test' -Headers @{ Title='Test'; Priority='high' }
      ```
      
      ## Removal
      
      ```powershell
      .\Install-WinHealthCheck.ps1 -Uninstall          # remove the task (keeps logs/state)
      .\Install-WinHealthCheck.ps1 -Uninstall -Purge   # also delete %LOCALAPPDATA%\win-health
      # Optional: Uninstall-Module BurntToast
      ```
      
    • cleanup-tiers.md 15.3 KB
      # Cleanup tiers
      
      Ten tiers, ordered by risk and reward. Always start at the lowest, only escalate if the goal isn't met. Each tier ends with a free-space checkpoint. **Prefer native Microsoft-supported tools and the tool's own cache command over hand-rolled `Remove-Item`** — see `native-tools.md`. Every destructive item the agent surfaces in the cleanup UI runs through `assets/apply-cleanup-selection.py`, which validates the command before executing; do not bypass it with ad-hoc deletions.
      
      ## Table of contents
      
      - [Tier 1 — Storage Sense + trivial wins](#tier-1--storage-sense--trivial-wins-low-risk)
      - [Tier 2 — Package manager caches](#tier-2--package-manager-caches)
      - [Tier 3 — Browser & Electron caches](#tier-3--browser--electron-caches)
      - [Tier 4 — Stale IDE / SDK versions](#tier-4--stale-ide--sdk-versions)
      - [Tier 5 — Downloads (interactive)](#tier-5--downloads-interactive)
      - [Tier 6 — Component Store (DISM)](#tier-6--component-store-dism)
      - [Tier 7 — Project artifacts (purge)](#tier-7--project-artifacts-purge)
      - [Tier 8 — Docker & WSL2 / Hyper-V VHDX](#tier-8--docker--wsl2--hyper-v-vhdx)
      - [Tier 9 — Elevated: Windows.old, DO, Event Logs, restore points](#tier-9--elevated-windowsold-delivery-optimization-event-logs-restore-points)
      - [Tier 10 — Discuss-first](#tier-10--discuss-first)
      - [Large-file visibility pass](#large-file-visibility-pass)
      - [Reset prevention recipes](#reset-prevention-recipes)
      - [After cleanup](#after-cleanup)
      
      **Baseline first:**
      
      ```powershell
      Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' |
        Select-Object DeviceID, @{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,1)}}, @{N='FreePct';E={[math]::Round($_.FreeSpace/$_.Size*100,1)}}
      # For Tiers 3 (Electron) and 8 (Docker/WSL): quit the apps / `wsl --shutdown` first.
      ```
      
      ---
      
      ## Tier 1 — Storage Sense + trivial wins (low risk)
      
      The biggest safe win on Windows is the built-in tooling. Start here.
      
      ```powershell
      # GUI: Settings → System → Storage → "Cleanup recommendations" (temp files, large/unused, cloud-synced, unused apps).
      # Enable Storage Sense to keep it tidy automatically (per-user):
      Start-Process ms-settings:storagesense
      
      # Recycle Bin (all drives)
      Clear-RecycleBin -Force -ErrorAction SilentlyContinue
      
      # User + Windows temp older than N days (Remove-Item is PERMANENT — no Recycle Bin)
      $cut = (Get-Date).AddDays(-14)
      Get-ChildItem $env:TEMP -Force -EA SilentlyContinue | Where-Object LastWriteTime -lt $cut |
        Remove-Item -Recurse -Force -EA SilentlyContinue
      Get-ChildItem "$env:SystemRoot\Temp" -Force -EA SilentlyContinue | Where-Object LastWriteTime -lt $cut |
        Remove-Item -Recurse -Force -EA SilentlyContinue   # needs admin
      
      # Legacy Disk Cleanup, scripted (define a profile once, run it anywhere):
      cleanmgr /sageset:1   # tick categories in the dialog once
      cleanmgr /sagerun:1   # runs that profile on all drives
      ```
      
      ⚠️ Leave **Prefetch** (`C:\Windows\Prefetch`) alone — deleting it slows boot for ~tens of MB. Don't delete `Windows.edb` (search index) live; rebuild via Settings or stopping `WSearch` instead.
      
      ---
      
      ## Tier 2 — Package manager caches
      
      All regenerate on next build. Use the tool's own command where it exists — they keep index integrity better than `Remove-Item`.
      
      ```powershell
      npm cache clean --force
      pnpm store prune
      dotnet nuget locals all --clear
      pip cache purge
      if (Get-Command uv -EA SilentlyContinue) { uv cache clean }
      if (Get-Command cargo -EA SilentlyContinue) { cargo cache --autoclean }  # needs cargo-cache; else: Remove-Item "$env:USERPROFILE\.cargo\registry\cache" -Recurse -Force
      if (Get-Command go -EA SilentlyContinue) { go clean -cache; go clean -modcache }
      # Gradle wrapper dists / downloaded JDKs (regenerate on next build):
      Remove-Item "$env:USERPROFILE\.gradle\wrapper\dists" -Recurse -Force -EA SilentlyContinue
      Remove-Item "$env:USERPROFILE\.gradle\caches\jars-*" -Recurse -Force -EA SilentlyContinue
      # Playwright/Puppeteer browser binaries — list, then remove old versions only:
      Get-ChildItem "$env:USERPROFILE\AppData\Local\ms-playwright" -EA SilentlyContinue
      ```
      
      ---
      
      ## Tier 3 — Browser & Electron caches
      
      ⚠️ **Quit the apps first.** Running apps regenerate cache mid-write and can glitch.
      
      ```powershell
      # Cache-family subfolders only — NEVER IndexedDB / Local Storage / Cookies / "User Data\Default" wholesale.
      $apps = @(
        "$env:APPDATA\Slack",
        "$env:APPDATA\Notion",
        "$env:APPDATA\Microsoft\Teams",
        "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default",
        "$env:LOCALAPPDATA\Google\Chrome\User Data\Default",
        "$env:APPDATA\Code",
        "$env:APPDATA\Cursor"
      )
      $cacheSubs = 'Cache','Code Cache','GPUCache','DawnCache','DawnGraphiteCache','GrShaderCache','ShaderCache','Service Worker\CacheStorage'
      foreach ($a in $apps) {
        foreach ($s in $cacheSubs) { Remove-Item (Join-Path $a $s) -Recurse -Force -EA SilentlyContinue }
      }
      ```
      
      Browser cache is also clearable from each browser's own Settings → Privacy → Clear browsing data (pick **Cached images and files** only; leave cookies/passwords). In Edge with sync on, clearing browsing data can affect synced devices.
      
      ⚠️ Do NOT delete `IndexedDB`, `Local Storage`, `Cookies`, Telegram `tdata\`, or anything carrying app state — see `never-touch.md`.
      
      ---
      
      ## Tier 4 — Stale IDE / SDK versions
      
      ```powershell
      # JetBrains: list data dirs, remove only confirmed-stale major.minor versions
      Get-ChildItem "$env:APPDATA\JetBrains" -Directory -EA SilentlyContinue | Select-Object Name
      # e.g. keep Rider2025.1, remove Rider2024.3:
      # Remove-Item "$env:APPDATA\JetBrains\Rider2024.3" -Recurse -Force
      # Caches/logs/indexes for current versions (re-index on next launch — slow but safe):
      Remove-Item "$env:LOCALAPPDATA\JetBrains\*\caches" -Recurse -Force -EA SilentlyContinue
      Remove-Item "$env:LOCALAPPDATA\JetBrains\*\log"    -Recurse -Force -EA SilentlyContinue
      
      # .NET old SDKs — list first; remove only if a newer same-major exists:
      dotnet --list-sdks
      # Visual Studio leftover component cache:
      Remove-Item "$env:ProgramData\Microsoft\VisualStudio\Packages\_Instances\*\..\..\..\*.cache" -EA SilentlyContinue
      ```
      
      ---
      
      ## Tier 5 — Downloads (interactive)
      
      Largest variable category. Show the breakdown first; always confirm before delete.
      
      ```powershell
      Get-ChildItem "$env:USERPROFILE\Downloads" -File -EA SilentlyContinue |
        Sort-Object Length -Desc | Select-Object -First 30 Name,
          @{N='MB';E={[math]::Round($_.Length/1MB,1)}}, LastWriteTime
      ```
      
      ⚠️ **OneDrive Known Folder Move:** Downloads is usually local, but **Desktop / Documents / Pictures are often redirected into OneDrive**. Deleting a redirected file removes the cloud copy and propagates to every synced device. Check before touching those folders (the audit script reports KFM status). Propose for deletion: installers (`.iso/.msi/.exe/.zip`) older than 30 days that were already used; extracted folders sitting next to their archive; old recordings.
      
      ---
      
      ## Tier 6 — Component Store (DISM)
      
      WinSxS grows with updates. **Never delete WinSxS by hand** — Microsoft says the system can stop booting/updating. Analyze, then clean only if recommended.
      
      ```powershell
      Dism.exe /Online /Cleanup-Image /AnalyzeComponentStore   # look for "Component Store Cleanup Recommended : Yes"
      Dism.exe /Online /Cleanup-Image /StartComponentCleanup    # only if reclaimable / recommended
      ```
      
      `/ResetBase` (removes the ability to uninstall existing updates) is **Tier 10**, not here.
      
      ---
      
      ## Tier 7 — Project artifacts (purge)
      
      Highest-reward tier on a dev machine. Regenerable build/dependency dirs, age-gated. There is **no mature Mole-for-Windows**, so the apply-script validator is the only safety floor here — keep `mtime ≥ 7 days` and `-LiteralPath`, and never recurse through a reparse point (junctions in `node_modules`/pnpm store/WSL escape recursive deletes). See `native-tools.md` for the marker→target map and the safe-purge harness.
      
      ```powershell
      # Inventory candidate roots (HOME by default; pass external drives as scan roots to the apply script):
      $roots = "$env:USERPROFILE\source","$env:USERPROFILE\repos","$env:USERPROFILE\dev","$env:USERPROFILE\Projects"
      $cut = (Get-Date).AddDays(-7)
      foreach ($r in $roots) {
        if (-not (Test-Path $r)) { continue }
        Get-ChildItem $r -Recurse -Depth 4 -Directory -Force -EA SilentlyContinue |
          Where-Object { $_.Name -in 'node_modules','.next','.nuxt','dist','build','target','__pycache__','.venv','.gradle' -and
                         $_.LastWriteTime -lt $cut -and
                         -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) } |
          Select-Object FullName, LastWriteTime
      }
      # Surface the confirmed list in the cleanup UI; apply via apply-cleanup-selection.py (per-item -LiteralPath Remove-Item).
      ```
      
      ⚠️ `bin`/`obj` only for confirmed .NET projects (sibling `*.csproj`); `vendor` only for PHP Composer (Go/Rails `vendor` is hand-curated). Check `git status` for uncommitted state before deleting build dirs.
      
      ---
      
      ## Tier 8 — Docker & WSL2 / Hyper-V VHDX
      
      ```powershell
      docker system df              # see reclaimable
      docker image prune -f
      docker builder prune -f
      docker volume ls -f dangling=true    # review BEFORE removing — data volumes live here
      ```
      
      ⚠️ Never `docker system prune -af --volumes` without listing volumes — it deletes data volumes (postgres/mongo data) unconfirmed.
      
      **WSL2 / Docker-Desktop VHDX — compact, never delete.** `ext4.vhdx` is the entire Linux filesystem; deleting it destroys all your WSL data. It grows and doesn't auto-shrink. Reclaim by compacting:
      
      ```powershell
      wsl --shutdown
      # Find the disk, then compact it (run from an elevated prompt):
      $vhdx = "$env:LOCALAPPDATA\Packages\<DistroPackage>\LocalState\ext4.vhdx"
      Optimize-VHD -Path $vhdx -Mode Full   # Hyper-V module; OR use diskpart 'compact vdisk' on Home SKUs
      ```
      
      ---
      
      ## Tier 9 — Elevated: Windows.old, Delivery Optimization, Event Logs, restore points
      
      Each needs admin and an explicit OK. Use the **command**, not folder deletion.
      
      ```powershell
      # Windows.old — ONLY after confirming you won't roll back. Check the rollback window first:
      Dism.exe /Online /Get-OSUninstallWindow
      # Then remove via Settings → System → Storage → Temporary files → "Previous Windows installation(s)", or cleanmgr.
      
      # Delivery Optimization cache (auto-capped anyway; point fix, not routine):
      Delete-DeliveryOptimizationCache -Force
      
      # Event Logs — EXPORT before clearing (forensics). Pattern: export then clear:
      wevtutil epl Application "$env:USERPROFILE\Desktop\Application-backup.evtx" /ow:true
      wevtutil cl Application /bu:"$env:USERPROFILE\Desktop\Application-before-clear.evtx"
      
      # System Restore points — only if you have another backup/rollback:
      vssadmin list shadowstorage
      # vssadmin delete shadows /for=C: /oldest      # destroys restore points — discuss first
      ```
      
      ---
      
      ## Tier 10 — Discuss-first
      
      Each item has real, often irreversible side effects. Explicit user OK required (the apply-script flags these as requiring a protected-override even though the tool is "supported").
      
      ```powershell
      # DISM /ResetBase — removes ability to UNINSTALL existing updates. Stable baselines only.
      Dism.exe /Online /Cleanup-Image /StartComponentCleanup /ResetBase
      
      # Driver Store — remove stale third-party packages AFTER inventory/export:
      pnputil /enum-drivers
      # pnputil /export-driver oem42.inf C:\DriverBackup    # back up first
      # pnputil /delete-driver oem42.inf /uninstall
      
      # Shadow copies / restore points (loses rollback):
      # vssadmin delete shadows /for=C: /all
      
      # Hibernation off (frees hiberfil.sys; breaks hybrid sleep / fast startup; data-loss risk on power loss):
      # powercfg /h off          # or shrink: powercfg /h /type reduced
      
      # Pagefile — almost never "junk". Needed for modified pages + crash dumps. Leave system-managed.
      Get-CimInstance Win32_PageFileUsage | Select-Object Name, AllocatedBaseSize, CurrentUsage, PeakUsage
      ```
      
      ⚠️ **Registry is never a cleanup target.** Microsoft does not support registry cleaners; "registry bloat" is a myth as a space/perf source. Only export-and-edit a specific known key when fixing a concrete problem.
      
      ---
      
      ## Large-file visibility pass
      
      Run this after the tier scans and before building the cleanup JSON. Its purpose is visibility, not automatic deletion: it catches the large one-off files the user may no longer need, while keeping them unchecked/protected in the single final UI unless they are clearly safe cache.
      
      ```powershell
      $roots = @(
        $env:USERPROFILE,
        $env:LOCALAPPDATA,
        $env:APPDATA,
        "$env:USERPROFILE\RiderProjects",
        "$env:USERPROFILE\PycharmProjects",
        "$env:USERPROFILE\source",
        "$env:USERPROFILE\repos",
        "$env:USERPROFILE\dev",
        "$env:USERPROFILE\Projects",
        "$env:USERPROFILE\GitHub",
        "$env:USERPROFILE\Code",
        "$env:USERPROFILE\work"
      ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique
      
      $denyRegex = '\\\.git\\objects\\|\\Windows\\|\\Program Files( \(x86\))?\\|\\ProgramData\\Package Cache\\'
      
      foreach ($root in $roots) {
        Get-ChildItem -LiteralPath $root -Recurse -File -Force -EA SilentlyContinue |
          Where-Object {
            $_.Length -gt 500MB -and
            $_.FullName -notmatch $denyRegex -and
            -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -and
            -not ($_.Attributes -band [IO.FileAttributes]::Offline)
          } |
          Sort-Object Length -Desc |
          Select-Object -First 80 FullName,
            @{N='GB';E={[math]::Round($_.Length/1GB,2)}},
            LastWriteTime,
            Extension
      }
      ```
      
      When converting these findings to JSON:
      
      - Mark user data, synced media, model weights (`.gguf`, `.safetensors`, `.onnx`), archives, app/project assets, app diagnostic dumps, and profiler captures as `protected: true`, `default_selected: false`, with a concrete warning.
      - Include those protected findings in the same final cleanup JSON as the safe cache/temp/build-artifact candidates. Do not open a second UI round just for large files.
      - Show VHDX files as inventory, but do not offer bare deletion. Use compact/prune workflows only; a WSL/Docker VHDX may be an entire filesystem.
      - Do not offer `.git\objects` files as direct deletes. If a repository is too large, propose Git-native cleanup (`git gc`, removing stale branches, deleting the whole repo if the user chooses), not object-file deletion.
      - Do not include cloud placeholders or Files On-Demand stubs. Deleting them deletes the cloud item.
      
      ---
      
      ## Reset prevention recipes
      
      ```powershell
      # Storage Sense policy: run monthly, clear Recycle Bin > 30d (Settings → Storage → Storage Sense), or via Intune/GPO in a fleet.
      # pnpm global store with hardlinks (dedupes across projects):
      pnpm config set store-dir "$env:LOCALAPPDATA\pnpm-store"
      # Docker tidy alias (PowerShell profile):
      'function docker-tidy { docker container prune -f; docker image prune -f; docker builder prune -f }' |
        Add-Content $PROFILE
      # Cap WSL memory so it stops ballooning (%USERPROFILE%\.wslconfig):
      "[wsl2]`nmemory=8GB`nswap=2GB" | Set-Content "$env:USERPROFILE\.wslconfig"
      ```
      
      ---
      
      ## After cleanup
      
      ```powershell
      # 1. Report the delta:
      Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" |
        Select-Object @{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,1)}}
      
      # 2. Integrity post-check (Microsoft-recommended order):
      Dism /Online /Cleanup-Image /CheckHealth
      sfc /scannow
      
      # 3. Re-check Windows Update opens, drivers intact, restore points still per plan.
      # 4. Recommend the alerter (alerting.md) to prevent recurrence.
      ```
      
      If freed space looks smaller than deleted, allow a minute — Storage Sense/`cleanmgr` defer some deletion, and `Remove-Item` to Recycle-Bin-bypassing paths is immediate while NTFS metadata settles.
      
    • native-tools.md 9.4 KB
      # Native tools & the safety floor
      
      macOS has Mole (`mo`) as a community-vetted safety floor. **Windows has no mature equivalent** — so the safety floor here is two things working together: (1) **native Microsoft-supported tooling** for everything the OS maintains itself, and (2) **the apply-script validator** (`assets/apply-cleanup-selection.py`) for bare deletes. On Windows the validator is the *only* line behind project-artifact purge, not a second line behind a vetted tool. Treat it accordingly.
      
      ## Table of contents
      
      - [Native tooling = first choice](#native-tooling--first-choice)
      - [Marker → target map for project-artifact purge](#marker--target-map-for-project-artifact-purge)
      - [Age thresholds](#age-thresholds)
      - [Path-validation rules (what the validator enforces)](#path-validation-rules-what-the-validator-enforces)
      - [Operational logging](#operational-logging)
      - [Safe-purge harness (when scripting by hand)](#safe-purge-harness-when-scripting-by-hand)
      - [Third-party tools — optional, with caveats](#third-party-tools--optional-with-caveats)
      
      ## Native tooling = first choice
      
      | Tool | For | Notes |
      |---|---|---|
      | **Storage Sense** | ongoing temp/Recycle Bin/cloud dehydration | per-user; configurable via Settings, or Intune/CSP/GPO in a fleet |
      | **Cleanup recommendations** | one-shot GUI audit | Settings → System → Storage; temp, large/unused, cloud-synced, unused apps |
      | **cleanmgr** | scripted/legacy cleanup | `/sageset:N` defines a profile, `/sagerun:N` runs it on all drives |
      | **DISM** | Component Store (WinSxS) | `/AnalyzeComponentStore` then `/StartComponentCleanup`; `/ResetBase` is discuss-first |
      | **pnputil** | Driver Store | `/enum-drivers`, `/export-driver` (back up), `/delete-driver` |
      | **vssadmin** | shadow copies / restore | `list`/`delete`/`resize shadowstorage` |
      | **wevtutil** | Event Logs | `epl`/`al` (export) before `cl` (clear) |
      | **powercfg** | hiberfil / power state | `/h off`, `/h /type reduced`, `/a` |
      | **Clear-RecycleBin** | Recycle Bin | cmdlet, safe by construction |
      | **winget** | install/update/uninstall apps | `winget uninstall` is the clean app-removal path |
      
      These are the **wrapper** commands the validator trusts. The destructive subset (`vssadmin delete`, `Dism …/ResetBase`, `wevtutil cl`, `pnputil /delete-driver`, `powercfg /h off`) still requires an explicit protected-override even though the tool self-polices — because the effect is irreversible.
      
      ## Marker → target map for project-artifact purge
      
      Find dev projects by marker files; remove regenerable build/dependency dirs only. All targets must be **`LastWriteTime` ≥ 7 days old**.
      
      | Marker | Tooling | Targets |
      |---|---|---|
      | `package.json`, `pnpm-lock.yaml`, `yarn.lock` | npm/pnpm/yarn | `node_modules`, `.next`, `.nuxt`, `.output`, `dist`, `build`, `.turbo`, `.parcel-cache` |
      | `*.csproj`, `*.fsproj`, `*.vbproj`, `*.sln` | .NET | `bin`, `obj` (only with a sibling project file) |
      | `Cargo.toml` | Rust | `target` |
      | `pom.xml` | Maven | `target` |
      | `build.gradle(.kts)` | Gradle | `build`, `.gradle` |
      | `pyproject.toml`, `requirements.txt` | Python | `__pycache__`, `.venv`, `venv`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.tox` |
      | `composer.json` | PHP Composer | `vendor` (Go/Rails `vendor` is protected — hand-curated) |
      | `pubspec.yaml` | Flutter/Dart | `.dart_tool`, `build` |
      | `go.mod` | Go | `go clean -cache`/`-modcache` (don't delete `vendor`) |
      | `angular.json` / `svelte.config.*` / `astro.config.*` | frameworks | `.angular` / `.svelte-kit` / `.astro` |
      | any project | universal | `coverage`, `.cache` |
      
      Default scan roots (under HOME): `%USERPROFILE%\source`, `\repos`, `\dev`, `\Projects`, `\GitHub`, `\Code`, `\work`. External drives must be passed explicitly (`--scan-root D:\projects`) — the validator denies bare deletes outside HOME/TEMP/scan-roots by default.
      
      ### Guards (mirror Mole's)
      
      - **`bin`/`obj`**: only if a sibling `*.csproj`/`*.fsproj`/`*.vbproj` exists (avoids deleting Go binaries / generated CLI dirs).
      - **`vendor`**: only with `composer.json`.
      - **Project root**: a marker file present and `.git` nearby.
      - **Reparse points**: never recurse into a junction/symlink (`FILE_ATTRIBUTE_REPARSE_POINT`) — recursive delete can escape into the target.
      
      ## Age thresholds
      
      ```
      temp files / caches : LastWriteTime ≥ 7 days
      crash dumps         : keep until triaged (forensics) — never age-delete blindly
      project artifacts   : LastWriteTime ≥ 7 days
      orphan app data     : ≥ 30 days
      ```
      
      Always include the age filter in any `Get-ChildItem | Where-Object LastWriteTime -lt …` sweep. This single rule eliminates 90% of "I just built that yesterday" surprises.
      
      ## Path-validation rules (what the validator enforces)
      
      `assets/apply-cleanup-selection.py` is the safety core. It is a Windows **rewrite**, not a translation of the macOS validator, because NTFS/PowerShell give five separate ways to spell a protected path that a naive `startswith` misses. For every bare-delete command it:
      
      1. Rejects chaining/redirection metacharacters outright: `;  |  &  \`  $(  @(  ::  >  <` and newlines (PowerShell's injection surface is far wider than bash).
      2. Expands `%VAR%`/`$VAR`, then per path token rejects: UNC/device paths (`\\?\`, `\\.\`, `\\server`), 8.3 short names (`PROGRA~1`), non-filesystem providers (`HKLM:`, `Env:`, `Cert:`), and Alternate Data Streams (`file.txt:hidden`).
      3. Canonicalizes like Win32 `GetFullPath` (unify separators, collapse `..`, strip trailing dots/spaces), then requires a drive-absolute path and refuses a bare drive root.
      4. Classifies the path against an allow-list (HOME, TEMP, `%SystemRoot%\Temp`, scan roots) and a deny-list (the `never-touch.md` hard-protected prefixes) using **longest-prefix-wins**, so carve-outs resolve correctly (`C:\Windows` denied but `C:\Windows\Temp` allowed; `C:\Users\me` allowed but `C:\Users\me\.ssh` denied).
      5. Refuses any registry-provider delete entirely.
      
      Wrappers are trusted to self-police; the irreversible-destructive ones additionally require a protected-override. The adversarial test suite (`assets/test_validate_command.py`) pins all of this and runs on any OS — run it after any edit to the validator.
      
      ## Operational logging
      
      Every applied operation is appended (TSV, append-only) to `%LOCALAPPDATA%\win-health\operations.log`:
      
      ```
      TIMESTAMP \t apply-selection \t ACTION \t PATH \t SIZE \t STATUS
      2026-06-07 09:12:43  apply-selection  REMOVED  C:\Users\dev\proj\node_modules  1.1 GB  OK
      ```
      
      It's the audit trail when "what did we delete?" comes up later. Keep the same format for any hand-rolled sweep.
      
      ## Safe-purge harness (when scripting by hand)
      
      If you must sweep without the apply script, port these guards:
      
      ```powershell
      function Invoke-SafeRemove {
          param([Parameter(Mandatory)][string]$Target)
          $full = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Target))
          if ($full -notmatch '^[A-Za-z]:\\') { return }                       # drive-absolute only
          $item = Get-Item -LiteralPath $full -Force -EA SilentlyContinue
          if (-not $item) { return }
          if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { return }   # never follow junctions
          $low = $full.ToLowerInvariant()
          foreach ($deny in @("$env:SystemRoot","$env:ProgramFiles","${env:ProgramFiles(x86)}",
                              "$env:USERPROFILE\.ssh","$env:APPDATA\Microsoft\Protect")) {
              $d = ([System.IO.Path]::GetFullPath($deny)).ToLowerInvariant()
              if ($low -eq $d -or $low.StartsWith($d + '\')) { return }
          }
          Remove-Item -LiteralPath $full -Recurse -Force -EA SilentlyContinue
      }
      
      # Apply with an age filter:
      Get-ChildItem $root -Recurse -Depth 4 -Directory -Force -EA SilentlyContinue |
        Where-Object { $_.Name -eq 'node_modules' -and $_.LastWriteTime -lt (Get-Date).AddDays(-7) -and
                       -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) } |
        ForEach-Object { Invoke-SafeRemove $_.FullName }
      ```
      
      This is the minimal harness; the full apply-script validator is stricter (8.3/ADS/UNC/provider/longest-prefix). Prefer it.
      
      ## Third-party tools — optional, with caveats
      
      Use these as an *overlay* for user/app caches, never as a replacement for native tooling. Several have a real CLI:
      
      | Tool | CLI | Use | Caveat |
      |---|---|---|---|
      | **BleachBit** | `bleachbit_console.exe --preview --preset` then `--clean` | transparent cache/temp/browser cleaning | always `--preview` first; permanent |
      | **BCUninstaller** | console interface | bulk app removal with leftovers | review the leftover list |
      | **WizTree** | `wiztree64.exe C: /export=...` | fast disk audit (read MFT) | audit only, not a cleaner |
      | **Autorunsc** | `autorunsc -accepteula -a * -c` | startup inventory | disabling entries is the change, not deletion |
      | **winget** | `winget uninstall` | clean app removal | preferred over manual folder delete |
      
      **Avoid as a primary choice on a work machine:**
      
      - **Mole for Windows** — the author's own Windows branch is flagged "currently not mature" and "do not use" on machines with important data. Interesting for a test box; not the safe default here. Prefer `cleanmgr` / Storage Sense / DISM + the apply script.
      - **CCleaner Registry Cleaner** — registry cleaning is unsupported by Microsoft and can destabilize the system. If CCleaner is used at all, restrict to **Custom Clean** and disable the registry feature.
      - Any **"RAM optimizer" / "memory booster"** — purging working sets/standby lists degrades performance. For memory problems, diagnose causes (RAMMap/Autoruns/Task Manager), don't "free RAM".
      
    • never-touch.md 10.9 KB
      # Never-touch list
      
      Categories that **must not be deleted** even if the user asks, even elevated, even when desperate for space. Pushback expected: explain the consequence, suggest an alternative. The hard-protected prefixes below are kept in sync with the deny-list in `assets/apply-cleanup-selection.py` — if you edit one, edit both.
      
      ## Table of contents
      
      - [Golden rule: clean with commands, not by deleting folders](#golden-rule-clean-with-commands-not-by-deleting-folders)
      - [Hard-protected paths (system roots)](#hard-protected-paths-system-roots)
      - [Paging / hibernation / crash files](#paging--hibernation--crash-files)
      - [Boot, recovery & volume metadata](#boot-recovery--volume-metadata)
      - [Credential & key stores](#credential--key-stores)
      - [Cloud placeholders (Files On-Demand) & OneDrive KFM](#cloud-placeholders-files-on-demand--onedrive-kfm)
      - [Per-app folders that LOOK like cache but hold state](#per-app-folders-that-look-like-cache-but-hold-state)
      - [The registry is not a cleanup target](#the-registry-is-not-a-cleanup-target)
      - [Windows-only dangers with no macOS analog](#windows-only-dangers-with-no-macos-analog)
      - [How to handle pushback](#how-to-handle-pushback)
      
      ---
      
      ## Golden rule: clean with commands, not by deleting folders
      
      Windows maintains several areas itself. Hand-deleting them corrupts the OS; the **supported command** does it safely. Memorize this mapping — most "free space" footguns are someone reaching for `Remove-Item` where a command was required:
      
      | Area | NEVER `Remove-Item` | Use instead |
      |---|---|---|
      | Component Store (`WinSxS`) | manual delete = unbootable / un-updatable | `Dism /Online /Cleanup-Image /StartComponentCleanup` |
      | Driver Store (`System32\DriverStore`) | breaks devices / driver rollback | `pnputil /delete-driver` (after export) |
      | Shadow copies / restore points (`System Volume Information`) | loses all rollback | `vssadmin` / System Protection |
      | Event Logs | loses forensics | `wevtutil epl` then `cl /bu:` |
      | Windows Update store (`SoftwareDistribution`) | breaks WU | stop `wuauserv`+`bits`, rename, restart |
      | MSI cache (`Installer`, `Package Cache`) | breaks uninstall/repair of every MSI app | leave it; uninstall apps via winget/Apps |
      | pagefile / hiberfil | crash on delete | `powercfg`, System settings |
      | Registry | instability up to reinstall | export + targeted edit only |
      
      ---
      
      ## Hard-protected paths (system roots)
      
      Never delete inside these prefixes — one wrong path can brick the OS. (`%SystemRoot%` is normally `C:\Windows`; covers all of System32, SysWOW64, WinSxS, DriverStore, config, SoftwareDistribution, Installer, Minidump, Prefetch, servicing, assembly, Microsoft.NET, catroot/catroot2.)
      
      ```
      %SystemRoot%                         (C:\Windows and everything under it)
      %ProgramFiles%                       (C:\Program Files)
      %ProgramFiles(x86)%                  (C:\Program Files (x86))
      %ProgramW6432%
      C:\ProgramData\Microsoft             (crypto, Defender, provisioning)
      C:\ProgramData\Package Cache         (MSI/Burn bootstrapper — repair/uninstall)
      ```
      
      The one carve-out inside `C:\Windows` that IS cleanable is `C:\Windows\Temp` (the validator allows it via longest-prefix-wins). Everything else under `%SystemRoot%` is off-limits to bare deletes.
      
      ## Paging / hibernation / crash files
      
      Two separate swap files on Windows (macOS has one concept) — protect both:
      
      ```
      C:\pagefile.sys       # classic page file — modified pages + crash dumps
      C:\swapfile.sys       # UWP/Store app swap — separate from pagefile
      C:\hiberfil.sys        # hibernation image — manage via powercfg /h
      C:\DumpStack.log.tmp
      %SystemRoot%\MEMORY.DMP, %SystemRoot%\Minidump   # forensics until the crash is triaged
      ```
      
      `pagefile.sys` is **not junk**: it backs infrequently-used modified pages and is required for crash dumps. Size tracks peak commit + dump policy, not a "× RAM" formula. Leave it system-managed.
      
      ## Boot, recovery & volume metadata
      
      A sudo-equivalent recursive delete can reach these even though they're hidden. **On a BitLocker volume, damaging boot/EFI/Recovery triggers a recovery-key prompt the user may not have** — doubly important.
      
      ```
      EFI System Partition, \Boot, \EFI, bootmgr, \BCD
      C:\$Recycle.Bin                  # use Clear-RecycleBin, never Remove-Item internals
      C:\System Volume Information     # VSS shadow data + restore points + search index
      C:\Recovery                      # WinRE.wim — recovery environment
      C:\$WinREAgent                   # in-progress update rollback
      C:\Config.Msi                    # transactional MSI rollback (lethal mid-install)
      C:\PerfLogs
      ```
      
      Also: never delete the **BitLocker recovery key** if escrowed locally.
      
      ## Credential & key stores
      
      Tiny but irreplaceable. Cleaner tools that scan `$HOME` for "old hidden files" flag these — never include them, even in a "clean old dotfiles" pass. The validator denies them even though they sit under the allowed HOME root (deny beats allow via longest-prefix).
      
      | Path | What it stores | Recovery if deleted |
      |---|---|---|
      | `%USERPROFILE%\.ssh` | private SSH keys | none — regenerate + redistribute pubkeys |
      | `%USERPROFILE%\.gnupg` | GPG secret keyring | none |
      | `%USERPROFILE%\.aws`, `.azure`, `.kube` | cloud access keys / kube contexts | re-issue / re-fetch |
      | `%USERPROFILE%\.docker\config.json` | registry auth tokens | `docker login` again |
      | `%USERPROFILE%\.netrc`, `.git-credentials` | plaintext credentials | from password manager |
      | `%APPDATA%\Microsoft\Protect` | **DPAPI master keys** | deletion = all DPAPI-encrypted blobs unrecoverable |
      | `%APPDATA%\Microsoft\Crypto`, `\SystemCertificates` | RSA/DSS keys, certs | none |
      | `%APPDATA%\Microsoft\Credentials`, `%LOCALAPPDATA%\Microsoft\Credentials` | Credential Manager vault | none |
      | `%APPDATA%\gh\hosts.yml` | GitHub CLI tokens | `gh auth login` |
      
      Heuristic: any dotfile/dotfolder under `%USERPROFILE%` outside an explicit cache (e.g. `.cache`, `.npm\_cacache`, `.gradle\caches`) should be assumed credential- or config-bearing.
      
      ## Cloud placeholders (Files On-Demand) & OneDrive KFM
      
      Detect by **file attribute**, not by path list — this generically covers OneDrive, Dropbox, Google Drive:
      
      - Refuse to delete any item with `FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS`, `RECALL_ON_OPEN`, or `OFFLINE` set. These are dehydrated cloud placeholders; deleting the stub deletes the cloud original and propagates to every device.
      - **OneDrive Known Folder Move (KFM):** `Desktop`, `Documents`, `Pictures` are frequently redirected into `%OneDrive%`. So "clean the Desktop" or "review Downloads" has different stakes there. Resolve those folders and check for a reparse point / `*OneDrive*` target before treating them as local (the audit script reports this). `Downloads` is usually NOT redirected; `Desktop`/`Documents` often ARE.
      
      ## Per-app folders that LOOK like cache but hold state
      
      Windows has no bundle-ID namespace, so protection is **path-based** under `%APPDATA%` / `%LOCALAPPDATA%`. Only the cache-family subfolders (`Cache`, `Code Cache`, `GPUCache`, `Service Worker\CacheStorage`, `DawnCache`) are safe; everything else is state.
      
      | App | Path | What it actually is |
      |---|---|---|
      | Telegram | `%APPDATA%\Telegram Desktop\tdata\` | full chat history + session keys — delete = logout + lost history |
      | Slack | `%APPDATA%\Slack\storage\`, `IndexedDB\` | workspace prefs, draft messages |
      | Notion | `%APPDATA%\Notion\...\IndexedDB\` | offline page data |
      | Teams | `%APPDATA%\Microsoft\Teams\` (and the new Store app data) | account/auth + cached messages |
      | Browsers | `...\User Data\Default\` (Edge/Chrome/Brave) | history, cookies, **extension state incl. password managers / MetaMask seed** |
      | VS Code / Cursor | `%APPDATA%\Code\User\`, `globalStorage\` | settings, snippets, extension state |
      | JetBrains | `%APPDATA%\JetBrains\<IDE><ver>\` | settings, project list, keymaps, licenses |
      | WSL distros | `%LOCALAPPDATA%\Packages\<distro>\LocalState\ext4.vhdx` | the **entire Linux filesystem** — compact, never delete |
      | Docker Desktop | `%LOCALAPPDATA%\Docker\wsl\...\*.vhdx` | images + volumes — compact, never delete |
      | Password managers | `%LOCALAPPDATA%\1Password`, `%APPDATA%\Bitwarden` etc. | vault/session state |
      | Crypto wallets | `%APPDATA%\Exodus`, `Ledger Live`, browser-extension state | encrypted wallet / seed-derived data |
      
      When unsure for an AppData folder: only `Cache`/`Code Cache`/`GPUCache`/`Service Worker` subdirs are safe. Everything else, ask.
      
      ## The registry is not a cleanup target
      
      Microsoft does not support registry cleaners; "registry bloat" is not a meaningful space or performance source. The apply-script **refuses any delete against a registry provider** (`HKLM:`, `HKCU:`, …). For a concrete fix, export the specific key (`reg export` / Registry Editor) and edit it by hand — never bulk-"optimize".
      
      ## Windows-only dangers with no macOS analog
      
      These are easy to forget because macOS has no equivalent:
      
      1. **Reparse points / junctions / symlinks.** `Remove-Item -Recurse` can follow a junction and delete the *target's* contents — the classic data-loss bug. Dev trees are full of junctions (npm/pnpm store, Docker, WSL). Always use `-LiteralPath`; never recurse through `FILE_ATTRIBUTE_REPARSE_POINT` (the audit script lists reparse points under scan roots).
      2. **`Remove-Item` is permanent — no Recycle Bin.** Unlike dragging to the bin, `Remove-Item` deletes immediately. Users expect "delete = recoverable"; it isn't here. Prefer moving to a dated quarantine folder over `Remove-Item` when the user is unsure.
      3. **Controlled Folder Access (ransomware protection).** If on, deletes in Documents/Pictures/Desktop fail with Access Denied **from Defender, not from permissions**. Distinguish a CFA block from a real error so the tool isn't blamed (`Get-MpPreference | Select EnableControlledFolderAccess`).
      4. **System Restore / VSS.** `vssadmin delete shadows` frees space but destroys the user's rollback net (the analog of Time Machine local snapshots). Treat like the macOS `tmutil` rule: only via the supported tool, only after warning.
      5. **MSI `Installer` cache + `Package Cache`.** Every "free space" tutorial wrongly recommends cleaning `C:\Windows\Installer` — it breaks uninstall/repair/patch for every MSI app. Hard no.
      6. **Prefetch.** Cleaner-tool muscle memory reaches for it; deleting it slows boot for negligible space. Leave it.
      
      ## How to handle pushback
      
      User says "I know it's risky, just delete it":
      
      1. State the consequence concretely ("you'll lose every System Restore point and can't roll back this driver update", not "system data").
      2. Offer an alternative: the supported command, an export/backup first, or move-to-quarantine instead of delete.
      3. If they insist, document in chat: exact path, exact action, exact consequence. Make them say "yes, delete X which holds Y, I accept losing Z."
      4. Even then prefer non-destructive: `Move-Item` to `%USERPROFILE%\.quarantine-YYYYMMDD\` over `Remove-Item`. Easy to recover if regretted.
      
      Risk is asymmetric: false caution costs disk space; one bad delete costs weeks of work, an un-decryptable DPAPI vault, or an unbootable machine. Bias toward caution.
      
    • triage.md 8 KB
      # Triage — first 5 minutes
      
      When the user reports trouble, or an alert fires, identify which signal class fired before doing anything destructive. The right response differs. Run the snapshot read-only; nothing here deletes anything.
      
      ## Table of contents
      
      - [Quick state snapshot](#quick-state-snapshot-always-run-first)
      - [Signal classification](#signal-classification)
        - [A. Disk-driven](#a-disk-driven-most-common)
        - [B. Memory / commit-driven](#b-memory--commit-driven)
        - [C. BSOD / crash / unexpected shutdown](#c-bsod--crash--unexpected-shutdown)
        - [D. New crash dump appeared](#d-new-crash-dump-appeared)
        - [E. "PC just feels slow"](#e-pc-just-feels-slow)
      - [Decision tree](#decision-tree)
      - [What to NOT do at triage](#what-to-not-do-at-triage)
      
      ## Quick state snapshot (always run first)
      
      Run in an **elevated** PowerShell (`Get-Counter` memory objects, `Get-WinEvent System`, and DISM need admin). Everything here is read-only.
      
      ```powershell
      Write-Host "=== Disk ==="
      Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' |
        Select-Object DeviceID,
          @{N='SizeGB';E={[math]::Round($_.Size/1GB,1)}},
          @{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,1)}},
          @{N='FreePct';E={[math]::Round($_.FreeSpace/$_.Size*100,1)}} | Format-Table -Auto
      
      Write-Host "=== Memory / commit ==="
      $os = Get-CimInstance Win32_OperatingSystem
      "RAM free %  = {0}" -f [math]::Round($os.FreePhysicalMemory/$os.TotalVisibleMemorySize*100,1)
      Get-Counter '\Memory\Available MBytes','\Memory\% Committed Bytes In Use','\Paging File(_Total)\% Usage' -EA SilentlyContinue |
        Select-Object -Expand CounterSamples | Select-Object Path, CookedValue | Format-Table -Auto
      
      Write-Host "=== Recent crashes (7d) ==="
      Get-WinEvent -FilterHashtable @{LogName='System'; Id=41,1001,6008; StartTime=(Get-Date).AddDays(-7)} -EA SilentlyContinue |
        Select-Object TimeCreated, Id, ProviderName | Sort-Object TimeCreated -Desc | Select-Object -First 10 | Format-Table -Auto
      Get-ChildItem "$env:SystemRoot\Minidump" -Filter *.dmp -EA SilentlyContinue |
        Sort-Object LastWriteTime -Desc | Select-Object -First 5 Name, LastWriteTime
      
      Write-Host "=== Top working-set processes ==="
      Get-Process | Sort-Object WS -Desc | Select-Object -First 15 Name, Id,
        @{N='WS_MB';E={[math]::Round($_.WS/1MB)}}, @{N='CPU';E={[math]::Round($_.CPU)}} | Format-Table -Auto
      
      Write-Host "=== Health-check log if installed ==="
      $hl = Join-Path $env:LOCALAPPDATA 'win-health\logs\health.log'
      if (Test-Path $hl) { Get-Content $hl -Tail 20 }
      ```
      
      ## Signal classification
      
      ### A. Disk-driven (most common)
      
      Symptoms: low free space, "out of space", install/update failures.
      
      - **free % < 10** → CRITICAL. Run Tier 1–6 from `cleanup-tiers.md` immediately.
      - **free % 10–20** → HIGH. Run Tier 1–3, propose Tier 4–6. Usually manageable.
      - **free % > 20** → user-perception issue. Audit with a tree sizer (WizTree CLI export, or `Get-ChildItem $env:USERPROFILE -Directory | %{ ... }`) and target specifically.
      
      Prefer `Get-CimInstance Win32_LogicalDisk` (uint64 bytes) over `Get-PSDrive` (doubles) for the threshold math.
      
      ### B. Memory / commit-driven
      
      Symptoms: slow, stuttering, swap/pagefile growing, fans up.
      
      - **Commit % is the primary signal** (`\Memory\% Committed Bytes In Use`). Commit charge is total reserved virtual memory (RAM + pagefile). Commit-limit exhaustion is the real Windows OOM — the analog of macOS swap saturation.
        - **> 85 %** → critical; commit failures / OOM risk. Pair with **available RAM < 5 %** for a true CRITICAL.
        - **75–85 %** → warning; pagefile is expanding aggressively.
        - **< 60 %** → healthy.
      - **Do NOT alarm on "In Use" RAM % alone.** `\Memory\Available MBytes` includes **standby/cache** pages that are instantly reclaimable. Task Manager showing 14/16 GB "in use" with 2 GB available is normal and healthy. Memory Compression (`(Get-MMAgent).MemoryCompression`) extending RAM is also normal.
      - VM-driven: WSL2 (`vmmem`/`vmmemWSL`) and Docker Desktop can dominate. Check WSL memory cap in `%USERPROFILE%\.wslconfig` (`memory=`). Rule of thumb: keep WSL ≤ ~1/2 host RAM.
      
      ### C. BSOD / crash / unexpected shutdown (rare but severe)
      
      Symptoms: PC rebooted on its own; "Windows recovered from an unexpected shutdown".
      
      ```powershell
      $since = (Get-Date).AddDays(-14)
      Get-WinEvent -FilterHashtable @{LogName='System'; ProviderName='Microsoft-Windows-Kernel-Power'; Id=41; StartTime=$since} -EA SilentlyContinue |
        Select-Object TimeCreated, @{N='BugcheckCode';E={ ([xml]$_.ToXml()).Event.EventData.Data | ?{$_.Name -eq 'BugcheckCode'} | %{$_.'#text'} }}
      Get-WinEvent -FilterHashtable @{LogName='System'; ProviderName='Microsoft-Windows-WER-SystemErrorReporting'; Id=1001; StartTime=$since} -EA SilentlyContinue |
        Select-Object TimeCreated, Message
      ```
      
      Interpret:
      - **Kernel-Power 41** with `BugcheckCode = 0` → power loss / hard reset (no BSOD). Nonzero `BugcheckCode` (decimal stop code) → a bluescreen occurred.
      - **WER-SystemErrorReporting 1001** → the BSOD record with the stop code and dump path.
      - **WHEA-Logger 1** (fatal) / **18** (corrected) → hardware errors. A single corrected error after S0ix resume is usually benign; sustained sequences are not.
      - Cross-check disk free at the time and whether commit was saturated — a memory/commit crisis can precede an OOM-driven crash.
      
      After a crash of this class: confirm it's a one-off (look back 30 days), run cleanup Tier 1–7 for breathing room, install/verify the alerter (`alerting.md`), and if a VM (WSL/Docker/Hyper-V) was the proximate cause, discuss reducing its memory cap.
      
      ### D. New crash dump appeared
      
      This is the alerter's loudest signal and a leading indicator of (C). Find recent dumps:
      
      ```powershell
      Get-ChildItem "$env:SystemRoot\Minidump" -Filter *.dmp -EA SilentlyContinue |
        Where-Object LastWriteTime -gt (Get-Date).AddDays(-2) |
        Select-Object FullName, LastWriteTime, @{N='MB';E={[math]::Round($_.Length/1MB,1)}}
      Get-Item "$env:SystemRoot\MEMORY.DMP" -EA SilentlyContinue
      ```
      
      If a fresh dump exists, the system already bugchecked. **Keep the dumps** until triaged — they are forensic evidence (never delete as "cleanup" before the cause is understood). Analyze with WinDbg/`!analyze -v` or `Get-WinEvent` 1001 for the stop code.
      
      ### E. "PC just feels slow"
      
      Usually commit/memory pressure, thermal throttling, or startup bloat — not a "RAM cleaner" problem.
      
      ```powershell
      # Startup inventory (causes, not symptoms). Autorunsc (Sysinternals) is the CLI form of Autoruns:
      #   autorunsc.exe -accepteula -a * -c -h -s | ConvertFrom-Csv | Sort-Object Entry
      Get-CimInstance Win32_StartupCommand | Select-Object Name, Command, Location | Format-Table -Auto
      (Get-MMAgent).MemoryCompression   # True = OS compressing pages (normal, not a fault)
      ```
      
      If nothing stands out, fall back to the memory/commit flow (B). **Do not run "RAM optimizers"** — they purge working sets/standby and make the next access slower, not faster.
      
      ## Decision tree
      
      ```
      ALERT or USER REPORT
             │
             ▼
         free % < 20? ──yes──▶ A. Disk-driven (cleanup-tiers.md)
             │
             no
             ▼
         crash event /
         dump last 24h? ──yes──▶ C/D. Read stop code, keep dumps, propose cleanup + alerter
             │
             no
             ▼
         commit % > 85 AND
         RAM free < 5%? ──yes──▶ B. Memory/commit-driven, check WSL/Docker caps
             │
             no
             ▼
         thermal / startup ──yes──▶ E. Startup audit, thermal advice (no RAM cleaners)
             │
             no
             ▼
         user perception → tree-size audit, propose targeted cleanup
      ```
      
      ## What to NOT do at triage
      
      - Don't run a broad cleanup reflexively. Identify what's hurting first.
      - Don't delete crash dumps before the crash is triaged — they're evidence.
      - Don't delete `pagefile.sys` / `swapfile.sys` / `hiberfil.sys`, touch WinSxS by hand, or run a registry cleaner. None of those are "cleanup".
      - Don't run "RAM optimizer" tools — purging standby/working sets degrades performance.
      - Don't `Stop-Process` the top working-set process to "free memory" — corrupts open handles (Docker volumes, DB writes, unsaved work). Ask the user to close it gracefully.
      
  • .gitignore 44 B · in bundle
  • README.md 6.2 KB
    # maintaining-windows-health
    
    A hands-on playbook skill for **Windows 11** disk cleanup, dev-machine optimization, and proactive health alerting. It is the Windows port of [`maintaining-macos-health`](../maintaining-macos-health/) — same three-layer architecture and the same drift-protection safety invariant, rebuilt around Windows-native tooling.
    
    > **Recovery and prevention**, not blind deletion. The skill follows a managed cycle: inventory → classify → delete only with Microsoft-supported tools → verify integrity → keep a rollback path.
    
    ## What it does
    
    Three layers, mirroring the macOS skill:
    
    1. **Triage** (`references/triage.md`) — classify which signal fired: disk-driven, commit-memory-driven, BSOD/crash, or "feels slow" — with a read-only PowerShell snapshot.
    2. **Recovery** (`references/cleanup-tiers.md`, `never-touch.md`, `native-tools.md`) — a 10-tier, risk-ordered cleanup playbook (Storage Sense → discuss-first), a hard blacklist of what never to delete, and the native-tooling safety floor + project-artifact purge map.
    3. **Automation / alerting** (`references/alerting.md`, `assets/*.ps1`) — a drift-protected HTML cleanup UI plus a Task Scheduler + BurntToast alerter (3 CRITICAL-only triggers, hysteresis, calibration window).
    
    ## The safety invariant
    
    ```
    scan  →  build JSON  →  user picks in the HTML UI  →  selection JSON  →  apply deletes ONLY selected_items
    ```
    
    `assets/apply-cleanup-selection.py` is the only sanctioned way to apply a cleanup. It never hand-rolls `Remove-Item`; it reads the user's picks from the selection JSON and validates every command before running it. The validator is a **Windows rewrite** (not a translation) of the macOS one, because NTFS + PowerShell offer several ways to spell a protected path that a naive check misses:
    
    - NTFS path canonicalization (separator unification, `..` collapse, trailing dot/space stripping) like Win32 `GetFullPath`
    - refusal of UNC/device paths (`\\?\`, `\\.\`), 8.3 short names (`PROGRA~1`), Alternate Data Streams, and non-filesystem providers (`HKLM:`, `Env:`)
    - a **longest-prefix-wins** allow/deny classifier so carve-outs resolve correctly (`C:\Windows` denied, but `C:\Windows\Temp` allowed; `C:\Users\me` allowed, but `C:\Users\me\.ssh` denied)
    - a **two-tier wrapper** model: cache tools (npm/docker/dotnet…) are trusted; irreversible tools (`vssadmin delete`, DISM `/ResetBase`, `wevtutil cl`, `pnputil /delete-driver`, `powercfg /h off`) require an explicit protected-override
    - a refusal of command chaining/injection metacharacters (`; | & \` $( :: > <`)
    
    ## Quick start
    
    Read-only preflight audit:
    
    ```powershell
    .\assets\Audit-WinHealth.ps1     # drives, Component Store, shadow storage, drivers, CFA, KFM, BitLocker, reparse points, dumps
    ```
    
    Interactive cleanup (requires Python 3 for the HTML UI):
    
    ```powershell
    python3 .\assets\render-cleanup-plan.py %TEMP%\cleanup-data.json   # opens the picker, writes the selection JSON
    python3 .\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json   # applies only what was picked
    ```
    
    Install the alerter (non-elevated, interactive session — required for toasts):
    
    ```powershell
    .\assets\Install-WinHealthCheck.ps1        # installs BurntToast, registers the 5-min task, runs once
    .\assets\Install-WinHealthCheck.ps1 -Test  # synthetic disk alert to confirm the toast pipeline
    ```
    
    ## File map
    
    ```
    SKILL.md                         agent entry point (workflows A–D, safety rules, quirks)
    references/
      triage.md                      which signal fired (read-only snapshot)
      cleanup-tiers.md               10 risk-ordered tiers, PowerShell blocks
      never-touch.md                 hard blacklist + Windows-only dangers
      native-tools.md                safety floor, purge map, validator rules, 3rd-party caveats
      alerting.md                    Task Scheduler + BurntToast + ntfy alerter design
    assets/
      win-health-check.ps1           the monitor (PS 5.1 compatible)
      win-health-check.config.ps1    thresholds
      Install-WinHealthCheck.ps1     registers the task (interactive session); -Test / -Uninstall
      Audit-WinHealth.ps1            read-only preflight inventory
      render-cleanup-plan.py         HTML cleanup UI (cross-platform, Python 3)
      apply-cleanup-selection.py     the only sanctioned apply path (Windows validator)
      test_validate_command.py       adversarial validator tests (safety-core gate)
    ```
    
    ## Key Windows-specific guardrails
    
    - **Toasts need an interactive session.** The scheduled task is registered as the logged-on user (`LogonType Interactive`), never SYSTEM — a SYSTEM task's toasts silently no-op.
    - **`Remove-Item` is permanent** (no Recycle Bin) and **recursive deletes can escape through junctions** — always `-LiteralPath`, never recurse a reparse point.
    - **OneDrive KFM / Files On-Demand** — deleting redirected/placeholder files propagates to the cloud and every device.
    - **Clean with commands, not folders** — WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil, hiberfil via powercfg. The registry is never a cleanup target.
    - **Commit %, not "available MB", is the memory signal** — `Available MBytes` includes reclaimable standby/cache.
    
    ## Verification status
    
    Authored and statically verified on a macOS machine (no Windows runtime available):
    
    - ✅ **Verified here:** PowerShell AST parse of all `.ps1` (via `pwsh`), `py_compile` of both Python scripts, and the adversarial validator suite (`test_validate_command.py`) — pure NTFS string logic that runs on any OS and is the safety-core gate.
    - ⚠️ **Requires a Windows smoke-test before fully trusting:** real NTFS behavior of `GetFullPath`/8.3/ADS/reparse resolution, toast rendering and AUMID attribution, SYSTEM-vs-interactive session behavior, Task Scheduler registration semantics on S0ix/battery, and the exact output of DISM/vssadmin/wevtutil/pnputil. Gate the alerter and apply-on-real-paths behind this smoke-test.
    
    ## Credits & sources
    
    Built on official Microsoft Learn guidance for Storage Sense, `cleanmgr`, DISM (WinSxS), `pnputil`, `vssadmin`, `wevtutil`, `powercfg`, `Get-WinEvent`, Task Scheduler, and BurntToast, plus the macOS sibling's incident-validated architecture. See `references/` for inline citations of the behavior each rule depends on.
    
  • SKILL.md 15.8 KB
    ---
    name: maintaining-windows-health
    description: Hands-on playbook for Windows 11 disk cleanup, dev-machine optimization, and proactive health alerting. Use when the PC is full or slow, when a BSOD / Kernel-Power 41 / crash dump / commit-memory pressure happened, when the user asks to free disk space, audit storage, set up disk/memory alerts, or restore the same monitoring on a new PC. Built around native Microsoft-supported tooling (Storage Sense, cleanmgr, DISM, pnputil, vssadmin, wevtutil, powercfg) as the safety floor, a drift-protected HTML cleanup UI, and a Task Scheduler + BurntToast alerter. Covers dev machines with heavy AI/Docker/WSL workloads. Not for general Windows support, hardware diagnostics, GPU/driver troubleshooting, antivirus/malware removal, Windows Update repair, networking, or app-specific performance problems unrelated to disk or memory pressure.
    ---
    
    # Maintaining Windows 11 Health
    
    Recovery and prevention playbook for Windows 11 disk and memory crises. A Windows port of the `maintaining-macos-health` skill: same three layers (triage → tiered recovery → automation/alerting) and the same safety invariant — **scan → JSON → user picks in a UI → apply deletes only what was picked** — but with Windows-native tooling and Windows-specific "never touch" rules. The same playbook works for routine cleanup or first-time setup on a new machine.
    
    ## Table of contents
    
    - [When to use](#when-to-use)
    - [Skill layout](#skill-layout)
    - [Core mental model](#core-mental-model)
    - [Standard workflows](#standard-workflows)
      - [A. "Free space NOW" (incident response)](#a-free-space-now-incident-response)
      - [B. "Set up alerting" (new machine or first time)](#b-set-up-alerting-new-machine-or-first-time)
      - [C. Alerter stopped working / making noise](#c-alerter-stopped-working--making-noise)
      - [D. "Uninstall an app cleanly"](#d-uninstall-an-app-cleanly)
    - [Safety rules (non-negotiable)](#safety-rules-non-negotiable)
    - [Domain quirks captured](#domain-quirks-captured)
    - [Outcomes scale](#outcomes-scale)
    
    ## When to use
    
    Trigger on any of:
    
    - Disk free < 20 % or user complains about being out of space
    - BSOD / unexpected reboot / Kernel-Power 41 / WHEA error / a new crash dump in `C:\Windows\Minidump`
    - "PC is slow", commit pressure high (`% Committed Bytes In Use` > 85), pagefile growing
    - User wants to set up monitoring/alerting from scratch
    - Migration to a new Windows PC → restore the same alerter
    - General "clean my PC" / "audit storage" / "free space" requests
    
    ## Skill layout
    
    | File | Use for |
    |---|---|
    | `references/triage.md` | First 5 minutes — which signal fired (disk / commit-memory / BSOD-crash / "feels slow"), read-only snapshot |
    | `references/cleanup-tiers.md` | Tiered cleanup playbook (10 tiers, low-risk → discuss-first), copy-paste-safe PowerShell blocks |
    | `references/never-touch.md` | Categories that **must not** be deleted even elevated (hard-protected prefixes synced with the validator + Windows-only dangers) |
    | `references/native-tools.md` | The safety floor: native Microsoft tooling, project-artifact purge marker→target map, the apply-script validator rules, third-party caveats |
    | `references/alerting.md` | Full alerter design: 3 CRITICAL-only triggers, hysteresis, calibration, Task Scheduler interactive session, BurntToast + ntfy, S0ix/battery |
    | `assets/win-health-check.ps1` | Production PowerShell monitor (PS 5.1 compatible) |
    | `assets/win-health-check.config.ps1` | Default thresholds |
    | `assets/Install-WinHealthCheck.ps1` | Registers the scheduled task in the interactive user session; `-Test` / `-Uninstall` |
    | `assets/Audit-WinHealth.ps1` | Read-only preflight inventory (drives, Component Store, shadow storage, profiles, drivers, CFA, KFM, BitLocker, reparse points, dumps) |
    | `assets/render-cleanup-plan.py` | Interactive HTML cleanup-plan UI. Renders categorised checkboxes from a JSON of scan findings, serves on `127.0.0.1:18347`, opens the browser, waits for the user's selection, writes it to `%TEMP%\cleanup-selection-<ts>.json`. Used by Workflow A. **Requires Python 3.** |
    | `assets/apply-cleanup-selection.py` | The **only sanctioned way** to apply a cleanup selection. Reads `selected_items` from a selection JSON and executes each item's `command` via PowerShell. Windows-rewritten validator (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal, two-tier wrappers) + operations log. Supports `--dry-run`. |
    | `assets/test_validate_command.py` | Adversarial unit tests for the validator — the safety-core gate. Runs on any OS. |
    
    Read the relevant reference before acting. Do NOT operate from memory of these files — the details are calibrated to Windows-specific failure modes and small changes break safety.
    
    ## Core mental model
    
    1. **Native tooling is the safety floor.** Unlike macOS (Mole), Windows has no mature community safety tool — so Storage Sense / Cleanup recommendations / `cleanmgr` / DISM do the heavy lifting, and the apply-script validator is the *only* line behind project-artifact purge. Use the supported command for anything the OS maintains itself.
    2. **Clean with commands, not by deleting folders.** WinSxS → DISM; Driver Store → pnputil; shadow copies → vssadmin; Event Logs → wevtutil; hiberfil → powercfg. Hand-deleting these corrupts the OS.
    3. **Commit %, not "available MB", is the memory signal.** `Available MBytes` includes reclaimable standby/cache; commit-limit exhaustion is the real Windows OOM.
    4. **Drift protection via the selection JSON.** The HTML UI writes exactly what the user picked; `apply-cleanup-selection.py` deletes only `selected_items`. Never hand-roll `Remove-Item` in the apply phase.
    5. **Escalate conservatively.** Start zero-risk (Storage Sense, caches, Recycle Bin), only reach project artifacts, Docker/WSL VHDX, Component Store, and elevated/discuss-first tiers if needed.
    
    ## Standard workflows
    
    ### A. "Free space NOW" (incident response)
    
    1. **Triage** — read `references/triage.md`, identify which signal fired and how urgent. Run `assets/Audit-WinHealth.ps1` (read-only) for the preflight inventory (drives, Component Store, shadow storage, drivers, **CFA / OneDrive KFM / BitLocker / reparse points**).
    2. **Snapshot baseline** — record free GB on the target drive.
    3. **Run all scans, don't delete yet** — work the tiers in `references/cleanup-tiers.md` in *inventory* mode (list candidates, sizes, ages), including the large-file visibility pass. Capture everything before opening the browser; deletion comes only after the user picks via the UI. The cleanup UI should be a single final questionnaire containing safe cleanup candidates, protected/discuss-first items, and large suspicious user/workload files together, not a sequence of separate UI rounds.
    4. **Python gate** — the cleanup UI needs Python 3. Check for a real interpreter (`py -3 --version`, or a `python.exe` that isn't the Microsoft Store alias). **If Python is missing, ask the user for permission to install it** (`winget install Python.Python.3.12`), then continue. Do not silently skip the UI.
    5. **Resolve unknown items before building JSON** — for every candidate > 500 MB you cannot explain in one sentence (unfamiliar app/folder, vendor cache, VM/VHDX, model weights), research it first: check `references/never-touch.md`, then delegate a quick lookup to the `web-searcher` subagent ("what is `<path>` on Windows 11, safe to delete in 2026"). Write a concrete `description` (1–3 sentences in the user's language) into the item. **Never show vague placeholders like "unknown".**
    6. **Show large-but-not-safe items too** — any explainable item > 500 MB that is user-owned or workload-owned but not a safe cache must still appear in the same final UI, normally `protected: true`, `default_selected: false`, with a concrete `warning`. Examples: old `.7z/.zip/.iso` archives, `.gguf`/`.safetensors` model weights, project/game assets, app diagnostic dumps, profiler snapshots, synced-folder media, and VHDX files. Visibility is required because only the user can know whether these are still needed. Do not include hard-protected system internals as bare-delete candidates; show them only through supported tools, or omit deletion entirely if no supported action exists.
    7. **Build one data JSON** — every candidate becomes a structured `item` (id, label, path, size_bytes, age_days, kind, **PowerShell `command`**, mandatory `description`, optional `protected` + `warning`). Use the schema in `assets/render-cleanup-plan.py`. Irreversible-tool commands (DISM `/ResetBase`, `vssadmin delete`, `wevtutil cl`, `pnputil /delete-driver`, `powercfg /h off`) should be marked `protected: true`. Do not open the UI until this unified JSON contains both safe default-selected items and protected unchecked items.
    8. **Render and open the cleanup UI**:
       ```powershell
       python3 <skill>\assets\render-cleanup-plan.py %TEMP%\cleanup-data-<ts>.json
       ```
       It serves on `127.0.0.1:18347`, opens the browser, and **blocks** until Submit/Cancel. On submit it writes `%TEMP%\cleanup-selection-<ts>.json`. Tell the user out loud: "браузер открыт — поставь галочки, нажми Submit, потом пингани меня." Then **stop and wait**.
    9. **After the user pings** — read the selection JSON, render their choices back in chat (categories, item list, total GB, any protected overrides flagged), and **ask one explicit confirmation** before deleting.
    10. **Apply via the helper — never hand-rolled `Remove-Item`**:
       ```powershell
       python3 <skill>\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json
       #   add --scan-root D:\projects to allow bare deletes on an external dev drive
       ```
       It reads `selected_items`, validates each `command` (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal), skips protected items not in `protected_overrides`, runs each via `powershell.exe`, and logs to `%LOCALAPPDATA%\win-health\operations.log`. `--dry-run` previews. **The selection JSON is the single source of truth.**
    11. **Post-check** — report the free-space delta, then `Dism /Online /Cleanup-Image /CheckHealth` + `sfc /scannow` to confirm nothing was broken. Stop at the goal.
    
    Hard-protected items (per `references/never-touch.md`) must always appear in the UI with `"protected": true` + a concrete `warning` — the UI dims them and requires a per-item confirm before they can be checked. Never omit a protected item user data depends on; visibility teaches the surrounding risk.
    
    ### B. "Set up alerting" (new machine or first time)
    
    Run a **non-elevated** PowerShell as the user who should receive alerts (interactive session — required for toasts):
    
    ```powershell
    cd <skill>\assets
    .\Install-WinHealthCheck.ps1     # installs BurntToast, copies script+config, registers the task, runs once
    ```
    
    Then read `references/alerting.md` for tuning. The task is registered in the interactive user session (NOT SYSTEM — that silently swallows toasts), every 5 minutes, `-StartWhenAvailable`, battery-friendly. The first 7-day calibration window is silent (logs only). Verify with `.\Install-WinHealthCheck.ps1 -Test`.
    
    ### C. Alerter stopped working / making noise
    
    Read `references/alerting.md` § Troubleshooting. Common causes:
    - Task running as SYSTEM / "whether user is logged on or not" → toasts silently never render. Re-run the installer (interactive principal).
    - BurntToast not installed → toast attributed to "PowerShell" or absent. `Install-Module BurntToast -Scope CurrentUser`.
    - Laptop never checks → was AC-only / paused in sleep. Re-run installer (battery flags + `-StartWhenAvailable`).
    - Constant alerts during heavy work → create `%LOCALAPPDATA%\win-health\silent`, raise thresholds, or increase hysteresis.
    
    ### D. "Uninstall an app cleanly"
    
    Prefer `winget uninstall --id <App.Id>` (clean, supported). For apps with stubborn leftovers, BCUninstaller (review the leftover list) is the dev-friendly option. Never hand-delete `Program Files` install dirs or registry keys to "remove" an app — that orphans the MSI/uninstall state. Always confirm before removing user data folders.
    
    ## Safety rules (non-negotiable)
    
    1. **Never delete without dry-run + user confirmation** for any tier ≥ 5 or any elevated operation.
    2. **Never bypass `references/never-touch.md`** — even if the user explicitly asks. Push back, explain the consequence.
    3. **Clean with commands, not folders.** WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil (export before clear), hiberfil via powercfg. Never `Remove-Item` these.
    4. **The registry is never a cleanup target.** No registry cleaners (Microsoft-unsupported). The validator refuses registry-provider deletes.
    5. **Apply phase reads only the selection JSON.** Never hand-roll `Remove-Item` or hard-code paths from the earlier scan when applying — that's how you delete items the user unchecked. Use `assets/apply-cleanup-selection.py`, which iterates `selected_items` only.
    6. **`Remove-Item` is permanent** (no Recycle Bin) and recursive deletes can escape through junctions — always `-LiteralPath`, never recurse a reparse point. Prefer move-to-quarantine when the user is unsure.
    7. **No auto-cleanup tied to alerts.** Alerts notify; the human decides.
    8. **Keep crash dumps until triaged** — they're forensic evidence, not cleanup.
    
    ## Domain quirks captured
    
    - **SYSTEM session can't show toasts.** A scheduled task as SYSTEM executes but its toasts silently no-op (Session 0 has no desktop). Register the task in the interactive user session — the Windows analog of the macOS "osascript → Script Editor" trap.
    - **`Remove-Item -Recurse` follows junctions** and can delete the target's contents — the classic data-loss bug. Dev trees are full of junctions (npm/pnpm store, Docker, WSL). Use `-LiteralPath`; never recurse a `FILE_ATTRIBUTE_REPARSE_POINT`.
    - **`Remove-Item` bypasses the Recycle Bin** — deletion is immediate and permanent, unlike dragging to the bin.
    - **OneDrive KFM** redirects Desktop/Documents/Pictures into OneDrive — deleting there propagates to all devices. Files On-Demand placeholders (`RECALL_ON_DATA_ACCESS`/`OFFLINE`) are cloud originals; deleting the stub deletes the cloud file.
    - **Controlled Folder Access** blocks deletes in Documents/Pictures/Desktop with Access Denied from Defender — distinguish from a real error.
    - **`Available MBytes` includes standby/cache** — high "available" doesn't mean healthy; commit-limit exhaustion is the real OOM signal.
    - **`pagefile.sys` and `swapfile.sys` are two different files**; `C:\Windows\Installer` + `Package Cache` break MSI repair if deleted; **Prefetch** should not be cleaned (myth).
    - **Modern Standby (S0ix)** makes wake timers unreliable and hides S3 — normal, not a fault. The task pauses in sleep and catches up on wake via `-StartWhenAvailable`.
    - **General rule**: if you can't describe a folder in one sentence (especially > 500 MB), don't guess — delegate a `web-searcher` lookup before writing the item's `description`. If you can describe it but it may be user data, still show it in the single final cleanup UI as protected and unchecked by default.
    
    ## Outcomes scale
    
    A representative recovery on a dev machine that hit ~8 % free under heavy AI/Docker/WSL load:
    
    - Largest single contribution: project build artifacts (`node_modules`, `bin`/`obj`, `target`, `.next`) via Tier 7 purge.
    - WSL2 / Docker `*.vhdx` compaction (not deletion): often 10–40 GB reclaimed.
    - Component Store cleanup via DISM after many cumulative updates: a few GB.
    - Package-manager caches (npm/pnpm/NuGet/pip/cargo/gradle): a few GB.
    - `Downloads` review (old installers, ISOs): variable, often 10–20 GB.
    - Elevated tier (Windows.old after rollback check, Delivery Optimization, exported+cleared logs): variable.
    
    Active alerter installed with a 7-day calibration window; verified via the synthetic disk-trigger test before going live. Numbers scale with workload and disk size — light users see less; heavy AI/Docker/WSL/IDE users see more.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related