# 3 Weeks of Silent Backup Failure: Why launchd Can't Write to ~/Documents on macOS

> Source: <https://dev.to/bokuwalily/3-weeks-of-silent-backup-failure-why-launchd-cant-write-to-documents-on-macos-4ecm>
> Published: 2026-08-19 05:00:06+00:00

Every Sunday at 6:00 AM, my backup job ran, logged `no changes`

, and exited 0. For three weeks straight it never copied a single file. macOS was blocking it, and nothing in the system told me.

The first problem you hit when you automate personal development work is "I can't roll back when my config breaks."

Once you build out a Claude Code environment, hundreds of lines of configuration accumulate under `~/.claude/`

. `settings.json`

(tool permissions, model selection, hook paths), `CLAUDE.md`

(the full text of global operating rules), the `hooks/`

directory (automated checks like Stop hooks and PreTool hooks), `agents/`

(specialized agent definition files), `skills/auto/`

(skills that get self-generated during real usage), `rules/`

(the ECC rules digest) — dozens of places in these change every week. Claude Code updates, hook script improvements, new agent definitions. The more actively you use it, the more the configuration changes like a living thing.

The problem is that `~/.claude/`

isn't under version control. A Claude Code cache update wipes out a config file, a mistake in a hook script takes down every PreTool hook, a one-line typo in `settings.json`

makes permission prompts explode everywhere — and when that happens, with no diff and no history, you can't even tell *when* it broke. Reconstructing by feel from "it was working a week ago" is pure lost time.

The two approaches most people reach for here are "back it up manually" or "put the whole thing in git."

**Manual backups** get tedious even at weekly cadence. And your environment breaks precisely during the week you skipped. "I was going to get around to it" doesn't help you recover.

**The whole thing in git** doesn't work in practice, because `~/.claude/`

mixes in noise like plugin cache, session history, telemetry, and paste-cache, which balloons the repository to several GB. It also can't rule out the risk of secrets ending up in the cache.

The solution is a two-stage structure: rsync only the config files to a separate directory, and put only that directory under git. Never touch `~/.claude/`

itself, and define the sync targets explicitly with an INCLUDE list. Even when the human forgets, launchd runs every Sunday at 6:00. If there's a diff, commit it in conventional commits format. If there's no diff, write `no changes`

to the log and exit.

This works because **it takes human willpower out of the loop**. Config files are too fine-grained to manage on the strength of "I should really back this up." But losing them costs hours to reconstruct. Automation fills that asymmetry, and launchd sits at the bottom layer of that automation as a highly reliable OS feature.

There are side benefits too. Because the diffs stay in git, you can track down "which change last week broke the hook" with `git log --oneline`

. You can discover configuration drift (parts that changed unintentionally). You can reproduce the setup immediately when moving to another machine. You can build up improvements while checking "what did I do a week ago" with `git diff`

.

The header comment of the actual script (`~/.claude/scripts/dotfiles-snapshot.sh`

) reads like this:

```
# dotfiles-snapshot.sh — ~/.claude の設定だけを別ディレクトリに同期して git 管理
# 元の ~/.claude は触らない（plugin cache 等のノイズと混ざらないため）
# 同期先: ~/.claude/config-snapshots/
#   （旧 ~/Documents/claude-config-snapshots は launchd 実行時に macOS TCC で
#     "Operation not permitted" になり全コピー失敗していたため 2026-06-01 に移設）
# 既存 ~/Documents/my-knowledge-base/ の SessionEnd auto-commit と分離管理
```

The line "never touch the original `~/.claude`

" expresses the design philosophy. The backup source directory itself isn't under git; only the necessary config files are copied to a separate directory, and only that becomes a repository. The INCLUDE list is the "canonical definition" of what counts as a config file.

And the fourth line of that comment — *the old ~/Documents/claude-config-snapshots hit macOS TCC "Operation not permitted" when run under launchd and every copy failed, so it was relocated on 2026-06-01* — is the actual subject of this article. Before that relocation on 2026-06-01, the backup ran every Sunday at 6:00 "looking like it worked" while doing nothing at all.

Here's the structure of the current system.

```
┌─────────────────────────────────────────────────────────┐
│  launchd                                                │
│  Label: com.shun.dotfiles-snapshot                      │
│  毎週日曜 06:00                                          │
│  ProcessType: Background / Nice: 10 / LowPriorityIO: true│
└──────────────────────┬──────────────────────────────────┘
                       │ /bin/zsh -c <script>
                       ▼
┌─────────────────────────────────────────────────────────┐
│  dotfiles-snapshot.sh                                   │
│                                                         │
│  SRC: ~/.claude/                                        │
│  DST: ~/.claude/config-snapshots/   ← 重要              │
│                                                         │
│  1. INCLUDE 11項目を rsync -a でコピー                   │
│  2. settings.json に APIキー混入チェック                  │
│  3. git add -A && git commit (変更ゼロなら skip)          │
│  4. HEAD 前進チェック（exit 0 でも HEAD 不動なら exit 3） │
└──────────────────────┬──────────────────────────────────┘
                       │
              ┌────────┴────────┐
              ▼                 ▼
┌─────────────────┐  ┌──────────────────────────────────┐
│ git repository  │  │ ~/.claude/logs/                  │
│ config-snapshots│  │ dotfiles-snapshot.log            │
│ (conventional   │  │ com.shun.dotfiles-snapshot.log   │
│  commits)       │  │ （plist StandardOutPath と共用）  │
└─────────────────┘  └──────────────────────────────────┘
```

