Skip to content

Change-triggered backup — hook install

One page on wiring the backup_on_change.py PostToolUse hook into Claude Code so a new snapshot fires automatically whenever you edit a tracked config file (~/.claude/settings.json, agents, skills, top-level manifests, etc.).

What it does

On every Edit / Write / MultiEdit tool call, the hook:

  1. Reads the tool payload from stdin.
  2. Resolves tool_input.file_path and checks if it sits under ~/.claude in a file/tree/memory path tracked by BackupConfig.
  3. If tracked, shells out to python <repo>/src/backup_mirror.py snapshot-if-changed --reason <tool>:<basename>.
  4. snapshot-if-changed hashes every tracked file, compares against the most recent snapshot's manifest.json, and only creates a new folder when at least one SHA differs.

No-op edits don't create folders. The hook always exits 0 so a bug in the backup layer cannot stall a Claude session.

Register the hook

Edit ~/.claude/settings.json and add the following under hooks (keep any existing entries alongside it). Replace <REPO> with the absolute path to this checkout — on Windows this is a path like C:/Steves_Files/Work/Research_and_Papers/ctx.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "python <REPO>/hooks/backup_on_change.py"
          }
        ]
      }
    ]
  }
}

Notes:

  • The matcher is a regex against the tool name — the three names above are the only tools that touch files.
  • Use forward slashes in the path even on Windows.
  • If python on your PATH is not the interpreter you want, give the absolute path instead (e.g. C:/Users/you/.pyenv/pyenv-win/versions/3.13.2/python.exe).

Verify it works

  1. Reload Claude Code (the hook registration is read at session start).
  2. Edit a tracked file, e.g. ~/.claude/CLAUDE.md.
  3. Watch ~/.claude/backups/ — a new folder named <timestamp>__edit-claude-md should appear within a second.
  4. Edit the same file again with identical content — no new folder appears (SHA is unchanged).

If nothing shows up, run the verb manually to isolate the failure:

python -m backup_mirror snapshot-if-changed --reason smoke-test --json

The JSON output tells you which files the detector considered new, changed, or removed.

What gets backed up

See src/backup_config.py and the backup section of src/config.json for the current defaults:

  • top_filessettings.json, skill-manifest.json, pending-skills.json, CLAUDE.md, AGENTS.md, user-profile.json, skill-system-config.json, skill-registry.json. User overrides must be top-level filenames only: no path separators, absolute paths, drive letters, UNC paths, . entries, or .. traversal.
  • treesagents/, skills/.
  • memoryprojects/*/memory/** when memory_glob is true.
  • always excluded.credentials.json, claude.json, token caches; these are dropped even if a user config lists them.

To override per user, drop a partial config at ~/.claude/backup-config.json. Fields you omit fall back to the repo default. Invalid top_files entries are ignored with a [backup-config] stderr warning; if every supplied top_files entry is invalid, the effective config falls back to the defaults instead of disabling top-level backups. Example:

{
  "retention": { "keep_latest": 100 },
  "top_files": ["settings.json", "CLAUDE.md"]
}

Manual CLI

The same verb is available as a one-shot command:

# snapshot only when something changed
python -m backup_mirror snapshot-if-changed --reason manual-check

# force an unconditional snapshot with a reason label
python -m backup_mirror create --reason pre-upgrade

Both land under ~/.claude/backups/<timestamp>__<reason>/ and write a manifest.json that records the reason alongside every file's SHA-256.

Watchdog — snapshot on changes outside a Claude session

The PostToolUse hook only fires on Edit / Write / MultiEdit tool calls inside a Claude session. If you edit ~/.claude/settings.json in VS Code, or a git pull updates an agent file, the hook never sees it.

For that gap, run the polling watchdog — a simple loop that calls snapshot-if-changed every N seconds:

python -m backup_mirror watchdog --interval 60

Flags:

Flag Meaning
--interval N Seconds between polls. Clamped to [5, 3600]. Default 60.
--reason-prefix LBL Prefix used for each snapshot's --reason label. Default watchdog.
--once Run exactly one tick and exit. Useful for cron / Task Scheduler.
--json Emit run stats as JSON on exit.

Because change detection is SHA-gated, polling is cheap — a tick with no real changes does zero disk writes.

Running it as a background service

Ready-to-use service manifests live under docs/services/. Each one expects you to edit a handful of paths — there's no installer that guesses where you keep the checkout.

All three manifests assume the watchdog runs as an unprivileged user — no admin/root — because it only reads ~/.claude/ and writes ~/.claude/backups/.

The watchdog stops cleanly on SIGINT/SIGTERM, flushes its stats line to stderr, and exits 0. Pair it with the hook: the hook handles in-session edits in real time; the watchdog catches everything else.

Retention — how old snapshots get pruned

Auto-pruning runs after every successful snapshot-if-changed, so the hook cannot fill the disk. The active policy comes from BackupRetention in src/backup_config.py (or your user override):

Field Default Meaning
keep_latest 50 Always keep the N most-recent snapshots.
keep_daily 14 For the M most-recent UTC days that have snapshots, keep the newest snapshot from each.

A snapshot survives the sweep iff it's in the union of those two sets. Snapshots whose manifest.json has a missing or zero created_at are always protected — we never silently delete something we can't place in time.

To override per user, add a partial config at ~/.claude/backup-config.json:

{
  "retention": { "keep_latest": 100, "keep_daily": 30 }
}

Manual prune

# Dry-run the configured policy — no deletions, JSON report.
python -m backup_mirror prune --policy --dry-run --json

# Apply the configured policy for real.
python -m backup_mirror prune --policy

# Legacy mode (still works): keep only the N newest.
python -m backup_mirror prune --keep 20 --dry-run
python -m backup_mirror prune --keep 20

The policy output tells you which snapshots were kept by keep_latest versus keep_daily, so a surprising retention decision is easy to audit. Legacy --keep dry-runs print the snapshot IDs that would be removed without deleting their directories.

Troubleshooting

Symptom Likely cause
Hook never fires Settings not reloaded, or matcher typo.
Snapshot folder with no reason suffix Called create without --reason.
Hook fires but no folder appears Content hash matched — nothing actually changed.
Credentials appear in a snapshot User put them in top_files; the ALWAYS_EXCLUDE filter would drop them — check you're on the current backup_config.py.
ImportError: backup_config from the hook Repo moved; update the path in settings.json.
Snapshots pile up forever retention.keep_latest / keep_daily too high. Run prune --policy --dry-run --json to see what the current policy would do, then lower the caps in ~/.claude/backup-config.json.
Prune removed too much Run prune --policy --dry-run before committing to a new policy. A snapshot with a missing/zero created_at is always protected, so if it's getting deleted the manifest is probably fine and the policy is genuinely too aggressive.