📦 📦 launchdが~/Documents/に書けない理由──macOS TCC×dotfiles自動バックアップの罠と修復 — リーダー×
📦

📦 launchdが~/Documents/に書けない理由──macOS TCC×dotfiles自動バックアップの罠と修復

#automation#claudecode#macos2026-08-02 · 約35

月10万の大学生が掛け持ちで月60万まで伸ばし、会社都合の解雇でゼロに戻り、半年でClaude Code自律環境を組み上げて月商120万に達した。その環境の核心は「自分が寝ている間も動き続けるスクリプト群」であり、そのスクリプト群は一度壊れると数時間では再現できない。

なぜこの仕組みが効くのか

個人開発を自動化すると、最初に直面する問題が「設定が壊れたときに戻せない」です。

Claude Codeで月商120万の環境を作り上げると、~/.claude/ 配下には数百行の設定が積み重なります。settings.json(ツール許可・モデル指定・hookパス)、CLAUDE.md(グローバル運用ルール全文)、hooks/ ディレクトリ(StopフックやPreToolフックなどの自動チェック群)、agents/(特化エージェント定義ファイル)、skills/auto/(実運用の中で自己生成されていくスキル群)、rules/(ECCルール要約)──これらは毎週数十か所が書き換わります。Claude Codeのアップデート、hookスクリプトの改良、新しいエージェント定義の追加。活発に使えば使うほど、設定は生き物のように変わっていきます。

問題は ~/.claude/ がバージョン管理されていないことです。Claude Codeのキャッシュ更新で設定ファイルが消える、hookスクリプトに誤りを入れてPreToolフックが全落ちする、settings.json の1行書き間違いで権限プロンプトが全爆発する──そういう事故が起きたとき、差分も履歴も残っていなければ「いつから壊れたか」すら分かりません。「一週間前まで動いていたのに」という状態から手探りで再現するのは、単純な時間の損失です。

ここで多くの人が取る手は「手動でバックアップする」か「丸ごとgitに入れる」かです。

手動バックアップは週次でも飽きます。飽きた週に限って環境が壊れます。「やろうと思っていた」というセリフは復旧の役に立ちません。

丸ごとgit~/.claude/ に plugin cache・session履歴・telemetry・paste-cacheなどのノイズが混在するため、リポジトリが数GBに膨らんで実用になりません。また秘密値がキャッシュに混入するリスクも排除できません。

解決策は「設定ファイルだけを別ディレクトリにrsyncし、そこだけをgit管理する」二段構成です。~/.claude/ 本体には一切触れず、同期対象をINCLUDEリストで明示的に定義する。人間が忘れても毎週日曜6時にlaunchdが走る。差分があればconventional commits形式でコミットする。差分がなければ no changes とログに書いて終わる。

この仕組みが効くのは、人間の意志力を介在させないからです。設定ファイル群は「ちゃんとバックアップしよう」という気持ちで管理するには細かすぎます。しかし失うと再現に数時間かかります。その非対称性を埋めるのが自動化であり、launchdはその自動化の最下層に位置する信頼性の高いOS機能です。

副産物もあります。差分がgitに残るため、「先週のどの変更でhookが壊れたか」を git log --oneline で追跡できます。設定のドリフト(意図せず変わっていた部分)を発見できます。別マシンへの移設時に即座に再現できます。git diff で「1週間前の自分が何をしたか」を確認しながら改良を積み上げられます。

実際のスクリプト(~/.claude/scripts/dotfiles-snapshot.sh)の冒頭コメントにはこう書かれています。

# 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 と分離管理

「元の ~/.claude は触らない」という1行が設計思想を表しています。バックアップ対象のディレクトリ自体はgit管理せず、必要な設定ファイルだけを別ディレクトリに写して、そこだけをリポジトリにする。INCLUDEリストが設定ファイルの「正典定義」になっています。

そして4行目のコメント──旧 ~/Documents/claude-config-snapshots は launchd 実行時に macOS TCC で "Operation not permitted" になり全コピー失敗していたため──がこの記事の本題です。2026-06-01の移設の前、バックアップは毎週日曜6時に「動いているように見えながら」一切機能していませんでした。