Let's look at the core of the plist.

```
<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>    <integer>6</integer>
  <key>Minute</key>  <integer>0</integer>
  <key>Weekday</key> <integer>0</integer>
</dict>
<key>ProcessType</key>   <string>Background</string>
<key>LowPriorityIO</key> <true/>
<key>Nice</key>          <integer>10</integer>
```

`Weekday=0`

is Sunday. `Nice=10`

and `LowPriorityIO=true`

are appropriate settings for a 6 AM background job — they avoid competing for IO with the workload spike right after startup. It's weekly rather than daily because config changes are coarser-grained than daily. Claude Code configuration alternates between "weeks I touched a ton" and "weeks I didn't touch at all." Weekly gives sufficient diff resolution.

Log output is split across two destinations.

```
<key>StandardErrorPath</key>
<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>
<key>StandardOutPath</key>
<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>
```

The plist-level stdout/stderr are written by launchd. Logging inside the script flows to `LOGFILE="$HOME/.claude/logs/dotfiles-snapshot.log"`

. Since the script does `echo "[$(ts)] ..." >> "$LOGFILE"`

, launchd's startup log and the script's progress log get recorded in two separate files.

Next, the INCLUDE list. The script's backup targets are these 11 items:

```
INCLUDE=(
  "settings.json"
  "settings.local.json"
  "CLAUDE.md"
  "hooks/"
  "commands/"
  "agents/"
  "skills/auto/"
  "skills/ecc/"
  "scripts/"
  "rules/"
  "improvements/"
)
```

Cache, session, telemetry, paste-cache, and file-history are nowhere in there. Conversely, look at the EXCLUDES list:

```
EXCLUDES=(
  "--exclude=*.log"
  "--exclude=.cache/"
  "--exclude=node_modules/"
  "--exclude=*.tsbuildinfo"
  "--exclude=.harvest-watermark"
  "--exclude=tmp/"
)
```

`--exclude=.harvest-watermark`

stands out. That's a watermark file generated by Claude Code's automatic skill-harvesting feature, and it becomes diff noise if it makes it into commits. By explicitly excluding environment-specific junk, `git log`

records only meaningful configuration changes.

The rsync loop is simple.

```
for item in "${INCLUDE[@]}"; do
  if [ -e "$SRC/$item" ]; then
    rsync -a "${EXCLUDES[@]}" "$SRC/$item" "$DST/$item" 2>>"$LOGFILE"
  fi
done
```

Because it checks existence with `[ -e "$SRC/$item" ]`

before rsyncing, an item that's listed in INCLUDE but hasn't been created yet is harmlessly skipped. Errors flow to the log via `2>>"$LOGFILE"`

, so you can check afterward what happened during the run.

Before committing, an API key detection check runs.

```
if grep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}' \
    "$DST/settings.json" 2>/dev/null; then
  echo "[$(ts)] ABORT: secret detected in settings.json copy" >> "$LOGFILE"
  rm -f "$DST/settings.json"
  exit 1
fi
```

It greps for three patterns: `sk-*`

(Anthropic API key), `ghp_*`

(GitHub Personal Access Token), and `AKIA*`

(AWS Access Key). If you accidentally wrote an API key into settings.json, it deletes the copied file and terminates immediately with exit 1. The three steps — detect secret, delete the copy, abort — are consolidated in one place, so it stays safe even combined with pushing to a public repository.

The commit logic has one more defense.

```
PREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo "")
if git commit -m "chore(snapshot): claude-config $(date '+%Y-%m-%d %H:%M')" \
    >>"$LOGFILE" 2>&1; then
  NEW_HEAD=$(git rev-parse --verify --quiet HEAD || echo "")
  if [ -z "$NEW_HEAD" ] || [ "$NEW_HEAD" = "$PREV_HEAD" ]; then
    echo "[$(ts)] commit reported success but HEAD did not advance \
(prev=$PREV_HEAD new=$NEW_HEAD) — ABORT" >> "$LOGFILE"
    exit 3
  fi
  COMMIT=$(git rev-parse --short HEAD)
  echo "[$(ts)] snapshot done: ${CHANGED} files changed, commit=${COMMIT}" >> "$LOGFILE"
```

