Skill: joeproai/joevault (version 1.0.0)

--- name: joevault description: Audit, classify, and quarantine stale paths after a profile switch, account migration, reset, or workspace move. Use when OpenClaw, Claude, WSL, Windows home directories, or project folders may still point at old locations, and when the user wants inactive state archived without breaking live paths. --- # JoeVault Archive old state without breaking live state. ## Quick start 1. Run `scripts/audit_paths.py` with the current root, stale root, and candidate relative paths. 2. Classify each stale path as `quarantine now`, `leave for inventory`, or `keep active`. 3. Quarantine only small, clearly inactive state first. 4. Inventory large stale workspaces before moving them. 5. When moving anything, use `scripts/quarantine_path.py` so the archive gets a manifest entry. ## Workflow ### 1. Audit first Run the audit script before any move: ```bash python3 scripts/audit_paths.py \ --current-root /mnt/c/Users/Joseph \ --stale-root /mnt/c/Users/Joe \ --candidate .openclaw \ --candidate openclaw-workspace \ --search-root /mnt/c/Users/Joseph/.openclaw \ --search-root /mnt/c/Users/Joseph/openclaw-workspace ``` What to look for: - stale paths that still exist - live configs that still reference stale paths - symlinks that still point at old homes - large directories that should not be moved blindly - recent activity inside stale paths ### 2. Classify the stale paths Use `references/classification.md` when deciding what to do. Default rules: - small old state directories like `.openclaw` or `.claude` are usually safe to quarantine first - large workspaces, model folders, training outputs, or media trees must be inventoried before moving - anything still referenced by live config is not ready for quarantine ### 3. Quarantine conservatively Use quarantine for paths that are clearly inactive: ```bash python3 scripts/quarantine_path.py \ --source /mnt/c/Users/Joe/.openclaw \ --archive-root /mnt/c/Users/Joseph/Archives/profile-switch-2026-03-31 \ --label old-joe-openclaw ``` The script: - creates the archive root if needed - refuses to overwrite an existing target - moves the source into the archive - appends a manifest entry to `MANIFEST.md` ### 4. Inventory large trees before touching them For large stale workspaces, get a top-level size view first: ```bash du -sh /path/to/stale-workspace/* 2>/dev/null | sort -h | tail -n 30 ``` Do not move a massive stale workspace until you know whether it contains: - model weights - voice assets - training outputs - media renders - reference repos - recent work ### 5. Verify the live system after every move After any quarantine step, check: - current symlinks still resolve correctly - active configs no longer reference old paths - the live service still starts - the archive manifest records what moved and why ## Notes - Prefer rename-and-quarantine over deletion. - Prefer one small safe move over one giant risky move. - If the user asks to “clean it all up,” still audit first. - If a stale workspace had recent activity, treat it as a reference archive candidate, not trash.

path: references/classification.md
content: # Classification guide

Use this when deciding whether a stale path should be quarantined immediately or left in place.

## Quarantine now

Good candidates:
- old `.openclaw` or `.claude` state directories
- stale cache directories
- old logs, completions, or ephemeral session state
- any path that is small, clearly inactive, and not referenced by current config

Checklist:
- not referenced by active config or workspace
- not a current symlink target
- small enough to move safely
- easy to reverse if needed

## Leave for inventory

Do not move blindly:
- large workspaces
- model directories
- training outputs
- voice or avatar assets
- media renders
- old repos with recent activity
- anything with unclear ownership

Checklist:
- get top-level sizes first
- check latest modified files
- decide whether it is reference, cold archive, or deletion candidate

## Keep active

Do not quarantine:
- current home directories
- current `.openclaw` or active config roots
- symlink targets in active use
- any path still referenced by services, scripts, or env files

## Archive naming

Prefer explicit, timestamped archive roots such as:

- `C:\Users\<user>\Archives\profile-switch-2026-03-31`
- `C:\Users\<user>\Archives\reset-cleanup-2026-03-31`

Inside the archive, use labels that explain what moved:

- `old-joe-openclaw`
- `old-claude-state`
- `legacy-agent-cache`

## Safety rule

If there is uncertainty, do not move the path yet. Inventory it first.


path: scripts/audit_paths.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable


def iso(ts: float | None) -> str | None:
    if ts is None:
        return None
    return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()


def windowsish(path: str) -> str:
    p = path.replace('/', '\\')
    if p.startswith('\\mnt\\'):
        parts = p.split('\\')
        if len(parts) >= 5:
            drive = parts[2].upper().rstrip(':')
            return f'{drive}:\\' + '\\'.join(parts[4:])
    return p


def latest_files(root: Path, limit: int = 10) -> list[dict]:
    items: list[tuple[float, str]] = []
    if not root.exists():
        return []
    for base, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in {'.git', 'node_modules', 'dist', 'build', '.next', '__pycache__'}]
        for name in files:
            fp = Path(base) / name
            try:
                mtime = fp.stat().st_mtime
            except OSError:
                continue
            items.append((mtime, str(fp)))
    items.sort(reverse=True)
    return [{'path': p, 'modified_at': iso(ts)} for ts, p in items[:limit]]