全体の流れ

現在のシステムの構造を示します。

┌─────────────────────────────────────────────────────────┐
│  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 と共用)  │
└─────────────────┘  └──────────────────────────────────┘

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 が日曜です。Nice=10LowPriorityIO=true は朝6時のバックグラウンド処理にふさわしい設定で、起動直後の作業スパイクとIO競合しません。毎日ではなく週次にしているのは、設定変更の粒度がdailyより粗いためです。Claude Codeの設定は「ガバッと触った週」と「まったく触らない週」が交互に来ます。週次で十分な差分解像度があります。

ログ出力先は2か所に分かれています。

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

plistレベルのstdout/stderrはlaunchdが書き出します。スクリプト内部のログは LOGFILE="$HOME/.claude/logs/dotfiles-snapshot.log" に流れます。スクリプト内で echo "[$(ts)] ..." >> "$LOGFILE" としているので、launchdの起動ログとスクリプトの進捗ログが2ファイルに分かれて記録されます。

次にINCLUDEリストを見ます。スクリプトのバックアップ対象は次の11項目です。

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

キャッシュ・session・telemetry・paste-cache・file-historyは一切入っていません。逆にEXCLUDESリストを見ると:

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

--exclude=.harvest-watermark が目を引きます。これはClaude Codeのスキル自動収集機能が生成するウォーターマークファイルで、コミットに混入すると差分ノイズになります。環境固有のゴミを明示除外することで、git log が意味のある設定変更だけを記録します。

rsyncのループはシンプルです。

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

[ -e "$SRC/$item" ] で存在確認してからrsyncするため、INCLUDEに書いてあるがまだ作っていない項目があっても無害にスキップします。エラーは 2>>"$LOGFILE" でログに流れるので、実行時に何が起きたかを後から確認できます。

コミット前にはAPIキー混入チェックが走ります。

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

sk-*(Anthropic APIキー)、ghp_*(GitHub Personal Access Token)、AKIA*(AWS Access Key)の3パターンをgrepします。誤ってAPIキーをsettings.jsonに書き込んでいた場合、コピーファイルを削除してexit 1で即時終了します。secrets検知→コピー削除→中断、の3ステップが1箇所にまとまっており、公開リポジトリへのpushと組み合わせても安全です。

コミットロジックにはもう一つ防御があります。

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"

git commit がexit 0を返してもHEADが動いていなければexit 3で異常終了します。スクリプト内のコメントには 前は -q で潰していて exit code も拾えていなかった とあり、過去に commit hook(commit-msgバリデータ)が弾いたのにexit codeが0に見えた バグへの対策として実装されています。ログに残る commit reported success but HEAD did not advance という一文で、原因追跡を素早く行えます。

正常実行時のログはこうなります。

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

差分がなければ:

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

この2行だけ見れば、バックアップが動いたか・何か変わったかが分かります。ダッシュボードも通知も不要です。問題が起きたときだけログを開けばいい。

ここまでが現在の(正常動作する)システムの全体像です。

ではこのバックアップ先が ~/Documents/claude-config-snapshots/ だった2026-06-01以前は何が起きていたのか──冒頭のコメントが示す "Operation not permitted" はどこから来て、なぜ無音で全失敗していたのか。次章でその根拠を分解します。

実装の詳細

set -uo pipefail-e を足さない理由

スクリプト冒頭は set -uo pipefail です。未定義変数参照(-u)とパイプ途中のエラー(-o pipefail)は即死させながら、-e(任意のコマンド非ゼロで即exit)はあえて外しています。

理由は2か所にあります。

1か所目はシークレットチェックです。

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 はパターンが見つからないとき(=APIキーが存在しない、正常な状態)exit code 1を返します。-e を有効にすると「シークレットが存在しない = 正常 = exit 1 = スクリプト中断」という本末転倒な動作になります。if grep ... で制御フローを明示しているため、-e は不要どころか有害です。

2か所目はHEAD取得です。

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

git rev-parse HEADgit init 直後(まだコミットが存在しない状態)でexit 1を返します。|| echo "" で空文字列に落として初回も安全に動かしますが、-e があれば || の評価前にスクリプトが死にます。