Even if `git commit`

returns exit 0, if HEAD hasn't moved it terminates abnormally with exit 3. A comment in the script says *前は -q で潰していて exit code も拾えていなかった* ("previously `-q`

suppressed it and the exit code wasn't captured either"), and this was implemented as a countermeasure for a past bug where *the commit hook (commit-msg validator) rejected the commit but the exit code appeared to be 0*. The line `commit reported success but HEAD did not advance`

in the log lets you trace the cause quickly.

A normal run's log looks like this:

```
[2026-06-08 06:00:12] snapshot start
[2026-06-08 06:00:13] snapshot done: 3 files changed, commit=a1b2c3d
```

And with no diff:

```
[2026-06-08 06:00:12] snapshot start
[2026-06-08 06:00:12] no changes
```

Just those two lines tell you whether the backup ran and whether anything changed. No dashboard, no notifications needed. You only open the log when something goes wrong.

That's the full picture of the current (correctly working) system.

So what was happening before 2026-06-01, when the backup destination was `~/Documents/claude-config-snapshots/`

? Where did the `"Operation not permitted"`

from that header comment come from, and why did everything fail silently? The next section breaks down the evidence.

`-e`

isn't added to `set -uo pipefail`

The script starts with `set -uo pipefail`

. Undefined variable references (`-u`

) and mid-pipeline errors (`-o pipefail`

) kill the script immediately, while `-e`

(exit immediately on any non-zero command) is deliberately left out.

There are two reasons.

The first is the secret check.

```
if grep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}' \
    "$DST/settings.json" 2>/dev/null; then
  echo "[$(ts)] ABORT: secret detected in settings.json copy" >> "$LOGFILE"
  rm -f "$DST/settings.json"
  exit 1
fi
```

`grep -q`

returns exit code 1 when the pattern is **not** found (i.e., no API key present, the normal state). Enabling `-e`

would produce the backwards behavior of "no secret present = normal = exit 1 = script aborts." Since `if grep ...`

makes the control flow explicit, `-e`

isn't just unnecessary — it's harmful.

The second is HEAD retrieval.

```
PREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo "")
```

`git rev-parse HEAD`

returns exit 1 right after `git init`

(when no commit exists yet). `|| echo ""`

falls back to an empty string so the first run works safely too, but with `-e`

the script would die before the `||`

is evaluated.

In a design that controls failures with explicit `exit 1 / exit 2 / exit 3`

, `-e`

is noise. `set -uo pipefail`

expresses a deliberate policy: "kill on undefined variables and mid-pipeline errors; handle command non-zero returns myself."

The plist's `EnvironmentVariables`

block enumerates a complete PATH including nvm-managed Node.js, Homebrew, and `~/.local/bin`

.

Background processes launched by launchd read neither `~/.zshrc`

nor `~/.zprofile`

. The default PATH is barely more than `/usr/bin:/bin:/usr/sbin:/sbin`

and doesn't even include `/opt/homebrew/bin`

. `git`

and `rsync`

live in the system's `/usr/bin`

, but git hooks that need Node.js will absolutely jam.

This script itself is written with only bash, rsync, and git, and doesn't call node.js directly. So why is the Node.js path needed? Because the **global git hook ~/.git-hooks/commit-msg** calls a Node-based validator in some environments when verifying conventional commits format. With an incomplete PATH, you end up in a situation where

`git commit`

runs but the hook silently fails every time.Writing the full PATH into the launchd process is a one-time cost. Far cheaper than getting stuck later on "for some reason only git commit fails."

The plist says this:

```
<key>StandardErrorPath</key>
<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>
<key>StandardOutPath</key>
<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>
```

But the execution string in `ProgramArguments`

contains a shell-level redirect.

```
<string>/bin/zsh</string>
<string>-c</string>
<string>/path/to/dotfiles-snapshot.sh >> /dev/null 2>&1</string>
```

With `>> /dev/null 2>&1`

, the script's own stdout/stderr are thrown into `/dev/null`

at shell evaluation time. The fd that launchd set up via `StandardOutPath`

gets overwritten. So what is `StandardOutPath`

actually capturing?

The answer is "zsh's own startup errors." If zsh fails before evaluating the `-c`

string — a broken script path, zsh not found, and so on — that error flows to the fd launchd set up, ahead of the shell-level redirect. `com.shun.dotfiles-snapshot.log`

functions not as the script's progress log but as a **fallback that catches launchd-layer startup failures**.

The script's progress is recorded via explicit `>>`

writes to `LOGFILE="$HOME/.claude/logs/dotfiles-snapshot.log"`

.

```
ts() { date '+%Y-%m-%d %H:%M:%S'; }
echo "[$(ts)] snapshot start" >> "$LOGFILE"
```

