diff --git a/LaunchAgents/com.asiago.disk-cleanup.plist b/LaunchAgents/com.asiago.disk-cleanup.plist
new file mode 100644
index 0000000..225a8f4
--- /dev/null
+++ b/LaunchAgents/com.asiago.disk-cleanup.plist
@@ -0,0 +1,34 @@
+
+
+
+
+ Label
+ com.asiago.disk-cleanup
+ ProgramArguments
+
+ /Users/andrewrich/.local/bin/disk-cleanup.sh
+
+ StartCalendarInterval
+
+ Weekday
+ 0
+ Hour
+ 4
+ Minute
+ 0
+
+ RunAtLoad
+
+ LimitLoadToSessionType
+ Aqua
+ StandardOutPath
+ /Users/andrewrich/.local/state/disk-cleanup.out
+ StandardErrorPath
+ /Users/andrewrich/.local/state/disk-cleanup.out
+ EnvironmentVariables
+
+ PATH
+ /opt/homebrew/bin:/Users/andrewrich/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin
+
+
+
diff --git a/LaunchAgents/com.asiago.disk-space-alert.plist b/LaunchAgents/com.asiago.disk-space-alert.plist
new file mode 100644
index 0000000..1bee326
--- /dev/null
+++ b/LaunchAgents/com.asiago.disk-space-alert.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ Label
+ com.asiago.disk-space-alert
+ ProgramArguments
+
+ /Users/andrewrich/.local/bin/disk-space-alert.sh
+
+ StartCalendarInterval
+
+ Hour
+ 9
+ Minute
+ 0
+
+ RunAtLoad
+
+ LimitLoadToSessionType
+ Aqua
+ StandardOutPath
+ /Users/andrewrich/.local/state/disk-space-alert.out
+ StandardErrorPath
+ /Users/andrewrich/.local/state/disk-space-alert.out
+ EnvironmentVariables
+
+ PATH
+ /opt/homebrew/bin:/Users/andrewrich/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin
+
+
+
diff --git a/README.md b/README.md
index 9a1a287..4dab42c 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,8 @@ Internal repo; not intended for public consumption.
| `reset-tccd.sh` | Resets the macOS TCC daemon (Transparency, Consent, and Control) and waits for it to respawn. Used when privacy-permission state gets wedged. |
| `local-macs.sh` | Lists ssh-able Macs on the LAN (Bonjour `_ssh._tcp`, 3-second browse), excluding this host and any name with a space. |
| `allup-fleet.sh` | Runs `allup` on every host from `local-macs` at once, one tmux pane per host via `ssh -t` — so each remote run stays interactive (casks, macOS updates, sudo prompts) and its output stays separate. `-d` starts without attaching; any other arguments replace `allup` as the remote command. Tests in `tests/allup-fleet.bats`. |
+| `disk-cleanup.sh` | Weekly cache cleanup for the data volume. It runs `npm cache clean --force`, `uv cache prune` and `xcrun simctl delete unavailable`. The simctl step runs under `timeout` (120 s) and skips if there is no `timeout` or `gtimeout`. The script deletes no path itself; each tool cleans its own cache. A missing tool is a logged skip. A failed step does not stop the others, but the run then exits 1. It logs free space before and after. Runs under `LaunchAgents/com.asiago.disk-cleanup.plist` (Sunday 04:00) and logs to `~/.local/state/disk-cleanup.out`. Design: [`docs/plans/2026-10-22-disk-space-automation.md`](docs/plans/2026-10-22-disk-space-automation.md). Tests in `tests/disk-cleanup.bats`. |
+| `disk-space-alert.sh` | Daily low-disk alert. If free space on `/System/Volumes/Data` is below 40 GiB (the first argument overrides it), it posts a macOS notification with `osascript`. It also posts one if it cannot read free space. It deletes nothing. `osascript` can exit 0 when the notification is not shown, so the log is the record. Runs under `LaunchAgents/com.asiago.disk-space-alert.plist` (daily 09:00) and logs to `~/.local/state/disk-space-alert.out`. Tests in `tests/disk-space-alert.bats`. |
| `tm-caffeinate.sh` | Keeps the machine awake while Time Machine is backing up. LaunchAgent-driven: re-execs under `caffeinate` and polls until the backup completes, then exits. |
### Development workflow
@@ -92,7 +94,7 @@ Internal repo; not intended for public consumption.
- `docs/plans/` — design docs and specs for non-trivial scripts.
- `tests/` — `bats` test suites and fixtures (`ssh-wrapper.bats`).
- `LaunchAgents/` — plists for scripts in this repo, symlinked into `~/Library/LaunchAgents/` (see `CLAUDE.md`).
-- `.github/workflows/` — `claude-blocking-review.yml`, `dependabot-auto-merge.yml` (PR-review automation and dependabot auto-merge). Scheduled ralph-burndown automation moved to a LaunchAgent on mimolette.local.
+- `.github/workflows/` — `bats.yml` (bats suites and shellcheck), `standards-check.yml`, `auto-tag.yml` (tags each push to main) and `dependabot-auto-merge.yml`. The scheduled ralph-burndown automation is no longer in operation.
## Standards
diff --git a/disk-cleanup.sh b/disk-cleanup.sh
new file mode 100755
index 0000000..28dc27c
--- /dev/null
+++ b/disk-cleanup.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Weekly cache cleanup. Each tool deletes its own cache; never add rm here.
+# Why: docs/plans/2026-10-22-disk-space-automation.md (Incident).
+
+# Each tool is a variable so tests can stub it. Empty or not executable = missing.
+NPM_BIN="${NPM_BIN:-$(command -v npm || true)}"
+UV_BIN="${UV_BIN:-$(command -v uv || true)}"
+XCRUN_BIN="${XCRUN_BIN:-$(command -v xcrun || true)}"
+DF_BIN="${DF_BIN:-$(command -v df || true)}"
+
+# simctl hung past 300 s unwrapped on 2026-10-22. timeout is from Homebrew
+# coreutils; without it the step skips.
+TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v timeout || command -v gtimeout || true)}"
+SIMCTL_TIMEOUT_SECONDS="${SIMCTL_TIMEOUT_SECONDS:-120}"
+SIMCTL_KILL_GRACE_SECONDS=10
+
+# Free space is logged for the Data volume; / is the read-only system volume.
+DATA_VOLUME="${DATA_VOLUME:-/System/Volumes/Data}"
+
+# Step results.
+STEP_OK=0
+STEP_FAILED=1
+STEP_SKIPPED=2
+
+# =========================================================
+# FUNCTIONS
+# =========================================================
+function log_info() {
+ local timestamp
+ timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
+ echo "[${timestamp}] [INFO] $*"
+}
+
+function log_error() {
+ local timestamp
+ timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
+ echo "[${timestamp}] [ERROR] $*" >&2
+}
+
+# True when $1 names an executable file. An empty value is "missing".
+function have_tool() {
+ [[ -n "${1:-}" && -x "${1}" ]]
+}
+
+# Report only: a df failure is logged but never changes the exit status.
+function log_free_space() {
+ local label="$1"
+ local avail_kib
+
+ if ! have_tool "${DF_BIN}"; then
+ log_info "Free space ${label}: unknown (df not found)"
+ return 0
+ fi
+
+ # -P forces one line per filesystem on both macOS and Linux; Available is
+ # field 4 in both.
+ if ! avail_kib="$("${DF_BIN}" -Pk "${DATA_VOLUME}" 2>/dev/null | awk 'NR == 2 { print $4 }')" \
+ || [[ ! "${avail_kib}" =~ ^[0-9]+$ ]]; then
+ log_error "Free space ${label}: could not read df for ${DATA_VOLUME}"
+ return 0
+ fi
+
+ log_info "Free space ${label}: $((avail_kib / 1024 / 1024)) GiB (${avail_kib} KiB) on ${DATA_VOLUME}"
+}
+
+function step_npm() {
+ if ! have_tool "${NPM_BIN}"; then
+ log_info "npm: not found, skipping"
+ return "${STEP_SKIPPED}"
+ fi
+ log_info "npm: cache clean --force"
+ if ! "${NPM_BIN}" cache clean --force; then
+ log_error "npm: cache clean failed"
+ return "${STEP_FAILED}"
+ fi
+ return "${STEP_OK}"
+}
+
+# `prune`, not `clean`: prune keeps cache entries still in use.
+function step_uv() {
+ if ! have_tool "${UV_BIN}"; then
+ log_info "uv: not found, skipping"
+ return "${STEP_SKIPPED}"
+ fi
+ log_info "uv: cache prune"
+ if ! "${UV_BIN}" cache prune; then
+ log_error "uv: cache prune failed"
+ return "${STEP_FAILED}"
+ fi
+ return "${STEP_OK}"
+}
+
+function step_simctl() {
+ if ! have_tool "${XCRUN_BIN}"; then
+ log_info "simctl: xcrun not found, skipping"
+ return "${STEP_SKIPPED}"
+ fi
+ if ! have_tool "${TIMEOUT_BIN}"; then
+ log_info "simctl: timeout/gtimeout not found, skipping rather than running simctl unbounded"
+ return "${STEP_SKIPPED}"
+ fi
+ log_info "simctl: delete unavailable (timeout ${SIMCTL_TIMEOUT_SECONDS}s, kill after ${SIMCTL_KILL_GRACE_SECONDS}s grace)"
+ local rc=0
+ "${TIMEOUT_BIN}" -k "${SIMCTL_KILL_GRACE_SECONDS}s" "${SIMCTL_TIMEOUT_SECONDS}" "${XCRUN_BIN}" simctl delete unavailable || rc=$?
+ if ((rc == 124)); then
+ log_error "simctl: timed out after ${SIMCTL_TIMEOUT_SECONDS}s"
+ return "${STEP_FAILED}"
+ elif ((rc == 137)); then
+ # 137 is SIGKILL: from timeout -k, or from outside (OOM). GNU timeout gives 137 for both.
+ log_error "simctl: killed (exit 137): timed out after ${SIMCTL_TIMEOUT_SECONDS}s and ignored SIGTERM for ${SIMCTL_KILL_GRACE_SECONDS}s, or killed from outside"
+ return "${STEP_FAILED}"
+ elif ((rc != 0)); then
+ log_error "simctl: delete unavailable failed (exit ${rc})"
+ return "${STEP_FAILED}"
+ fi
+ return "${STEP_OK}"
+}
+
+# =========================================================
+# MAIN EXECUTION
+# =========================================================
+function main() {
+ local failures=0 rc step
+
+ log_info "disk-cleanup started"
+ log_free_space "before"
+
+ for step in step_npm step_uv step_simctl; do
+ rc=0
+ "${step}" || rc=$?
+ if ((rc == STEP_FAILED)); then
+ ((failures += 1))
+ fi
+ done
+
+ log_free_space "after"
+
+ if ((failures > 0)); then
+ log_error "disk-cleanup finished with ${failures} failed step(s)"
+ return 1
+ fi
+ log_info "disk-cleanup finished"
+ return 0
+}
+
+# Skip main when sourced (e.g., by bats tests loading function defs).
+# Mirrors the guard in break-watcher.sh.
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ main "$@"
+fi
diff --git a/disk-space-alert.sh b/disk-space-alert.sh
new file mode 100755
index 0000000..6ac6edb
--- /dev/null
+++ b/disk-space-alert.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Daily low-disk alert. Posts a notification; deletes nothing.
+# Why: docs/plans/2026-10-22-disk-space-automation.md
+
+# Each tool is a variable so tests can stub it. Empty or not executable = missing.
+DF_BIN="${DF_BIN:-$(command -v df || true)}"
+OSASCRIPT_BIN="${OSASCRIPT_BIN:-/usr/bin/osascript}"
+
+# Free space is read for the Data volume; / is the read-only system volume.
+DATA_VOLUME="${DATA_VOLUME:-/System/Volumes/Data}"
+
+# 40, not 50: the snapshot thinner targets 50 GiB and clears that on its own.
+DEFAULT_THRESHOLD_GIB=40
+
+# =========================================================
+# FUNCTIONS
+# =========================================================
+function log_info() {
+ local timestamp
+ timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
+ echo "[${timestamp}] [INFO] $*"
+}
+
+function log_error() {
+ local timestamp
+ timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
+ echo "[${timestamp}] [ERROR] $*" >&2
+}
+
+# True when $1 names an executable file. An empty value is "missing".
+function have_tool() {
+ [[ -n "${1:-}" && -x "${1}" ]]
+}
+
+# Prints whole GiB free on DATA_VOLUME, rounded down. Returns 1 if unknown.
+function free_gib() {
+ local avail_kib
+
+ have_tool "${DF_BIN}" || return 1
+ # -P forces one line per filesystem on both macOS and Linux; Available is
+ # field 4 in both.
+ avail_kib="$("${DF_BIN}" -Pk "${DATA_VOLUME}" 2>/dev/null | awk 'NR == 2 { print $4 }')" || return 1
+ [[ "${avail_kib}" =~ ^[0-9]+$ ]] || return 1
+ echo "$((avail_kib / 1024 / 1024))"
+}
+
+# The title and message go in as argv, never into the AppleScript source, so
+# no value can change the script.
+function notify() {
+ local title="$1" message="$2"
+
+ if ! have_tool "${OSASCRIPT_BIN}"; then
+ log_error "osascript not found; could not post: ${title}: ${message}"
+ return 1
+ fi
+ if ! "${OSASCRIPT_BIN}" \
+ -e 'on run argv' \
+ -e 'display notification (item 1 of argv) with title (item 2 of argv)' \
+ -e 'end run' \
+ "${message}" "${title}"; then
+ log_error "osascript failed; could not post: ${title}: ${message}"
+ return 1
+ fi
+}
+
+# =========================================================
+# MAIN EXECUTION
+# =========================================================
+function main() {
+ local threshold="${1:-${DEFAULT_THRESHOLD_GIB}}" free
+
+ if [[ ! "${threshold}" =~ ^[1-9][0-9]*$ ]]; then
+ log_error "threshold must be a whole number of GiB above 0, got '${threshold}'"
+ return 2
+ fi
+
+ # An alert that fails silently is worse than none, so an unreadable
+ # volume is reported too.
+ if ! free="$(free_gib)"; then
+ log_error "could not read free space on ${DATA_VOLUME}"
+ notify "Disk space check failed" "Could not read free space on ${DATA_VOLUME}." || true
+ return 1
+ fi
+
+ if ((free < threshold)); then
+ log_info "LOW: ${free} GiB free on ${DATA_VOLUME}, below ${threshold} GiB"
+ notify "Low disk space" "${free} GiB free on ${DATA_VOLUME} (alert below ${threshold} GiB)." || return 1
+ return 0
+ fi
+
+ log_info "OK: ${free} GiB free on ${DATA_VOLUME} (threshold ${threshold} GiB)"
+}
+
+# Skip main when sourced (e.g., by bats tests loading function defs).
+# Mirrors the guard in break-watcher.sh.
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ main "$@"
+fi
diff --git a/docs/plans/2026-10-22-disk-space-automation.md b/docs/plans/2026-10-22-disk-space-automation.md
new file mode 100644
index 0000000..8d0c9c3
--- /dev/null
+++ b/docs/plans/2026-10-22-disk-space-automation.md
@@ -0,0 +1,129 @@
+# Disk space automation
+
+Date: 2026-10-22, revised 2026-10-23. Branch:
+`claude/feat-disk-space-automation-20261022`.
+
+## Why
+
+On 2026-10-22 ASIAGO's data volume reached 92% used (37 GiB free of 460 GiB).
+Manual cleanup recovered 145 GiB. Where it went:
+
+| Source | Freed |
+| --- | --- |
+| `~/.npm` | 24 GiB |
+| iOS simulator runtimes and devices | ~23 GiB |
+| Android emulators and SDK | ~21 GiB |
+| `~/.cache/uv` | 13 GiB |
+| Puppeteer browsers, Gradle caches | ~16 GiB |
+| Time Machine local snapshots | 8 GiB or more |
+
+Two jobs existed and neither caught it:
+
+- `thin-timemachine-snapshots` (LaunchDaemon, source in `~/Developer/LaunchAgents`,
+ a local repo with no remote) runs weekly with a 50 GiB target. It last ran at
+ boot on 2026-10-21 with 52 GiB free, did nothing (correctly), and was not due
+ again until Sunday 2026-10-25. Free space fell to 37 GiB in between. It can
+ only thin snapshots; it cannot touch caches.
+- `updates` (dotfiles, nightly) runs `brew cleanup --prune=all`, which works.
+ Nothing cleans npm, uv or simulator caches.
+
+On 2026-10-23, after the cleanup, the volume had 135 GiB free: `~/.npm` was
+1.8 GiB, `~/.cache/uv` was empty, `~/.cache/puppeteer` was gone and
+CoreSimulator held 8 KiB. Most of the 145 GiB was one-time (mobile work moved
+to another server). npm and uv grow back, so a small weekly job and an alert
+are still worth having.
+
+## Incident, 2026-10-22
+
+The first version of this branch had a Puppeteer step that ran its own
+`rm -rf`: in each `//` it kept the newest version directory by
+`sort -V` and removed the rest. Its path guard was a denylist. It refused an
+empty path, a relative path and exactly `$HOME`, after stripping one trailing
+slash.
+
+A red-phase bats test passed `//` to that guard before the fix for it
+existed. `//` became `/` and passed. The loop then treated every top-level
+directory as a "browser" and removed all but one child of each. About 40 apps
+in `/Applications` were deleted (user-owned ones; root-owned bundles resisted).
+Backups restored everything within a few hours. The second guard
+(`entry == cache_dir/*/*`) was a glob and matched `//Applications/Foo.app`, so
+it failed on the same input.
+
+What changed as a result:
+
+- The Puppeteer step is gone. No project under `~/Developer` uses Puppeteer,
+ and the cache no longer exists.
+- `disk-cleanup.sh` deletes nothing itself. Every cache is cleaned by the tool
+ that owns it. A guard test fails if a code line calls `rm`, `rmdir`, `mv`,
+ `unlink`, `shred` or `-delete`.
+- The test file has no `rm` either: it uses `BATS_TEST_TMPDIR`, which bats
+ creates and removes.
+- `~/.claude/CLAUDE.md` gained a "Destructive code" rule in Protocol 3
+ (claude-config PR 778): stub every destructive command, keep real
+ dangerous paths out of safety tests, and run red-phase safety tests only in
+ a disposable container or VM.
+- Claude Code sessions here now run Bash under the macOS sandbox.
+ `/Applications` is outside its write allowlist.
+
+## What
+
+Two commits, one PR.
+
+### 1. `disk-cleanup.sh` + `LaunchAgents/com.asiago.disk-cleanup.plist`
+
+Weekly LaunchAgent (Sunday 04:00). Each step skips with a log line when its
+tool is absent, and one failing step does not stop the others:
+
+- `npm cache clean --force`
+- `uv cache prune` (keeps entries still in use, unlike `uv cache clean`)
+- `xcrun simctl delete unavailable`, wrapped in `timeout` (an unwrapped
+ `simctl` call hung past 300 s during the 2026-10-22 cleanup). Kept although
+ CoreSimulator is empty now: `simctl` decides what to delete, and the step
+ costs nothing when there is nothing to delete.
+
+Logs to `~/.local/state/disk-cleanup.out` with free space before and after.
+
+### 2. `disk-space-alert.sh` + `LaunchAgents/com.asiago.disk-space-alert.plist`
+
+Daily LaunchAgent at 09:00. If free space on `/System/Volumes/Data` is below
+the threshold (default 40 GiB, first argument overrides), post a macOS
+notification with `osascript`. 40 GiB, not 50, so the alert does not fire on a
+shortfall the 50 GiB snapshot thinner clears on its own. It deletes nothing.
+
+- An unreadable volume also posts a notification and exits 1, so the alert
+ cannot fail silently on its own input.
+- The title and message reach AppleScript as `argv`, not as script text, so
+ no value can change the script.
+- `osascript` exits 0 even when the notification service is unreachable (seen
+ inside the Claude Code sandbox on 2026-10-23), so a missing notification
+ is not always detected. The log line is the record.
+
+## Later, separate PR
+
+### Move `thin-timemachine-snapshots` into this repo, run it daily
+
+Split out because it installs a root LaunchDaemon, which this branch's
+sandboxed sessions cannot do, and because it changes system state
+(`tmutil`), so it falls under the destructive-code rule.
+
+- `thin-timemachine-snapshots.sh` moves here from `~/Developer/LaunchAgents`.
+- Its plist goes in a new `LaunchDaemons/` directory with
+ `StartCalendarInterval` changed from weekly (Sunday 03:00) to daily (03:00).
+- A LaunchDaemon plist and the script it runs as root must be root-owned
+ regular files, so the repo's symlink rule cannot apply.
+ `install-thin-timemachine-snapshots.sh` copies both into place with the
+ right owner and mode and bootstraps the daemon; `--uninstall` reverses it.
+ This is the repo's "generated by a tracked installer" pattern.
+- `CLAUDE.md` gains a LaunchDaemons clause in the placement rule.
+- `tmutil` is injected and stubbed in tests. Any test that needs the real
+ `tmutil` runs in a disposable macOS VM (see
+ `2026-10-23-mimolette-vm-test-runner.md`).
+- After it merges and the installer has run, the old copies in
+ `~/Developer/LaunchAgents` are removed in a separate local commit there.
+
+## Not doing
+
+- No Puppeteer cleanup (see Incident).
+- No cleanup of Gradle, Xcode DerivedData or Android files. Mobile development
+ moved to another server, and those were removed by hand.
+- No change to the `updates` job in dotfiles.
diff --git a/docs/plans/2026-10-23-mimolette-vm-test-runner.md b/docs/plans/2026-10-23-mimolette-vm-test-runner.md
new file mode 100644
index 0000000..c31adeb
--- /dev/null
+++ b/docs/plans/2026-10-23-mimolette-vm-test-runner.md
@@ -0,0 +1,148 @@
+# Disposable macOS VM test runner on mimolette
+
+Date: 2026-10-23. Status: design, not built. Nothing on mimolette changes
+until this is approved.
+
+## Why
+
+`~/.claude/CLAUDE.md` Protocol 3 ("Destructive code", claude-config PR 778)
+says a test that may reach a real destructive command, and the first run of
+any new safety test, must run in a disposable environment. The trigger was
+the 2026-10-22 incident in `2026-10-22-disk-space-automation.md`.
+
+A Linux container covers code that is portable or fully stubbed. It cannot
+run `tmutil`, `diskutil`, `launchctl`, `osascript` or `simctl`. A GitHub macOS
+runner can, at about 10 times the Linux per-minute price. mimolette is an
+always-on Apple silicon Mac with room to spare, so it hosts throwaway macOS
+VMs instead.
+
+## Facts checked on 2026-10-23
+
+| Item | Value |
+| --- | --- |
+| Chip | arm64 |
+| macOS | 27.0.1 (same as ASIAGO) |
+| Free on `/System/Volumes/Data` | 158 GiB of 460 GiB |
+| Access | passwordless ssh as `andrewrich@`, always on |
+
+## Threat model
+
+The thing under test is assumed hostile by accident: it may run `rm -rf /`
+inside the guest. The runner must make sure that:
+
+1. Nothing the guest does reaches mimolette's files, ASIAGO's files or the
+ `Workspaces` share that mimolette serves.
+2. A session on ASIAGO cannot use the runner as a general shell on
+ mimolette. On ASIAGO, `ssh *` is in the Claude Code sandbox's
+ `excludedCommands`, so any ssh command runs outside the sandbox.
+3. Every run starts from a known-clean image, and nothing from one run
+ survives into the next.
+
+## Design
+
+### Account
+
+- A standard (non-admin) macOS user `claude` on mimolette. No sudo.
+- All VM images, clones and logs live under `/Users/claude`. Deleting that
+ account removes all of it.
+- Check at setup: `ls -ld /Users/andrewrich` and its subfolders. New users
+ join group `staff`, and home folders may be group-readable. Lock them down
+ if `claude` can list them.
+
+### SSH access is one command, not a shell
+
+- ASIAGO gets a new key, used only for this.
+- `/Users/claude/.ssh/authorized_keys` on mimolette holds that key with
+ `command="/Users/claude/bin/vm-test-run",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc`.
+- Whatever the client asks to run arrives only as `SSH_ORIGINAL_COMMAND`,
+ which `vm-test-run` parses against a strict allowlist. It never passes it
+ to a shell.
+
+### Tool
+
+- Tart (Cirrus Labs), installed by `andrewrich` with Homebrew. `claude` only
+ runs it. Tart uses Apple's Virtualization framework; no root is needed.
+- One base image, `macos-base`, built once and kept read-only in practice:
+ macOS 27, Homebrew, bats, shellcheck, coreutils, git. No Apple ID, no
+ secrets, no keys except the one that lets the runner ssh into the guest.
+
+### Code goes in as data
+
+The code under test is often unpushed (a red-phase test should not need a
+push, and pushes cost CI money). So the client sends a git bundle on stdin:
+
+```text
+git bundle create - HEAD | ssh -i claude@mimolette run
+```
+
+`vm-test-run`:
+
+1. Reads the bundle from stdin into a fresh temp dir under `/Users/claude`.
+ Caps its size.
+2. Checks `` against `^tests/[A-Za-z0-9._-]+\.bats$`.
+3. Takes a lock, so only one run is active at a time. Apple's licence allows
+ at most 2 extra macOS VMs per Mac, and one at a time keeps it simple.
+4. `tart clone macos-base run-`, then `tart run --no-graphics run-`
+ with **no `--dir` option**, so no host folder is shared.
+5. Waits for `tart ip run-`, copies the bundle into the guest with `scp`,
+ clones from it there, and runs `bats --print-output-on-failure `
+ inside the guest.
+6. Streams the guest's output to the client as text and exits with the
+ suite's exit code.
+7. Always stops and deletes `run-` and the temp dir, from a `trap`, on
+ success, failure or timeout.
+8. Bounds the whole run with `timeout` (default 15 minutes).
+
+### Results come out as text
+
+Only stdout, stderr and the exit code go back. No files are copied out of
+the guest.
+
+### Client side, on ASIAGO
+
+A small `vm-test` wrapper in this repo builds the bundle and calls ssh with
+the dedicated key. That is the only supported way in. Protocol 3's "where
+suites run" rule points destructive suites here.
+
+## Validation before first real use
+
+A clean result from an unvalidated sandbox proves nothing. Before the runner
+counts as "disposable", run a known-bad suite through it:
+
+1. A bats test that runs `rm -rf /` inside the guest (and, separately, tries
+ to reach `/Volumes`, the host's IP and mimolette's `Workspaces` share).
+2. Confirm on mimolette: `/Users/claude` and `/Users/andrewrich` unchanged,
+ the share unchanged, `run-` gone, `macos-base` unchanged.
+3. Confirm the next run starts clean.
+4. Confirm a plain `ssh -i claude@mimolette` gets no shell, and that
+ `run '; id'` or a path outside `tests/` is refused.
+
+## Open questions
+
+1. **Headless start.** Does `tart run --no-graphics` start a macOS guest
+ from an ssh login with no GUI session for `claude`? Not confirmed. If it
+ does not, `claude` may need an auto-login GUI session, which changes the
+ setup.
+2. **Base image source.** Pull a Cirrus Labs macOS 27 image if one exists,
+ or build from an IPSW with `tart create --from-ipsw`. Not checked.
+3. **Image size.** I expect tens of GB for the base. Whether `tart clone` is
+ a cheap APFS copy-on-write clone is not confirmed.
+4. **Guest network.** With the bundle approach the guest needs no network
+ during a run. Whether Tart can cut the guest off from the LAN (and so
+ from the `Workspaces` share) is not confirmed. Until it is, step 1 of the
+ validation is the evidence.
+5. **Linux layer.** Apple's `container` (Homebrew, 1.6.0) could run the
+ portable suites the same way, on ASIAGO or mimolette. Not part of this
+ design; add it if the VM start time proves too slow.
+
+## Rollout
+
+Each step is a checkpoint; stop and report if one fails.
+
+1. Andrew creates the `claude` account and installs Tart.
+2. Build `macos-base`; answer open questions 1 to 4.
+3. Write `vm-test-run` and `vm-test`, both tracked in this repo, with bats
+ tests that stub `tart` and `ssh`. `vm-test-run` reaches
+ `/Users/claude/bin` on mimolette through a tracked installer, not by hand.
+4. Run the validation above.
+5. Update Protocol 3 if anything here changed what it promises.
diff --git a/tests/disk-cleanup.bats b/tests/disk-cleanup.bats
new file mode 100755
index 0000000..92dc75d
--- /dev/null
+++ b/tests/disk-cleanup.bats
@@ -0,0 +1,243 @@
+#!/usr/bin/env bats
+# bats tests for disk-cleanup.sh
+#
+# Run from repo root: bats tests/disk-cleanup.bats
+#
+# disk-cleanup.sh deletes caches, so these tests must never reach a real
+# tool. Every external it calls is a *_BIN variable; setup() points each one
+# at a stub in the per-test temp dir that records its argv to CALLS and
+# exits with the code in .rc (default 0). A "missing" tool is a path
+# that does not exist. DATA_VOLUME also points into the temp dir.
+#
+# The script itself never deletes a path (a guard test below checks this),
+# so no test here can reach a real rm. The temp dir is BATS_TEST_TMPDIR,
+# which bats creates and removes; this file has no rm either.
+# Nothing here assumes macOS, so the suite runs on the ubuntu CI runner.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:?must be run via bats}")/.." && pwd)"
+ TMPDIR_T="${BATS_TEST_TMPDIR:?bats 1.4 or later is required}"
+ STUBS="${TMPDIR_T}/stubs"
+ mkdir -p "${STUBS}"
+ export CALLS="${TMPDIR_T}/calls"
+ : >"${CALLS}"
+
+ local tool
+ for tool in npm uv xcrun df; do
+ _stub "${tool}"
+ done
+ # df prints a fixed two-line block, as `df -Pk` does.
+ cat >"${STUBS}/df" <<'EOF'
+#!/usr/bin/env bash
+echo "df $*" >>"${CALLS}"
+echo "Filesystem 1024-blocks Used Available Capacity Mounted on"
+echo "/dev/disk3s5 482797652 300000000 41943040 88% /System/Volumes/Data"
+EOF
+ chmod +x "${STUBS}/df"
+
+ # timeout stub: records its argv, then runs the wrapped command, so the
+ # xcrun stub is still invoked through it.
+ cat >"${STUBS}/timeout" <<'EOF'
+#!/usr/bin/env bash
+echo "timeout $*" >>"${CALLS}"
+rc_file="$(dirname "$0")/timeout.rc"
+if [[ -f "${rc_file}" ]]; then exit "$(cat "${rc_file}")"; fi
+if [[ "${1}" == "-k" ]]; then shift 2; fi
+shift
+exec "$@"
+EOF
+ chmod +x "${STUBS}/timeout"
+
+ # Exported BEFORE sourcing: the script reads these as defaults at load
+ # time, so even if the source guard regressed and main() ran, it would
+ # reach only the stubs and the temp dir, never a real cache.
+ export NPM_BIN="${STUBS}/npm"
+ export UV_BIN="${STUBS}/uv"
+ export XCRUN_BIN="${STUBS}/xcrun"
+ export DF_BIN="${STUBS}/df"
+ export TIMEOUT_BIN="${STUBS}/timeout"
+ export SIMCTL_TIMEOUT_SECONDS=7
+ export DATA_VOLUME="${TMPDIR_T}"
+
+ # The source guard keeps main() from running when we load the functions.
+ source "${REPO_ROOT}/disk-cleanup.sh"
+}
+
+# Writes a stub that appends " " to CALLS and exits with the
+# value in .rc if that file exists.
+_stub() {
+ local name="$1"
+ cat >"${STUBS}/${name}" <>"\${CALLS}"
+rc_file="\$(dirname "\$0")/${name}.rc"
+if [[ -f "\${rc_file}" ]]; then exit "\$(cat "\${rc_file}")"; fi
+exit 0
+EOF
+ chmod +x "${STUBS}/${name}"
+}
+
+_fail() {
+ printf '%s\n' "${2:-1}" >"${STUBS}/$1.rc"
+}
+
+# ---------------------------------------------------------------------------
+# Whole run
+# ---------------------------------------------------------------------------
+
+@test "a clean run calls every tool with the expected arguments and exits 0" {
+ run main
+ [ "${status}" -eq 0 ]
+ grep -qx "npm cache clean --force" "${CALLS}"
+ grep -qx "uv cache prune" "${CALLS}"
+ grep -qx "xcrun simctl delete unavailable" "${CALLS}"
+}
+
+@test "free space is logged before and after, from DATA_VOLUME" {
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"Free space before: 40 GiB (41943040 KiB) on ${TMPDIR_T}"* ]]
+ [[ "${output}" == *"Free space after: 40 GiB"* ]]
+ [ "$(grep -c "^df -Pk ${TMPDIR_T}\$" "${CALLS}")" -eq 2 ]
+}
+
+@test "log lines carry a timestamp" {
+ run main
+ [[ "${lines[0]}" =~ ^\[[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}:[0-9]{2}\]\ \[INFO\] ]]
+}
+
+@test "a df failure is logged but does not fail the run" {
+ # Swap the fixed-output df for the generic stub, made to exit 1.
+ _stub df
+ _fail df
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"could not read df"* ]]
+}
+
+# ---------------------------------------------------------------------------
+# Missing tools skip; they are not failures
+# ---------------------------------------------------------------------------
+
+@test "missing npm is a logged skip, not a failure" {
+ NPM_BIN="${TMPDIR_T}/does-not-exist/npm"
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"npm: not found, skipping"* ]]
+ grep -qx "uv cache prune" "${CALLS}"
+}
+
+@test "missing uv is a logged skip, not a failure" {
+ UV_BIN="${TMPDIR_T}/does-not-exist/uv"
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"uv: not found, skipping"* ]]
+}
+
+@test "missing xcrun is a logged skip, not a failure (the Linux case)" {
+ XCRUN_BIN=""
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"simctl: xcrun not found, skipping"* ]]
+ [ "$(grep -c "^timeout" "${CALLS}")" -eq 0 ]
+}
+
+@test "missing df logs unknown free space, not a failure" {
+ DF_BIN=""
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"Free space before: unknown"* ]]
+}
+
+@test "every tool missing at once still exits 0" {
+ NPM_BIN="" UV_BIN="" XCRUN_BIN="" DF_BIN="" TIMEOUT_BIN=""
+ run main
+ [ "${status}" -eq 0 ]
+ [ ! -s "${CALLS}" ]
+}
+
+# ---------------------------------------------------------------------------
+# Failure isolation
+# ---------------------------------------------------------------------------
+
+@test "a failing npm step does not stop the other steps, and the run exits 1" {
+ _fail npm
+ run main
+ [ "${status}" -eq 1 ]
+ [[ "${output}" == *"npm: cache clean failed"* ]]
+ grep -qx "uv cache prune" "${CALLS}"
+ grep -qx "xcrun simctl delete unavailable" "${CALLS}"
+ [[ "${output}" == *"Free space after"* ]]
+ [[ "${output}" == *"1 failed step(s)"* ]]
+}
+
+@test "two failing steps are both counted" {
+ _fail uv
+ _fail xcrun 3
+ run main
+ [ "${status}" -eq 1 ]
+ [[ "${output}" == *"simctl: delete unavailable failed (exit 3)"* ]]
+ [[ "${output}" == *"2 failed step(s)"* ]]
+}
+
+# ---------------------------------------------------------------------------
+# simctl runs under timeout
+# ---------------------------------------------------------------------------
+
+@test "simctl gets a 10-second SIGKILL grace period after its timeout" {
+ run step_simctl
+ [ "${status}" -eq 0 ]
+ grep -qx "timeout -k 10s 7 ${STUBS}/xcrun simctl delete unavailable" "${CALLS}"
+ # The timeout call comes first: xcrun ran through it, not beside it.
+ [ "$(head -n 1 "${CALLS}")" = "timeout -k 10s 7 ${STUBS}/xcrun simctl delete unavailable" ]
+}
+
+@test "a simctl timeout (exit 124) is a failure that says it timed out" {
+ _fail timeout 124
+ run step_simctl
+ [ "${status}" -eq "${STEP_FAILED}" ]
+ [[ "${output}" == *"simctl: timed out after 7s"* ]]
+}
+
+@test "a simctl SIGKILL (exit 137) is a failure that names both causes" {
+ _fail timeout 137
+ run step_simctl
+ [ "${status}" -eq "${STEP_FAILED}" ]
+ [[ "${output}" == *"simctl: killed (exit 137): timed out after 7s and ignored SIGTERM for 10s, or killed from outside"* ]]
+}
+
+@test "no timeout binary skips simctl rather than running it unbounded" {
+ TIMEOUT_BIN=""
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"timeout/gtimeout not found, skipping"* ]]
+ [ "$(grep -c "xcrun" "${CALLS}")" -eq 0 ]
+}
+
+# ---------------------------------------------------------------------------
+# Script shape
+# ---------------------------------------------------------------------------
+
+@test "the script never deletes or moves a path itself" {
+ # Cache deletion belongs to the tool that owns the cache. A hand-written
+ # rm -rf in this script removed ~40 apps from /Applications on
+ # 2026-10-22. Comment lines are ignored; any code line that calls one of
+ # these fails the test. The word boundaries are spelled out because \b
+ # and [[:<:]] differ between BSD and GNU grep.
+ run grep -nE '^([^#]*[^[:alnum:]_-])?(rm|rmdir|mv|unlink|shred)([^[:alnum:]_-]|$)|^[^#]*[[:space:]]-delete([^[:alnum:]_]|$)' \
+ "${REPO_ROOT}/disk-cleanup.sh"
+ [ "${status}" -eq 1 ]
+}
+
+@test "sourcing the script does not run main" {
+ # setup() sourced it with every tool pointed at a recording stub; if the
+ # guard regressed, main() would have run and CALLS would not be empty.
+ [ "$(type -t main)" = "function" ]
+ [ ! -s "${CALLS}" ]
+}
+
+@test "the default simctl timeout is a positive integer" {
+ run env -u SIMCTL_TIMEOUT_SECONDS bash -c \
+ "source '${REPO_ROOT}/disk-cleanup.sh'; echo \"\${SIMCTL_TIMEOUT_SECONDS}\""
+ [[ "${output}" =~ ^[1-9][0-9]*$ ]]
+}
diff --git a/tests/disk-space-alert.bats b/tests/disk-space-alert.bats
new file mode 100755
index 0000000..4e229d9
--- /dev/null
+++ b/tests/disk-space-alert.bats
@@ -0,0 +1,188 @@
+#!/usr/bin/env bats
+# bats tests for disk-space-alert.sh
+#
+# Run from repo root: bats tests/disk-space-alert.bats
+#
+# The script reads df and posts a notification with osascript. Both are
+# *_BIN variables; setup() points each at a stub in the per-test temp dir.
+# The df stub reports the KiB value in df.kib; the osascript stub records
+# one argv item per line to NOTIFY and exits with the code in osascript.rc.
+# The script deletes nothing, and the temp dir is BATS_TEST_TMPDIR, which
+# bats removes. Nothing here assumes macOS, so it runs on the ubuntu runner.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:?must be run via bats}")/.." && pwd)"
+ TMPDIR_T="${BATS_TEST_TMPDIR:?bats 1.4 or later is required}"
+ STUBS="${TMPDIR_T}/stubs"
+ mkdir -p "${STUBS}"
+ export NOTIFY="${TMPDIR_T}/notify"
+ export DF_CALLS="${TMPDIR_T}/df-calls"
+
+ cat >"${STUBS}/df" <<'EOF'
+#!/usr/bin/env bash
+echo "df $*" >>"${DF_CALLS}"
+dir="$(dirname "$0")"
+if [[ -f "${dir}/df.rc" ]]; then exit "$(cat "${dir}/df.rc")"; fi
+echo "Filesystem 1024-blocks Used Available Capacity Mounted on"
+echo "/dev/disk3s5 482797652 300000000 $(cat "${dir}/df.kib") 88% /System/Volumes/Data"
+EOF
+ chmod +x "${STUBS}/df"
+
+ cat >"${STUBS}/osascript" <<'EOF'
+#!/usr/bin/env bash
+printf '%s\n' "$@" >"${NOTIFY}"
+rc_file="$(dirname "$0")/osascript.rc"
+if [[ -f "${rc_file}" ]]; then exit "$(cat "${rc_file}")"; fi
+exit 0
+EOF
+ chmod +x "${STUBS}/osascript"
+
+ # Exported BEFORE sourcing: the script reads these as defaults at load
+ # time, so even if the source guard regressed, only stubs would run.
+ export DF_BIN="${STUBS}/df"
+ export OSASCRIPT_BIN="${STUBS}/osascript"
+ export DATA_VOLUME="${TMPDIR_T}"
+
+ _free_gib 100
+ source "${REPO_ROOT}/disk-space-alert.sh"
+}
+
+# Sets the df stub's Available column to $1 GiB (as KiB).
+_free_gib() {
+ echo "$(($1 * 1024 * 1024))" >"${STUBS}/df.kib"
+}
+
+# ---------------------------------------------------------------------------
+# Threshold
+# ---------------------------------------------------------------------------
+
+@test "plenty of free space logs OK and posts nothing" {
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"OK: 100 GiB free on ${TMPDIR_T} (threshold 40 GiB)"* ]]
+ [ ! -e "${NOTIFY}" ]
+}
+
+@test "free space below the default 40 GiB posts a notification" {
+ _free_gib 37
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"LOW: 37 GiB free"* ]]
+ grep -qx "Low disk space" "${NOTIFY}"
+ grep -qx "37 GiB free on ${TMPDIR_T} (alert below 40 GiB)." "${NOTIFY}"
+}
+
+@test "free space exactly at the threshold does not alert" {
+ _free_gib 40
+ run main
+ [ "${status}" -eq 0 ]
+ [ ! -e "${NOTIFY}" ]
+}
+
+@test "a partial GiB rounds down, so 39.9 GiB alerts" {
+ echo "$((40 * 1024 * 1024 - 1))" >"${STUBS}/df.kib"
+ run main
+ [ "${status}" -eq 0 ]
+ [[ "${output}" == *"LOW: 39 GiB free"* ]]
+ [ -e "${NOTIFY}" ]
+}
+
+@test "the first argument overrides the threshold" {
+ _free_gib 60
+ run main 75
+ [ "${status}" -eq 0 ]
+ grep -qx "60 GiB free on ${TMPDIR_T} (alert below 75 GiB)." "${NOTIFY}"
+}
+
+@test "a non-numeric, zero or negative threshold exits 2 and posts nothing" {
+ local bad
+ for bad in abc 0 -5 4.5 "" " 40"; do
+ run main "${bad}"
+ if [[ -z "${bad}" ]]; then
+ # An empty first argument falls back to the default.
+ [ "${status}" -eq 0 ]
+ continue
+ fi
+ [ "${status}" -eq 2 ]
+ [[ "${output}" == *"threshold must be a whole number"* ]]
+ [ ! -e "${NOTIFY}" ]
+ done
+}
+
+# ---------------------------------------------------------------------------
+# Failures are reported, not silent
+# ---------------------------------------------------------------------------
+
+@test "a df failure posts a check-failed notification and exits 1" {
+ echo 1 >"${STUBS}/df.rc"
+ run main
+ [ "${status}" -eq 1 ]
+ [[ "${output}" == *"could not read free space on ${TMPDIR_T}"* ]]
+ grep -qx "Disk space check failed" "${NOTIFY}"
+}
+
+@test "unparseable df output is treated as a failure" {
+ echo "n/a" >"${STUBS}/df.kib"
+ run main
+ [ "${status}" -eq 1 ]
+ grep -qx "Disk space check failed" "${NOTIFY}"
+}
+
+@test "missing df is a failure, not a pass" {
+ DF_BIN=""
+ run main
+ [ "${status}" -eq 1 ]
+ grep -qx "Disk space check failed" "${NOTIFY}"
+}
+
+@test "an osascript failure on a low reading exits 1 and logs the alert" {
+ _free_gib 10
+ echo 1 >"${STUBS}/osascript.rc"
+ run main
+ [ "${status}" -eq 1 ]
+ [[ "${output}" == *"osascript failed; could not post: Low disk space"* ]]
+}
+
+@test "missing osascript on a low reading exits 1" {
+ _free_gib 10
+ OSASCRIPT_BIN="${TMPDIR_T}/does-not-exist/osascript"
+ run main
+ [ "${status}" -eq 1 ]
+ [[ "${output}" == *"osascript not found"* ]]
+}
+
+@test "df is asked about DATA_VOLUME in POSIX KiB form" {
+ run main
+ grep -qx "df -Pk ${TMPDIR_T}" "${DF_CALLS}"
+}
+
+# ---------------------------------------------------------------------------
+# Notification safety
+# ---------------------------------------------------------------------------
+
+@test "title and message reach osascript as argv, not as script source" {
+ DATA_VOLUME='/Vol "quoted" & end tell'
+ _free_gib 5
+ run main
+ [ "${status}" -eq 0 ]
+ # The three -e lines are fixed; the volume name appears only in argv.
+ [ "$(sed -n '1,6p' "${NOTIFY}" | grep -c 'quoted')" -eq 0 ]
+ grep -qx '5 GiB free on /Vol "quoted" & end tell (alert below 40 GiB).' "${NOTIFY}"
+}
+
+# ---------------------------------------------------------------------------
+# Script shape
+# ---------------------------------------------------------------------------
+
+@test "the script never deletes or moves a path itself" {
+ # Same guard as tests/disk-cleanup.bats. Comment lines are ignored.
+ run grep -nE '^([^#]*[^[:alnum:]_-])?(rm|rmdir|mv|unlink|shred)([^[:alnum:]_-]|$)|^[^#]*[[:space:]]-delete([^[:alnum:]_]|$)' \
+ "${REPO_ROOT}/disk-space-alert.sh"
+ [ "${status}" -eq 1 ]
+}
+
+@test "sourcing the script does not run main" {
+ [ "$(type -t main)" = "function" ]
+ [ ! -e "${NOTIFY}" ]
+ [ ! -e "${DF_CALLS}" ]
+}