← Skills A skill, free · works in Claude

Persona Exorcist.

Find every AI persona in a codebase, and the hidden channel that keeps resurrecting the one you already deleted.

You delete an AI persona, some helpful pirate or snarky reviewer you wired up one afternoon, and it keeps coming back. You remove the file, and next session it's talking to you again. The definition is gone, but something is still summoning it.

I built this skill to find that something. It separates a persona's definition (the text describing the character) from its re-injection channels (the hooks, memory files, imports, and generators that actually load it back into context), then sweeps every place it can hide, including the folders above your repo and your global ~/.claude config, where one poisoned hook quietly re-summons the same character in every project. When it finds the source, it quarantines it reversibly instead of deleting, so nothing load-bearing disappears by accident.

The whole thing is right here. Read it, copy it, or download it. If it exorcises a ghost you'd been fighting, tell me.

persona-exorcist / SKILL.md
---
name: persona-exorcist
description: >-
  Hunt AI agent personas/personalities across a project AND trace every hidden
  channel that keeps re-invoking a persona you already deleted. Use this whenever
  the user mentions AI personas, agent personalities, role-play agents, a "team"
  or "cast" of AI characters/roles, persona/agent definition files, OR — most
  importantly — complains that a deleted, removed, or "killed" persona, agent,
  character, sub-agent, or role keeps coming back, getting invoked, summoned,
  resurfacing, re-appearing, or "won't stay gone" in agent.md / AGENTS.md /
  CLAUDE.md, skills, sub-agents, hooks, memory, settings, or script/prompt
  folders. Also trigger on "ghost persona", "why does X keep getting summoned",
  "I deleted the persona but it still runs", "phantom agent", or any request to
  audit / inventory / clean up AI persona definitions across a codebase. Trigger
  even if the user never says the word "persona" but describes an AI character or
  named role that re-appears after deletion. Hunts every scope — the repo, its
  parent directories, and the user-level global config (~/.claude) — because one
  poisoned global hook or CLAUDE.md re-summons a persona in EVERY project, the
  usual reason "I deleted it everywhere and it still comes back." Can also
  safely and reversibly scrub the persona once found.
---

# Persona Exorcist

Find AI-agent personas in a project and, crucially, trace the **re-injection
channels** that keep summoning one after its obvious definition file is deleted.

The easy half is grepping for `persona.md`. The half that matters — and the
reason the user is here — is the *ghost*: a persona they removed that keeps
getting invoked because something else still feeds it in. Your job is to find
that something else.

## Mental model (read this first)

A persona has two kinds of presence:

- **Definition** — text that describes the character. Deleting it removes one
  description.
- **Re-injection channel** — anything that causes the persona to be loaded into a
  model's context or spawned as an agent. Channels outlive definition files.

> A ghost = a deleted persona still re-injected because a channel (a) holds an
> inline copy, (b) points to / regenerates a definition, or (c) auto-triggers on
> the persona's name or role.

So when someone says "I deleted it but it keeps running," do **not** go looking
for another copy of the definition. Go looking for the *channel*. The full
catalog of definition surfaces and channels — and why each one matters — is in
`references/surfaces.md`. Read it before reporting; it's the substance of the
skill.

## Scope: a channel can live above the repo

The second thing that makes ghosts immortal: a re-injection channel doesn't have
to be *in* the repo. The same persona text in a parent monorepo's `CLAUDE.md`, in
a user-level `~/.claude/settings.json` hook, or in `~/.claude/CLAUDE.md`, loads
into **every** project. Someone can scrub their repo perfectly and the persona
returns, because the source sits one or more levels up. This is the usual reason
for "I deleted it everywhere and it *still* won't die."

So the hunt has nested scopes, and the sweep walks them on demand:

| Scope | What it covers | When |
|---|---|---|
| `repo` | the project tree (deep) | default; inventory; first pass |
| `up` | repo + every ancestor dir to `$HOME` (surface) | monorepo / nested projects |
| `user` | `~/.claude` global + editor configs (surface) | suspect global poisoning |
| `all` | `up` + `user` | **a real ghost hunt — use this** |