This two-path separation lets you track "did the script start (launchd log)" and "what happened inside the script (dotfiles-snapshot.log)" in separate files. The starting point for troubleshooting is the latter.

The rsync loop sends errors to the log but doesn't stop the loop.

```
for item in "${INCLUDE[@]}"; do
  if [ -e "$SRC/$item" ]; then
    rsync -a "${EXCLUDES[@]}" "$SRC/$item" "$DST/$item" 2>>"$LOGFILE"
  fi
done
```

It doesn't check rsync's exit code. This is an intentional design. If the `agents/`

copy fails on a permission error, I don't want that to stop the `CLAUDE.md`

or `hooks/`

copies too. The idea is: tolerate partial failure, copy as many files as possible, and leave errors in the log.

But this design also means "the script exits normally even if every item errors." If nothing gets copied to DST, no diff appears in git, and it records `no changes`

and finishes. The TCC bug described later is exactly this case.

```
grep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}'
```

`sk-*`

is an Anthropic API key, `ghp_*`

a GitHub Personal Access Token, and `AKIA*`

an AWS Access Key ID. settings.json is where you write Claude Code tool permissions and model settings, so these three families are chosen because they're the ones you might accidentally hardcode. A generic "long alphanumeric string" pattern produces too many false positives, so the policy is to narrow by prefix.

When something is detected, it **deletes the copied file and then exits 1**. If you exit 1 with the copied file still there, the next run records the "deleted state" as a diff and leaves contamination in the git log. The order — delete, then exit 1 — matters.

**Symptom**: launchd starts every Sunday at 6:00. The log shows `[2026-05-xx 06:00:12] snapshot start`

and `[2026-05-xx 06:00:12] no changes`

lined up weekly. At a glance, it looks like it's working fine.

But when I checked `~/.claude/config-snapshots/.git/`

, `git log`

had stopped at a commit from a month earlier. `~/.claude/CLAUDE.md`

had 50 lines added last week, but the snapshot repository's copy was still the old one.

**Cause**: The reason for `no changes`

wasn't "no changes in the config files" but "rsync couldn't copy anything, so no diff appears in DST."

The backup destination at the time was `~/Documents/claude-config-snapshots/`

. Picking up rsync's error log, the same line was repeated for all 11 items.

```
rsync: [sender] send_files failed to open ".../Documents/claude-config-snapshots/..." : Operation not permitted (1)
```

macOS TCC (Transparency, Consent, and Control) was blocking writes to `~/Documents/`

.

TCC is macOS's mechanism for protecting user data. Access to `~/Desktop/`

, `~/Documents/`

, `~/Downloads/`

, and external storage prompts for permission via a system dialog on an app's first access. But background processes launched by launchd **get no dialog**. Without permission, it quietly returns Operation not permitted with no user notification and no warning log.

rsync's errors were being written to the log file via `2>>"$LOGFILE"`

, but the script body proceeds to the next item without checking that exit code. Even if all 11 items fail, if DST is unchanged then `CHANGED=0`

and it records `no changes`

and exits normally.

**Fix**: I relocated the backup destination to `~/.claude/config-snapshots/`

(2026-06-01). `~/.claude/`

is a dot folder directly under the home directory and isn't among TCC's protected targets (Desktop/Documents/Downloads). Even a launchd background process can read and write it without access restrictions.

After the relocation, rsync copied all items correctly, and the first commit recorded hundreds of lines of diff at once. That was the moment "three weeks of configuration changes had been treated as if they never happened" first became visible.

The single line in the script's header comment (*the old ~/Documents/claude-config-snapshots hit macOS TCC "Operation not permitted" when run under launchd and every copy failed, so it was relocated on 2026-06-01*) is there to pass this lesson on to future me.

**The asymmetry between TCC and launchd**: When a GUI app accesses `~/Documents/`

for the first time, the system shows a permission dialog. The user gets to notice "ah, this app is trying to write there." A launchd background daemon has no such feedback. The gap between "looks successful" and "actually failed" goes unclosed for weeks because no notification mechanism exists. When writing automation on macOS, the places you can safely write to from launchd are `~/.`

-style dot folders, `/tmp/`

, and app containers. You have to accept `~/Documents/`

as protected territory for GUI apps.

`git commit`

emitted a "success log" while HEAD didn't move
**Symptom**: The log says `[2026-xx-xx 06:00:13] snapshot done: 5 files changed, commit=a1b2c3d`

. It even records the commit hash. But running `cd ~/.claude/config-snapshots && git log --oneline`

, that hash doesn't exist. It hasn't moved from a commit a week earlier.

**Cause**: The global commit-msg hook was rejecting the commit, but that wasn't being communicated to the script.

The commit execution code at the time had the `-q`

(quiet) flag.

```
# 旧コード（問題版）
if git commit -q -m "chore(snapshot): claude-config $(date '+%Y-%m-%d %H:%M')"; then
  echo "[$(ts)] snapshot done: ..." >> "$LOGFILE"
fi
```