def path_size_bytes(root: Path) -> int:
    if not root.exists():
        return 0
    if root.is_file():
        return root.stat().st_size
    total = 0
    for base, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in {'.git', 'node_modules', 'dist', 'build', '.next', '__pycache__'}]
        for name in files:
            fp = Path(base) / name
            try:
                total += fp.stat().st_size
            except OSError:
                pass
    return total


def summarize_path(path: Path, recent_limit: int) -> dict:
    if not path.exists() and not path.is_symlink():
        return {'path': str(path), 'exists': False}
    try:
        stat = path.lstat()
        mtime = stat.st_mtime
    except OSError:
        mtime = None
    info = {
        'path': str(path),
        'exists': path.exists() or path.is_symlink(),
        'is_dir': path.is_dir(),
        'is_file': path.is_file(),
        'is_symlink': path.is_symlink(),
        'symlink_target': str(path.resolve()) if path.is_symlink() else None,
        'modified_at': iso(mtime),
    }
    if path.exists():
        info['size_bytes'] = path_size_bytes(path)
        if path.is_dir():
            info['latest_files'] = latest_files(path, recent_limit)
    return info


def scan_refs(search_roots: Iterable[Path], patterns: list[re.Pattern[str]]) -> list[dict]:
    hits: list[dict] = []
    for root in search_roots:
        if not root.exists():
            continue
        for base, dirs, files in os.walk(root):
            dirs[:] = [d for d in dirs if d not in {'.git', 'node_modules', 'dist', 'build', '.next', '__pycache__'}]
            for name in files:
                fp = Path(base) / name
                try:
                    text = fp.read_text(encoding='utf-8', errors='ignore')
                except OSError:
                    continue
                matched = [p.pattern for p in patterns if p.search(text)]
                if matched:
                    hits.append({'file': str(fp), 'patterns': matched})
    return hits


def main() -> None:
    ap = argparse.ArgumentParser(description='Audit active and stale paths before quarantine.')
    ap.add_argument('--current-root', required=True)
    ap.add_argument('--stale-root', required=True)
    ap.add_argument('--candidate', action='append', required=True, help='Relative path to inspect under both roots')
    ap.add_argument('--search-root', action='append', default=[], help='Root to scan for stale path references')
    ap.add_argument('--recent-limit', type=int, default=10)
    args = ap.parse_args()

    current_root = Path(args.current_root)
    stale_root = Path(args.stale_root)
    stale_strings = [
        re.escape(str(stale_root)),
        re.escape(windowsish(str(stale_root))),
        re.escape(str(stale_root).replace('/mnt/c/', 'C:/').replace('/', '\\')),
    ]
    patterns = [re.compile(s) for s in dict.fromkeys(stale_strings)]

    report = {
        'generated_at': datetime.now(timezone.utc).isoformat(),
        'current_root': str(current_root),
        'stale_root': str(stale_root),
        'candidates': [],
        'reference_hits': scan_refs([Path(p) for p in args.search_root], patterns),
    }

    for rel in args.candidate:
        report['candidates'].append({
            'relative_path': rel,
            'current': summarize_path(current_root / rel, args.recent_limit),
            'stale': summarize_path(stale_root / rel, args.recent_limit),
        })

    print(json.dumps(report, indent=2))


if __name__ == '__main__':
    main()


path: scripts/quarantine_path.py
content: #!/usr/bin/env python3
import argparse
import shutil
from datetime import datetime
from pathlib import Path


def append_manifest(manifest: Path, source: Path, target: Path, note: str | None) -> None:
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    if not manifest.exists():
        manifest.write_text('# Archive Manifest\n\n', encoding='utf-8')
    with manifest.open('a', encoding='utf-8') as fh:
        fh.write(f'## {timestamp}\n\n')
        fh.write(f'- moved: `{source}`\n')
        fh.write(f'- to: `{target}`\n')
        if note:
            fh.write(f'- note: {note}\n')
        fh.write('\n')


def main() -> None:
    ap = argparse.ArgumentParser(description='Move a stale path into an archive and append a manifest entry.')
    ap.add_argument('--source', required=True)
    ap.add_argument('--archive-root', required=True)
    ap.add_argument('--label', required=True, help='Target folder/file name inside the archive root')
    ap.add_argument('--note')
    args = ap.parse_args()

    source = Path(args.source)
    archive_root = Path(args.archive_root)
    target = archive_root / args.label
    manifest = archive_root / 'MANIFEST.md'

    if not source.exists() and not source.is_symlink():
        raise SystemExit(f'Source does not exist: {source}')
    if target.exists():
        raise SystemExit(f'Target already exists: {target}')

    archive_root.mkdir(parents=True, exist_ok=True)
    shutil.move(str(source), str(target))
    append_manifest(manifest, source, target, args.note)
    print(f'Moved {source} -> {target}')
    print(f'Updated manifest: {manifest}')


if __name__ == '__main__':
    main()


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