agents: add conversation-done skill (#4150)

Agent workflows create substantial disk and process overhead outside
the conversation directory, including Bazel server processes, output
bases, git worktrees, and feature branches. Without automated cleanup,
these resources accumulate across tasks.

Add the `conversation-done` skill and an async cleanup engine to safely
reclaim these resources when a conversation finishes. The tool shuts
down active Bazel servers, deletes matching output bases, and prunes
git worktrees and branches while enforcing safeguards for main repos,
canonical branches, and upstream remotes.
diff --git a/.agents/skills/conversation-done/SKILL.md b/.agents/skills/conversation-done/SKILL.md
new file mode 100644
index 0000000..11a5b21
--- /dev/null
+++ b/.agents/skills/conversation-done/SKILL.md
@@ -0,0 +1,95 @@
+---
+name: conversation-done
+description: >-
+  Clean up files and resources that aren't part of the agent conversation
+  (e.g. Bazel output base, Git worktree, local/remote branches)
+---
+
+# Conversation Done Skill
+
+Cleans up external workspace resources created during agent workflows while
+preserving agent conversation data, chat history, and brain transcripts.
+
+## Overview
+
+When agents work on tasks, several external resources outside the agent
+conversation directory (`~/.gemini/jetski/brain/`) are created:
+
+1. **Bazel Output Base(s)**: Intermediate build artifacts, execroot trees,
+   and running Bazel server and persistent worker processes (consuming tens of
+   gigabytes of disk and substantial RAM).
+2. **Git Worktree**: The checked-out directory on disk.
+3. **Local Git Branch**: The feature branch associated with the worktree.
+4. **Remote Git Branch**: The feature branch pushed to the user's fork
+   (e.g., `origin`).
+
+This skill safely shuts down active build processes and reclaims these
+resources without removing the conversation transcript or chat history.
+
+## Script Location
+
+The cleanup script is located at:
+`./.agents/skills/conversation-done/scripts/cleanup.py`
+
+## Usage
+
+### 1. Clean Up Current Workspace
+
+When invoked from within the agent's worktree:
+
+```bash
+# Preview what would be removed:
+./.agents/skills/conversation-done/scripts/cleanup.py --dry-run
+
+# Clean up resources (interactive confirmation if TTY):
+./.agents/skills/conversation-done/scripts/cleanup.py
+
+# Force cleanup non-interactively (ideal for agents/scripts):
+./.agents/skills/conversation-done/scripts/cleanup.py --force
+```
+
+### 2. Clean Up by Worktree Path or Branches
+
+```bash
+# By worktree path:
+./.agents/skills/conversation-done/scripts/cleanup.py \
+  --worktree /path/to/worktree --force
+
+# By branch name:
+./.agents/skills/conversation-done/scripts/cleanup.py \
+  --branches my-feature-branch --force
+
+# By multiple branches:
+./.agents/skills/conversation-done/scripts/cleanup.py \
+  --branches branch-one branch-two --force
+```
+
+### 3. Inspect Active Resources
+
+List all worktrees, branches, and Bazel output bases:
+
+```bash
+./.agents/skills/conversation-done/scripts/cleanup.py --list
+```
+
+## Selective Cleanup Flags
+
+Fine-tune what gets cleaned up using selective leave flags:
+
+* `--leave-bazel-output-base`: Leave Bazel output bases and build caches
+  intact.
+* `--leave-worktree`: Leave Git worktree directory on disk.
+* `--leave-branch`: Keep local Git branch.
+* `--leave-remote`: Do not delete branch from remote fork.
+* `--remote <name>`: Override remote destination (default: `pushRemote` or
+  `origin`).
+
+## Safety Guarantees
+
+* **Main Repository Protection**: Never removes the primary Git repository
+  worktree.
+* **Protected Branches**: Never deletes `main`, `master`, `release/*`, or
+  `HEAD`.
+* **Upstream Remote Protection**: Never deletes branches on `upstream`.
+* **No Expunge Rule**: Never runs `bazel clean --expunge`; shuts down servers
+  gracefully and deletes the specific output base directory directly.
diff --git a/.agents/skills/conversation-done/scripts/cleanup.py b/.agents/skills/conversation-done/scripts/cleanup.py
new file mode 100755
index 0000000..24d13a3
--- /dev/null
+++ b/.agents/skills/conversation-done/scripts/cleanup.py
@@ -0,0 +1,781 @@
+#!/usr/bin/env python3
+"""Cleans up workspace resources created for an agent conversation.
+
+This script cleans up files and resources that are external to the agent
+conversation itself (such as Bazel output bases, Git worktrees, and local or
+remote Git branches), while leaving the conversation history, transcripts,
+and brain state intact.
+
+Usage examples:
+  # Clean up current worktree and its associated resources:
+  cleanup.py
+
+  # Preview what would be cleaned up without making changes:
+  cleanup.py --dry-run
+
+  # Clean up a specific worktree by path:
+  cleanup.py --worktree /path/to/worktree
+
+  # Clean up resources for specific branches:
+  cleanup.py --branches my-feature-branch
+
+  # Clean up resources for multiple branches:
+  cleanup.py --branches branch-one branch-two
+
+  # List worktrees and their associated resources:
+  cleanup.py --list
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import dataclasses
+import glob
+import os
+import shutil
+import signal
+import stat
+import subprocess
+import sys
+
+PROTECTED_BRANCHES = frozenset({"main", "master", "HEAD"})
+PROTECTED_REMOTES = frozenset({"upstream"})
+
+
+@dataclasses.dataclass
+class WorktreeInfo:
+    """Information about a Git worktree."""
+
+    path: str
+    head: str
+    branch: str | None
+    is_main: bool
+
+
+@dataclasses.dataclass
+class CleanupTarget:
+    """Target resources identified for cleanup."""
+
+    worktree_path: str | None = None
+    branch: str | None = None
+    remote: str | None = None
+    remote_branch_exists: bool = False
+    bazel_output_bases: list[str] = dataclasses.field(default_factory=list)
+    is_main_worktree: bool = False
+
+
+def log(msg: str) -> None:
+    """Prints an informational message."""
+    print(msg, flush=True)
+
+
+def log_warn(msg: str) -> None:
+    """Prints a warning message to stderr."""
+    print(f"Warning: {msg}", file=sys.stderr, flush=True)
+
+
+def log_error(msg: str) -> None:
+    """Prints an error message to stderr."""
+    print(f"Error: {msg}", file=sys.stderr, flush=True)
+
+
+async def run_command(
+    cmd: list[str],
+    cwd: str | None = None,
+    capture_output: bool = True,
+    timeout: float | None = None,
+) -> subprocess.CompletedProcess[str]:
+    """Executes a subprocess command asynchronously and safely."""
+    stdout_pipe = asyncio.subprocess.PIPE if capture_output else None
+    stderr_pipe = asyncio.subprocess.PIPE if capture_output else None
+    try:
+        proc = await asyncio.create_subprocess_exec(
+            *cmd,
+            cwd=cwd,
+            stdout=stdout_pipe,
+            stderr=stderr_pipe,
+        )
+        try:
+            stdout_b, stderr_b = await asyncio.wait_for(
+                proc.communicate(), timeout=timeout
+            )
+        except asyncio.TimeoutError:
+            try:
+                proc.kill()
+                await proc.wait()
+            except OSError:
+                pass
+            return subprocess.CompletedProcess(
+                args=cmd,
+                returncode=1,
+                stdout="",
+                stderr=f"Command timed out after {timeout} seconds",
+            )
+        stdout = stdout_b.decode("utf-8", errors="replace") if stdout_b else ""
+        stderr = stderr_b.decode("utf-8", errors="replace") if stderr_b else ""
+        return subprocess.CompletedProcess(
+            args=cmd,
+            returncode=proc.returncode if proc.returncode is not None else 0,
+            stdout=stdout,
+            stderr=stderr,
+        )
+    except (FileNotFoundError, OSError) as exc:
+        return subprocess.CompletedProcess(
+            args=cmd,
+            returncode=1,
+            stdout="",
+            stderr=str(exc),
+        )
+
+
+async def get_main_repo(cwd: str | None = None) -> str:
+    """Finds the root directory of the primary Git repository."""
+    res = await run_command(["git", "rev-parse", "--git-common-dir"], cwd=cwd)
+    if res.returncode == 0 and res.stdout.strip():
+        git_common = os.path.abspath(res.stdout.strip())
+        if os.path.basename(git_common) == ".git":
+            return os.path.dirname(git_common)
+        return git_common
+
+    res = await run_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
+    if res.returncode == 0 and res.stdout.strip():
+        return os.path.abspath(res.stdout.strip())
+
+    raise RuntimeError("Not inside a Git repository.")
+
+
+async def get_current_worktree(cwd: str | None = None) -> str | None:
+    """Returns the top-level directory of the current worktree, if any."""
+    res = await run_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
+    if res.returncode == 0 and res.stdout.strip():
+        return os.path.abspath(res.stdout.strip())
+    return None
+
+
+async def get_worktrees(main_repo: str) -> list[WorktreeInfo]:
+    """Returns all Git worktrees registered in the repository."""
+    res = await run_command(["git", "-C", main_repo, "worktree", "list", "--porcelain"])
+    if res.returncode != 0:
+        log_error(f"Failed to list Git worktrees: {res.stderr.strip()}")
+        return []
+
+    worktrees: list[WorktreeInfo] = []
+    current_wt: str | None = None
+    current_head = ""
+    current_branch: str | None = None
+
+    for line in res.stdout.splitlines():
+        line = line.strip()
+        if line.startswith("worktree "):
+            if current_wt:
+                worktrees.append(
+                    WorktreeInfo(
+                        path=current_wt,
+                        head=current_head,
+                        branch=current_branch,
+                        is_main=len(worktrees) == 0,
+                    )
+                )
+            current_wt = line.split("worktree ", 1)[1].strip()
+            current_head = ""
+            current_branch = None
+        elif line.startswith("HEAD "):
+            current_head = line.split("HEAD ", 1)[1].strip()
+        elif line.startswith("branch refs/heads/"):
+            current_branch = line.split("branch refs/heads/", 1)[1].strip()
+
+    if current_wt:
+        worktrees.append(
+            WorktreeInfo(
+                path=current_wt,
+                head=current_head,
+                branch=current_branch,
+                is_main=len(worktrees) == 0,
+            )
+        )
+
+    return worktrees
+
+
+def is_protected_branch(branch: str | None) -> bool:
+    """Checks if a branch is protected from deletion."""
+    if not branch:
+        return True
+    if branch in PROTECTED_BRANCHES:
+        return True
+    if branch.startswith("release/") or branch.startswith("release-"):
+        return True
+    return False
+
+
+async def get_push_remote_for_branch(main_repo: str, branch: str) -> str:
+    """Determines the push remote for a branch, defaulting to 'origin'."""
+    res = await run_command(
+        ["git", "-C", main_repo, "config", f"branch.{branch}.pushRemote"]
+    )
+    remote = res.stdout.strip()
+    if remote:
+        return remote
+
+    res = await run_command(
+        ["git", "-C", main_repo, "config", f"branch.{branch}.remote"]
+    )
+    remote = res.stdout.strip()
+    if remote and remote not in PROTECTED_REMOTES:
+        return remote
+
+    return "origin"
+
+
+async def remote_branch_exists(main_repo: str, remote: str, branch: str) -> bool:
+    """Checks whether a branch exists on the specified remote."""
+    if remote in PROTECTED_REMOTES or is_protected_branch(branch):
+        return False
+    res = await run_command(
+        ["git", "-C", main_repo, "ls-remote", "--heads", remote, branch],
+        timeout=10,
+    )
+    return res.returncode == 0 and bool(res.stdout.strip())
+
+
+def find_bazel_output_bases(target_worktree: str) -> list[str]:
+    """Finds all Bazel output bases associated with a worktree path."""
+    target_worktree = os.path.realpath(target_worktree)
+    output_bases: list[str] = []
+
+    user_cache = os.path.expanduser("~/.cache/bazel")
+    if not os.path.isdir(user_cache):
+        return output_bases
+
+    for user_dir in glob.glob(os.path.join(user_cache, "_bazel_*")):
+        if not os.path.isdir(user_dir):
+            continue
+        for ob in glob.glob(os.path.join(user_dir, "*")):
+            if not os.path.isdir(ob) or os.path.basename(ob) in ("cache", "install"):
+                continue
+            dnbh = os.path.join(ob, "DO_NOT_BUILD_HERE")
+            if os.path.isfile(dnbh):
+                try:
+                    with open(dnbh, "r", encoding="utf-8") as f:
+                        ws = os.path.realpath(f.read().strip())
+                    if ws == target_worktree or ws.startswith(target_worktree + os.sep):
+                        output_bases.append(ob)
+                except OSError:
+                    pass
+
+    return sorted(list(set(output_bases)))
+
+
+async def get_path_size_human(path: str) -> str:
+    """Returns human-readable size of a directory path."""
+    if not os.path.exists(path):
+        return "0 B"
+    res = await run_command(["du", "-sk", path], timeout=5)
+    if res.returncode == 0 and res.stdout.strip():
+        try:
+            kb = int(res.stdout.split()[0])
+            if kb < 1024:
+                return f"{kb} KB"
+            mb = kb / 1024.0
+            if mb < 1024:
+                return f"{mb:.1f} MB"
+            gb = mb / 1024.0
+            return f"{gb:.2f} GB"
+        except (ValueError, IndexError):
+            pass
+    return "unknown size"
+
+
+async def shutdown_and_remove_bazel_output_base(
+    output_base: str, dry_run: bool = False
+) -> bool:
+    """Cleanly shuts down the Bazel server and removes the output base directory."""
+    if dry_run:
+        log(
+            f"  [Dry Run] Would shutdown Bazel server and delete output base: {output_base}"
+        )
+        return True
+
+    log(f"  Shutting down Bazel server for output base: {output_base}")
+    # 1. Graceful shutdown command
+    await run_command(
+        ["bazel", f"--output_base={output_base}", "shutdown"],
+        timeout=10,
+    )
+
+    # 2. Check for running server process from server.pid.txt
+    server_pid_file = os.path.join(output_base, "server", "server.pid.txt")
+    if os.path.isfile(server_pid_file):
+        try:
+            with open(server_pid_file, "r", encoding="utf-8") as f:
+                pid = int(f.read().strip())
+            os.kill(pid, 0)
+            log(f"  Terminating lingering Bazel server process (PID: {pid})...")
+            os.kill(pid, signal.SIGTERM)
+            await asyncio.sleep(0.5)
+            try:
+                os.kill(pid, signal.SIGKILL)
+            except OSError:
+                pass
+        except (ValueError, OSError):
+            pass
+
+    # 3. Terminate any persistent workers or subprocesses tied to this output base
+    terminate_processes_under_path(output_base)
+
+    # 4. Remove output base directory (NEVER run bazel clean --expunge per rules)
+    log(f"  Removing Bazel output base directory: {output_base}")
+    return await remove_directory_safely(output_base)
+
+
+def terminate_processes_under_path(path: str) -> None:
+    """Terminates active processes running binaries or workers inside path."""
+    current_pid = os.getpid()
+    if not os.path.isdir("/proc"):
+        return
+
+    real_path = os.path.realpath(path)
+    for entry in os.listdir("/proc"):
+        if not entry.isdigit():
+            continue
+        pid = int(entry)
+        if pid == current_pid or pid == 1:
+            continue
+        try:
+            exe = os.path.realpath(f"/proc/{pid}/exe")
+            if exe.startswith(real_path + os.sep):
+                os.kill(pid, signal.SIGTERM)
+                continue
+            with open(f"/proc/{pid}/cmdline", "rb") as f:
+                cmdline = f.read().decode("utf-8", errors="ignore")
+            if real_path in cmdline:
+                os.kill(pid, signal.SIGTERM)
+        except (OSError, FileNotFoundError):
+            continue
+
+
+async def remove_directory_safely(path: str) -> bool:
+    """Recursively removes a directory tree, making read-only files writable."""
+    if not os.path.exists(path) and not os.path.islink(path):
+        return True
+
+    res = await run_command(["rm", "-rf", path])
+    if res.returncode == 0 and not os.path.exists(path):
+        return True
+
+    def _sync_rmtree():
+        def _make_writable_and_retry(func, fpath, unused_exc_info):
+            try:
+                os.chmod(fpath, stat.S_IWRITE | stat.S_IWUSR | stat.S_IRUSR)
+                func(fpath)
+            except OSError:
+                pass
+
+        try:
+            shutil.rmtree(path, onerror=_make_writable_and_retry)
+        except Exception as exc:
+            log_warn(f"Failed to completely remove {path}: {exc}")
+
+    await asyncio.to_thread(_sync_rmtree)
+    return not os.path.exists(path)
+
+
+async def remove_git_worktree(
+    main_repo: str, worktree_path: str, dry_run: bool = False
+) -> bool:
+    """Removes a Git worktree safely."""
+    real_wt = os.path.realpath(worktree_path)
+    real_main = os.path.realpath(main_repo)
+
+    if real_wt == real_main:
+        log_error(f"Cannot remove main repository worktree: {worktree_path}")
+        return False
+
+    if dry_run:
+        log(f"  [Dry Run] Would remove Git worktree: {worktree_path}")
+        return True
+
+    # If current working directory is inside worktree, cd to main repo
+    try:
+        cwd = os.path.realpath(os.getcwd())
+        if cwd == real_wt or cwd.startswith(real_wt + os.sep):
+            os.chdir(real_main)
+    except OSError:
+        os.chdir(real_main)
+
+    log(f"  Removing Git worktree: {worktree_path}")
+    res = await run_command(
+        ["git", "-C", real_main, "worktree", "remove", "--force", real_wt]
+    )
+    if res.returncode != 0:
+        log_warn(
+            f"git worktree remove failed ({res.stderr.strip()}), removing directly..."
+        )
+        await remove_directory_safely(real_wt)
+        await run_command(["git", "-C", real_main, "worktree", "prune"])
+
+    return not os.path.exists(real_wt)
+
+
+async def delete_local_branch(
+    main_repo: str, branch: str, dry_run: bool = False
+) -> tuple[bool, str]:
+    """Deletes a local Git branch."""
+    if is_protected_branch(branch):
+        return False, f"Branch '{branch}' is protected and will not be deleted."
+
+    if dry_run:
+        return True, f"[Dry Run] Would delete local branch '{branch}'."
+
+    # Verify branch is not checked out in another worktree
+    worktrees = await get_worktrees(main_repo)
+    for wt in worktrees:
+        if wt.branch == branch and os.path.exists(wt.path):
+            return (
+                False,
+                f"Branch '{branch}' is still checked out in active worktree: {wt.path}",
+            )
+
+    res = await run_command(["git", "-C", main_repo, "branch", "-D", branch])
+    if res.returncode == 0:
+        return True, f"Deleted local branch '{branch}'."
+    return False, f"Failed to delete local branch '{branch}': {res.stderr.strip()}"
+
+
+async def delete_remote_branch(
+    main_repo: str,
+    branch: str,
+    remote: str,
+    dry_run: bool = False,
+) -> tuple[bool, str]:
+    """Deletes a branch on the remote fork."""
+    if is_protected_branch(branch):
+        return False, f"Branch '{branch}' is protected and will not be deleted."
+
+    if remote in PROTECTED_REMOTES:
+        return (
+            False,
+            f"Remote '{remote}' is protected (canonical upstream). Branch will not be deleted.",
+        )
+
+    exists = await remote_branch_exists(main_repo, remote, branch)
+    if not exists:
+        return True, f"Remote branch '{branch}' does not exist on '{remote}'."
+
+    if dry_run:
+        return (
+            True,
+            f"[Dry Run] Would delete remote branch '{branch}' on remote '{remote}'.",
+        )
+
+    res = await run_command(
+        ["git", "-C", main_repo, "push", remote, "--delete", branch],
+        timeout=30,
+    )
+    if res.returncode == 0:
+        return True, f"Deleted remote branch '{branch}' from '{remote}'."
+    return (
+        False,
+        f"Failed to delete remote branch '{branch}' on '{remote}': {res.stderr.strip()}",
+    )
+
+
+async def build_cleanup_target(
+    main_repo: str,
+    worktree_path: str | None,
+    branch: str | None,
+    remote: str | None = None,
+) -> CleanupTarget:
+    """Assembles all resources associated with a target worktree/branch."""
+    target = CleanupTarget(
+        worktree_path=worktree_path,
+        branch=branch,
+        remote=remote,
+    )
+
+    if worktree_path:
+        real_wt = os.path.realpath(worktree_path)
+        real_main = os.path.realpath(main_repo)
+        target.is_main_worktree = real_wt == real_main
+        target.bazel_output_bases = find_bazel_output_bases(worktree_path)
+
+    if branch:
+        if not target.remote:
+            target.remote = await get_push_remote_for_branch(main_repo, branch)
+        if target.remote and target.remote not in PROTECTED_REMOTES:
+            target.remote_branch_exists = await remote_branch_exists(
+                main_repo, target.remote, branch
+            )
+
+    return target
+
+
+async def execute_cleanup_target(
+    main_repo: str,
+    target: CleanupTarget,
+    leave_bazel_output_base: bool = False,
+    leave_worktree: bool = False,
+    leave_branch: bool = False,
+    leave_remote: bool = False,
+    dry_run: bool = False,
+) -> None:
+    """Executes cleanup on a single target."""
+    log(f"\nCleaning up target resources for: {target.worktree_path or target.branch}")
+
+    if target.is_main_worktree:
+        log_error(
+            "Target is the main repository! Refusing to clean up main repository."
+        )
+        return
+
+    # 1. Clean up Bazel output bases
+    if not leave_bazel_output_base and target.bazel_output_bases:
+        log("1. Cleaning up Bazel output bases:")
+        for ob in target.bazel_output_bases:
+            size_str = await get_path_size_human(ob)
+            log(f"  - Output base: {ob} ({size_str})")
+            await shutdown_and_remove_bazel_output_base(ob, dry_run=dry_run)
+    elif leave_bazel_output_base:
+        log("1. Leaving Bazel output base intact.")
+    else:
+        log("1. No associated Bazel output bases found.")
+
+    # 2. Clean up Git worktree
+    if not leave_worktree and target.worktree_path:
+        log("2. Cleaning up Git worktree:")
+        await remove_git_worktree(main_repo, target.worktree_path, dry_run=dry_run)
+    elif leave_worktree:
+        log("2. Leaving Git worktree directory on disk.")
+    else:
+        log("2. No worktree directory to remove.")
+
+    # 3. Clean up local Git branch
+    if not leave_branch and target.branch:
+        log(f"3. Cleaning up local branch '{target.branch}':")
+        success, msg = await delete_local_branch(
+            main_repo, target.branch, dry_run=dry_run
+        )
+        if success:
+            log(f"  {msg}")
+        else:
+            log_warn(f"  {msg}")
+    elif leave_branch:
+        log("3. Leaving local branch intact.")
+    else:
+        log("3. No local branch specified.")
+
+    # 4. Clean up remote Git branch
+    if (
+        not leave_remote
+        and target.branch
+        and target.remote
+        and target.remote_branch_exists
+    ):
+        log(f"4. Cleaning up remote branch '{target.branch}' on '{target.remote}':")
+        success, msg = await delete_remote_branch(
+            main_repo, target.branch, target.remote, dry_run=dry_run
+        )
+        if success:
+            log(f"  {msg}")
+        else:
+            log_warn(f"  {msg}")
+    elif leave_remote:
+        log("4. Leaving remote branch intact.")
+    elif target.branch and target.remote:
+        log(f"4. Remote branch '{target.branch}' not present on '{target.remote}'.")
+
+
+async def list_resources(main_repo: str) -> None:
+    """Lists all worktrees and their associated resources."""
+    worktrees = await get_worktrees(main_repo)
+    log(f"Found {len(worktrees)} Git worktrees in {main_repo}:\n")
+    for wt in worktrees:
+        kind = " [MAIN REPO]" if wt.is_main else ""
+        branch_str = f" (branch: {wt.branch})" if wt.branch else " (detached HEAD)"
+        log(f"* {wt.path}{kind}{branch_str}")
+        obs = find_bazel_output_bases(wt.path)
+        if obs:
+            for ob in obs:
+                size_str = await get_path_size_human(ob)
+                log(f"    - Bazel output base: {ob} ({size_str})")
+        else:
+            log("    - No Bazel output base found.")
+
+
+def parse_args() -> argparse.Namespace:
+    """Parses command-line arguments."""
+    parser = argparse.ArgumentParser(
+        description="Clean up agent workspace resources (Bazel output base, worktree, branches)."
+    )
+    parser.add_argument(
+        "--worktree",
+        type=str,
+        default=None,
+        help="Path to the Git worktree to clean up.",
+    )
+    parser.add_argument(
+        "--branches",
+        nargs="+",
+        default=[],
+        help="One or more branch names to clean up.",
+    )
+    parser.add_argument(
+        "--remote",
+        type=str,
+        default=None,
+        help="Remote name for remote branch deletion (default: pushRemote or origin).",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Preview resources to delete without making changes.",
+    )
+    parser.add_argument(
+        "-f",
+        "--force",
+        action="store_true",
+        help="Proceed with cleanup without interactive confirmation.",
+    )
+    parser.add_argument(
+        "--list",
+        action="store_true",
+        help="List worktrees, branches, and associated Bazel output bases.",
+    )
+    parser.add_argument(
+        "--leave-bazel-output-base",
+        action="store_true",
+        help="Leave Bazel output bases and build caches intact.",
+    )
+    parser.add_argument(
+        "--leave-worktree",
+        action="store_true",
+        help="Leave Git worktree directory on disk.",
+    )
+    parser.add_argument(
+        "--leave-branch",
+        action="store_true",
+        help="Keep local Git branch.",
+    )
+    parser.add_argument(
+        "--leave-remote",
+        action="store_true",
+        help="Do not delete branch from remote fork.",
+    )
+    return parser.parse_args()
+
+
+async def async_main() -> None:
+    """Async CLI entrypoint."""
+    args = parse_args()
+    main_repo = await get_main_repo()
+
+    if args.list:
+        await list_resources(main_repo)
+        return
+
+    worktrees = await get_worktrees(main_repo)
+    targets: list[CleanupTarget] = []
+
+    # If branches were explicitly provided
+    if args.branches:
+        for branch in args.branches:
+            wt_path = None
+            for wt in worktrees:
+                if wt.branch == branch:
+                    wt_path = wt.path
+                    break
+            target = await build_cleanup_target(
+                main_repo, wt_path, branch, remote=args.remote
+            )
+            targets.append(target)
+
+    # If worktree was explicitly provided
+    elif args.worktree:
+        real_target = os.path.realpath(args.worktree)
+        found_branch = None
+        for wt in worktrees:
+            if os.path.realpath(wt.path) == real_target:
+                found_branch = wt.branch
+                break
+        target = await build_cleanup_target(
+            main_repo, args.worktree, found_branch, remote=args.remote
+        )
+        targets.append(target)
+
+    # Otherwise default to current worktree
+    else:
+        curr_wt = await get_current_worktree()
+        if not curr_wt:
+            log_error(
+                "Could not determine current worktree. "
+                "Specify --worktree or --branches."
+            )
+            sys.exit(1)
+
+        real_curr = os.path.realpath(curr_wt)
+        real_main = os.path.realpath(main_repo)
+        if real_curr == real_main:
+            log_error(
+                "Currently inside the main repository. Refusing to clean up main repository.\n"
+                "Specify --worktree or --branches."
+            )
+            sys.exit(1)
+
+        found_branch = None
+        for wt in worktrees:
+            if os.path.realpath(wt.path) == real_curr:
+                found_branch = wt.branch
+                break
+
+        target = await build_cleanup_target(
+            main_repo, curr_wt, found_branch, remote=args.remote
+        )
+        targets.append(target)
+
+    if not targets:
+        log_error("No valid targets identified for cleanup.")
+        sys.exit(1)
+
+    # Show preview
+    for target in targets:
+        log("\nIdentified resources for cleanup:")
+        if target.worktree_path:
+            log(f"  Worktree directory: {target.worktree_path}")
+        if target.bazel_output_bases:
+            for ob in target.bazel_output_bases:
+                size_str = await get_path_size_human(ob)
+                log(f"  Bazel output base:  {ob} ({size_str})")
+        else:
+            log("  Bazel output base:  None found")
+        if target.branch:
+            log(f"  Local branch:       {target.branch}")
+        if target.remote and target.remote_branch_exists:
+            log(f"  Remote branch:      {target.remote}/{target.branch}")
+
+    if not args.force and not args.dry_run and sys.stdin.isatty():
+        confirm = input("\nProceed with cleanup? [y/N]: ").strip()
+        if confirm.lower() not in ("y", "yes"):
+            log("Aborted.")
+            return
+
+    for target in targets:
+        await execute_cleanup_target(
+            main_repo,
+            target,
+            leave_bazel_output_base=args.leave_bazel_output_base,
+            leave_worktree=args.leave_worktree,
+            leave_branch=args.leave_branch,
+            leave_remote=args.leave_remote,
+            dry_run=args.dry_run,
+        )
+    log("\nCleanup completed.")
+
+
+def main() -> None:
+    """Main CLI entrypoint."""
+    asyncio.run(async_main())
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.agents/skills/conversation-done/scripts/cleanup_test.py b/.agents/skills/conversation-done/scripts/cleanup_test.py
new file mode 100644
index 0000000..18a38b1
--- /dev/null
+++ b/.agents/skills/conversation-done/scripts/cleanup_test.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Unit tests for the async cleanup script."""
+
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+# Ensure scripts dir is in sys.path
+sys_path = os.path.dirname(os.path.abspath(__file__))
+if sys_path not in sys.path:
+    sys.path.insert(0, sys_path)
+
+import cleanup  # noqa: E402
+
+
+class CleanupScriptTest(unittest.IsolatedAsyncioTestCase):
+    """Tests for cleanup.py async logic and safety guards."""
+
+    def test_is_protected_branch(self):
+        """Verifies protected branches cannot be deleted."""
+        self.assertTrue(cleanup.is_protected_branch("main"))
+        self.assertTrue(cleanup.is_protected_branch("master"))
+        self.assertTrue(cleanup.is_protected_branch("HEAD"))
+        self.assertTrue(cleanup.is_protected_branch("release/1.0"))
+        self.assertTrue(cleanup.is_protected_branch("release/2026-08"))
+        self.assertTrue(cleanup.is_protected_branch("release-1.0"))
+        self.assertTrue(cleanup.is_protected_branch("release-2026-08"))
+        self.assertTrue(cleanup.is_protected_branch(None))
+        self.assertFalse(cleanup.is_protected_branch("feature/my-cool-branch"))
+        self.assertFalse(cleanup.is_protected_branch("bugfix-1234"))
+
+    async def test_worktrees_parsing(self):
+        """Verifies parsing git worktree list porcelain output."""
+        sample_output = """worktree /path/to/main/repo
+HEAD 03f212c3f3b590a7103d512650ce09818d30d218
+branch refs/heads/main
+
+worktree /path/to/worktrees/feat1
+HEAD f130aea7fa0eab7d558897ed0ad915a1057e065c
+branch refs/heads/feat1
+
+worktree /path/to/worktrees/detached
+HEAD 21e9bd5b3b069d4057abac69adf985b7907308f8
+detached
+"""
+        with mock.patch("cleanup.run_command") as mock_run:
+            mock_run.return_value = subprocess.CompletedProcess(
+                args=[], returncode=0, stdout=sample_output, stderr=""
+            )
+            worktrees = await cleanup.get_worktrees("/path/to/main/repo")
+
+        self.assertEqual(len(worktrees), 3)
+        self.assertEqual(worktrees[0].path, "/path/to/main/repo")
+        self.assertTrue(worktrees[0].is_main)
+        self.assertEqual(worktrees[0].branch, "main")
+
+        self.assertEqual(worktrees[1].path, "/path/to/worktrees/feat1")
+        self.assertFalse(worktrees[1].is_main)
+        self.assertEqual(worktrees[1].branch, "feat1")
+
+        self.assertEqual(worktrees[2].path, "/path/to/worktrees/detached")
+        self.assertFalse(worktrees[2].is_main)
+        self.assertIsNone(worktrees[2].branch)
+
+    def test_find_bazel_output_bases(self):
+        """Verifies finding Bazel output bases matching target workspace."""
+        with tempfile.TemporaryDirectory() as temp_dir:
+            user_dir = os.path.join(temp_dir, "_bazel_testuser")
+            os.makedirs(user_dir)
+
+            # Output base 1: matches main worktree
+            ob1 = os.path.join(user_dir, "hash1")
+            os.makedirs(ob1)
+            with open(os.path.join(ob1, "DO_NOT_BUILD_HERE"), "w") as f:
+                f.write("/home/user/worktrees/my_feature\n")
+
+            # Output base 2: matches nested integration workspace
+            ob2 = os.path.join(user_dir, "hash2")
+            os.makedirs(ob2)
+            with open(os.path.join(ob2, "DO_NOT_BUILD_HERE"), "w") as f:
+                f.write("/home/user/worktrees/my_feature/tests/integration/nested\n")
+
+            # Output base 3: unrelated workspace
+            ob3 = os.path.join(user_dir, "hash3")
+            os.makedirs(ob3)
+            with open(os.path.join(ob3, "DO_NOT_BUILD_HERE"), "w") as f:
+                f.write("/home/user/worktrees/different_feature\n")
+
+            with mock.patch("os.path.expanduser", return_value=temp_dir):
+                bases = cleanup.find_bazel_output_bases(
+                    "/home/user/worktrees/my_feature"
+                )
+
+            self.assertEqual(sorted(bases), sorted([ob1, ob2]))
+
+    async def test_main_repo_safety(self):
+        """Verifies refusal to remove main repository worktree."""
+        main_repo = "/repo/main"
+        target = cleanup.CleanupTarget(
+            worktree_path="/repo/main",
+            branch="main",
+            is_main_worktree=True,
+        )
+        with mock.patch("cleanup.remove_git_worktree") as mock_rm_wt, mock.patch(
+            "cleanup.log_error"
+        ) as mock_log_err:
+            await cleanup.execute_cleanup_target(main_repo, target)
+            mock_rm_wt.assert_not_called()
+            mock_log_err.assert_called_with(
+                "Target is the main repository! Refusing to clean up main repository."
+            )
+
+    async def test_protected_branch_safety(self):
+        """Verifies refusal to delete protected branch."""
+        success, msg = await cleanup.delete_local_branch("/repo/main", "main")
+        self.assertFalse(success)
+        self.assertIn("protected", msg)
+
+        success, msg = await cleanup.delete_remote_branch(
+            "/repo/main", "main", "origin"
+        )
+        self.assertFalse(success)
+        self.assertIn("protected", msg)
+
+    async def test_upstream_remote_safety(self):
+        """Verifies refusal to delete branch on upstream remote."""
+        success, msg = await cleanup.delete_remote_branch(
+            "/repo/main", "feature-x", "upstream"
+        )
+        self.assertFalse(success)
+        self.assertIn("protected", msg)
+
+    async def test_build_cleanup_target_branches(self):
+        """Verifies building cleanup target for a branch."""
+        with mock.patch(
+            "cleanup.get_push_remote_for_branch", return_value="origin"
+        ), mock.patch("cleanup.remote_branch_exists", return_value=True):
+            target = await cleanup.build_cleanup_target(
+                "/repo/main",
+                "/repo/worktrees/feat1",
+                "feat1",
+            )
+            self.assertEqual(target.worktree_path, "/repo/worktrees/feat1")
+            self.assertEqual(target.branch, "feat1")
+            self.assertEqual(target.remote, "origin")
+            self.assertTrue(target.remote_branch_exists)
+
+
+if __name__ == "__main__":
+    unittest.main()