`-q`

suppresses git's stdout output. The problem is recorded in a comment.

```
# stdout/stderr を両方 LOG に流す（前は -q で潰していて exit code も拾えていなかった）
```

With stdout suppressed by `-q`

, there were cases where the exit code the script received appeared to be 0 even when the `~/.git-hooks/commit-msg`

validator returned non-zero and aborted the commit. The `if git commit -q ...`

condition evaluated true, and the log recorded it as "succeeded."

Before the HEAD-advancement check was added, "a hash appears in the commit log" was the only means of confirming normal operation, so looking at the log told you nothing about the cause.

**Fix**: I made two changes at once.

First, drop `-q`

and send both stdout and stderr to `$LOGFILE`

.

```
if git commit -m "chore(snapshot): claude-config $(date '+%Y-%m-%d %H:%M')" \
    >>"$LOGFILE" 2>&1; then
```

Next, explicitly confirm HEAD advanced.

```
PREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo "")
# ... commit実行後 ...
NEW_HEAD=$(git rev-parse --verify --quiet HEAD || echo "")
if [ -z "$NEW_HEAD" ] || [ "$NEW_HEAD" = "$PREV_HEAD" ]; then
  echo "[$(ts)] commit reported success but HEAD did not advance \
(prev=$PREV_HEAD new=$NEW_HEAD) — ABORT" >> "$LOGFILE"
  exit 3
fi
```

The reason for the custom `exit 3`

code is the `LastExitStatus`

value you can check with `launchctl list com.shun.dotfiles-snapshot`

. launchd stores the script's exit code as `code × 256`

. If `LastExitStatus`

is `768`

, that's `3 × 256`

, so you know it terminated with `exit 3`