明示的な exit 1 / exit 2 / exit 3 で異常を制御する設計では、-e はノイズです。set -uo pipefail は「未定義変数とパイプ途中エラーは殺す、コマンドの非ゼロリターンは自分でハンドルする」という意識的な方針の表れです。

launchd プロセスに PATH を明示しなければならない理由

plistの EnvironmentVariables ブロックには、nvm管理下のNode.js・Homebrew・~/.local/bin を含む完全なPATHが列挙されています。

launchdが起動するバックグラウンドプロセスは ~/.zshrc~/.zprofile も読みません。デフォルトのPATHは /usr/bin:/bin:/usr/sbin:/sbin に毛が生えた程度で、/opt/homebrew/bin すら含まれません。gitrsync はシステムの /usr/bin にありますが、Node.jsが必要なgitフックは確実に詰まります。

このスクリプト自体はbash+rsync+gitだけで書かれており、node.jsを直接呼びません。ではなぜNode.jsパスが必要かというと、グローバルgitフック ~/.git-hooks/commit-msg がconventional commits形式を検証する際に、環境によってはNode製バリデータを呼ぶためです。PATHが不完全だと git commit は走っているのにフックが毎回silent failする、という状況になります。

launchdプロセスにフルPATHを書く手間は一度だけです。後から「なぜかgit commitだけ失敗する」で詰まるよりはるかに安い。

2経路のログ設計:plistの StandardOutPath とスクリプト内部ログ

plistにはこう書かれています。

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

しかし ProgramArguments の実行文字列にはシェルレベルのリダイレクトが含まれています。

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

>> /dev/null 2>&1 でスクリプト本体のstdout/stderrはシェル評価の時点で /dev/null に捨てられます。launchdが設定した StandardOutPath のfdは上書きされます。では StandardOutPath は何を捕まえているのか。

答えは「zsh自身の起動エラー」です。zshが -c 文字列を評価する前に失敗した場合──スクリプトのパスが壊れている、zshが見つからないなど──そのエラーはシェルレベルのリダイレクトより先にlaunchdが設定したfdに流れます。com.shun.dotfiles-snapshot.log はスクリプトの進捗ではなく、launchd層の起動失敗を捕まえるフォールバックとして機能します。

スクリプトの進捗は LOGFILE="$HOME/.claude/logs/dotfiles-snapshot.log" への明示的な >> 書き込みで記録されます。

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

この2経路の分離により「スクリプトが起動したか(launchdログ)」と「スクリプトの中で何が起きたか(dotfiles-snapshot.log)」を別ファイルで追跡できます。問題調査の起点は後者です。

rsync エラーを「止めずに記録する」設計と、そのリスク

rsyncのループはエラーをログに流しますが、ループを止めません。

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

rsyncのexit codeを確認していません。これは意図的な設計です。agents/ のコピーがパーミッションエラーで落ちても CLAUDE.mdhooks/ のコピーまで止めたくない。部分失敗を許容して最大限のファイルをコピーし、エラーはログに残す、という考え方です。

ただしこの設計は「全項目がエラーでもスクリプトが正常終了する」ことを意味します。DST側に何もコピーされなければgitに差分が出ず、no changes を記録して終わります。後述するTCCバグがまさにこのケースです。

シークレットチェックの3パターン選択

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

sk-* はAnthropicのAPIキー、ghp_* はGitHub Personal Access Token、AKIA* はAWS Access Key IDです。settings.jsonはClaude Codeのツール許可・モデル設定を書く場所であり、誤ってAPIキーを直書きするリスクがある3系統を選んでいます。汎用的な「長い英数字列」パターンは誤検知が多いため、プレフィックスで絞り込む方針です。

検知した場合はコピーファイルを削除してからexit 1します。コピーファイルを残したままexit 1すると次回実行時に「削除された状態」が差分として記録され、git logに汚染が残ります。削除→exit 1の順序が重要です。


私が詰まった話

詰まり1: 3週間、バックアップは一切取れていなかった

症状: launchdは毎週日曜6時に起動しています。ログには [2026-05-xx 06:00:12] snapshot start[2026-05-xx 06:00:12] no changes が週次で並んでいます。一見、正常に動いているように見えます。