Rule of thumb: if a `repo`-scope sweep finds no live channel that explains the
resurrection, the ghost is almost certainly above the repo — escalate to
`--scope all` immediately. A user-level source is the highest-priority finding
because its blast radius is every project the user has.

## Workflow

### 1. Scope the hunt
Ask (or infer) which persona(s) are the problem and whether the user is doing a
broad **inventory** ("find all personas") or chasing a specific **ghost** ("X
keeps coming back"). Confirm the project root. If they don't have a name yet,
that's fine — the sweep will discover candidates.

### 2. Sweep
Run the bundled script to gather raw signal across every surface at once. It
reasons about nothing; it just collects, so you don't miss a channel:

```bash
python3 <skill-dir>/scripts/sweep.py <project-root> --scope all --name "Persona Name"
```

- **Pick the scope** from the table above. For a "deleted but still invoked"
  ghost, default to `--scope all` so a parent-dir or `~/.claude` source can't
  hide. For a plain inventory, `--scope repo` is enough.
- Repeat `--name` for several personas. Omit it on a first pass to discover
  candidate names from definition files, then re-run with the names found.
- Add `--all-text` to also scan source/config files (`.py/.js/.ts/.json/.yaml`)
  for personas embedded as system-prompt strings — slower and noisier, worth it
  when scripts are implicated (which they often are).

Output is grouped — **definition surfaces**, **hooks**, **import chains**,
**symlinks**, **name matches** — and every line is tagged with its scope
(`[REPO]`, `[ANCESTOR:…]`, `[USER(~)]`). User-level matches are pulled to the top
under a loud banner; treat those as the headline. The script also lists **manual
checks** it can't reach (git history, cron/routines, MCP `instructions`,
generators) — actually perform them; they hide real ghosts.

### 3. Classify
For every hit, decide which it is, using the labels from `references/surfaces.md`:
**Confirmed source**, **Likely source**, **Definition (inert)**, or **Mention
only**. Re-injection requires the text to actually reach a model's context or
spawn an agent — a changelog that names the persona is a mention, not a source.

### 4. Trace the ghost
For a "deleted but still invoked" persona, walk the channels in priority order.
**Read the actual hook commands and any scripts they call** — a `SessionStart` or
`UserPromptSubmit` hook whose stdout echoes the persona (or reads a file that
still contains it) is the single most common real culprit, followed by
auto-loaded memory files and triggering skill/sub-agent descriptions. Don't stop
at "this file mentions X"; confirm the path by which it re-enters context.

### 5. Report
Present findings grouped by source, strongest first:

```
## Persona Exorcist — findings for "<persona>"

### 🔴 USER-LEVEL sources  (poison EVERY project — fix first)
- [USER(~)] <file>:<line> — <what it is> — <why it re-invokes>

### Confirmed re-injection sources  (fix these to stop the ghost)
- [<scope>] <file>:<line> — <what it is> — <why it re-invokes> — <confidence>

### Likely sources
- ...

### Inert definitions  (describe the persona; nothing auto-loads them)
- ...

### Mentions only  (safe to ignore)
- ...

### Manual checks performed
- git history: ... | routines: ... | MCP/plugins: ... | generators: ...
```

Always tag each source with its scope, and surface any `USER(~)` source first —
its blast radius is every project, so it's the headline even if a repo-level
source also exists. Lead with the source(s) that explain the resurrection. Be
explicit when you *didn't* find a live channel — "no remaining re-injection
source found in any scope; the persona is inert" is a real and useful result.

### 6. Scrub — safely and reversibly
The user wants the ghost *gone*, but scrubbing can touch shared, high-stakes
config (a user-level hook, a global `CLAUDE.md`, a parent monorepo's
instructions) that may be load-bearing for other projects. So never hard-delete.
Use the bundled `scrub.py`, which quarantines to a timestamped backup with a
one-command undo:

```bash
# whole-file source (a persona file, or a hook script that only injects it):
python3 <skill-dir>/scripts/scrub.py quarantine <path> [<path> ...]

# line-level source (one line in MEMORY.md, one hook block in settings.json):
python3 <skill-dir>/scripts/scrub.py backup <path>     # then edit the original
```

Process:
1. **Confirm per source, loudest for user-level.** Before touching anything in
   `~/.claude` or an ancestor dir, say plainly that it affects every project and
   get explicit approval for *that* file. Repo-only changes are lower-stakes but
   still confirm.
2. **Quarantine whole-file sources; back-up-then-edit line-level ones.** For a
   line in `settings.json`/`MEMORY.md`/a triggering description, `backup` the
   file first, then make the precise edit with the Edit tool.
3. **Reversibility is the safety net.** `scrub.py restore latest` (or
   `restore <session-dir>`) puts everything back if a "garbage" file turns out to
   matter. Tell the user this exists; it's why quarantining beats `rm`.

### 7. Re-sweep to confirm the kill
Re-run the sweep at the **same scope you scrubbed** (usually `--scope all`) for
the same `--name`. The whole point is that the ghost stays dead across every
scope — show the user that no live re-injection source remains anywhere. This
close-the-loop step is what distinguishes an exorcism from another round of
deleting files.