. By distinguishing exit 2 (commit failure), exit 3 (HEAD didn't move), and exit 1 (secret detected), you can identify the failure type from the exit code without looking at a dashboard.

**Never trust the commit exit code**: You must not judge `git commit`

's success by exit code alone. commit-msg hooks, pre-commit hooks, and post-commit hooks run inside git's process, and exit code propagation varies with the git version and the hook implementation. Directly checking "did HEAD advance" with `git rev-parse`

is the only reliable verification method.

`git init`

always ended in exit 3
**Symptom**: When I deleted the backup destination directory manually and retested, the first run always terminated with `exit 3`

. The log said `commit reported success but HEAD did not advance (prev= new=) — ABORT`

.

This surfaced right after adding the HEAD-advancement check.

**Cause**: A freshly `git init`

ed repository has no HEAD (no commits at all). So `PREV_HEAD=""`

. If the commit succeeds, `NEW_HEAD`

gets the new hash and `[ -z "$NEW_HEAD" ]`

should be false.

But the comment records: *過去バグ: 初回 init 時 validator が弾いたのに success ログ* ("past bug: on first init the validator rejected it but a success log was written"). When the commit-msg hook rejects, `NEW_HEAD`

stays `""`

. The check condition is `[ -z "$NEW_HEAD" ] || [ "$NEW_HEAD" = "$PREV_HEAD" ]`

, so:

`PREV_HEAD=""`

and `NEW_HEAD="abc1234"`

→ left side false, right side false → check passes`PREV_HEAD=""`

and `NEW_HEAD=""`

→ left side true → exit 3These two patterns are handled by one line with `||`

.

The exit 3 during testing was the "first run plus hook error" combination. The commit-msg hook was rejecting the state where git config's `user.name`

and `user.email`

were unset. The script does have a line that writes repo-local settings at `git init`

time (inside the first-init block), but in my test I had manually deleted only `.git/`

, so the user settings didn't remain and the hook refused.

```
if [ ! -d "$DST/.git" ]; then
  ( cd "$DST" && git init -q && git config user.name "..." && git config user.email "..." )
  echo "[$(ts)] git init at $DST" >> "$LOGFILE"
fi
```

If you delete all of `DST`

and rerun, the order `mkdir -p "$DST"`

→ `git init`

→ user settings → commit success is guaranteed. Deleting only `.git/`

produces "partial initialization" and breaks that ordering. My test procedure was wrong.

**The correct reproduction test procedure**: Delete everything with `rm -rf "$DST"`

, then start manually with `launchctl start com.shun.dotfiles-snapshot`

. Partial deletion creates a half-baked state and complicates debugging. A flaw in the test procedure was manufacturing a bug that doesn't reproduce in production, since production launchd always starts from `mkdir -p "$DST"`

.

In the section above I dug into three cases: "the TCC bug where no backups were taken for three weeks," "the bug where `git commit`

returned exit 0 while HEAD didn't move," and "the bug where the first commit right after `git init`

exits 3." Here I'll cover the other pitfalls you commonly hit in a launchd × rsync × git setup.

**You can't use ~ in plist path values (write absolute paths)**

launchd interprets the XML values of `StandardOutPath`

and `StandardErrorPath`

literally. `~`

is a shell expansion feature, and the plist parser has no such logic. Even if you write `~/.claude/logs/...`

, launchd goes looking for a path literally named `~/.claude/`

, which of course doesn't exist, so the output is lost. The actual plist writes absolute paths like this:

```
<key>StandardErrorPath</key>
<string>/Users/home_dir/.claude/logs/com.shun.dotfiles-snapshot.log</string>
<key>StandardOutPath</key>
<string>/Users/home_dir/.claude/logs/com.shun.dotfiles-snapshot.log</string>
```

A plist copied from a template and reused with `~`

intact will keep running while producing no logs whatsoever. If you find yourself thinking "it's launching under launchd but I can't capture zsh startup errors," check here first.

**Shell redirects in ProgramArguments override StandardOutPath**

```
<string>/bin/zsh</string>
<string>-c</string>
<string>/path/to/dotfiles-snapshot.sh &gt;&gt; /dev/null 2&gt;&amp;1</string>
```

When you write `>> /dev/null 2>&1`

, the write destination that launchd set via `StandardOutPath`

gets replaced with `/dev/null`

at the zsh level. As explained above, this design is an intentional separation: "capture only zsh errors before the script starts via `StandardOutPath`

." But if you do it unintentionally, you end up with "nothing shows up in the launchd log." While debugging, the safe procedure is to temporarily remove `>> /dev/null`

so output flows to StandardOutPath, then restore it once you've confirmed behavior.

**Omitting EnvironmentVariables means neither Homebrew nor nvm is visible**

launchd background processes read neither `.zshrc`

nor `.zprofile`

. The default PATH is effectively `/usr/bin:/bin:/usr/sbin:/sbin`

. `git`

and `rsync`

happen to be in the system's `/usr/bin`

so they work, but Homebrew's `git`

(a newer version) and nvm-managed Node.js are invisible.

The problem becomes visible in the case where **the global git hook is Node-based**. This script itself doesn't call Node.js directly. But if a conventional commits validator like `~/.git-hooks/commit-msg`

is written in Node, Node.js becomes indirectly required. The actual plist writes the full PATH like this:

```
<key>PATH</key>
<string>/Users/home_dir/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/home_dir/.local/bin</string>
```

You need to update this path every time the nvm version changes. Rewrite it based on the version you check with `nvm current`

.

**StartCalendarInterval skips scheduled runs during sleep**

One of the big differences between cron and launchd is how missed fires are handled. If the Mac was asleep at 6 AM Sunday, the StartCalendarInterval launch is silently skipped. There's no catch-up behavior after wake. It won't run until the next Sunday at 6 AM.

In an environment where you frequently shut down or sleep a laptop, a weekly schedule risks leaving holes. There are two countermeasures: switch to fixed-interval execution with `StartInterval`

(e.g., 86400 seconds = daily), or add `RunAtLoad: true`

so it also runs at load time. Running daily greatly reduces the probability of a gap longer than a week.

**Confusing launchctl load with launchctl start**

`launchctl load ~/Library/LaunchAgents/com.shun.dotfiles-snapshot.plist`

only registers the schedule. It doesn't run immediately. Doing `load`

while debugging and then agonizing for 10 minutes over "why isn't it running" is an extremely common way to get stuck.

Immediate execution is `launchctl start com.shun.dotfiles-snapshot`

. The test cycle is as follows.

```
# plistを更新した場合
launchctl unload ~/Library/LaunchAgents/com.shun.dotfiles-snapshot.plist
launchctl load   ~/Library/LaunchAgents/com.shun.dotfiles-snapshot.plist
launchctl start  com.shun.dotfiles-snapshot

# 実行後に確認
launchctl list com.shun.dotfiles-snapshot
# → "LastExitStatus" = 0 なら正常
```

**How to read LastExitStatus (what does 768 mean?)**

When `launchctl list com.shun.dotfiles-snapshot`

outputs `"LastExitStatus" = 768`

, that's `768 ÷ 256 = 3`

, meaning it terminated with `exit 3`

. launchd stores the script's exit code as `exit_code × 256`

. This script assigns the following meanings to its exit codes:

`0`

— normal termination (changes committed successfully, or no changes)`1`

— secret detected in settings.json; copy deleted and aborted`2`

— `git commit`

failed with a non-zero exit`3`

— `git commit`

exited 0 but `git rev-parse HEAD`

shows HEAD didn't advanceChecking `LastExitStatus`

with `launchctl list`

before opening the log is the fastest first move when something breaks.

**You have to maintain the INCLUDE list yourself**

Even if you create a new directory in `~/.claude/`

, it won't be backed up unless you add it to the script's INCLUDE list.

```
INCLUDE=(
  "settings.json"
  "settings.local.json"
  "CLAUDE.md"
  "hooks/"
  "commands/"
  "agents/"
  "skills/auto/"
  "skills/ecc/"
  "scripts/"
  "rules/"
  "improvements/"
)
```

In my environment, I ran a `memory/`

directory (files written by agentmemory) for a month before noticing "that's not in INCLUDE." The commit the week I added it was the first time that month's worth of changes made it into a snapshot. Get in the habit of reconciling `ls ~/.claude/`

against this list monthly.

**The script rewrites .gitignore every run**

The script overwrites `.gitignore`

from a heredoc on every run.

```
cat > "$DST/.gitignore" << 'GITIGNORE'
# Auto-generated by dotfiles-snapshot.sh
*.log
.DS_Store
tmp/
...
GITIGNORE
```

Anything you append to `.gitignore`

by hand disappears on the next run. If you want to add exclusion patterns, edit the heredoc in the script body. The `# Auto-generated`

comment signals that intent.

`2>>"$LOGFILE"`

alone won't make you notice rsync errors

rsync sends errors to the log inside the loop, but proceeds to the next item without checking the exit code.

```
for item in "${INCLUDE[@]}"; do
  if [ -e "$SRC/$item" ]; then
    rsync -a "${EXCLUDES[@]}" "$SRC/$item" "$DST/$item" 2>>"$LOGFILE"
  fi
done
```

Even if all 11 items fail, it's `CHANGED=0`

→ `no changes`

→ normal exit. If you get the nagging feeling that "`no changes`

has continued for weeks even though I'm sure I changed the config," check the log with this command:

```
grep "Operation not permitted\|rsync error" ~/.claude/logs/dotfiles-snapshot.log | tail -20
```

If anything shows up, that leads you to the cause.

**TCC protected targets aren't just ~/Documents/**

The TCC problem detailed above isn't limited to `~/Documents/`

. The directories where a launchd background process gets silently blocked are:

`~/Desktop/`

`~/Documents/`

`~/Downloads/`

`~/Movies/`

, `~/Music/`

, `~/Pictures/`

`~/Library/Mobile Documents/`

)Conversely, the **directories launchd can write to without permission** are:

`~/`

(`~/.claude/`

, `~/.config/`

, `~/.local/`

, etc.)`~/Library/Application Support/`

, `~/Library/Logs/`

, `~/Library/Caches/`

`/tmp/`

, `/var/folders/`

(temporary files)Before designing an automation script, confirm which of these two lists its write destination belongs to.

**Set git's local user config at the same time as git init**

The global `~/.gitconfig`

is normally read even in a launchd environment, but if a commit-msg hook assumes the existence of `user.name`

/`user.email`

, it can be rejected when something like `GIT_CONFIG_NOSYSTEM`

is set in the launchd environment. That's why this script writes `git config user.name "..."`

and `git config user.email "..."`

locally at the same time as `git init`

— as a failsafe.

```
if [ ! -d "$DST/.git" ]; then
  ( cd "$DST" && git init -q \
    && git config user.name "..." \
    && git config user.email "..." )
fi
```

Unless you delete all of `DST`

before retesting, the `git init`

block won't execute. A test that deletes only `.git/`

creates a "partially initialized" state and induces bugs that don't reproduce in production (see Stuck 3 above for details).

`~/Documents/`

as a backup destination
launchd background processes get rejected silently. The first choice for a backup destination is a `~/.`

-style dot folder. This setup's `~/.claude/config-snapshots/`

is the correct example.

If you use `~`

, that character isn't expanded and doesn't function as a path. `StandardOutPath`

, `StandardErrorPath`

, the script path inside `ProgramArguments`

— write them all in `/Users/username/...`

absolute path form.

The default PATH is basically `/usr/bin:/bin`

. Unless you explicitly include `/opt/homebrew/bin`

, the nvm-managed Node.js path, and `~/.local/bin`

, it's no surprise when a tool your script depends on stops being found. The fastest way to get the PATH is from `echo $PATH`

in the shell you're currently working in.

`git commit`

success directly with `git rev-parse HEAD`

Don't trust the exit code. Exit code propagation is unstable depending on commit-msg hooks, pre-commit hooks, and the hook implementation. Comparing `PREV_HEAD`

and `NEW_HEAD`

to directly confirm "did HEAD advance" is the only reliable means.

```
PREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo "")
git commit -m "chore(snapshot): ..." >>"$LOGFILE" 2>&1
NEW_HEAD=$(git rev-parse --verify --quiet HEAD || echo "")
[ -z "$NEW_HEAD" ] || [ "$NEW_HEAD" = "$PREV_HEAD" ] && exit 3
```

Assigning `exit 1`

(secret detected), `exit 2`

(commit failure), and `exit 3`