しかし ~/.claude/config-snapshots/.git/ を確認すると、git log が1か月前のコミットで止まっていました。~/.claude/CLAUDE.md は先週50行追加されているのに、スナップショットリポジトリのそれは古いままです。

原因: no changes の理由が「設定ファイルに変更がない」ではなく「rsyncが何もコピーできていないからDSTに差分が出ない」でした。

当時のバックアップ先は ~/Documents/claude-config-snapshots/ でした。rsyncのエラーログを拾うと全11項目に対して同じ行が並んでいました。

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

macOSのTCC(Transparency, Consent, and Control)が ~/Documents/ への書き込みをブロックしていました。

TCCはmacOSがユーザーデータを守るための仕組みです。~/Desktop/~/Documents/~/Downloads/・外部ストレージへのアクセスは、アプリ初回アクセス時にシステムダイアログで許可を求めます。しかしlaunchdが起動するバックグラウンドプロセスにはダイアログが表示されません。許可がなければ、ユーザーへの通知も警告ログもなく、静かにOperation not permittedを返します。

rsyncのエラーは 2>>"$LOGFILE" でログファイルに書かれていましたが、スクリプト本体はそのexit codeをチェックせずに次の項目へ進みます。11項目すべてが失敗しても、DSTに変化がなければ CHANGED=0 となり no changes を記録して正常終了します。

直し方: バックアップ先を ~/.claude/config-snapshots/ に移設しました(2026-06-01)。~/.claude/ はホームディレクトリ直下のドットフォルダであり、TCCの保護対象(Desktop/Documents/Downloads)に含まれません。launchdのバックグラウンドプロセスでもアクセス制限なしに読み書きできます。

移設後は rsync が全項目を正常にコピーし、初回コミットで一気に数百行の差分が記録されました。「3週間分の設定変更がなかったことになっていた」という事実がそこで初めて可視化されました。

スクリプト冒頭コメントの1行(旧 ~/Documents/claude-config-snapshots は launchd 実行時に macOS TCC で "Operation not permitted" になり全コピー失敗していたため 2026-06-01 に移設)はこの教訓を未来の自分に伝えるために残しました。

TCCとlaunchdの非対称性: GUIアプリが ~/Documents/ に初回アクセスするとシステムが許可ダイアログを出します。ユーザーは「あ、このアプリが書こうとしているな」と気づけます。launchdのバックグラウンドデーモンにはそのフィードバックがありません。成功したように見えて失敗している、というギャップが何週間も埋まらないのは、通知機構が存在しないからです。macOSで自動化を書く場合、launchdから安全に書けるのは ~/. 系のドットフォルダ、/tmp/、アプリのコンテナです。~/Documents/ はGUIアプリ向けの保護対象と割り切る必要があります。

詰まり2: git commit が「成功ログ」を出しながらHEADが動かなかった

症状: ログには [2026-xx-xx 06:00:13] snapshot done: 5 files changed, commit=a1b2c3d と出ています。commitハッシュまで記録されている。ところが cd ~/.claude/config-snapshots && git log --oneline を実行すると、そのハッシュは存在しません。1週間前のコミットから動いていません。

原因: グローバルcommit-msgフックがコミットを弾いていましたが、それがスクリプトに伝わっていませんでした。

当時のコミット実行コードは -q(quiet)フラグ付きでした。

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

-q はgitのstdout出力を抑制します。問題はコメントに記録されています。

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

-q でstdoutを抑制した状態で、~/.git-hooks/commit-msg バリデータが非ゼロを返してコミットを中断した場合でも、スクリプトが受け取るexit codeが0に見えるケースがありました。if git commit -q ... の条件が真になり、「成功した」としてログが記録されました。

HEAD前進チェックの追加前は「commitログにハッシュが出ている」ことが唯一の正常確認手段だったため、ログを見ても原因が分かりませんでした。

直し方: 2つの変更を同時に入れました。

まず -q を外してstdout/stderrを両方 $LOGFILE に流します。

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

次にHEAD前進を明示確認します。

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

