📥 Claude会話ログをObsidianに「夜間ドレイン」する三重構造の作り方
会話ログはセッションを閉じた瞬間から忘却が始まります。月10万の大学生から掛け持ちで60万まで積み上げ、会社都合の解雇で一度0に戻り、そこから半年でClaude Code自律環境を建てて月商120万に達した背景には、「今日やったこと」をちゃんと翌朝以降に引き継ぐ仕組みがありました。その核心が vault-auto-ingest.sh です。
なぜこの仕組みが効くのか
Claude Codeの「記憶」はデフォルトで消える
Claude Codeのセッションは会話ごとに独立しています。今日の長い作業セッションで判明したバグの原因、試行錯誤の末に選んだアーキテクチャの理由、「この方向は一度失敗した」という経験値——それらはセッションを閉じると次の会話では参照できません。
有料プランでも、あるいはいくら高性能なモデルを使っていても、文脈が引き継がれなければ毎回ゼロからの説明が要ります。「前も同じことを調べた」「一度失敗したはずなのにまた同じ方向へ進んでしまった」という経験をした方は多いはずです。
個人開発を量産するフェーズでは、この問題は致命的です。並行して走るプロジェクトが3本・4本になると、各プロジェクトの「今どこにいるか」を人間が手で追うのはすぐ限界が来ます。そしてClaudeも、前回の会話を参照できないので同じ検討を繰り返します。
解決策は「作業でなく環境」を作ること
この問題に対して最初は「毎日手でまとめてメモしよう」と試みました。続きませんでした。作業の勢いがあるときはまとめを書く気にならず、疲れているときはもっと書けない。人間の意志力に頼る仕組みは、個人開発の量産フェーズでは機能しません。
答えは「毎晩自動でClaudeの会話ログをObsidianへ流し込む環境を作る」ことでした。環境が整えば、意志力も気力も関係ありません。Macが勝手にやります。
Obsidianを選んだ理由はシンプルです。ローカルのMarkdownファイルなので、Claude Codeから直接読み書きできます。Gitでバージョン管理できます。[[リンク]] 構文で知識同士を繋げられます。毎朝ログが流れ込み、プロジェクト間のリンクが自然に育っていく——これが「外付け脳」として機能し始めた瞬間から、作業の質が変わりました。
なぜ三重構造が必要になったか
単純に「毎日4:55にスクリプトを動かす」だけでは、実際に動かしてみると3種類の障害が発生しました。それぞれが実害を出して初めて気づいたものです。
スリープ凍結: Macの蓋を閉じて寝ると、caffeinate -s(AC電源時のみ有効)ではスリープを防げません。スクリプトが途中で止まり、次のスロットが動くまでその日の処理が宙ぶらりんになります。
二重実行レース: 2026-06-10に実際に発生した障害です。launchdからの定時発火と、手動実行が重なりました。両方が同じVaultをGitで操作しようとした結果、コンフリクトが起きました。
タイムアウト: 2026-06-13に終日全スロットが失敗した日がありました。claude -p による28時間分のログ消化が、40分のタイムアウト枠に収まらず全て刈られました。ログには 04:55:00開始→05:40:01でstep2 timeout というパターンが4スロット分、完全に一定で並んでいます。
これら3つの実障害が「多段スロット再発火」「caffeinate スリープ防止」「ステップマーカーによる冪等リトライ」という三重構造を作らせました。設計から生まれたのではなく、実際に壊れた経験から積み上がったものです。
「環境が育つ」という感覚
仕組みが安定して動き始めると、Obsidianに毎朝ログが降り積もっていきます。Claudeがそのログを読んでプロジェクト別の記事を更新し、hot.md(直近の文脈まとめ)に今週の論点が積まれていきます。
次の会話でClaudeがhot.mdとwiki/を読むと、「先週の判断」「一度失敗したアプローチ」「並行している3プロジェクトの現在地」を全部知った状態で作業を始められます。これが月商120万の環境の根幹です。会話が終わっても忘れない「外付け長期記憶」が、Claude Codeの能力を本来のポテンシャルで引き出す前提条件になっています。
全体の流れ
4層アーキテクチャ
スクリプトが扱うデータの流れは以下の4層です。
【層1】Claude Code セッションログ(セッション終了時にStop hookが書き出し)
↓ extract_conversations.py(step1: 最新化)
【層2】~/Documents/my-knowledge-base/raw/conversations/
↓ claude -p(step2a: Claude由来ログを消化, timeout 1500s)
~/Documents/my-knowledge-base/raw/codex-conversations/
↓ claude -p(step2b: Codex由来ログを消化, timeout 1500s)
【層3】Obsidian Vault(~/Documents/claude-obsidian/wiki/)
├── hot.md(直近サマリ)
├── index.md(全体目次)
├── projects/ / learning/ / career/ ... (ドメイン別記事)
└── today-brief.md(step2.5: 今日の行動提案)
↓ git add -A && git commit && git push
【層4】private repo(安全網: 荒れてもrevert可能)
層2を「Claude由来」と「Codex由来」の2本に分割しているのが2026-06-11以降の変更です。もともと1本で全部処理していたところ、活動が多い日に40分のタイムアウト枠に収まらなくなりました。分割後は各1500秒(25分)の独立したタイムアウトを持ち、片方が失敗しても次のスロットで残りだけ再試行できるようになっています。
多段スロット発火の実装
launchd plist(~/Library/LaunchAgents/com.shun.vault-auto-ingest.plist)で定義した4つのスロットが、1日かけて「成功するまで再試行」を繰り返します。
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>55</integer></dict>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>45</integer></dict>
<dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>15</integer></dict>
</array>
RunAtLoad は false です。ログイン時の自動起動はしません。スロット4本だけが発火源です。
スクリプトの冒頭で当日分の成功マーカーを確認し、既に終わっていれば即終了します。
TODAY=$(date +%Y%m%d)
DONE_MARKER="$HOME/.claude/logs/.vault-ingest-done-${TODAY}"
# 0. 本日分が既に成功していれば即終了
[ -f "$DONE_MARKER" ] && exit 0
これにより、4:55に成功したなら8:20・10:45・12:15の3回は「ファイル確認して終了」という無害な空振りになります。4:55に失敗(スリープ凍結・ネット未接続・launchd一時障害など)した場合だけ、8:20が実際に処理を引き継ぎます。
caffeinate によるスリープ防止
MacBookで運用する場合、蓋を閉じると caffeinate -s(AC電源時のみ有効)では防ぎきれません。そこでスクリプト自身が -i(システムスリープ防止)と -s の両方で自己再実行する構造になっています。
if [ -z "${CAFFEINATED:-}" ]; then
exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi
CAFFEINATED 環境変数がなければ caffeinate 配下で自分自身を再実行し、それ以降の処理を caffeinate の傘の下に置きます。一度だけ再実行されて以降は CAFFEINATED=1 がセットされているので無限ループにはなりません。
-s はバッテリー駆動時は無効です。蓋閉じスリープで凍結した場合、timeout が刈って次のスロットがやり直します。Step2が途中まで済んでいればマーカーで済んだ分をスキップし、残りだけ実行します。
lockdir による二重実行防止
mkdir の原子性(同時に2プロセスが呼んでも片方だけ成功する)を使ったロック機構です。Bashのファイルロックとして広く使われている手法です。
LOCKDIR="$HOME/.claude/locks/vault-auto-ingest.lock"
if ! /bin/mkdir "$LOCKDIR" 2>/dev/null; then
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || true)
if [ -n "${oldpid:-}" ] && kill -0 "$oldpid" 2>/dev/null; then
echo "[$(date '+%F %T')] 別インスタンス実行中(pid=${oldpid}) — skip" >> "$LOG"
exit 0
fi
rm -rf "$LOCKDIR"
/bin/mkdir "$LOCKDIR" 2>/dev/null || exit 0
fi
echo $$ > "$LOCKDIR/pid"
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM
既存のlockdirがあってもPIDを確認し、プロセスが生きていれば「別インスタンスが動作中」として終了します。プロセスが死んでいれば(前回が異常終了してロックが残った場合)staleとして回収し、新たにロックを取得して処理を続けます。trap でスクリプト終了時に必ずlockdirを削除します。
ステップマーカーによる冪等リトライ
最も工夫が要ったのがここです。step2(Vaultへのingest)をClaudeとCodexで2本に分割し、各々が独立したマーカーを持ちます。
STEP2A_MARKER="$HOME/.claude/logs/.vault-ingest-step2a-claude-${TODAY}"
STEP2B_MARKER="$HOME/.claude/logs/.vault-ingest-step2b-codex-${TODAY}"
ingest_src 関数がこのマーカーを見て「完了済みならスキップ・未完了なら実行」を判断します。
ingest_src() {
local marker="$1" src="$2" name="$3" to="$4" extra="$5"
[ -f "$marker" ] && { echo "[...] step2($name) は本日実施済み — skip" >> "$LOG"; return 0; }
cd "$VAULT" && run_to "$to" "$CLAUDE" -p \
"...(Vaultのルールに従って wiki/ を更新するプロンプト)..." \
--dangerously-skip-permissions >> "$LOG" 2>&1 \
&& { touch "$marker"; return 0; } \
|| { echo "[...] WARN: step2($name) 失敗/timeout(次スロットで再試行)" >> "$LOG"; return 1; }
}
ingest_src "$STEP2A_MARKER" "$KB/raw/conversations/" "claude" 1500 ""
ingest_src "$STEP2B_MARKER" "$KB/raw/codex-conversations/" "codex" 1500 "Codex由来でも既存記事に統合し重複は追記でまとめろ。"
[ -f "$STEP2A_MARKER" ] && [ -f "$STEP2B_MARKER" ] && touch "$STEP2_MARKER"
動作パターンは次のとおりです。
4:55 発火
├── DONE_MARKER なし → 処理続行
├── step2a 実行(claude由来, 1500s上限) → 成功 → STEP2A_MARKER 作成
├── step2b 実行(codex由来, 1500s上限) → timeout! → マーカーなし
├── brief生成 → 失敗(step2b未完でログが薄い) → DONE_MARKER 作らない
└── notify_fail で Desktop に FAILED ファイル + 通知
8:20 発火
├── DONE_MARKER なし → 処理続行
├── step2a → STEP2A_MARKER あり → skip(再実行しない)
├── step2b → マーカーなし → 実行 → 成功 → STEP2B_MARKER 作成
├── brief生成 → 成功
├── DONE_MARKER 作成 ✓
└── FAILED ファイル削除
10:45 / 12:15 発火
└── DONE_MARKER あり → exit 0(空振り)
step2aが成功していれば8:20はstep2bだけを実行します。step2aもstep2bも両方終わっていれば8:20はbriefだけを実行します。どの時点のスロットが何を担当するかが動的に決まるため、「4:55に失敗しても必ず当日中に終わる」という保証を維持できます。
launchd PATH とノードバイナリの補完
launchdの実行環境は /usr/bin:/bin:/usr/sbin:/sbin 程度の最小PATHしか持ちません。git commit 後のフック(node製)が動かず commit 自体が失敗するというトラップを回避するため、スクリプト冒頭でnvmの最新nodeを自動探索して追加しています。
NODE_BIN=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
[ -n "$NODE_BIN" ] && export PATH="${NODE_BIN}:$PATH"
sort -V(バージョン順ソート)の末尾を取ることで、nvmに複数バージョンが入っていても常に最新を使います。バージョンを直書きしていないので、nvm install でバージョンが増えてもスクリプト側の修正は不要です。
また、macOS TCC(プライバシー保護)により /bin/bash にフルディスクアクセス権限がないとlaunchdから ~/Documents/ 以下に書けません。スクリプトはこれを早期検知してサイレント失敗を防ぎます。
if ! ( cd "$VAULT" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1 ); then
echo "[...] ❌ FDA未付与: launchdから '$VAULT' にアクセス不可(TCC保護)。" >> "$LOG"
notify_fail "FDA未付与: vault にアクセス不可(設定→フルディスクアクセス→/bin/bash)"
exit 1
fi
exit 0 を返してしまうと「成功した」に見えてDONE_MARKERが作られ、次のスロットが再試行しません。TCC失敗は exit 1 で明示し、次スロットに引き継ぎます。
実装の詳細
timeout ヘルパーと「確実に刈る」設計
スクリプトの核心的な判断のひとつが、run_to 関数です。
TIMEOUT_BIN="/opt/homebrew/bin/timeout"
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""
run_to() { local s=$1; shift; if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" --kill-after=30 "$s" "$@"; else "$@"; fi; }
GNU coreutils の timeout を明示パスで呼んでいます。macOS 標準の /usr/bin には GNU 版の timeout がありません。/opt/homebrew/bin/timeout が存在しない環境では TIMEOUT_BIN を空にして素通し(ハング耐性は落ちるが動作は維持)します。
--kill-after=30 が地味に重要です。SIGTERM を送ってから 30 秒後にプロセスがまだ生きていれば SIGKILL で強制終了します。claude -p は重い処理をしているとき SIGTERM を無視することがあるため、--kill-after がないと「タイムアウト後もプロセスが生き続けて lockdir を掴んだまま」という最悪のケースが起きえます。
step2a(Claude 由来ログ)と step2b(Codex 由来ログ)にはそれぞれ 1500 秒(25 分)の上限を渡しています。前述のとおり、もともとは 2400 秒(40 分)の単一処理でしたが活動量が増えるにつれて収まらなくなりました。2 本に分割して各 1500 秒にしたことで、一方が死んでも次のスロットは残りだけを再実行できるようになっています。
wake 直後のネット待ちとプリフライト検知の連鎖
Mac が 4:55 に起床しても、Wi-Fi の接続が安定するまでに数十秒かかることがあります。claude も git push もネットを必要とするため、接続前に走ると即エラーです。
net_ok=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18; do
if /usr/bin/nc -z -G 3 1.1.1.1 443 2>/dev/null; then net_ok=1; break; fi
sleep 5
done
[ -z "$net_ok" ] && echo "[...] WARN: 網未接続のまま続行(失敗時は次スロットが再試行)" >> "$LOG"
18 回 × 5 秒 = 最大 90 秒待ちます。nc -z -G 3 は 3 秒でタイムアウトする TCP コネクションテストで、curl より軽量です。90 秒経っても繋がらない場合は「警告を出して続行」にしています。ここで exit 1 にしてしまうと、オフライン環境での step1(ローカル処理)まで止まるためです。失敗したら次スロットが自動再試行するという設計が前提にあるので、ここは「止めずに記録」で十分です。
続いてプリフライト検知が 3 段階で連鎖します。
① claude バイナリの存在確認: アップデートで symlink 先が消えるケースを早期検知します。claude update を実行すると一時的にバイナリが不在になることがあり、これがサイレント失敗の原因になります。
② macOS TCC のプリフライト: Vault が ~/Documents/ 以下にある場合、launchd から動くプロセスにフルディスクアクセス権限がないと書けません。
if ! ( cd "$VAULT" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1 ); then
echo "[...] ❌ FDA未付与: launchdから '$VAULT' にアクセス不可(TCC保護)。" >> "$LOG"
notify_fail "FDA未付与: vault にアクセス不可(設定→フルディスクアクセス→/bin/bash)"
exit 1
fi
ここで exit 0 を返すとDONE_MARKER が作られてしまい、次のスロットが「成功した」と誤認して再試行しません。TCC 失敗は必ず exit 1 で終了し、次スロットに引き継ぎます。この区別が後述する失敗話の根でもあります。
ログ肥大ガードと brief の鮮度判定
claude -p の出力をログに全部書き込むと、数週間で数十 MB になります。
if [ -f "$LOG" ] && [ "$(stat -f%z "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ]; then
mv "$LOG" "${LOG}.old"
fi
5 MB(5,242,880 バイト)を超えたら .old にリネームして新規ログを始めます。stat -f%z は macOS のファイルサイズ取得コマンドです。GNU stat とオプションが異なるため || echo 0 でフォールバックを入れています。
brief の鮮度判定も重要な部分です。
START_STAMP=$(mktemp /tmp/vault-ingest-start.XXXXXX)
スクリプト冒頭でこの一時ファイルを作り、step2.5(brief 生成)の後にこれより新しいかを確認します。
if [ -s "$BRIEF_SRC" ] && [ "$BRIEF_SRC" -nt "$START_STAMP" ]; then
# アーカイブ処理
touch "$DONE_MARKER"
else
notify_fail "ブリーフ生成が未完"
fi
-nt(newer than)で比較することで、「昨日生成された古い today-brief.md が残っていた場合に今日の成果として誤アーカイブする」事故を防ぎます。brief が今回のラン開始より新しくなければ成功とみなさず、DONE_MARKER を作らずに次スロットへ引き継ぎます。
LLM 出力を決定論で上書きする
claude -p が wiki/index.md を更新するとき、「総ページ数」を誤った値で書くことがあります。モデルが文脈から推測して出力するため、実際のファイル数と一致しません。
real=$(find "$VAULT/wiki" -name '*.md' -not -path '*/.*' | wc -l | tr -d ' ')
sed -i '' -E "s/総ページ数:[0-9]+/総ページ数:${real}/" "$INDEX_FILE"
find で実ファイル数を数え直し、sed で強制上書きします。git commit の直前に入ることで、LLM 生成の数値が決定論的な値に差し替えられた状態でコミットされます。「LLM の出力する数値は後段の決定的チェックで上書きする」という設計です。
カレンダーの last-known-good パターン
brief に「今日の予定」を書くとき、カレンダー情報が取得できない日は過去の情報を保持します。
if [ -n "$CAL_SRC" ]; then
cp "$CAL_TMP" "$CAL_SNAPSHOT"
cp "$CAL_SNAPSHOT" "$CAL_LASTGOOD" # 成功したら last-good を更新
elif [ -s "$CAL_LASTGOOD" ]; then
# 取得失敗: 前回 good を温存し stale 印を付けて出力
{ tail -n +4 "$CAL_LASTGOOD"; } > "$CAL_SNAPSHOT"
fi
Google Calendar API(ADC 認証)と icalBuddy(ローカル Apple カレンダー)を順に試し、両方失敗したら前回成功時の _calendar-snapshot.md.lastgood を使います。「正典を絶対に空で潰さない」という原則です。stale であることを冒頭の ⚠️ で明示し、brief 生成プロンプトに「stale とあれば取得日時点の値である点を断れ」と渡します。
私が詰まった話
障害①: 全スロットが秒単位で同じ場所で死んだ日(2026-06-13)
~/Desktop/Daily Brief/ に FAILED-20260613.md が残っていて気づきました。ログを開くと、4 本のスロット全部が完全に同じパターンでした。
04:55:00 ===== auto-ingest 開始 =====
05:40:01 WARN: step2(claude) 失敗/timeout(次スロットで再試行)
08:20:00 ===== auto-ingest 開始 =====
09:05:01 WARN: step2(claude) 失敗/timeout(次スロットで再試行)
10:45:00 ===== auto-ingest 開始 =====
11:30:01 WARN: step2(claude) 失敗/timeout(次スロットで再試行)
12:15:00 ===== auto-ingest 開始 =====
13:00:01 WARN: step2(claude) 失敗/timeout(次スロットで再試行)
全スロットが 45 分 01 秒でタイムアウトしています。当時の step2 タイムアウトは 2400 秒(40 分)でした。claude -p が 40 分間ずっと処理し続けてタイムアウトされ、次スロットが再試行しても同じ結果、というループが 4 回繰り返されました。
最初に疑ったのは git commit 後のエラーメッセージです。ログに「commit-msg フック」「node not found」という文字列が見えたため、ノードバイナリが原因だと思いました。しかし claude -p "say OK" を手動で走らせると即座に exit 0 が返りました。commit を手動で試すと普通に通ります。
真因は一過性の claude -p 側の障害でした。06-12 は正常動作、06-13 当日だけ全リクエストがハングする状態になっており(おそらくレート制限かサービス側の一時障害)、翌日には解消されていました。commit-msg フック説は完全に外れでした。
この経験から得た教訓が 2 つあります。一つは「エラーメッセージは犯人の目星であって確定ではない。実際に走らせて切り分ける」。もう一つは FAILED マーカーの掃除に関するものです。当時のクリーンアップは「当日の FAILED ファイルを成功時に削除する」だけでした。06-13 の FAILED ファイルは、翌日以降も机上に残り続けます。
# 修正後: 過去日の FAILED も正常稼働日に掃く
find "$HOME/Desktop/Daily Brief" -maxdepth 1 -name "FAILED-*.md" ! -name "FAILED-${TODAY}.md" -delete 2>/dev/null
この 1 行を追加したことで、複数日連続で障害が起きても、最初に正常稼働した日に過去日分が全部消えるようになりました。
障害②: launchd と手動実行が Vault を同時に書こうとした(2026-06-10)
ある朝、スクリプトの進捗を確認しようとして手動でも vault-auto-ingest.sh を走らせました。既に launchd の 4:55 スロットが動いていました。両方が同じ Vault に git add -A && git commit を走らせた結果、コンフリクトが起きました。
error: cannot lock ref 'refs/heads/main': is at xxx but expected yyy
処理が途中でぶつかったため、Vault の状態が中途半端になり、手動でリセットが必要になりました。
前述の mkdir ロックで解決していますが、ポイントは「stale lock の自動回収」です。
if ! /bin/mkdir "$LOCKDIR" 2>/dev/null; then
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || true)
if [ -n "${oldpid:-}" ] && kill -0 "$oldpid" 2>/dev/null; then
echo "[...] 別インスタンス実行中(pid=${oldpid}) — skip" >> "$LOG"
exit 0
fi
rm -rf "$LOCKDIR"
/bin/mkdir "$LOCKDIR" 2>/dev/null || exit 0
fi
kill -0 "$oldpid" はシグナルを送らず、プロセスが生きているかだけを確認します。プロセスが死んでいれば stale な lockdir を削除して自分がロックを取得します。これにより「前回が異常終了して lockdir が残ったまま次回以降が全部スキップされ続ける」という最悪ケースを防いでいます。
障害③: 「成功しているはずなのに何も更新されない」TCC の罠
Vault を ~/Documents/claude-obsidian/ に置いていた時期があります(後に TCC 保護外の ~/claude-obsidian/ へ移設)。当時のスクリプトは TCC エラーを適切に処理しておらず、cd "$VAULT" に失敗しても exit 0 で終了していました。
# 修正前の問題コード(イメージ)
cd "$VAULT" && git rev-parse --git-dir >/dev/null 2>&1 || exit 0
^^^^^^
この exit 0 が DONE_MARKER を作る前提を壊す
スクリプトが exit 0 で終了すると、呼び出し元の launchd は「正常終了した」とみなします。しかし次の処理でも同じことが起きるため、DONE_MARKER が一切作られないまま 4 スロット全部が「成功したように見えて何もしない」状態になります。
症状は不思議なものでした。ログには「===== 完了 =====」が 4 回記録されています。しかし Vault は更新されていません。DONE_MARKER もありません。
原因に気づいたのは、手動でスクリプトを対話実行したときに出たメッセージです。launchd から走ると /bin/bash に Full Disk Access がないため、cd "$VAULT" がサイレントに失敗していました。
修正後は TCC 失敗を exit 1 で明示し、デスクトップに FAILED ファイルを作って通知します。
if ! ( cd "$VAULT" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1 ); then
notify_fail "FDA未付与: vault にアクセス不可(設定→フルディスクアクセス→/bin/bash)"
exit 1 # DONE_MARKER は作られない → 次スロットが再試行
fi
exit 0 と exit 1 の区別が「成功した」「次スロットが引き継ぐ」を分けます。自動化の文脈では、サイレントな成功偽装が最も診断しにくい障害です。
障害④: caffeinate -s を信じすぎた
最初の実装では caffeinate -s だけをスクリプト冒頭に置いていました。
# 初期の誤った実装
caffeinate -s "$0" "$@"
-s フラグの説明は「システムスリープを防ぐ」です。しかし実際には AC 電源に接続されているときしか効きません。バッテリー駆動中に蓋を閉じると macOS はスリープに入り、caffeinate -s の傘下にあるスクリプトも一緒に凍結します。
翌朝確認するとスクリプトは起動した形跡があるのに中途半端な状態で終わっていました。timeout が後から刈ったため lockdir は消えていますが、step2a のマーカーは存在するのに step2b のマーカーがない状態です。
修正は -i(システムスリープ防止)と -s の併用に変えることでした。
if [ -z "${CAFFEINATED:-}" ]; then
exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi
-i はバッテリー駆動でも有効です。ただし蓋を閉じた場合のスリープ(ディスプレイスリープではなくサスペンドに相当するもの)は -i でも完全には防げない状況があります。それを前提とした設計が「timeout が刈って次スロットが残りを再試行」という多段スロット構造です。caffeinate はあくまでスリープを「遅らせる」補助であり、「必ず処理が完了する」を保証するのはマーカーと多段スロットの組み合わせです。
実際に「詰まった時間」の総量
これらの障害を整理すると、すべて 1 日以内の実害で済んでいます。スリープ凍結は次の 8:20 スロットが回収しました。二重実行レースは手動リセットに 10 分かかりました。06-13 の終日障害は翌日の 4:55 スロットで自動回復しました。TCC の罠は、気づくまで数日かかりましたが気づいてから修正は 30 分以内でした。
どの障害も「気づけた」のはログとデスクトップの FAILED ファイルのおかげです。サイレントに失敗していたら、Vault が何日も更新されていないことに気づかなかったかもしれません。notify_fail でデスクトップに FAILED ファイルを置く設計は、気づきの機会を最大化するためのものです。自動化は「動かして終わり」ではなく「壊れたとき確実に気づける」まで設計して初めて動く、というのが半年の実感です。
つまずきポイント
前半・中段で4つの実障害(全スロット死滅・二重実行レース・TCC罠・caffeinate誤信)を取り上げました。ここでは「そこまで大きくないが確実に詰まる」細かいポイントを網羅します。
set -u とlaunchd環境変数の組み合わせで即死する
スクリプト冒頭に set -u を書いています(実際のコードにあります)。未定義変数を参照すると即 exit 1 するオプションです。launchd から走るシェルは、インタラクティブシェルと違って多くの環境変数が未定義です。${VAR:-} 形式でデフォルト値を与えていない変数を参照すると、対話実行では問題ないのにlaunchd実行だけ死ぬという現象が起きます。このスクリプトでは ${CAFFEINATED:-} ${oldpid:-} ${net_ok:-} と全てデフォルト空文字を明示しています。見落とすと原因不明のexit 1が量産されます。
RunAtLoad を設定し忘れると毎回ログイン時に走る
plistに <key>RunAtLoad</key><false/> を明示しています。書かないとデフォルトが true になり、Mac を再起動またはログインするたびにスクリプトが1回余計に発火します。DONE_MARKERがあれば即終了するので実害は出ませんが、起動直後のCPU負荷と意図しないログ汚染が積み重なります。
trap を書かないとlockdirが残り続けて次回以降が全スキップになる
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM
この1行がないと、スクリプトが Ctrl+C や kill で停止した際にlockdirが残ります。次の発火時に mkdir でロック取得を試みると既存lockdirがあるため「別インスタンス実行中」と誤判断してskipします。しかしPIDを確認するコードを書いていれば stale を自動回収できます。stale 回収がない場合は永遠にスキップされます。
deep夜に走ると日付が変わってDONE_MARKERが2つ生成される
4:55スロットが深夜0:10に動き始めた場合(手動起動などで)、処理途中で date +%Y%m%d が変わりDONE_MARKERのファイル名が変わります。これを防ぐためスクリプトはラン開始時点の日付を BD="$TODAY" で固定します。
BD="$TODAY" # 日付跨ぎ対策: 評価はラン開始時の日付で固定
ARCH_FILE="$VAULT/wiki/briefs/daily/today-brief-${BD}.md"
TODAY はスクリプト冒頭で一度だけ取得します。24時をまたぐ長時間処理でも、ファイル名は「ランを開始した日」で統一されます。
stat -f%z はmacOS専用オプション
ログローテーションのコードです。
[ "$(stat -f%z "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ]
GNU stat(Linux)では -c%s です。macOS で動かす前提で書いているため stat -f%z を使っていますが、Linuxへ移植しようとすると無音で echo 0 に落ちて常にローテーションされません。|| echo 0 のフォールバックがあるため壊れはしませんが、ログが肥大し続けます。
launchd の StandardErrorPath を設定しないと標準エラーが消える
plistには以下が入っています。
<key>StandardErrorPath</key>
<string>/Users/.../.claude/logs/vault-auto-ingest.launchd.log</string>
これを書かないと、launchd経由で走ったプロセスの標準エラーが /dev/null 相当に捨てられます。step2の claude -p が何かをstderrに出していても永遠に見えません。対話実行では見えてlaunchd実行では見えない、という診断困難な状況が生まれます。
node版commit-msgフックがPATH補完なしで死ぬ
Vault の git commit を走らせると commit-msg フックが実行されます。このフックが node 製の場合、launchd の最小PATH(/usr/bin:/bin 程度)には node がありません。git commit が「フック実行失敗」で止まり、変更が一切コミットされないまま終わります。スクリプトは nvm の node を自動探索してPATHに追加しています。
NODE_BIN=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
[ -n "$NODE_BIN" ] && export PATH="${NODE_BIN}:$PATH"
sort -V でバージョン順ソートの末尾を取るのがミソです。文字列ソートにすると v9.x が v10.x より後に来るという逆転が起きます。
step2bのプロンプトで「既存記事への統合」を明示しないと重複記事が量産される
step2a(Claude由来ログ)が先に走り、Vault記事を更新します。その後にstep2b(Codex由来ログ)が走るとき、同じトピックの記事を新規作成しようとします。プロンプトに明示的な指示がないと、同じ内容の記事が projects/foo.md と projects/foo-2.md に分裂します。
ingest_src "$STEP2B_MARKER" "$KB/raw/codex-conversations/" "codex" 1500 \
"Codex由来でも舜の知識として既存記事に統合し、Claude側と重複する話題は新記事を作らず追記でまとめろ。"
この追加プロンプト(extra 引数)がstep2bにだけ渡されます。step2aの結果を見た上で「統合して追記」という指示が機能します。
-nt 比較のSTART_STAMPを消し忘れるとbriefが常に「古い」と判定される
START_STAMP=$(mktemp /tmp/vault-ingest-start.XXXXXX)
この一時ファイルはスクリプト終了時に削除されます。しかし /tmp に残っていると次回ラン時に同じ名前で作られず、古い START_STAMP との比較で today-brief.md が「古い」と判定されてDONE_MARKERが作られないループに入ることがあります。スクリプト末尾の rm -f "$START_STAMP" は必須です。
マーカーファイルが7日以上残り続けると /tmp が肥大する
find "$HOME/.claude/logs" -maxdepth 1 -name '.vault-ingest-*' -mtime +7 -delete 2>/dev/null
日次で作られるマーカーファイルを放置すると、1年で365ファイルが ~/.claude/logs/ に溜まります。-mtime +7 で7日より古いものを定期削除します。この掃除ラインが記念に入っています。
ベストプラクティス
実際に半年間動かして固まった運用原則を12個にまとめます。
① 「成功」と「次スロットに引き継ぐ失敗」をexit codeで分離する
TCC障害でやらかした教訓です。exit 0 を返すとDONE_MARKERが作られ、次のスロットは再試行しません。本物の失敗は必ず exit 1 にします。「サイレントな成功偽装」が自動化で最も診断しにくい障害の形です。
② DONE_MARKERは「全ステップ完了後」にだけ作る
step2a完了時点でDONE_MARKERを作ってしまうと、step2bとbriefが未完でも「今日は終わった」になります。このスクリプトでは brief が START_STAMP より新しい場合にだけ DONE_MARKER を作ります。最後の成果物が出力された後に初めてフラグを立てる、という順序の原則です。
③ 中間マーカーで部分リトライを可能にする
単純な「done/not done」の2状態ではなく、step2a・step2bそれぞれに半マーカーを持たせます。step2aが成功していれば次スロットはstep2aをスキップしてstep2bから始めます。ステップ数が増えるほど部分リトライの価値が大きくなります。
④ caffeinate はスリープを「遅らせる」補助と割り切る
-i -s の両方を使っても、蓋を閉じたMacBookのサスペンドは完全には防げません。caffeinate はベストエフォートです。「必ず完了する」を保証するのはマーカーと多段スロットです。caffeinate に頼りすぎた設計はバッテリー運用で必ず壊れます。
⑤ スロット数は「一番遅い失敗でも当日中に終わる」から逆算する
4スロット(4:55 / 8:20 / 10:45 / 12:15)は「昼までに終われば当日中に使える」という観点から設計されています。朝4:55に失敗しても最悪12:15に成功すれば、その日の活動ログに基づいたbriefが使えます。翌朝の発火は「翌日分の処理」に使うためです。
⑥ --kill-after=30 を必ず付ける
run_to() { "$TIMEOUT_BIN" --kill-after=30 "$s" "$@"; }
SIGTERM 後30秒でSIGKILLを送ります。claude -p は重い処理中にSIGTERMを無視することがあります。kill-afterがないと「タイムアウト後もプロセスが生き続けてlockdirを掴んだまま」になります。
⑦ LLM出力の数値は後段の決定論で上書きする
real=$(find "$VAULT/wiki" -name '*.md' -not -path '*/.*' | wc -l | tr -d ' ')
sed -i '' -E "s/総ページ数:[0-9]+/総ページ数:${real}/" "$INDEX_FILE"
Claudeが wiki/index.md に書く「総ページ数」は推測です。実ファイルを数えて上書きします。git commit直前に入ることで、リポジトリには必ず実測値がコミットされます。LLMが生成する数値は権威にしない、という設計原則です。
⑧ 取得失敗でも前回値を保持する(last-known-good)
カレンダー情報は Google Calendar API → icalBuddy → lastgood という3段フォールバックになっています。「取得できなかったから空にする」ではなく「前回成功時の値をstaleとして保持する」。briefプロンプトには ⚠️stale があれば明示するよう指示します。情報が古くてもゼロより有益です。
⑨ 失敗を「見えるところ」に置く
FAILED_FILE="$HOME/Desktop/Daily Brief/FAILED-${TODAY}.md"
notify_fail() {
mkdir -p "$HOME/Desktop/Daily Brief"
{ echo "# Daily Brief 生成失敗 — ..."; echo "- 自動再試行: 8:20 / 10:45 / 12:15 ..."; } > "$FAILED_FILE"
/usr/bin/osascript -e "display notification ..."
}
ログファイルは「見に行かないと気づかない」ものです。デスクトップに FAILED ファイルを置き、macOS 通知を投げることで「自動化が壊れていること」を目に入れます。そして成功時にそのファイルを自動削除します。「失敗中だけ存在する」ファイルが正常稼働の証明になります。
⑩ 過去日のFAILEDファイルも掃除する
06-13の連続障害の後処理で追加した1行です。
find "$HOME/Desktop/Daily Brief" -maxdepth 1 -name "FAILED-*.md" ! -name "FAILED-${TODAY}.md" -delete 2>/dev/null
「当日の成功時に当日分だけ消す」設計では、複数日連続障害のあとで机上にFAILEDファイルが溜まり続けます。正常稼働した最初の日に過去日分を全部消す、という設計にします。
⑪ nvm node は絶対パスではなくバージョン自動探索にする
launchd plistのEnvironmentVariablesには v24.13.0 のパスを直書きしています(参照ファイル8行目)。ただしスクリプト内の node 探索は絶対バージョン固定ではなく sort -V | tail -1 で最新を取ります。plist側の固定パスを使いたい場合は nvm install のたびに更新が必要になります。スクリプト側の動的探索を使うとバージョン更新後も修正不要です。どちらを信頼の根拠にするかを意識して使い分けます。
⑫ 「動く」と「壊れたとき気づける」は別の設計問題
最初のバージョンは「動く」ところまでしか考えていませんでした。壊れたとき、Vault が何日も更新されていないことに気づかなかったかもしれません。FAILED ファイル・通知・翌日のbriefが古いことで気づく、という複数の検知経路を意図的に設計します。自動化は「壊れたとき確実に気づける」まで設計して初めて信頼できます。
まとめ
vault-auto-ingest.sh の三重構造を振り返ります。
多段スロット再発火は「成功するまで1日かけてリトライする」という保証を与えます。4:55に始まって最悪12:15まで使う設計ですが、ほとんどの日は4:55か8:20に終わります。残りの2スロットはDONE_MARKERを確認して即終了する空振りになります。
caffeinate -i -s によるスリープ防止はプロセスが途中で凍結することを防ぐ補助です。バッテリー駆動での蓋閉じには完全には対抗できませんが、凍結したランはtimeoutが刈り、次スロットが引き継ぎます。caffeinate とマーカー構造の組み合わせで「たとえ一部が死んでも前に進む」構造が成立します。
ステップマーカーによる冪等リトライが最も重要な部分です。step2aとstep2bがそれぞれ独立したマーカーを持ち、完了済みのステップは再実行されません。40分枠に収まらなかった処理を25分×2本に分割し、片方が死んでも次のスロットは残りだけを実行します。これにより「一部timeout」が「一部終わった」に変わります。
この仕組みが毎朝安定して動くようになって何が変わったか。Claude Codeが翌朝に hot.md を読むと、昨日の判断・一度失敗したアーキテクチャの理由・並行している3プロジェクトの現在地を知った状態で作業を始められます。毎回ゼロからの説明が不要になります。並行プロジェクトが3本・4本になっても管理コストが増えません。
月商120万の環境は、会話ログを確実に引き継ぐこの「夜間ドレイン」があってこそ成立しています。自動化の価値は「動かしたとき」ではなく「壊れても自動で回復して気づかないほど当たり前に動き続けるとき」に発揮されます。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!