(HEAD didn't move) means you can identify the cause type just by dividing `launchctl list`

's `LastExitStatus`

by 256. If you only use a generic `exit 1`

, you have to read through logs afterward to trace "what did it fail on."

```
grep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}'
```

A generic pattern like "30+ alphanumeric characters" also catches Base64-encoded config values. Narrowing by prefix — `sk-*`

(Anthropic), `ghp_*`

(GitHub PAT), `AKIA*`

(AWS) — covers the substantive risk while keeping false positives near zero.

Catch zsh startup errors with `StandardOutPath`

, and send the script's internal progress to a separate file with `>> "$LOGFILE"`

. This two-path separation lets you track "did the script start" and "what happened inside the script" independently. The first step in troubleshooting is: `launchctl list`

→ check `LastExitStatus`

→ check `dotfiles-snapshot.log`

.

`set -uo pipefail`

and deliberately leave out `-e`

Undefined variables (`-u`

) and pipeline interruptions (`-o pipefail`

) should kill the script. But `-e`

doesn't play well with a secret check where `grep -q`

treats "pattern not found (exit 1)" as normal, or with first-init handling that has a `git rev-parse ... || echo ""`

fallback. Leaving `-e`

out and explicitly handling non-zero returns with `if`

makes the intent clearer.

It's important to keep copying the rest even when 1 of 11 items fails. Proceed to the next item without checking rsync's exit code, and record errors with `2>>"$LOGFILE"`

. But note that in this design, even a total failure of every item ends in `no changes`

. Check the log for `Operation not permitted`

periodically.

`launchctl start`

and check LastExitStatus with `launchctl list`

When you change the plist, test immediately with the `unload → load → start`

cycle. If `launchctl list com.shun.dotfiles-snapshot`

's `LastExitStatus`

isn't 0, trace the cause in this order: divide by 256 to identify the type → check the log. Waiting for the next scheduled run is a waste of time.

`Nice=10`

and `LowPriorityIO=true`

as a set

```
<key>LowPriorityIO</key>  <true/>
<key>Nice</key>           <integer>10</integer>
<key>ProcessType</key>    <string>Background</string>
```

`Nice=10`

alone only lowers CPU priority. If you omit `LowPriorityIO: true`

, IO runs at normal priority and competes with other writes on a freshly booted Mac. For background backups, specify all three as a set.

`ls ~/.claude/`

Every time a new directory appears in `~/.claude/`

, it needs to be added to the INCLUDE list. Either build the habit of comparing `ls ~/.claude/`

against the list monthly, or run this command periodically:

```
# INCLUDE未収録のディレクトリを抽出する例
comm -23 <(ls ~/.claude/ | sort) \
         <(echo -e "CLAUDE.md\nhooks\ncommands\nagents\nskills\nscripts\nrules\nimprovements\nconfig-snapshots\nlogs\nsettings.json\nsettings.local.json" | sort)
```

`rm -rf "$DST"`

Retesting after deleting only `.git/`

creates a "partially initialized" state and produces bugs that don't reproduce in production. Always start tests from a state where all of `$DST`

has been deleted. Since production launchd always starts from `mkdir -p "$DST"`

, this fully reproduces that condition.

The core thing I wanted to convey in this article is **the structure of the "looks successful but actually failed" trap**.

macOS TCC shows GUI apps a dialog asking "allow access?" The user learns that the app is trying to write to `~/Documents/`

. But a launchd background process has no such feedback. `Operation not permitted`

flows into the log as an rsync error, the script proceeds to the next item, and even with every item failing it records `no changes`

and exits normally. launchd's LastExitStatus is 0. The signal conveyed to the user is zero.

`git commit`

's exit code had the same structure. Even when a hook rejects, it can look like exit 0. Even when the log says `commit=a1b2c3d`

, that hash may not exist in `git log`

. A log record saying "succeeded" does not guarantee that it actually succeeded.

The common remedy for both traps is "confirm facts that are verifiable from the outside." Not "did rsync succeed" but "does the file exist at the destination." Not "did the git commit succeed" but "did HEAD advance," confirmed with `git rev-parse`

. "The command returned exit 0" is not the same as "the intended side effect happened."

One of the reasons I spent half a year building out my Claude Code autonomous environment was to find and eliminate these "silent failures" one by one. The environment rests less on flashy AI usage than on an accumulation of unglamorous defensive lines. The line left in the script's header comment — *the old ~/Documents/claude-config-snapshots hit macOS TCC "Operation not permitted" when run under launchd and every copy failed, so it was relocated on 2026-06-01* — is the lesson learned from three weeks of silent failure.

The code is simple. `dotfiles-snapshot.sh`

is 112 lines: rsync over the INCLUDE list, the secrets check, the HEAD-advancement check, and log output. That's it. The complexity came not from the design but from accurately understanding macOS behavior.

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