exit 3 という独自コードにした理由は launchctl list com.shun.dotfiles-snapshot で確認できる LastExitStatus の値です。launchdはスクリプトの exit code をcode × 256で保持します。LastExitStatus768 なら 3 × 256exit 3 で終了したと分かります。exit 2(コミット失敗)・exit 3(HEAD不動)・exit 1(シークレット検知)を使い分けることで、ダッシュボードを見なくても終了コードから障害種別が判別できます。

commit exitコードを信じてはいけない: git commit の成功はexit codeだけで判断してはいけません。commit-msgフック・pre-commitフック・post-commitフックはgitのプロセス内で動き、exit codeの伝播はgitバージョンとフックの実装次第で変わります。「HEADが前進したか」を git rev-parse で直接確認するのが、唯一の確実な検証手段です。

詰まり3: git init 直後の初回コミットが毎回 exit 3 になっていた

症状: バックアップ先ディレクトリを手動で削除して再テストすると、初回実行だけ必ず exit 3 で終了します。ログには commit reported success but HEAD did not advance (prev= new=) — ABORT と出ます。

これはHEAD前進チェックを入れた直後に発覚しました。

原因: git init したばかりのリポジトリにはHEADが存在しません(コミットが1件もない状態)。そのため PREV_HEAD="" になります。コミットに成功すれば NEW_HEAD に新しいハッシュが入り、[ -z "$NEW_HEAD" ] は偽になるはずです。

ところがコメントには 過去バグ: 初回 init 時 validator が弾いたのに success ログ と記録されています。commit-msgフックが弾いた場合は NEW_HEAD="" のままになります。チェック条件は [ -z "$NEW_HEAD" ] || [ "$NEW_HEAD" = "$PREV_HEAD" ] なので:

  • 初回+コミット成功: PREV_HEAD="" かつ NEW_HEAD="abc1234" → 左辺偽・右辺偽 → チェック通過
  • 初回+フックで弾かれた: PREV_HEAD="" かつ NEW_HEAD="" → 左辺真 → exit 3

この2パターンを || 1行で捌いています。

テスト時の exit 3 は「初回かつフックエラー」の組み合わせでした。commit-msgフックが git config の user.nameuser.email が未設定の状態を弾いていました。git init 時にリポジトリローカルの設定を書き込む行がスクリプトに存在しますが(初回initブロック内)、テスト時に手動で .git/ だけ消した状態では user 設定が残っておらず、フックが拒否していました。

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

DST ごと消してから再実行すれば mkdir -p "$DST"git init → user設定 → コミット成功、という順序が担保されます。.git/ だけ消すのは「一部だけ初期化」になり、この順序が崩れます。テストの手順が間違っていました。

再現テストの正しい手順: rm -rf "$DST" で全体を消してから launchctl start com.shun.dotfiles-snapshot で手動起動する。部分削除は中途半端な状態を作り、デバッグを複雑にします。本番のlaunchdは毎回 mkdir -p "$DST" から始まるため再現しないバグを、テスト手順の不備が作り出していました。

つまずきポイント

p2では「3週間バックアップが取れていなかったTCCバグ」「git commit がexit 0を返しながらHEADが動かなかったバグ」「git init 直後の初回コミットがexit 3になるバグ」の3件を深掘りしました。ここではそれ以外の、launchd×rsync×git構成でよく踏む落とし穴を網羅します。

plist固有の落とし穴

plistのパス値に~は使えない(絶対パスで書く)

StandardOutPathStandardErrorPath のXML値はlaunchdが文字通りに解釈します。~ はシェルの展開機能であり、plistパーサーにはそのロジックがありません。~/.claude/logs/... と書いてもlaunchdは文字どおり ~/.claude/ というパスを探しに行き、当然存在しないため出力が失われます。実際のplistは次のように絶対パスで書かれています。

<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>

テンプレートからコピーして ~ のまま使い回したplistは、ログが一切出ないまま動き続けます。「launchdで起動しているのにzsh起動エラーが捕捉できない」と思ったらまずここを確認してください。

ProgramArgumentsのシェルリダイレクトがStandardOutPathを上書きする

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