## Notes
- **Read before you delete.** A persona file you're told is "garbage" may be
  load-bearing for a real workflow. Surface what you find and let the user
  decide; channels are the safe thing to cut, definitions less so. Quarantine
  (don't `rm`) so any mistake is one `restore` away.
- **User-level edits are global.** A change in `~/.claude` or an ancestor dir
  affects every project the user has, not just this repo. Treat those as the
  highest-stakes, highest-priority edits and confirm each one explicitly.
persona-exorcist / scripts/sweep.py
#!/usr/bin/env python3
"""
persona-exorcist sweep
======================
Gathers raw signal about AI-persona DEFINITIONS and the RE-INJECTION CHANNELS
that can keep loading a persona into a model's context even after its obvious
definition file is deleted.

Crucially, a ghost can live ABOVE the repo. The same persona text in a parent
monorepo's CLAUDE.md, or in a user-level ~/.claude/settings.json hook, or in
~/.claude/CLAUDE.md, re-injects into EVERY project. That is the textbook cause of
"I deleted it everywhere in my repo and it still won't die." So this sweep can
walk outward by scope:

    repo      just the project (deep recursive scan)        [default]
    up        repo + every ancestor directory up to $HOME    (surface scan)
    user      only ~/.claude global + editor configs         (surface scan)
    all       up + user  (the full exorcism — use this when chasing a ghost
              that has no source inside the repo)

This script decides nothing. It surfaces candidates + the channels that could
resurface them, labelled by scope, and the model traces and reports. Fixing a
USER-scope source affects all projects — the report must flag that loudly.

Usage:
    python3 sweep.py [ROOT] [--scope repo|up|user|all] [--name "Name" ...] [--all-text]
"""
import argparse
import fnmatch
import glob
import os
import re
import sys

SKIP_DIRS = {
    ".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build",
    ".next", ".turbo", ".cache", "coverage", ".pytest_cache", "vendor",
    ".mypy_cache", ".ruff_cache", "target",
}
DEFINITION_FILENAMES = {
    "claude.md", "agents.md", "agent.md", ".cursorrules", ".windsurfrules",
    "gemini.md", "copilot-instructions.md", "system-prompt.md", "persona.md",
    "personas.md", "characters.md", "roles.md", "team.md",
}
DEFINITION_DIRHINTS = {
    "personas", "persona", "characters", "agents", "subagents", "roles",
    "team", "prompts", "prompt", "system-prompts",
}
SETTINGS_FILENAMES = {"settings.json", "settings.local.json"}
INJECTING_HOOK_EVENTS = {
    "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse",
    "Stop", "SubagentStop", "Notification",
}
IMPORT_RE = re.compile(r"(?m)^\s*@([^\s]+\.(?:md|markdown|txt))\s*$")
FM_NAME_RE = re.compile(r"(?mi)^\s*name\s*:\s*(.+?)\s*$")
FM_DESC_RE = re.compile(r"(?mi)^\s*description\s*:\s*(.+?)\s*$")
TEXT_EXT = {".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".toml", ".sh", ".bash"}
CODE_EXT = {".py", ".js", ".ts", ".tsx", ".jsx", ".rb", ".go"}

# Auto-load surfaces checked at every scope (relative to a scope root).
SURFACE_FILES = [
    "CLAUDE.md", "AGENTS.md", "agent.md", "GEMINI.md", ".cursorrules",
    ".windsurfrules", ".github/copilot-instructions.md", ".claude/CLAUDE.md",
    ".claude/settings.json", ".claude/settings.local.json", "MEMORY.md",
]
SURFACE_GLOBS = [
    ".claude/agents/*.md", ".claude/commands/*.md", ".claude/skills/*/SKILL.md",
    ".claude/hooks/*", "memory/*.md", ".claude/memory/*.md",
]
# Extra surfaces that only exist at the user/global root (~ / $HOME).
USER_GLOBS = [
    ".claude/projects/*/memory/*.md", ".claude/projects/*/MEMORY.md",
    ".cursor/rules/*", ".codeium/*",
    ".claude/plugins/**/SKILL.md",  # plugin-bundled personas (name-grep only)
]


def read(path, limit=200_000):
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            return f.read(limit)
    except (OSError, UnicodeError):
        return ""


def rel(base, path):
    try:
        return os.path.relpath(path, base)
    except ValueError:
        return path


def section(title):
    print("\n" + "=" * 72 + f"\n{title}\n" + "=" * 72)


def grep_text(text, names):
    """Return [(lineno, line)] where any name matches (case-insensitive)."""
    if not names:
        return []
    pats = [re.compile(re.escape(n), re.IGNORECASE) for n in names]
    hits = []
    for i, line in enumerate(text.splitlines(), 1):
        if any(p.search(line) for p in pats):
            hits.append((i, line.strip()[:160]))
    return hits


# ---------------------------------------------------------------- deep scan
def iter_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for name in filenames:
            yield os.path.join(dirpath, name)


def scan_deep(root, names, all_text, out):
    base = root
    grep_ext = TEXT_EXT | (CODE_EXT if all_text else set())
    for path in iter_files(root):
        fn = os.path.basename(path)
        low = fn.lower()
        parts = {p.lower() for p in path.split(os.sep)}
        text = None
        # definitions
        is_def = low in DEFINITION_FILENAMES or low == "skill.md" or (
            (parts & DEFINITION_DIRHINTS) and low.endswith((".md", ".txt", ".json", ".yaml", ".yml")))
        if is_def:
            text = read(path)
            n = FM_NAME_RE.search(text[:1500])
            d = FM_DESC_RE.search(text[:1500])
            out["def"].append(("REPO", rel(base, path), n.group(1) if n else "",
                               d.group(1)[:160] if d else ""))
        # hooks
        if fn in SETTINGS_FILENAMES:
            text = text if text is not None else read(path)
            events = [e for e in INJECTING_HOOK_EVENTS if f'"{e}"' in text]
            cmds = re.findall(r'"command"\s*:\s*"([^"]+)"', text)
            out["hook"].append(("REPO", rel(base, path), events, cmds))
        # imports
        if low in {"claude.md", "agents.md", "agent.md"}:
            text = text if text is not None else read(path)
            imps = IMPORT_RE.findall(text)
            if imps:
                out["import"].append(("REPO", rel(base, path), imps))
        # name matches
        if names and (os.path.splitext(path)[1].lower() in grep_ext or low in DEFINITION_FILENAMES):
            text = text if text is not None else read(path)
            for ln, line in grep_text(text, names):
                out["name"].append(("REPO", rel(base, path), ln, line))
    # symlinks
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for name in list(dirnames) + filenames:
            p = os.path.join(dirpath, name)
            if os.path.islink(p):
                out["link"].append(("REPO", rel(base, p), os.readlink(p)))


# ------------------------------------------------------------- surface scan
def scan_surface(root, label, names, out, user=False):
    """Scan only the known auto-load surfaces at one scope level (no deep walk —
    we must NOT recurse a parent dir or we'd crawl every sibling repo)."""
    candidates = [os.path.join(root, f) for f in SURFACE_FILES]
    for g in SURFACE_GLOBS + (USER_GLOBS if user else []):
        recursive = "**" in g
        candidates += glob.glob(os.path.join(root, g), recursive=recursive)
    seen = set()
    for path in candidates:
        if path in seen or not os.path.isfile(path):
            continue
        seen.add(path)
        fn = os.path.basename(path)
        low = fn.lower()
        text = read(path)
        if fn in SETTINGS_FILENAMES:
            events = [e for e in INJECTING_HOOK_EVENTS if f'"{e}"' in text]
            cmds = re.findall(r'"command"\s*:\s*"([^"]+)"', text)
            out["hook"].append((label, path, events, cmds))
        if low in {"claude.md", "agents.md", "agent.md"}:
            imps = IMPORT_RE.findall(text)
            if imps:
                out["import"].append((label, path, imps))
        if low == "skill.md" or low.endswith(".md"):
            n = FM_NAME_RE.search(text[:1500])
            d = FM_DESC_RE.search(text[:1500])
            if n or d or "/agents/" in path or "/skills/" in path or "/commands/" in path:
                out["def"].append((label, path, n.group(1) if n else "",
                                   d.group(1)[:160] if d else ""))
        for ln, line in grep_text(text, names):
            out["name"].append((label, path, ln, line))


def ancestors(root, home):
    out = []
    cur = os.path.dirname(os.path.abspath(root))
    home = os.path.abspath(home)
    while cur and cur not in ("/",) and cur != home:
        out.append(cur)
        parent = os.path.dirname(cur)
        if parent == cur:
            break
        cur = parent
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("root", nargs="?", default=".")
    ap.add_argument("--scope", choices=["repo", "up", "user", "all"], default="repo")
    ap.add_argument("--name", action="append", default=[], dest="names")
    ap.add_argument("--all-text", action="store_true")
    args = ap.parse_args()
    root = os.path.abspath(args.root)
    home = os.path.abspath(os.path.expanduser("~"))

    out = {"def": [], "hook": [], "import": [], "link": [], "name": []}

    print("PERSONA-EXORCIST SWEEP")
    print(f"root:  {root}")
    print(f"scope: {args.scope}")
    print(f"home:  {home}")
    print(f"candidate names: {args.names or '(none — discovering from definitions)'}")

    scan_deep(root, args.names, args.all_text, out)
    if args.scope in ("up", "all"):
        for a in ancestors(root, home):
            scan_surface(a, f"ANCESTOR:{a}", args.names, out)
    if args.scope in ("user", "all"):
        scan_surface(home, "USER(~)", args.names, out, user=True)

    section("DEFINITION SURFACES  (where a persona is described)")
    if not out["def"]:
        print("(none found)")
    for scope, path, name, desc in out["def"]:
        print(f"\n• [{scope}] {path}")
        if name:
            print(f"    name: {name}")
        if desc:
            print(f"    description: {desc}")

    section("RE-INJECTION: HOOKS  (settings.json — the #1 ghost source)")
    print("Hook stdout is injected into context every session/turn.")
    if not out["hook"]:
        print("(none found)")
    for scope, path, events, cmds in out["hook"]:
        warn = "  <-- USER LEVEL: affects EVERY project!" if scope.startswith("USER") else ""
        print(f"\n• [{scope}] {path}{warn}")
        print(f"    injecting hook events: {events or 'none'}")
        for c in cmds:
            print(f"    hook command -> {c}")

    section("RE-INJECTION: IMPORT CHAINS  (@file pulled into CLAUDE.md/AGENTS.md)")
    if not out["import"]:
        print("(none found)")
    for scope, path, imps in out["import"]:
        print(f"\n• [{scope}] {path} imports:")
        for i in imps:
            print(f"    @{i}")

    section("RE-INJECTION: SYMLINKS  (a deleted file may live behind a link)")
    if not out["link"]:
        print("(none)")
    for scope, path, target in out["link"]:
        print(f"• [{scope}] {path} -> {target}")

    section("NAME MATCHES  (every line mentioning a candidate persona)")
    if not args.names:
        print("No --name given. Review DEFINITION SURFACES, pick the persona")
        print("name(s)/role(s), then re-run with --name (and --scope all) to trace.")
    elif not out["name"]:
        print("(no matches in any scanned scope)")
    else:
        user_hits = [m for m in out["name"] if m[0].startswith("USER")]
        if user_hits:
            print("!!! USER-LEVEL MATCHES — these poison EVERY project. Fix first:")
            for scope, path, ln, line in user_hits:
                print(f"  [{scope}] {path}:{ln}: {line}")
            print("")
        for scope, path, ln, line in out["name"]:
            if scope.startswith("USER"):
                continue
            print(f"  [{scope}] {path}:{ln}: {line}")

    section("MANUAL CHECKS NOT COVERED BY THIS SWEEP")
    print("- git history / stashes:  git log --all -p -S '<persona>'")
    print("- scheduled/cron agents (routines) that name the persona")
    print("- MCP server `instructions` fields injected at session start")
    print("- generators/templates that RECREATE a persona file on some command")
    if args.scope == "repo":
        print("- !! You scanned scope=repo only. If no in-repo source explains the")
        print("     ghost, re-run with --scope all to reach ancestors + ~/.claude.")


if __name__ == "__main__":
    main()
persona-exorcist / scripts/scrub.py
#!/usr/bin/env python3
"""
persona-exorcist scrub  (safe, reversible removal)
==================================================
Scrubbing a persona ghost often means touching high-stakes, shared config:
a user-level ~/.claude/settings.json hook, a global CLAUDE.md, a parent
monorepo's instructions. Those can be load-bearing for OTHER projects, so this
tool never hard-deletes. It QUARANTINES (moves to a timestamped backup with a
manifest) so any over-eager removal is one command to undo.

Two granularities:
  - Whole-file source (a persona definition file, or a hook script that exists
    only to inject the persona): `quarantine` it (move out, reversible).
  - Line-level source (one line in MEMORY.md, one hook block in settings.json):
    `backup` the file first, then edit the original in place with your editor /
    the Edit tool. The backup lets you restore if the edit goes wrong.

Quarantine root: ~/.persona-exorcist-quarantine/<timestamp>/
A manifest.json in each session maps original <-> quarantined paths.

Usage:
    python3 scrub.py backup     <path> [<path> ...]      # copy aside, leave original
    python3 scrub.py quarantine <path> [<path> ...]      # copy aside, remove original
    python3 scrub.py list                                # show sessions
    python3 scrub.py restore    [<session-dir>|latest]   # put everything back

ALWAYS run with the user's explicit approval per source. Print what you did.
"""
import json
import os
import shutil
import sys
from datetime import datetime

QROOT = os.path.expanduser("~/.persona-exorcist-quarantine")


def _session_dir():
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    d = os.path.join(QROOT, ts)
    os.makedirs(d, exist_ok=True)
    return d


def _safe_rel(path):
    # Mirror the absolute path under the session dir, stripping the leading sep.
    ap = os.path.abspath(path)
    return ap.lstrip(os.sep)


def _load_manifest(session):
    mf = os.path.join(session, "manifest.json")
    if os.path.exists(mf):
        return json.load(open(mf)), mf
    return {"entries": []}, mf


def _do(paths, remove):
    missing = [p for p in paths if not os.path.exists(p)]
    if missing:
        print(f"ERROR: not found: {missing}")
        sys.exit(1)
    session = _session_dir()
    manifest, mf = _load_manifest(session)
    for p in paths:
        ap = os.path.abspath(p)
        dest = os.path.join(session, _safe_rel(ap))
        os.makedirs(os.path.dirname(dest), exist_ok=True)
        if os.path.islink(ap):
            link_target = os.readlink(ap)
            with open(dest + ".symlink", "w") as f:
                f.write(link_target)
            entry = {"original": ap, "backup": dest + ".symlink", "type": "symlink",
                     "target": link_target, "removed": remove}
            if remove:
                os.unlink(ap)
        elif os.path.isdir(ap):
            shutil.copytree(ap, dest, symlinks=True)
            entry = {"original": ap, "backup": dest, "type": "dir", "removed": remove}
            if remove:
                shutil.rmtree(ap)
        else:
            shutil.copy2(ap, dest)
            entry = {"original": ap, "backup": dest, "type": "file", "removed": remove}
            if remove:
                os.remove(ap)
        manifest["entries"].append(entry)
        verb = "QUARANTINED (moved)" if remove else "BACKED UP (kept original)"
        print(f"{verb}: {ap}\n  -> {dest}")
    json.dump(manifest, open(mf, "w"), indent=2)
    print(f"\nManifest: {mf}")
    print(f"Undo with: python3 scrub.py restore {session}")


def cmd_list():
    if not os.path.isdir(QROOT):
        print("(no quarantine sessions)")
        return
    for d in sorted(os.listdir(QROOT)):
        sd = os.path.join(QROOT, d)
        man, _ = _load_manifest(sd)
        n = len(man.get("entries", []))
        removed = sum(1 for e in man["entries"] if e.get("removed"))
        print(f"{sd}  ({n} entries, {removed} moved)")


def cmd_restore(which):
    if not os.path.isdir(QROOT):
        print("(nothing to restore)")
        return
    sessions = sorted(os.path.join(QROOT, d) for d in os.listdir(QROOT)
                      if os.path.isdir(os.path.join(QROOT, d)))
    if not sessions:
        print("(nothing to restore)")
        return
    session = sessions[-1] if which in (None, "latest") else os.path.abspath(which)
    man, _ = _load_manifest(session)
    for e in man.get("entries", []):
        orig, typ = e["original"], e["type"]
        os.makedirs(os.path.dirname(orig), exist_ok=True)
        if typ == "symlink":
            if os.path.lexists(orig):
                os.unlink(orig)
            os.symlink(e["target"], orig)
        elif typ == "dir":
            if os.path.exists(orig):
                shutil.rmtree(orig)
            shutil.copytree(e["backup"], orig, symlinks=True)
        else:
            shutil.copy2(e["backup"], orig)
        print(f"RESTORED: {orig}")
    print(f"\nRestored from {session}")


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)
    cmd = sys.argv[1]
    rest = sys.argv[2:]
    if cmd == "backup":
        _do(rest, remove=False)
    elif cmd == "quarantine":
        _do(rest, remove=True)
    elif cmd == "list":
        cmd_list()
    elif cmd == "restore":
        cmd_restore(rest[0] if rest else "latest")
    else:
        print(f"unknown command: {cmd}\n")
        print(__doc__)
        sys.exit(1)


if __name__ == "__main__":
    main()

Or grab the file

Drop it into Claude and go.

Download the skill (.zip)

The full bundle: SKILL.md plus the sweep and scrub scripts. Unzip it into ~/.claude/skills, then point it at a project where a persona won't stay dead.