>> /dev/null 2>&1 と書くと、launchdが設定した StandardOutPath の書き込み先がzshレベルで /dev/null に置き換わります。p2で説明したとおり、この設計は「スクリプト起動前のzshエラーだけを StandardOutPath で捕まえる」という意図的な分離です。しかし意図せずやると「launchdのログに何も出ない」という状態になります。デバッグ中は一時的に >> /dev/null を外してStandardOutPathに流し、動作確認が終わったら戻す、という手順が安全です。

EnvironmentVariablesを省略するとHomebrewもnvmも見えない

launchdのバックグラウンドプロセスは .zshrc.zprofile も読みません。デフォルトのPATHは実質 /usr/bin:/bin:/usr/sbin:/sbin です。gitrsync はたまたまシステムの /usr/bin にあるので動きますが、Homebrewの git(より新しいバージョン)やnvm管理下のNode.jsは見えません。

問題が顕在化するのは グローバルgit hookがNode製 のケースです。このスクリプト自体はNode.jsを直接呼びません。しかし ~/.git-hooks/commit-msg のような conventional commits バリデータがNodeで書かれていれば、間接的にNode.jsが必要になります。実際のplistには次のようにフルPATHが書かれています。

<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>

nvmのバージョンが変わるたびにこのパスを更新する必要があります。nvm current で確認したバージョンをベースに書き換えてください。

StartCalendarIntervalはスリープ中の予定をスキップする

cronとlaunchdの大きな違いの一つが missed-fire の扱いです。Macが日曜6時にスリープ中だった場合、StartCalendarIntervalの起動は静かにスキップされます。Wake後に取り戻す動作(catch-up)はありません。次の日曜6時まで実行されません。

ラップトップをよくシャットダウン・スリープする環境では、週次スケジュールで穴が開くリスクがあります。対策は2つ。StartInterval を使って定間隔実行(例: 86400秒=毎日)に変更するか、RunAtLoad: true を追加してロード時にも実行するかです。毎日実行にすれば1週間以上抜ける確率は大幅に下がります。

launchctl loadとlaunchctl startを混同する

launchctl load ~/Library/LaunchAgents/com.shun.dotfiles-snapshot.plist はスケジュールを登録するだけです。即時実行はしません。デバッグ時に load して「なぜ動かないんだ」と10分悩むのは非常によくある詰まりです。

即時実行は launchctl start com.shun.dotfiles-snapshot です。テストサイクルは次の通りです。

# 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 なら正常

LastExitStatusの読み方(768は何を意味するか)

launchctl list com.shun.dotfiles-snapshot の出力に "LastExitStatus" = 768 と出たとき、これは 768 ÷ 256 = 3 なので exit 3 で終了したことを意味します。launchdはスクリプトの終了コードを exit_code × 256 で保持します。このスクリプトでは終了コードに次の意味を持たせています。

  • 0 — 正常終了(変更あり・コミット成功、または変更なし)
  • 1 — settings.jsonにシークレットを検知、コピーを削除して中断
  • 2git commit がexit 0以外で失敗
  • 3git commit はexit 0だが git rev-parse HEAD でHEADが前進していない

ログを開く前に launchctl listLastExitStatus を確認するのが最速の障害初動です。

スクリプト設計の落とし穴

INCLUDEリストは自分でメンテしなければならない

~/.claude/ に新しいディレクトリを作っても、スクリプトのINCLUDEリストに追加しなければバックアップされません。

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

私の環境では memory/ ディレクトリ(agentmemoryが書き込むファイル群)を1か月運用してから「INCLUDEに入っていないな」と気づきました。追加したその週のコミットで初めてその1か月分の変更がスナップショットに載りました。ls ~/.claude/ とこのリストを月次で照合する習慣を持ってください。

.gitignoreはスクリプトが毎回書き直す

スクリプトは実行のたびにヒアドキュメントで .gitignore を上書きします。

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

手動で .gitignore に追記しても次回実行時に消えます。除外パターンを追加したい場合はスクリプト本体のヒアドキュメントを編集してください。# Auto-generated というコメントがその意図を示しています。

rsync の 2>>"$LOGFILE" だけではエラーに気づけない

rsyncはループ内でエラーをログに流しますが、exit codeをチェックせずに次の項目に進みます。

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

全11項目が失敗しても CHANGED=0no changes で正常終了します。「数週間no changesが続いているのに設定は変えているはずだ」という違和感を感じたら、次のコマンドでログを確認してください。

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

何かが出てきたら原因特定につながります。

TCC保護対象は ~/Documents/ だけではない

p2で詳述したTCCの問題は ~/Documents/ に限りません。launchdのバックグラウンドプロセスが無音でブロックされるディレクトリは以下の通りです。

  • ~/Desktop/
  • ~/Documents/
  • ~/Downloads/
  • ~/Movies/, ~/Music/, ~/Pictures/
  • 外部ドライブ・ネットワークボリューム
  • iCloud Drive(~/Library/Mobile Documents/

逆に launchdが許可なしで書き込めるディレクトリ は:

  • ~/ 直下のドットフォルダ(~/.claude/~/.config/~/.local/ 等)
  • ~/Library/Application Support/~/Library/Logs/~/Library/Caches/
  • /tmp//var/folders/(一時ファイル)

自動化スクリプトの書き込み先は、この二つのリストのどちらに属するかを確認してから設計してください。

gitのローカルuser設定は git init と同時に行う

グローバルの ~/.gitconfig は launchd 環境でも通常読まれますが、commit-msgフックが user.nameuser.email の存在を前提に動いている場合、launchd環境で GIT_CONFIG_NOSYSTEM 等が設定されていると弾かれることがあります。このスクリプトが git init と同時に git config user.name "..."git config user.email "..." をローカルに書くのはそのフェイルセーフです。

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

DST ごと消してから再テストしないと git init ブロックが実行されません。.git/ だけ削除するテストは「一部初期化」状態を作り、本番では再現しないバグを引き起こします(詳細はp2の詰まり3を参照)。


ベストプラクティス

1. バックアップ先は ~/Documents/ 等のTCC保護ディレクトリを絶対に使わない

launchdのバックグラウンドプロセスは無音で弾かれます。バックアップ先の第一候補は ~/. 系のドットフォルダです。本構成の ~/.claude/config-snapshots/ がその正解例です。

2. plist値のパスは絶対パスで書く

~ を使うとその文字が展開されずパスとして機能しません。StandardOutPathStandardErrorPathProgramArguments 内のスクリプトパス──すべて /Users/username/... 形式の絶対パスで記述します。

3. EnvironmentVariablesにnvm・Homebrew込みのフルPATHを書く

デフォルトPATHはほぼ /usr/bin:/bin です。/opt/homebrew/bin、nvm配下のNode.jsパス、~/.local/bin を明示しなければ、スクリプトが依存するツールがいつ見つからなくなっても不思議ではありません。PATHは「今動いているshellのecho $PATH」から取るのが最速です。

4. git commit の成否は git rev-parse HEAD で直接確認する

exit codeを信じてはいけません。commit-msgフック・pre-commitフック・フックの実装次第でexit codeの伝播は不安定です。PREV_HEADNEW_HEAD を比較して「HEADが前進したか」を直接確認するのが唯一の確実な手段です。

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

5. exit codeは障害種別で意味を分ける

exit 1(secrets検知)・exit 2(commit失敗)・exit 3(HEAD不動)のように割り当てておくと、launchctl listLastExitStatus を256で割るだけで原因種別が判明します。汎用の exit 1 だけ使うと後から「何で失敗したか」を追うためにログを読み込む必要があります。

6. secretsチェックはプレフィックス限定パターンで誤検知を抑える

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

「英数字30文字以上」のような汎用パターンはBase64エンコードされた設定値でも引っかかります。sk-*(Anthropic)、ghp_*(GitHub PAT)、AKIA*(AWS)のようにプレフィックスで絞ることで誤検知をほぼゼロにしながら実質的なリスクをカバーします。

7. ログは「launchd起動ログ」と「スクリプト進捗ログ」を分離する

StandardOutPath で zsh 起動エラーを捕まえ、スクリプト内部の進捗は >> "$LOGFILE" で別ファイルに流します。この2経路分離によって「スクリプトが起動したか」と「スクリプトの中で何が起きたか」を独立して追跡できます。問題調査のファーストステップは launchctl listLastExitStatus 確認 → dotfiles-snapshot.log 確認の順です。

8. set -uo pipefail を使い、-e は意識的に外す

未定義変数(-u)とパイプ中断(-o pipefail)は即死させます。しかし grep -q が「パターンなし(exit 1)」を正常とするシークレットチェックや、git rev-parse ... || echo "" のフォールバックがある初回init処理は -e と相性が悪いです。-e を外して非ゼロリターンを if で明示的にハンドルする設計の方が意図が明確です。

9. rsyncは「止めずに記録する」設計にする

11項目のうち1つが失敗しても残りをコピーし続けることが重要です。rsyncのexit codeを確認せずに次の項目へ進み、エラーは 2>>"$LOGFILE" で記録します。ただしこの設計では全項目失敗でも no changes で終わります。定期的にログの Operation not permitted を確認してください。

10. launchctl start でデバッグし launchctl list でLastExitStatus確認

plistを変更したら unload → load → start のサイクルで即時テストします。launchctl list com.shun.dotfiles-snapshotLastExitStatus が 0 でなければ 256で割って種別確認 → ログ確認 の順で原因を追います。次のスケジュール起動を待つのは時間の無駄です。

11. Nice=10LowPriorityIO=true はセットで指定する

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

Nice=10 だけではCPU優先度しか下がりません。LowPriorityIO: true を省略するとIOが普通の優先度で走り、起動直後のMacで他の書き込みと競合します。バックグラウンドバックアップには3つセットで指定してください。

12. INCLUDEリストを ls ~/.claude/ と定期的に照合する

新しいディレクトリが ~/.claude/ に増えるたびにINCLUDEリストへの追加が必要です。月次で ls ~/.claude/ とリストを比べる習慣を持つか、次のコマンドを定期的に実行してください。

# 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)

13. 再現テストは rm -rf "$DST" から始める

.git/ だけ消して再テストすると「一部初期化」状態になり、本番では再現しないバグが発生します。テストは常に $DST 全体を削除した状態から始めてください。本番のlaunchdは毎回 mkdir -p "$DST" から始まるため、この条件を完全に再現できます。


まとめ

この記事で伝えたかった核心は、「成功しているように見えて失敗している」罠の構造です。

macOSのTCCはGUIアプリには「アクセスを許可しますか?」というダイアログを出します。ユーザーはアプリが ~/Documents/ に書き込もうとしていることを知ります。しかしlaunchdのバックグラウンドプロセスにはそのフィードバックがありません。Operation not permitted はrsyncのエラーとしてログに流れ、スクリプトは次の項目へ進み、全項目失敗でも no changes を記録して正常終了します。launchdのLastExitStatusは 0 です。ユーザーに伝わる信号はゼロです。

git commit のexit codeも同じ構造を持っていました。フックが弾いても exit 0 に見えることがある。ログに commit=a1b2c3d と書かれていても、そのハッシュが git log に存在しないことがある。「成功した」というログの記録が「実際に成功した」ことを保証しません。

この2つの罠に共通する対処は「外部から検証可能な事実を確認すること」です。rsyncが成功したか──ではなく、コピー先にファイルが存在するかを確認する。gitコミットが成功したか──ではなく、HEADが前進したかを git rev-parse で確認する。「コマンドがexit 0を返した」は「意図した副作用が起きた」ではありません。

私がClaude Code自律環境の整備に半年かけた理由のひとつは、こうした「静かな失敗」を一つひとつ発見し潰すことにありました。月商120万の環境は、派手なAIの使い方よりも、地味な防衛線の積み上げで成り立っています。スクリプトの冒頭コメントに残された 旧 ~/Documents/claude-config-snapshots は launchd 実行時に macOS TCC で "Operation not permitted" になり全コピー失敗していたため 2026-06-01 に移設 という一文は、3週間の無音の失敗から学んだ教訓です。

コードはシンプルです。dotfiles-snapshot.sh は112行で、INCLUDEリストのrsync・secretsチェック・HEADの前進確認・ログ出力、それだけです。複雑さは設計ではなく、macOSの挙動を正確に理解することから来ていました。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!